From ea108e48e4c302315cf0c49c0e7d3e40d8826bf7 Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Mon, 6 Jul 2026 18:50:49 -0700 Subject: [PATCH] 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 --- .../Presentation/CombatFeedbackSystem.cs | 8 +- .../Scripts/Client/Presentation/HudSystem.cs | 41 +++++++++- .../PlayerAnimationDriveSystem.cs | 26 +++++- .../Scripts/Server/Combat/BossAISystem.cs | 73 +++++++++++++++-- .../Scripts/Server/Combat/EnemyAISystem.cs | 81 ++++++++++++++++++- .../Server/Combat/RoomEnemyDirectorSystem.cs | 11 ++- .../Scripts/Simulation/Combat/BossState.cs | 9 +++ .../Scripts/Simulation/Debug/TuningConfig.cs | 23 +++++- Assets/_Project/Scripts/Simulation/Tuning.cs | 14 ++++ 9 files changed, 269 insertions(+), 17 deletions(-) diff --git a/Assets/_Project/Scripts/Client/Presentation/CombatFeedbackSystem.cs b/Assets/_Project/Scripts/Client/Presentation/CombatFeedbackSystem.cs index 0321a7030..cd21476d1 100644 --- a/Assets/_Project/Scripts/Client/Presentation/CombatFeedbackSystem.cs +++ b/Assets/_Project/Scripts/Client/Presentation/CombatFeedbackSystem.cs @@ -1158,12 +1158,18 @@ void TriggerSlash(Vector3 pos, float2 facing, float range, float halfAngle, int } float coneRange = math.max(1f, stats.ValueRO.AttackRange + 0.6f); if (lunging) coneRange += 1.5f; // forward-stretch the wedge to read the committed travel - if (isBoss) + if (isBoss && !lunging) { // A7: the boss SLAM is RADIAL (Tuning.BossSlamRadius) -> paint a FULL ground ring so the tell // matches the hit area (a forward wedge sized to melee reach would lie about a radial AoE). BuildDangerMesh(go.GetComponent().sharedMesh, Tuning.BossSlamRadius, 3.14159f, intensity); } + else if (isBoss) + { + // B4: the boss LUNGE is a committed forward gap-closer (IsLunging bit on through windup + + // travel) - a radial ring would lie about the threat shape; paint a long narrow travel wedge. + BuildDangerMesh(go.GetComponent().sharedMesh, math.max(coneRange, 8f), 0.45f, intensity); + } else if (tele.ValueRO.Kind == ZoneEnemyMath.KindSpitter) { // MC-3: a Spitter is a RANGED threat — a melee wedge at its feet is useless. Paint a thin aim diff --git a/Assets/_Project/Scripts/Client/Presentation/HudSystem.cs b/Assets/_Project/Scripts/Client/Presentation/HudSystem.cs index 84bce62ab..6d3538199 100644 --- a/Assets/_Project/Scripts/Client/Presentation/HudSystem.cs +++ b/Assets/_Project/Scripts/Client/Presentation/HudSystem.cs @@ -181,6 +181,28 @@ namespace ProjectM.Client // ---- Macro: phase + cycle + countdown (center-top banner) ---- bool haveRun = SystemAPI.TryGetSingleton(out var runInfo); // hoisted: the phase banner is lifecycle-aware (Phase 0 fix — it read "AT BASE" inside expedition rooms) + + // B6: run-failed read (review-confirmed design: NEVER key on Returning — it is a 1-tick transient and + // the Charge bank lands a tick after it; detect the (in-run)->Staging edge with a launch-cached Charge. + // Lifecycle + Charge ride the SAME director ghost snapshot, so at the Staging edge the bank has arrived). + if (haveRun) + { + bool haveGoalNow = SystemAPI.TryGetSingleton(out var goalSnap); + byte lcNow = runInfo.Lifecycle; + if (lcNow == RunLifecycle.Launching && _prevRunLifecycle == RunLifecycle.Staging) + { + _chargeAtLaunch = haveGoalNow ? goalSnap.Charge : 0; + _wentInRun = false; + } + if (lcNow == RunLifecycle.InRoom) _wentInRun = true; + if (lcNow == RunLifecycle.Staging && _prevRunLifecycle != RunLifecycle.Staging && _wentInRun) + { + if (haveGoalNow && goalSnap.Charge <= _chargeAtLaunch) + _runFailedUntil = (float)SystemAPI.Time.ElapsedTime + 6f; // wipe/abort: nothing banked + _wentInRun = false; + } + _prevRunLifecycle = lcNow; + } bool haveCycle = SystemAPI.TryGetSingleton(out var cyc); bool siege = haveCycle && cyc.Phase == CyclePhase.Siege; bool goalFull = SystemAPI.TryGetSingleton(out var goalNow) && goalNow.Target > 0 && goalNow.Charge >= goalNow.Target; @@ -224,8 +246,17 @@ namespace ProjectM.Client { case RunLifecycle.Staging: // The READY panel (bottom-center) owns the action + N/M count; the top line frames intent. - _locationText.text = "AT THE BASE - build defenses, buy upgrades, READY UP to launch"; - _locationText.style.color = new Color(0.55f, 0.85f, 1f); + if ((float)SystemAPI.Time.ElapsedTime < _runFailedUntil) + { + // B6: a silent wipe used to land players home with ZERO explanation. + _locationText.text = "EXPEDITION FAILED - the party fell; nothing was banked"; + _locationText.style.color = new Color(1f, 0.35f, 0.3f); + } + else + { + _locationText.text = "AT THE BASE - build defenses, buy upgrades, READY UP to launch"; + _locationText.style.color = new Color(0.55f, 0.85f, 1f); + } break; case RunLifecycle.Launching: { @@ -1848,6 +1879,12 @@ namespace ProjectM.Client Label _classTitle, _prepTitle, _portalPrompt; Button _classWarBtn, _classRangerBtn; bool _classPanelBuilt, _prepPanelBuilt, _portalBuilt; + + // B6 run-failed read (client-local edge state; see the tracker near the top of OnUpdate). + byte _prevRunLifecycle; + int _chargeAtLaunch; + bool _wentInRun; + float _runFailedUntil; int _classShownFor, _prepShownFor; void UpdateClassPanel(bool show, byte classId) diff --git a/Assets/_Project/Scripts/Client/Presentation/PlayerAnimationDriveSystem.cs b/Assets/_Project/Scripts/Client/Presentation/PlayerAnimationDriveSystem.cs index e54034307..5c4cf1ea4 100644 --- a/Assets/_Project/Scripts/Client/Presentation/PlayerAnimationDriveSystem.cs +++ b/Assets/_Project/Scripts/Client/Presentation/PlayerAnimationDriveSystem.cs @@ -113,6 +113,20 @@ namespace ProjectM.Client return start.IsValid && end.IsValid && !start.IsNewerThan(serverTick) && end.IsNewerThan(serverTick); } + // B7: the class FIRE ability finally plays a body animation - a stateless window derived from the + // replicated AbilityCooldown ([GhostField] NextFireTick) minus the locally-derived CooldownTicks + // (EffectiveAbilityStats recomputes identically on every world), so it works for the predicted local + // player AND interpolated remotes with no cached edges (review B7: edge-caches false-fire on + // relevancy/join/rollback). Reuses the swing clip until a dedicated fire clip exists. + static bool FireActive(uint nextFireRaw, int cooldownTicks, NetworkTick serverTick, uint animTicks) + { + if (nextFireRaw == 0u || cooldownTicks <= 0 || !serverTick.IsValid) return false; + uint startRaw = TickUtil.NonZero(nextFireRaw - (uint)cooldownTicks); + var start = new NetworkTick(startRaw); + var end = new NetworkTick(TickUtil.NonZero(startRaw + animTicks)); + return start.IsValid && end.IsValid && !start.IsNewerThan(serverTick) && end.IsNewerThan(serverTick); + } + // LOCAL: GhostOwnerIsLocal ENABLED -> exactly the owned player. WithPresent so alive // (Dead-disabled) players are visited. NOTE: GhostOwnerIsLocal as a WithAll filter respects the // enable bit; do NOT take it as an `in` parameter (that matches on presence -> drives remotes too). @@ -132,12 +146,16 @@ namespace ProjectM.Client in EffectiveCharacterStats stats, in KinematicCharacterBody body, in MeleeCombo melee, + in AbilityCooldown fireCooldown, + in EffectiveAbilityStats abilityStats, EnabledRefRO dead) { var a = new AnimatorParametersAspect(parametersArr, indexTable); float3 p = AnimParamMath.LocomotionParams(body.RelativeVelocity, facing.Direction, stats.MoveSpeed); Write(ref a, p, dead.ValueRO, moveX, moveZ, speed, isDead); - if (a.HasParameter(isAttacking)) a.SetParameterValue(isAttacking, SwingActive(melee, serverTick, attackTicks)); + if (a.HasParameter(isAttacking)) a.SetParameterValue(isAttacking, + SwingActive(melee, serverTick, attackTicks) + || FireActive(fireCooldown.NextFireTick, abilityStats.CooldownTicks, serverTick, attackTicks)); } } @@ -163,6 +181,8 @@ namespace ProjectM.Client in PlayerFacing facing, in EffectiveCharacterStats stats, in MeleeCombo melee, + in AbilityCooldown fireCooldown, + in EffectiveAbilityStats abilityStats, EnabledRefRO dead) { seen.Add(e); @@ -175,7 +195,9 @@ namespace ProjectM.Client var a = new AnimatorParametersAspect(parametersArr, indexTable); float3 p = AnimParamMath.LocomotionParams(vel, facing.Direction, stats.MoveSpeed); Write(ref a, p, dead.ValueRO, moveX, moveZ, speed, isDead); - if (a.HasParameter(isAttacking)) a.SetParameterValue(isAttacking, SwingActive(melee, serverTick, attackTicks)); + if (a.HasParameter(isAttacking)) a.SetParameterValue(isAttacking, + SwingActive(melee, serverTick, attackTicks) + || FireActive(fireCooldown.NextFireTick, abilityStats.CooldownTicks, serverTick, attackTicks)); } } diff --git a/Assets/_Project/Scripts/Server/Combat/BossAISystem.cs b/Assets/_Project/Scripts/Server/Combat/BossAISystem.cs index 8d1b666db..4edf964e6 100644 --- a/Assets/_Project/Scripts/Server/Combat/BossAISystem.cs +++ b/Assets/_Project/Scripts/Server/Combat/BossAISystem.cs @@ -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 [GhostField] (CombatFeedbackSystem /// draws a boss-scale ring). At/below 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 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, RefRO, RefRO, RefRW, - RefRW, RefRW>() + RefRW, RefRW, RefRW>() .WithAll().WithNone()) { 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) diff --git a/Assets/_Project/Scripts/Server/Combat/EnemyAISystem.cs b/Assets/_Project/Scripts/Server/Combat/EnemyAISystem.cs index 6f647ce47..711378c68 100644 --- a/Assets/_Project/Scripts/Server/Combat/EnemyAISystem.cs +++ b/Assets/_Project/Scripts/Server/Combat/EnemyAISystem.cs @@ -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(Allocator.Temp); + var sepPos = new NativeList(Allocator.Temp); + var sepRad = new NativeList(Allocator.Temp); + var sepMov = new NativeList(Allocator.Temp); + foreach (var (sxf, shr, se) in SystemAPI.Query, RefRO>() + .WithAll().WithNone().WithEntityAccess()) + { + bool movable = !SystemAPI.HasComponent(se); + if (movable && SystemAPI.HasComponent(se)) + { + var k = SystemAPI.GetComponent(se); + if (k.UntilTick != 0 && new NetworkTick(k.UntilTick).IsNewerThan(serverTick)) movable = false; + } + if (movable && SystemAPI.HasComponent(se)) + { + var l = SystemAPI.GetComponent(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(sepEnt[i]); + sepXf.ValueRW.Position = sepTarget; + } + sepEnt.Dispose(); sepPos.Dispose(); sepRad.Dispose(); sepMov.Dispose(); + } + if (chargerWhiffsThisTick != 0 && SystemAPI.HasSingleton()) SystemAPI.GetSingletonRW().ValueRW.ChargerWhiffWindowsOpened += chargerWhiffsThisTick; diff --git a/Assets/_Project/Scripts/Server/Combat/RoomEnemyDirectorSystem.cs b/Assets/_Project/Scripts/Server/Combat/RoomEnemyDirectorSystem.cs index 2c5731841..e92799d24 100644 --- a/Assets/_Project/Scripts/Server/Combat/RoomEnemyDirectorSystem.cs +++ b/Assets/_Project/Scripts/Server/Combat/RoomEnemyDirectorSystem.cs @@ -171,9 +171,16 @@ namespace ProjectM.Server ecb.SetComponent(enemy, bxform); if (SystemAPI.HasComponent(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>().WithAll()) + 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(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(prefab)) diff --git a/Assets/_Project/Scripts/Simulation/Combat/BossState.cs b/Assets/_Project/Scripts/Simulation/Combat/BossState.cs index 8cbd8b150..67917d275 100644 --- a/Assets/_Project/Scripts/Simulation/Combat/BossState.cs +++ b/Assets/_Project/Scripts/Simulation/Combat/BossState.cs @@ -27,5 +27,14 @@ namespace ProjectM.Simulation /// Earliest raw tick the boss may summon its next add pack (phase two only; NonZero; 0 = ready). public uint SummonReadyTick; + + /// Earliest raw tick the boss may begin its next LUNGE wind-up (B4; NonZero; 0 = ready). + public uint LungeReadyTick; + + /// Which attack the live AttackWindup belongs to: 0 = radial slam, 1 = lunge (B4 — slam and lunge + /// share the one replicated windup field; this server-only byte disambiguates the elapse branch). The client + /// distinguishes via the IsLunging ghost bit instead (BossAISystem holds LungeState.UntilTick through the + /// lunge wind-up + travel, so EnemyAISystem's derive turns the bit on). + public byte PendingAttack; } } diff --git a/Assets/_Project/Scripts/Simulation/Debug/TuningConfig.cs b/Assets/_Project/Scripts/Simulation/Debug/TuningConfig.cs index 1027f88f3..adf4d61ab 100644 --- a/Assets/_Project/Scripts/Simulation/Debug/TuningConfig.cs +++ b/Assets/_Project/Scripts/Simulation/Debug/TuningConfig.cs @@ -61,6 +61,11 @@ namespace ProjectM.Simulation // than a normal one; GoalReachedSystem also math.max(1, ...) at the use-site. public float FinalSiegeMultiplier; + // Phase 1 B1/B2 (design review wf_fd177263): poise threshold on the EXISTING KnockbackState.Speed channel + // (light melee 6 nudges; finisher 6x1.8=10.8 and cone 8 stagger), and the separation pass's push-speed cap. + public float StaggerKnockbackSpeed; + public float SeparationMaxSpeed; + /// The baked feel defaults == the pre-MC-0 consts. Single source of truth for the fallback path. public static TuningConfig Defaults() => new TuningConfig { @@ -88,6 +93,8 @@ namespace ProjectM.Simulation CoreRegenIntervalTicks = 18f, // END-1: +1 integrity / 0.3s in Calm (~30s to refill 100 from 0) CoreOverrunDrainPct = 0.5f, // END-1: a breach costs half the shared ledger (soft-loss penalty) FinalSiegeMultiplier = 2.5f, // END-2: the final siege is ~2.5x the would-be-next normal siege + StaggerKnockbackSpeed = 7f, // B2 poise: kb.Speed >= this interrupts windups/lunges; below = nudge only + SeparationMaxSpeed = 3f, // B1: max separation push (units/s) so soft-collision can't fling }; /// Clamp a knob to its safe floor: tick knobs >= 1, value knobs >= 0. Used by every write path @@ -109,6 +116,8 @@ namespace ProjectM.Simulation case TuningKnob.StructureAggroWeight: case TuningKnob.CoreDamagePerHusk: case TuningKnob.CoreOverrunDrainPct: + case TuningKnob.StaggerKnockbackSpeed: + case TuningKnob.SeparationMaxSpeed: return math.max(0f, value); // tick knobs: >= 1 (a 0 tick count is degenerate; a 0 i-frame window divides-by-zero in DashSystem). // FinalSiegeMultiplier also lands here on purpose — a final siege should never be < 1x a normal one. @@ -147,6 +156,8 @@ namespace ProjectM.Simulation case TuningKnob.CoreRegenIntervalTicks: c.CoreRegenIntervalTicks = value; break; case TuningKnob.CoreOverrunDrainPct: c.CoreOverrunDrainPct = value; break; case TuningKnob.FinalSiegeMultiplier: c.FinalSiegeMultiplier = value; break; + case TuningKnob.StaggerKnockbackSpeed: c.StaggerKnockbackSpeed = value; break; + case TuningKnob.SeparationMaxSpeed: c.SeparationMaxSpeed = value; break; // unknown index -> no-op (matches the no-default switch convention in DebugCommandReceiveSystem) } } @@ -180,6 +191,8 @@ namespace ProjectM.Simulation case TuningKnob.CoreRegenIntervalTicks: return c.CoreRegenIntervalTicks; case TuningKnob.CoreOverrunDrainPct: return c.CoreOverrunDrainPct; case TuningKnob.FinalSiegeMultiplier: return c.FinalSiegeMultiplier; + case TuningKnob.StaggerKnockbackSpeed: return c.StaggerKnockbackSpeed; + case TuningKnob.SeparationMaxSpeed: return c.SeparationMaxSpeed; default: return 0f; } } @@ -211,6 +224,8 @@ namespace ProjectM.Simulation CoreRegenIntervalTicks = c.CoreRegenIntervalTicks, CoreOverrunDrainPct = c.CoreOverrunDrainPct, FinalSiegeMultiplier = c.FinalSiegeMultiplier, + StaggerKnockbackSpeed = c.StaggerKnockbackSpeed, + SeparationMaxSpeed = c.SeparationMaxSpeed, }; /// Reconstruct the full config from a wire snapshot (FULL state, not a delta). @@ -240,6 +255,8 @@ namespace ProjectM.Simulation CoreRegenIntervalTicks = r.CoreRegenIntervalTicks, CoreOverrunDrainPct = r.CoreOverrunDrainPct, FinalSiegeMultiplier = r.FinalSiegeMultiplier, + StaggerKnockbackSpeed = r.StaggerKnockbackSpeed, + SeparationMaxSpeed = r.SeparationMaxSpeed, }; } @@ -270,9 +287,11 @@ namespace ProjectM.Simulation public const byte CoreRegenIntervalTicks = 21; public const byte CoreOverrunDrainPct = 22; public const byte FinalSiegeMultiplier = 23; + public const byte StaggerKnockbackSpeed = 24; + public const byte SeparationMaxSpeed = 25; /// Knob count (overlay iteration bound). - public const byte Count = 24; + public const byte Count = 26; } /// @@ -307,5 +326,7 @@ namespace ProjectM.Simulation public float CoreRegenIntervalTicks; public float CoreOverrunDrainPct; public float FinalSiegeMultiplier; + public float StaggerKnockbackSpeed; + public float SeparationMaxSpeed; } } diff --git a/Assets/_Project/Scripts/Simulation/Tuning.cs b/Assets/_Project/Scripts/Simulation/Tuning.cs index 28b1ce031..629e84764 100644 --- a/Assets/_Project/Scripts/Simulation/Tuning.cs +++ b/Assets/_Project/Scripts/Simulation/Tuning.cs @@ -99,6 +99,10 @@ namespace ProjectM.Simulation /// non-targetable corpse so the client death animation has time to read before the despawn burst. public const uint EnemyDeathWindowTicks = 54; + /// Phase 1 (B5): boss HP grows by this fraction of its base per LIVING expedition player beyond the + /// first (counted at spawn; Health.Max replicates so the bar stays truthful). + public const float BossHealthPerExtraPlayer = 0.75f; + // ---- 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 @@ -114,6 +118,16 @@ namespace ProjectM.Simulation /// Ticks between SLAMs in phase one (~4 s). Phase two multiplies this by BossPhase2SlamCooldownMult. public const uint BossSlamCooldownTicks = 240; + // Phase 1 (B4): the boss's SECOND attack - a telegraphed gap-closing LUNGE (repositioning; the slam stays + // the damage beat). Windup is readable (~0.6 s); travel reuses the Charger contract (committed, wall-stopped). + public const uint BossLungeWindupTicks = 35; + public const float BossLungeSpeed = 20f; + public const uint BossLungeDurationTicks = 22; + public const uint BossLungeCooldownTicks = 300; + /// Lunge only when the target is beyond this (else slam) and within LungeMaxRange. + public const float BossLungeMinRange = 7f; + public const float BossLungeMaxRange = 20f; + /// Health fraction at/below which the boss enters phase two (faster + summons adds). public const float BossPhase2HealthFraction = 0.5f;