Files
Project-M/Assets/_Project/Scripts/Client/Presentation/ZoneTelegraphSystem.cs
T
kronic 2aebc37115 G6+G4: Cone damage-at-contact + zone fill telegraph (ZoneEffect GhostFields) + co-op saturation budget
Cone/SpecialSlam damage lands at its visual contact via ConeContactPending (knob 32, 0=legacy;
early-flush + resolve re-validate; death + BOTH class-swap paths drop armed pendings — fixes the
shipped melee death-strand in the same stroke). Zones: [GhostField] Caster/Radius/NextTick +
ZoneTelegraphSystem (Geyser latch contract on InterpolationTick; rim = true radius; arm grows,
persistent phase drains). Cone cues latch to contact (FireStartRaw, C14); TuningConfig.Defaults()
fallback at client cue sites (release-build timing fix). G4: SaturationMath ally-FX degrade
(living-enemy census, solo-exempt; enemy telegraphs structurally exempt) + CombatStressDebug +
overlay saturation rows. Reviews wf_98bf1268 (13 confirmed folded) / wf_9757d214 (5 confirmed fixed).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 13:27:50 -07:00

208 lines
10 KiB
C#

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
{
/// <summary>
/// 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
/// <see cref="ZoneEffect.Radius"/> (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
/// <see cref="ZoneEffect.NextTick"/> over <see cref="ZoneEffect.PulsePeriodTicks"/>; each server re-stamp
/// naturally resets it (the persistent-zone fill encoding, guidelines G6/R6). Observe-only
/// <see cref="SystemBase"/> in <see cref="PresentationSystemGroup"/>, templated on
/// <see cref="GeyserTelegraphSystem"/> (shared <see cref="FeedbackFx.BuildDisc"/>/<see cref="FeedbackFx.BuildRing"/>
/// 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).
/// <para>
/// DELIBERATE divergence from the Geyser precedent: ticks are evaluated against
/// <c>NetworkTime.InterpolationTick</c>, 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).
/// </para>
/// </summary>
[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<Entity, GameObject> _zones = new();
readonly Dictionary<Entity, uint> _armed = new(); // NextTick seen counting down (arm-guard)
readonly Dictionary<Entity, uint> _lastFired = new(); // NextTick we last pulsed for (value latch)
readonly Dictionary<Entity, float> _flashUntil = new();
readonly HashSet<Entity> _seen = new();
readonly List<Entity> _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<NetworkTime>(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<NetworkId>(out var nid) ? nid.Value : -1;
EntityManager.CompleteDependencyBeforeRO<ZoneEffect>();
EntityManager.CompleteDependencyBeforeRO<LocalTransform>();
_seen.Clear();
foreach (var (zone, xf, e) in
SystemAPI.Query<RefRO<ZoneEffect>, RefRO<LocalTransform>>().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<MeshFilter>().sharedMesh = mesh;
var mr = child.AddComponent<MeshRenderer>();
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<MeshRenderer>().SetPropertyBlock(_mpb);
}
void PruneMap(Dictionary<Entity, uint> 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<Entity, float> 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]);
}
}
}