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,15 +337,24 @@ namespace ProjectM.Client
var c = _cache[_stale[i]];
if (c.IsEnemy)
{
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);
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]);
}