From 14569558fb45eec3ef63a551679fdb75534b9fba Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 13 Apr 2026 00:08:02 +0200 Subject: [PATCH] refactor(physics): wire PhysicsBody + MotionInterpreter into PlayerMovementController MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../Input/PlayerMovementController.cs | 345 +++++++++++------- .../Input/PlayerMovementControllerTests.cs | 7 +- 2 files changed, 215 insertions(+), 137 deletions(-) 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); }