diff --git a/src/AcDream.App/Input/PlayerMovementController.cs b/src/AcDream.App/Input/PlayerMovementController.cs
index c0012bd..f97159b 100644
--- a/src/AcDream.App/Input/PlayerMovementController.cs
+++ b/src/AcDream.App/Input/PlayerMovementController.cs
@@ -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.
///
public enum PlayerState { InWorld, PortalSpace }
///
/// 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.
///
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;
+
///
/// 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.
///
public float StepUpHeight { get; set; } = 5.0f;
- public float VerticalVelocity { get; private set; }
- public bool IsAirborne { get; private set; }
- ///
- /// 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.
- ///
- public float JumpImpulse { get; set; } = 5.0f;
- /// AC's gravity constant (F_GRAVITY = 9.8 m/s²).
- public float GravityAccel { get; set; } = 9.8f;
- public float AirControlFactor { get; set; } = 0.2f;
-
///
/// 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;
+
+ ///
+ /// Current vertical (Z-axis) velocity of the physics body.
+ /// Positive = rising, negative = falling. Exposed for tests and HUD.
+ ///
+ 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);
}
}
diff --git a/tests/AcDream.Core.Tests/Input/PlayerMovementControllerTests.cs b/tests/AcDream.Core.Tests/Input/PlayerMovementControllerTests.cs
index b090996..44c55c4 100644
--- a/tests/AcDream.Core.Tests/Input/PlayerMovementControllerTests.cs
+++ b/tests/AcDream.Core.Tests/Input/PlayerMovementControllerTests.cs
@@ -131,12 +131,13 @@ public class PlayerMovementControllerTests
float z2 = controller.Position.Z;
Assert.True(z2 > z1, "Should be rising");
- // Many frames — should come back down
- for (int i = 0; i < 30; i++)
+ // Many frames — should come back down.
+ // DefaultJumpVz = 10 m/s → full flight time ≈ 2.04s, so run 50 × 50ms = 2.5s
+ // to ensure the player has definitely landed.
+ for (int i = 0; i < 50; i++)
controller.Update(0.05f, new MovementInput());
Assert.False(controller.IsAirborne, "Should have landed");
- // +0.15 Z bias keeps feet above terrain surface (prevents z-fighting).
Assert.Equal(50f, controller.Position.Z, precision: 1);
}