acdream/tests/AcDream.Core.Tests/Input/PlayerMovementControllerTests.cs
Erik 14569558fb 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>
2026-04-13 00:08:02 +02:00

183 lines
6.7 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System;
using System.Numerics;
using AcDream.App.Input;
using AcDream.Core.Physics;
using Xunit;
namespace AcDream.Core.Tests.Input;
public class PlayerMovementControllerTests
{
private static PhysicsEngine MakeFlatEngine()
{
var engine = new PhysicsEngine();
var heights = new byte[81];
Array.Fill(heights, (byte)50);
var heightTable = new float[256];
for (int i = 0; i < 256; i++) heightTable[i] = i * 1f;
var terrain = new TerrainSurface(heights, heightTable);
engine.AddLandblock(0xA9B4FFFFu, terrain, Array.Empty<CellSurface>(),
Array.Empty<PortalPlane>(), worldOffsetX: 0f, worldOffsetY: 0f);
return engine;
}
[Fact]
public void Update_NoInput_PositionUnchanged()
{
var engine = MakeFlatEngine();
var controller = new PlayerMovementController(engine);
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
var result = controller.Update(0.016f, new MovementInput());
Assert.Equal(96f, result.Position.X, precision: 1);
Assert.Equal(96f, result.Position.Y, precision: 1);
}
[Fact]
public void Update_ForwardInput_MovesInFacingDirection()
{
var engine = MakeFlatEngine();
var controller = new PlayerMovementController(engine);
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
controller.Yaw = 0f; // facing +X
var input = new MovementInput { Forward = true };
var result = controller.Update(1.0f, input); // 1 second
// Should have moved ~4 units in +X (walk speed).
Assert.True(result.Position.X > 96f + 2f, $"X={result.Position.X} should have moved forward");
}
[Fact]
public void Update_RunForward_MoveFasterThanWalk()
{
var engine = MakeFlatEngine();
var controller = new PlayerMovementController(engine);
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
controller.Yaw = 0f;
var walkInput = new MovementInput { Forward = true };
var walkResult = controller.Update(1.0f, walkInput);
float walkDist = walkResult.Position.X - 96f;
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
var runInput = new MovementInput { Forward = true, Run = true };
var runResult = controller.Update(1.0f, runInput);
float runDist = runResult.Position.X - 96f;
Assert.True(runDist > walkDist, $"Run ({runDist}) should be faster than walk ({walkDist})");
}
[Fact]
public void Update_TurnInput_ChangesYaw()
{
var engine = MakeFlatEngine();
var controller = new PlayerMovementController(engine);
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
float initialYaw = controller.Yaw;
var input = new MovementInput { TurnRight = true };
controller.Update(0.5f, input);
Assert.NotEqual(initialYaw, controller.Yaw);
}
[Fact]
public void MotionStateChanged_WhenStartingToWalk()
{
var engine = MakeFlatEngine();
var controller = new PlayerMovementController(engine);
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
// First frame: idle (no input).
controller.Update(0.016f, new MovementInput());
// Second frame: start walking.
var input = new MovementInput { Forward = true };
var result = controller.Update(0.016f, input);
Assert.True(result.MotionStateChanged);
}
[Fact]
public void Update_JumpOnFlatTerrain_BecomesAirborne()
{
var engine = MakeFlatEngine();
var controller = new PlayerMovementController(engine);
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
var input = new MovementInput(Jump: true);
controller.Update(0.016f, input);
Assert.True(controller.IsAirborne);
Assert.True(controller.VerticalVelocity > 0f);
}
[Fact]
public void Update_AirborneFrames_ZRiseThenFalls()
{
var engine = MakeFlatEngine();
var controller = new PlayerMovementController(engine);
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
// Jump
controller.Update(0.016f, new MovementInput(Jump: true));
float z1 = controller.Position.Z;
// A few frames of rising
controller.Update(0.1f, new MovementInput());
float z2 = controller.Position.Z;
Assert.True(z2 > z1, "Should be rising");
// 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");
Assert.Equal(50f, controller.Position.Z, precision: 1);
}
[Fact]
public void Update_WalkOffLedge_BecomesFalling()
{
// Build terrain with a sharp cliff: grid x<5 = Z50, grid x>=5 = Z20.
// heights[x*9+y] is indexed x-major; heightTable[i]=i*1f so
// byte value == Z value directly.
var heights = new byte[81];
for (int x = 0; x < 9; x++)
for (int y = 0; y < 9; y++)
heights[x * 9 + y] = (byte)(x < 5 ? 50 : 20);
var heightTable = new float[256];
for (int i = 0; i < 256; i++) heightTable[i] = i * 1f;
var engine = new PhysicsEngine();
var terrain = new TerrainSurface(heights, heightTable);
engine.AddLandblock(0xA9B4FFFFu, terrain, Array.Empty<CellSurface>(),
Array.Empty<PortalPlane>(), worldOffsetX: 0f, worldOffsetY: 0f);
// Position the player just before the cliff edge (localX=118 ≈ grid x=4.92).
// At this point terrain Z is ~51.7 (bilinear interpolation near the high side).
// One step at walk speed will cross into the low region where terrain drops
// ~28 units — more than StepUpHeight=5, triggering the ledge-fall.
var controller = new PlayerMovementController(engine);
controller.SetPosition(new Vector3(118f, 96f, 50f), 0x0001);
controller.Yaw = 0f; // facing +X
// Single step — should trigger airborne state because terrain drops sharply.
controller.Update(0.05f, new MovementInput(Forward: true));
Assert.True(controller.IsAirborne, "Player should be airborne after stepping off the cliff");
// Simulate enough frames to fall and land on the Z=20 floor.
for (int i = 0; i < 60; i++)
controller.Update(0.05f, new MovementInput(Forward: true));
Assert.False(controller.IsAirborne, "Player should have landed");
Assert.Equal(20f, controller.Position.Z, precision: 1);
}
}