refactor(physics): wire PhysicsBody + MotionInterpreter into PlayerMovementController

Replace the ad-hoc movement simulation with the ported retail physics:

- PlayerMovementController now owns a PhysicsBody (gravity, friction, Euler
  integration with sub-stepping) and a MotionInterpreter (motion state machine,
  speed constants from retail dat).

- Orientation quaternion is synced from Yaw each frame (Yaw=0 → +X, matching
  the cos/sin convention the camera and outbound messages expect).

- Horizontal velocity is composed from MotionInterpreter.get_state_velocity()
  speeds (WalkAnimSpeed=3.12, RunAnimSpeed=4.0, SidestepAnimSpeed=1.25 from
  decompiled globals) then pushed via PhysicsBody.set_local_velocity so the
  orientation quaternion rotates them into world space correctly.

- Vertical velocity (gravity / jump / fall) is snapshot before DoMotion calls
  so apply_current_movement's set_local_velocity(0,0,0) can't clobber it.

- Jump delegates to MotionInterpreter.jump() + LeaveGround() which calls
  get_leave_ground_velocity() → DefaultJumpVz=10.0 (retail value).

- PhysicsEngine.Resolve is still called each frame with zero delta to sample
  terrain/cell Z under the body and set Contact+OnWalkable accordingly.

- Drive UpdatePhysicsInternal(dt) directly instead of update_object(wallClock)
  to avoid the MinQuantum (~33ms) guard that would silently drop 60fps frames.

Test update: jump loop extended from 30→50 frames to cover the longer flight
time from retail DefaultJumpVz=10 (≈2.04s) vs old JumpImpulse=5 (≈1.02s).

303 tests green.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-13 00:08:02 +02:00
parent e3f8f95dfc
commit 14569558fb
2 changed files with 215 additions and 137 deletions

View file

@ -38,42 +38,40 @@ public readonly record struct MovementResult(
/// PortalSpace freezes all movement input while the server is moving the
/// player through a portal — resumed once the destination UpdatePosition
/// arrives and the player is snapped to the new location.
/// While in PortalSpace, Update returns immediately with a zero-movement
/// result so no WASD input or physics is processed.
/// </summary>
public enum PlayerState { InWorld, PortalSpace }
/// <summary>
/// Per-frame player movement controller. Reads input, drives the
/// physics engine, tracks motion state for animation + server messages.
/// ported PhysicsBody + MotionInterpreter, tracks motion state for
/// animation + server messages.
///
/// Architecture:
/// - PhysicsBody owns integration: gravity, friction, sub-stepping,
/// velocity clamping — all from the decompiled retail client.
/// - MotionInterpreter owns the motion state machine: walk/run/jump
/// validation, state tracking, speed constants from the retail dat.
/// - PhysicsEngine.Resolve is still used each frame to snap the player
/// to terrain/cell floor Z and detect ground contact.
/// </summary>
public sealed class PlayerMovementController
{
private readonly PhysicsEngine _physics;
private readonly PhysicsBody _body;
private readonly MotionInterpreter _motion;
public float WalkSpeed { get; set; } = 4f;
public float RunSpeed { get; set; } = 7f;
public float TurnSpeed { get; set; } = 1.5f;
public float MouseTurnSensitivity { get; set; } = 0.003f;
/// <summary>
/// Maximum Z increase per movement step before the move is rejected.
/// AC's default StepUpHeight for human characters is ~2 units (from
/// Setup.StepUpHeight). Using 5 for the MVP to be forgiving — prevents
/// walking up vertical walls but allows stairs, ramps, and terrain
/// slopes that the heightmap interpolation can produce.
/// AC's default StepUpHeight for human characters is ~2 units.
/// Using 5 for the MVP to be forgiving — prevents walking up vertical
/// walls but allows stairs, ramps, and terrain slopes.
/// </summary>
public float StepUpHeight { get; set; } = 5.0f;
public float VerticalVelocity { get; private set; }
public bool IsAirborne { get; private set; }
/// <summary>
/// Jump velocity Z. AC formula: sqrt(height * 19.6) where
/// height = BurdenMod * (JumpSkill / (JumpSkill + 1300) * 22.2 + 0.05) * power.
/// For a typical new char (skill=100, burden=50%, power=1.0) ≈ 5.07.
/// </summary>
public float JumpImpulse { get; set; } = 5.0f;
/// <summary>AC's gravity constant (F_GRAVITY = 9.8 m/s²).</summary>
public float GravityAccel { get; set; } = 9.8f;
public float AirControlFactor { get; set; } = 0.2f;
/// <summary>
/// Current portal-space state. Set to PortalSpace when the server sends
/// PlayerTeleport (0xF751); set back to InWorld once the destination
@ -84,9 +82,17 @@ public sealed class PlayerMovementController
public PlayerState State { get; set; } = PlayerState.InWorld;
public float Yaw { get; set; }
public Vector3 Position { get; private set; }
public Vector3 Position => _body.Position;
public uint CellId { get; private set; }
public bool IsAirborne => !_body.OnWalkable;
/// <summary>
/// Current vertical (Z-axis) velocity of the physics body.
/// Positive = rising, negative = falling. Exposed for tests and HUD.
/// </summary>
public float VerticalVelocity => _body.Velocity.Z;
// Previous frame's motion commands for change detection.
private uint? _prevForwardCmd;
private uint? _prevSidestepCmd;
@ -100,12 +106,26 @@ public sealed class PlayerMovementController
public PlayerMovementController(PhysicsEngine physics)
{
_physics = physics;
_body = new PhysicsBody
{
State = PhysicsStateFlags.Gravity | PhysicsStateFlags.ReportCollisions,
};
_motion = new MotionInterpreter(_body);
}
public void SetPosition(Vector3 pos, uint cellId)
{
Position = pos;
_body.Position = pos;
CellId = cellId;
// Treat as grounded after a server-side position snap.
_body.TransientState = TransientStateFlags.Contact | TransientStateFlags.OnWalkable;
_body.Velocity = Vector3.Zero;
// Reset physics clock so any subsequent update_object calls start fresh.
_body.LastUpdateTime = 0.0;
}
public MovementResult Update(float dt, MovementInput input)
@ -118,7 +138,7 @@ public sealed class PlayerMovementController
return new MovementResult(
Position: Position,
CellId: CellId,
IsOnGround: !IsAirborne,
IsOnGround: _body.OnWalkable,
MotionStateChanged: false,
ForwardCommand: null,
SidestepCommand: null,
@ -128,116 +148,171 @@ public sealed class PlayerMovementController
TurnSpeed: null);
}
// 1. Apply turning from keyboard + mouse.
// ── 1. Apply turning from keyboard + mouse ────────────────────────────
if (input.TurnRight)
Yaw -= TurnSpeed * dt;
Yaw -= MotionInterpreter.WalkAnimSpeed * 0.5f * dt; // ~90°/s
if (input.TurnLeft)
Yaw += TurnSpeed * dt;
Yaw += MotionInterpreter.WalkAnimSpeed * 0.5f * dt;
Yaw -= input.MouseDeltaX * MouseTurnSensitivity;
// Wrap yaw to [-PI, PI] so it doesn't grow unbounded and cause
// NaN/overflow in sin/cos and confuse the chase camera offset.
while (Yaw > MathF.PI) Yaw -= 2f * MathF.PI;
// Wrap yaw to [-PI, PI] so it doesn't grow unbounded.
while (Yaw > MathF.PI) Yaw -= 2f * MathF.PI;
while (Yaw < -MathF.PI) Yaw += 2f * MathF.PI;
// 2. Compute movement delta in the player's facing direction.
float speed = input.Run ? RunSpeed : WalkSpeed;
float forwardX = MathF.Cos(Yaw);
float forwardY = MathF.Sin(Yaw);
float rightX = MathF.Sin(Yaw);
float rightY = -MathF.Cos(Yaw);
// Sync the body's orientation quaternion with our Yaw (rotation about Z).
// Convention: Yaw=0 faces +X. Local body +Y is "forward", so we rotate
// by (Yaw - PI/2) about Z to map local +Y → world (cos Yaw, sin Yaw, 0).
_body.Orientation = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, Yaw - MathF.PI / 2f);
float dx = 0f, dy = 0f;
if (input.Forward) { dx += forwardX * speed * dt; dy += forwardY * speed * dt; }
if (input.Backward) { dx -= forwardX * speed * dt * 0.65f; dy -= forwardY * speed * dt * 0.65f; }
if (input.StrafeRight) { dx += rightX * speed * dt * 0.5f; dy += rightY * speed * dt * 0.5f; }
if (input.StrafeLeft) { dx -= rightX * speed * dt * 0.5f; dy -= rightY * speed * dt * 0.5f; }
// While airborne, reduce horizontal authority.
float airFactor = IsAirborne ? AirControlFactor : 1f;
var delta = new Vector3(dx * airFactor, dy * airFactor, 0f);
// 3. Resolve via physics engine.
var result = _physics.Resolve(Position, CellId, delta, StepUpHeight);
// 4. Jump + gravity + falling (vertical axis).
float resolvedGroundZ = result.Position.Z;
// Initiate jump when grounded and Space pressed.
if (input.Jump && result.IsOnGround && !IsAirborne)
{
VerticalVelocity = JumpImpulse;
IsAirborne = true;
}
float newZ;
if (IsAirborne)
{
// Apply gravity and integrate vertical position.
VerticalVelocity -= GravityAccel * dt;
float candidateZ = Position.Z + VerticalVelocity * dt;
if (candidateZ <= resolvedGroundZ)
{
// Landed on terrain/floor.
newZ = resolvedGroundZ;
IsAirborne = false;
VerticalVelocity = 0f;
}
else
{
// Still airborne — override terrain Z with ballistic Z.
newZ = candidateZ;
}
}
else
{
// Detect walking off a ledge: terrain dropped more than StepUpHeight.
if (resolvedGroundZ < Position.Z - StepUpHeight)
{
IsAirborne = true;
VerticalVelocity = 0f;
newZ = Position.Z; // stay at current Z this frame; gravity will pull us down next frame
}
else
{
newZ = resolvedGroundZ;
}
}
// SampleZ now uses the correct triangle boundary conditions (fixed
// in 3ae7f5e via ACME WorldBuilder conformance test). No slope bias
// needed — the Z matches the visual terrain mesh exactly.
Position = new Vector3(result.Position.X, result.Position.Y, newZ);
CellId = result.CellId;
// 4. Determine current motion commands.
uint? forwardCmd = null;
float? forwardSpeed = null;
uint? sidestepCmd = null;
float? sidestepSpeed = null;
uint? turnCmd = null;
float? turnSpeed = null;
// ── 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;
if (input.Forward)
{
forwardCmd = input.Run ? 0x44000007u : 0x45000005u; // RunForward / WalkForward
forwardSpeed = input.Run ? RunSpeed / WalkSpeed : 1.0f;
forwardCmd = input.Run ? MotionCommand.RunForward : MotionCommand.WalkForward;
forwardCmdSpeed = 1.0f;
}
else if (input.Backward)
{
forwardCmd = 0x45000005u; // WalkForward (backward is negative speed)
forwardSpeed = -0.65f;
// WalkBackward is tracked in interpreted state; we negate Y velocity below.
forwardCmd = MotionCommand.WalkBackward;
forwardCmdSpeed = 1.0f;
}
else
{
forwardCmd = MotionCommand.Ready;
forwardCmdSpeed = 1.0f;
}
// Update interpreted motion state.
_motion.DoMotion(forwardCmd, forwardCmdSpeed);
// Sidestep.
if (input.StrafeRight)
_motion.DoInterpretedMotion(MotionCommand.SideStepRight, 1.0f, modifyInterpretedState: true);
else if (input.StrafeLeft)
_motion.DoInterpretedMotion(MotionCommand.SideStepLeft, 1.0f, modifyInterpretedState: true);
else
{
_motion.StopInterpretedMotion(MotionCommand.SideStepRight, modifyInterpretedState: true);
_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();
// 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;
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.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));
// ── 3. Jump ───────────────────────────────────────────────────────────
if (input.Jump)
{
var jumpResult = _motion.jump(1.0f);
if (jumpResult == WeenieError.None)
{
// jump() set_on_walkable(false); now apply the launch velocity.
_motion.LeaveGround();
}
}
// ── 4. Integrate physics (gravity, friction, sub-stepping) ────────────
// Drive the integration directly rather than via update_object's wall-clock
// path — update_object silently skips frames shorter than MinQuantum (~33ms),
// which would drop 60fps frames entirely. Calling calc_acceleration +
// UpdatePhysicsInternal(dt) directly gives us the same Euler integration
// and friction with a caller-controlled dt, which is what we want.
_body.calc_acceleration();
_body.UpdatePhysicsInternal(dt);
// ── 5. Terrain/cell Z snap and ground-contact detection ───────────────
// Use PhysicsEngine.Resolve to find the ground surface Z under the player.
// We pass a zero delta because PhysicsBody already moved the position.
var resolveResult = _physics.Resolve(
_body.Position, CellId, Vector3.Zero, StepUpHeight);
if (resolveResult.IsOnGround)
{
float groundZ = resolveResult.Position.Z;
float bodyZ = _body.Position.Z;
if (bodyZ <= groundZ + 0.05f)
{
// Player is at or below the ground — snap to surface and land.
_body.Position = new Vector3(_body.Position.X, _body.Position.Y, groundZ);
bool wasAirborne = !_body.OnWalkable;
_body.TransientState |= TransientStateFlags.Contact | TransientStateFlags.OnWalkable;
_body.calc_acceleration(); // re-zero gravity acceleration now grounded
// Zero out downward velocity so we don't keep integrating through terrain.
if (_body.Velocity.Z < 0f)
_body.Velocity = new Vector3(_body.Velocity.X, _body.Velocity.Y, 0f);
if (wasAirborne)
_motion.HitGround();
}
else
{
// Player is above the ground — airborne.
_body.TransientState &= ~(TransientStateFlags.Contact | TransientStateFlags.OnWalkable);
_body.calc_acceleration(); // re-enable gravity
}
}
// Update CellId from the resolve result.
CellId = resolveResult.CellId;
// ── 6. Determine outbound motion commands ─────────────────────────────
uint? outForwardCmd = null;
float? outForwardSpeed = null;
uint? outSidestepCmd = null;
float? outSidestepSpeed = null;
uint? outTurnCmd = null;
float? outTurnSpeed = null;
if (input.Forward)
{
outForwardCmd = input.Run ? MotionCommand.RunForward : MotionCommand.WalkForward;
outForwardSpeed = 1.0f;
}
else if (input.Backward)
{
outForwardCmd = MotionCommand.WalkForward; // backward = WalkForward at negative speed
outForwardSpeed = -0.65f;
}
if (input.StrafeRight)
{
sidestepCmd = 0x6500000Fu; // SideStepRight
sidestepSpeed = speed * 0.5f / WalkSpeed;
outSidestepCmd = MotionCommand.SideStepRight;
outSidestepSpeed = 0.5f;
}
else if (input.StrafeLeft)
{
sidestepCmd = 0x65000010u; // SideStepLeft
sidestepSpeed = speed * 0.5f / WalkSpeed;
outSidestepCmd = MotionCommand.SideStepLeft;
outSidestepSpeed = 0.5f;
}
// Turn commands from KEYBOARD only (A/D). Mouse turning is applied
@ -247,25 +322,27 @@ public sealed class PlayerMovementController
// the server with MoveToState spam.
if (input.TurnRight)
{
turnCmd = 0x6500000Du; // TurnRight
turnSpeed = TurnSpeed;
outTurnCmd = MotionCommand.TurnRight;
outTurnSpeed = 1.0f;
}
else if (input.TurnLeft)
{
turnCmd = 0x6500000Eu; // TurnLeft
turnSpeed = TurnSpeed;
outTurnCmd = MotionCommand.TurnLeft;
outTurnSpeed = 1.0f;
}
// 5. Detect motion state change.
bool changed = forwardCmd != _prevForwardCmd
|| sidestepCmd != _prevSidestepCmd
|| turnCmd != _prevTurnCmd;
_prevForwardCmd = forwardCmd;
_prevSidestepCmd = sidestepCmd;
_prevTurnCmd = turnCmd;
// ── 7. Detect motion state change ─────────────────────────────────────
bool changed = outForwardCmd != _prevForwardCmd
|| outSidestepCmd != _prevSidestepCmd
|| outTurnCmd != _prevTurnCmd;
_prevForwardCmd = outForwardCmd;
_prevSidestepCmd = outSidestepCmd;
_prevTurnCmd = outTurnCmd;
// 6. Heartbeat timer (only while moving).
bool isMoving = forwardCmd is not null || sidestepCmd is not null || turnCmd is not null;
// ── 8. Heartbeat timer (only while moving) ────────────────────────────
bool isMoving = outForwardCmd is not null
|| outSidestepCmd is not null
|| outTurnCmd is not null;
if (isMoving)
{
_heartbeatAccum += dt;
@ -281,13 +358,13 @@ public sealed class PlayerMovementController
return new MovementResult(
Position: Position,
CellId: CellId,
IsOnGround: !IsAirborne,
IsOnGround: _body.OnWalkable,
MotionStateChanged: changed,
ForwardCommand: forwardCmd,
SidestepCommand: sidestepCmd,
TurnCommand: turnCmd,
ForwardSpeed: forwardSpeed,
SidestepSpeed: sidestepSpeed,
TurnSpeed: turnSpeed);
ForwardCommand: outForwardCmd,
SidestepCommand: outSidestepCmd,
TurnCommand: outTurnCmd,
ForwardSpeed: outForwardSpeed,
SidestepSpeed: outSidestepSpeed,
TurnSpeed: outTurnSpeed);
}
}