435 lines
15 KiB
C#
Raw Normal View History

2025-09-19 19:43:49 +05:00
using System.Collections.Generic;
using System.Threading;
using BulletHellTemplate.VFX;
using Cysharp.Threading.Tasks;
2025-09-19 14:56:58 +05:00
using UnityEngine;
2025-09-19 19:43:49 +05:00
#if FUSION2
using Fusion;
#endif
2025-09-19 14:56:58 +05:00
namespace BulletHellTemplate
{
/// <summary>
2025-09-19 19:43:49 +05:00
/// Generic damage projectile: movement, ricochet, boomerang,
/// per-target cooldown and optional size-change over time.
2025-09-19 14:56:58 +05:00
/// </summary>
2025-09-19 19:43:49 +05:00
[RequireComponent(typeof(Rigidbody), typeof(Collider))]
public sealed class DamageEntity : MonoBehaviour
2025-09-19 14:56:58 +05:00
{
2025-09-19 19:43:49 +05:00
/* ───── Runtime ───── */
private Rigidbody _rb;
private CharacterEntity _attacker;
private IDamageProvider _cfg;
private float _life;
private Vector3 _spawnPos;
private bool _returning;
private bool _isReplica;
private bool _inited = false;
/* ───── Target cooldown ───── */
private readonly HashSet<Collider> _valid = new(16);
private readonly Dictionary<Collider, float> _cool = new(32);
private static readonly List<Collider> _tmpKeys = new(32);
/* ───── Size-change ───── */
private CancellationTokenSource _scaleCts;
/* ───── Non-alloc helpers ───── */
private static readonly RaycastHit[] _ray = new RaycastHit[1];
#if FUSION2
private NetworkObject _nob;
#endif
static ulong _seq;
public ulong HitId { get; private set; }
private static long _hitSeq = 0;
private static ulong NextHitId() =>
(ulong)System.Threading.Interlocked.Increment(ref _hitSeq);
#region Unity ---------------------------------------------------------
2025-09-19 14:56:58 +05:00
private void Awake()
{
2025-09-19 19:43:49 +05:00
_rb = GetComponent<Rigidbody>();
_rb.useGravity = false;
GetComponent<Collider>().isTrigger = true;
#if FUSION2
_nob = GetComponent<NetworkObject>();
#endif
}
private void OnEnable()
{
_valid.Clear();
_cool.Clear();
_returning = false;
#if FUSION2
if (_nob && _nob.Runner && _nob.Runner.IsRunning && !_nob.HasStateAuthority)
2025-09-19 14:56:58 +05:00
{
2025-09-19 19:43:49 +05:00
_isReplica = true;
2025-09-19 14:56:58 +05:00
}
2025-09-19 19:43:49 +05:00
#endif
}
private void Update()
{
if (GameplayManager.Singleton.IsPaused()) return;
if (!_inited) return;
if (_isReplica)
2025-09-19 14:56:58 +05:00
{
2025-09-19 19:43:49 +05:00
transform.position += _rb.linearVelocity * Time.deltaTime;
TickLifetimeLocal(Time.deltaTime);
return;
2025-09-19 14:56:58 +05:00
}
2025-09-19 19:43:49 +05:00
SimulateBullet(Time.deltaTime);
2025-09-19 14:56:58 +05:00
}
2025-09-19 19:43:49 +05:00
private void SimulateBullet(float dt)
2025-09-19 14:56:58 +05:00
{
2025-09-19 19:43:49 +05:00
TickCooldowns();
TickBoomerang();
TickLifetimeLocal(dt);
2025-09-19 14:56:58 +05:00
}
2025-09-19 19:43:49 +05:00
#endregion
#region Public API ----------------------------------------------------
2025-09-19 14:56:58 +05:00
/// <summary>
2025-09-19 19:43:49 +05:00
/// Initialises the projectile just before being spawned.
2025-09-19 14:56:58 +05:00
/// </summary>
2025-09-19 19:43:49 +05:00
public void Init(IDamageProvider cfg, CharacterEntity attacker, Vector3 velocity, bool isReplica)
2025-09-19 14:56:58 +05:00
{
2025-09-19 19:43:49 +05:00
#if FUSION2
ulong a = attacker ? (ulong)attacker.Id.Object.Raw : 0UL;
#else
ulong a = 0UL;
#endif
HitId = (a << 32) | ++_seq;
_cfg = cfg;
_attacker = attacker;
_rb.linearVelocity = cfg.IsOrbital ? Vector3.zero : velocity;
_life = cfg.LifeTime;
_spawnPos = transform.position;
_isReplica = isReplica;
_inited = true;
if (_cfg == null) Debug.LogError("DamageEntity.Init: cfg is null!");
if (_attacker == null && !isReplica)
Debug.LogError("DamageEntity.Init: attacker is null on nonreplica!");
2025-09-19 14:56:58 +05:00
}
2025-09-19 19:43:49 +05:00
/// <summary>
/// Starts a size-change routine based on supplied config.
/// </summary>
public void SetSizeChange(DamageEntitySizeChange cfg)
2025-09-19 14:56:58 +05:00
{
2025-09-19 19:43:49 +05:00
_scaleCts?.Cancel();
if (cfg == null || !cfg.enableSizeChange) return;
2025-09-19 14:56:58 +05:00
2025-09-19 19:43:49 +05:00
_scaleCts = new CancellationTokenSource();
ChangeSizeAsync(cfg, _scaleCts.Token).Forget();
2025-09-19 14:56:58 +05:00
}
/// <summary>
2025-09-19 19:43:49 +05:00
/// Backwards-compat alias kept for older calls.
2025-09-19 14:56:58 +05:00
/// </summary>
2025-09-19 19:43:49 +05:00
public void StartSizeChange(DamageEntitySizeChange cfg) => SetSizeChange(cfg);
#endregion
#region Tick helpers --------------------------------------------------
private void TickCooldowns()
2025-09-19 14:56:58 +05:00
{
2025-09-19 19:43:49 +05:00
if (_cool.Count == 0) return;
_tmpKeys.Clear();
_tmpKeys.AddRange(_cool.Keys);
2025-09-19 14:56:58 +05:00
2025-09-19 19:43:49 +05:00
foreach (var k in _tmpKeys)
2025-09-19 14:56:58 +05:00
{
2025-09-19 19:43:49 +05:00
float t = _cool[k] - Time.deltaTime;
if (t <= 0) _cool.Remove(k);
else _cool[k] = t;
2025-09-19 14:56:58 +05:00
}
}
2025-09-19 19:43:49 +05:00
private void TickLifetimeLocal(float dt)
2025-09-19 14:56:58 +05:00
{
2025-09-19 19:43:49 +05:00
if ((_life -= dt) > 0) return;
if (_cfg.ExplodeOnDie) SpawnExplosion(_cfg.ExplodePrefab.gameObject, _cfg.ExplodePrefabSettings);
ReturnToPool();
}
2025-09-19 14:56:58 +05:00
2025-09-19 19:43:49 +05:00
private void TickBoomerang()
{
if (!_cfg.IsBoomerang) return;
if (!_returning)
2025-09-19 14:56:58 +05:00
{
2025-09-19 19:43:49 +05:00
if (Vector3.Distance(_spawnPos, transform.position) >= _cfg.MaxDistance)
_returning = true;
2025-09-19 14:56:58 +05:00
}
2025-09-19 19:43:49 +05:00
else
2025-09-19 14:56:58 +05:00
{
2025-09-19 19:43:49 +05:00
Vector3 dir = (_attacker.transform.position - transform.position).normalized;
_rb.linearVelocity = dir * _rb.linearVelocity.magnitude;
transform.forward = dir;
2025-09-19 14:56:58 +05:00
2025-09-19 19:43:49 +05:00
if (Vector3.Distance(transform.position, _attacker.transform.position) <= 0.3f)
ReturnToPool();
2025-09-19 14:56:58 +05:00
}
}
2025-09-19 19:43:49 +05:00
#endregion
#region Collision -----------------------------------------------------
2025-09-19 14:56:58 +05:00
private void OnTriggerEnter(Collider other)
{
2025-09-19 19:43:49 +05:00
if (_isReplica || !_inited) return;
if (_attacker && other.gameObject == _attacker.gameObject) return;
2025-09-19 14:56:58 +05:00
2025-09-19 19:43:49 +05:00
if (other.TryGetComponent<MonsterEntity>(out _))
2025-09-19 14:56:58 +05:00
{
2025-09-19 19:43:49 +05:00
_valid.Add(other);
2025-09-19 14:56:58 +05:00
return;
}
2025-09-19 19:43:49 +05:00
if (GameInstance.Singleton && GameplayManager.Singleton.IsPvp &&
other.TryGetComponent<CharacterEntity>(out var otherPlayer))
2025-09-19 14:56:58 +05:00
{
2025-09-19 19:43:49 +05:00
if (_attacker != null && otherPlayer != _attacker &&
otherPlayer.IsDead == false &&
otherPlayerTeamDifferent(otherPlayer, _attacker))
2025-09-19 14:56:58 +05:00
{
2025-09-19 19:43:49 +05:00
_valid.Add(other);
return;
2025-09-19 14:56:58 +05:00
}
}
2025-09-19 19:43:49 +05:00
if (other.CompareTag("Wall") && _cfg.IsRicochet)
HandleRicochet();
2025-09-19 14:56:58 +05:00
}
2025-09-19 19:43:49 +05:00
private void OnTriggerStay(Collider other)
2025-09-19 14:56:58 +05:00
{
2025-09-19 19:43:49 +05:00
if (_isReplica || !_inited) return;
if (!_valid.Contains(other) || _cool.ContainsKey(other)) return;
2025-09-19 14:56:58 +05:00
2025-09-19 19:43:49 +05:00
//Monster
if (other.TryGetComponent<MonsterEntity>(out var monster))
ApplyDamageMonster(monster);
// PLAYER (PVP)
else if (GameInstance.Singleton && GameplayManager.Singleton.IsPvp &&
other.TryGetComponent<CharacterEntity>(out var player))
ApplyDamagePlayerPvp(player);
2025-09-19 14:56:58 +05:00
2025-09-19 19:43:49 +05:00
_cool[other] = 0.3f;
if (_cfg.DestroyOnHit)
{
if (_cfg.ExplodeOnDie) SpawnExplosion(_cfg.ExplodePrefab.gameObject, _cfg.ExplodePrefabSettings);
ReturnToPool();
2025-09-19 14:56:58 +05:00
}
}
2025-09-19 19:43:49 +05:00
private static bool otherPlayerTeamDifferent(CharacterEntity a, CharacterEntity b)
2025-09-19 14:56:58 +05:00
{
2025-09-19 19:43:49 +05:00
#if FUSION2
return a.TeamId != b.TeamId;
#else
return a.Team != b.Team;
#endif
}
2025-09-19 14:56:58 +05:00
2025-09-19 19:43:49 +05:00
private void HandleRicochet()
{
var v = _rb.linearVelocity;
if (Physics.RaycastNonAlloc(transform.position, v.normalized, _ray, 1f) == 0) return;
2025-09-19 14:56:58 +05:00
2025-09-19 19:43:49 +05:00
Vector3 refl = Vector3.Reflect(v, _ray[0].normal);
_rb.linearVelocity = refl;
transform.forward = refl;
2025-09-19 14:56:58 +05:00
}
2025-09-19 19:43:49 +05:00
#endregion
#region Damage --------------------------------------------------------
2025-09-19 14:56:58 +05:00
/// <summary>
2025-09-19 19:43:49 +05:00
/// Applies damage (with crit, extra effects) and spawns a VFX hit-spark.
2025-09-19 14:56:58 +05:00
/// </summary>
2025-09-19 19:43:49 +05:00
private void ApplyDamageMonster(MonsterEntity target)
2025-09-19 14:56:58 +05:00
{
2025-09-19 19:43:49 +05:00
if (!target) return;
2025-09-19 14:56:58 +05:00
2025-09-19 19:43:49 +05:00
if (_cfg is SkillDamageProvider sdp)
2025-09-19 14:56:58 +05:00
{
2025-09-19 19:43:49 +05:00
var anchor = target.effectTransform ? target.effectTransform : target.transform;
if (sdp.HitEffect)
2025-09-19 14:56:58 +05:00
{
2025-09-19 19:43:49 +05:00
var vfx = GameEffectsManager.SpawnEffect(sdp.HitEffect, anchor.position, Quaternion.identity);
var auto = vfx.GetComponent<ReturnEffectToPool>() ?? vfx.AddComponent<ReturnEffectToPool>();
2025-09-19 14:56:58 +05:00
}
2025-09-19 19:43:49 +05:00
if (sdp.HitAudio)
AudioManager.Singleton.PlayAudio(sdp.HitAudio, "vfx", anchor.position);
2025-09-19 14:56:58 +05:00
}
2025-09-19 19:43:49 +05:00
_cfg.ApplyExtraEffects(_attacker ? _attacker.transform.position : transform.position, target);
bool crit = _cfg.CanCrit && _attacker && Random.value < _attacker.GetCurrentCriticalRate();
float dmg = _cfg.BaseDamage + (_attacker ? _attacker.GetCurrentDamage() * _cfg.AttackerDamageRate : 0f);
2025-09-19 14:56:58 +05:00
2025-09-19 19:43:49 +05:00
if (_cfg is SkillDamageProvider sdpe)
dmg = GameInstance.Singleton.TotalDamageWithElements(sdpe.ElementalType, target.GetCharacterTypeData, dmg);
2025-09-19 14:56:58 +05:00
2025-09-19 19:43:49 +05:00
if (crit && _attacker) dmg *= _attacker.GetCurrentCriticalDamageMultiplier();
2025-09-19 14:56:58 +05:00
2025-09-19 19:43:49 +05:00
var mh = target.GetComponent<MonsterHealth>();
if (mh)
2025-09-19 14:56:58 +05:00
{
2025-09-19 19:43:49 +05:00
#if FUSION2
var attackerNob = _attacker ? _attacker.GetComponent<NetworkObject>() : null;
if (attackerNob)
mh.ApplyDamageFromHit(attackerNob, NextHitId(), dmg, crit);
else
mh.ApplyDamageRequest(dmg, crit);
#else
mh.ReceiveDamage(dmg, crit);
#endif
2025-09-19 14:56:58 +05:00
}
2025-09-19 19:43:49 +05:00
}
2025-09-19 14:56:58 +05:00
2025-09-19 19:43:49 +05:00
private void ApplyDamagePlayerPvp(CharacterEntity target)
{
if (!target || target == _attacker) return;
if (!GameInstance.Singleton || !GameplayManager.Singleton.IsPvp) return;
if (!otherPlayerTeamDifferent(target, _attacker)) return;
bool crit = _cfg.CanCrit && _attacker && Random.value < _attacker.GetCurrentCriticalRate();
float dmg = _cfg.BaseDamage + (_attacker ? _attacker.GetCurrentDamage() * _cfg.AttackerDamageRate : 0f);
if (crit && _attacker) dmg *= _attacker.GetCurrentCriticalDamageMultiplier();
#if FUSION2
var attackerNob = _attacker ? _attacker.GetComponent<NetworkObject>() : null;
if (attackerNob)
target.ApplyDamageToSelfFromHit(attackerNob, NextHitId(), dmg, crit);
else
target.ReceiveDamage(dmg); // offline fallback
#else
target.ReceiveDamage(dmg);
#endif
// if (target.IsDead) GameInstance.Singleton.AddTeamScore(_attacker.TeamId, 1);
}
2025-09-19 14:56:58 +05:00
2025-09-19 19:43:49 +05:00
#endregion
2025-09-19 14:56:58 +05:00
2025-09-19 19:43:49 +05:00
#region Size-change (UniTask) -----------------------------------------
2025-09-19 14:56:58 +05:00
2025-09-19 19:43:49 +05:00
private async UniTaskVoid ChangeSizeAsync(
DamageEntitySizeChange cfg,
CancellationToken token)
2025-09-19 14:56:58 +05:00
{
2025-09-19 19:43:49 +05:00
Vector3 start = new(cfg.initialSizeX, cfg.initialSizeY, cfg.initialSizeZ);
Vector3 end = new(cfg.finalSizeX, cfg.finalSizeY, cfg.finalSizeZ);
Vector3 dur = new(cfg.sizeChangeTimeX, cfg.sizeChangeTimeY, cfg.sizeChangeTimeZ);
2025-09-19 14:56:58 +05:00
2025-09-19 19:43:49 +05:00
Vector3 t = Vector3.zero;
transform.localScale = start;
2025-09-19 14:56:58 +05:00
2025-09-19 19:43:49 +05:00
while (t.x < dur.x || t.y < dur.y || t.z < dur.z)
2025-09-19 14:56:58 +05:00
{
2025-09-19 19:43:49 +05:00
if (GameplayManager.Singleton.IsPaused())
{
await UniTask.Yield(PlayerLoopTiming.Update, token);
continue;
}
float dt = Time.deltaTime;
if (t.x < dur.x) t.x = Mathf.Min(t.x + dt, dur.x);
if (t.y < dur.y) t.y = Mathf.Min(t.y + dt, dur.y);
if (t.z < dur.z) t.z = Mathf.Min(t.z + dt, dur.z);
float lx = dur.x > 0 ? t.x / dur.x : 1;
float ly = dur.y > 0 ? t.y / dur.y : 1;
float lz = dur.z > 0 ? t.z / dur.z : 1;
transform.localScale = new Vector3(
Mathf.Lerp(start.x, end.x, lx),
Mathf.Lerp(start.y, end.y, ly),
Mathf.Lerp(start.z, end.z, lz));
await UniTask.Yield(PlayerLoopTiming.Update, token);
2025-09-19 14:56:58 +05:00
}
}
2025-09-19 19:43:49 +05:00
#endregion
2025-09-19 14:56:58 +05:00
2025-09-19 19:43:49 +05:00
#region Pool helpers --------------------------------------------------
2025-09-19 14:56:58 +05:00
2025-09-19 19:43:49 +05:00
private void SpawnExplosion(GameObject explosionPrefab, SkillLevel cfg)
{
if (explosionPrefab == null) return;
2025-09-19 14:56:58 +05:00
2025-09-19 19:43:49 +05:00
var exp = GameEffectsManager.SpawnEffect(explosionPrefab, transform.position, Quaternion.identity);
2025-09-19 14:56:58 +05:00
2025-09-19 19:43:49 +05:00
if (exp.TryGetComponent<DamageEntity>(out var de))
2025-09-19 14:56:58 +05:00
{
2025-09-19 19:43:49 +05:00
var baseProv = _cfg;
var safeProv = new NoChainProvider(baseProv, cfg);
de.Init(safeProv, _attacker, Vector3.zero, _isReplica);
}
2025-09-19 14:56:58 +05:00
2025-09-19 19:43:49 +05:00
var auto = exp.GetComponent<ReturnEffectToPool>() ?? exp.AddComponent<ReturnEffectToPool>();
auto.Delay = cfg.lifeTime;
}
2025-09-19 14:56:58 +05:00
2025-09-19 19:43:49 +05:00
private void ReturnToPool()
{
_scaleCts?.Cancel();
GameEffectsManager.ReleaseEffect(gameObject);
}
2025-09-19 14:56:58 +05:00
2025-09-19 19:43:49 +05:00
private readonly struct NoChainProvider : IDamageProvider
{
private readonly IDamageProvider _base;
private readonly float _life;
2025-09-19 14:56:58 +05:00
2025-09-19 19:43:49 +05:00
public NoChainProvider(IDamageProvider @base, SkillLevel lvl)
{
_base = @base;
_life = lvl.lifeTime;
2025-09-19 14:56:58 +05:00
}
2025-09-19 19:43:49 +05:00
public float BaseDamage => _base.BaseDamage;
public float AttackerDamageRate => _base.AttackerDamageRate;
public float LifeTime => _life;
public bool CanCrit => _base.CanCrit;
public CharacterTypeData ElementalType => _base.ElementalType;
public bool IsOrbital => _base.IsOrbital;
public bool IsBoomerang => _base.IsBoomerang;
public float MaxDistance => _base.MaxDistance;
public bool IsRicochet => _base.IsRicochet;
public bool DestroyOnHit => _base.DestroyOnHit;
public bool ExplodeOnDie => false;
public DamageEntity ExplodePrefab => null;
public SkillLevel ExplodePrefabSettings => null;
public GameObject HitEffect => _base.HitEffect;
public AudioClip HitAudio => _base.HitAudio;
public void ApplyExtraEffects(Vector3 origin, MonsterEntity m)
=> _base.ApplyExtraEffects(origin, m);
2025-09-19 14:56:58 +05:00
}
2025-09-19 19:43:49 +05:00
#endregion
2025-09-19 14:56:58 +05:00
}
}