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:
@@ -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);
|
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 (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
|
// 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).
|
// matches the hit area (a forward wedge sized to melee reach would lie about a radial AoE).
|
||||||
BuildDangerMesh(go.GetComponent<MeshFilter>().sharedMesh, Tuning.BossSlamRadius, 3.14159f, intensity);
|
BuildDangerMesh(go.GetComponent<MeshFilter>().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<MeshFilter>().sharedMesh, math.max(coneRange, 8f), 0.45f, intensity);
|
||||||
|
}
|
||||||
else if (tele.ValueRO.Kind == ZoneEnemyMath.KindSpitter)
|
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
|
// MC-3: a Spitter is a RANGED threat — a melee wedge at its feet is useless. Paint a thin aim
|
||||||
|
|||||||
@@ -181,6 +181,28 @@ namespace ProjectM.Client
|
|||||||
|
|
||||||
// ---- Macro: phase + cycle + countdown (center-top banner) ----
|
// ---- Macro: phase + cycle + countdown (center-top banner) ----
|
||||||
bool haveRun = SystemAPI.TryGetSingleton<RunInfo>(out var runInfo); // hoisted: the phase banner is lifecycle-aware (Phase 0 fix — it read "AT BASE" inside expedition rooms)
|
bool haveRun = SystemAPI.TryGetSingleton<RunInfo>(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<GoalProgress>(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<CycleState>(out var cyc);
|
bool haveCycle = SystemAPI.TryGetSingleton<CycleState>(out var cyc);
|
||||||
bool siege = haveCycle && cyc.Phase == CyclePhase.Siege;
|
bool siege = haveCycle && cyc.Phase == CyclePhase.Siege;
|
||||||
bool goalFull = SystemAPI.TryGetSingleton<GoalProgress>(out var goalNow) && goalNow.Target > 0 && goalNow.Charge >= goalNow.Target;
|
bool goalFull = SystemAPI.TryGetSingleton<GoalProgress>(out var goalNow) && goalNow.Target > 0 && goalNow.Charge >= goalNow.Target;
|
||||||
@@ -224,8 +246,17 @@ namespace ProjectM.Client
|
|||||||
{
|
{
|
||||||
case RunLifecycle.Staging:
|
case RunLifecycle.Staging:
|
||||||
// The READY panel (bottom-center) owns the action + N/M count; the top line frames intent.
|
// 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";
|
if ((float)SystemAPI.Time.ElapsedTime < _runFailedUntil)
|
||||||
_locationText.style.color = new Color(0.55f, 0.85f, 1f);
|
{
|
||||||
|
// 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;
|
break;
|
||||||
case RunLifecycle.Launching:
|
case RunLifecycle.Launching:
|
||||||
{
|
{
|
||||||
@@ -1848,6 +1879,12 @@ namespace ProjectM.Client
|
|||||||
Label _classTitle, _prepTitle, _portalPrompt;
|
Label _classTitle, _prepTitle, _portalPrompt;
|
||||||
Button _classWarBtn, _classRangerBtn;
|
Button _classWarBtn, _classRangerBtn;
|
||||||
bool _classPanelBuilt, _prepPanelBuilt, _portalBuilt;
|
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;
|
int _classShownFor, _prepShownFor;
|
||||||
|
|
||||||
void UpdateClassPanel(bool show, byte classId)
|
void UpdateClassPanel(bool show, byte classId)
|
||||||
|
|||||||
@@ -113,6 +113,20 @@ namespace ProjectM.Client
|
|||||||
return start.IsValid && end.IsValid && !start.IsNewerThan(serverTick) && end.IsNewerThan(serverTick);
|
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<Dead> so alive
|
// LOCAL: GhostOwnerIsLocal ENABLED -> exactly the owned player. WithPresent<Dead> so alive
|
||||||
// (Dead-disabled) players are visited. NOTE: GhostOwnerIsLocal as a WithAll filter respects the
|
// (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).
|
// 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 EffectiveCharacterStats stats,
|
||||||
in KinematicCharacterBody body,
|
in KinematicCharacterBody body,
|
||||||
in MeleeCombo melee,
|
in MeleeCombo melee,
|
||||||
|
in AbilityCooldown fireCooldown,
|
||||||
|
in EffectiveAbilityStats abilityStats,
|
||||||
EnabledRefRO<Dead> dead)
|
EnabledRefRO<Dead> dead)
|
||||||
{
|
{
|
||||||
var a = new AnimatorParametersAspect(parametersArr, indexTable);
|
var a = new AnimatorParametersAspect(parametersArr, indexTable);
|
||||||
float3 p = AnimParamMath.LocomotionParams(body.RelativeVelocity, facing.Direction, stats.MoveSpeed);
|
float3 p = AnimParamMath.LocomotionParams(body.RelativeVelocity, facing.Direction, stats.MoveSpeed);
|
||||||
Write(ref a, p, dead.ValueRO, moveX, moveZ, speed, isDead);
|
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 PlayerFacing facing,
|
||||||
in EffectiveCharacterStats stats,
|
in EffectiveCharacterStats stats,
|
||||||
in MeleeCombo melee,
|
in MeleeCombo melee,
|
||||||
|
in AbilityCooldown fireCooldown,
|
||||||
|
in EffectiveAbilityStats abilityStats,
|
||||||
EnabledRefRO<Dead> dead)
|
EnabledRefRO<Dead> dead)
|
||||||
{
|
{
|
||||||
seen.Add(e);
|
seen.Add(e);
|
||||||
@@ -175,7 +195,9 @@ namespace ProjectM.Client
|
|||||||
var a = new AnimatorParametersAspect(parametersArr, indexTable);
|
var a = new AnimatorParametersAspect(parametersArr, indexTable);
|
||||||
float3 p = AnimParamMath.LocomotionParams(vel, facing.Direction, stats.MoveSpeed);
|
float3 p = AnimParamMath.LocomotionParams(vel, facing.Direction, stats.MoveSpeed);
|
||||||
Write(ref a, p, dead.ValueRO, moveX, moveZ, speed, isDead);
|
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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,8 +19,11 @@ namespace ProjectM.Server
|
|||||||
/// v2 boss = a real fight (operator-locked): chase the nearest living expedition player, then a telegraphed radial
|
/// 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
|
/// 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,
|
/// 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
|
/// slams more often, and periodically summons swarmer adds. B4 (Phase 1): the boss ALSO lunges - a telegraphed
|
||||||
/// idle, so EnemyAISystem's IsLunging derive sees UntilTick==0 → bit off, harmless). Knockback-immune (the stamp
|
/// 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
|
/// 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
|
/// 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
|
/// 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);
|
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>,
|
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>())
|
.WithAll<EnemyTag, BossState>().WithNone<Dying>())
|
||||||
{
|
{
|
||||||
float3 pos = xform.ValueRO.Position;
|
float3 pos = xform.ValueRO.Position;
|
||||||
@@ -132,6 +135,18 @@ namespace ProjectM.Server
|
|||||||
var wt = new NetworkTick(windRaw);
|
var wt = new NetworkTick(windRaw);
|
||||||
if (!(wt.IsValid && wt.IsNewerThan(serverTick)))
|
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;
|
float slamSq = Tuning.BossSlamRadius * Tuning.BossSlamRadius;
|
||||||
for (int i = 0; i < playerEntities.Length; i++)
|
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
|
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). ---
|
// --- Chase (no active slam). ---
|
||||||
float speed = stats.ValueRO.MoveSpeed * (phase == 2 ? Tuning.BossPhase2SpeedMult : 1f);
|
float speed = stats.ValueRO.MoveSpeed * (phase == 2 ? Tuning.BossPhase2SpeedMult : 1f);
|
||||||
float stopDist = stats.ValueRO.AttackRange * 0.9f;
|
float stopDist = stats.ValueRO.AttackRange * 0.9f;
|
||||||
@@ -166,8 +209,28 @@ namespace ProjectM.Server
|
|||||||
bool slamReady = boss.ValueRO.SlamReadyTick == 0u
|
bool slamReady = boss.ValueRO.SlamReadyTick == 0u
|
||||||
|| !new NetworkTick(boss.ValueRO.SlamReadyTick).IsNewerThan(serverTick);
|
|| !new NetworkTick(boss.ValueRO.SlamReadyTick).IsNewerThan(serverTick);
|
||||||
float lead = Tuning.BossSlamRadius + 1.5f;
|
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);
|
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.
|
// Summon (phase two only): ready + under the live cap + a swarmer prefab wired.
|
||||||
if (phase == 2 && swarmerPrefab != Entity.Null && liveZone < Tuning.BossSummonLiveCap)
|
if (phase == 2 && swarmerPrefab != Entity.Null && liveZone < Tuning.BossSummonLiveCap)
|
||||||
|
|||||||
@@ -126,7 +126,8 @@ namespace ProjectM.Server
|
|||||||
if (sweep)
|
if (sweep)
|
||||||
kpos = SweptMove(in physics, pos, kpos, SweepRadius, envFilter);
|
kpos = SweptMove(in physics, pos, kpos, SweepRadius, envFilter);
|
||||||
xform.ValueRW.Position = kpos;
|
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
|
continue; // recoiling: skip seek + strike this tick
|
||||||
}
|
}
|
||||||
knockback.ValueRW.UntilTick = 0; // window elapsed
|
knockback.ValueRW.UntilTick = 0; // window elapsed
|
||||||
@@ -240,8 +241,11 @@ namespace ProjectM.Server
|
|||||||
kpos.y = pos.y;
|
kpos.y = pos.y;
|
||||||
if (sweep) kpos = SweptMove(in physics, pos, kpos, SweepRadius, envFilter);
|
if (sweep) kpos = SweptMove(in physics, pos, kpos, SweepRadius, envFilter);
|
||||||
xform.ValueRW.Position = kpos;
|
xform.ValueRW.Position = kpos;
|
||||||
windup.ValueRW.WindUpUntilTick = 0;
|
if (kb.Speed >= tune.StaggerKnockbackSpeed)
|
||||||
lunge.ValueRW.UntilTick = 0;
|
{
|
||||||
|
windup.ValueRW.WindUpUntilTick = 0; // B2 poise: only a HEAVY hit breaks the windup / committed lunge
|
||||||
|
lunge.ValueRW.UntilTick = 0;
|
||||||
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
knockback.ValueRW.UntilTick = 0;
|
knockback.ValueRW.UntilTick = 0;
|
||||||
@@ -380,7 +384,8 @@ namespace ProjectM.Server
|
|||||||
kpos.y = pos.y;
|
kpos.y = pos.y;
|
||||||
if (sweep) kpos = SweptMove(in physics, pos, kpos, SweepRadius, envFilter);
|
if (sweep) kpos = SweptMove(in physics, pos, kpos, SweepRadius, envFilter);
|
||||||
xform.ValueRW.Position = kpos;
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
knockback.ValueRW.UntilTick = 0;
|
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
|
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>())
|
if (chargerWhiffsThisTick != 0 && SystemAPI.HasSingleton<DevTelemetry>())
|
||||||
SystemAPI.GetSingletonRW<DevTelemetry>().ValueRW.ChargerWhiffWindowsOpened += chargerWhiffsThisTick;
|
SystemAPI.GetSingletonRW<DevTelemetry>().ValueRW.ChargerWhiffWindowsOpened += chargerWhiffsThisTick;
|
||||||
|
|
||||||
|
|||||||
@@ -171,9 +171,16 @@ namespace ProjectM.Server
|
|||||||
ecb.SetComponent(enemy, bxform);
|
ecb.SetComponent(enemy, bxform);
|
||||||
if (SystemAPI.HasComponent<Health>(prefab))
|
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);
|
var hp = SystemAPI.GetComponent<Health>(prefab);
|
||||||
hp.Current *= Tuning.BossHealthMultiplier;
|
hp.Current *= Tuning.BossHealthMultiplier * partyScale;
|
||||||
hp.Max *= Tuning.BossHealthMultiplier;
|
hp.Max *= Tuning.BossHealthMultiplier * partyScale;
|
||||||
ecb.SetComponent(enemy, hp);
|
ecb.SetComponent(enemy, hp);
|
||||||
}
|
}
|
||||||
if (SystemAPI.HasComponent<HitRadius>(prefab))
|
if (SystemAPI.HasComponent<HitRadius>(prefab))
|
||||||
|
|||||||
@@ -27,5 +27,14 @@ namespace ProjectM.Simulation
|
|||||||
|
|
||||||
/// <summary>Earliest raw tick the boss may summon its next add pack (phase two only; NonZero; 0 = ready).</summary>
|
/// <summary>Earliest raw tick the boss may summon its next add pack (phase two only; NonZero; 0 = ready).</summary>
|
||||||
public uint SummonReadyTick;
|
public uint SummonReadyTick;
|
||||||
|
|
||||||
|
/// <summary>Earliest raw tick the boss may begin its next LUNGE wind-up (B4; NonZero; 0 = ready).</summary>
|
||||||
|
public uint LungeReadyTick;
|
||||||
|
|
||||||
|
/// <summary>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).</summary>
|
||||||
|
public byte PendingAttack;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,6 +61,11 @@ namespace ProjectM.Simulation
|
|||||||
// than a normal one; GoalReachedSystem also math.max(1, ...) at the use-site.
|
// than a normal one; GoalReachedSystem also math.max(1, ...) at the use-site.
|
||||||
public float FinalSiegeMultiplier;
|
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;
|
||||||
|
|
||||||
/// <summary>The baked feel defaults == the pre-MC-0 consts. Single source of truth for the fallback path.</summary>
|
/// <summary>The baked feel defaults == the pre-MC-0 consts. Single source of truth for the fallback path.</summary>
|
||||||
public static TuningConfig Defaults() => new TuningConfig
|
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)
|
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)
|
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
|
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
|
||||||
};
|
};
|
||||||
|
|
||||||
/// <summary>Clamp a knob to its safe floor: tick knobs >= 1, value knobs >= 0. Used by every write path
|
/// <summary>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.StructureAggroWeight:
|
||||||
case TuningKnob.CoreDamagePerHusk:
|
case TuningKnob.CoreDamagePerHusk:
|
||||||
case TuningKnob.CoreOverrunDrainPct:
|
case TuningKnob.CoreOverrunDrainPct:
|
||||||
|
case TuningKnob.StaggerKnockbackSpeed:
|
||||||
|
case TuningKnob.SeparationMaxSpeed:
|
||||||
return math.max(0f, value);
|
return math.max(0f, value);
|
||||||
// tick knobs: >= 1 (a 0 tick count is degenerate; a 0 i-frame window divides-by-zero in DashSystem).
|
// 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.
|
// 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.CoreRegenIntervalTicks: c.CoreRegenIntervalTicks = value; break;
|
||||||
case TuningKnob.CoreOverrunDrainPct: c.CoreOverrunDrainPct = value; break;
|
case TuningKnob.CoreOverrunDrainPct: c.CoreOverrunDrainPct = value; break;
|
||||||
case TuningKnob.FinalSiegeMultiplier: c.FinalSiegeMultiplier = 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)
|
// 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.CoreRegenIntervalTicks: return c.CoreRegenIntervalTicks;
|
||||||
case TuningKnob.CoreOverrunDrainPct: return c.CoreOverrunDrainPct;
|
case TuningKnob.CoreOverrunDrainPct: return c.CoreOverrunDrainPct;
|
||||||
case TuningKnob.FinalSiegeMultiplier: return c.FinalSiegeMultiplier;
|
case TuningKnob.FinalSiegeMultiplier: return c.FinalSiegeMultiplier;
|
||||||
|
case TuningKnob.StaggerKnockbackSpeed: return c.StaggerKnockbackSpeed;
|
||||||
|
case TuningKnob.SeparationMaxSpeed: return c.SeparationMaxSpeed;
|
||||||
default: return 0f;
|
default: return 0f;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -211,6 +224,8 @@ namespace ProjectM.Simulation
|
|||||||
CoreRegenIntervalTicks = c.CoreRegenIntervalTicks,
|
CoreRegenIntervalTicks = c.CoreRegenIntervalTicks,
|
||||||
CoreOverrunDrainPct = c.CoreOverrunDrainPct,
|
CoreOverrunDrainPct = c.CoreOverrunDrainPct,
|
||||||
FinalSiegeMultiplier = c.FinalSiegeMultiplier,
|
FinalSiegeMultiplier = c.FinalSiegeMultiplier,
|
||||||
|
StaggerKnockbackSpeed = c.StaggerKnockbackSpeed,
|
||||||
|
SeparationMaxSpeed = c.SeparationMaxSpeed,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// <summary>Reconstruct the full config from a wire snapshot (FULL state, not a delta).</summary>
|
/// <summary>Reconstruct the full config from a wire snapshot (FULL state, not a delta).</summary>
|
||||||
@@ -240,6 +255,8 @@ namespace ProjectM.Simulation
|
|||||||
CoreRegenIntervalTicks = r.CoreRegenIntervalTicks,
|
CoreRegenIntervalTicks = r.CoreRegenIntervalTicks,
|
||||||
CoreOverrunDrainPct = r.CoreOverrunDrainPct,
|
CoreOverrunDrainPct = r.CoreOverrunDrainPct,
|
||||||
FinalSiegeMultiplier = r.FinalSiegeMultiplier,
|
FinalSiegeMultiplier = r.FinalSiegeMultiplier,
|
||||||
|
StaggerKnockbackSpeed = r.StaggerKnockbackSpeed,
|
||||||
|
SeparationMaxSpeed = r.SeparationMaxSpeed,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -270,9 +287,11 @@ namespace ProjectM.Simulation
|
|||||||
public const byte CoreRegenIntervalTicks = 21;
|
public const byte CoreRegenIntervalTicks = 21;
|
||||||
public const byte CoreOverrunDrainPct = 22;
|
public const byte CoreOverrunDrainPct = 22;
|
||||||
public const byte FinalSiegeMultiplier = 23;
|
public const byte FinalSiegeMultiplier = 23;
|
||||||
|
public const byte StaggerKnockbackSpeed = 24;
|
||||||
|
public const byte SeparationMaxSpeed = 25;
|
||||||
|
|
||||||
/// <summary>Knob count (overlay iteration bound).</summary>
|
/// <summary>Knob count (overlay iteration bound).</summary>
|
||||||
public const byte Count = 24;
|
public const byte Count = 26;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -307,5 +326,7 @@ namespace ProjectM.Simulation
|
|||||||
public float CoreRegenIntervalTicks;
|
public float CoreRegenIntervalTicks;
|
||||||
public float CoreOverrunDrainPct;
|
public float CoreOverrunDrainPct;
|
||||||
public float FinalSiegeMultiplier;
|
public float FinalSiegeMultiplier;
|
||||||
|
public float StaggerKnockbackSpeed;
|
||||||
|
public float SeparationMaxSpeed;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -99,6 +99,10 @@ namespace ProjectM.Simulation
|
|||||||
/// non-targetable corpse so the client death animation has time to read before the despawn burst.</summary>
|
/// non-targetable corpse so the client death animation has time to read before the despawn burst.</summary>
|
||||||
public const uint EnemyDeathWindowTicks = 54;
|
public const uint EnemyDeathWindowTicks = 54;
|
||||||
|
|
||||||
|
/// <summary>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).</summary>
|
||||||
|
public const float BossHealthPerExtraPlayer = 0.75f;
|
||||||
|
|
||||||
// ---- 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
|
||||||
@@ -114,6 +118,16 @@ namespace ProjectM.Simulation
|
|||||||
/// <summary>Ticks between SLAMs in phase one (~4 s). Phase two multiplies this by BossPhase2SlamCooldownMult.</summary>
|
/// <summary>Ticks between SLAMs in phase one (~4 s). Phase two multiplies this by BossPhase2SlamCooldownMult.</summary>
|
||||||
public const uint BossSlamCooldownTicks = 240;
|
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;
|
||||||
|
/// <summary>Lunge only when the target is beyond this (else slam) and within LungeMaxRange.</summary>
|
||||||
|
public const float BossLungeMinRange = 7f;
|
||||||
|
public const float BossLungeMaxRange = 20f;
|
||||||
|
|
||||||
/// <summary>Health fraction at/below which the boss enters phase two (faster + summons adds).</summary>
|
/// <summary>Health fraction at/below which the boss enters phase two (faster + summons adds).</summary>
|
||||||
public const float BossPhase2HealthFraction = 0.5f;
|
public const float BossPhase2HealthFraction = 0.5f;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user