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 BLIGHT-GEYSER telegraph + erupt VFX (Phase 1.5b bundle 3; Geyser_Build_Spec). Observe-only /// in — sibling of /// , never mutates the sim. Reads each geyser's replicated /// against the client's PREDICTED NetworkTime.ServerTick and derives /// everything from that ABSOLUTE tick (never an edge on the field — so it survives snapshot loss + relevancy /// re-entry; the design-review-hardened contract, review wf_900e9965-8f0 H1): /// /// a growing/pulsing warning DISC at over the last /// before the eruption (the absolute-tick countdown, exactly the /// enemy-telegraph idiom); /// the erupt BURST fired when the countdown CROSSES >0 → <=0, LATCHED once per /// NextEruptTick value + an ARM-guard (only if a prior frame saw it counting down) — immune to the /// 0→stamp phantom, relevancy re-entry mid-period, and snapshot loss. /// /// Erupt VFX reuse the shipped statics — burst + /// + (bundle 2, built for this). Pooled disc GameObjects under a /// private root, pruned every frame; a pruned geyser (teardown / relevancy drop) is SILENT. /// [WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)] [UpdateInGroup(typeof(PresentationSystemGroup))] public partial class GeyserTelegraphSystem : SystemBase { static readonly Color DiscColor = new Color(2.6f, 0.35f, 2.4f); // hot blight magenta-purple (danger + biome id) static readonly Color EruptColor = new Color(2.9f, 0.5f, 3.2f); // brighter burst static readonly int ColorId = Shader.PropertyToID("_Color"); Transform _fxRoot; Material _discMat; Material _burstMat; Mesh _discMesh; ParticleSystem _eruptFx; AudioClip _eruptClip; MaterialPropertyBlock _mpb; readonly Dictionary _discs = new(); readonly Dictionary _armed = new(); // NextEruptTick we've seen counting down for readonly Dictionary _lastFired = new(); // NextEruptTick we last fired the burst for (latch) readonly HashSet _seen = new(); readonly List _stale = new(); protected override void OnCreate() { _eruptClip = MakeClip("geyser_erupt", 90f, 300f, 0.30f, 0.55f, noise: true, decay: 5f); _mpb = new MaterialPropertyBlock(); } protected override void OnStartRunning() { if (_fxRoot != null) return; _fxRoot = new GameObject("~GeyserTelegraphFX").transform; _discMat = MakeParticleMaterial("GeyserDisc"); _discMat.color = DiscColor; _discMesh = BuildDisc(40); _burstMat = MakeParticleMaterial("GeyserErupt"); _eruptFx = MakeBurst(_fxRoot, "GeyserErupt", _burstMat, EruptColor, 0.18f, 8f, 0.5f, 256, -0.15f, 0.25f, 0.25f); } protected override void OnDestroy() { if (_fxRoot != null) Object.Destroy(_fxRoot.gameObject); if (_discMat != null) Object.Destroy(_discMat); if (_burstMat != null) Object.Destroy(_burstMat); if (_discMesh != null) Object.Destroy(_discMesh); } protected override void OnUpdate() { if (_fxRoot == null || _discMat == null) return; if (!SystemAPI.TryGetSingleton(out var nt) || !nt.ServerTick.IsValid) return; var serverTick = nt.ServerTick; EntityManager.CompleteDependencyBeforeRO(); EntityManager.CompleteDependencyBeforeRO(); _seen.Clear(); foreach (var (geyser, xf, e) in SystemAPI.Query, RefRO>().WithEntityAccess()) { uint next = geyser.ValueRO.NextEruptTick; if (next == 0u) continue; // unstamped -> no telegraph yet (born-correct pending) var untilTick = new NetworkTick(next); if (!untilTick.IsValid) continue; _seen.Add(e); int lead = untilTick.TicksSince(serverTick); // >0 = erupt in the future, <=0 = erupt now/past float3 pos = xf.ValueRO.Position; if (lead > 0) { _armed[e] = next; // arm this eruption while it counts down } else // lead <= 0: the erupt tick has arrived / passed { bool armedForThis = _armed.TryGetValue(e, out var av) && av == next; bool alreadyFired = _lastFired.TryGetValue(e, out var lf) && lf == next; if (armedForThis && !alreadyFired) { // fire ONCE per eruption, on the ServerTick crossing (co-located with the disc completion) EmitTinted(_eruptFx, (Vector3)pos + Vector3.up * 0.3f, 44, EruptColor); DynamicLightSystem.RequestFlash((Vector3)pos, new Color(0.85f, 0.35f, 1f), 1.4f); ScorchDecalSystem.RequestScorch((Vector3)pos, Tuning.GeyserEruptRadius); PlayClip(_eruptClip, (Vector3)pos, 0.7f); _lastFired[e] = next; } } // Warning disc: grow + pulse over the telegraph window; hidden when dormant or erupted. bool showing = lead > 0 && lead <= Tuning.GeyserTelegraphTicks; if (showing) { float charge = 1f - lead / (float)Tuning.GeyserTelegraphTicks; // 0 at window open -> 1 at erupt if (!_discs.TryGetValue(e, out var go) || go == null) { go = new GameObject("GeyserWarning"); go.transform.SetParent(_fxRoot, false); go.AddComponent().sharedMesh = _discMesh; var mr = go.AddComponent(); mr.sharedMaterial = _discMat; mr.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off; mr.receiveShadows = false; mr.lightProbeUsage = UnityEngine.Rendering.LightProbeUsage.Off; _discs[e] = go; } if (!go.activeSelf) go.SetActive(true); float radius = Tuning.GeyserEruptRadius * (0.55f + 0.45f * charge); go.transform.position = new Vector3(pos.x, 0.055f, pos.z); go.transform.localScale = new Vector3(radius, 1f, radius); float alpha = 0.14f + 0.5f * charge + 0.12f * Mathf.Abs(Mathf.Sin(UnityEngine.Time.time * 10f)); _mpb.SetColor(ColorId, new Color(DiscColor.r, DiscColor.g, DiscColor.b, alpha)); go.GetComponent().SetPropertyBlock(_mpb); } else if (_discs.TryGetValue(e, out var go) && go != null && go.activeSelf) { go.SetActive(false); // dormant (lead > window) or just erupted (lead <= 0) } } // Prune despawned geysers (teardown / relevancy drop) — destroy the disc, drop tracking, emit nothing. if (_discs.Count > 0) { _stale.Clear(); foreach (var kv in _discs) if (!_seen.Contains(kv.Key)) _stale.Add(kv.Key); for (int i = 0; i < _stale.Count; i++) { if (_discs[_stale[i]] != null) Object.Destroy(_discs[_stale[i]]); _discs.Remove(_stale[i]); } } PruneMap(_armed); PruneMap(_lastFired); } void PruneMap(Dictionary dict) { if (dict.Count == 0) return; _stale.Clear(); foreach (var kv in dict) if (!_seen.Contains(kv.Key)) _stale.Add(kv.Key); for (int i = 0; i < _stale.Count; i++) dict.Remove(_stale[i]); } } }