fix(movement): jump works locally (airborne velocity preserved)

Two fixes for jump physics:
- Skip ground-snap when velocity Z > 0 (prevents immediate re-landing
  at high framerates where per-frame Z delta < 0.05 snap threshold)
- Guard apply_current_movement velocity write behind OnWalkable check
  (prevents MotionInterpreter.DoMotion from zeroing jump velocity on
  every frame while airborne)
- Guard PlayerMovementController velocity replacement behind OnWalkable
  (preserves momentum during airborne flight)

Jump works locally but server packet not yet sent (BUG-002).
Facing direction mismatch logged as BUG-003.
RunRate not verified as BUG-004.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-14 00:12:11 +02:00
parent e08a06ac5b
commit 157ed9d974
4 changed files with 68 additions and 31 deletions

View file

@ -189,12 +189,6 @@ public sealed class PlayerMovementController
_body.Orientation = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, Yaw - MathF.PI / 2f);
// ── 2. Set velocity via MotionInterpreter state machine ───────────────
// Snapshot the current vertical velocity BEFORE calling DoMotion.
// DoMotion routes through apply_current_movement → set_local_velocity,
// which overwrites _body.Velocity with the horizontal state speed and
// zeros Z. We must snapshot Z first so we can restore it afterward.
float savedWorldVz = _body.Velocity.Z;
// Determine the dominant forward/backward command and speed.
uint forwardCmd;
float forwardCmdSpeed;
@ -205,7 +199,6 @@ public sealed class PlayerMovementController
}
else if (input.Backward)
{
// WalkBackward is tracked in interpreted state; we negate Y velocity below.
forwardCmd = MotionCommand.WalkBackward;
forwardCmdSpeed = 1.0f;
}
@ -215,7 +208,7 @@ public sealed class PlayerMovementController
forwardCmdSpeed = 1.0f;
}
// Update interpreted motion state.
// Update interpreted motion state (needed for animation + server messages).
_motion.DoMotion(forwardCmd, forwardCmdSpeed);
// Sidestep.
@ -229,28 +222,30 @@ public sealed class PlayerMovementController
_motion.StopInterpretedMotion(MotionCommand.SideStepLeft, modifyInterpretedState: true);
}
// get_state_velocity gives us the body-local speed magnitude from retail constants.
var stateVel = _motion.get_state_velocity();
// Only replace velocity with motion interpreter output when grounded.
// While airborne, the physics body's integrated velocity (from LeaveGround)
// persists — gravity pulls Z down, horizontal momentum is preserved.
// Retail AC works this way: you maintain momentum in the air.
if (_body.OnWalkable)
{
float savedWorldVz = _body.Velocity.Z;
var stateVel = _motion.get_state_velocity();
// Build the body-local velocity from the retail-derived speed values.
// get_state_velocity only fills +X for SideStepRight and +Y for forward;
// we must handle WalkBackward (negate Y) and SideStepLeft (negate X) manually.
float localY = 0f;
float localX = 0f;
float localY = 0f;
float localX = 0f;
if (input.Forward)
localY = stateVel.Y; // WalkAnimSpeed or RunAnimSpeed
else if (input.Backward)
localY = -(MotionInterpreter.WalkAnimSpeed * 0.65f); // retail backward is ~65% walk
if (input.Forward)
localY = stateVel.Y;
else if (input.Backward)
localY = -(MotionInterpreter.WalkAnimSpeed * 0.65f);
if (input.StrafeRight)
localX = MotionInterpreter.SidestepAnimSpeed * 0.5f;
else if (input.StrafeLeft)
localX = -MotionInterpreter.SidestepAnimSpeed * 0.5f;
if (input.StrafeRight)
localX = MotionInterpreter.SidestepAnimSpeed * 0.5f;
else if (input.StrafeLeft)
localX = -MotionInterpreter.SidestepAnimSpeed * 0.5f;
// Restore the vertical velocity snapshotted before DoMotion clobbered it.
// Rotation about Z does not affect the Z component, so world Vz == local Vz.
_body.set_local_velocity(new Vector3(localX, localY, savedWorldVz));
_body.set_local_velocity(new Vector3(localX, localY, savedWorldVz));
}
// ── 3. Jump (charged) ─────────────────────────────────────────────────
// Hold spacebar to charge (0→1 over JumpChargeRate seconds).
@ -301,9 +296,9 @@ public sealed class PlayerMovementController
float groundZ = resolveResult.Position.Z;
float bodyZ = _body.Position.Z;
if (bodyZ <= groundZ + 0.05f)
if (bodyZ <= groundZ + 0.05f && _body.Velocity.Z <= 0f)
{
// Player is at or below the ground — snap to surface and land.
// Player is at or below the ground AND not jumping upward — snap to surface.
_body.Position = new Vector3(_body.Position.X, _body.Position.Y, groundZ);
bool wasAirborne = !_body.OnWalkable;