From 3836e9c842e9526bb90098553ba6c1749dc93edc Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Mon, 6 Jul 2026 18:34:12 -0700 Subject: [PATCH] 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 --- .../Presentation/CombatFeedbackSystem.cs | 42 +++++++++++++++---- .../Presentation/EnemyAnimationDriveSystem.cs | 14 +++++-- .../Server/Building/TurretFireSystem.cs | 2 +- .../Scripts/Server/Combat/BossAISystem.cs | 6 +-- .../Scripts/Server/Combat/EnemyAISystem.cs | 8 ++-- .../Server/Combat/HealthApplyDamageSystem.cs | 39 +++++++++++++++-- .../Server/Combat/ProjectileDamageSystem.cs | 1 + .../Server/Combat/RoomEnemyDirectorSystem.cs | 2 +- .../Scripts/Server/Combat/WaveSystem.cs | 4 +- .../Server/Debug/DebugCommandReceiveSystem.cs | 2 +- .../Server/Debug/DevTelemetrySystem.cs | 2 +- .../Scripts/Server/World/CoreDamageSystem.cs | 2 +- .../Scripts/Server/World/CyclePhaseSystem.cs | 4 +- .../Server/World/ThreatDirectorSystem.cs | 2 +- .../Simulation/Combat/AbilityFireSystem.cs | 3 +- .../Scripts/Simulation/Combat/Dying.cs | 19 +++++++++ .../Scripts/Simulation/Combat/Dying.cs.meta | 2 + Assets/_Project/Scripts/Simulation/Tuning.cs | 4 ++ 18 files changed, 125 insertions(+), 33 deletions(-) create mode 100644 Assets/_Project/Scripts/Simulation/Combat/Dying.cs create mode 100644 Assets/_Project/Scripts/Simulation/Combat/Dying.cs.meta diff --git a/Assets/_Project/Scripts/Client/Presentation/CombatFeedbackSystem.cs b/Assets/_Project/Scripts/Client/Presentation/CombatFeedbackSystem.cs index 0b98e729e..0321a7030 100644 --- a/Assets/_Project/Scripts/Client/Presentation/CombatFeedbackSystem.cs +++ b/Assets/_Project/Scripts/Client/Presentation/CombatFeedbackSystem.cs @@ -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]); } diff --git a/Assets/_Project/Scripts/Client/Presentation/EnemyAnimationDriveSystem.cs b/Assets/_Project/Scripts/Client/Presentation/EnemyAnimationDriveSystem.cs index c47279482..fc7e85e77 100644 --- a/Assets/_Project/Scripts/Client/Presentation/EnemyAnimationDriveSystem.cs +++ b/Assets/_Project/Scripts/Client/Presentation/EnemyAnimationDriveSystem.cs @@ -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 _prevPos; @@ -63,7 +64,7 @@ namespace ProjectM.Client var seen = new NativeParallelHashSet(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; // 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 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); } } } diff --git a/Assets/_Project/Scripts/Server/Building/TurretFireSystem.cs b/Assets/_Project/Scripts/Server/Building/TurretFireSystem.cs index 1e8259446..f08031134 100644 --- a/Assets/_Project/Scripts/Server/Building/TurretFireSystem.cs +++ b/Assets/_Project/Scripts/Server/Building/TurretFireSystem.cs @@ -45,7 +45,7 @@ namespace ProjectM.Server var huskRegion = new NativeList(Allocator.Temp); foreach (var (xform, health, region, e) in SystemAPI.Query, RefRO, RefRO>() - .WithAll().WithEntityAccess()) + .WithAll().WithNone().WithEntityAccess()) // LIVING targets only (B3) { if (health.ValueRO.Current <= 0f) continue; diff --git a/Assets/_Project/Scripts/Server/Combat/BossAISystem.cs b/Assets/_Project/Scripts/Server/Combat/BossAISystem.cs index d452aa03e..8d1b666db 100644 --- a/Assets/_Project/Scripts/Server/Combat/BossAISystem.cs +++ b/Assets/_Project/Scripts/Server/Combat/BossAISystem.cs @@ -39,9 +39,9 @@ namespace ProjectM.Server public void OnCreate(ref SystemState state) { state.RequireForUpdate(); - m_Bosses = state.GetEntityQuery(ComponentType.ReadOnly(), ComponentType.ReadOnly()); + m_Bosses = state.GetEntityQuery(ComponentType.ReadOnly(), ComponentType.ReadOnly(), ComponentType.Exclude()); state.RequireForUpdate(m_Bosses); - m_ZoneEnemies = state.GetEntityQuery(ComponentType.ReadOnly()); + m_ZoneEnemies = state.GetEntityQuery(ComponentType.ReadOnly(), ComponentType.Exclude()); // 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, RefRO, RefRO, RefRW, RefRW, RefRW>() - .WithAll()) + .WithAll().WithNone()) { float3 pos = xform.ValueRO.Position; diff --git a/Assets/_Project/Scripts/Server/Combat/EnemyAISystem.cs b/Assets/_Project/Scripts/Server/Combat/EnemyAISystem.cs index 4176d6c5a..6f647ce47 100644 --- a/Assets/_Project/Scripts/Server/Combat/EnemyAISystem.cs +++ b/Assets/_Project/Scripts/Server/Combat/EnemyAISystem.cs @@ -108,7 +108,7 @@ namespace ProjectM.Server foreach (var (xform, stats, cooldown, knockback, windup, region) in SystemAPI.Query, RefRO, RefRW, RefRW, RefRW, RefRO>() - .WithAll().WithNone()) + .WithAll().WithNone()) { 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, RefRO, RefRW, RefRW, RefRW, RefRW, RefRO>() - .WithAll().WithNone()) + .WithAll().WithNone()) { 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, RefRO, RefRW, RefRW, RefRW, RefRO>() - .WithAll().WithNone()) + .WithAll().WithNone()) { 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, EnabledRefRW>() - .WithAll().WithPresent()) + .WithAll().WithPresent().WithNone()) { isLunging.ValueRW = lunge.ValueRO.UntilTick != 0u; // lunging iff a committed lunge is live this tick } diff --git a/Assets/_Project/Scripts/Server/Combat/HealthApplyDamageSystem.cs b/Assets/_Project/Scripts/Server/Combat/HealthApplyDamageSystem.cs index 97989aac9..9334c83d7 100644 --- a/Assets/_Project/Scripts/Server/Combat/HealthApplyDamageSystem.cs +++ b/Assets/_Project/Scripts/Server/Combat/HealthApplyDamageSystem.cs @@ -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(entity) || SystemAPI.HasComponent(entity) || SystemAPI.HasComponent(entity))) - ecb.DestroyEntity(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(entity) && haveTick && netTime.ServerTick.IsValid) + { + if (!SystemAPI.HasComponent(entity)) + { + ecb.AddComponent(entity, new Dying + { + UntilTick = TickUtil.NonZero(netTime.ServerTick.TickIndexForValidTick + Tuning.EnemyDeathWindowTicks), + }); + if (SystemAPI.HasComponent(entity)) SystemAPI.SetComponent(entity, default(AttackWindup)); + if (SystemAPI.HasComponent(entity)) SystemAPI.SetComponent(entity, default(KnockbackState)); + if (SystemAPI.HasComponent(entity)) SystemAPI.SetComponent(entity, default(LungeState)); + if (SystemAPI.HasComponent(entity)) SystemAPI.SetComponentEnabled(entity, false); + } + } + else if (SystemAPI.HasComponent(entity) || SystemAPI.HasComponent(entity) || SystemAPI.HasComponent(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>().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()) { var telem = SystemAPI.GetSingletonRW(); diff --git a/Assets/_Project/Scripts/Server/Combat/ProjectileDamageSystem.cs b/Assets/_Project/Scripts/Server/Combat/ProjectileDamageSystem.cs index f5679f4c3..cc2e8afc7 100644 --- a/Assets/_Project/Scripts/Server/Combat/ProjectileDamageSystem.cs +++ b/Assets/_Project/Scripts/Server/Combat/ProjectileDamageSystem.cs @@ -83,6 +83,7 @@ namespace ProjectM.Server foreach (var (xform, hitRadius, targetEntity) in SystemAPI.Query, RefRO>() .WithAll() + .WithNone() // B3: corpses are not shields - a shot passes through to living targets .WithEntityAccess()) { targetEntities.Add(targetEntity); diff --git a/Assets/_Project/Scripts/Server/Combat/RoomEnemyDirectorSystem.cs b/Assets/_Project/Scripts/Server/Combat/RoomEnemyDirectorSystem.cs index 0a31a93cb..2c5731841 100644 --- a/Assets/_Project/Scripts/Server/Combat/RoomEnemyDirectorSystem.cs +++ b/Assets/_Project/Scripts/Server/Combat/RoomEnemyDirectorSystem.cs @@ -45,7 +45,7 @@ namespace ProjectM.Server state.RequireForUpdate(); state.RequireForUpdate(); state.RequireForUpdate(); - m_ZoneEnemies = state.GetEntityQuery(ComponentType.ReadOnly()); + m_ZoneEnemies = state.GetEntityQuery(ComponentType.ReadOnly(), ComponentType.Exclude()); // room clear + MaxAlive fit count LIVING only (B3) - corpses neither hold the room open nor crowd out spawns } [BurstCompile] diff --git a/Assets/_Project/Scripts/Server/Combat/WaveSystem.cs b/Assets/_Project/Scripts/Server/Combat/WaveSystem.cs index 5bec3d71b..386c66309 100644 --- a/Assets/_Project/Scripts/Server/Combat/WaveSystem.cs +++ b/Assets/_Project/Scripts/Server/Combat/WaveSystem.cs @@ -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>().WithAll()) + foreach (var hr in SystemAPI.Query>().WithAll().WithNone()) 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>().WithAll()) + foreach (var hr in SystemAPI.Query>().WithAll().WithNone()) if (hr.ValueRO.Region == RegionId.Base) baseHusks++; if (baseHusks == 0) { diff --git a/Assets/_Project/Scripts/Server/Debug/DebugCommandReceiveSystem.cs b/Assets/_Project/Scripts/Server/Debug/DebugCommandReceiveSystem.cs index 559383d50..e36817248 100644 --- a/Assets/_Project/Scripts/Server/Debug/DebugCommandReceiveSystem.cs +++ b/Assets/_Project/Scripts/Server/Debug/DebugCommandReceiveSystem.cs @@ -26,7 +26,7 @@ namespace ProjectM.Server public void OnCreate(ref SystemState state) { - m_Husks = state.GetEntityQuery(ComponentType.ReadOnly()); + m_Husks = state.GetEntityQuery(ComponentType.ReadOnly(), ComponentType.Exclude()); // corpses expire on their own (B3; avoids a cross-ECB double-destroy) var builder = new EntityQueryBuilder(Allocator.Temp) .WithAll(); state.RequireForUpdate(state.GetEntityQuery(builder)); diff --git a/Assets/_Project/Scripts/Server/Debug/DevTelemetrySystem.cs b/Assets/_Project/Scripts/Server/Debug/DevTelemetrySystem.cs index 71cc3e7fa..7f7229a74 100644 --- a/Assets/_Project/Scripts/Server/Debug/DevTelemetrySystem.cs +++ b/Assets/_Project/Scripts/Server/Debug/DevTelemetrySystem.cs @@ -24,7 +24,7 @@ namespace ProjectM.Server public void OnCreate(ref SystemState state) { state.RequireForUpdate(); - m_Husks = state.GetEntityQuery(ComponentType.ReadOnly()); + m_Husks = state.GetEntityQuery(ComponentType.ReadOnly(), ComponentType.Exclude()); // telemetry counts LIVING (B3) if (state.GetEntityQuery(ComponentType.ReadWrite()).IsEmpty) state.EntityManager.CreateEntity(typeof(DevTelemetry)); } diff --git a/Assets/_Project/Scripts/Server/World/CoreDamageSystem.cs b/Assets/_Project/Scripts/Server/World/CoreDamageSystem.cs index bbfb7007f..fd63e751d 100644 --- a/Assets/_Project/Scripts/Server/World/CoreDamageSystem.cs +++ b/Assets/_Project/Scripts/Server/World/CoreDamageSystem.cs @@ -58,7 +58,7 @@ namespace ProjectM.Server var ecb = new EntityCommandBuffer(Allocator.Temp); int drained = 0; foreach (var (xform, entity) in - SystemAPI.Query>().WithAll().WithEntityAccess()) + SystemAPI.Query>().WithAll().WithNone().WithEntityAccess()) // corpses don't drain the Core (B3) { if (!EnemyAIMath.InAttackRange(xform.ValueRO.Position, corePos, CoreReachRadius)) continue; diff --git a/Assets/_Project/Scripts/Server/World/CyclePhaseSystem.cs b/Assets/_Project/Scripts/Server/World/CyclePhaseSystem.cs index 76e556ea5..c8b316361 100644 --- a/Assets/_Project/Scripts/Server/World/CyclePhaseSystem.cs +++ b/Assets/_Project/Scripts/Server/World/CyclePhaseSystem.cs @@ -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>().WithAll().WithEntityAccess()) + foreach (var (hr, he) in SystemAPI.Query>().WithAll().WithNone().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>().WithAll()) + foreach (var hr in SystemAPI.Query>().WithAll().WithNone()) // LIVING only (B3) if (hr.ValueRO.Region == RegionId.Base) baseHusks++; return wave.WaveNumber > defendStartWave && wave.RemainingToSpawn == 0 diff --git a/Assets/_Project/Scripts/Server/World/ThreatDirectorSystem.cs b/Assets/_Project/Scripts/Server/World/ThreatDirectorSystem.cs index 09bc01e66..53274d378 100644 --- a/Assets/_Project/Scripts/Server/World/ThreatDirectorSystem.cs +++ b/Assets/_Project/Scripts/Server/World/ThreatDirectorSystem.cs @@ -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>().WithAll().WithEntityAccess()) + foreach (var (hr, he) in SystemAPI.Query>().WithAll().WithNone().WithEntityAccess()) // skip corpses: the B3 expiry pass owns their destroy if (hr.ValueRO.Region == RegionId.Base) ecb.DestroyEntity(he); ecb.Playback(state.EntityManager); diff --git a/Assets/_Project/Scripts/Simulation/Combat/AbilityFireSystem.cs b/Assets/_Project/Scripts/Simulation/Combat/AbilityFireSystem.cs index 65a42a651..699062bd0 100644 --- a/Assets/_Project/Scripts/Simulation/Combat/AbilityFireSystem.cs +++ b/Assets/_Project/Scripts/Simulation/Combat/AbilityFireSystem.cs @@ -82,8 +82,9 @@ namespace ProjectM.Simulation foreach (var (tx, th, te) in SystemAPI.Query, RefRO>().WithAny().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(); diff --git a/Assets/_Project/Scripts/Simulation/Combat/Dying.cs b/Assets/_Project/Scripts/Simulation/Combat/Dying.cs new file mode 100644 index 000000000..395fc9e41 --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/Combat/Dying.cs @@ -0,0 +1,19 @@ +using Unity.Entities; + +namespace ProjectM.Simulation +{ + /// + /// Phase 1 (B3): the enemy corpse window. Stamped ONCE by HealthApplyDamageSystem on the lethal + /// 0-crossing (server-only, never replicated — clients derive the death read from the replicated + /// Health.Current<=0); the entity is destroyed when ServerTick reaches . + /// 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). + /// + public struct Dying : IComponentData + { + /// Server tick the corpse despawns (via TickUtil.NonZero; compared via NetworkTick). + public uint UntilTick; + } +} diff --git a/Assets/_Project/Scripts/Simulation/Combat/Dying.cs.meta b/Assets/_Project/Scripts/Simulation/Combat/Dying.cs.meta new file mode 100644 index 000000000..80a836fde --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/Combat/Dying.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 3b794b215be5185499725afc93d9d78c \ No newline at end of file diff --git a/Assets/_Project/Scripts/Simulation/Tuning.cs b/Assets/_Project/Scripts/Simulation/Tuning.cs index 346baea15..28b1ce031 100644 --- a/Assets/_Project/Scripts/Simulation/Tuning.cs +++ b/Assets/_Project/Scripts/Simulation/Tuning.cs @@ -95,6 +95,10 @@ namespace ProjectM.Simulation /// polish; an idle player died in ~2 s of landing). Subsequent slots keep the normal drip cadence. public const uint RoomEntryGraceTicks = 100; + /// 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. + public const uint EnemyDeathWindowTicks = 54; + // ---- Expedition BOSS (a scaled Charger given a real kit by BossAISystem; server-only feel consts) ---- /// Radial SLAM AoE radius (world units): a player inside this ring at wind-up elapse eats the hit