Hazard: exploding barrels — fused explosive clutter, friendly-fire bait

Phase 1.5 environmental hazard v1 (design-review wf_2cb10454-fdf: 13
findings confirmed + folded; Build Spec in the vault). ~25% of room
clutter seeds as EXPLOSIVE (Variant 3 on the existing replicated byte
- zero new GhostFields/prefabs/RPCs).

The FUSE is the review's fold - one mechanism closes both HIGHs:
- Pop sites (projectile sweep + isServer-gated melee harvest) do NOT
  destroy an explosive: they zero the replicated Remaining + add a
  server-only BarrelFuse (~36 ticks). The client sees Remaining=0 on a
  still-alive barrel across snapshots -> unambiguous fuse cue, and
  booms at despawn ONLY when cached Remaining<=0 - a teardown despawn
  carries Remaining>0, so portal-advance teardowns can never fire
  false booms (HIGH #1).
- HazardExplosionSystem (server, plain group, presence-gated on
  BarrelFuse, never lifecycle-gated) detonates at ExplodeTick: radius
  damage to LIVING enemies AND players (boss-slam player filter
  verbatim; friendly fire = the bait mechanic), SourceTick stamped at
  detonation = the authoring moment (HIGH #2: the authored-tick
  contract dash i-frames negate against), SourceNetworkId=-1 (the
  environment convention - a player id would consume Charger
  whiff-punish windows). Fuse rides the RoomTag'd barrel -> teardown
  cleans lit barrels free.
- Lit barrels are unhittable at both snapshot sites (no double-pop).
- Client: WorldFeedback caches Variant -> boom-vs-puff split (big
  burst + light flash + boom SFX vs the old puff); DynamicLightSystem
  gives Variant-3 barrels a red danger glow that STROBES once fused.

Verified: 470/470 EditMode (4 new: fused pop instead of destroy +
unhittable, both-sides damage w/ -1 source + authored tick, dead-player
+ radius filters, unelapsed-fuse inert); live smoke - seeded 3/8
explosive, real-sweep pop, CLIENT observed the fuse cue on the live
ghost, pinned bait enemy took exactly 26 (30->4), walk-out escape
confirmed; console clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-10 19:41:47 -07:00
parent cca8c1d44e
commit cd607ae156
12 changed files with 415 additions and 13 deletions
@@ -51,6 +51,11 @@ namespace ProjectM.Server
public void OnUpdate(ref SystemState state)
{
var ledgerEntity = SystemAPI.GetSingletonEntity<ResourceLedger>();
// Fuse scheduling needs the server tick; absent/invalid (plain test worlds) -> explosive pops fall
// back to plain destroy (old behaviour).
bool haveTick = SystemAPI.TryGetSingleton<Unity.NetCode.NetworkTime>(out var hvNetTime)
&& hvNetTime.ServerTick.IsValid;
uint nowTick = haveTick ? hvNetTime.ServerTick.TickIndexForValidTick : 0u;
var ledger = SystemAPI.GetBuffer<StorageEntry>(ledgerEntity);
// Resolve the harvesting player from the projectile's GhostOwner so yield lands in their PERSONAL
@@ -80,6 +85,8 @@ namespace ProjectM.Server
foreach (var (xform, hr, node, e) in
SystemAPI.Query<RefRO<LocalTransform>, RefRO<HitRadius>, RefRO<ResourceNode>>().WithEntityAccess())
{
if (node.ValueRO.Remaining <= 0) continue; // spent (a lit fuse) is not a target
tgtEntity.Add(e);
tgtPos.Add(xform.ValueRO.Position.xz);
tgtRadius.Add(hr.ValueRO.Value);
@@ -94,6 +101,8 @@ namespace ProjectM.Server
foreach (var (xform, hr, clutter, e) in
SystemAPI.Query<RefRO<LocalTransform>, RefRO<HitRadius>, RefRO<BlightClutter>>().WithEntityAccess())
{
if (clutter.ValueRO.Remaining <= 0) continue; // lit-fuse barrel: unhittable, detonation owns it
tgtEntity.Add(e);
tgtPos.Add(xform.ValueRO.Position.xz);
tgtRadius.Add(hr.ValueRO.Value);
@@ -164,7 +173,25 @@ namespace ProjectM.Server
if (!destroyed[bestIdx])
{
destroyed[bestIdx] = true;
ecb.DestroyEntity(tgtEntity[bestIdx]);
if (tgtIsClutter[bestIdx] && tgtVariant[bestIdx] == 3 && haveTick)
{
// EXPLOSIVE pop (Variant 3): light the fuse instead of destroying — the replicated
// Remaining=0 on a still-alive barrel is the client's unambiguous fuse cue;
// HazardExplosionSystem detonates + destroys (Exploding_Barrels_Build_Spec).
SystemAPI.SetComponent(tgtEntity[bestIdx], new BlightClutter
{
Remaining = 0,
Variant = tgtVariant[bestIdx],
ScrapResourceId = tgtYieldId[bestIdx],
ScrapPerHit = tgtYieldPerHit[bestIdx],
});
ecb.AddComponent(tgtEntity[bestIdx], new BarrelFuse
{
ExplodeTick = TickUtil.NonZero(nowTick + Tuning.BarrelFuseTicks),
});
}
else
ecb.DestroyEntity(tgtEntity[bestIdx]);
}
}
else if (tgtIsClutter[bestIdx])