Phase 1 B3: enemies die with a corpse window instead of popping out of existence

Server: HealthApplyDamageSystem marks EnemyTag Dying{UntilTick} (TickUtil.
NonZero, ~54 ticks) on the lethal 0-crossing instead of instant destroy,
zeroes every live cue (replicated AttackWindup, LungeState + IsLunging bit,
KnockbackState), destroys on expiry; plain-world tests keep instant destroy
(no NetworkTime) so the suite's assertions stay meaningful.

Every consumer now ignores corpses (all confirmed entity-count/unfiltered by
the design review): EnemyAISystem all 4 passes, BossAISystem brain + summon
cap, RoomEnemyDirector room-clear + MaxAlive fit, WaveSystem cap + cleared,
CyclePhaseSystem breach wipe + DefendCleared, ThreatDirector timeout cull,
TurretFire targeting, CoreDamage drain, ProjectileDamage snapshot (corpses
are not shields), AbilityFire auto-aim candidates, debug cull + telemetry.
Wipe passes skip Dying to avoid cross-ECB double-destroys.

Client: EnemyAnimationDriveSystem finally drives the controller's IsDead
param (Health.Current<=0 is the replicated death read; corpse locomotion
zeroed); CombatFeedbackSystem moves the kill CRUNCH to the 0-crossing (the
prune-edge timing would land it ~1 s late) - the prune keeps a small
dissolve puff for corpses and the legacy full read for alive-vanish culls.

456/456 EditMode.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-06 18:34:12 -07:00
parent 358ac14b06
commit 3836e9c842
18 changed files with 125 additions and 33 deletions
@@ -275,6 +275,21 @@ namespace ProjectM.Client
EmitColored(_hitFx, (Vector3)p + Vector3.up * 0.7f, FeelConfig.HitFlashBurstCount, FeelConfig.HitFlashColor);
if (FeelConfig.RumbleEnabled && AimPresentation.Scheme == 1)
RumbleUtil.Pulse(FeelConfig.RumbleHit * 0.6f, FeelConfig.RumbleHit, FeelConfig.RumbleDurationSec);
// B3: the kill CRUNCH fires at the 0-CROSSING (the moment of death) — the corpse now
// lingers ~0.9 s playing its death anim, so the old prune-edge timing would land the
// whole package a second late. The prune keeps only a small despawn puff.
if (cur <= 0f && prev.Hp > 0f)
{
Burst(_deathFx, cfg != null ? cfg.EnemyDeath : null, (Vector3)p + Vector3.up * 0.5f, Mathf.Max(1, Mathf.RoundToInt(FeelConfig.DeathBurstCount * FeelConfig.KillBurstScale)));
PlayClip(_deathClip, (Vector3)p, FeelConfig.KillSfxVolume);
PrototypeCameraRig.AddShake(FeelConfig.KillShake);
PrototypeCameraRig.PunchFov(FeelConfig.KillFovKick, FeelConfig.HitStopDurationMs);
TryHold(); // C4: kill crunch (throttled)
EmitColored(_hitFx, (Vector3)p + Vector3.up * 0.6f, FeelConfig.KillFlashBurstCount, FeelConfig.HitFlashColor);
if (FeelConfig.RumbleEnabled && AimPresentation.Scheme == 1)
RumbleUtil.Pulse(FeelConfig.RumbleKill * 0.7f, FeelConfig.RumbleKill, FeelConfig.RumbleDurationSec);
}
}
}
@@ -322,16 +337,25 @@ namespace ProjectM.Client
var c = _cache[_stale[i]];
if (c.IsEnemy)
{
if (c.Hp > 0f)
{
// Vanished while ALIVE (siege wipe / timeout cull / relevancy) — keep the legacy full read.
Burst(_deathFx, cfg != null ? cfg.EnemyDeath : null, (Vector3)c.Pos + Vector3.up * 0.5f, Mathf.Max(1, Mathf.RoundToInt(FeelConfig.DeathBurstCount * FeelConfig.KillBurstScale)));
PlayClip(_deathClip, (Vector3)c.Pos, FeelConfig.KillSfxVolume);
PrototypeCameraRig.AddShake(FeelConfig.KillShake);
PrototypeCameraRig.PunchFov(FeelConfig.KillFovKick, FeelConfig.HitStopDurationMs);
TryHold(); // C4: kill crunch (throttled)
EmitColored(_hitFx, (Vector3)c.Pos + Vector3.up * 0.6f, FeelConfig.KillFlashBurstCount, FeelConfig.HitFlashColor); // kill pop
if (FeelConfig.RumbleEnabled && AimPresentation.Scheme == 1)
RumbleUtil.Pulse(FeelConfig.RumbleKill * 0.7f, FeelConfig.RumbleKill, FeelConfig.RumbleDurationSec);
}
else
{
// B3: corpse despawn after the death window — the kill crunch already fired at the
// 0-crossing; the corpse just dissolves.
Burst(_deathFx, cfg != null ? cfg.EnemyDeath : null, (Vector3)c.Pos + Vector3.up * 0.3f, Mathf.Max(1, FeelConfig.DeathBurstCount / 2));
}
}
_cache.Remove(_stale[i]);
}
}
@@ -41,6 +41,7 @@ namespace ProjectM.Client
static readonly FastAnimatorParameter k_MoveZ = new FastAnimatorParameter("MoveZ");
static readonly FastAnimatorParameter k_Speed = new FastAnimatorParameter("Speed");
static readonly FastAnimatorParameter k_IsAttacking = new FastAnimatorParameter("IsAttacking");
static readonly FastAnimatorParameter k_IsDead = new FastAnimatorParameter("IsDead"); // B3: the controller's Death state existed but was never driven
// prevPos cache (per Husk Entity). Pruned every frame (a vanished Husk = a server-authoritative death).
NativeParallelHashMap<Entity, float3> _prevPos;
@@ -63,7 +64,7 @@ namespace ProjectM.Client
var seen = new NativeParallelHashSet<Entity>(64, Allocator.TempJob);
var job = new EnemyDriveJob
{
moveX = k_MoveX, moveZ = k_MoveZ, speed = k_Speed, isAttacking = k_IsAttacking,
moveX = k_MoveX, moveZ = k_MoveZ, speed = k_Speed, isAttacking = k_IsAttacking, isDead = k_IsDead,
dt = dt,
prevPos = _prevPos,
seen = seen,
@@ -91,7 +92,7 @@ namespace ProjectM.Client
[WithAll(typeof(EnemyTag))]
partial struct EnemyDriveJob : IJobEntity
{
public FastAnimatorParameter moveX, moveZ, speed, isAttacking;
public FastAnimatorParameter moveX, moveZ, speed, isAttacking, isDead;
[Unity.Collections.ReadOnly] public ComponentLookup<IsLunging> isLunging; // A7: a lunge has no AttackWindup; OR it in so the boss/Charger attack anim plays during the committed lunge
public float dt;
@@ -104,7 +105,8 @@ namespace ProjectM.Client
DynamicBuffer<AnimatorControllerParameterComponent> parametersArr,
in LocalTransform xform,
in EnemyStats stats,
in AttackWindup windup)
in AttackWindup windup,
in Health health)
{
seen.Add(e);
float3 cur = xform.Position;
@@ -117,11 +119,17 @@ namespace ProjectM.Client
float3 p = AnimParamMath.LocomotionParams(vel, facing, stats.MoveSpeed);
bool attacking = windup.WindUpUntilTick != 0 || (isLunging.HasComponent(e) && isLunging.IsComponentEnabled(e)); // A7: the committed lunge (which zeroes AttackWindup) still animates as an attack
// B3: the corpse window — Health.Current is replicated, so <=0 IS the death read. A corpse
// plays the Death state and nothing else (no jog, no frozen attack).
bool dead = health.Current <= 0f;
if (dead) { p = float3.zero; attacking = false; }
var a = new AnimatorParametersAspect(parametersArr, indexTable);
if (a.HasParameter(moveX)) a.SetParameterValue(moveX, p.x);
if (a.HasParameter(moveZ)) a.SetParameterValue(moveZ, p.y);
if (a.HasParameter(speed)) a.SetParameterValue(speed, p.z);
if (a.HasParameter(isAttacking)) a.SetParameterValue(isAttacking, attacking);
if (a.HasParameter(isDead)) a.SetParameterValue(isDead, dead);
}
}
}
@@ -45,7 +45,7 @@ namespace ProjectM.Server
var huskRegion = new NativeList<byte>(Allocator.Temp);
foreach (var (xform, health, region, e) in
SystemAPI.Query<RefRO<LocalTransform>, RefRO<Health>, RefRO<RegionTag>>()
.WithAll<EnemyTag>().WithEntityAccess())
.WithAll<EnemyTag>().WithNone<Dying>().WithEntityAccess()) // LIVING targets only (B3)
{
if (health.ValueRO.Current <= 0f)
continue;
@@ -39,9 +39,9 @@ namespace ProjectM.Server
public void OnCreate(ref SystemState state)
{
state.RequireForUpdate<NetworkTime>();
m_Bosses = state.GetEntityQuery(ComponentType.ReadOnly<EnemyTag>(), ComponentType.ReadOnly<BossState>());
m_Bosses = state.GetEntityQuery(ComponentType.ReadOnly<EnemyTag>(), ComponentType.ReadOnly<BossState>(), ComponentType.Exclude<Dying>());
state.RequireForUpdate(m_Bosses);
m_ZoneEnemies = state.GetEntityQuery(ComponentType.ReadOnly<ZoneEnemyTag>());
m_ZoneEnemies = state.GetEntityQuery(ComponentType.ReadOnly<ZoneEnemyTag>(), ComponentType.Exclude<Dying>()); // summon cap counts LIVING only (B3)
}
[BurstCompile]
@@ -97,7 +97,7 @@ namespace ProjectM.Server
foreach (var (xform, stats, health, boss, windup, knockback) in
SystemAPI.Query<RefRW<LocalTransform>, RefRO<EnemyStats>, RefRO<Health>, RefRW<BossState>,
RefRW<AttackWindup>, RefRW<KnockbackState>>()
.WithAll<EnemyTag, BossState>())
.WithAll<EnemyTag, BossState>().WithNone<Dying>())
{
float3 pos = xform.ValueRO.Position;
@@ -108,7 +108,7 @@ namespace ProjectM.Server
foreach (var (xform, stats, cooldown, knockback, windup, region) in
SystemAPI.Query<RefRW<LocalTransform>, RefRO<EnemyStats>, RefRW<EnemyAttackCooldown>,
RefRW<KnockbackState>, RefRW<AttackWindup>, RefRO<RegionTag>>()
.WithAll<EnemyTag>().WithNone<LungeState, SpitterState>())
.WithAll<EnemyTag>().WithNone<LungeState, SpitterState, Dying>())
{
float3 pos = xform.ValueRO.Position;
byte huskRegion = region.ValueRO.Region;
@@ -223,7 +223,7 @@ namespace ProjectM.Server
foreach (var (xform, stats, cooldown, knockback, windup, lunge, region) in
SystemAPI.Query<RefRW<LocalTransform>, RefRO<EnemyStats>, RefRW<EnemyAttackCooldown>,
RefRW<KnockbackState>, RefRW<AttackWindup>, RefRW<LungeState>, RefRO<RegionTag>>()
.WithAll<EnemyTag>().WithNone<SpitterState, BossState>())
.WithAll<EnemyTag>().WithNone<SpitterState, BossState, Dying>())
{
float3 pos = xform.ValueRO.Position;
byte cHuskRegion = region.ValueRO.Region;
@@ -363,7 +363,7 @@ namespace ProjectM.Server
foreach (var (xform, stats, knockback, windup, spitter, region) in
SystemAPI.Query<RefRW<LocalTransform>, RefRO<EnemyStats>, RefRW<KnockbackState>,
RefRW<AttackWindup>, RefRW<SpitterState>, RefRO<RegionTag>>()
.WithAll<EnemyTag, SpitterState>().WithNone<LungeState>())
.WithAll<EnemyTag, SpitterState>().WithNone<LungeState, Dying>())
{
float3 pos = xform.ValueRO.Position;
byte sRegion = region.ValueRO.Region;
@@ -470,7 +470,7 @@ namespace ProjectM.Server
// Charger whose bit is currently DISABLED is still visited (Entities default-excludes disabled enableables).
foreach (var (lunge, isLunging) in
SystemAPI.Query<RefRO<LungeState>, EnabledRefRW<IsLunging>>()
.WithAll<EnemyTag>().WithPresent<IsLunging>())
.WithAll<EnemyTag>().WithPresent<IsLunging>().WithNone<Dying>())
{
isLunging.ValueRW = lunge.ValueRO.UntilTick != 0u; // lunging iff a committed lunge is live this tick
}
@@ -132,13 +132,46 @@ namespace ProjectM.Server
health.ValueRW.Current = newHp;
// Server-authoritative death: training dummies + enemies + EB-1 Destructible structures despawn;
// Server-authoritative death: training dummies + EB-1 Destructible structures despawn instantly;
// player death is deferred (clamp only). A structure carries NO EffectiveCharacterStats, so it took
// the math.max(0,..) branch above and CAN reach 0 — never give a structure stats (it would clamp to
// a non-zero floor and become immortal).
if (health.ValueRO.Current <= 0f && (SystemAPI.HasComponent<TrainingDummyTag>(entity) || SystemAPI.HasComponent<EnemyTag>(entity) || SystemAPI.HasComponent<Destructible>(entity)))
// Phase 1 (B3): ENEMIES get a corpse window instead — mark Dying ONCE on the lethal crossing and
// zero every live cue (the replicated windup, the lunge state + ghost bit, knockback) so clients
// never see a corpse telegraphing. Plain-world EditMode tests have no NetworkTime → keep the old
// instant destroy there so the existing suite still means what it asserts.
if (health.ValueRO.Current <= 0f)
{
if (SystemAPI.HasComponent<EnemyTag>(entity) && haveTick && netTime.ServerTick.IsValid)
{
if (!SystemAPI.HasComponent<Dying>(entity))
{
ecb.AddComponent(entity, new Dying
{
UntilTick = TickUtil.NonZero(netTime.ServerTick.TickIndexForValidTick + Tuning.EnemyDeathWindowTicks),
});
if (SystemAPI.HasComponent<AttackWindup>(entity)) SystemAPI.SetComponent(entity, default(AttackWindup));
if (SystemAPI.HasComponent<KnockbackState>(entity)) SystemAPI.SetComponent(entity, default(KnockbackState));
if (SystemAPI.HasComponent<LungeState>(entity)) SystemAPI.SetComponent(entity, default(LungeState));
if (SystemAPI.HasComponent<IsLunging>(entity)) SystemAPI.SetComponentEnabled<IsLunging>(entity, false);
}
}
else if (SystemAPI.HasComponent<TrainingDummyTag>(entity) || SystemAPI.HasComponent<EnemyTag>(entity) || SystemAPI.HasComponent<Destructible>(entity))
ecb.DestroyEntity(entity);
}
}
// Phase 1 (B3): corpse expiry — destroy Dying enemies whose window elapsed (at-most-once: the Dying
// mark is the only path here, and destruction removes it). NetworkTick compare, never raw uint math.
if (haveTick && netTime.ServerTick.IsValid)
{
foreach (var (dying, entity) in SystemAPI.Query<RefRO<Dying>>().WithEntityAccess())
{
var until = new NetworkTick(dying.ValueRO.UntilTick);
if (until.IsValid && !until.IsNewerThan(netTime.ServerTick))
ecb.DestroyEntity(entity);
}
}
if ((negatedThisTick != 0u || punishesThisTick != 0u) && SystemAPI.HasSingleton<DevTelemetry>())
{
var telem = SystemAPI.GetSingletonRW<DevTelemetry>();
@@ -83,6 +83,7 @@ namespace ProjectM.Server
foreach (var (xform, hitRadius, targetEntity) in
SystemAPI.Query<RefRO<LocalTransform>, RefRO<HitRadius>>()
.WithAll<Health>()
.WithNone<Dying>() // B3: corpses are not shields - a shot passes through to living targets
.WithEntityAccess())
{
targetEntities.Add(targetEntity);
@@ -45,7 +45,7 @@ namespace ProjectM.Server
state.RequireForUpdate<RunInfo>();
state.RequireForUpdate<RunRuntime>();
state.RequireForUpdate<ZoneEnemyDirector>();
m_ZoneEnemies = state.GetEntityQuery(ComponentType.ReadOnly<ZoneEnemyTag>());
m_ZoneEnemies = state.GetEntityQuery(ComponentType.ReadOnly<ZoneEnemyTag>(), ComponentType.Exclude<Dying>()); // room clear + MaxAlive fit count LIVING only (B3) - corpses neither hold the room open nor crowd out spawns
}
[BurstCompile]
@@ -98,7 +98,7 @@ namespace ProjectM.Server
// Live BASE husks for the entity cap (expedition zone enemies are EnemyTag too -> excluded).
int aliveBase = 0;
foreach (var hr in SystemAPI.Query<RefRO<RegionTag>>().WithAll<EnemyTag>())
foreach (var hr in SystemAPI.Query<RefRO<RegionTag>>().WithAll<EnemyTag>().WithNone<Dying>())
if (hr.ValueRO.Region == RegionId.Base) aliveBase++;
// MaxAlive counts ENTITIES; spawn the whole pack only if it fits (else WAIT — don't consume the slot).
@@ -134,7 +134,7 @@ namespace ProjectM.Server
// Wave fully spawned: cleared only when no BASE husk remains. Expedition zone enemies are also
// EnemyTag but RegionTag{Expedition}; they must NOT hold the base siege open (DR-040 BLOCKER 3).
int baseHusks = 0;
foreach (var hr in SystemAPI.Query<RefRO<RegionTag>>().WithAll<EnemyTag>())
foreach (var hr in SystemAPI.Query<RefRO<RegionTag>>().WithAll<EnemyTag>().WithNone<Dying>())
if (hr.ValueRO.Region == RegionId.Base) baseHusks++;
if (baseHusks == 0)
{
@@ -26,7 +26,7 @@ namespace ProjectM.Server
public void OnCreate(ref SystemState state)
{
m_Husks = state.GetEntityQuery(ComponentType.ReadOnly<EnemyTag>());
m_Husks = state.GetEntityQuery(ComponentType.ReadOnly<EnemyTag>(), ComponentType.Exclude<Dying>()); // corpses expire on their own (B3; avoids a cross-ECB double-destroy)
var builder = new EntityQueryBuilder(Allocator.Temp)
.WithAll<DebugCommandRequest, ReceiveRpcCommandRequest>();
state.RequireForUpdate(state.GetEntityQuery(builder));
@@ -24,7 +24,7 @@ namespace ProjectM.Server
public void OnCreate(ref SystemState state)
{
state.RequireForUpdate<NetworkTime>();
m_Husks = state.GetEntityQuery(ComponentType.ReadOnly<EnemyTag>());
m_Husks = state.GetEntityQuery(ComponentType.ReadOnly<EnemyTag>(), ComponentType.Exclude<Dying>()); // telemetry counts LIVING (B3)
if (state.GetEntityQuery(ComponentType.ReadWrite<DevTelemetry>()).IsEmpty)
state.EntityManager.CreateEntity(typeof(DevTelemetry));
}
@@ -58,7 +58,7 @@ namespace ProjectM.Server
var ecb = new EntityCommandBuffer(Allocator.Temp);
int drained = 0;
foreach (var (xform, entity) in
SystemAPI.Query<RefRO<LocalTransform>>().WithAll<EnemyTag>().WithEntityAccess())
SystemAPI.Query<RefRO<LocalTransform>>().WithAll<EnemyTag>().WithNone<Dying>().WithEntityAccess()) // corpses don't drain the Core (B3)
{
if (!EnemyAIMath.InAttackRange(xform.ValueRO.Position, corePos, CoreReachRadius))
continue;
@@ -107,7 +107,7 @@ namespace ProjectM.Server
// Slice 3: cull the BASE wave only — an Expedition wave runs in its own region and must
// survive a base Core breach. A region-blind EnemyTag wipe would also spuriously trip the
// zone director's aliveZone==0 clear/reward edge. Mirrors ThreatDirectorSystem + DefendCleared.
foreach (var (hr, he) in SystemAPI.Query<RefRO<RegionTag>>().WithAll<EnemyTag>().WithEntityAccess())
foreach (var (hr, he) in SystemAPI.Query<RefRO<RegionTag>>().WithAll<EnemyTag>().WithNone<Dying>().WithEntityAccess()) // skip corpses: the B3 expiry pass owns their destroy (cross-ECB double-destroy)
if (hr.ValueRO.Region == RegionId.Base)
ecb.DestroyEntity(he);
ecb.Playback(state.EntityManager);
@@ -185,7 +185,7 @@ namespace ProjectM.Server
// Cleared only when no BASE husk remains: expedition zone enemies (EnemyTag + RegionTag{Expedition})
// must not hold the base siege open (DR-040 BLOCKER 3 — same global-count soft-lock as WaveSystem).
int baseHusks = 0;
foreach (var hr in SystemAPI.Query<RefRO<RegionTag>>().WithAll<EnemyTag>())
foreach (var hr in SystemAPI.Query<RefRO<RegionTag>>().WithAll<EnemyTag>().WithNone<Dying>()) // LIVING only (B3)
if (hr.ValueRO.Region == RegionId.Base) baseHusks++;
return wave.WaveNumber > defendStartWave
&& wave.RemainingToSpawn == 0
@@ -111,7 +111,7 @@ namespace ProjectM.Server
// Collapse the siege: cull every remaining BASE Husk only (expedition zone enemies are also
// EnemyTag but RegionTag{Expedition}; the timeout must not destroy them — DR-040 BLOCKER 3).
var ecb = new EntityCommandBuffer(Allocator.Temp);
foreach (var (hr, he) in SystemAPI.Query<RefRO<RegionTag>>().WithAll<EnemyTag>().WithEntityAccess())
foreach (var (hr, he) in SystemAPI.Query<RefRO<RegionTag>>().WithAll<EnemyTag>().WithNone<Dying>().WithEntityAccess()) // skip corpses: the B3 expiry pass owns their destroy
if (hr.ValueRO.Region == RegionId.Base)
ecb.DestroyEntity(he);
ecb.Playback(state.EntityManager);
@@ -82,8 +82,9 @@ namespace ProjectM.Simulation
foreach (var (tx, th, te) in
SystemAPI.Query<RefRO<LocalTransform>, RefRO<Health>>().WithAny<TrainingDummyTag, EnemyTag>().WithEntityAccess())
{
if (th.ValueRO.Current <= 0f) continue; // B3: corpses are neither aim magnets nor cleave targets
candidatePositions.Add(tx.ValueRO.Position);
if (th.ValueRO.Current > 0f) { coneTargets.Add(te); coneTargetPos.Add(tx.ValueRO.Position); }
coneTargets.Add(te); coneTargetPos.Add(tx.ValueRO.Position);
}
}
var candidates = candidatePositions.AsArray();
@@ -0,0 +1,19 @@
using Unity.Entities;
namespace ProjectM.Simulation
{
/// <summary>
/// Phase 1 (B3): the enemy corpse window. Stamped ONCE by <c>HealthApplyDamageSystem</c> on the lethal
/// 0-crossing (server-only, never replicated — clients derive the death read from the replicated
/// <c>Health.Current&lt;=0</c>); the entity is destroyed when ServerTick reaches <see cref="UntilTick"/>.
/// While present the enemy is a CORPSE: every actor pass (AI move/windup/contact, boss kit), every
/// LIVING count (room clear, HUD Remaining, MaxAlive fit, summon cap, siege cleared) and every target
/// snapshot (projectiles, melee, turrets, auto-aim, aggro) must skip it — a corpse neither acts, counts,
/// nor soaks hits (design review wf_fd177263: all counts were entity-count based, all snapshots unfiltered).
/// </summary>
public struct Dying : IComponentData
{
/// <summary>Server tick the corpse despawns (via <c>TickUtil.NonZero</c>; compared via <c>NetworkTick</c>).</summary>
public uint UntilTick;
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 3b794b215be5185499725afc93d9d78c
@@ -95,6 +95,10 @@ namespace ProjectM.Simulation
/// polish; an idle player died in ~2 s of landing). Subsequent slots keep the normal drip cadence.</summary>
public const uint RoomEntryGraceTicks = 100;
/// <summary>Phase 1 (B3) corpse window (~0.9 s @60): a dead enemy lingers as a non-acting, non-counting,
/// non-targetable corpse so the client death animation has time to read before the despawn burst.</summary>
public const uint EnemyDeathWindowTicks = 54;
// ---- 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