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. SFX are generated procedurally; VFX use a small runtime pool — the /// slice ships with NO binary audio/particle assets. /// /// 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; } readonly Dictionary _cache = new(); readonly HashSet _seen = new(); readonly List _stale = new(); readonly List _numbers = new(); Transform _fxRoot; ParticleSystem _hitFx; ParticleSystem _deathFx; ParticleSystem _muzzleFx; AudioClip _hitClip; AudioClip _deathClip; AudioClip _fireClip; Entity _localPlayer = Entity.Null; uint _lastLocalFireTick; bool _fireTickInit; const int NumberPoolSize = 32; 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); } 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; // Make sure predicted/physics jobs writing these are done before this main-thread read. 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); bool isLocalPlayer = entity == _localPlayer; if (_cache.TryGetValue(entity, out var prev)) { if (cur < prev.Hp - 0.001f) { SpawnNumber(prev.Hp - cur, (Vector3)p, isLocalPlayer, cam); EmitAt(_hitFx, (Vector3)p + Vector3.up * 0.8f, 10); PlayClip(_hitClip, (Vector3)p, 0.7f); PrototypeCameraRig.AddShake(isLocalPlayer ? 0.32f : 0.10f); } // Player death (players don't despawn — they respawn; Husk death is handled on prune). if (!isEnemy && cur <= 0f && prev.Hp > 0f) { EmitAt(_deathFx, (Vector3)p + Vector3.up * 0.5f, 28); PlayClip(_deathClip, (Vector3)p, 0.7f); PrototypeCameraRig.AddShake(isLocalPlayer ? 0.5f : 0.25f); } } _cache[entity] = new FxCache { Hp = cur, Pos = p, IsEnemy = isEnemy }; } // 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) { EmitAt(_deathFx, (Vector3)c.Pos + Vector3.up * 0.5f, 28); PlayClip(_deathClip, (Vector3)c.Pos, 0.65f); PrototypeCameraRig.AddShake(0.16f); } _cache.Remove(_stale[i]); } } // Local-player fire feedback: AbilityCooldown.NextFireTick advances on each shot. if (_localPlayer != Entity.Null && EntityManager.HasComponent(_localPlayer)) { uint nextFire = EntityManager.GetComponentData(_localPlayer).NextFireTick; if (_fireTickInit && nextFire != 0 && nextFire != _lastLocalFireTick) { EmitAt(_muzzleFx, (Vector3)localPos + Vector3.up * 0.9f, 8); PlayClip(_fireClip, (Vector3)localPos, 0.5f); } _lastLocalFireTick = nextFire; _fireTickInit = true; } AnimateNumbers(dt, cam); } // ---- 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; 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.32f, 0.26f) : new Color(1f, 0.92f, 0.45f); 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 ---- 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); } } }