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:
@@ -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)))
|
||||
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<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);
|
||||
|
||||
Reference in New Issue
Block a user