4febf3dfe2
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>
174 lines
8.7 KiB
C#
174 lines
8.7 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>
|
|
/// 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>>0 → <=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]);
|
|
}
|
|
}
|
|
}
|