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:
@@ -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>>0 → <=0</c>, LATCHED once per
|
||||
/// <c>NextEruptTick</c> value + an ARM-guard (only if a prior frame saw it counting down) — immune to the
|
||||
/// <c>0→stamp</c> phantom, relevancy re-entry mid-period, and snapshot loss.</item>
|
||||
/// </list>
|
||||
/// Erupt VFX reuse the shipped statics — <see cref="FeedbackFx"/> burst + <see cref="DynamicLightSystem.RequestFlash"/>
|
||||
/// + <see cref="ScorchDecalSystem.RequestScorch"/> (bundle 2, built for this). Pooled disc GameObjects under a
|
||||
/// private root, pruned every frame; a pruned geyser (teardown / relevancy drop) is SILENT.
|
||||
/// </summary>
|
||||
[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)]
|
||||
[UpdateInGroup(typeof(PresentationSystemGroup))]
|
||||
public partial class GeyserTelegraphSystem : SystemBase
|
||||
{
|
||||
static readonly Color DiscColor = new Color(2.6f, 0.35f, 2.4f); // hot blight magenta-purple (danger + biome id)
|
||||
static readonly Color EruptColor = new Color(2.9f, 0.5f, 3.2f); // brighter burst
|
||||
static readonly int ColorId = Shader.PropertyToID("_Color");
|
||||
|
||||
Transform _fxRoot;
|
||||
Material _discMat;
|
||||
Material _burstMat;
|
||||
Mesh _discMesh;
|
||||
ParticleSystem _eruptFx;
|
||||
AudioClip _eruptClip;
|
||||
MaterialPropertyBlock _mpb;
|
||||
|
||||
readonly Dictionary<Entity, GameObject> _discs = new();
|
||||
readonly Dictionary<Entity, uint> _armed = new(); // NextEruptTick we've seen counting down for
|
||||
readonly Dictionary<Entity, uint> _lastFired = new(); // NextEruptTick we last fired the burst for (latch)
|
||||
readonly HashSet<Entity> _seen = new();
|
||||
readonly List<Entity> _stale = new();
|
||||
|
||||
protected override void OnCreate()
|
||||
{
|
||||
_eruptClip = MakeClip("geyser_erupt", 90f, 300f, 0.30f, 0.55f, noise: true, decay: 5f);
|
||||
_mpb = new MaterialPropertyBlock();
|
||||
}
|
||||
|
||||
protected override void OnStartRunning()
|
||||
{
|
||||
if (_fxRoot != null) return;
|
||||
_fxRoot = new GameObject("~GeyserTelegraphFX").transform;
|
||||
_discMat = MakeParticleMaterial("GeyserDisc");
|
||||
_discMat.color = DiscColor;
|
||||
_discMesh = BuildDisc(40);
|
||||
_burstMat = MakeParticleMaterial("GeyserErupt");
|
||||
_eruptFx = MakeBurst(_fxRoot, "GeyserErupt", _burstMat, EruptColor, 0.18f, 8f, 0.5f, 256, -0.15f, 0.25f, 0.25f);
|
||||
}
|
||||
|
||||
protected override void OnDestroy()
|
||||
{
|
||||
if (_fxRoot != null) Object.Destroy(_fxRoot.gameObject);
|
||||
if (_discMat != null) Object.Destroy(_discMat);
|
||||
if (_burstMat != null) Object.Destroy(_burstMat);
|
||||
if (_discMesh != null) Object.Destroy(_discMesh);
|
||||
}
|
||||
|
||||
protected override void OnUpdate()
|
||||
{
|
||||
if (_fxRoot == null || _discMat == null) return;
|
||||
if (!SystemAPI.TryGetSingleton<NetworkTime>(out var nt) || !nt.ServerTick.IsValid) return;
|
||||
var serverTick = nt.ServerTick;
|
||||
|
||||
EntityManager.CompleteDependencyBeforeRO<Geyser>();
|
||||
EntityManager.CompleteDependencyBeforeRO<LocalTransform>();
|
||||
|
||||
_seen.Clear();
|
||||
foreach (var (geyser, xf, e) in
|
||||
SystemAPI.Query<RefRO<Geyser>, RefRO<LocalTransform>>().WithEntityAccess())
|
||||
{
|
||||
uint next = geyser.ValueRO.NextEruptTick;
|
||||
if (next == 0u) continue; // unstamped -> no telegraph yet (born-correct pending)
|
||||
var untilTick = new NetworkTick(next);
|
||||
if (!untilTick.IsValid) continue;
|
||||
_seen.Add(e);
|
||||
|
||||
int lead = untilTick.TicksSince(serverTick); // >0 = erupt in the future, <=0 = erupt now/past
|
||||
float3 pos = xf.ValueRO.Position;
|
||||
|
||||
if (lead > 0)
|
||||
{
|
||||
_armed[e] = next; // arm this eruption while it counts down
|
||||
}
|
||||
else // lead <= 0: the erupt tick has arrived / passed
|
||||
{
|
||||
bool armedForThis = _armed.TryGetValue(e, out var av) && av == next;
|
||||
bool alreadyFired = _lastFired.TryGetValue(e, out var lf) && lf == next;
|
||||
if (armedForThis && !alreadyFired)
|
||||
{
|
||||
// fire ONCE per eruption, on the ServerTick crossing (co-located with the disc completion)
|
||||
EmitTinted(_eruptFx, (Vector3)pos + Vector3.up * 0.3f, 44, EruptColor);
|
||||
DynamicLightSystem.RequestFlash((Vector3)pos, new Color(0.85f, 0.35f, 1f), 1.4f);
|
||||
ScorchDecalSystem.RequestScorch((Vector3)pos, Tuning.GeyserEruptRadius);
|
||||
PlayClip(_eruptClip, (Vector3)pos, 0.7f);
|
||||
_lastFired[e] = next;
|
||||
}
|
||||
}
|
||||
|
||||
// Warning disc: grow + pulse over the telegraph window; hidden when dormant or erupted.
|
||||
bool showing = lead > 0 && lead <= Tuning.GeyserTelegraphTicks;
|
||||
if (showing)
|
||||
{
|
||||
float charge = 1f - lead / (float)Tuning.GeyserTelegraphTicks; // 0 at window open -> 1 at erupt
|
||||
if (!_discs.TryGetValue(e, out var go) || go == null)
|
||||
{
|
||||
go = new GameObject("GeyserWarning");
|
||||
go.transform.SetParent(_fxRoot, false);
|
||||
go.AddComponent<MeshFilter>().sharedMesh = _discMesh;
|
||||
var mr = go.AddComponent<MeshRenderer>();
|
||||
mr.sharedMaterial = _discMat;
|
||||
mr.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off;
|
||||
mr.receiveShadows = false;
|
||||
mr.lightProbeUsage = UnityEngine.Rendering.LightProbeUsage.Off;
|
||||
_discs[e] = go;
|
||||
}
|
||||
if (!go.activeSelf) go.SetActive(true);
|
||||
float radius = Tuning.GeyserEruptRadius * (0.55f + 0.45f * charge);
|
||||
go.transform.position = new Vector3(pos.x, 0.055f, pos.z);
|
||||
go.transform.localScale = new Vector3(radius, 1f, radius);
|
||||
float alpha = 0.14f + 0.5f * charge + 0.12f * Mathf.Abs(Mathf.Sin(UnityEngine.Time.time * 10f));
|
||||
_mpb.SetColor(ColorId, new Color(DiscColor.r, DiscColor.g, DiscColor.b, alpha));
|
||||
go.GetComponent<MeshRenderer>().SetPropertyBlock(_mpb);
|
||||
}
|
||||
else if (_discs.TryGetValue(e, out var go) && go != null && go.activeSelf)
|
||||
{
|
||||
go.SetActive(false); // dormant (lead > window) or just erupted (lead <= 0)
|
||||
}
|
||||
}
|
||||
|
||||
// Prune despawned geysers (teardown / relevancy drop) — destroy the disc, drop tracking, emit nothing.
|
||||
if (_discs.Count > 0)
|
||||
{
|
||||
_stale.Clear();
|
||||
foreach (var kv in _discs) if (!_seen.Contains(kv.Key)) _stale.Add(kv.Key);
|
||||
for (int i = 0; i < _stale.Count; i++)
|
||||
{
|
||||
if (_discs[_stale[i]] != null) Object.Destroy(_discs[_stale[i]]);
|
||||
_discs.Remove(_stale[i]);
|
||||
}
|
||||
}
|
||||
PruneMap(_armed);
|
||||
PruneMap(_lastFired);
|
||||
}
|
||||
|
||||
void PruneMap(Dictionary<Entity, uint> dict)
|
||||
{
|
||||
if (dict.Count == 0) return;
|
||||
_stale.Clear();
|
||||
foreach (var kv in dict) if (!_seen.Contains(kv.Key)) _stale.Add(kv.Key);
|
||||
for (int i = 0; i < _stale.Count; i++) dict.Remove(_stale[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 > 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 -> 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
|
||||
|
||||
Reference in New Issue
Block a user