Hazard: Blight geyser — permanent periodic both-sides AoE (Phase 1.5b bundle 3)

Review-first netcode slice (design review wf_900e9965-8f0 -> 11 folded;
post-impl wf_5c8299f8-299 clean). A PERMANENT periodic telegraphed AoE in
Blight-biome rooms only; one new [GhostField] uint NextEruptTick.

- Geyser component + GeyserEruptSystem (server): inverted invalid-tick guard
  (a baked-0 tick must NEVER erupt -> lazy-stamps born-correct instead of the
  party-wiping per-tick barrage), inline both-sides gather (SourceNetworkId=-1),
  reschedule = now + period (never +=), never destroyed.
- GeyserTelegraphSystem (client): absolute-tick growing warning disc +
  erupt burst on the >0->=<0 crossing, latched per NextEruptTick + arm-guard
  (never edge-detects the replicated field -> no phantom on 0->stamp /
  relevancy re-entry). Reuses ScorchDecalSystem + DynamicLightSystem.
- Seeded in RoomFieldSystem (Blight-gated on plan.Biome, born-correct staggered
  stamp from live ServerTick), GeyserFieldSpawner + authoring wired into the
  Gameplay subscene. Geyser.prefab duplicated from ResourceNode (no collider).
- BuildDisc promoted to FeedbackFx (shared with the barrel fuse ring).
- 474/474 EditMode incl. the unstamped-storm regression; live no-storm end-to-end.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-12 13:53:48 -07:00
parent f271d5f47f
commit 4febf3dfe2
21 changed files with 826 additions and 21 deletions
@@ -0,0 +1,173 @@
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>
/// Client-only BLIGHT-GEYSER telegraph + erupt VFX (Phase 1.5b bundle 3; Geyser_Build_Spec). Observe-only
/// <see cref="SystemBase"/> in <see cref="PresentationSystemGroup"/> — sibling of
/// <see cref="EnemyDangerTelegraphSystem"/>, never mutates the sim. Reads each geyser's replicated
/// <see cref="Geyser.NextEruptTick"/> against the client's PREDICTED <c>NetworkTime.ServerTick</c> 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):
/// <list type="bullet">
/// <item>a growing/pulsing warning DISC at <see cref="Tuning.GeyserEruptRadius"/> over the last
/// <see cref="Tuning.GeyserTelegraphTicks"/> before the eruption (the absolute-tick countdown, exactly the
/// enemy-telegraph idiom);</item>
/// <item>the erupt BURST fired when the countdown CROSSES <c>&gt;0 → &lt;=0</c>, LATCHED once per
/// <c>NextEruptTick</c> value + an ARM-guard (only if a prior frame saw it counting down) — immune to the
/// <c>0→stamp</c> phantom, relevancy re-entry mid-period, and snapshot loss.</item>
/// </list>
/// Erupt VFX reuse the shipped statics — <see cref="FeedbackFx"/> burst + <see cref="DynamicLightSystem.RequestFlash"/>
/// + <see cref="ScorchDecalSystem.RequestScorch"/> (bundle 2, built for this). Pooled disc GameObjects under a
/// private root, pruned every frame; a pruned geyser (teardown / relevancy drop) is SILENT.
/// </summary>
[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<Entity, GameObject> _discs = new();
readonly Dictionary<Entity, uint> _armed = new(); // NextEruptTick we've seen counting down for
readonly Dictionary<Entity, uint> _lastFired = new(); // NextEruptTick we last fired the burst for (latch)
readonly HashSet<Entity> _seen = new();
readonly List<Entity> _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<NetworkTime>(out var nt) || !nt.ServerTick.IsValid) return;
var serverTick = nt.ServerTick;
EntityManager.CompleteDependencyBeforeRO<Geyser>();
EntityManager.CompleteDependencyBeforeRO<LocalTransform>();
_seen.Clear();
foreach (var (geyser, xf, e) in
SystemAPI.Query<RefRO<Geyser>, RefRO<LocalTransform>>().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<MeshFilter>().sharedMesh = _discMesh;
var mr = go.AddComponent<MeshRenderer>();
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<MeshRenderer>().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<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]);
}
}
}