Compare commits
2 Commits
cca8c1d44e
...
d8e724e40a
| Author | SHA1 | Date | |
|---|---|---|---|
| d8e724e40a | |||
| cd607ae156 |
@@ -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)
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
using Unity.Entities;
|
||||
|
||||
namespace ProjectM.Simulation
|
||||
{
|
||||
/// <summary>
|
||||
/// Server-only lit-fuse marker on an EXPLOSIVE clutter barrel (BlightClutter.Variant==3) that has been
|
||||
/// popped: the pop sites zero the barrel's replicated Remaining (the client's unambiguous fuse cue —
|
||||
/// a still-alive barrel with Remaining==0 can only mean "about to blow"; a room-teardown despawn still
|
||||
/// carries Remaining>0, so the client's boom-vs-puff choice at despawn cannot misfire) and add this
|
||||
/// component instead of destroying. HazardExplosionSystem detonates when <see cref="ExplodeTick"/>
|
||||
/// elapses: radius damage to LIVING enemies AND players (friendly fire = the bait mechanic), then the
|
||||
/// single DestroyEntity. Never replicated; the fuse rides the RoomTag'd barrel, so room teardown / the
|
||||
/// Staging sweep clean lit barrels for free (no orphaned-event lifecycle).
|
||||
/// </summary>
|
||||
public struct BarrelFuse : IComponentData
|
||||
{
|
||||
/// <summary>Server tick the barrel detonates (via TickUtil.NonZero — 0 never means "ready").</summary>
|
||||
public uint ExplodeTick;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c677948826634c44fb355a4e00d7c5df
|
||||
@@ -206,6 +206,8 @@ namespace ProjectM.Simulation
|
||||
foreach (var (hx, node, he) in
|
||||
SystemAPI.Query<RefRO<LocalTransform>, RefRO<ResourceNode>>().WithEntityAccess())
|
||||
{
|
||||
if (node.ValueRO.Remaining <= 0) continue; // spent is not a target
|
||||
|
||||
harvEntity.Add(he);
|
||||
harvPos.Add(hx.ValueRO.Position);
|
||||
harvRemaining.Add(node.ValueRO.Remaining);
|
||||
@@ -218,6 +220,8 @@ namespace ProjectM.Simulation
|
||||
foreach (var (hx, clutter, he) in
|
||||
SystemAPI.Query<RefRO<LocalTransform>, RefRO<BlightClutter>>().WithEntityAccess())
|
||||
{
|
||||
if (clutter.ValueRO.Remaining <= 0) continue; // lit-fuse barrel: unhittable, detonation owns it
|
||||
|
||||
harvEntity.Add(he);
|
||||
harvPos.Add(hx.ValueRO.Position);
|
||||
harvRemaining.Add(clutter.ValueRO.Remaining);
|
||||
@@ -284,7 +288,24 @@ namespace ProjectM.Simulation
|
||||
if (rem <= 0)
|
||||
{
|
||||
harvDestroyed[i] = true;
|
||||
ecb.DestroyEntity(harvEntity[i]);
|
||||
if (harvIsClutter[i] && harvVariant[i] == 3)
|
||||
{
|
||||
// EXPLOSIVE pop (Variant 3): light the fuse instead of destroying — mirrors the
|
||||
// projectile pop site; HazardExplosionSystem detonates (Exploding_Barrels_Build_Spec).
|
||||
ecb.SetComponent(harvEntity[i], new BlightClutter
|
||||
{
|
||||
Remaining = 0,
|
||||
Variant = harvVariant[i],
|
||||
ScrapResourceId = harvYieldId[i],
|
||||
ScrapPerHit = harvPerHit[i],
|
||||
});
|
||||
ecb.AddComponent(harvEntity[i], new BarrelFuse
|
||||
{
|
||||
ExplodeTick = TickUtil.NonZero(now + Tuning.BarrelFuseTicks),
|
||||
});
|
||||
}
|
||||
else
|
||||
ecb.DestroyEntity(harvEntity[i]);
|
||||
}
|
||||
else if (harvIsClutter[i])
|
||||
{
|
||||
|
||||
@@ -107,6 +107,19 @@ namespace ProjectM.Simulation
|
||||
/// first (counted at spawn; Health.Max replicates so the bar stays truthful).</summary>
|
||||
public const float BossHealthPerExtraPlayer = 0.75f;
|
||||
|
||||
// ---- Exploding barrels (Phase 1.5 hazard; server-only detonation consts — see Exploding_Barrels_Build_Spec) ----
|
||||
|
||||
/// <summary>Fuse ticks between the pop (Remaining hits 0) and detonation (~0.6 s @60): long enough for the
|
||||
/// popper to step out + for the replicated Remaining=0 to straddle snapshots (the client's fuse cue).</summary>
|
||||
public const uint BarrelFuseTicks = 36;
|
||||
|
||||
/// <summary>Detonation radius (world units, XZ). Above melee reach so point-blank pops are punished, but a
|
||||
/// fuse-length walk clears it comfortably.</summary>
|
||||
public const float BarrelExplodeRadius = 3.25f;
|
||||
|
||||
/// <summary>Detonation damage — hits LIVING enemies AND players alike (friendly fire = the bait mechanic).</summary>
|
||||
public const float BarrelExplodeDamage = 26f;
|
||||
|
||||
// ---- 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
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
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="HazardExplosionSystem"/> (Exploding_Barrels_Build_Spec).
|
||||
/// Pins the review-hardened contract: an ELAPSED fuse radius-damages LIVING enemies AND players
|
||||
/// (friendly fire) with SourceNetworkId = -1 (the environment convention — a player id would score
|
||||
/// Charger whiff-punishes) and destroys the barrel exactly once; a dead player in radius gets NOTHING
|
||||
/// (the BossAISystem slam filter, copied verbatim); out-of-radius targets get nothing; an UN-ELAPSED
|
||||
/// fuse does nothing. Also pins the pop-site half via ResourceHarvestSystem: an explosive (Variant 3)
|
||||
/// clutter pop lights the fuse (Remaining -> 0, BarrelFuse added, entity ALIVE) instead of destroying,
|
||||
/// and a lit barrel is unhittable.
|
||||
/// </summary>
|
||||
public class HazardExplosionSystemTests
|
||||
{
|
||||
const uint T0 = 1000;
|
||||
|
||||
static (World world, SimulationSystemGroup group) MakeWorld(string name)
|
||||
{
|
||||
var world = new World(name);
|
||||
var group = world.GetOrCreateSystemManaged<SimulationSystemGroup>();
|
||||
group.AddSystemToUpdateList(world.GetOrCreateSystem<HazardExplosionSystem>());
|
||||
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(T0) });
|
||||
return (world, group);
|
||||
}
|
||||
|
||||
static Entity MakeBarrel(EntityManager em, float3 pos, uint explodeTick)
|
||||
{
|
||||
var e = em.CreateEntity();
|
||||
em.AddComponentData(e, LocalTransform.FromPosition(pos));
|
||||
em.AddComponentData(e, new BlightClutter { Remaining = 0, Variant = 3, ScrapResourceId = ResourceId.Biomass, ScrapPerHit = 2f });
|
||||
em.AddComponentData(e, new BarrelFuse { ExplodeTick = explodeTick });
|
||||
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_Fuse_Damages_Living_Player_And_Enemy_And_Destroys_Barrel()
|
||||
{
|
||||
var (world, group) = MakeWorld("BoomBoth");
|
||||
using (world)
|
||||
{
|
||||
var em = world.EntityManager;
|
||||
var barrel = MakeBarrel(em, new float3(10, 1, 10), explodeTick: T0); // due exactly now
|
||||
var player = MakePlayer(em, new float3(11, 1, 10), health: 100f, region: RegionId.Expedition);
|
||||
var enemy = MakeEnemy(em, new float3(10, 1, 12), health: 30f);
|
||||
|
||||
group.Update();
|
||||
|
||||
Assert.IsFalse(em.Exists(barrel), "An elapsed fuse destroys the barrel.");
|
||||
var pd = em.GetBuffer<DamageEvent>(player);
|
||||
Assert.AreEqual(1, pd.Length, "Friendly fire: the living expedition player in radius is hit.");
|
||||
Assert.AreEqual(Tuning.BarrelExplodeDamage, pd[0].Amount);
|
||||
Assert.AreEqual(-1, pd[0].SourceNetworkId, "Environment convention: never a player id (Charger punish scoring).");
|
||||
Assert.AreNotEqual(0u, pd[0].SourceTick, "SourceTick stamped at detonation (the authored-tick contract).");
|
||||
Assert.AreEqual(1, em.GetBuffer<DamageEvent>(enemy).Length, "The living enemy in radius is hit too (the bait mechanic).");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Dead_Player_And_Out_Of_Radius_Targets_Get_Nothing()
|
||||
{
|
||||
var (world, group) = MakeWorld("BoomFilters");
|
||||
using (world)
|
||||
{
|
||||
var em = world.EntityManager;
|
||||
MakeBarrel(em, new float3(10, 1, 10), explodeTick: T0);
|
||||
var deadPlayer = MakePlayer(em, new float3(10.5f, 1, 10), health: 0f, region: RegionId.Expedition);
|
||||
var farEnemy = MakeEnemy(em, new float3(10 + Tuning.BarrelExplodeRadius + 1f, 1, 10), health: 30f);
|
||||
|
||||
group.Update();
|
||||
|
||||
Assert.AreEqual(0, em.GetBuffer<DamageEvent>(deadPlayer).Length, "A dead player in radius gets NOTHING (the boss-slam filter).");
|
||||
Assert.AreEqual(0, em.GetBuffer<DamageEvent>(farEnemy).Length, "Out of radius gets nothing.");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Unelapsed_Fuse_Does_Nothing()
|
||||
{
|
||||
var (world, group) = MakeWorld("BoomWait");
|
||||
using (world)
|
||||
{
|
||||
var em = world.EntityManager;
|
||||
var barrel = MakeBarrel(em, new float3(10, 1, 10), explodeTick: T0 + 30); // still burning
|
||||
var player = MakePlayer(em, new float3(10.5f, 1, 10), health: 100f, region: RegionId.Expedition);
|
||||
|
||||
group.Update();
|
||||
|
||||
Assert.IsTrue(em.Exists(barrel), "A burning fuse leaves the barrel alive.");
|
||||
Assert.AreEqual(0, em.GetBuffer<DamageEvent>(player).Length, "No damage before detonation.");
|
||||
}
|
||||
}
|
||||
|
||||
// ---- pop-site half (ResourceHarvestSystem drives the fuse, never a direct destroy) ----
|
||||
|
||||
static (World world, SimulationSystemGroup group) MakeHarvestWorld(string name)
|
||||
{
|
||||
var world = new World(name);
|
||||
var group = world.GetOrCreateSystemManaged<SimulationSystemGroup>();
|
||||
group.AddSystemToUpdateList(world.GetOrCreateSystem<ResourceHarvestSystem>());
|
||||
group.SortSystems();
|
||||
world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f));
|
||||
var em = world.EntityManager;
|
||||
var ledger = em.CreateEntity(typeof(ResourceLedger));
|
||||
em.AddBuffer<StorageEntry>(ledger);
|
||||
var nt = em.CreateEntity(typeof(NetworkTime));
|
||||
em.SetComponentData(nt, new NetworkTime { ServerTick = new NetworkTick(T0) });
|
||||
return (world, group);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Explosive_Pop_Lights_The_Fuse_Instead_Of_Destroying_And_Is_Unhittable()
|
||||
{
|
||||
var (world, group) = MakeHarvestWorld("PopFuse");
|
||||
using (world)
|
||||
{
|
||||
var em = world.EntityManager;
|
||||
var barrel = em.CreateEntity();
|
||||
em.AddComponentData(barrel, LocalTransform.FromPosition(new float3(10, 1, 10)));
|
||||
em.AddComponentData(barrel, new HitRadius { Value = 1f });
|
||||
em.AddComponentData(barrel, new BlightClutter { Remaining = 2, Variant = 3, ScrapResourceId = ResourceId.Biomass, ScrapPerHit = 2f });
|
||||
var proj = em.CreateEntity();
|
||||
em.AddComponentData(proj, LocalTransform.FromPosition(new float3(10, 1, 10)));
|
||||
em.AddComponentData(proj, new Projectile { Direction = new float2(1, 0), LastStep = 5f });
|
||||
|
||||
group.Update();
|
||||
|
||||
Assert.IsTrue(em.Exists(barrel), "An explosive pop LIGHTS THE FUSE — the barrel survives the hit.");
|
||||
Assert.AreEqual(0, em.GetComponentData<BlightClutter>(barrel).Remaining, "Remaining=0 is the replicated fuse cue.");
|
||||
Assert.IsTrue(em.HasComponent<BarrelFuse>(barrel), "The fuse component schedules detonation.");
|
||||
Assert.AreEqual(TickUtil.NonZero(T0 + Tuning.BarrelFuseTicks), em.GetComponentData<BarrelFuse>(barrel).ExplodeTick);
|
||||
Assert.IsFalse(em.Exists(proj), "The popping projectile is consumed.");
|
||||
|
||||
// A lit barrel is unhittable: a second projectile flies through (no target on the segment).
|
||||
var proj2 = em.CreateEntity();
|
||||
em.AddComponentData(proj2, LocalTransform.FromPosition(new float3(10, 1, 10)));
|
||||
em.AddComponentData(proj2, new Projectile { Direction = new float2(0, 1), LastStep = 5f });
|
||||
group.Update();
|
||||
Assert.IsTrue(em.Exists(proj2), "A lit-fuse barrel is not a harvest target — the shot passes through.");
|
||||
Assert.IsTrue(em.Exists(barrel), "Still alive until detonation.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b4d2d1e676cddf54f9205fc04b92035f
|
||||
@@ -0,0 +1,71 @@
|
||||
---
|
||||
title: Exploding_Barrels_Build_Spec
|
||||
type: note
|
||||
permalink: gamevault/03-design/exploding-barrels-build-spec
|
||||
---
|
||||
|
||||
# Exploding Barrels — Build Spec (Phase 1.5 hazard v1, design-review-hardened)
|
||||
|
||||
Review: `wf_2cb10454-fdf` (3 lenses, 23 agents, 13 findings CONFIRMED / 3 refuted; the first run lost 8
|
||||
verifiers to a session limit and was resumed from cache — the [[workflow-agent-quota-failures-look-clean]]
|
||||
protocol held). This spec is the fold; it supersedes the pre-review draft.
|
||||
|
||||
## The core mechanism: the FUSE (one fix for both HIGHs)
|
||||
|
||||
On pop the barrel does NOT die. The server zeroes the barrel's REPLICATED `BlightClutter.Remaining` and adds
|
||||
a server-only `BarrelFuse { uint ExplodeTick }` (`TickUtil.NonZero(now + Tuning.BarrelFuseTicks≈36)`). The
|
||||
client therefore sees `Remaining==0` on a still-alive barrel across multiple snapshots → an unambiguous
|
||||
"about to blow" cue, and the boom-vs-puff choice at despawn keys on the CACHED `Remaining<=0` — a room-
|
||||
teardown despawn carries `Remaining>0` → **no false booms** (HIGH #1: despawn-inference is cause-blind;
|
||||
players stand in torn-down rooms through RouteSelect — RunDirectorSystem.cs:247-251, WorldFeedbackSystem
|
||||
prune :96-119). Detonation is the AUTHORING moment → `DamageEvent.SourceTick = TickUtil.NonZero(detonation
|
||||
tick)` honors the authored-tick contract dash i-frames negate against (HIGH #2; DamageEvent.cs:20-24).
|
||||
The fuse also: dissolves the melee self-hit complaint (pop → step out), creates the bait loop (lure enemies
|
||||
onto a lit barrel), and — because the fuse lives ON the RoomTag'd barrel — room teardown/Staging sweep
|
||||
clean fused barrels for free (the orphaned-ExplosionEvent lifecycle findings dissolve).
|
||||
|
||||
## Server
|
||||
|
||||
- **Seeding** (RoomFieldSystem clutter loop): `bc.Variant = crng.NextFloat() < 0.25f ? (byte)3 : (byte)(i % 3)`
|
||||
(seeded rng, deterministic; variants 0-2 stay cosmetic).
|
||||
- **Pop sites** (BOTH: ResourceHarvestSystem projectile sweep; MeleeComboSystem isServer-gated harvest block):
|
||||
gate on `isClutter && Variant==3` INSIDE the destroyed-bitset guard (nodes share the packed arrays with
|
||||
Variant slot 0 — confirmed LOW). On rem<=0 for an explosive: mark destroyed[i] (unhittable this tick),
|
||||
`SetComponent(BlightClutter{Remaining=0,...})` + `ecb.AddComponent(BarrelFuse{ExplodeTick})` — NO destroy.
|
||||
Both target snapshots SKIP entries with `Remaining <= 0` (a fused barrel is not hittable → no double-pop).
|
||||
- **`HazardExplosionSystem`** (new, ProjectM.Server, plain `SimulationSystemGroup`, NO ordering attributes,
|
||||
NO lifecycle gating — a fused barrel must always drain (confirmed LOW: never RequireForUpdate-gate it);
|
||||
`TryGetSingleton<NetworkTime>` invalid → skip tick, fuse persists): query `(BarrelFuse, LocalTransform)`;
|
||||
when `ExplodeTick` elapses (NetworkTick.IsNewerThan semantics): radius scan (XZ, `Tuning.BarrelExplodeRadius
|
||||
≈3.25`) — players via the BossAISystem:62-70 gather VERBATIM (`Health>0 && RegionTag==Expedition`; confirmed:
|
||||
dead players persist at death pos for DelayTicks) + enemies (`EnemyTag && Health>0`, excludes Dying corpses)
|
||||
→ `AppendToBuffer(DamageEvent{ Amount=Tuning.BarrelExplodeDamage≈26, SourceNetworkId=-1, SourceTick=
|
||||
TickUtil.NonZero(now) })` → `ecb.DestroyEntity(barrel)` (single destroy site).
|
||||
**SourceNetworkId = -1** (environment convention — confirmed: a player id would score+consume Charger
|
||||
whiff-punish windows via HealthApplyDamageSystem:102, the sole reader).
|
||||
- **No chain reactions** v1 (clutter has Remaining, not Health). Damage numbers as `Tuning` consts v1;
|
||||
promote to TuningConfig knobs when the knob-table extension is worth it (confirmed-LOW tension, deferred
|
||||
deliberately — noted here per [[ask-before-deferring-during-implementation]]: the overlay knob table is a
|
||||
separate mechanical slice).
|
||||
|
||||
## Client (all new work — confirmed: NO existing per-variant path; Variant is read by zero client systems)
|
||||
|
||||
- **WorldFeedbackSystem**: add `Variant` to the Cache struct; at prune, `IsClutter && Variant==3 &&
|
||||
Remaining<=0` → BIG boom (burst + `DynamicLightSystem.RequestFlash` large orange + low boom SFX); else the
|
||||
existing small puff. Teardown despawns (Remaining>0) keep the puff.
|
||||
- **DynamicLightSystem**: Variant==3 barrels get a red glow light (node-glow pattern); when the cached
|
||||
`Remaining<=0` (fuse lit) the glow strobes hard — the flee cue. (Body tint via URPMaterialPropertyBaseColor
|
||||
on LEG children = polish, deferred.)
|
||||
|
||||
## Tests
|
||||
|
||||
- ResourceHarvestSystemTests: explosive pop → BarrelFuse added, Remaining==0, entity ALIVE; fused barrel
|
||||
not re-hittable.
|
||||
- New HazardExplosionSystemTests: elapsed fuse → DamageEvent(Amount, SourceNetworkId=-1) on in-radius living
|
||||
enemy AND living player, dead player skipped, out-of-radius skipped, barrel destroyed once; un-elapsed
|
||||
fuse → nothing.
|
||||
|
||||
## Wire/bake classification
|
||||
|
||||
ZERO new [GhostField]s / ghost prefabs / RPCs; `BarrelFuse` is server-only; Variant==3 rides the existing
|
||||
replicated byte. No new ordering edges. Re-bake: none (code + seeded-value only).
|
||||
@@ -145,8 +145,11 @@ attacks/animations/effects**. Art direction deliberately UNDECIDED — lighting
|
||||
|
||||
### Long-term (server-sim; adversarial design-review REQUIRED per slice)
|
||||
|
||||
7. **Environmental hazards:** exploding barrels, Blight geysers, trap tiles — damage BOTH sides so enemies can be
|
||||
baited into them. Real combat interplay; replication + predicted-damage surface needs the review.
|
||||
7. **Environmental hazards — v1 SHIPPED (2026-07-10, `cd607ae15`, review-first `wf_2cb10454-fdf`):**
|
||||
exploding barrels — ~25% of clutter seeds explosive; pop lights a ~0.6s FUSE (replicated Remaining=0 =
|
||||
the client cue, strobing red glow) then radius-damages BOTH sides (bait mechanic; escapable). Zero new
|
||||
replication. Geysers/trap tiles = later hazard variants on the same HazardExplosionSystem chassis.
|
||||
See [[Exploding_Barrels_Build_Spec]].
|
||||
8. **Destructible cover:** in-room cover rocks take damage and crumble (Charger smashes through, players carve LOS).
|
||||
Structures already have Health; **re-validate the enemy stuck-fix backstop when cover can disappear mid-fight.**
|
||||
|
||||
|
||||
@@ -214,14 +214,28 @@ Verified live: a 12 s combat histogram showed zones rendering K0+K1 materials ke
|
||||
Client presentation file that only used Simulation types before). Remaining distinctness depth (per-kind
|
||||
anim clips, creature locomotion) rides the parallel Blender workstream.
|
||||
|
||||
## Part J — exploding barrels SHIPPED (`cd607ae15`; 07-10, session continued past midnight)
|
||||
|
||||
The 1.5 long-term hazard v1, **review-first as mandated**: 3-lens adversarial workflow `wf_2cb10454-fdf`
|
||||
(23 agents; the first run lost 8 verifiers to the session limit — resumed via `resumeFromRunId`, cache
|
||||
replayed, 13 findings CONFIRMED / 3 refuted; Build Spec kept at [[Exploding_Barrels_Build_Spec]]).
|
||||
**The fold: the FUSE** — one mechanism closed both HIGHs (despawn-inference false booms on room teardown ·
|
||||
the SourceTick authored-tick contract): pop → replicated `Remaining=0` on a still-alive barrel + server-only
|
||||
`BarrelFuse` (~36 ticks) → detonation damages LIVING enemies AND players (boss-slam filter verbatim,
|
||||
`SourceNetworkId=-1`, SourceTick at detonation) → single destroy. Client: boom-vs-puff keys on cached
|
||||
`Remaining<=0` (teardowns carry >0 → no false booms); Variant-3 barrels get a red glow that STROBES when
|
||||
fused. ~25% seeding on the existing replicated Variant byte — **zero new GhostFields/prefabs/RPCs**.
|
||||
Verified: 470/470 (4 new tests) · live smoke: client OBSERVED the fuse cue on the live ghost, pinned bait
|
||||
enemy took exactly 26 (30→4), walk-out escape confirmed (fuse is escapable by design — first unpinned bait
|
||||
simply left the radius) · console clean.
|
||||
|
||||
## Next-session intent
|
||||
|
||||
**Phase 1.5 short-term wins are COMPLETE** (collision ✓ lighting ✓ art-look ✓ ambient motion ✓ breakables ✓
|
||||
attack distinctness ✓). Next: either the 1.5 long-term slices (**environmental hazards** / **destructible
|
||||
cover** — BOTH adversarial design-review-first, and cover must re-validate the enemy backstop) or jump to
|
||||
**Phase 1.7 boon overhaul** (~12 mechanic-changers; design review required — replicated ability swaps).
|
||||
Operator gate: a feel-lap over the whole 1.5 package ("no longer static"?) decides. Queued taste items:
|
||||
re-enable curated base flora · arid brightness · craters call · B5 split-panels smoke.
|
||||
Phase 1.5 remaining: **destructible cover** (the last long-term item — design-review-first, MUST re-validate
|
||||
the enemy stuck-fix backstop when cover can vanish mid-fight) → then the 1.5 gate lap → **Phase 1.7 boon
|
||||
overhaul** (design review required — replicated ability swaps). Queued taste items: re-enable curated base
|
||||
flora · arid brightness · craters call · B5 split-panels smoke · barrel VFX polish (authored explosion
|
||||
prefab instead of the procedural burst).
|
||||
|
||||
Related: [[Iteration_2026-07_CoopHades]] · [[2026-07-07_Workflow_Consolidation]] ·
|
||||
[[2026-07-07_Expedition_Enemy_Stuck_Harvest_Feedback]]
|
||||
|
||||
Reference in New Issue
Block a user