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:
@@ -275,6 +275,21 @@ namespace ProjectM.Client
|
|||||||
EmitColored(_hitFx, (Vector3)p + Vector3.up * 0.7f, FeelConfig.HitFlashBurstCount, FeelConfig.HitFlashColor);
|
EmitColored(_hitFx, (Vector3)p + Vector3.up * 0.7f, FeelConfig.HitFlashBurstCount, FeelConfig.HitFlashColor);
|
||||||
if (FeelConfig.RumbleEnabled && AimPresentation.Scheme == 1)
|
if (FeelConfig.RumbleEnabled && AimPresentation.Scheme == 1)
|
||||||
RumbleUtil.Pulse(FeelConfig.RumbleHit * 0.6f, FeelConfig.RumbleHit, FeelConfig.RumbleDurationSec);
|
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]];
|
var c = _cache[_stale[i]];
|
||||||
if (c.IsEnemy)
|
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)));
|
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);
|
PlayClip(_deathClip, (Vector3)c.Pos, FeelConfig.KillSfxVolume);
|
||||||
PrototypeCameraRig.AddShake(FeelConfig.KillShake);
|
PrototypeCameraRig.AddShake(FeelConfig.KillShake);
|
||||||
PrototypeCameraRig.PunchFov(FeelConfig.KillFovKick, FeelConfig.HitStopDurationMs);
|
PrototypeCameraRig.PunchFov(FeelConfig.KillFovKick, FeelConfig.HitStopDurationMs);
|
||||||
TryHold(); // C4: kill crunch (throttled)
|
TryHold(); // C4: kill crunch (throttled)
|
||||||
|
|
||||||
EmitColored(_hitFx, (Vector3)c.Pos + Vector3.up * 0.6f, FeelConfig.KillFlashBurstCount, FeelConfig.HitFlashColor); // kill pop
|
EmitColored(_hitFx, (Vector3)c.Pos + Vector3.up * 0.6f, FeelConfig.KillFlashBurstCount, FeelConfig.HitFlashColor); // kill pop
|
||||||
if (FeelConfig.RumbleEnabled && AimPresentation.Scheme == 1)
|
if (FeelConfig.RumbleEnabled && AimPresentation.Scheme == 1)
|
||||||
RumbleUtil.Pulse(FeelConfig.RumbleKill * 0.7f, FeelConfig.RumbleKill, FeelConfig.RumbleDurationSec);
|
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]);
|
_cache.Remove(_stale[i]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ namespace ProjectM.Client
|
|||||||
static readonly FastAnimatorParameter k_MoveZ = new FastAnimatorParameter("MoveZ");
|
static readonly FastAnimatorParameter k_MoveZ = new FastAnimatorParameter("MoveZ");
|
||||||
static readonly FastAnimatorParameter k_Speed = new FastAnimatorParameter("Speed");
|
static readonly FastAnimatorParameter k_Speed = new FastAnimatorParameter("Speed");
|
||||||
static readonly FastAnimatorParameter k_IsAttacking = new FastAnimatorParameter("IsAttacking");
|
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).
|
// prevPos cache (per Husk Entity). Pruned every frame (a vanished Husk = a server-authoritative death).
|
||||||
NativeParallelHashMap<Entity, float3> _prevPos;
|
NativeParallelHashMap<Entity, float3> _prevPos;
|
||||||
@@ -63,7 +64,7 @@ namespace ProjectM.Client
|
|||||||
var seen = new NativeParallelHashSet<Entity>(64, Allocator.TempJob);
|
var seen = new NativeParallelHashSet<Entity>(64, Allocator.TempJob);
|
||||||
var job = new EnemyDriveJob
|
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,
|
dt = dt,
|
||||||
prevPos = _prevPos,
|
prevPos = _prevPos,
|
||||||
seen = seen,
|
seen = seen,
|
||||||
@@ -91,7 +92,7 @@ namespace ProjectM.Client
|
|||||||
[WithAll(typeof(EnemyTag))]
|
[WithAll(typeof(EnemyTag))]
|
||||||
partial struct EnemyDriveJob : IJobEntity
|
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
|
[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;
|
public float dt;
|
||||||
@@ -104,7 +105,8 @@ namespace ProjectM.Client
|
|||||||
DynamicBuffer<AnimatorControllerParameterComponent> parametersArr,
|
DynamicBuffer<AnimatorControllerParameterComponent> parametersArr,
|
||||||
in LocalTransform xform,
|
in LocalTransform xform,
|
||||||
in EnemyStats stats,
|
in EnemyStats stats,
|
||||||
in AttackWindup windup)
|
in AttackWindup windup,
|
||||||
|
in Health health)
|
||||||
{
|
{
|
||||||
seen.Add(e);
|
seen.Add(e);
|
||||||
float3 cur = xform.Position;
|
float3 cur = xform.Position;
|
||||||
@@ -117,11 +119,17 @@ namespace ProjectM.Client
|
|||||||
float3 p = AnimParamMath.LocomotionParams(vel, facing, stats.MoveSpeed);
|
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
|
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);
|
var a = new AnimatorParametersAspect(parametersArr, indexTable);
|
||||||
if (a.HasParameter(moveX)) a.SetParameterValue(moveX, p.x);
|
if (a.HasParameter(moveX)) a.SetParameterValue(moveX, p.x);
|
||||||
if (a.HasParameter(moveZ)) a.SetParameterValue(moveZ, p.y);
|
if (a.HasParameter(moveZ)) a.SetParameterValue(moveZ, p.y);
|
||||||
if (a.HasParameter(speed)) a.SetParameterValue(speed, p.z);
|
if (a.HasParameter(speed)) a.SetParameterValue(speed, p.z);
|
||||||
if (a.HasParameter(isAttacking)) a.SetParameterValue(isAttacking, attacking);
|
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);
|
var huskRegion = new NativeList<byte>(Allocator.Temp);
|
||||||
foreach (var (xform, health, region, e) in
|
foreach (var (xform, health, region, e) in
|
||||||
SystemAPI.Query<RefRO<LocalTransform>, RefRO<Health>, RefRO<RegionTag>>()
|
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)
|
if (health.ValueRO.Current <= 0f)
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
@@ -39,9 +39,9 @@ namespace ProjectM.Server
|
|||||||
public void OnCreate(ref SystemState state)
|
public void OnCreate(ref SystemState state)
|
||||||
{
|
{
|
||||||
state.RequireForUpdate<NetworkTime>();
|
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);
|
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]
|
[BurstCompile]
|
||||||
@@ -97,7 +97,7 @@ namespace ProjectM.Server
|
|||||||
foreach (var (xform, stats, health, boss, windup, knockback) in
|
foreach (var (xform, stats, health, boss, windup, knockback) in
|
||||||
SystemAPI.Query<RefRW<LocalTransform>, RefRO<EnemyStats>, RefRO<Health>, RefRW<BossState>,
|
SystemAPI.Query<RefRW<LocalTransform>, RefRO<EnemyStats>, RefRO<Health>, RefRW<BossState>,
|
||||||
RefRW<AttackWindup>, RefRW<KnockbackState>>()
|
RefRW<AttackWindup>, RefRW<KnockbackState>>()
|
||||||
.WithAll<EnemyTag, BossState>())
|
.WithAll<EnemyTag, BossState>().WithNone<Dying>())
|
||||||
{
|
{
|
||||||
float3 pos = xform.ValueRO.Position;
|
float3 pos = xform.ValueRO.Position;
|
||||||
|
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ namespace ProjectM.Server
|
|||||||
foreach (var (xform, stats, cooldown, knockback, windup, region) in
|
foreach (var (xform, stats, cooldown, knockback, windup, region) in
|
||||||
SystemAPI.Query<RefRW<LocalTransform>, RefRO<EnemyStats>, RefRW<EnemyAttackCooldown>,
|
SystemAPI.Query<RefRW<LocalTransform>, RefRO<EnemyStats>, RefRW<EnemyAttackCooldown>,
|
||||||
RefRW<KnockbackState>, RefRW<AttackWindup>, RefRO<RegionTag>>()
|
RefRW<KnockbackState>, RefRW<AttackWindup>, RefRO<RegionTag>>()
|
||||||
.WithAll<EnemyTag>().WithNone<LungeState, SpitterState>())
|
.WithAll<EnemyTag>().WithNone<LungeState, SpitterState, Dying>())
|
||||||
{
|
{
|
||||||
float3 pos = xform.ValueRO.Position;
|
float3 pos = xform.ValueRO.Position;
|
||||||
byte huskRegion = region.ValueRO.Region;
|
byte huskRegion = region.ValueRO.Region;
|
||||||
@@ -223,7 +223,7 @@ namespace ProjectM.Server
|
|||||||
foreach (var (xform, stats, cooldown, knockback, windup, lunge, region) in
|
foreach (var (xform, stats, cooldown, knockback, windup, lunge, region) in
|
||||||
SystemAPI.Query<RefRW<LocalTransform>, RefRO<EnemyStats>, RefRW<EnemyAttackCooldown>,
|
SystemAPI.Query<RefRW<LocalTransform>, RefRO<EnemyStats>, RefRW<EnemyAttackCooldown>,
|
||||||
RefRW<KnockbackState>, RefRW<AttackWindup>, RefRW<LungeState>, RefRO<RegionTag>>()
|
RefRW<KnockbackState>, RefRW<AttackWindup>, RefRW<LungeState>, RefRO<RegionTag>>()
|
||||||
.WithAll<EnemyTag>().WithNone<SpitterState, BossState>())
|
.WithAll<EnemyTag>().WithNone<SpitterState, BossState, Dying>())
|
||||||
{
|
{
|
||||||
float3 pos = xform.ValueRO.Position;
|
float3 pos = xform.ValueRO.Position;
|
||||||
byte cHuskRegion = region.ValueRO.Region;
|
byte cHuskRegion = region.ValueRO.Region;
|
||||||
@@ -363,7 +363,7 @@ namespace ProjectM.Server
|
|||||||
foreach (var (xform, stats, knockback, windup, spitter, region) in
|
foreach (var (xform, stats, knockback, windup, spitter, region) in
|
||||||
SystemAPI.Query<RefRW<LocalTransform>, RefRO<EnemyStats>, RefRW<KnockbackState>,
|
SystemAPI.Query<RefRW<LocalTransform>, RefRO<EnemyStats>, RefRW<KnockbackState>,
|
||||||
RefRW<AttackWindup>, RefRW<SpitterState>, RefRO<RegionTag>>()
|
RefRW<AttackWindup>, RefRW<SpitterState>, RefRO<RegionTag>>()
|
||||||
.WithAll<EnemyTag, SpitterState>().WithNone<LungeState>())
|
.WithAll<EnemyTag, SpitterState>().WithNone<LungeState, Dying>())
|
||||||
{
|
{
|
||||||
float3 pos = xform.ValueRO.Position;
|
float3 pos = xform.ValueRO.Position;
|
||||||
byte sRegion = region.ValueRO.Region;
|
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).
|
// Charger whose bit is currently DISABLED is still visited (Entities default-excludes disabled enableables).
|
||||||
foreach (var (lunge, isLunging) in
|
foreach (var (lunge, isLunging) in
|
||||||
SystemAPI.Query<RefRO<LungeState>, EnabledRefRW<IsLunging>>()
|
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
|
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;
|
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
|
// 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
|
// 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).
|
// 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);
|
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>())
|
if ((negatedThisTick != 0u || punishesThisTick != 0u) && SystemAPI.HasSingleton<DevTelemetry>())
|
||||||
{
|
{
|
||||||
var telem = SystemAPI.GetSingletonRW<DevTelemetry>();
|
var telem = SystemAPI.GetSingletonRW<DevTelemetry>();
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ namespace ProjectM.Server
|
|||||||
foreach (var (xform, hitRadius, targetEntity) in
|
foreach (var (xform, hitRadius, targetEntity) in
|
||||||
SystemAPI.Query<RefRO<LocalTransform>, RefRO<HitRadius>>()
|
SystemAPI.Query<RefRO<LocalTransform>, RefRO<HitRadius>>()
|
||||||
.WithAll<Health>()
|
.WithAll<Health>()
|
||||||
|
.WithNone<Dying>() // B3: corpses are not shields - a shot passes through to living targets
|
||||||
.WithEntityAccess())
|
.WithEntityAccess())
|
||||||
{
|
{
|
||||||
targetEntities.Add(targetEntity);
|
targetEntities.Add(targetEntity);
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ namespace ProjectM.Server
|
|||||||
state.RequireForUpdate<RunInfo>();
|
state.RequireForUpdate<RunInfo>();
|
||||||
state.RequireForUpdate<RunRuntime>();
|
state.RequireForUpdate<RunRuntime>();
|
||||||
state.RequireForUpdate<ZoneEnemyDirector>();
|
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]
|
[BurstCompile]
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ namespace ProjectM.Server
|
|||||||
|
|
||||||
// Live BASE husks for the entity cap (expedition zone enemies are EnemyTag too -> excluded).
|
// Live BASE husks for the entity cap (expedition zone enemies are EnemyTag too -> excluded).
|
||||||
int aliveBase = 0;
|
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++;
|
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).
|
// 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
|
// 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).
|
// EnemyTag but RegionTag{Expedition}; they must NOT hold the base siege open (DR-040 BLOCKER 3).
|
||||||
int baseHusks = 0;
|
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 (hr.ValueRO.Region == RegionId.Base) baseHusks++;
|
||||||
if (baseHusks == 0)
|
if (baseHusks == 0)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ namespace ProjectM.Server
|
|||||||
|
|
||||||
public void OnCreate(ref SystemState state)
|
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)
|
var builder = new EntityQueryBuilder(Allocator.Temp)
|
||||||
.WithAll<DebugCommandRequest, ReceiveRpcCommandRequest>();
|
.WithAll<DebugCommandRequest, ReceiveRpcCommandRequest>();
|
||||||
state.RequireForUpdate(state.GetEntityQuery(builder));
|
state.RequireForUpdate(state.GetEntityQuery(builder));
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ namespace ProjectM.Server
|
|||||||
public void OnCreate(ref SystemState state)
|
public void OnCreate(ref SystemState state)
|
||||||
{
|
{
|
||||||
state.RequireForUpdate<NetworkTime>();
|
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)
|
if (state.GetEntityQuery(ComponentType.ReadWrite<DevTelemetry>()).IsEmpty)
|
||||||
state.EntityManager.CreateEntity(typeof(DevTelemetry));
|
state.EntityManager.CreateEntity(typeof(DevTelemetry));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ namespace ProjectM.Server
|
|||||||
var ecb = new EntityCommandBuffer(Allocator.Temp);
|
var ecb = new EntityCommandBuffer(Allocator.Temp);
|
||||||
int drained = 0;
|
int drained = 0;
|
||||||
foreach (var (xform, entity) in
|
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))
|
if (!EnemyAIMath.InAttackRange(xform.ValueRO.Position, corePos, CoreReachRadius))
|
||||||
continue;
|
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
|
// 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
|
// 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.
|
// 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)
|
if (hr.ValueRO.Region == RegionId.Base)
|
||||||
ecb.DestroyEntity(he);
|
ecb.DestroyEntity(he);
|
||||||
ecb.Playback(state.EntityManager);
|
ecb.Playback(state.EntityManager);
|
||||||
@@ -185,7 +185,7 @@ namespace ProjectM.Server
|
|||||||
// Cleared only when no BASE husk remains: expedition zone enemies (EnemyTag + RegionTag{Expedition})
|
// 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).
|
// must not hold the base siege open (DR-040 BLOCKER 3 — same global-count soft-lock as WaveSystem).
|
||||||
int baseHusks = 0;
|
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++;
|
if (hr.ValueRO.Region == RegionId.Base) baseHusks++;
|
||||||
return wave.WaveNumber > defendStartWave
|
return wave.WaveNumber > defendStartWave
|
||||||
&& wave.RemainingToSpawn == 0
|
&& wave.RemainingToSpawn == 0
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ namespace ProjectM.Server
|
|||||||
// Collapse the siege: cull every remaining BASE Husk only (expedition zone enemies are also
|
// 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).
|
// EnemyTag but RegionTag{Expedition}; the timeout must not destroy them — DR-040 BLOCKER 3).
|
||||||
var ecb = new EntityCommandBuffer(Allocator.Temp);
|
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)
|
if (hr.ValueRO.Region == RegionId.Base)
|
||||||
ecb.DestroyEntity(he);
|
ecb.DestroyEntity(he);
|
||||||
ecb.Playback(state.EntityManager);
|
ecb.Playback(state.EntityManager);
|
||||||
|
|||||||
@@ -82,8 +82,9 @@ namespace ProjectM.Simulation
|
|||||||
foreach (var (tx, th, te) in
|
foreach (var (tx, th, te) in
|
||||||
SystemAPI.Query<RefRO<LocalTransform>, RefRO<Health>>().WithAny<TrainingDummyTag, EnemyTag>().WithEntityAccess())
|
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);
|
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();
|
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<=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>
|
/// polish; an idle player died in ~2 s of landing). Subsequent slots keep the normal drip cadence.</summary>
|
||||||
public const uint RoomEntryGraceTicks = 100;
|
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) ----
|
// ---- 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
|
/// <summary>Radial SLAM AoE radius (world units): a player inside this ring at wind-up elapse eats the hit
|
||||||
|
|||||||
Reference in New Issue
Block a user