Compare commits

...

4 Commits

Author SHA1 Message Date
kronic 4febf3dfe2 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>
2026-07-12 13:53:48 -07:00
kronic f271d5f47f Docs: Geyser Build Spec + session log Part Q + roadmap tick + CLAUDE.md gotcha — Phase 1.5b bundle 3
Blight geyser (bundle 3) shipped review-first. Records the design-review fold
(2 HIGH: no edge-detect on the replicated tick; invert the invalid-guard for a
baked scheduled-tick so a born-0 can't storm) and the new CLAUDE.md tick-sentinel
corollary for baked [GhostField] scheduled-ticks.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 13:53:22 -07:00
kronic 649f656833 Decals: explosion scorch pool + cover damage-cracks + room-arena scars (Phase 1.5b bundle 2)
All procedural, client-only, observe-only in PresentationSystemGroup, zero
netcode surface (read existing replicated state). Shared FeedbackFx decal
primitives (scorch blob + crack star meshes + transparent decal material).
ScorchDecalSystem: static RequestScorch queue -> pooled fading discs
(mirrors DynamicLightSystem), wired from the barrel boom, reusable by the
geyser. CoverDamageSystem: crack accretion keyed on BlightClutter.Remaining
(Variant 4) + proximity-gated shatter-scorch. RoomDressingSystem: persistent
arena scars on a distinct hash sub-stream, torn down with dressing. Knobs in
DecalConfig. Cover cracks use decals (not URPMaterialPropertyBaseColor) because
the Synty prop shader is not Hybrid-Per-Instance.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 12:58:33 -07:00
kronic b3ef846fb8 Docs: session log Part P + roadmap tick + CLAUDE.md gotcha — Phase 1.5b decals bundle shipped
Bundle 2 (decals): explosion scorch pool, cover damage-cracks, room-arena
scars — all procedural, client-only, zero netcode. Records the pivotal
finding that a stock Synty prop shadergraph's _BaseColor is Unity-Per-Material
(not Hybrid-Per-Instance), so URPMaterialPropertyBaseColor darkening silently
no-ops on cover — the fork to decal quads.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 11:04:43 -07:00
32 changed files with 1604 additions and 32 deletions
+177
View File
@@ -0,0 +1,177 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!1 &2427603878820464664
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 8054832990839364730}
- component: {fileID: 7899507094755352317}
- component: {fileID: 4642700531704847346}
m_Layer: 0
m_Name: Model
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!4 &8054832990839364730
Transform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 2427603878820464664}
serializedVersion: 2
m_LocalRotation: {x: 0, y: -0, z: -0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 2.2, y: 2.2, z: 2.2}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 3572766465862231365}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!33 &7899507094755352317
MeshFilter:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 2427603878820464664}
m_Mesh: {fileID: 4300000, guid: a52e2d101bf32694d9012060774e97fd, type: 3}
--- !u!23 &4642700531704847346
MeshRenderer:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 2427603878820464664}
m_Enabled: 1
m_CastShadows: 1
m_ReceiveShadows: 1
m_DynamicOccludee: 1
m_StaticShadowCaster: 0
m_MotionVectors: 1
m_LightProbeUsage: 1
m_ReflectionProbeUsage: 1
m_RayTracingMode: 2
m_RayTraceProcedural: 0
m_RayTracingAccelStructBuildFlagsOverride: 0
m_RayTracingAccelStructBuildFlags: 1
m_SmallMeshCulling: 1
m_ForceMeshLod: -1
m_MeshLodSelectionBias: 0
m_RenderingLayerMask: 1
m_RendererPriority: 0
m_Materials:
- {fileID: 2100000, guid: 1da6f4366b5f0ed40b87287df95b5a81, type: 2}
m_StaticBatchInfo:
firstSubMesh: 0
subMeshCount: 0
m_StaticBatchRoot: {fileID: 0}
m_ProbeAnchor: {fileID: 0}
m_LightProbeVolumeOverride: {fileID: 0}
m_ScaleInLightmap: 1
m_ReceiveGI: 1
m_PreserveUVs: 0
m_IgnoreNormalsForChartDetection: 0
m_ImportantGI: 0
m_StitchLightmapSeams: 1
m_SelectedEditorRenderState: 3
m_MinimumChartSize: 4
m_AutoUVMaxDistance: 0.5
m_AutoUVMaxAngle: 89
m_LightmapParameters: {fileID: 0}
m_GlobalIlluminationMeshLod: 0
m_SortingLayerID: 0
m_SortingLayer: 0
m_SortingOrder: 0
m_MaskInteraction: 0
m_AdditionalVertexStreams: {fileID: 0}
--- !u!1 &3885353946372160549
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 3572766465862231365}
- component: {fileID: 9053853372340598254}
- component: {fileID: 6834786618115927220}
- component: {fileID: 5589603174154228860}
m_Layer: 0
m_Name: Geyser
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!4 &3572766465862231365
Transform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 3885353946372160549}
serializedVersion: 2
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children:
- {fileID: 8054832990839364730}
m_Father: {fileID: 0}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!114 &9053853372340598254
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 3885353946372160549}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: c16549610bfe4458aa9389201d072bb6, type: 3}
m_Name:
m_EditorClassIdentifier: Unity.Entities.Hybrid::Unity.Entities.Hybrid.Baking.LinkedEntityGroupAuthoring
--- !u!114 &6834786618115927220
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 3885353946372160549}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 7c79d771cedb4794bf100ce60df5f764, type: 3}
m_Name:
m_EditorClassIdentifier: Unity.NetCode.Authoring.Hybrid::Unity.NetCode.GhostAuthoringComponent
HasOwner: 0
SupportAutoCommandTarget: 1
TrackInterpolationDelay: 0
GhostGroup: 0
UsePreSerialization: 0
UseSingleBaseline: 0
RollbackPredictedSpawnedGhostState: 0
RollbackPredictionOnStructuralChanges: 1
DefaultGhostMode: 0
SupportedGhostModes: 3
OptimizationMode: 0
Importance: 1
MaxSendRate: 0
prefabId: 8565e5eb00679fb45b8b7dac1e2ae9f3
--- !u!114 &5589603174154228860
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 3885353946372160549}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: e45c85bf47692824eacc671035712f38, type: 3}
m_Name:
m_EditorClassIdentifier: ProjectM.Authoring::ProjectM.Authoring.GeyserAuthoring
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 8425e38ad7271e44693e65e8d574a993
PrefabImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -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
@@ -0,0 +1,177 @@
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 COVER DAMAGE decals (Phase 1.5b bundle 2 — decals). Destructible cover = a
/// <see cref="BlightClutter"/> ghost (Variant 4, Remaining 8 → 0; see the Destructible_Cover_Build_Spec). This
/// observe-only <see cref="PresentationSystemGroup"/> <see cref="SystemBase"/> edge-detects the replicated
/// <see cref="BlightClutter.Remaining"/> on cover ghosts and accretes jagged CRACK decals on the ground around
/// the rock as it is carved (<see cref="WorldFeedbackSystem"/> already fires the chip puff / SFX / camera-punch
/// on the same edge — we only add the persistent cracks). On despawn (the rock shattered NEAR the local player)
/// it drops a small ground scorch via <see cref="ScorchDecalSystem.RequestScorch"/> and releases the piece's
/// cracks. The despawn scorch is PROXIMITY-GATED (mirrors WorldFeedbackSystem) so a room-teardown / region-transit
/// despawn storm — every cover ghost dropped at once, far off-camera — can't spray scorches or exhaust the pool.
/// <para>
/// Pure procedural decal quads — NO material/shader dependency. This is deliberate: cover's Synty prop shader
/// declares <c>_BaseColor</c> as Unity-Per-Material (NOT Hybrid-Per-Instance), so the
/// <c>URPMaterialPropertyBaseColor</c> darken that <see cref="EnemyHitFlashSystem"/> uses on the DOTS-authored
/// AnimatedLitShader would silently no-op here. Decals sidestep that entirely and read as literal damage.
/// </para>
/// No new component / [GhostField], no server work. Knobs live in <see cref="DecalConfig"/>.
/// </summary>
[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)]
[UpdateInGroup(typeof(PresentationSystemGroup))]
public partial class CoverDamageSystem : SystemBase
{
const byte CoverVariant = 4;
class Piece
{
public int LastRemaining;
public int MaxRemaining;
public float3 Pos;
public readonly List<GameObject> Cracks = new();
}
readonly Dictionary<Entity, Piece> _tracked = new();
readonly HashSet<Entity> _seen = new();
readonly List<Entity> _stale = new();
GameObject _root;
Material _mat;
Mesh[] _crackMeshes; // pre-built variants, reused across all cracks (no per-crack Mesh alloc/leak)
MaterialPropertyBlock _mpb;
static readonly int ColorId = Shader.PropertyToID("_Color");
protected override void OnCreate()
{
_crackMeshes = new Mesh[6];
for (int i = 0; i < _crackMeshes.Length; i++) _crackMeshes[i] = BuildCrackMesh(2 + (i % 2), i + 1);
_mpb = new MaterialPropertyBlock();
}
protected override void OnDestroy()
{
if (_root != null) Object.Destroy(_root);
if (_mat != null) Object.Destroy(_mat);
if (_crackMeshes != null)
for (int i = 0; i < _crackMeshes.Length; i++)
if (_crackMeshes[i] != null) Object.Destroy(_crackMeshes[i]);
}
protected override void OnUpdate()
{
if (UnityEngine.SceneManagement.SceneManager.GetActiveScene().name != "Game") return;
if (!DecalConfig.Enabled) { if (_tracked.Count > 0) ClearAll(); return; }
EntityManager.CompleteDependencyBeforeRO<BlightClutter>();
EntityManager.CompleteDependencyBeforeRO<LocalTransform>();
if (_root == null)
{
_root = new GameObject("~CoverDecals");
Object.DontDestroyOnLoad(_root);
_mat = MakeDecalMaterial("CoverCrackDecal");
}
// Local player position for the shatter-scorch proximity gate (mirrors WorldFeedbackSystem).
bool haveLocal = false;
float3 localPos = default;
foreach (var xf in SystemAPI.Query<RefRO<LocalTransform>>().WithAll<GhostOwnerIsLocal, PlayerTag>())
{
localPos = xf.ValueRO.Position;
haveLocal = true;
}
_seen.Clear();
foreach (var (clutter, xf, e) in
SystemAPI.Query<RefRO<BlightClutter>, RefRO<LocalTransform>>().WithEntityAccess())
{
if (clutter.ValueRO.Variant != CoverVariant) continue; // cover only
_seen.Add(e);
int remaining = clutter.ValueRO.Remaining;
float3 pos = xf.ValueRO.Position;
if (!_tracked.TryGetValue(e, out var piece))
{
// First sight: cache the baseline; a joiner mid-carve keeps its already-damaged look implicitly.
_tracked[e] = new Piece { LastRemaining = remaining, MaxRemaining = math.max(1, remaining), Pos = pos };
continue;
}
piece.Pos = pos;
if (remaining < piece.LastRemaining)
{
// accrete cracks proportional to damage taken; cap at CoverCrackMaxCount.
float dmgFrac = 1f - remaining / (float)piece.MaxRemaining;
int want = math.clamp((int)math.round(DecalConfig.CoverCrackMaxCount * dmgFrac), 0, DecalConfig.CoverCrackMaxCount);
while (piece.Cracks.Count < want) SpawnCrack(piece, piece.Cracks.Count);
piece.LastRemaining = remaining;
}
}
// ---- prune shattered cover ----
if (_tracked.Count != _seen.Count)
{
float rangeSq = WorldFeelConfig.ProximityRange * WorldFeelConfig.ProximityRange;
_stale.Clear();
foreach (var kv in _tracked) if (!_seen.Contains(kv.Key)) _stale.Add(kv.Key);
for (int i = 0; i < _stale.Count; i++)
{
var piece = _tracked[_stale[i]];
// Only a real shatter NEAR the local player leaves a scorch; a teardown / region-transit despawn
// storm (all cover dropped at once, off-camera) stays silent — no spray, no pool exhaustion.
if (DecalConfig.CoverShatterScorch && haveLocal && math.distancesq(piece.Pos, localPos) <= rangeSq)
ScorchDecalSystem.RequestScorch((Vector3)piece.Pos, 1.6f);
DestroyCracks(piece);
_tracked.Remove(_stale[i]);
}
}
}
void SpawnCrack(Piece piece, int index)
{
uint seed = (uint)math.max(1, index * 131 + (int)(math.abs(piece.Pos.x) * 7f + math.abs(piece.Pos.z) * 13f));
var rng = new Unity.Mathematics.Random(seed);
var go = new GameObject("Crack");
go.transform.SetParent(_root.transform, false);
go.AddComponent<MeshFilter>().sharedMesh = _crackMeshes[(index + (int)(seed % 3u)) % _crackMeshes.Length];
var mr = go.AddComponent<MeshRenderer>();
mr.sharedMaterial = _mat;
mr.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off;
mr.receiveShadows = false;
mr.lightProbeUsage = UnityEngine.Rendering.LightProbeUsage.Off;
_mpb.SetColor(ColorId, DecalConfig.CoverCrackColor);
mr.SetPropertyBlock(_mpb);
// scatter as GROUND cracks radiating from the rock's footprint (avoids z-fighting the rock mesh)
float ang = rng.NextFloat(0f, math.PI * 2f);
float rad = rng.NextFloat(0.8f, 1.8f);
float scale = rng.NextFloat(0.9f, 1.6f);
go.transform.SetPositionAndRotation(
new Vector3(piece.Pos.x + math.cos(ang) * rad, 0.06f + 0.003f * index, piece.Pos.z + math.sin(ang) * rad),
Quaternion.Euler(0f, rng.NextFloat(0f, 360f), 0f));
go.transform.localScale = new Vector3(scale, 1f, scale);
piece.Cracks.Add(go);
}
void DestroyCracks(Piece piece)
{
for (int c = 0; c < piece.Cracks.Count; c++)
if (piece.Cracks[c] != null) Object.Destroy(piece.Cracks[c]);
piece.Cracks.Clear();
}
void ClearAll()
{
foreach (var kv in _tracked) DestroyCracks(kv.Value);
_tracked.Clear();
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 63769d219acf0b248b321ef76bdf4189
@@ -0,0 +1,68 @@
using UnityEngine;
namespace ProjectM.Client
{
/// <summary>
/// Live-tunable knobs for the client-only DECALS slice (Phase 1.5b bundle 2: explosion scorch pool, cover
/// damage cracks, room-arena scars). A static bridge (mirrors <see cref="WorldFeelConfig"/> / <see cref="FeelConfig"/>)
/// so values can be poked at runtime via MCP <c>execute_code</c> without a recompile
/// (e.g. <c>ProjectM.Client.DecalConfig.ScorchColor = ...;</c>). Read ONLY by the decal presentation systems
/// (<see cref="ScorchDecalSystem"/>, <see cref="CoverDamageSystem"/>) and <see cref="RoomDressingSystem"/> — all
/// managed, main-thread. NEVER read from a [BurstCompile] system (managed-static + Color-in-Burst hazards).
/// <see cref="ResetDefaults"/> re-stamps on play-enter via [RuntimeInitializeOnLoadMethod] because statics survive
/// fast-enter-playmode reloads (else a poked value leaks across play-enters).
/// </summary>
public static class DecalConfig
{
/// <summary>Master gate for every decal (scorch pool + cover cracks + room scars).</summary>
public static bool Enabled;
// ---- explosion scorch pool (ScorchDecalSystem) ----
/// <summary>Scorch disc radius as a multiple of the explosion radius passed to RequestScorch.</summary>
public static float ScorchRadiusMul;
/// <summary>Seconds a fresh scorch holds at full strength before it starts fading.</summary>
public static float ScorchHoldSec;
/// <summary>Seconds a scorch fades from full to gone (then returns to the pool).</summary>
public static float ScorchFadeSec;
/// <summary>Peak scorch tint (dark char); the alpha is the peak opacity it fades from.</summary>
public static Color ScorchColor;
/// <summary>Max live scorch decals; the oldest is recycled past this.</summary>
public static int ScorchPoolCap;
// ---- cover damage cracks (CoverDamageSystem) ----
/// <summary>Crack-decal tint (dark).</summary>
public static Color CoverCrackColor;
/// <summary>Max crack decals accreted around one cover rock as it is carved to 0.</summary>
public static int CoverCrackMaxCount;
/// <summary>Drop a small ground scorch/debris mark where a cover rock shattered.</summary>
public static bool CoverShatterScorch;
// ---- room-arena scars (RoomDressingSystem) ----
/// <summary>Persistent crack/scorch detail decals laid at the room arena centre (0 = off).</summary>
public static int RoomArenaDecalCount;
/// <summary>Room-arena scar tint (dark, subtle).</summary>
public static Color RoomDecalColor;
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
public static void ResetDefaults()
{
Enabled = true;
// scorch pool — slightly inside the blast radius so it reads as the burn, not the danger ring
ScorchRadiusMul = 0.85f;
ScorchHoldSec = 4f;
ScorchFadeSec = 6f;
ScorchColor = new Color(0.05f, 0.04f, 0.03f, 0.85f); // near-black char, mostly opaque at peak
ScorchPoolCap = 24;
// cover cracks
CoverCrackColor = new Color(0.06f, 0.05f, 0.05f, 0.8f);
CoverCrackMaxCount = 3;
CoverShatterScorch = true;
// room-arena scars
RoomArenaDecalCount = 3;
RoomDecalColor = new Color(0.08f, 0.07f, 0.06f, 0.5f); // subtle
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 95c95df641b3d1a4cbce24348a716d66
@@ -109,5 +109,100 @@ namespace ProjectM.Client
if (clip == null) return;
AudioSource.PlayClipAtPoint(clip, pos, vol * GameVolume.Sfx);
}
// ---- ground DECAL primitives (Bundle 2 — explosion scorch, cover cracks, room scars) ----
// Transparent unlit ground-decal material: Sprites/Default honours vertex colour × the per-renderer _Color
// MPB, blends alpha, is double-sided (Cull Off) and writes no depth (ZWrite Off) — so flat, overlapping
// ground decals never z-fight and never cast/receive shadows. Same shader family as the fuse disc.
public static Material MakeDecalMaterial(string name = "GroundDecal")
{
Shader sh = Shader.Find("Sprites/Default");
if (sh == null) sh = Shader.Find("Universal Render Pipeline/Particles/Unlit");
if (sh == null) sh = Shader.Find("Unlit/Transparent");
return new Material(sh) { name = name, renderQueue = 3000 }; // Transparent
}
// Irregular soft radial disc on the XZ plane (already flat — scale x/z, spin about Y): vertex alpha 1 at the
// centre fading to 0 at a JITTERED rim, so it reads as an organic scorch blob with NO hard rectangle edge
// (the feathered rim is what keeps it from ghosting over fog the way an opaque quad did). White vertex colour
// so the caller's material/MPB tint (dark char) shows through.
public static Mesh BuildScorchMesh(int segments = 24, float jitter = 0.3f, int seed = 1)
{
if (segments < 6) segments = 6;
var rng = new System.Random(seed * 6151 + 13);
var m = new Mesh { name = "ScorchDecal" };
var v = new Vector3[segments + 1];
var col = new Color[segments + 1];
var tris = new int[segments * 3];
v[0] = Vector3.zero;
col[0] = new Color(1f, 1f, 1f, 1f);
for (int i = 0; i < segments; i++)
{
float a = i / (float)segments * Mathf.PI * 2f;
float r = 1f - jitter * (float)rng.NextDouble();
v[i + 1] = new Vector3(Mathf.Cos(a) * r, 0f, Mathf.Sin(a) * r);
col[i + 1] = new Color(1f, 1f, 1f, 0f); // transparent feathered rim
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.colors = col; m.triangles = tris;
m.RecalculateBounds();
return m;
}
// A jagged crack star on the XZ plane: `spokes` thin tapered triangles radiating from the centre at random
// angles/lengths, opaque at the base and feathered to nothing at the tip. Reads as surface cracking, visually
// distinct from the scorch blob. White vertex colour; caller tints dark and scales it to the piece.
public static Mesh BuildCrackMesh(int spokes = 3, int seed = 1)
{
if (spokes < 1) spokes = 1;
var rng = new System.Random(seed * 9277 + 5);
var m = new Mesh { name = "CrackDecal" };
var v = new Vector3[spokes * 3];
var col = new Color[spokes * 3];
var tris = new int[spokes * 3];
for (int s = 0; s < spokes; s++)
{
float a = (s / (float)spokes) * Mathf.PI * 2f + (float)(rng.NextDouble() - 0.5) * 1.2f;
float len = 0.6f + 0.4f * (float)rng.NextDouble();
float w = 0.06f + 0.05f * (float)rng.NextDouble();
var dir = new Vector3(Mathf.Cos(a), 0f, Mathf.Sin(a));
var perp = new Vector3(-dir.z, 0f, dir.x) * w;
int b = s * 3;
v[b] = perp; v[b + 1] = -perp; v[b + 2] = dir * len;
col[b] = new Color(1f, 1f, 1f, 1f);
col[b + 1] = new Color(1f, 1f, 1f, 1f);
col[b + 2] = new Color(1f, 1f, 1f, 0f); // feathered tip
tris[b] = b; tris[b + 1] = b + 2; tris[b + 2] = b + 1;
}
m.vertices = v; m.colors = col; m.triangles = tris;
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
@@ -14,6 +14,11 @@ namespace ProjectM.Client
/// (0xC17). Deterministic from the replicated run seed: every co-op client (and a late-joiner) sees the
/// same floor. Torn down when the room changes or the run ends. Colliders + rigidbodies are stripped on
/// spawn — the aim-reticle raycast and the physics world must never see dressing. Never mutates the sim.
/// <para>
/// Bundle 2 (decals): also lays a few persistent feathered crack/scorch DETAIL scars at the arena centre
/// (higher-frequency detail the baked ground splat can't resolve), on its own hash sub-stream, parented to
/// the same dressing root so they tear down together. Base POIs are skipped — Part O baked their wear in.
/// </para>
/// </summary>
[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)]
[UpdateInGroup(typeof(PresentationSystemGroup))]
@@ -22,6 +27,13 @@ namespace ProjectM.Client
Transform _root;
uint _activeKey;
// Bundle 2 arena-scar decal assets (reused across rebuilds; the scar GameObjects are children of _root and
// torn down each room change, these shared assets persist until OnDestroy).
Material _decalMat;
Mesh[] _decalMeshes;
MaterialPropertyBlock _decalMpb;
static readonly int DecalColorId = Shader.PropertyToID("_Color");
protected override void OnStartRunning()
{
if (_root == null) _root = new GameObject("~RoomDressing").transform;
@@ -30,6 +42,10 @@ namespace ProjectM.Client
protected override void OnDestroy()
{
if (_root != null) Object.Destroy(_root.gameObject);
if (_decalMat != null) Object.Destroy(_decalMat);
if (_decalMeshes != null)
for (int i = 0; i < _decalMeshes.Length; i++)
if (_decalMeshes[i] != null) Object.Destroy(_decalMeshes[i]);
}
protected override void OnUpdate()
@@ -106,6 +122,51 @@ namespace ProjectM.Client
foreach (var col in go.GetComponentsInChildren<Collider>(true)) Object.Destroy(col);
foreach (var rb in go.GetComponentsInChildren<Rigidbody>(true)) Object.Destroy(rb);
}
// Bundle 2 arena scars: distinct hash sub-stream (0xD2E56) so they never co-locate with the prop
// scatter (0xD2E55); clustered around the arena centre. Persistent (no fade); torn down with dressing.
if (DecalConfig.Enabled && DecalConfig.RoomArenaDecalCount > 0)
{
EnsureDecalAssets();
var srng = new Unity.Mathematics.Random(RunMapMath.Hash(ri.RunSeed, (uint)(ri.CurrentRoom + 1), 0xD2E56u) | 1u);
for (int i = 0; i < DecalConfig.RoomArenaDecalCount; i++)
SpawnArenaScar(origin, i, ref srng);
}
}
void EnsureDecalAssets()
{
if (_decalMat == null) _decalMat = FeedbackFx.MakeDecalMaterial("RoomScarDecal");
if (_decalMpb == null) _decalMpb = new MaterialPropertyBlock();
if (_decalMeshes == null)
{
_decalMeshes = new Mesh[4];
_decalMeshes[0] = FeedbackFx.BuildScorchMesh(20, 0.32f, 11);
_decalMeshes[1] = FeedbackFx.BuildScorchMesh(20, 0.34f, 23);
_decalMeshes[2] = FeedbackFx.BuildCrackMesh(3, 31);
_decalMeshes[3] = FeedbackFx.BuildCrackMesh(2, 47);
}
}
void SpawnArenaScar(float3 origin, int index, ref Unity.Mathematics.Random rng)
{
var go = new GameObject("ArenaScar");
go.transform.SetParent(_root, false);
go.AddComponent<MeshFilter>().sharedMesh = _decalMeshes[rng.NextInt(0, _decalMeshes.Length)];
var mr = go.AddComponent<MeshRenderer>();
mr.sharedMaterial = _decalMat;
mr.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off;
mr.receiveShadows = false;
mr.lightProbeUsage = UnityEngine.Rendering.LightProbeUsage.Off;
_decalMpb.SetColor(DecalColorId, DecalConfig.RoomDecalColor);
mr.SetPropertyBlock(_decalMpb);
float ang = rng.NextFloat(0f, math.PI * 2f);
float rad = rng.NextFloat(0f, 4.5f); // clustered around the arena centre where the fight happens
float scale = rng.NextFloat(1.4f, 3.0f);
go.transform.SetPositionAndRotation(
new Vector3(origin.x + math.cos(ang) * rad, 0.05f + 0.003f * index, origin.z + math.sin(ang) * rad),
Quaternion.Euler(0f, rng.NextFloat(0f, 360f), 0f));
go.transform.localScale = new Vector3(scale, 1f, scale);
}
}
}
@@ -0,0 +1,144 @@
using System.Collections.Generic;
using Unity.Entities;
using UnityEngine;
using static ProjectM.Client.FeedbackFx;
namespace ProjectM.Client
{
/// <summary>
/// Client-only EXPLOSION SCORCH pool (Phase 1.5b bundle 2 — decals). A fading pool of flat ground scorch discs
/// dropped at boom sites via the static <see cref="RequestScorch"/> queue — the exact request-drain idiom of
/// <see cref="DynamicLightSystem.RequestFlash"/>: <see cref="WorldFeedbackSystem"/> enqueues at a barrel
/// detonation (same thread, same <see cref="PresentationSystemGroup"/>), we drain once per frame. Each decal
/// HOLDS then FADES its alpha through a single reused <see cref="MaterialPropertyBlock"/> (per-renderer copy →
/// independent lifetimes, no shared-material bleed) and returns to the pool. Pooled GameObjects live under a
/// private DontDestroyOnLoad root (never scene-saved), scene-gated to "Game", null-guarded across scene reloads.
/// Observe-only — never touches the sim, no netcode, no [GhostField]. Reusable by the future Blight-geyser hazard.
/// Knobs live in <see cref="DecalConfig"/>.
/// </summary>
[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)]
[UpdateInGroup(typeof(PresentationSystemGroup))]
public partial class ScorchDecalSystem : SystemBase
{
struct ScorchRequest { public Vector3 Pos; public float Radius; }
class Active { public GameObject Go; public MeshRenderer Renderer; public float SpawnTime; public float PeakAlpha; }
// Static request queue: WorldFeedbackSystem (same thread, PresentationSystemGroup) enqueues, we drain.
static readonly List<ScorchRequest> Pending = new List<ScorchRequest>();
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
static void ResetStatics() { Pending.Clear(); }
/// <summary>Queue a ground scorch at a detonation. <paramref name="radius"/> is the explosion radius; the
/// decal is scaled by <see cref="DecalConfig.ScorchRadiusMul"/>.</summary>
public static void RequestScorch(Vector3 pos, float radius)
{
if (Pending.Count < 64) Pending.Add(new ScorchRequest { Pos = pos, Radius = radius });
}
GameObject _root;
Material _mat;
Mesh[] _meshes; // a few pre-jittered variants for organic variety (reused, no per-decal alloc)
MaterialPropertyBlock _mpb;
readonly List<Active> _active = new List<Active>();
readonly Stack<GameObject> _pool = new Stack<GameObject>();
static readonly int ColorId = Shader.PropertyToID("_Color");
protected override void OnCreate()
{
_meshes = new Mesh[4];
for (int i = 0; i < _meshes.Length; i++) _meshes[i] = BuildScorchMesh(24, 0.3f, i + 1);
_mpb = new MaterialPropertyBlock();
}
protected override void OnDestroy()
{
if (_root != null) Object.Destroy(_root);
if (_mat != null) Object.Destroy(_mat);
if (_meshes != null)
for (int i = 0; i < _meshes.Length; i++)
if (_meshes[i] != null) Object.Destroy(_meshes[i]);
}
protected override void OnUpdate()
{
if (UnityEngine.SceneManagement.SceneManager.GetActiveScene().name != "Game") { Pending.Clear(); return; }
if (!DecalConfig.Enabled)
{
Pending.Clear();
if (_root != null && _root.activeSelf) _root.SetActive(false);
return;
}
if (_root == null)
{
_root = new GameObject("~ScorchDecals");
Object.DontDestroyOnLoad(_root);
_mat = MakeDecalMaterial("ScorchDecal");
}
if (!_root.activeSelf) _root.SetActive(true);
float now = UnityEngine.Time.time;
// ---- drain new scorch requests ----
for (int i = 0; i < Pending.Count; i++)
{
if (_active.Count >= Mathf.Max(1, DecalConfig.ScorchPoolCap)) RecycleOldest();
var req = Pending[i];
var go = Rent();
float scale = Mathf.Max(0.5f, req.Radius * DecalConfig.ScorchRadiusMul);
// small per-decal y-stagger avoids coplanar transparent z-fight between overlapping scorches
float y = 0.045f + 0.002f * (_active.Count % 8);
float yaw = Mathf.Repeat(req.Pos.x * 53f + req.Pos.z * 31f, 360f);
go.transform.SetPositionAndRotation(new Vector3(req.Pos.x, y, req.Pos.z), Quaternion.Euler(0f, yaw, 0f));
go.transform.localScale = new Vector3(scale, 1f, scale);
int mi = Mathf.Abs(Mathf.RoundToInt(req.Pos.x + req.Pos.z)) % _meshes.Length;
go.GetComponent<MeshFilter>().sharedMesh = _meshes[mi];
_active.Add(new Active { Go = go, Renderer = go.GetComponent<MeshRenderer>(), SpawnTime = now, PeakAlpha = DecalConfig.ScorchColor.a });
}
Pending.Clear();
// ---- hold + fade + recycle ----
float hold = Mathf.Max(0f, DecalConfig.ScorchHoldSec);
float fade = Mathf.Max(0.01f, DecalConfig.ScorchFadeSec);
var baseCol = DecalConfig.ScorchColor;
for (int i = _active.Count - 1; i >= 0; i--)
{
var a = _active[i];
if (a.Go == null) { _active.RemoveAt(i); continue; } // scene reload can null pooled objects
float age = now - a.SpawnTime;
float alpha = age <= hold ? a.PeakAlpha : a.PeakAlpha * Mathf.Clamp01(1f - (age - hold) / fade);
if (alpha <= 0.001f) { Return(a.Go); _active.RemoveAt(i); continue; }
_mpb.SetColor(ColorId, new Color(baseCol.r, baseCol.g, baseCol.b, alpha));
a.Renderer.SetPropertyBlock(_mpb);
}
}
GameObject Rent()
{
GameObject go = null;
while (_pool.Count > 0) { go = _pool.Pop(); if (go != null) break; }
if (go == null)
{
go = new GameObject("Scorch");
go.transform.SetParent(_root.transform, false);
go.AddComponent<MeshFilter>();
var mr = go.AddComponent<MeshRenderer>();
mr.sharedMaterial = _mat;
mr.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off;
mr.receiveShadows = false;
mr.lightProbeUsage = UnityEngine.Rendering.LightProbeUsage.Off;
}
go.SetActive(true);
return go;
}
void Return(GameObject go) { if (go != null) { go.SetActive(false); _pool.Push(go); } }
void RecycleOldest()
{
if (_active.Count == 0) return;
Return(_active[0].Go);
_active.RemoveAt(0);
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 225e2db8aab51ad47847beba0f4e0d7f
@@ -139,6 +139,7 @@ namespace ProjectM.Client
PlayClip(_boomClip, (Vector3)c.Pos, 0.8f);
PrototypeCameraRig.PunchFov(WorldFeelConfig.ClearFovKick * 2.2f, 90f);
PrototypeCameraRig.AddShake(WorldFeelConfig.ClearShake * 2.5f);
ScorchDecalSystem.RequestScorch((Vector3)c.Pos, Tuning.BarrelExplodeRadius); // bundle 2: charred ground mark at the boom
}
else
{
@@ -198,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
+15
View File
@@ -344,6 +344,7 @@ GameObject:
- component: {fileID: 17637047}
- component: {fileID: 17637046}
- component: {fileID: 17637048}
- component: {fileID: 17637049}
m_Layer: 0
m_Name: ResourceFieldSpawner
m_TagString: Untagged
@@ -396,6 +397,20 @@ MonoBehaviour:
ClutterPrefab: {fileID: 3885353946372160549, guid: 6ffeddcc4482c0f44880dc9a555884dd, type: 3}
Count: 14
Radius: 14
--- !u!114 &17637049
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 17637045}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: e5d1544d6afe18245b563de599240493, type: 3}
m_Name:
m_EditorClassIdentifier: ProjectM.Authoring::ProjectM.Authoring.GeyserFieldSpawnerAuthoring
GeyserPrefab: {fileID: 3885353946372160549, guid: 8425e38ad7271e44693e65e8d574a993, type: 3}
Count: 2
--- !u!1 &25399149
GameObject:
m_ObjectHideFlags: 0
@@ -0,0 +1,149 @@
using NUnit.Framework;
using ProjectM.Server;
using ProjectM.Simulation;
using Unity.Core;
using Unity.Entities;
using Unity.Mathematics;
using Unity.NetCode;
using Unity.Transforms;
namespace ProjectM.Tests
{
/// <summary>
/// Plain-Entities EditMode tests for <see cref="GeyserEruptSystem"/> (Geyser_Build_Spec; design-review
/// wf_900e9965-8f0). Pins the review-hardened contract: an ELAPSED geyser radius-damages LIVING players AND
/// enemies (friendly fire) with SourceNetworkId = -1, RESCHEDULES NextEruptTick = now + period (never +=, never
/// the 0 sentinel), and is NEVER destroyed (opposite of the barrel). The load-bearing HIGH regression: a geyser
/// that ships an UNSTAMPED NextEruptTick (0) must NEVER erupt — the inverted guard lazy-stamps it born-correct
/// instead of the party-wiping per-tick barrage that copying the barrel's invalid-guard verbatim would cause.
/// </summary>
public class GeyserEruptSystemTests
{
const uint T0 = 1000;
static (World world, SimulationSystemGroup group) MakeWorld(string name, uint serverTick = T0)
{
var world = new World(name);
var group = world.GetOrCreateSystemManaged<SimulationSystemGroup>();
group.AddSystemToUpdateList(world.GetOrCreateSystem<GeyserEruptSystem>());
group.SortSystems();
world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f));
var nt = world.EntityManager.CreateEntity(typeof(NetworkTime));
world.EntityManager.SetComponentData(nt, new NetworkTime { ServerTick = new NetworkTick(serverTick) });
return (world, group);
}
static Entity MakeGeyser(EntityManager em, float3 pos, uint nextEruptTick)
{
var e = em.CreateEntity();
em.AddComponentData(e, LocalTransform.FromPosition(pos));
em.AddComponentData(e, new Geyser { NextEruptTick = nextEruptTick });
return e;
}
static Entity MakePlayer(EntityManager em, float3 pos, float health, byte region)
{
var e = em.CreateEntity();
em.AddComponentData(e, LocalTransform.FromPosition(pos));
em.AddComponentData(e, new PlayerTag());
em.AddComponentData(e, new Health { Current = health, Max = 130f });
em.AddComponentData(e, new RegionTag { Region = region });
em.AddBuffer<DamageEvent>(e);
return e;
}
static Entity MakeEnemy(EntityManager em, float3 pos, float health)
{
var e = em.CreateEntity();
em.AddComponentData(e, LocalTransform.FromPosition(pos));
em.AddComponentData(e, new EnemyTag());
em.AddComponentData(e, new Health { Current = health, Max = 30f });
em.AddBuffer<DamageEvent>(e);
return e;
}
[Test]
public void Elapsed_Geyser_Damages_Living_Both_Sides_Reschedules_And_Survives()
{
var (world, group) = MakeWorld("EruptBoth");
using (world)
{
var em = world.EntityManager;
var geyser = MakeGeyser(em, new float3(10, 0, 10), nextEruptTick: T0); // due exactly now
var player = MakePlayer(em, new float3(11, 0, 10), health: 100f, region: RegionId.Expedition);
var enemy = MakeEnemy(em, new float3(10, 0, 12), health: 30f);
group.Update();
Assert.IsTrue(em.Exists(geyser), "A geyser is PERMANENT — it is never destroyed on erupt.");
var pd = em.GetBuffer<DamageEvent>(player);
Assert.AreEqual(1, pd.Length, "Friendly fire: the living expedition player in radius is hit.");
Assert.AreEqual(Tuning.GeyserEruptDamage, pd[0].Amount);
Assert.AreEqual(-1, pd[0].SourceNetworkId, "Environment convention: never a player id.");
Assert.AreNotEqual(0u, pd[0].SourceTick, "SourceTick stamped at NOW (the authored-tick contract).");
Assert.AreEqual(1, em.GetBuffer<DamageEvent>(enemy).Length, "The living enemy in radius is hit too (bait mechanic).");
Assert.AreEqual(TickUtil.NonZero(T0 + Tuning.GeyserPeriodTicks), em.GetComponentData<Geyser>(geyser).NextEruptTick,
"Reschedule = now + period (never += ; never the 0 sentinel).");
}
}
[Test]
public void Unstamped_Geyser_Never_Erupts_And_Is_Lazy_Stamped()
{
// The HIGH regression: a baked-0 NextEruptTick at a high serverTick must NOT storm (the inverted guard).
var (world, group) = MakeWorld("UnstampedNoStorm", serverTick: 5000);
using (world)
{
var em = world.EntityManager;
var geyser = MakeGeyser(em, new float3(10, 0, 10), nextEruptTick: 0u); // unstamped
var player = MakePlayer(em, new float3(10, 0, 10), health: 130f, region: RegionId.Expedition);
group.Update();
Assert.AreEqual(0, em.GetBuffer<DamageEvent>(player).Length,
"An unstamped (0) geyser must NEVER erupt — no per-tick barrage.");
Assert.AreEqual(TickUtil.NonZero(5000u + Tuning.GeyserPeriodTicks), em.GetComponentData<Geyser>(geyser).NextEruptTick,
"0 is lazy-stamped born-correct (now + period) instead of erupting.");
Assert.IsTrue(em.Exists(geyser));
}
}
[Test]
public void Dead_Player_And_Out_Of_Radius_Get_Nothing()
{
var (world, group) = MakeWorld("EruptFilters");
using (world)
{
var em = world.EntityManager;
MakeGeyser(em, new float3(10, 0, 10), nextEruptTick: T0);
var deadPlayer = MakePlayer(em, new float3(10.5f, 0, 10), health: 0f, region: RegionId.Expedition);
var basePlayer = MakePlayer(em, new float3(10.5f, 0, 10), health: 100f, region: RegionId.Base);
var farEnemy = MakeEnemy(em, new float3(10 + Tuning.GeyserEruptRadius + 1f, 0, 10), health: 30f);
group.Update();
Assert.AreEqual(0, em.GetBuffer<DamageEvent>(deadPlayer).Length, "A dead player in radius gets NOTHING.");
Assert.AreEqual(0, em.GetBuffer<DamageEvent>(basePlayer).Length, "A non-expedition player gets NOTHING.");
Assert.AreEqual(0, em.GetBuffer<DamageEvent>(farEnemy).Length, "Out of radius gets nothing.");
}
}
[Test]
public void Unelapsed_Geyser_Does_Nothing_And_Keeps_Counting_Down()
{
var (world, group) = MakeWorld("EruptWait");
using (world)
{
var em = world.EntityManager;
var geyser = MakeGeyser(em, new float3(10, 0, 10), nextEruptTick: T0 + 120); // still counting down
var player = MakePlayer(em, new float3(10, 0, 10), health: 100f, region: RegionId.Expedition);
group.Update();
Assert.AreEqual(0, em.GetBuffer<DamageEvent>(player).Length, "No damage before the eruption tick.");
Assert.AreEqual(T0 + 120, em.GetComponentData<Geyser>(geyser).NextEruptTick, "An un-elapsed geyser is not rescheduled.");
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: a02ba4950608fb347a8491783ebc4746
+2 -1
View File
@@ -72,7 +72,7 @@ Long-form originals + the milestone each came from: `Docs/Vault/_Meta/CLAUDE_Bui
- **A system-ordering CYCLE is INVISIBLE to plain-Entities EditMode tests** (they register systems individually, unsorted) — it only throws `ComponentSystemSorter` "circular dependency cycle" at **world creation (Play)**. When you add cross-system `[UpdateBefore/After]`, re-audit the EXISTING `[Update*]` attributes of the systems you order around and **always Play-validate**. [[DR-017_Persistent_Base_Player_Driven_Pacing]]
- **A dev/debug `IRpcCommand` wire TYPE must be UNCONDITIONAL (no `#if`)** — the reflection-built RpcCollection hash must match across release/dev peers or the handshake refuses; `#if UNITY_EDITOR`-gate only the send/receive SYSTEMS, never the request struct. **Re-mean bytes, don't rename**: unchanged byte VALUES keep the `[GhostField]` serializer identical → re-bake-free (only authoring *default-value* edits re-bake the subscene).
- **Derive enableable gates instead of replicating them.** e.g. player `Dead` = a LOCAL enableable derived every predicted tick from replicated `Health<=0` (rollback-correct, no `[GhostEnabledBit]`). To write the bit on a disabled entity the query must visit it (`.WithPresent<Dead>()`); **bake the enableable DISABLED** so instances spawn off. Respawn/death *timing* is server-only.
- **Cooldown/spawn "next tick" sentinels:** route every stored tick through **`TickUtil.NonZero(...)`** (a computed `ServerTick+delay` can wrap to 0, the "ready" sentinel) and compare with `NetworkTick.IsNewerThan` / `.TicksSince`, **never** raw `uint <` / subtraction.
- **Cooldown/spawn "next tick" sentinels:** route every stored tick through **`TickUtil.NonZero(...)`** (a computed `ServerTick+delay` can wrap to 0, the "ready" sentinel) and compare with `NetworkTick.IsNewerThan` / `.TicksSince`, **never** raw `uint <` / subtraction. **★ A BAKED `[GhostField]` scheduled-tick defaults to 0 → the "invalid-tick ⇒ fire" guard that's safe for a runtime-ADDED fuse STORMS it (0 fails `IsValid`, falls through, fires every tick); for a baked/periodic tick INVERT it (0 = not-ready → skip/lazy-stamp) + stamp born-correct at spawn. Client cues off a periodic tick ride the ABSOLUTE tick + a value-latch + a was-counting-down arm-guard — never edge-detect the field increment (phantom-fires on `0→stamp` + relevancy re-entry).** See [[Geyser_Build_Spec]].
- **`GhostRelevancy` for region splits:** use `GhostRelevancyMode.SetIsIrrelevant` (not `SetIsRelevant`) so untagged/global ghosts stay relevant for free — only enumerate cross-region ghosts to hide. `RegionTag{byte Region}` is **server-only, NOT a `[GhostField]`**. **★ A 2nd region sharing an EXISTING tag (`EnemyTag`) → re-audit every query/cull over it: once-safe global despawns/cleared-checks then wipe or block cross-region (DR-031, DR-040).** `RelevantGhostForConnection` = `{int Connection=NetworkId.Value; int Ghost=ghostId}`. See [[DR-013_M6_Aether_Cycle_Region_Split]].
- **Shared GLOBAL state (cycle phase, resource ledger, goal meter) rides an UNTAGGED ghost**, never a region-tagged one (`SetIsIrrelevant` would hide it cross-region). Resolve the ledger via its DISTINCT `ResourceLedger` tag (the multi-`StorageEntry` "multiple instances" rule — EB-2 line).
- **Frontend world lifecycle (menu → on-demand worlds) ★:** use `CreateClientWorld`/`CreateServerWorld` (they register the `ServerWorld`/`ClientWorld` statics the UI reads; `CreateLocalWorld` was internal pre-6.5, PUBLIC on 6.5.0); menu world via `DefaultWorldInitialization.Initialize(name, false)`. **Never dispose/create worlds inside an ECS system** — do it on a frame-boundary coroutine (`SessionRunner`, `DontDestroyOnLoad`). The gameplay subscene streams in ONLY if a netcode world is the `DefaultGameObjectInjectionWorld` at `LoadScene` time. See [[DR-019_Frontend_Menu_Settings_Saves_Build]].
@@ -105,6 +105,7 @@ Long-form originals + the milestone each came from: `Docs/Vault/_Meta/CLAUDE_Bui
- BefourStudios HDRP art: convert via `EnvArtTools.cs`, never switch pipelines (HDRP breaks EG); Synty = URP-native → archive 2026-07-06.
- **World = cosmetic Synty nature biomes ★ (DR-025):** `Game.unity` roots `BaseBiome`(Meadow_Forest)@origin + `ExpeditionBiome`(Arid_Desert)@+1000; ground = stock URP/Lit (NOT prop-atlas `S_General`). Per-region fog/ambient cross-fade via client `WorldAtmosphereSystem` (camera X>500). **PNB fog/cloud-ring prefabs = white torus — don't place.** See [[DR-025_World_Environment_Redo_Natural_Frontier]].
- **A dark-lit screenshot MASKS material bugs — verify material *values*.** `shader.GetPropertyType(idx)`-guard before `GetColor`/`GetFloat`/`GetTexture` (`S_General`'s `_BaseColorMultiply` is a float → `GetColor` returns black). Gate emission on the `_Emissive` flag + a fixture name; keep converted env metallic low (0.10.2).
- **An EG per-instance tint (`URPMaterialPropertyBaseColor`) darkens a ghost ONLY if the shader's `_BaseColor` is Hybrid-Per-Instance** (ShaderGraph `overrideHLSLDeclaration:true`+`hlslDeclarationOverride:2`). Enemies flash because `AnimatedLitShader` is DOTS-authored; a **stock Synty prop graph (`Generic_Basic`) is Unity-Per-Material → the override RENDERS but silently no-ops.** Check the graph before planning a per-instance tint on a baked/prop mesh; else use procedural decal quads (07-12 cover damage-cracks, Part P).
- **VolumeProfile.Add persistence + the URP `m_AssetVersion` build blocker** → archive 2026-07-06 (+ native memory `urp-global-settings-version-blocks-build`).
- **`LocalTransform.FromPosition()` resets Scale=1** — server spawners read the prefab's baked `LocalTransform`, override only Position (Scale is a `[GhostField]` → consistent-but-wrong).
- **Static decor → gameplay subscene** (EG renders only baked entities); **strip colliders from cosmetic props** + no `GhostAuthoring` on scenery (classic-URP colliders are inert to the DOTS PhysicsWorld). **World collision = subscene-only ★:** `Environment`-layer boundary ring + landmark colliders (player blocked via the layer matrix); enemies slide via a server `CollisionWorld.SphereCast` in `EnemyAISystem`. **★ enemy slide has NO pathfinding — a near-vertical wall normal or an embedded spawn FROZE Husks on cover rocks (soft-locks a room on one leftover); fixed 07-07 via `EnemyMoveUtil.Depenetrate` + tangent-slide + an `EnemyNavState` nudge backstop. Re-validate movers aren't frozen when adding Environment cover. 07-10: the nudge is COVER-AWARE — a live destructible-cover ghost (`BlightClutter` carrier) SUPPRESSES the phase-through (breakable ⇒ no soft-lock); static wedges still nudge.** Boundary = `SM_Env_Rock_Cliff` rim. See [[2026-06-08_World_Collision_HUD_Scaling]].
+151
View File
@@ -0,0 +1,151 @@
---
title: Geyser_Build_Spec
type: note
permalink: gamevault/03-design/geyser-build-spec
---
# Blight Geyser — Build Spec (Phase 1.5b bundle 3, design-review-hardened)
Review: `wf_900e9965-8f0` (3 lenses — netcode/relevancy · determinism/prediction · reuse/scope — 23 agents,
0 errors, 19 findings → **11 CONFIRMED / 8 refuted**; the [[workflow-agent-quota-failures-look-clean]] check
passed — no agents died). This spec is the fold; it supersedes the pre-review draft. Mirrors the two shipped
hazards [[Exploding_Barrels_Build_Spec]] + [[Destructible_Cover_Build_Spec]].
**Locked (operator, do not revisit):** Blight-biome rooms ONLY · PERMANENT (never destroyed; `RoomTag`
teardown is the only removal) · both-sides telegraphed AoE (friendly fire vs living players AND enemies, like
the shipped exploding barrels) · also a tactical lure tool.
## 1. Core mechanism
A `Geyser` is an ownerless **interpolated** ghost placed only in Blight rooms. One replicated field —
`[GhostField] uint NextEruptTick`, the absolute server tick of its next eruption. Server-only
`GeyserEruptSystem` (plain `SimulationSystemGroup`) fires when `ServerTick` reaches `NextEruptTick`:
radius-gathers living players + living enemies in XZ, appends `DamageEvent`s (drained next tick by
`HealthApplyDamageSystem`, exactly the barrel path), then reschedules `NextEruptTick` one period ahead of
**now**. NEVER destroys itself. The client telegraph derives the countdown from the *absolute* `NextEruptTick`
vs its own predicted `ServerTick` (never an edge on the replicated value) to grow a warning disc, and fires
the erupt burst on the tick-crossing, latched once per eruption.
## 2. Server
- **Component** `Geyser { [GhostField] uint NextEruptTick }` in `ProjectM.Simulation`.
- **`GeyserEruptSystem`** — `ProjectM.Server`, `[WorldSystemFilter(ServerSimulation)]` +
`[UpdateInGroup(typeof(SimulationSystemGroup))]`, **NO `[UpdateBefore/After]` edges**,
`RequireForUpdate<Geyser>`. Mirror `HazardExplosionSystem.cs:25-27` (`[BurstCompile]` fine — no enums, no
lifted `SystemAPI.Query`). Each tick: `TryGetSingleton<NetworkTime>`; if `!ServerTick.IsValid` → skip.
Iterate `(RefRW<Geyser>, LocalTransform, Entity)`:
- **Erupt condition — INVERTED from the barrel (H2):** `until = new NetworkTick(g.NextEruptTick)`. If
`g.NextEruptTick == 0u || !until.IsValid`**skip** (unstamped = not-ready; never erupt). If
`until.IsNewerThan(serverTick)` → not yet, skip. Otherwise erupt.
- **Gather — inline, do NOT extract a helper (M5):** duplicate the ~12-line both-sides gather verbatim from
`HazardExplosionSystem.cs:59-89`, parameterized only by this geyser's position + `Tuning.GeyserEruptRadius`
+ `Tuning.GeyserEruptDamage`. Players `Health>0 && RegionTag==Expedition`, XZ within radius; enemies
`Health>0`, XZ within radius. `AppendToBuffer(DamageEvent{ Amount=GeyserEruptDamage, SourceNetworkId=-1,
SourceTick=stamp })`.
- **SourceTick = NOW, not the scheduled tick (M8):** `uint stamp =
TickUtil.NonZero(serverTick.TickIndexForValidTick);` — verbatim from `HazardExplosionSystem.cs:57`.
- **Reschedule = now + period, NOT `+=` (M3):** `g.ValueRW.NextEruptTick =
TickUtil.NonZero(serverTick.TickIndexForValidTick + Tuning.GeyserPeriodTicks);` via `RefRW<Geyser>.ValueRW`.
- **Never** `DestroyEntity`.
## 3. Client
- **New sibling `GeyserTelegraphSystem`** — `ProjectM.Client`, observe-only `SystemBase`,
`[WorldSystemFilter(ClientSimulation)]` + `[UpdateInGroup(typeof(PresentationSystemGroup))]` (sibling of
`EnemyDangerTelegraphSystem`). `CompleteDependencyBeforeRO<Geyser>()` before reading. Read the client
`NetworkTime.ServerTick` (the **predicted** ServerTick — matches shipped enemy/HUD countdowns; NOT
InterpolationTick). Per-geyser cache keyed by `Entity`, pruned every frame (pruned = silent removal; emit
nothing on prune).
- **Disc (absolute-tick derived — the sound half of the original design):** guard `g.NextEruptTick != 0u &&
new NetworkTick(g.NextEruptTick).IsValid`. `int lead = new NetworkTick(g.NextEruptTick).TicksSince(serverTick)`
(negative once passed); clamp to `[0, GeyserTelegraphTicks]`. Grow the disc at `GeyserEruptRadius` scaled by
`(1 - lead/(float)GeyserTelegraphTicks)` while `0 < lead <= GeyserTelegraphTicks`.
- **Erupt burst — fire on the ServerTick CROSSING, latched (H1):** fire when `lead` crosses `>0 → <=0`
(`serverTick` first reaches/exceeds `NextEruptTick`). Store `_lastFired[entity] = g.NextEruptTick`; refire
ONLY when `NextEruptTick` differs (the `EnemyDangerTelegraphSystem.cs:177-185` `_strikeBeeped` latch). Plus a
**"was counting down" arm-guard** — only fire if a prior frame observed `lead>0` for this entity
(`EnemyDangerTelegraphSystem.cs:139-140`). Immune to: the `0→stamp` phantom, relevancy re-entry mid-period
(a freshly re-added ghost has `lead>0` → no crossing on seed), and snapshot loss (absolute tick). Co-locates
the burst with the disc completion on the ServerTick timeline (also fixes the boom-trails-disc split).
- **Erupt VFX — reuse shipped statics (no new plumbing):** `FeedbackFx.EmitTinted`/`MakeBurst` particles +
`DynamicLightSystem.RequestFlash` + `ScorchDecalSystem.RequestScorch(pos, GeyserEruptRadius)` (bundle 2,
documented "Reusable by the future Blight-geyser hazard").
- **Promote the disc primitive (M6):** move `BuildDisc` (+ a disc/ring material factory) out of
`WorldFeedbackSystem` (private `:203`) into `FeedbackFx` so the barrel fuse ring and the geyser telegraph
share ONE primitive. Do NOT re-implement a second private disc-builder.
## 4. Seeding / authoring
- **Ghost prefab `Geyser.prefab`:** duplicate **`ResourceNode.prefab`** (or `BlightClutter.prefab`) — NOT
`CoverRock.prefab` (its Environment-layer collider would block the walk-over hazard). Swap authoring →
`GeyserAuthoring` (bakes `Geyser` + `RegionTag{Expedition}`); cosmetic vent mesh; NO collider. Ownerless/
interpolated `GhostAuthoringComponent` + `LinkedEntityGroup` come free. An inherited `HitRadius` is inert
(server scans by `Tuning.GeyserEruptRadius`) — no strip needed.
- **`GeyserFieldSpawnerAuthoring` → baked `GeyserFieldSpawner { Entity Prefab; int Count }`** singleton,
mirroring `ClutterFieldSpawnerAuthoring`. Author into `Gameplay.unity`'s subscene; assign the prefab.
- **Seeding in `RoomFieldSystem.OnUpdate` InRoom block**, sibling of cover/clutter:
- **Gate on `plan.Biome == RoomBiomeId.Blight`** (local plan, NOT `info.CurrentBiome`) — matches the cover
block's `plan.RoomType != RoomTypeId.Boss`. `RoomBiomeId.Blight == 3`.
- **Never Boss rooms** — an explicit, **commented** design choice (geyser has no collider → NOT the cover
depenetration concern; it's a boss-fight decision).
- `TryGetSingleton<GeyserFieldSpawner>` + `Prefab != Entity.Null` (optional singleton; absent → skip).
- Distinct hash sub-stream `0x6E7`; keep-out ring around the room origin (like cover's `KeepOutFromOrigin=7`).
- Stamp `RoomTag{room}` with the SAME `room` local every sibling uses → free `RoomTeardown` removal.
- **Born-correct spawn-stamp (H2):** the block has no `NetworkTime` today — add `TryGetSingleton<NetworkTime>`;
if `!ServerTick.IsValid` this tick, do NOT seed geysers (the once-per-`RoomEpoch` guard retries). Otherwise
per instance `ecb.SetComponent(e, new Geyser{ NextEruptTick =
TickUtil.NonZero(serverTick.TickIndexForValidTick + Tuning.GeyserPeriodTicks) })` in the deferred loop → the
entity is fully formed before ECB playback / `GhostSendSystem` serialization → the first snapshot never
carries 0.
- **Tuning consts** in `Simulation/Tuning.cs` beside the barrel block: `GeyserPeriodTicks = 300` (5s@60),
`GeyserTelegraphTicks = 78` (~1.3s), `GeyserEruptRadius = 3.0f`, `GeyserEruptDamage = 22`. Single-source only
the constants (no shared gather helper).
## 5. Tests
`Assets/_Project/Tests/EditMode/GeyserEruptSystemTests.cs`, plain-Entities, mirroring
`HazardExplosionSystemTests.cs` (reuse `MakeWorld`/`MakePlayer`/`MakeEnemy`):
1. **Both-sides erupt:** living player + living enemy in radius each get exactly one `DamageEvent`;
`SourceNetworkId == -1`.
2. **Reschedule:** after eruption `NextEruptTick` advances by exactly `GeyserPeriodTicks` from now, never the
`0` sentinel.
3. **Never destroyed:** the geyser survives the eruption (opposite of the barrel destroy-once assertion).
4. **Unstamped-storm regression (H2):** seed a geyser at `serverTick=5000` with `NextEruptTick=0`; assert
**zero** `DamageEvent`s on the first ticks (the inverted guard skips — no per-tick barrage).
5. **Out-of-radius / dead filters:** dead or out-of-radius get no `DamageEvent`.
## 6. Wire / bake classification
- **New ghost type + prefab** `Geyser.prefab` → genuinely NEW ghost hash (NOT a byte re-mean). ONE new
`[GhostField]` (`Geyser.NextEruptTick`).
- **New systems:** `GeyserEruptSystem` (server) + `GeyserTelegraphSystem` (client).
- **New baked singleton** `GeyserFieldSpawner` authored into `Gameplay.unity` subscene (load-bearing — unwired
= zero geysers, silent).
- **Modified:** `RoomFieldSystem` (Blight seed block + `NetworkTime` spawn-stamp), `Tuning.cs` (4 consts),
`FeedbackFx` (promote `BuildDisc` + material factory), `WorldFeedbackSystem` (fuse ring calls the promoted
primitive).
- **Relevancy:** free via prefab-baked `RegionTag{Expedition}` → existing `RegionRelevancySystem`
`SetIsIrrelevant` hides it cross-region. No relevancy-system change.
- **Ordering:** edge-free server system → no new sort constraint, no Play-only cycle (confirmed).
- Re-bake: new prefab + subscene spawner + authoring defaults; full asmdef recompile for the new types.
## 7. Changes the review FORCED vs the original design
**HIGH** — H1 erupt VFX must NOT edge-detect the `NextEruptTick` jump (phantom-fires on `0→stamp` + relevancy
re-entry + snapshot loss) → absolute-tick `TicksSince` disc + latched `>0→<=0` crossing + arm-guard (mirror
`EnemyDangerTelegraphSystem`; `AttackWindup.cs:12` "absolute, not an edge"). Also fixes the boom-trails-disc
timeline split. · H2 INVERT the barrel's invalid-tick guard (unstamped `0` → SKIP, never erupt) + add
`NetworkTime` to `RoomFieldSystem` and stamp `NextEruptTick` born-correct at spawn (else a baked-0 geyser
erupts every tick = a party-wiping barrage).
**MED** — M3 reschedule `= now + period`, not `+=` (catch-up storm; every in-repo sentinel is `= now + delay`).
· M5 do NOT extract a shared `HazardDamage` gather helper (`SystemAPI.Query` can't be lifted to a static;
verbatim-copy is the shipped convention; Burst-ICE risk) — duplicate inline. · M6 promote `BuildDisc` into
`FeedbackFx`. · M7 gate on `plan.Biome`, not `info.CurrentBiome`. · M8 stamp `SourceTick` at NOW, not the
scheduled `eruptTick` (tick-batching mis-negates dash i-frames).
**LOW** — duplicate `ResourceNode`/`BlightClutter` (no collider), not `CoverRock`. · add `GeyserEruptSystemTests`.
**Confirmed correct, no change:** plain-`SimulationSystemGroup` placement · next-tick `DamageEvent` drain ·
single-apply · `SourceNetworkId=-1` attribution · dash-i-frame negation · edge-free ordering ·
`RegionTag{Expedition}` relevancy scoping.
@@ -168,10 +168,21 @@ plain"** — expand 1.5 before 1.7. Locked bundles, sequenced ground-first:
per room inside the room's ACTUAL shape (hash stream 0xD2E55; 47 build-safe prefab refs), room-torn-down;
live-verified incl. teardown + exact Meadow tint convergence; (c) worn paths storage→warpgate/base-gate;
(d) 357 base flora re-enabled (buildings/pond/FX stay parked).
2. **Decals**: dynamic explosion scorch marks (fading quad pool at boom sites), static POI scorch/cracks,
cover damage-crack states (URPMaterialPropertyBaseColor darkening keyed on replicated Remaining).
3. **Hazard variant**: Blight geyser (periodic both-sides AoE on the HazardExplosion + telegraph-disc
chassis) — mini design review first (new replicated erupt-phase surface).
2. **Decals** (SHIPPED, Part P — all procedural, client-only, zero netcode): explosion scorch pool
(fading disc pool via `ScorchDecalSystem.RequestScorch`, wired from the barrel boom; reusable by the
geyser) · cover damage-CRACKS keyed on replicated `BlightClutter.Remaining` (Variant 4) + proximity-gated
shatter-scorch · light room-arena crack/scorch scars (via `RoomDressingSystem`). ★ The specified
`URPMaterialPropertyBaseColor` darken would have **silently no-op'd** — cover's Synty `Generic_Basic`
shadergraph declares `_BaseColor` Unity-Per-Material, NOT Hybrid-Per-Instance (only the DOTS-authored
`AnimatedLitShader` honours the EG per-instance override); operator fork → decal quads instead. Base POIs
skipped (Part O baked their wear). Knobs in `DecalConfig`.
3.**Hazard variant — Blight geyser** (SHIPPED, Part Q; review-FIRST `wf_900e9965-8f0` → 11 folded,
post-impl `wf_5c8299f8-299` clean): a PERMANENT periodic both-sides telegraphed AoE, Blight-biome rooms only
(operator forks). ONE new `[GhostField] uint NextEruptTick`; server `GeyserEruptSystem` (INVERTED invalid-guard
+ lazy-stamp — a baked-0 tick must NEVER erupt; reschedule `=now+period`), client `GeyserTelegraphSystem`
(absolute-tick disc + latched/armed crossing burst — NEVER edge-detect the field; reuses bundle-2 scorch).
Seeded in `RoomFieldSystem` (born-correct staggered), spawner wired into the subscene. 474/474 + live
no-storm end-to-end. See [[Geyser_Build_Spec]].
4. **Critters + weather beats**: fleeing bugs/birds; cloud-shadow drifts; Blight lightning flicker.
Gate: the same "no longer static" lap, now with the ground judged explicitly.
@@ -319,14 +319,90 @@ normals untouched. Ground luminance now 0.30/0.33 in edit ambient (tints stay th
for the play lap). Recipe note: bake pattern = readback-blit sources to linear Color[] → composite →
LinearToGammaSpace → PNG sRGB; HP detail imports LINEAR.
## Part P — Phase 1.5b bundle 2 (DECALS) shipped
Three locked sub-features, all **client-only presentation, procedural, zero netcode surface** (they read
*existing* replicated state): (a) explosion scorch pool, (b) cover damage-cracks, (c) light room-arena scars.
- **Pivotal grounding finding (a fork the roadmap didn't foresee):** the roadmap specified cover cracks as
`URPMaterialPropertyBaseColor` darkening — but that would **silently no-op**. Cover's render child uses the
Synty `Generic_Basic.shadergraph`, whose `_BaseColor` is declared **Unity-Per-Material**
(`overrideHLSLDeclaration:false`, `hlslDeclarationOverride:0`), **NOT Hybrid-Per-Instance** — so the
Entities-Graphics per-instance override that `EnemyHitFlashSystem` drives (enemies use the DOTS-authored
`AnimatedLitShader`, which IS hybrid-per-instance) writes to a slot the shader never reads. Exactly the
"dark render masks a material bug" trap. **Operator fork → crack-decal QUADS** (guaranteed to render,
on-theme for a decals bundle, most legible as damage) + **light room-arena cracks** (skip base POIs — Part O
already baked their wear; the deleted-WornPath double-darken lesson).
- **Architecture — one procedural decal primitive:** `FeedbackFx` gained `MakeDecalMaterial` (Sprites/Default,
ZWrite off, transparent, vertex×MPB `_Color`), `BuildScorchMesh` (irregular soft radial disc, vertex α1→0
jittered rim — the feathered edge is what stops it ghosting over fog like the old rectangle quad) and
`BuildCrackMesh` (jagged radial spokes, feathered tips). `ScorchDecalSystem` = static `RequestScorch(pos,
radius)` queue → pooled fading discs under a `DontDestroyOnLoad` root, scene-gated "Game", per-decal
hold→fade via a reused MPB (mirrors `DynamicLightSystem.RequestFlash`); wired from `WorldFeedbackSystem`'s
barrel-boom branch; **reusable by the bundle-3 geyser**. `CoverDamageSystem` accretes cracks keyed on
replicated `BlightClutter.Remaining` (Variant 4, 8→0) + shatter-scorch on despawn. `RoomDressingSystem` lays
arena scars on a distinct hash sub-stream (0xD2E56), torn down with dressing. `DecalConfig` = live knobs.
- **Self-review caught + fixed (stood in for the diff-review — client-only, no netcode):** the shatter-scorch
is now **proximity-gated** (mirrors WorldFeedbackSystem) — else a room-teardown / region-transit despawn
storm (every cover ghost dropped at once, off-camera) would spray scorches and exhaust the 24-cap pool.
- **Verified:** 470/470 EditMode; console clean (only pre-existing `WorldAtmosphereSystem` CS0618 warnings —
not this bundle). L3 headless — all 3 systems exception-free in ClientWorld, `DecalConfig` reset correct,
and the flagship scorch path proven end-to-end via VALUE inspection (mesh 25 verts + feathered vColors,
y=0.05, scale 2.76 = 3.25×0.85 exact, Sprites/Default queue 3000, shadows off, `_Color` dark-char @ α0.85)
**plus** the full spawn→hold→fade→recycle→pooled-reuse lifecycle (no leak). Commit `<pending>`.
- **Deferred to the operator's gate lap (documented, per precedent):** the in-run VISUAL judgment — cover
cracks accreting during real carving, arena scars in a real room, and whether the dark scorch reads well
over the dark baked ground — needs a launched expedition + a focused editor (unfocused blocks live-frame
verify). Knobs to taste on the lap: `ScorchColor`/alpha, `CoverCrackMaxCount`, `RoomArenaDecalCount`.
## Part Q — Phase 1.5b bundle 3 (BLIGHT GEYSER) shipped — review-first netcode slice
A PERMANENT periodic hazard (operator forks: **Blight-biome rooms only · permanent · both-sides telegraphed
AoE**). The genuinely-new replicated surface is ONE `[GhostField] uint NextEruptTick`; everything else reuses
the barrel/cover chassis + bundle-2's scorch pool.
- **Design-review FIRST (`wf_900e9965-8f0`, 3 lenses / 23 agents / 0 errors → 11 CONFIRMED, 8 refuted; the
[[workflow-agent-quota-failures-look-clean]] check passed).** It caught **two HIGH defects the naive design
would have shipped**: (H1) firing the erupt VFX by EDGE-DETECTING the replicated `NextEruptTick` jump —
phantom-fires on the `0→stamp` transition + on every relevancy re-entry (death→respawn→re-enter, region
transit) + drops under snapshot loss; **fix = absolute-tick `TicksSince(predicted ServerTick)` disc + a
`>0→<=0` crossing burst LATCHED per NextEruptTick + a was-counting-down arm-guard** (the
`EnemyDangerTelegraphSystem`/`AttackWindup` "absolute, not an edge" idiom). (H2) copying the barrel's
`invalid-tick → detonate` guard VERBATIM onto a **baked [GhostField] that defaults to 0** → the 0 fails
`IsValid`, falls through, and erupts EVERY tick — a party-wiping AoE barrage; **fix = INVERT the guard (0 =
not-ready → skip) + born-correct stamp from the live ServerTick at seed**, hardened further with a
**lazy-stamp** in the erupt system so a stray 0 can neither storm nor stay inert-forever. Plus MEDs: reschedule
`= now + period` not `+=` (catch-up storm); do NOT extract a shared gather helper (`SystemAPI.Query` can't be
a static — verbatim-duplicate, the shipped convention); promote `BuildDisc` to `FeedbackFx` (shared with the
fuse ring); gate on `plan.Biome` not `info.CurrentBiome`; `SourceTick` at NOW not the scheduled tick. Durable
fold: [[Geyser_Build_Spec]].
- **Shipped:** `Geyser` component · server `GeyserEruptSystem` (inverted+lazy guard, inline both-sides gather,
`SourceNetworkId=-1`, reschedule `=now+period`, never destroyed) · client `GeyserTelegraphSystem` (absolute-tick
growing disc + latched/armed crossing burst; erupt VFX reuse `ScorchDecalSystem.RequestScorch` + `RequestFlash`)
· `GeyserFieldSpawner` + authoring · `Geyser.prefab` (duplicated `ResourceNode.prefab` — NOT `CoverRock`, which
has an Environment collider that'd block the walk-over hazard — authoring swapped to `GeyserAuthoring`) ·
`RoomFieldSystem` Blight-gated born-correct staggered seed (+`NetworkTime`) · `GeyserFieldSpawnerAuthoring`
wired into the `Gameplay.unity` subscene (`ResourceFieldSpawner` hub, Count 2). `BuildDisc` promoted to
`FeedbackFx`; `WorldFeedbackSystem` fuse ring now calls it.
- **Verified:** 474/474 EditMode (incl. the unstamped-storm regression asserting ZERO DamageEvents on a baked-0
geyser at a high tick) · console clean · **live end-to-end** (server-spawned geyser: periodic erupt +
single-clean reschedule `=now+period` across ~7 eruptions, **no storm**; ghost replicated to the client;
telegraph disc GOs created; erupt scorch fired). **Post-impl review clean** (`wf_5c8299f8-299`, 3 lenses, 0
findings — genuine, journal-verified). ⚠ Editing hazard hit + fixed mid-build: a `script_apply_edits` delete of
`WorldFeedbackSystem.BuildDisc` mangled the file (duplicated an `UpdateFuseRing` fragment, orphan brace) — a
brace-balanced whole-span `apply_text_edits` repaired it; prefer balanced-span replaces over structured deletes.
- **Deferred to the operator's gate lap:** the in-run VISUAL feel — telegraph disc read + erupt over the dark
Blight ground, and whether a distinct geyser mesh (interim = the ResourceNode mesh) is wanted. Knobs:
`Tuning.Geyser*` (period/telegraph/radius/damage) + `GeyserFieldSpawner.Count`.
## Next-session intent
**Phase 1.5b bundle 1 (ground) is SHIPPED**; remaining locked bundles in order: **2) decals** (explosion
scorch pool · POI grime · cover damage-cracks) → **3) Blight geyser** (mini design review first — new
replicated erupt-phase surface) → **4) critters + weather beats**. Then the operator's 1.5 gate lap ("no
longer static", ground judged explicitly) → Phase 1.7 boon overhaul (design-review-FIRST) → Phase 2
hub-ification. Taste queue: arid brightness · craters call · B5 split-panels smoke · barrel explosion VFX
polish · flower density judgment (base may read dense — operator call on the lap).
**Phase 1.5b bundles 1 (ground) + 2 (decals) + 3 (Blight geyser) are SHIPPED.** Last locked bundle: **4)
critters + weather beats** (fleeing bugs/birds · cloud-shadow drifts · Blight lightning flicker — client-only,
likely no design review). Then the operator's 1.5 gate lap ("no longer static"; ground + decals + geyser judged
explicitly) → Phase 1.7 boon overhaul (design-review-FIRST) → Phase 2 hub-ification. Taste queue: **geyser feel
lap (telegraph/erupt tuning · distinct geyser mesh?)** · decals feel lap · arid brightness · craters call · B5
split-panels smoke · barrel explosion VFX polish · flower density.
Related: [[Iteration_2026-07_CoopHades]] · [[2026-07-07_Workflow_Consolidation]] ·
[[2026-07-07_Expedition_Enemy_Stuck_Harvest_Feedback]]