2aebc37115
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>
208 lines
10 KiB
C#
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]);
|
|
}
|
|
}
|
|
}
|