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:
parent
e3f8f95dfc
commit
14569558fb
2 changed files with 215 additions and 137 deletions
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue