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 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; } readonly Dictionary _cache = new(); readonly HashSet _seen = new(); readonly List _stale = new(); Transform _fxRoot; ParticleSystem _chipFx; ParticleSystem _clearFx; AudioClip _chipClip; AudioClip _clearClip; protected override void OnCreate() { _chipClip = MakeClip("harvest_chip", 900f, 1400f, 0.06f, 0.30f); _clearClip = MakeClip("clutter_clear", 420f, 90f, 0.22f, 0.50f); } protected override void OnStartRunning() { if (_fxRoot != null) return; _fxRoot = new GameObject("~WorldFeedbackFX").transform; var mat = MakeParticleMaterial(); _chipFx = MakeBurst("HarvestChips", mat, new Color(2.6f, 1.9f, 0.7f), 0.10f, 5f, 0.30f, 256); _clearFx = MakeBurst("ClutterClear", mat, new Color(3.0f, 1.1f, 0.25f), 0.16f, 7f, 0.45f, 512); } protected override void OnDestroy() { if (_fxRoot != null) Object.Destroy(_fxRoot.gameObject); } 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)); } // 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); } // 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) { 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); } } _cache.Remove(_stale[i]); } } } void Observe(Entity e, int remaining, float3 pos, bool isClutter, Color tint) { 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); } _cache[e] = new Cache { Remaining = remaining, Pos = pos, IsClutter = isClutter, Tint = tint }; } 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) ---- 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 = "WorldFeedbackParticle" }; } 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.25f; 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.10f; 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 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); } } }