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:
@@ -41,6 +41,7 @@ namespace ProjectM.Client
|
||||
GameObject _root;
|
||||
readonly Dictionary<Entity, Light> _projectileLights = new Dictionary<Entity, Light>();
|
||||
readonly Dictionary<Entity, Light> _nodeLights = new Dictionary<Entity, Light>();
|
||||
readonly Dictionary<Entity, Light> _barrelLights = new Dictionary<Entity, Light>();
|
||||
readonly HashSet<Entity> _seen = new HashSet<Entity>();
|
||||
readonly List<Entity> _dead = new List<Entity>();
|
||||
readonly Stack<Light> _pool = new Stack<Light>();
|
||||
@@ -123,6 +124,30 @@ namespace ProjectM.Client
|
||||
}
|
||||
PruneUnseen(_nodeLights);
|
||||
|
||||
// ---- explosive-barrel danger glow (BlightClutter.Variant==3): steady red, STROBING once the
|
||||
// fuse is lit (replicated Remaining hits 0 while the barrel still exists) — the flee cue.
|
||||
_seen.Clear();
|
||||
foreach (var (clutter, lt, entity) in SystemAPI.Query<RefRO<BlightClutter>, RefRO<LocalTransform>>().WithEntityAccess())
|
||||
{
|
||||
if (clutter.ValueRO.Variant != 3) continue;
|
||||
_seen.Add(entity);
|
||||
if (!_barrelLights.TryGetValue(entity, out var blight))
|
||||
{
|
||||
if (_barrelLights.Count >= 16) continue;
|
||||
blight = Rent();
|
||||
_barrelLights[entity] = blight;
|
||||
}
|
||||
bool fused = clutter.ValueRO.Remaining <= 0;
|
||||
blight.color = new Color(1f, 0.28f, 0.12f);
|
||||
blight.range = fused ? 6f : 4f;
|
||||
blight.intensity = fused
|
||||
? 2.6f + 1.8f * Mathf.Abs(Mathf.Sin(UnityEngine.Time.time * 14f))
|
||||
: 1.1f;
|
||||
var bp = lt.ValueRO.Position;
|
||||
blight.transform.position = new Vector3(bp.x, bp.y + 0.9f, bp.z);
|
||||
}
|
||||
PruneUnseen(_barrelLights);
|
||||
|
||||
// ---- portal light (RoomExplore only) ----
|
||||
bool portalOn = false;
|
||||
if (SystemAPI.TryGetSingleton<RunInfo>(out var runInfo)
|
||||
|
||||
@@ -25,7 +25,7 @@ namespace ProjectM.Client
|
||||
[UpdateInGroup(typeof(PresentationSystemGroup))]
|
||||
public partial class WorldFeedbackSystem : SystemBase
|
||||
{
|
||||
struct Cache { public int Remaining; public float3 Pos; public bool IsClutter; public Color Tint; }
|
||||
struct Cache { public int Remaining; public float3 Pos; public bool IsClutter; public Color Tint; public byte Variant; }
|
||||
|
||||
readonly Dictionary<Entity, Cache> _cache = new();
|
||||
readonly HashSet<Entity> _seen = new();
|
||||
@@ -36,11 +36,13 @@ namespace ProjectM.Client
|
||||
ParticleSystem _clearFx;
|
||||
AudioClip _chipClip;
|
||||
AudioClip _clearClip;
|
||||
AudioClip _boomClip;
|
||||
|
||||
protected override void OnCreate()
|
||||
{
|
||||
_chipClip = MakeClip("harvest_chip", 900f, 1400f, 0.06f, 0.30f);
|
||||
_clearClip = MakeClip("clutter_clear", 420f, 90f, 0.22f, 0.50f);
|
||||
_boomClip = MakeClip("barrel_boom", 140f, 40f, 0.45f, 0.65f, noise: true, decay: 6f);
|
||||
}
|
||||
|
||||
protected override void OnStartRunning()
|
||||
@@ -82,7 +84,7 @@ namespace ProjectM.Client
|
||||
SystemAPI.Query<RefRO<ResourceNode>, RefRO<LocalTransform>>().WithEntityAccess())
|
||||
{
|
||||
_seen.Add(e);
|
||||
Observe(e, node.ValueRO.Remaining, xf.ValueRO.Position, false, TintForResource(node.ValueRO.ResourceId), haveLocal && math.distancesq(xf.ValueRO.Position, localPos) <= WorldFeelConfig.ProximityRange * WorldFeelConfig.ProximityRange);
|
||||
Observe(e, node.ValueRO.Remaining, xf.ValueRO.Position, false, TintForResource(node.ValueRO.ResourceId), 0, haveLocal && math.distancesq(xf.ValueRO.Position, localPos) <= WorldFeelConfig.ProximityRange * WorldFeelConfig.ProximityRange);
|
||||
}
|
||||
|
||||
// Blight clutter — chip on damage.
|
||||
@@ -90,7 +92,7 @@ namespace ProjectM.Client
|
||||
SystemAPI.Query<RefRO<BlightClutter>, RefRO<LocalTransform>>().WithEntityAccess())
|
||||
{
|
||||
_seen.Add(e);
|
||||
Observe(e, clutter.ValueRO.Remaining, xf.ValueRO.Position, true, WorldFeelConfig.WildTint, haveLocal && math.distancesq(xf.ValueRO.Position, localPos) <= WorldFeelConfig.ProximityRange * WorldFeelConfig.ProximityRange);
|
||||
Observe(e, clutter.ValueRO.Remaining, xf.ValueRO.Position, true, WorldFeelConfig.WildTint, clutter.ValueRO.Variant, haveLocal && math.distancesq(xf.ValueRO.Position, localPos) <= WorldFeelConfig.ProximityRange * WorldFeelConfig.ProximityRange);
|
||||
}
|
||||
|
||||
// Prune: a despawn = the server destroyed it (node depleted / clutter shattered). Gate on proximity so
|
||||
@@ -107,12 +109,26 @@ namespace ProjectM.Client
|
||||
var c = _cache[_stale[i]];
|
||||
if (haveLocal && math.distancesq(c.Pos, localPos) <= rangeSq)
|
||||
{
|
||||
EmitTinted(_clearFx, (Vector3)c.Pos + Vector3.up * 0.6f, WorldFeelConfig.ClearBurstCount, c.Tint);
|
||||
PlayClip(_clearClip, (Vector3)c.Pos, WorldFeelConfig.ClearSfxVolume);
|
||||
if (c.IsClutter)
|
||||
if (c.IsClutter && c.Variant == 3 && c.Remaining <= 0)
|
||||
{
|
||||
PrototypeCameraRig.PunchFov(WorldFeelConfig.ClearFovKick, 90f);
|
||||
PrototypeCameraRig.AddShake(WorldFeelConfig.ClearShake);
|
||||
// EXPLOSIVE detonation: only a hit-popped barrel reaches despawn with a cached
|
||||
// Remaining of 0 (a teardown despawn still carries Remaining>0) — the review-hardened
|
||||
// boom-vs-puff split, so portal-advance teardowns can never fire false booms.
|
||||
EmitTinted(_clearFx, (Vector3)c.Pos + Vector3.up * 0.6f, WorldFeelConfig.ClearBurstCount * 4, new Color(3.4f, 1.4f, 0.3f));
|
||||
DynamicLightSystem.RequestFlash((Vector3)c.Pos, new Color(1f, 0.62f, 0.25f), 1.6f);
|
||||
PlayClip(_boomClip, (Vector3)c.Pos, 0.8f);
|
||||
PrototypeCameraRig.PunchFov(WorldFeelConfig.ClearFovKick * 2.2f, 90f);
|
||||
PrototypeCameraRig.AddShake(WorldFeelConfig.ClearShake * 2.5f);
|
||||
}
|
||||
else
|
||||
{
|
||||
EmitTinted(_clearFx, (Vector3)c.Pos + Vector3.up * 0.6f, WorldFeelConfig.ClearBurstCount, c.Tint);
|
||||
PlayClip(_clearClip, (Vector3)c.Pos, WorldFeelConfig.ClearSfxVolume);
|
||||
if (c.IsClutter)
|
||||
{
|
||||
PrototypeCameraRig.PunchFov(WorldFeelConfig.ClearFovKick, 90f);
|
||||
PrototypeCameraRig.AddShake(WorldFeelConfig.ClearShake);
|
||||
}
|
||||
}
|
||||
}
|
||||
_cache.Remove(_stale[i]);
|
||||
@@ -120,7 +136,7 @@ namespace ProjectM.Client
|
||||
}
|
||||
}
|
||||
|
||||
void Observe(Entity e, int remaining, float3 pos, bool isClutter, Color tint, bool nearLocal)
|
||||
void Observe(Entity e, int remaining, float3 pos, bool isClutter, Color tint, byte variant, bool nearLocal)
|
||||
{
|
||||
if (_cache.TryGetValue(e, out var prev) && remaining < prev.Remaining)
|
||||
{
|
||||
@@ -132,7 +148,7 @@ void Observe(Entity e, int remaining, float3 pos, bool isClutter, Color tint, bo
|
||||
if (WorldFeelConfig.ChipShake > 0f) PrototypeCameraRig.AddShake(WorldFeelConfig.ChipShake);
|
||||
}
|
||||
}
|
||||
_cache[e] = new Cache { Remaining = remaining, Pos = pos, IsClutter = isClutter, Tint = tint };
|
||||
_cache[e] = new Cache { Remaining = remaining, Pos = pos, IsClutter = isClutter, Tint = tint, Variant = variant };
|
||||
}
|
||||
|
||||
static Color TintForResource(byte resourceId)
|
||||
|
||||
Reference in New Issue
Block a user