Base<->Expedition ties: portal rooms, class-at-base, prep spend + Health.Max, C3/C4/Spitter (DR-046)

Portal-gated rooms: new RunLifecycle.RoomExplore loot window; room+nodes
persist past the last kill; PortalInteractRequest -> server-only PortalCommand
advances (client derives the portal pos). RunDirectorSystem moves teardown off
the InRoom edge, relocates the boss-branch/route-gate into the RoomExplore exit,
and advances an empty expedition immediately (incl. a boss clear).

Class-at-base via a shared ClassSwapUtil (meta-band strip + per-class
MetaTierState replay, so the base RPC and the dev SetClass can't drift);
ClassSelectRequest Staging-gated. Per-run PREP buffs (PrepPurchaseRequest,
PrepCatalog): once-per-run == the row's prep StatModifier band is present,
TotalOf-before-Withdraw atomic, stripped on Returning beside the boon band.

Health.Max promoted to [GhostField] (the one ghost-hash re-bake) so the boss +
floating enemy HP bars read it directly. Feel: melee cone connect-thunk (C3),
hit-stop throttle so a horde wipe can't stutter (C4), Spitter cornered-hold.

456/456 EditMode; two adversarial reviews (pre-code 13-confirmed folded;
post-impl clean bar the RoomExplore boss-clear dead-time, fixed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-06 10:37:24 -07:00
parent 35357faa7a
commit 304ee8c2a7
35 changed files with 860 additions and 110 deletions
@@ -62,7 +62,8 @@ namespace ProjectM.Client
bool _slashActive;
float _slashRange, _slashHalf; // live cone geometry re-sampled each frame for the per-frame sweep rebuild
int _slashSweepSign = 1; // alternate sweep direction per combo step (reads as alternating strikes)
uint _lastConeFireTick; // own latch — the muzzle block owns _lastLocalFireTick and runs first
uint _lastConeFireTick; // own latch — the muzzle block owns _lastLocalFireTick and runs first
double _lastHoldTime; // C4: last hit-stop hold time (throttle so a horde wipe doesn't stutter)
bool _coneTickInit;
Material _dangerMat;
readonly Dictionary<Entity, GameObject> _dangerZones = new();
@@ -249,6 +250,8 @@ namespace ProjectM.Client
PlayClip(_hitClip, (Vector3)p, FeelConfig.HitSfxVolume);
PrototypeCameraRig.AddShake(isLocalPlayer ? FeelConfig.HitShakeLocal : FeelConfig.HitShakeRemote);
if (isLocalPlayer) PrototypeCameraRig.PunchFov(FeelConfig.HitStopFovKick, FeelConfig.HitStopDurationMs);
if (isLocalPlayer && (prev.Hp - cur) >= 20f) TryHold(); // C4: crunch on a heavy incoming hit (e.g. a boss slam)
if (isLocalPlayer && FeelConfig.RumbleEnabled && AimPresentation.Scheme == 1)
RumbleUtil.Pulse(FeelConfig.RumbleHit * 0.8f, FeelConfig.RumbleHit, FeelConfig.RumbleDurationSec);
if (isEnemy)
@@ -304,6 +307,8 @@ namespace ProjectM.Client
PlayClip(_deathClip, (Vector3)c.Pos, FeelConfig.KillSfxVolume);
PrototypeCameraRig.AddShake(FeelConfig.KillShake);
PrototypeCameraRig.PunchFov(FeelConfig.KillFovKick, FeelConfig.HitStopDurationMs);
TryHold(); // C4: kill crunch (throttled)
EmitColored(_hitFx, (Vector3)c.Pos + Vector3.up * 0.6f, FeelConfig.KillFlashBurstCount, FeelConfig.HitFlashColor); // kill pop
if (FeelConfig.RumbleEnabled && AimPresentation.Scheme == 1)
RumbleUtil.Pulse(FeelConfig.RumbleKill * 0.7f, FeelConfig.RumbleKill, FeelConfig.RumbleDurationSec);
@@ -413,7 +418,7 @@ namespace ProjectM.Client
if (finisher)
{
PrototypeCameraRig.PunchFov(FeelConfig.DashFovKick * 0.6f, FeelConfig.HitStopDurationMs);
if (FeelConfig.HitStopFreezeEnabled) PrototypeCameraRig.Hold(FeelConfig.HitStopMaxFrames); // C4: a beat of crunch on the combo finisher (the deliberate payoff hit)
TryHold(); // C4: a beat of crunch on the combo finisher (the deliberate payoff hit)
}
}
_lastLocalSwingTick = mc.SwingStartTick;
@@ -445,9 +450,29 @@ namespace ProjectM.Client
}
float coneRange = Mathf.Max(0.1f, ceff.Range);
float coneHalf = Mathf.Clamp(ceff.AutoTargetConeRadians, 0.01f, 3.14159f);
TriggerSlash((Vector3)localPos, new float2(cface.x, cface.z), coneRange, coneHalf, 1, 1, false);
// C3: client cone-overlap over the cached enemy snapshot -> an immediate "you hit" read + thunk
// (the server-only cone damage arrives a few ticks later), mirroring the melee connect path.
bool coneConnected = false; Vector3 coneHit = (Vector3)localPos; float cnd = float.MaxValue;
float coneCos = Mathf.Cos(coneHalf);
float2 cfdir = new float2(cface.x, cface.z);
foreach (var kv in _cache)
{
if (!kv.Value.IsEnemy) continue;
if (MeleeConeMath.InCone(localPos, cfdir, coneRange, coneCos, kv.Value.Pos))
{
float cd2 = math.distancesq(localPos, kv.Value.Pos);
if (cd2 < cnd) { cnd = cd2; coneHit = (Vector3)kv.Value.Pos; coneConnected = true; }
}
}
TriggerSlash((Vector3)localPos, new float2(cface.x, cface.z), coneRange, coneHalf, 1, 1, coneConnected);
PlayClip(_swingClip, (Vector3)localPos, 0.5f);
PrototypeCameraRig.AddShake(0.06f);
if (coneConnected)
{
Burst(_hitFx, cfg != null ? cfg.Hit : null, coneHit + Vector3.up * 0.7f, FeelConfig.HitBurstCount);
PlayClip(_meleeConnectClip, coneHit, FeelConfig.MeleeConnectVolume);
PrototypeCameraRig.PunchFov(FeelConfig.MeleeConnectFovKick, FeelConfig.HitStopDurationMs);
}
}
}
_lastConeFireTick = nextFire;
@@ -813,7 +838,18 @@ namespace ProjectM.Client
// Trigger a cone-shaped slash matching the LIVE melee range + half-angle, oriented along facing. The arc IS
// the range telegraph (MC-4 clarity) AND now SWEEPS across + ramps per combo step so the swing reads as a
// directional, escalating cleave rather than a static flash.
void TriggerSlash(Vector3 pos, float2 facing, float range, float halfAngle, int step, int comboLen, bool connected)
// C4: fire a brief presentation-only hit-stop hold, throttled (never Time.timeScale; the sim keeps ticking).
void TryHold()
{
if (!FeelConfig.HitStopFreezeEnabled) return;
double now = SystemAPI.Time.ElapsedTime;
if (now - _lastHoldTime < 0.22) return;
_lastHoldTime = now;
PrototypeCameraRig.Hold(FeelConfig.HitStopMaxFrames);
}
void TriggerSlash(Vector3 pos, float2 facing, float range, float halfAngle, int step, int comboLen, bool connected)
{
if (_slashMr == null || _slashMat == null) return;
bool finisher = step >= comboLen;