using System.Collections.Generic;
using System.Threading;
using BulletHellTemplate.VFX;
using Cysharp.Threading.Tasks;
using UnityEngine;
#if FUSION2
using Fusion;
#endif
namespace BulletHellTemplate
{
///
/// Generic damage projectile: movement, ricochet, boomerang,
/// per-target cooldown and optional size-change over time.
///
[RequireComponent(typeof(Rigidbody), typeof(Collider))]
public sealed class DamageEntity : MonoBehaviour
{
/* ───── 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 _valid = new(16);
private readonly Dictionary _cool = new(32);
private static readonly List _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 ---------------------------------------------------------
private void Awake()
{
_rb = GetComponent();
_rb.useGravity = false;
GetComponent().isTrigger = true;
#if FUSION2
_nob = GetComponent();
#endif
}
private void OnEnable()
{
_valid.Clear();
_cool.Clear();
_returning = false;
#if FUSION2
if (_nob && _nob.Runner && _nob.Runner.IsRunning && !_nob.HasStateAuthority)
{
_isReplica = true;
}
#endif
}
private void Update()
{
if (GameplayManager.Singleton.IsPaused()) return;
if (!_inited) return;
if (_isReplica)
{
transform.position += _rb.linearVelocity * Time.deltaTime;
TickLifetimeLocal(Time.deltaTime);
return;
}
SimulateBullet(Time.deltaTime);
}
private void SimulateBullet(float dt)
{
TickCooldowns();
TickBoomerang();
TickLifetimeLocal(dt);
}
#endregion
#region Public API ----------------------------------------------------
///
/// Initialises the projectile just before being spawned.
///
public void Init(IDamageProvider cfg, CharacterEntity attacker, Vector3 velocity, bool isReplica)
{
#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 non‐replica!");
}
///
/// Starts a size-change routine based on supplied config.
///
public void SetSizeChange(DamageEntitySizeChange cfg)
{
_scaleCts?.Cancel();
if (cfg == null || !cfg.enableSizeChange) return;
_scaleCts = new CancellationTokenSource();
ChangeSizeAsync(cfg, _scaleCts.Token).Forget();
}
///
/// Backwards-compat alias kept for older calls.
///
public void StartSizeChange(DamageEntitySizeChange cfg) => SetSizeChange(cfg);
#endregion
#region Tick helpers --------------------------------------------------
private void TickCooldowns()
{
if (_cool.Count == 0) return;
_tmpKeys.Clear();
_tmpKeys.AddRange(_cool.Keys);
foreach (var k in _tmpKeys)
{
float t = _cool[k] - Time.deltaTime;
if (t <= 0) _cool.Remove(k);
else _cool[k] = t;
}
}
private void TickLifetimeLocal(float dt)
{
if ((_life -= dt) > 0) return;
if (_cfg.ExplodeOnDie) SpawnExplosion(_cfg.ExplodePrefab.gameObject, _cfg.ExplodePrefabSettings);
ReturnToPool();
}
private void TickBoomerang()
{
if (!_cfg.IsBoomerang) return;
if (!_returning)
{
if (Vector3.Distance(_spawnPos, transform.position) >= _cfg.MaxDistance)
_returning = true;
}
else
{
Vector3 dir = (_attacker.transform.position - transform.position).normalized;
_rb.linearVelocity = dir * _rb.linearVelocity.magnitude;
transform.forward = dir;
if (Vector3.Distance(transform.position, _attacker.transform.position) <= 0.3f)
ReturnToPool();
}
}
#endregion
#region Collision -----------------------------------------------------
private void OnTriggerEnter(Collider other)
{
if (_isReplica || !_inited) return;
if (_attacker && other.gameObject == _attacker.gameObject) return;
if (other.TryGetComponent(out _))
{
_valid.Add(other);
return;
}
if (GameInstance.Singleton && GameplayManager.Singleton.IsPvp &&
other.TryGetComponent(out var otherPlayer))
{
if (_attacker != null && otherPlayer != _attacker &&
otherPlayer.IsDead == false &&
otherPlayerTeamDifferent(otherPlayer, _attacker))
{
_valid.Add(other);
return;
}
}
if (other.CompareTag("Wall") && _cfg.IsRicochet)
HandleRicochet();
}
private void OnTriggerStay(Collider other)
{
if (_isReplica || !_inited) return;
if (!_valid.Contains(other) || _cool.ContainsKey(other)) return;
//Monster
if (other.TryGetComponent(out var monster))
ApplyDamageMonster(monster);
// PLAYER (PVP)
else if (GameInstance.Singleton && GameplayManager.Singleton.IsPvp &&
other.TryGetComponent(out var player))
ApplyDamagePlayerPvp(player);
_cool[other] = 0.3f;
if (_cfg.DestroyOnHit)
{
if (_cfg.ExplodeOnDie) SpawnExplosion(_cfg.ExplodePrefab.gameObject, _cfg.ExplodePrefabSettings);
ReturnToPool();
}
}
private static bool otherPlayerTeamDifferent(CharacterEntity a, CharacterEntity b)
{
#if FUSION2
return a.TeamId != b.TeamId;
#else
return a.Team != b.Team;
#endif
}
private void HandleRicochet()
{
var v = _rb.linearVelocity;
if (Physics.RaycastNonAlloc(transform.position, v.normalized, _ray, 1f) == 0) return;
Vector3 refl = Vector3.Reflect(v, _ray[0].normal);
_rb.linearVelocity = refl;
transform.forward = refl;
}
#endregion
#region Damage --------------------------------------------------------
///
/// Applies damage (with crit, extra effects) and spawns a VFX hit-spark.
///
private void ApplyDamageMonster(MonsterEntity target)
{
if (!target) return;
if (_cfg is SkillDamageProvider sdp)
{
var anchor = target.effectTransform ? target.effectTransform : target.transform;
if (sdp.HitEffect)
{
var vfx = GameEffectsManager.SpawnEffect(sdp.HitEffect, anchor.position, Quaternion.identity);
var auto = vfx.GetComponent() ?? vfx.AddComponent();
}
if (sdp.HitAudio)
AudioManager.Singleton.PlayAudio(sdp.HitAudio, "vfx", anchor.position);
}
_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);
if (_cfg is SkillDamageProvider sdpe)
dmg = GameInstance.Singleton.TotalDamageWithElements(sdpe.ElementalType, target.GetCharacterTypeData, dmg);
if (crit && _attacker) dmg *= _attacker.GetCurrentCriticalDamageMultiplier();
var mh = target.GetComponent();
if (mh)
{
#if FUSION2
var attackerNob = _attacker ? _attacker.GetComponent() : null;
if (attackerNob)
mh.ApplyDamageFromHit(attackerNob, NextHitId(), dmg, crit);
else
mh.ApplyDamageRequest(dmg, crit);
#else
mh.ReceiveDamage(dmg, crit);
#endif
}
}
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() : 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);
}
#endregion
#region Size-change (UniTask) -----------------------------------------
private async UniTaskVoid ChangeSizeAsync(
DamageEntitySizeChange cfg,
CancellationToken token)
{
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);
Vector3 t = Vector3.zero;
transform.localScale = start;
while (t.x < dur.x || t.y < dur.y || t.z < dur.z)
{
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);
}
}
#endregion
#region Pool helpers --------------------------------------------------
private void SpawnExplosion(GameObject explosionPrefab, SkillLevel cfg)
{
if (explosionPrefab == null) return;
var exp = GameEffectsManager.SpawnEffect(explosionPrefab, transform.position, Quaternion.identity);
if (exp.TryGetComponent(out var de))
{
var baseProv = _cfg;
var safeProv = new NoChainProvider(baseProv, cfg);
de.Init(safeProv, _attacker, Vector3.zero, _isReplica);
}
var auto = exp.GetComponent() ?? exp.AddComponent();
auto.Delay = cfg.lifeTime;
}
private void ReturnToPool()
{
_scaleCts?.Cancel();
GameEffectsManager.ReleaseEffect(gameObject);
}
private readonly struct NoChainProvider : IDamageProvider
{
private readonly IDamageProvider _base;
private readonly float _life;
public NoChainProvider(IDamageProvider @base, SkillLevel lvl)
{
_base = @base;
_life = lvl.lifeTime;
}
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);
}
#endregion
}
}