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>
This commit is contained in:
@@ -0,0 +1,207 @@
|
||||
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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user