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 { /// /// 07-21 G6 (review wf_98bf1268) — client-only ZONE-socket fill telegraph (Vortex / LightZone), the /// WildStar-grade honesty decal for player zones: a thin RIM always drawn at the replicated /// (rim = the TRUE folded damage radius, guidelines G2) plus an inner FILL /// disc whose arrival at the rim IS the damage moment — fill derives from the replicated absolute /// over ; each server re-stamp /// naturally resets it (the persistent-zone fill encoding, guidelines G6/R6). Observe-only /// in , templated on /// (shared / /// unit meshes + MPB alpha + pooled GOs + per-frame silent prune; the value-latch + was-counting-down /// arm-guard pulse contract, review H1 — never edge-detect the re-stamp). /// /// DELIBERATE divergence from the Geyser precedent: ticks are evaluated against /// NetworkTime.InterpolationTick, NOT the predicted ServerTick — a geyser threatens the PREDICTED /// local player, but a zone's observables (enemy HP drops, vortex pull) live on the INTERPOLATED timeline /// the zone ghost itself renders on; the predicted tick would complete the fill ~RTT early and pin it at /// full (invisible on loopback, 20-40% of the bar wrong at internet RTTs — review finding, confirmed). /// Ownership tint per guidelines G3: local caster = warm (LightZone) / teal (Vortex); ally = dimmer /// cool-blue of the same shapes; never red. Enemy telegraphs live elsewhere and NEVER degrade (G4). /// /// [WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)] [UpdateInGroup(typeof(PresentationSystemGroup))] public partial class ZoneTelegraphSystem : SystemBase { static readonly Color LocalLightColor = new Color(2.2f, 1.6f, 0.7f); // warm lamp-amber — light is territory static readonly Color LocalVortexColor = new Color(0.5f, 1.9f, 1.8f); // teal swirl static readonly Color AllyColor = new Color(0.35f, 0.55f, 1.1f); // dim cool-blue (same shapes, G3) static readonly int ColorId = Shader.PropertyToID("_Color"); const float k_PulseFlashSeconds = 0.14f; Transform _fxRoot; Material _mat; Mesh _discMesh; Mesh _ringMesh; MaterialPropertyBlock _mpb; // Per-zone pooled pair: [0] = fill disc, [1] = rim ring (children of one root GO). readonly Dictionary _zones = new(); readonly Dictionary _armed = new(); // NextTick seen counting down (arm-guard) readonly Dictionary _lastFired = new(); // NextTick we last pulsed for (value latch) readonly Dictionary _flashUntil = new(); readonly HashSet _seen = new(); readonly List _stale = new(); protected override void OnCreate() { _mpb = new MaterialPropertyBlock(); } protected override void OnStartRunning() { if (_fxRoot != null) return; _fxRoot = new GameObject("~ZoneTelegraphFX").transform; _mat = MakeParticleMaterial("ZoneTelegraph"); _discMesh = BuildDisc(40); _ringMesh = BuildRing(48); } protected override void OnDestroy() { if (_fxRoot != null) Object.Destroy(_fxRoot.gameObject); if (_mat != null) Object.Destroy(_mat); if (_discMesh != null) Object.Destroy(_discMesh); if (_ringMesh != null) Object.Destroy(_ringMesh); } protected override void OnUpdate() { if (_fxRoot == null || _mat == null) return; if (!SystemAPI.TryGetSingleton(out var nt)) return; // The interpolated timeline — see the class doc for why NOT the predicted ServerTick. var tick = nt.InterpolationTick.IsValid ? nt.InterpolationTick : nt.ServerTick; if (!tick.IsValid) return; int localNetId = SystemAPI.TryGetSingleton(out var nid) ? nid.Value : -1; EntityManager.CompleteDependencyBeforeRO(); EntityManager.CompleteDependencyBeforeRO(); _seen.Clear(); foreach (var (zone, xf, e) in SystemAPI.Query, RefRO>().WithEntityAccess()) { var ze = zone.ValueRO; _seen.Add(e); if (ze.Radius <= 0.01f) continue; // pre-first-snapshot (baked zero) — nothing honest to draw yet float3 pos = xf.ValueRO.Position; bool mine = ze.CasterNetworkId == localNetId; bool vortex = (ze.Flags & ZoneEffectFlag.Vortex) != 0; Color baseCol = mine ? (vortex ? LocalVortexColor : LocalLightColor) : AllyColor; // Fill from the replicated ABSOLUTE next-pulse tick (0 = unscheduled -> rim only). float fill = 0f; uint next = ze.NextTick; if (next != 0u && new NetworkTick(next).IsValid) { int lead = new NetworkTick(next).TicksSince(tick); // >0 counting down, <=0 arrived/past if (lead > 0) { _armed[e] = next; // arm this pulse while it counts down // Operator report 07-21 ("casts seem to auto-recast"): a fill RE-GROWING every pulse reads // as a fresh cast. ARM phase (before the first pulse) grows 0->1 (fill reaches the rim = // first damage); the PERSISTENT phase DRAINS 1->0 toward each pulse (a metronome, not a // cast) - and the direction split is the G6/R6 one-shot-vs-persistent encoding, done right. float frac = math.saturate(lead / (float)ZoneEffect.PulsePeriodTicks); fill = _lastFired.ContainsKey(e) ? frac : 1f - frac; } else { fill = 1f; bool armedForThis = _armed.TryGetValue(e, out var av) && av == next; bool alreadyFired = _lastFired.TryGetValue(e, out var lf) && lf == next; if (armedForThis && !alreadyFired) { _flashUntil[e] = UnityEngine.Time.time + k_PulseFlashSeconds; // quiet tier: a rim flash, no burst (G4) _lastFired[e] = next; } } } if (!_zones.TryGetValue(e, out var go) || go == null) { go = new GameObject("ZoneTelegraph"); go.transform.SetParent(_fxRoot, false); MakeChild(go.transform, "Fill", _discMesh); MakeChild(go.transform, "Rim", _ringMesh); _zones[e] = go; } if (!go.activeSelf) go.SetActive(true); go.transform.position = new Vector3(pos.x, 0.05f, pos.z); bool flashing = _flashUntil.TryGetValue(e, out var fu) && UnityEngine.Time.time < fu; var fillTr = go.transform.GetChild(0); var rimTr = go.transform.GetChild(1); float fillRadius = ze.Radius * fill; fillTr.localScale = new Vector3(fillRadius, 1f, fillRadius); rimTr.localScale = new Vector3(ze.Radius, 1f, ze.Radius); float fillAlpha = (mine ? 0.16f : 0.10f) * (0.35f + 0.65f * fill); float rimAlpha = (mine ? 0.55f : 0.35f) + (flashing ? 0.4f : 0f); SetTint(fillTr, baseCol, fillAlpha); SetTint(rimTr, baseCol, rimAlpha); } // Prune despawned zones (expiry / teardown / relevancy drop) — destroy, drop tracking, emit nothing. if (_zones.Count > 0) { _stale.Clear(); foreach (var kv in _zones) if (!_seen.Contains(kv.Key)) _stale.Add(kv.Key); for (int i = 0; i < _stale.Count; i++) { if (_zones[_stale[i]] != null) Object.Destroy(_zones[_stale[i]]); _zones.Remove(_stale[i]); } } PruneMap(_armed); PruneMap(_lastFired); PruneFloatMap(_flashUntil); } void MakeChild(Transform parent, string name, Mesh mesh) { var child = new GameObject(name); child.transform.SetParent(parent, false); child.AddComponent().sharedMesh = mesh; var mr = child.AddComponent(); mr.sharedMaterial = _mat; mr.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off; mr.receiveShadows = false; mr.lightProbeUsage = UnityEngine.Rendering.LightProbeUsage.Off; } void SetTint(Transform tr, Color c, float alpha) { _mpb.SetColor(ColorId, new Color(c.r, c.g, c.b, alpha)); tr.GetComponent().SetPropertyBlock(_mpb); } 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]); } void PruneFloatMap(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]); } } }