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:
@@ -0,0 +1,97 @@
|
||||
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>
|
||||
/// Detonates lit-fuse EXPLOSIVE barrels (Exploding_Barrels_Build_Spec, design-review wf_2cb10454-fdf):
|
||||
/// when a <see cref="BarrelFuse"/>'s ExplodeTick elapses, radius-damage LIVING enemies AND players
|
||||
/// (friendly fire = the bait mechanic), then the single DestroyEntity. Player gather mirrors the
|
||||
/// BossAISystem slam filter VERBATIM (Health > 0 + RegionTag == Expedition — a dead player lying in
|
||||
/// radius gets nothing; a review-confirmed precedent). <see cref="DamageEvent.SourceTick"/> is stamped at
|
||||
/// DETONATION (the authoring moment — dash i-frames negate against it, honouring the DamageEvent
|
||||
/// authored-tick contract) and <see cref="DamageEvent.SourceNetworkId"/> = -1 (the environment
|
||||
/// convention: a player id would score+consume Charger whiff-punish windows). Plain server
|
||||
/// <see cref="SimulationSystemGroup"/>, NO ordering edges; presence-gated on BarrelFuse only — NEVER
|
||||
/// lifecycle-gated (a fuse must always drain; it rides the RoomTag'd barrel, so room teardown / the
|
||||
/// Staging sweep clean unexploded barrels for free). An invalid NetworkTime skips the tick; the fuse
|
||||
/// persists.
|
||||
/// </summary>
|
||||
[BurstCompile]
|
||||
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
|
||||
[UpdateInGroup(typeof(SimulationSystemGroup))]
|
||||
public partial struct HazardExplosionSystem : ISystem
|
||||
{
|
||||
[BurstCompile]
|
||||
public void OnCreate(ref SystemState state)
|
||||
{
|
||||
state.RequireForUpdate<BarrelFuse>();
|
||||
}
|
||||
|
||||
[BurstCompile]
|
||||
public void OnUpdate(ref SystemState state)
|
||||
{
|
||||
if (!SystemAPI.TryGetSingleton<NetworkTime>(out var netTime) || !netTime.ServerTick.IsValid)
|
||||
return;
|
||||
var serverTick = netTime.ServerTick;
|
||||
|
||||
var ecb = new EntityCommandBuffer(Allocator.Temp);
|
||||
var blasts = new NativeList<float3>(Allocator.Temp);
|
||||
foreach (var (fuse, lt, barrel) in
|
||||
SystemAPI.Query<RefRO<BarrelFuse>, RefRO<LocalTransform>>().WithEntityAccess())
|
||||
{
|
||||
var until = new NetworkTick(fuse.ValueRO.ExplodeTick);
|
||||
if (until.IsValid && until.IsNewerThan(serverTick)) continue; // still burning
|
||||
blasts.Add(lt.ValueRO.Position);
|
||||
ecb.DestroyEntity(barrel); // the single destroy site for a popped explosive
|
||||
}
|
||||
|
||||
if (blasts.Length > 0)
|
||||
{
|
||||
float radiusSq = Tuning.BarrelExplodeRadius * Tuning.BarrelExplodeRadius;
|
||||
uint stamp = TickUtil.NonZero(serverTick.TickIndexForValidTick);
|
||||
|
||||
// Players: the BossAISystem slam gather verbatim — living + expedition only.
|
||||
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 < blasts.Length; i++)
|
||||
if (math.distancesq(plt.ValueRO.Position.xz, blasts[i].xz) <= radiusSq)
|
||||
ecb.AppendToBuffer(player, new DamageEvent
|
||||
{
|
||||
Amount = Tuning.BarrelExplodeDamage,
|
||||
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 < blasts.Length; i++)
|
||||
if (math.distancesq(elt.ValueRO.Position.xz, blasts[i].xz) <= radiusSq)
|
||||
ecb.AppendToBuffer(enemy, new DamageEvent
|
||||
{
|
||||
Amount = Tuning.BarrelExplodeDamage,
|
||||
SourceNetworkId = -1,
|
||||
SourceTick = stamp,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
ecb.Playback(state.EntityManager);
|
||||
ecb.Dispose();
|
||||
blasts.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: cf7e54d631fbfc54aa3aaa08f9217641
|
||||
@@ -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])
|
||||
|
||||
@@ -121,7 +121,8 @@ namespace ProjectM.Server
|
||||
float3 pos = RoomLayoutMath.ScatterInShape(plan.ShapeId, origin, i, cCount, ref crng);
|
||||
ecb.SetComponent(e, cBaked.WithPosition(pos));
|
||||
var bc = cProto;
|
||||
bc.Variant = (byte)(i % 3);
|
||||
// ~25% EXPLOSIVE (Variant 3, the hazard — Exploding_Barrels_Build_Spec); rest stay cosmetic 0-2.
|
||||
bc.Variant = crng.NextFloat() < 0.25f ? (byte)3 : (byte)(i % 3);
|
||||
ecb.SetComponent(e, bc);
|
||||
ecb.AddComponent(e, new RoomTag { Room = room });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user