using System.Collections.Generic; using ProjectM.Simulation; using Unity.Entities; using Unity.Mathematics; using Unity.NetCode; using Unity.Transforms; using UnityEngine; namespace ProjectM.Client { /// /// EB-1 — client-only WORLD JUICE for player-built structures taking damage + dying ("loses have weight"). A /// managed in that OBSERVES replicated state and /// never mutates the sim: it edge-detects each structure ghost's [GhostField] Health.Current — a decrease /// spawns a small amber chip (camera-SILENT so a siege's many hits never clamp the shake), and a destruction /// (an HP<=0 edge OR a despawn) spawns a LOUD red-orange burst + camera punch. A PROXIMITY GATE suppresses the /// destruction burst unless the structure was near the local player, so the base->expedition RegionRelevancy /// despawn (every base structure drops from this client at once) stays SILENT. De-duped: a structure fires its /// death burst AT MOST once (the HP<=0 edge sets DeathFired so the prune-cleanup skips it; the server destroys /// a structure the same tick it hits 0, so the prune is usually the path that fires). CombatFeedbackSystem /// suppresses structures, so this is the SOLE structure cue. Procedural particles + SFX (mirrors /// WorldFeedbackSystem; self-contained). Never destroys a ghost (GhostDespawnSystem owns despawn); prunes the /// cache EVERY frame (no [RequireMatchingQueriesForUpdate] — else a cache entry leaks per kill). /// [WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)] [UpdateInGroup(typeof(PresentationSystemGroup))] public partial class StructureFeedbackSystem : SystemBase { struct Cache { public float Hp; public float3 Pos; public bool DeathFired; } readonly Dictionary _cache = new(); readonly HashSet _seen = new(); readonly List _stale = new(); Transform _fxRoot; ParticleSystem _chipFx; ParticleSystem _deathFx; AudioClip _chipClip; AudioClip _deathClip; protected override void OnCreate() { _chipClip = MakeClip("struct_chip", 700f, 500f, 0.05f, 0.30f); _deathClip = MakeClip("struct_death", 220f, 60f, 0.35f, 0.55f); } protected override void OnStartRunning() { if (_fxRoot != null) return; _fxRoot = new GameObject("~StructureFeedbackFX").transform; var mat = MakeParticleMaterial(); _chipFx = MakeBurst("StructChips", mat, StructureFeelConfig.DamageTint, 0.12f, 5f, 0.30f, 256); _deathFx = MakeBurst("StructDeath", mat, StructureFeelConfig.DeathTint, 0.20f, 8f, 0.55f, 512); } protected override void OnDestroy() { if (_fxRoot != null) Object.Destroy(_fxRoot.gameObject); } protected override void OnUpdate() { if (!StructureFeelConfig.Enabled) { _cache.Clear(); return; } EntityManager.CompleteDependencyBeforeRO(); EntityManager.CompleteDependencyBeforeRO(); EntityManager.CompleteDependencyBeforeRO(); bool haveLocal = false; float3 localPos = default; foreach (var xf in SystemAPI.Query>().WithAll()) { localPos = xf.ValueRO.Position; haveLocal = true; } float rangeSq = StructureFeelConfig.ProximityRange * StructureFeelConfig.ProximityRange; _seen.Clear(); foreach (var (health, xf, e) in SystemAPI.Query, RefRO>().WithAll().WithEntityAccess()) { _seen.Add(e); float cur = health.ValueRO.Current; float3 pos = xf.ValueRO.Position; bool nearby = haveLocal && math.distancesq(pos, localPos) <= rangeSq; if (_cache.TryGetValue(e, out var prev)) { if (cur <= 0f && prev.Hp > 0f && !prev.DeathFired) { if (nearby) FireDeath(pos); _cache[e] = new Cache { Hp = cur, Pos = pos, DeathFired = true }; continue; } if (cur < prev.Hp - 0.001f && cur > 0f && nearby) { EmitTinted(_chipFx, (Vector3)pos + Vector3.up * 0.7f, StructureFeelConfig.ChipBurstCount, StructureFeelConfig.DamageTint); PlayClip(_chipClip, (Vector3)pos, StructureFeelConfig.ChipSfxVolume); } } _cache[e] = new Cache { Hp = cur, Pos = pos, DeathFired = _cache.TryGetValue(e, out var c2) && c2.DeathFired }; } // Prune: a despawn = destroyed (or a region-transit drop). Proximity-gated so the +1000 base->expedition // despawn stays silent; de-duped against an HP<=0 edge that already fired this structure's death. 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.DeathFired && haveLocal && math.distancesq(c.Pos, localPos) <= rangeSq) FireDeath(c.Pos); _cache.Remove(_stale[i]); } } } void FireDeath(float3 pos) { EmitTinted(_deathFx, (Vector3)pos + Vector3.up * 0.6f, StructureFeelConfig.DeathBurstCount, StructureFeelConfig.DeathTint); PlayClip(_deathClip, (Vector3)pos, StructureFeelConfig.DeathSfxVolume); PrototypeCameraRig.PunchFov(StructureFeelConfig.DeathFovKick, 110f); PrototypeCameraRig.AddShake(StructureFeelConfig.DeathShake); } // ---- procedural particles + SFX (mirrors WorldFeedbackSystem; self-contained) ---- static void EmitTinted(ParticleSystem ps, Vector3 pos, int count, Color tint) { if (ps == null) return; var main = ps.main; main.startColor = tint; ps.transform.position = pos; ps.Emit(count); } static Material MakeParticleMaterial() { 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 = "StructureFeedbackParticle" }; } 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; main.startLifetime = life; main.startSpeed = speed; main.startSize = size; main.startColor = color; main.maxParticles = max; main.gravityModifier = 0.3f; main.simulationSpace = ParticleSystemSimulationSpace.World; var emission = ps.emission; emission.enabled = false; // manual Emit(count) var shape = ps.shape; shape.enabled = true; shape.shapeType = ParticleSystemShapeType.Sphere; shape.radius = 0.18f; 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.15f)); var renderer = ps.GetComponent(); renderer.material = mat; renderer.renderMode = ParticleSystemRenderMode.Billboard; return ps; } static AudioClip MakeClip(string name, float f0, float f1, float dur, float vol) { 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]; 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; data[i] = Mathf.Sin(phase) * env * vol; } clip.SetData(data, 0); return clip; } static void PlayClip(AudioClip clip, Vector3 pos, float vol) { if (clip == null) return; AudioSource.PlayClipAtPoint(clip, pos, vol * GameVolume.Sfx); } } }