using System.Collections.Generic; using ProjectM.Simulation; using Unity.Entities; using Unity.Mathematics; using Unity.NetCode; using Unity.Transforms; using UnityEngine; namespace ProjectM.Client { /// /// Client-only combat JUICE. A managed presentation system (SystemBase, main thread, NO Burst) in the /// that REACTS to replicated state — it never runs simulation. Each frame /// it edge-detects every damageable ghost's replicated : a decrease spawns a floating /// damage number + a hit-spark burst + a hit SFX + camera shake; a Husk despawn (server-authoritative death) /// spawns a death burst + death SFX; the local player crossing to 0 HP does the same. A local-player ability /// fire (AbilityCooldown advancing) spawns a muzzle flash + zap. Everything derives from already-replicated /// state, so it is correct without touching the prediction loop, and it lives only in the client world so the /// server never instantiates GameObjects. /// /// VFX prefer authored GabrielAguiar Shuriken prefabs supplied by (muzzle / hit / /// death + a projectile-following trail); each hook falls back to a procedural particle burst when no prefab /// is assigned, so the slice still runs asset-free. Spawned VFX are stripped to particles only /// () — GA "projectile" prefabs ship a Rigidbody + collider + mover that would /// otherwise self-propel and spawn secondary effects. SFX remain procedural. /// /// /// Per-entity last Health + position + isEnemy are cached in a managed dictionary (Entity is a stable client /// key for a ghost's lifetime); stale keys are pruned each frame (a pruned Husk = a kill → death VFX at its /// last position). Never destroys a ghost from the client — GhostDespawnSystem owns that off the snapshot /// protocol; we only OBSERVE. /// /// [WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)] [UpdateInGroup(typeof(PresentationSystemGroup))] public partial class CombatFeedbackSystem : SystemBase { struct FxCache { public float Hp; public float3 Pos; public bool IsEnemy; public uint Windup; } readonly Dictionary _cache = new(); readonly HashSet _seen = new(); readonly List _stale = new(); readonly List _numbers = new(); // Authored-VFX lifetime tracking (GabrielAguiar prefabs spawned via VFXConfig). readonly List _activeVfx = new(); readonly Dictionary _projTrails = new(); readonly HashSet _projSeen = new(); readonly List _projStale = new(); Transform _fxRoot; ParticleSystem _hitFx; ParticleSystem _deathFx; ParticleSystem _muzzleFx; AudioClip _hitClip; AudioClip _deathClip; AudioClip _fireClip; AudioClip _telegraphClip; Entity _localPlayer = Entity.Null; uint _lastLocalFireTick; bool _fireTickInit; const int NumberPoolSize = 32; const int MaxActiveVfx = 40; // bound one-shot VFX GameObject churn under sustained combat struct TimedVfx { public GameObject Go; public double Kill; } protected override void OnCreate() { _hitClip = MakeClip("husk_hit", 640f, 180f, 0.10f, 0.5f, noise: true); _deathClip = MakeClip("husk_death", 320f, 50f, 0.34f, 0.55f, noise: false); _fireClip = MakeClip("fire", 880f, 1500f, 0.07f, 0.30f, noise: false); _telegraphClip = MakeClip("telegraph", 680f, 1020f, 0.12f, 0.35f, noise: false); } protected override void OnStartRunning() { if (_fxRoot != null) return; _fxRoot = new GameObject("~CombatFeedbackFX").transform; var mat = MakeParticleMaterial(); _hitFx = MakeBurst("HitSparks", mat, new Color(3f, 2.2f, 0.6f), 0.13f, 7f, 0.32f, 256); _deathFx = MakeBurst("DeathBurst", mat, new Color(3.2f, 0.7f, 0.25f), 0.22f, 9f, 0.55f, 512); _muzzleFx = MakeBurst("Muzzle", mat, new Color(0.6f, 2.4f, 3.2f), 0.12f, 5f, 0.20f, 128); for (int i = 0; i < NumberPoolSize; i++) _numbers.Add(CreateNumber()); } protected override void OnDestroy() { if (_fxRoot != null) Object.Destroy(_fxRoot.gameObject); } protected override void OnUpdate() { float dt = SystemAPI.Time.DeltaTime; var cam = Camera.main; var cfg = VFXConfig.Instance; // Make sure predicted/physics jobs writing these are done before this main-thread read. EntityManager.CompleteDependencyBeforeRO(); EntityManager.CompleteDependencyBeforeRO(); EntityManager.CompleteDependencyBeforeRO(); // Resolve the local player (for hit colouring + fire feedback). _localPlayer = Entity.Null; float3 localPos = default; foreach (var (xf, entity) in SystemAPI.Query>() .WithAll().WithEntityAccess()) { _localPlayer = entity; localPos = xf.ValueRO.Position; } // Edge-detect Health on every damageable ghost (players + Husks). _seen.Clear(); foreach (var (health, xf, entity) in SystemAPI.Query, RefRO>().WithEntityAccess()) { _seen.Add(entity); float cur = health.ValueRO.Current; float3 p = xf.ValueRO.Position; bool isEnemy = SystemAPI.HasComponent(entity); uint windup = isEnemy && SystemAPI.HasComponent(entity) ? SystemAPI.GetComponent(entity).WindUpUntilTick : 0u; bool isLocalPlayer = entity == _localPlayer; if (_cache.TryGetValue(entity, out var prev)) { if (isEnemy && windup != 0 && prev.Windup == 0) { // Attack telegraph: the wind-up just began -> warn the player ~0.3s before the strike lands. Burst(_hitFx, null, (Vector3)p + Vector3.up * 1.2f, 6); PlayClip(_telegraphClip, (Vector3)p, 0.5f); } if (cur < prev.Hp - 0.001f) { SpawnNumber(prev.Hp - cur, (Vector3)p, isLocalPlayer, cam); Burst(_hitFx, cfg != null ? cfg.Hit : null, (Vector3)p + Vector3.up * 0.8f, FeelConfig.HitBurstCount); PlayClip(_hitClip, (Vector3)p, FeelConfig.HitSfxVolume); PrototypeCameraRig.AddShake(isLocalPlayer ? FeelConfig.HitShakeLocal : FeelConfig.HitShakeRemote); if (isLocalPlayer) PrototypeCameraRig.PunchFov(FeelConfig.HitStopFovKick, FeelConfig.HitStopDurationMs); } // Respawn recovery: the LOCAL player's Health rising from <=0 back to positive. No healing // mechanic exists, so a 0 -> positive edge is unambiguously a respawn (observer-only). if (isLocalPlayer && FeelConfig.RespawnShimmerEnabled && cur > prev.Hp + 0.001f && prev.Hp <= 0f) { Burst(_muzzleFx, null, (Vector3)p + Vector3.up * 0.6f, FeelConfig.RespawnShimmerBurst); PrototypeCameraRig.AddShake(FeelConfig.RespawnShimmerShake); } // Player death (players don't despawn — they respawn; Husk death is handled on prune). if (!isEnemy && cur <= 0f && prev.Hp > 0f) { Burst(_deathFx, PlayerDeathPrefab(cfg), (Vector3)p + Vector3.up * 0.5f, FeelConfig.DeathBurstCount); PlayClip(_deathClip, (Vector3)p, 0.7f); PrototypeCameraRig.AddShake(isLocalPlayer ? FeelConfig.PlayerDeathShake : FeelConfig.RemotePlayerDeathShake); } } _cache[entity] = new FxCache { Hp = cur, Pos = p, IsEnemy = isEnemy, Windup = windup }; } // Prune despawned ghosts. A Husk that vanished was killed -> death VFX at its last position. if (_cache.Count != _seen.Count) { _stale.Clear(); foreach (var kv in _cache) if (!_seen.Contains(kv.Key)) _stale.Add(kv.Key); for (int i = 0; i < _stale.Count; i++) { var c = _cache[_stale[i]]; if (c.IsEnemy) { Burst(_deathFx, cfg != null ? cfg.EnemyDeath : null, (Vector3)c.Pos + Vector3.up * 0.5f, Mathf.Max(1, Mathf.RoundToInt(FeelConfig.DeathBurstCount * FeelConfig.KillBurstScale))); PlayClip(_deathClip, (Vector3)c.Pos, FeelConfig.KillSfxVolume); PrototypeCameraRig.AddShake(FeelConfig.KillShake); PrototypeCameraRig.PunchFov(FeelConfig.KillFovKick, FeelConfig.HitStopDurationMs); } _cache.Remove(_stale[i]); } } // Local-player fire feedback: AbilityCooldown.NextFireTick advances on each shot. // Raw uint inequality is intentional here: only the edge (a new shot) matters and the worst case // of a tick wrap is a single dropped/duplicated muzzle flash — purely cosmetic, never the sim. if (_localPlayer != Entity.Null && EntityManager.HasComponent(_localPlayer)) { uint nextFire = EntityManager.GetComponentData(_localPlayer).NextFireTick; if (_fireTickInit && nextFire != 0 && nextFire != _lastLocalFireTick) { Burst(_muzzleFx, cfg != null ? cfg.Muzzle : null, (Vector3)localPos + Vector3.up * 0.9f, 8); PlayClip(_fireClip, (Vector3)localPos, 0.5f); } _lastLocalFireTick = nextFire; _fireTickInit = true; } UpdateProjectileTrails(cfg); PruneVfx(); AnimateNumbers(dt, cam); } // ---- Authored VFX (GabrielAguiar prefabs via VFXConfig); fall back to the procedural burst ---- static GameObject PlayerDeathPrefab(VFXConfig cfg) { if (cfg == null) return null; return cfg.PlayerDeath != null ? cfg.PlayerDeath : cfg.EnemyDeath; } void Burst(ParticleSystem fallback, GameObject prefab, Vector3 pos, int count) { if (prefab != null) SpawnVfx(prefab, pos, Quaternion.identity); else EmitAt(fallback, pos, count); } void SpawnVfx(GameObject prefab, Vector3 pos, Quaternion rot) { if (prefab == null || _fxRoot == null) return; if (_activeVfx.Count >= MaxActiveVfx) return; // saturated: drop (cheap) rather than thrash GC var go = Object.Instantiate(prefab, pos, rot, _fxRoot); go.transform.position = pos; StripCosmetic(go); var systems = go.GetComponentsInChildren(); for (int i = 0; i < systems.Length; i++) systems[i].Play(); _activeVfx.Add(new TimedVfx { Go = go, Kill = SystemAPI.Time.ElapsedTime + VfxLifetime(go) }); } void PruneVfx() { double now = SystemAPI.Time.ElapsedTime; for (int i = _activeVfx.Count - 1; i >= 0; i--) { if (now < _activeVfx[i].Kill) continue; if (_activeVfx[i].Go != null) Object.Destroy(_activeVfx[i].Go); _activeVfx.RemoveAt(i); } } // A looping trail prefab follows each in-flight projectile ghost; destroyed when it despawns. void UpdateProjectileTrails(VFXConfig cfg) { if (cfg == null || cfg.ProjectileTrail == null || _fxRoot == null) { // Config cleared mid-run: drop any orphaned trails so they don't linger. if (_projTrails.Count > 0) { foreach (var kv in _projTrails) if (kv.Value != null) Object.Destroy(kv.Value); _projTrails.Clear(); } return; } _projSeen.Clear(); foreach (var (xf, entity) in SystemAPI.Query>().WithAll().WithEntityAccess()) { _projSeen.Add(entity); Vector3 wp = (Vector3)xf.ValueRO.Position; if (_projTrails.TryGetValue(entity, out var trail)) { if (trail != null) trail.transform.position = wp; } else { var go = Object.Instantiate(cfg.ProjectileTrail, wp, Quaternion.identity, _fxRoot); StripCosmetic(go); // GA "projectile" prefabs ship a Rigidbody + mover; keep particles only var systems = go.GetComponentsInChildren(); for (int i = 0; i < systems.Length; i++) systems[i].Play(); _projTrails[entity] = go; } } if (_projTrails.Count == _projSeen.Count) return; _projStale.Clear(); foreach (var kv in _projTrails) if (!_projSeen.Contains(kv.Key)) _projStale.Add(kv.Key); for (int i = 0; i < _projStale.Count; i++) { if (_projTrails[_projStale[i]] != null) Object.Destroy(_projTrails[_projStale[i]]); _projTrails.Remove(_projStale[i]); } } // Cosmetic VFX must be particles only. GA demo "projectile" prefabs ship a non-kinematic Rigidbody, // a solid collider, and a mover (ProjectileMoveScript) that self-propels and spawns secondary muzzle/hit // effects on contact — strip all of that so our per-frame reposition is authoritative and nothing leaks. static void StripCosmetic(GameObject go) { foreach (var rb in go.GetComponentsInChildren(true)) Object.Destroy(rb); foreach (var col in go.GetComponentsInChildren(true)) Object.Destroy(col); foreach (var mb in go.GetComponentsInChildren(true)) { if (mb == null) continue; string n = mb.GetType().Name; // Disable (not destroy) BEFORE Start runs so the mover's Start-spawned muzzle never fires. if (n.IndexOf("Projectile", System.StringComparison.OrdinalIgnoreCase) >= 0 || n.IndexOf("Move", System.StringComparison.OrdinalIgnoreCase) >= 0) mb.enabled = false; } } // Real effect duration from the longest child ParticleSystem (clamped), so we don't force-kill early // or hold a finished GameObject around on a blanket TTL. static double VfxLifetime(GameObject go) { float longest = 0f; foreach (var ps in go.GetComponentsInChildren(true)) { var main = ps.main; float d = main.duration + main.startLifetime.constantMax; if (d > longest) longest = d; } return Mathf.Clamp(longest, 1f, 6f); } // ---- Floating damage numbers (pooled, billboarded TextMesh) ---- class FloatingNumber { public TextMesh Tm; public Transform Tr; public float Age; public float Life; public Vector3 Vel; public Color BaseColor; public bool Active; } FloatingNumber CreateNumber() { var go = new GameObject("DamageNumber"); go.transform.SetParent(_fxRoot, false); var tm = go.AddComponent(); tm.characterSize = 0.12f; tm.fontSize = 64; tm.anchor = TextAnchor.MiddleCenter; tm.alignment = TextAlignment.Center; tm.color = Color.white; tm.fontStyle = FontStyle.Bold; go.SetActive(false); return new FloatingNumber { Tm = tm, Tr = go.transform, Active = false }; } void SpawnNumber(float amount, Vector3 worldPos, bool isLocalPlayer, Camera cam) { FloatingNumber fn = null; for (int i = 0; i < _numbers.Count; i++) if (!_numbers[i].Active) { fn = _numbers[i]; break; } if (fn == null) return; // pool exhausted this frame: drop (cheap) fn.Active = true; fn.Age = 0f; fn.Life = 0.7f; fn.Tm.text = Mathf.Max(1, Mathf.RoundToInt(amount)).ToString(); fn.BaseColor = isLocalPlayer ? new Color(1f, 0.5f, 0.22f) : new Color(0.45f, 0.92f, 1f); // Blight orange (hurt) / Aether cyan (you hit) fn.Tm.color = fn.BaseColor; fn.Tr.position = worldPos + Vector3.up * 1.4f + new Vector3(UnityEngine.Random.Range(-0.25f, 0.25f), 0f, 0f); fn.Vel = new Vector3(0f, 2.2f, 0f); fn.Tr.gameObject.SetActive(true); if (cam != null) fn.Tr.rotation = cam.transform.rotation; } void AnimateNumbers(float dt, Camera cam) { for (int i = 0; i < _numbers.Count; i++) { var fn = _numbers[i]; if (!fn.Active) continue; fn.Age += dt; if (fn.Age >= fn.Life) { fn.Active = false; fn.Tr.gameObject.SetActive(false); continue; } fn.Vel.y -= 3.5f * dt; // ease the rise fn.Tr.position += fn.Vel * dt; if (cam != null) fn.Tr.rotation = cam.transform.rotation; var c = fn.BaseColor; c.a = 1f - (fn.Age / fn.Life); fn.Tm.color = c; } } // ---- Procedural SFX + pooled particle bursts (fallback when no authored prefab) ---- static AudioClip MakeClip(string name, float f0, float f1, float dur, float vol, bool noise) { const int rate = 44100; int len = Mathf.Max(16, (int)(dur * rate)); var clip = AudioClip.Create(name, len, 1, rate, false); var data = new float[len]; var rng = new System.Random(name.Length * 9973 + 7); float phase = 0f; for (int i = 0; i < len; i++) { float t = i / (float)len; float env = Mathf.Exp(-5f * t); float freq = Mathf.Lerp(f0, f1, t); phase += 2f * Mathf.PI * freq / rate; float s = noise ? (float)(rng.NextDouble() * 2.0 - 1.0) : Mathf.Sin(phase); data[i] = s * env * vol; } clip.SetData(data, 0); return clip; } static Material MakeParticleMaterial() { // Sprites/Default is an always-included, transparent, vertex-coloured shader — reliable for // billboarded sparks; HDR start colours still push past the bloom threshold (Stage 5 look pass). Shader sh = Shader.Find("Sprites/Default"); if (sh == null) sh = Shader.Find("Universal Render Pipeline/Particles/Unlit"); if (sh == null) sh = Shader.Find("Unlit/Color"); return new Material(sh) { name = "CombatFeedbackParticle" }; } ParticleSystem MakeBurst(string name, Material mat, Color color, float size, float speed, float life, int max) { var go = new GameObject(name); go.transform.SetParent(_fxRoot, false); var ps = go.AddComponent(); var main = ps.main; main.loop = false; main.playOnAwake = false; // duration unused: manual Emit bursts with emission disabled main.startLifetime = life; main.startSpeed = speed; main.startSize = size; main.startColor = color; main.maxParticles = max; main.gravityModifier = 0f; main.simulationSpace = ParticleSystemSimulationSpace.World; var emission = ps.emission; emission.enabled = false; // we Emit(count) manually var shape = ps.shape; shape.enabled = true; shape.shapeType = ParticleSystemShapeType.Sphere; shape.radius = 0.06f; var colOverLife = ps.colorOverLifetime; colOverLife.enabled = true; var grad = new Gradient(); grad.SetKeys( new[] { new GradientColorKey(Color.white, 0f), new GradientColorKey(Color.white, 1f) }, new[] { new GradientAlphaKey(1f, 0f), new GradientAlphaKey(0f, 1f) }); colOverLife.color = new ParticleSystem.MinMaxGradient(grad); var sizeOverLife = ps.sizeOverLifetime; sizeOverLife.enabled = true; sizeOverLife.size = new ParticleSystem.MinMaxCurve(1f, AnimationCurve.Linear(0f, 1f, 1f, 0.2f)); var renderer = ps.GetComponent(); renderer.material = mat; renderer.renderMode = ParticleSystemRenderMode.Billboard; return ps; } static void EmitAt(ParticleSystem ps, Vector3 pos, int count) { if (ps == null) return; ps.transform.position = pos; ps.Emit(count); } static void PlayClip(AudioClip clip, Vector3 pos, float vol) { if (clip == null) return; AudioSource.PlayClipAtPoint(clip, pos, vol * GameVolume.Sfx); } } }