Phase 1 B1/B2/B4-B7: separation, poise, boss lunge, party HP scale, run-failed banner, fire anim

B1 SEPARATION: pairwise soft-collision pass INSIDE EnemyAISystem (sole
enemy-Position writer preserved; O(n^2) fine at MaxAlive<=14). Knocked/
lunging enemies keep committed motion but still push neighbours; the boss is
never pushed; enemies yield a small personal radius around players (below
melee range per review B1-1); displacement goes through the swept move so
nothing shoves through walls. SeparationMaxSpeed = live knob.

B2 POISE: the windup-cancel lived in the knockback CONTINUE branches (grunt/
charger/spitter). Threshold on the EXISTING KnockbackState.Speed channel:
light melee (6) nudges without interrupting; the finisher (10.8) and cone
(8) stagger as before. StaggerKnockbackSpeed = live knob (default 7).

B4 BOSS LUNGE: a telegraphed gap-closer on its own cooldown when the target
sits outside slam reach (repositioning - the slam stays the damage beat).
BossState.PendingAttack (server-only byte) disambiguates the shared windup
elapse (review-confirmed: naive reuse would SLAM on a lunge elapse);
LungeState.UntilTick spans windup+travel so the existing IsLunging ghost bit
replicates the tell, and the client draws a travel wedge instead of the slam
ring while it is set.

B5 PARTY HP SCALE: boss Health x(1 + 0.75/extra LIVING expedition player) at
spawn (not RunParticipant - dead-respawned members park at base). Max is a
GhostField so the bar stays truthful.

B6 RUN-FAILED BANNER: silent wipes used to land players home with zero
explanation. Client detects the (in-run)->Staging edge with a launch-cached
Charge (NEVER Returning - a 1-tick transient that precedes the bank by a
tick) and shows EXPEDITION FAILED for 6 s.

B7 FIRE ANIM: the class ability finally moves the body - a stateless window
from replicated AbilityCooldown.NextFireTick minus derived CooldownTicks
(works for predicted local + interpolated remotes, no cached edges).

456/456 EditMode; live Play smoke: launch -> 5 kills credit the room clear
through the corpse window -> boon window opens -> corpses expire clean, no
exceptions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-06 18:50:49 -07:00
parent 3836e9c842
commit ea108e48e4
9 changed files with 269 additions and 17 deletions
@@ -19,8 +19,11 @@ namespace ProjectM.Server
/// v2 boss = a real fight (operator-locked): chase the nearest living expedition player, then a telegraphed radial
/// SLAM — the client danger cue rides the replicated <see cref="AttackWindup"/> [GhostField] (CombatFeedbackSystem
/// draws a boss-scale ring). At/below <see cref="Tuning.BossPhase2HealthFraction"/> HP it enters phase two: faster,
/// slams more often, and periodically summons swarmer adds. The boss does NOT lunge (it keeps its baked LungeState
/// idle, so EnemyAISystem's IsLunging derive sees UntilTick==0 → bit off, harmless). Knockback-immune (the stamp
/// slams more often, and periodically summons swarmer adds. B4 (Phase 1): the boss ALSO lunges - a telegraphed
/// gap-closer on its own cooldown when the target sits outside slam reach; LungeState.UntilTick spans the
/// windup+travel so EnemyAISystem's IsLunging derive replicates the tell (the client suppresses the slam ring
/// off that bit), and BossState.PendingAttack (server-only byte) tells the shared windup-elapse branch WHICH
/// attack fires. Knockback-immune (the stamp
/// sites skip BossState; this system also clears any residual so nothing else can shove it). Summoned adds go
/// through <see cref="ZoneEnemySpawnUtil"/> so they carry the SAME ZoneEnemyTag/RoomTag/RegionTag stack the
/// room-clear gate + teardown depend on (dropping one would leak adds or clear the room early). All ticks route
@@ -94,9 +97,9 @@ namespace ProjectM.Server
var ecb = new EntityCommandBuffer(Allocator.Temp);
foreach (var (xform, stats, health, boss, windup, knockback) in
foreach (var (xform, stats, health, boss, windup, knockback, lunge) in
SystemAPI.Query<RefRW<LocalTransform>, RefRO<EnemyStats>, RefRO<Health>, RefRW<BossState>,
RefRW<AttackWindup>, RefRW<KnockbackState>>()
RefRW<AttackWindup>, RefRW<KnockbackState>, RefRW<LungeState>>()
.WithAll<EnemyTag, BossState>().WithNone<Dying>())
{
float3 pos = xform.ValueRO.Position;
@@ -132,6 +135,18 @@ namespace ProjectM.Server
var wt = new NetworkTick(windRaw);
if (!(wt.IsValid && wt.IsNewerThan(serverTick)))
{
// B4: the windup elapse fires whichever attack was PENDING - the shared AttackWindup field
// alone cannot tell them apart (review-confirmed: the naive reuse slams on a lunge elapse).
if (boss.ValueRO.PendingAttack == 1)
{
// Lunge commit: lock direction at travel start (the Charger contract - dodge DURING
// travel with dash i-frames). No unique damage: arriving re-opens the slam threat.
lunge.ValueRW.Dir = math.normalizesafe(toTarget.xz, new float2(0f, 1f));
lunge.ValueRW.Speed = Tuning.BossLungeSpeed;
lunge.ValueRW.UntilTick = TickUtil.NonZero(now + Tuning.BossLungeDurationTicks);
windup.ValueRW.WindUpUntilTick = 0u;
continue;
}
float slamSq = Tuning.BossSlamRadius * Tuning.BossSlamRadius;
for (int i = 0; i < playerEntities.Length; i++)
{
@@ -154,6 +169,34 @@ namespace ProjectM.Server
continue; // rooted while winding up (the tell); rotation already written above
}
// --- B4 LUNGE travel in progress: committed movement along the locked direction. Wall-stop or
// timer ends it (the Charger contract); the replicated IsLunging bit rides LungeState.UntilTick. ---
if (lunge.ValueRO.UntilTick != 0u)
{
var blt = new NetworkTick(lunge.ValueRO.UntilTick);
if (blt.IsValid && blt.IsNewerThan(serverTick))
{
float3 intended = pos + new float3(lunge.ValueRO.Dir.x, 0f, lunge.ValueRO.Dir.y) * (lunge.ValueRO.Speed * dt);
intended.y = pos.y;
float3 moved = sweep ? EnemyMoveUtil.SweptMove(in physics, pos, intended, SweepRadius, envFilter) : intended;
xform.ValueRW.Position = moved;
if (math.lengthsq(lunge.ValueRO.Dir) > 1e-6f)
xform.ValueRW.Rotation = quaternion.LookRotationSafe(new float3(lunge.ValueRO.Dir.x, 0f, lunge.ValueRO.Dir.y), math.up());
float intendedDist = math.distance(pos.xz, intended.xz);
float actualDist = math.distance(pos.xz, moved.xz);
if (intendedDist > 1e-4f && actualDist < intendedDist * 0.5f)
{
lunge.ValueRW.UntilTick = 0u; // wall-stop -> end the travel early
boss.ValueRW.PendingAttack = 0;
boss.ValueRW.LungeReadyTick = TickUtil.NonZero(now + Tuning.BossLungeCooldownTicks);
}
continue; // committed this tick
}
lunge.ValueRW.UntilTick = 0u; // travel done
boss.ValueRW.PendingAttack = 0;
boss.ValueRW.LungeReadyTick = TickUtil.NonZero(now + Tuning.BossLungeCooldownTicks);
}
// --- Chase (no active slam). ---
float speed = stats.ValueRO.MoveSpeed * (phase == 2 ? Tuning.BossPhase2SpeedMult : 1f);
float stopDist = stats.ValueRO.AttackRange * 0.9f;
@@ -166,8 +209,28 @@ namespace ProjectM.Server
bool slamReady = boss.ValueRO.SlamReadyTick == 0u
|| !new NetworkTick(boss.ValueRO.SlamReadyTick).IsNewerThan(serverTick);
float lead = Tuning.BossSlamRadius + 1.5f;
if (slamReady && math.distancesq(newPos, targetPos) <= lead * lead)
float tgtDistSq = math.distancesq(newPos, targetPos);
if (slamReady && tgtDistSq <= lead * lead)
{
windup.ValueRW.WindUpUntilTick = TickUtil.NonZero(now + Tuning.BossSlamWindupTicks);
boss.ValueRW.PendingAttack = 0;
}
else
{
// B4 lunge gate: target out of slam reach but within lunge range -> telegraphed gap-closer.
// LungeState.UntilTick spans windup+travel so the IsLunging ghost bit (derived by EnemyAISystem
// from LungeState) is ON for the whole move - the client suppresses the slam ring off that bit.
bool lungeReady = boss.ValueRO.LungeReadyTick == 0u
|| !new NetworkTick(boss.ValueRO.LungeReadyTick).IsNewerThan(serverTick);
if (lungeReady
&& tgtDistSq >= Tuning.BossLungeMinRange * Tuning.BossLungeMinRange
&& tgtDistSq <= Tuning.BossLungeMaxRange * Tuning.BossLungeMaxRange)
{
windup.ValueRW.WindUpUntilTick = TickUtil.NonZero(now + Tuning.BossLungeWindupTicks);
boss.ValueRW.PendingAttack = 1;
lunge.ValueRW.UntilTick = TickUtil.NonZero(now + Tuning.BossLungeWindupTicks + Tuning.BossLungeDurationTicks);
}
}
// Summon (phase two only): ready + under the live cap + a swarmer prefab wired.
if (phase == 2 && swarmerPrefab != Entity.Null && liveZone < Tuning.BossSummonLiveCap)
@@ -126,7 +126,8 @@ namespace ProjectM.Server
if (sweep)
kpos = SweptMove(in physics, pos, kpos, SweepRadius, envFilter);
xform.ValueRW.Position = kpos;
windup.ValueRW.WindUpUntilTick = 0; // a recoiling Husk does not wind up
if (kb.Speed >= tune.StaggerKnockbackSpeed)
windup.ValueRW.WindUpUntilTick = 0; // B2 poise: only a HEAVY hit interrupts; a light hit nudges while the wind-up keeps counting
continue; // recoiling: skip seek + strike this tick
}
knockback.ValueRW.UntilTick = 0; // window elapsed
@@ -240,8 +241,11 @@ namespace ProjectM.Server
kpos.y = pos.y;
if (sweep) kpos = SweptMove(in physics, pos, kpos, SweepRadius, envFilter);
xform.ValueRW.Position = kpos;
windup.ValueRW.WindUpUntilTick = 0;
lunge.ValueRW.UntilTick = 0;
if (kb.Speed >= tune.StaggerKnockbackSpeed)
{
windup.ValueRW.WindUpUntilTick = 0; // B2 poise: only a HEAVY hit breaks the windup / committed lunge
lunge.ValueRW.UntilTick = 0;
}
continue;
}
knockback.ValueRW.UntilTick = 0;
@@ -380,7 +384,8 @@ namespace ProjectM.Server
kpos.y = pos.y;
if (sweep) kpos = SweptMove(in physics, pos, kpos, SweepRadius, envFilter);
xform.ValueRW.Position = kpos;
windup.ValueRW.WindUpUntilTick = 0;
if (kb.Speed >= tune.StaggerKnockbackSpeed)
windup.ValueRW.WindUpUntilTick = 0; // B2 poise: light hits nudge, only heavy interrupts
continue;
}
knockback.ValueRW.UntilTick = 0;
@@ -475,6 +480,74 @@ namespace ProjectM.Server
isLunging.ValueRW = lunge.ValueRO.UntilTick != 0u; // lunging iff a committed lunge is live this tick
}
// --- Phase 1 B1: SEPARATION (soft-collision) so hordes stop interpenetrating. Lives INSIDE
// EnemyAISystem (the sole enemy-Position writer; BossAISystem runs after and re-owns the boss).
// Pairwise among LIVING enemies (MaxAlive <= ~14 + boss -> O(n^2) is trivial); an enemy mid-knockback
// or mid-lunge keeps its committed motion (immovable this tick) but still pushes neighbours away, and
// the BOSS is never pushed (knockback-immune identity). Enemies also yield a SMALL personal radius
// around each player (below melee range so grunts can still strike - review B1-1). Displacement goes
// through the same swept move so separation cannot shove anything through walls.
{
var sepEnt = new NativeList<Entity>(Allocator.Temp);
var sepPos = new NativeList<float3>(Allocator.Temp);
var sepRad = new NativeList<float>(Allocator.Temp);
var sepMov = new NativeList<bool>(Allocator.Temp);
foreach (var (sxf, shr, se) in SystemAPI.Query<RefRO<LocalTransform>, RefRO<HitRadius>>()
.WithAll<EnemyTag>().WithNone<Dying>().WithEntityAccess())
{
bool movable = !SystemAPI.HasComponent<BossState>(se);
if (movable && SystemAPI.HasComponent<KnockbackState>(se))
{
var k = SystemAPI.GetComponent<KnockbackState>(se);
if (k.UntilTick != 0 && new NetworkTick(k.UntilTick).IsNewerThan(serverTick)) movable = false;
}
if (movable && SystemAPI.HasComponent<LungeState>(se))
{
var l = SystemAPI.GetComponent<LungeState>(se);
if (l.UntilTick != 0 && new NetworkTick(l.UntilTick).IsNewerThan(serverTick)) movable = false;
}
sepEnt.Add(se); sepPos.Add(sxf.ValueRO.Position); sepRad.Add(shr.ValueRO.Value); sepMov.Add(movable);
}
float sepMaxStep = math.max(0f, tune.SeparationMaxSpeed) * dt;
for (int i = 0; i < sepEnt.Length && sepMaxStep > 0f; i++)
{
if (!sepMov[i]) continue;
float3 pi = sepPos[i];
float2 push = float2.zero;
for (int j = 0; j < sepEnt.Length; j++)
{
if (j == i) continue;
float2 d = pi.xz - sepPos[j].xz;
float want = (sepRad[i] + sepRad[j]) * 0.9f;
float distSq = math.lengthsq(d);
if (want <= 0f || distSq >= want * want) continue;
float dist = math.sqrt(distSq);
// Exact stacks de-overlap deterministically by index (golden angle), never randomly.
float2 dir = dist > 1e-4f ? d / dist : new float2(math.sin(i * 2.399963f), math.cos(i * 2.399963f));
push += dir * (want - dist) * 0.5f;
}
for (int pj = 0; pj < playerPositions.Length; pj++)
{
float2 d = pi.xz - playerPositions[pj].xz;
float want = sepRad[i] + 0.35f; // small: never blocks strike range or the Spitter cornered-hold
float distSq = math.lengthsq(d);
if (distSq >= want * want) continue;
float dist = math.sqrt(distSq);
float2 dir = dist > 1e-4f ? d / dist : new float2(1f, 0f);
push += dir * (want - dist);
}
if (math.lengthsq(push) < 1e-8f) continue;
float pushLen = math.length(push);
if (pushLen > sepMaxStep) push *= sepMaxStep / pushLen;
float3 sepTarget = pi + new float3(push.x, 0f, push.y);
sepTarget.y = pi.y;
if (sweep) sepTarget = SweptMove(in physics, pi, sepTarget, SweepRadius, envFilter);
var sepXf = SystemAPI.GetComponentRW<LocalTransform>(sepEnt[i]);
sepXf.ValueRW.Position = sepTarget;
}
sepEnt.Dispose(); sepPos.Dispose(); sepRad.Dispose(); sepMov.Dispose();
}
if (chargerWhiffsThisTick != 0 && SystemAPI.HasSingleton<DevTelemetry>())
SystemAPI.GetSingletonRW<DevTelemetry>().ValueRW.ChargerWhiffWindowsOpened += chargerWhiffsThisTick;
@@ -171,9 +171,16 @@ namespace ProjectM.Server
ecb.SetComponent(enemy, bxform);
if (SystemAPI.HasComponent<Health>(prefab))
{
// B5: party-size HP scaling by LIVING EXPEDITION players at spawn (NOT the
// RunParticipant count - dead-respawned members keep the tag while parked at
// base). Health.Max is a [GhostField] since DR-046, so the scaled max replicates.
int livingParty = 0;
foreach (var (pHealth, pRegion) in SystemAPI.Query<RefRO<Health>, RefRO<RegionTag>>().WithAll<PlayerTag>())
if (pHealth.ValueRO.Current > 0f && pRegion.ValueRO.Region == RegionId.Expedition) livingParty++;
float partyScale = 1f + Tuning.BossHealthPerExtraPlayer * math.max(0, livingParty - 1);
var hp = SystemAPI.GetComponent<Health>(prefab);
hp.Current *= Tuning.BossHealthMultiplier;
hp.Max *= Tuning.BossHealthMultiplier;
hp.Current *= Tuning.BossHealthMultiplier * partyScale;
hp.Max *= Tuning.BossHealthMultiplier * partyScale;
ecb.SetComponent(enemy, hp);
}
if (SystemAPI.HasComponent<HitRadius>(prefab))