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,27 @@
using ProjectM.Simulation;
using Unity.Entities;
using UnityEngine;
namespace ProjectM.Authoring
{
/// <summary>
/// Authoring for a Blight-geyser ghost prefab (ownerless interpolated — duplicate from ResourceNode.prefab so the
/// GhostAuthoringComponent + LinkedEntityGroup come free; keep a cosmetic vent mesh, NO physics collider — the
/// hazard is the AoE, you walk over it). Bakes <see cref="Geyser"/> (NextEruptTick left 0 — the server stamps it
/// born-correct from the live ServerTick at spawn in RoomFieldSystem; a 0 must NEVER erupt) +
/// <see cref="RegionTag"/>{Expedition} so GhostRelevancy scopes it to expedition players. The field spawner
/// overrides Position + the born-correct NextEruptTick per instance.
/// </summary>
public class GeyserAuthoring : MonoBehaviour
{
private class GeyserBaker : Baker<GeyserAuthoring>
{
public override void Bake(GeyserAuthoring authoring)
{
var entity = GetEntity(authoring, TransformUsageFlags.Dynamic);
AddComponent(entity, new Geyser { NextEruptTick = 0u }); // 0 = unstamped; server stamps born-correct at spawn
AddComponent(entity, new RegionTag { Region = RegionId.Expedition });
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: e45c85bf47692824eacc671035712f38
@@ -0,0 +1,35 @@
using ProjectM.Simulation;
using Unity.Entities;
using UnityEngine;
namespace ProjectM.Authoring
{
/// <summary>
/// Authoring for the baked <see cref="GeyserFieldSpawner"/> singleton (mirrors ClutterFieldSpawnerAuthoring /
/// CoverFieldSpawnerAuthoring). Place once in the gameplay subscene and assign the geyser ghost prefab;
/// RoomFieldSystem scatters it in Blight-biome rooms (never Boss). Carries no transform.
/// </summary>
public class GeyserFieldSpawnerAuthoring : MonoBehaviour
{
[Tooltip("Geyser ghost prefab. Must carry GeyserAuthoring + a GhostAuthoringComponent (ownerless, interpolated).")]
public GameObject GeyserPrefab;
[Tooltip("Number of geysers per Blight-biome room (Boss rooms get none).")]
[Min(0)] public int Count = 2;
private class GeyserFieldSpawnerBaker : Baker<GeyserFieldSpawnerAuthoring>
{
public override void Bake(GeyserFieldSpawnerAuthoring authoring)
{
var entity = GetEntity(authoring, TransformUsageFlags.None);
AddComponent(entity, new GeyserFieldSpawner
{
Prefab = authoring.GeyserPrefab != null
? GetEntity(authoring.GeyserPrefab, TransformUsageFlags.Dynamic)
: Entity.Null,
Count = authoring.Count,
});
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: e5d1544d6afe18245b563de599240493
@@ -180,5 +180,29 @@ namespace ProjectM.Client
m.RecalculateBounds();
return m;
}
// Unit-radius upward-facing disc fan on the XZ plane (centre vertex + rim) for ground telegraph / blast-radius
// rings — scale x/z to the radius. No vertex colours (white default) so the material or a per-renderer _Color
// MPB fully tints + fades it. Promoted from WorldFeedbackSystem so the barrel fuse ring + the geyser telegraph
// share ONE primitive (review M6).
public static Mesh BuildDisc(int segments)
{
if (segments < 6) segments = 6;
var m = new Mesh { name = "Disc" };
var v = new Vector3[segments + 1];
var tris = new int[segments * 3];
v[0] = Vector3.zero;
for (int i = 0; i < segments; i++)
{
float a = i / (float)segments * Mathf.PI * 2f;
v[i + 1] = new Vector3(Mathf.Cos(a), 0f, Mathf.Sin(a));
int n = (i + 1) % segments;
tris[i * 3] = 0; tris[i * 3 + 1] = n + 1; tris[i * 3 + 2] = i + 1;
}
m.vertices = v;
m.triangles = tris;
m.RecalculateBounds();
return m;
}
}
}
@@ -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]);
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 9720b14cfcb422046a945abddddd8cfc
@@ -199,27 +199,6 @@ void Observe(Entity e, int remaining, float3 pos, bool isClutter, Color tint, by
go.transform.position = new Vector3(pos.x, 0.06f, pos.z);
}
// Unit-radius upward-facing disc fan; scaled per ring to the blast radius.
static Mesh BuildDisc(int segments)
{
var m = new Mesh { name = "FuseDisc" };
var v = new Vector3[segments + 1];
var tris = new int[segments * 3];
v[0] = Vector3.zero;
for (int i = 0; i < segments; i++)
{
float a = i / (float)segments * Mathf.PI * 2f;
v[i + 1] = new Vector3(Mathf.Cos(a), 0f, Mathf.Sin(a));
int n = (i + 1) % segments;
tris[i * 3] = 0; tris[i * 3 + 1] = n + 1; tris[i * 3 + 2] = i + 1;
}
m.vertices = v;
m.triangles = tris;
m.RecalculateBounds();
return m;
}
static Color TintForResource(byte resourceId)
{
if (resourceId == ResourceId.Ore) return WorldFeelConfig.OreTint;
@@ -0,0 +1,106 @@
using ProjectM.Simulation;
using Unity.Burst;
using Unity.Collections;
using Unity.Entities;
using Unity.Mathematics;
using Unity.NetCode;
using Unity.Transforms;
namespace ProjectM.Server
{
/// <summary>
/// Erupts PERMANENT Blight <see cref="Geyser"/>s (Geyser_Build_Spec, design-review wf_900e9965-8f0): when a
/// geyser's replicated <see cref="Geyser.NextEruptTick"/> elapses, radius-damage LIVING enemies AND players
/// (friendly fire — the bait/lure mechanic, exactly like the exploding-barrel hazard), then RESCHEDULE
/// NextEruptTick one period ahead of NOW and leave the geyser alive (it is permanent — <see cref="RoomTag"/>
/// teardown is the only removal; NEVER DestroyEntity here). Player + enemy gather mirrors
/// <see cref="HazardExplosionSystem"/> VERBATIM (Health &gt; 0; players also RegionTag == Expedition;
/// <see cref="DamageEvent.SourceNetworkId"/> = -1 environment convention; SourceTick stamped at NOW, the
/// authoring tick, so dash i-frames negate against it).
/// <para>
/// ★ The invalid-tick guard is INVERTED from the barrel (review HIGH H2): an unstamped/invalid NextEruptTick
/// (0, the "ready" sentinel) is NOT-READY and is SKIPPED — it must NEVER erupt. Copying the barrel's
/// "invalid -&gt; detonate" fall-through onto a born-0 [GhostField] would erupt every tick and wipe the party.
/// The reschedule is <c>= now + period</c> (NEVER <c>+=</c> — an anchored accumulator catch-up-storms after any
/// multi-period skip; every tick-sentinel in the project is <c>= now + delay</c>). Plain server
/// <see cref="SimulationSystemGroup"/>, NO ordering edges; presence-gated on <see cref="Geyser"/>.
/// </para>
/// </summary>
[BurstCompile]
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
[UpdateInGroup(typeof(SimulationSystemGroup))]
public partial struct GeyserEruptSystem : ISystem
{
[BurstCompile]
public void OnCreate(ref SystemState state)
{
state.RequireForUpdate<Geyser>();
}
[BurstCompile]
public void OnUpdate(ref SystemState state)
{
if (!SystemAPI.TryGetSingleton<NetworkTime>(out var netTime) || !netTime.ServerTick.IsValid)
return;
var serverTick = netTime.ServerTick;
uint stamp = TickUtil.NonZero(serverTick.TickIndexForValidTick); // SourceTick = NOW (authoring tick)
uint reschedule = TickUtil.NonZero(serverTick.TickIndexForValidTick + Tuning.GeyserPeriodTicks); // = now + period
var erupts = new NativeList<float3>(Allocator.Temp);
foreach (var (geyser, lt) in SystemAPI.Query<RefRW<Geyser>, RefRO<LocalTransform>>())
{
uint next = geyser.ValueRO.NextEruptTick;
var until = new NetworkTick(next);
// INVERTED guard (review H2): an unstamped/invalid NextEruptTick (0) is NOT-READY and must NEVER
// erupt. LAZY-STAMP it born-correct (now + period) instead — self-heals the rare case where the
// seed-time stamp missed (NetworkTime invalid at seed), so a 0 can neither storm nor stay inert.
if (next == 0u || !until.IsValid) { geyser.ValueRW.NextEruptTick = reschedule; continue; }
if (until.IsNewerThan(serverTick)) continue; // still counting down
erupts.Add(lt.ValueRO.Position);
geyser.ValueRW.NextEruptTick = reschedule; // reschedule in place; permanent, never destroyed
}
if (erupts.Length > 0)
{
float radiusSq = Tuning.GeyserEruptRadius * Tuning.GeyserEruptRadius;
var ecb = new EntityCommandBuffer(Allocator.Temp);
// Players: living + expedition only (the HazardExplosionSystem gather verbatim).
foreach (var (hp, region, plt, player) in
SystemAPI.Query<RefRO<Health>, RefRO<RegionTag>, RefRO<LocalTransform>>()
.WithAll<PlayerTag>().WithEntityAccess())
{
if (hp.ValueRO.Current <= 0f || region.ValueRO.Region != RegionId.Expedition) continue;
for (int i = 0; i < erupts.Length; i++)
if (math.distancesq(plt.ValueRO.Position.xz, erupts[i].xz) <= radiusSq)
ecb.AppendToBuffer(player, new DamageEvent
{
Amount = Tuning.GeyserEruptDamage,
SourceNetworkId = -1,
SourceTick = stamp,
});
}
// Enemies: living only (Health > 0 also excludes Dying corpses, the established convention).
foreach (var (hp, elt, enemy) in
SystemAPI.Query<RefRO<Health>, RefRO<LocalTransform>>()
.WithAll<EnemyTag>().WithEntityAccess())
{
if (hp.ValueRO.Current <= 0f) continue;
for (int i = 0; i < erupts.Length; i++)
if (math.distancesq(elt.ValueRO.Position.xz, erupts[i].xz) <= radiusSq)
ecb.AppendToBuffer(enemy, new DamageEvent
{
Amount = Tuning.GeyserEruptDamage,
SourceNetworkId = -1,
SourceTick = stamp,
});
}
ecb.Playback(state.EntityManager);
ecb.Dispose();
}
erupts.Dispose();
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: d2e98116af8019a488692870c6f09985
@@ -3,6 +3,7 @@ using Unity.Burst;
using Unity.Collections;
using Unity.Entities;
using Unity.Mathematics;
using Unity.NetCode;
using Unity.Transforms;
namespace ProjectM.Server
@@ -155,6 +156,45 @@ namespace ProjectM.Server
}
}
// BLIGHT GEYSER (OPTIONAL singleton; Geyser_Build_Spec / review wf_900e9965-8f0): a PERMANENT
// periodic BOTH-SIDES AoE hazard, ONLY in Blight-biome rooms (gate on the LOCAL plan, like the
// cover block). Never Boss rooms — a DESIGN choice (the geyser has no collider, so unlike cover it
// is NOT the depenetration concern). Distinct hash sub-stream 0x6E7; keep-out ring around origin.
// BORN-CORRECT: stamp NextEruptTick from the LIVE ServerTick (staggered per instance so eruptions
// desync) so the first snapshot never carries the 0 sentinel; if NetworkTime is invalid this tick
// the geyser ships 0 and GeyserEruptSystem lazy-stamps it born-correct instead (never a storm).
if (plan.Biome == RoomBiomeId.Blight
&& plan.RoomType != RoomTypeId.Boss
&& SystemAPI.TryGetSingleton<GeyserFieldSpawner>(out var geyser)
&& geyser.Prefab != Entity.Null)
{
uint eruptStamp = 0u;
if (SystemAPI.TryGetSingleton<NetworkTime>(out var gnt) && gnt.ServerTick.IsValid)
eruptStamp = gnt.ServerTick.TickIndexForValidTick;
var gBaked = SystemAPI.GetComponent<LocalTransform>(geyser.Prefab);
var grng = new Random(RunMapMath.Hash(run.RunSeed, (uint)run.CurrentNodeId, 0x6E7u) | 1u);
int gCount = math.clamp(geyser.Count, 0, 4);
const float GeyserKeepOut = 7f;
for (int i = 0; i < gCount; i++)
{
float3 pos = origin;
for (int attempt = 0; attempt < 8; attempt++)
{
pos = RoomLayoutMath.ScatterInShape(plan.ShapeId, origin, i, gCount, ref grng);
if (math.distance(pos.xz, origin.xz) >= GeyserKeepOut) break;
}
if (math.distance(pos.xz, origin.xz) < GeyserKeepOut) continue; // unlucky draws: drop the piece
var e = ecb.Instantiate(geyser.Prefab);
ecb.SetComponent(e, gBaked.WithPosition(pos));
// born-correct + per-instance stagger so geysers desync; 0 only if NetworkTime was invalid.
uint next = eruptStamp != 0u
? TickUtil.NonZero(eruptStamp + Tuning.GeyserPeriodTicks + (uint)i * 60u)
: 0u;
ecb.SetComponent(e, new Geyser { NextEruptTick = next });
ecb.AddComponent(e, new RoomTag { Room = room });
}
}
rf.LastSpawnedRoomEpoch = run.RoomEpoch;
SystemAPI.SetComponent(spawnerEntity, rf);
}
@@ -0,0 +1,23 @@
using Unity.Entities;
using Unity.NetCode;
namespace ProjectM.Simulation
{
/// <summary>
/// A PERMANENT periodic BLIGHT hazard fixture (Phase 1.5b bundle 3; see Geyser_Build_Spec). An ownerless
/// INTERPOLATED ghost placed only in Blight-biome expedition rooms. <see cref="NextEruptTick"/> is the ABSOLUTE
/// server tick of the next eruption — the ONLY replicated field; the client derives its telegraph countdown and
/// erupt-fire from it against its own predicted ServerTick (absolute-tick, NEVER an edge on the field — so it
/// survives snapshot loss + relevancy re-entry; the design-review-hardened contract, see Geyser_Build_Spec §7).
/// Server-only <see cref="ProjectM.Server"/> GeyserEruptSystem radius-damages LIVING players AND enemies (friendly
/// fire, like exploding barrels — also a tactical lure) when it elapses, then reschedules one period ahead of NOW
/// (never destroyed; RoomTag teardown removes it). Born-correct: RoomFieldSystem stamps NextEruptTick from the
/// live ServerTick at spawn, so the first snapshot never carries the 0 "ready/unstamped" sentinel (a baked 0 must
/// be treated as not-ready and NEVER erupted — the inverted invalid-tick guard).
/// </summary>
public struct Geyser : IComponentData
{
/// <summary>Absolute server tick of the next eruption (via TickUtil.NonZero; 0 = unstamped = never erupt).</summary>
[GhostField] public uint NextEruptTick;
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 686fc215cf5ebd84ab06f560f9a16d9e
@@ -0,0 +1,20 @@
using Unity.Entities;
namespace ProjectM.Simulation
{
/// <summary>
/// Baked singleton holding the BLIGHT-GEYSER ghost prefab (Geyser_Build_Spec; a ResourceNode sibling — a
/// <see cref="Geyser"/> carrier with RegionTag{Expedition} and NO collider). RoomFieldSystem scatters
/// <see cref="Count"/> geysers per room epoch ONLY in Blight-biome rooms (plan.Biome == Blight) and NEVER in
/// Boss rooms, with a keep-out ring around the room origin (player landing + portal). OPTIONAL: absent
/// singleton = no geysers. Mirrors <see cref="CoverFieldSpawner"/>; carries no transform.
/// </summary>
public struct GeyserFieldSpawner : IComponentData
{
/// <summary>Baked geyser ghost prefab to instantiate.</summary>
public Entity Prefab;
/// <summary>Geysers per Blight-biome room (Boss rooms get none).</summary>
public int Count;
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 3e7f72ba05b64124fbe572dbadb2a05f
@@ -121,6 +121,22 @@ namespace ProjectM.Simulation
/// <summary>Detonation damage — hits LIVING enemies AND players alike (friendly fire = the bait mechanic).</summary>
public const float BarrelExplodeDamage = 26f;
// ---- Blight geyser (Phase 1.5b bundle-3 hazard; periodic BOTH-SIDES AoE — see Geyser_Build_Spec) ----
/// <summary>Ticks between eruptions (~5 s @60). Server reschedules NextEruptTick = now + this each erupt
/// (never +=, per the review — anchored += catch-up-storms after a skip).</summary>
public const uint GeyserPeriodTicks = 300;
/// <summary>Telegraph window (ticks, ~1.3 s @60): the client grows the warning disc over the last this-many
/// ticks before an eruption (int so the client lead comparison stays signed).</summary>
public const int GeyserTelegraphTicks = 78;
/// <summary>Eruption AoE radius (world units, XZ) — hits LIVING players AND enemies (friendly fire = lure bait).</summary>
public const float GeyserEruptRadius = 3.0f;
/// <summary>Eruption damage per hit.</summary>
public const float GeyserEruptDamage = 22f;
// ---- Expedition BOSS (a scaled Charger given a real kit by BossAISystem; server-only feel consts) ----
/// <summary>Radial SLAM AoE radius (world units): a player inside this ring at wind-up elapse eats the hit