using System.Collections.Generic; using ProjectM.Simulation; using Unity.Entities; using Unity.Mathematics; using Unity.NetCode; using Unity.Transforms; using UnityEngine; using static ProjectM.Client.FeedbackFx; namespace ProjectM.Client { /// /// Client-only WORLD JUICE for harvesting resource nodes + smashing Blight clutter. A managed presentation /// system (SystemBase, main thread, NO Burst) in that REACTS to /// replicated state — it never runs simulation. Each frame it edge-detects every node/clutter ghost's /// replicated Remaining: a decrease spawns a small tinted chip burst + soft SFX; a despawn (the server /// destroyed it — depletion or shatter) spawns a clear burst + SFX, and clutter adds a camera punch (the /// "carve through the frontier" smash). A PROXIMITY GATE suppresses the prune VFX unless the despawned /// entity's last position was near the local player, so the region-transit despawn storm at +1000 X stays /// silent off-camera (GhostRelevancy drops every expedition ghost at once when the player walks home). /// Procedural particles + procedural SFX (mirrors CombatFeedbackSystem; self-contained); knobs live in /// . Never destroys a ghost — GhostDespawnSystem owns despawn; we only OBSERVE. /// [WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)] [UpdateInGroup(typeof(PresentationSystemGroup))] public partial class WorldFeedbackSystem : SystemBase { struct Cache { public int Remaining; public float3 Pos; public bool IsClutter; public Color Tint; public byte Variant; } readonly Dictionary _cache = new(); readonly HashSet _seen = new(); readonly List _stale = new(); Transform _fxRoot; ParticleSystem _chipFx; ParticleSystem _clearFx; AudioClip _chipClip; AudioClip _clearClip; AudioClip _boomClip; readonly Dictionary _fuseRings = new(); // blast-radius discs, one per lit fuse Mesh _fuseDiscMesh; Material _fuseMat; protected override void OnCreate() { _chipClip = MakeClip("harvest_chip", 900f, 1400f, 0.06f, 0.30f); _clearClip = MakeClip("clutter_clear", 420f, 90f, 0.22f, 0.50f); _boomClip = MakeClip("barrel_boom", 140f, 40f, 0.45f, 0.65f, noise: true, decay: 6f); } protected override void OnStartRunning() { if (_fxRoot != null) return; _fxRoot = new GameObject("~WorldFeedbackFX").transform; var mat = MakeParticleMaterial(); _chipFx = MakeBurst(_fxRoot, "HarvestChips", mat, new Color(2.6f, 1.9f, 0.7f), 0.10f, 5f, 0.30f, 256, 0.25f, 0.10f, 0.2f); _clearFx = MakeBurst(_fxRoot, "ClutterClear", mat, new Color(3.0f, 1.1f, 0.25f), 0.16f, 7f, 0.45f, 512, 0.25f, 0.10f, 0.2f); // Blast-radius disc for lit barrel fuses (operator 07-10: the radius was unreadable) — a flat ground // disc at EXACTLY Tuning.BarrelExplodeRadius, pulsing via the shared material's alpha. _fuseMat = MakeParticleMaterial(); _fuseMat.name = "BarrelFuseDisc"; _fuseMat.color = new Color(2.8f, 0.55f, 0.18f, 0.35f); _fuseDiscMesh = BuildDisc(32); } protected override void OnDestroy() { if (_fxRoot != null) Object.Destroy(_fxRoot.gameObject); // fuse rings are children -> die with it if (_fuseMat != null) Object.Destroy(_fuseMat); if (_fuseDiscMesh != null) Object.Destroy(_fuseDiscMesh); // Procedural clips are NOT children of _fxRoot — see FeedbackFx.DestroyClip. FeedbackFx.DestroyClip(ref _chipClip); FeedbackFx.DestroyClip(ref _clearClip); FeedbackFx.DestroyClip(ref _boomClip); } protected override void OnUpdate() { if (!WorldFeelConfig.Enabled) { _cache.Clear(); return; } // Complete predicted/interpolation jobs writing these before the main-thread reads. EntityManager.CompleteDependencyBeforeRO(); EntityManager.CompleteDependencyBeforeRO(); EntityManager.CompleteDependencyBeforeRO(); // Local player position (for the proximity gate). bool haveLocal = false; float3 localPos = default; foreach (var xf in SystemAPI.Query>().WithAll()) { localPos = xf.ValueRO.Position; haveLocal = true; } _seen.Clear(); // Resource nodes — chip on depletion. foreach (var (node, xf, e) in SystemAPI.Query, RefRO>().WithEntityAccess()) { _seen.Add(e); Observe(e, node.ValueRO.Remaining, xf.ValueRO.Position, false, TintForResource(node.ValueRO.ResourceId), 0, haveLocal && math.distancesq(xf.ValueRO.Position, localPos) <= WorldFeelConfig.ProximityRange * WorldFeelConfig.ProximityRange); } // Blight clutter — chip on damage. foreach (var (clutter, xf, e) in SystemAPI.Query, RefRO>().WithEntityAccess()) { _seen.Add(e); Observe(e, clutter.ValueRO.Remaining, xf.ValueRO.Position, true, WorldFeelConfig.WildTint, clutter.ValueRO.Variant, haveLocal && math.distancesq(xf.ValueRO.Position, localPos) <= WorldFeelConfig.ProximityRange * WorldFeelConfig.ProximityRange); UpdateFuseRing(e, clutter.ValueRO, xf.ValueRO.Position); } // blast-radius discs pulse in sync (one shared material) if (_fuseRings.Count > 0 && _fuseMat != null) { float fa = 0.30f + 0.22f * Mathf.Abs(Mathf.Sin(UnityEngine.Time.time * 9f)); var fc = _fuseMat.color; _fuseMat.color = new Color(fc.r, fc.g, fc.b, fa); } // Prune: a despawn = the server destroyed it (node depleted / clutter shattered). Gate on proximity so // a region-transit despawn storm (every expedition ghost dropped at once, far at +1000 X) stays silent. if (_cache.Count != _seen.Count) { _stale.Clear(); foreach (var kv in _cache) if (!_seen.Contains(kv.Key)) _stale.Add(kv.Key); float rangeSq = WorldFeelConfig.ProximityRange * WorldFeelConfig.ProximityRange; for (int i = 0; i < _stale.Count; i++) { var c = _cache[_stale[i]]; if (haveLocal && math.distancesq(c.Pos, localPos) <= rangeSq) { if (c.IsClutter && c.Variant == 3 && c.Remaining <= 0) { // EXPLOSIVE detonation: only a hit-popped barrel reaches despawn with a cached // Remaining of 0 (a teardown despawn still carries Remaining>0) — the review-hardened // boom-vs-puff split, so portal-advance teardowns can never fire false booms. EmitTinted(_clearFx, (Vector3)c.Pos + Vector3.up * 0.6f, WorldFeelConfig.ClearBurstCount * 4, new Color(3.4f, 1.4f, 0.3f)); DynamicLightSystem.RequestFlash((Vector3)c.Pos, new Color(1f, 0.62f, 0.25f), 1.6f); PlayClip(_boomClip, (Vector3)c.Pos, 0.8f); PrototypeCameraRig.PunchFov(WorldFeelConfig.ClearFovKick * 2.2f, 90f); PrototypeCameraRig.AddShake(WorldFeelConfig.ClearShake * 2.5f); ScorchDecalSystem.RequestScorch((Vector3)c.Pos, Tuning.BarrelExplodeRadius); // bundle 2: charred ground mark at the boom } else { EmitTinted(_clearFx, (Vector3)c.Pos + Vector3.up * 0.6f, WorldFeelConfig.ClearBurstCount, c.Tint); PlayClip(_clearClip, (Vector3)c.Pos, WorldFeelConfig.ClearSfxVolume); if (c.IsClutter) { PrototypeCameraRig.PunchFov(WorldFeelConfig.ClearFovKick, 90f); PrototypeCameraRig.AddShake(WorldFeelConfig.ClearShake); } } } if (_fuseRings.TryGetValue(_stale[i], out var ring)) { if (ring != null) Object.Destroy(ring); _fuseRings.Remove(_stale[i]); } _cache.Remove(_stale[i]); } } } void Observe(Entity e, int remaining, float3 pos, bool isClutter, Color tint, byte variant, bool nearLocal) { if (_cache.TryGetValue(e, out var prev) && remaining < prev.Remaining) { EmitTinted(_chipFx, (Vector3)pos + Vector3.up * 0.6f, WorldFeelConfig.ChipBurstCount, tint); PlayClip(_chipClip, (Vector3)pos, WorldFeelConfig.ChipSfxVolume); if (nearLocal) // tiny per-hit camera impact, only for harvesting near the local player (not a teammate's far node) { if (WorldFeelConfig.ChipFovKick > 0f) PrototypeCameraRig.PunchFov(WorldFeelConfig.ChipFovKick, 90f); if (WorldFeelConfig.ChipShake > 0f) PrototypeCameraRig.AddShake(WorldFeelConfig.ChipShake); } } _cache[e] = new Cache { Remaining = remaining, Pos = pos, IsClutter = isClutter, Tint = tint, Variant = variant }; } // Lit fuse (Variant 3, Remaining 0 on a live barrel) -> a flat ground disc at EXACTLY the detonation // radius so the player reads WHERE not to stand. Removed via the prune path (a fused barrel can only // despawn — detonation or teardown). void UpdateFuseRing(Entity e, BlightClutter clutter, float3 pos) { if (clutter.Variant != 3 || clutter.Remaining > 0) return; if (!_fuseRings.TryGetValue(e, out var go) || go == null) { go = new GameObject("BarrelBlastRadius"); go.transform.SetParent(_fxRoot, false); var mf = go.AddComponent(); mf.sharedMesh = _fuseDiscMesh; var mr = go.AddComponent(); mr.sharedMaterial = _fuseMat; mr.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off; mr.receiveShadows = false; go.transform.localScale = new Vector3(Tuning.BarrelExplodeRadius, 1f, Tuning.BarrelExplodeRadius); _fuseRings[e] = go; } go.transform.position = new Vector3(pos.x, 0.06f, pos.z); } static Color TintForResource(byte resourceId) { if (resourceId == ResourceId.Ore) return WorldFeelConfig.OreTint; if (resourceId == ResourceId.Biomass) return WorldFeelConfig.BiomassTint; return WorldFeelConfig.WildTint; // Aether + default } // ---- procedural particles + SFX (mirrors CombatFeedbackSystem; self-contained) ---- } }