diff --git a/src/AcDream.App/Input/PlayerMovementController.cs b/src/AcDream.App/Input/PlayerMovementController.cs new file mode 100644 index 0000000..6db677c --- /dev/null +++ b/src/AcDream.App/Input/PlayerMovementController.cs @@ -0,0 +1,178 @@ +using System; +using System.Numerics; +using AcDream.Core.Physics; + +namespace AcDream.App.Input; + +/// +/// Input state for a single frame of player movement. +/// +public readonly record struct MovementInput( + bool Forward = false, + bool Backward = false, + bool StrafeLeft = false, + bool StrafeRight = false, + bool TurnLeft = false, + bool TurnRight = false, + bool Run = false, + float MouseDeltaX = 0f); + +/// +/// Result of a single frame's movement update. +/// +public readonly record struct MovementResult( + Vector3 Position, + uint CellId, + bool IsOnGround, + bool MotionStateChanged, + uint? ForwardCommand, + uint? SidestepCommand, + uint? TurnCommand, + float? ForwardSpeed, + float? SidestepSpeed, + float? TurnSpeed); + +/// +/// Per-frame player movement controller. Reads input, drives the +/// physics engine, tracks motion state for animation + server messages. +/// +public sealed class PlayerMovementController +{ + private readonly PhysicsEngine _physics; + + 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; + public float StepUpHeight { get; set; } = 1.0f; + + public float Yaw { get; set; } + public Vector3 Position { get; private set; } + public uint CellId { get; private set; } + + // Previous frame's motion commands for change detection. + private uint? _prevForwardCmd; + private uint? _prevSidestepCmd; + private uint? _prevTurnCmd; + + // Heartbeat timer. + private float _heartbeatAccum; + public const float HeartbeatInterval = 0.2f; // 200ms + public bool HeartbeatDue { get; private set; } + + public PlayerMovementController(PhysicsEngine physics) + { + _physics = physics; + } + + public void SetPosition(Vector3 pos, uint cellId) + { + Position = pos; + CellId = cellId; + } + + public MovementResult Update(float dt, MovementInput input) + { + // 1. Apply turning from keyboard + mouse. + if (input.TurnRight) + Yaw -= TurnSpeed * dt; + if (input.TurnLeft) + Yaw += TurnSpeed * dt; + Yaw -= input.MouseDeltaX * MouseTurnSensitivity; + + // 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); + + 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; } + + var delta = new Vector3(dx, dy, 0f); + + // 3. Resolve via physics engine. + var result = _physics.Resolve(Position, CellId, delta, StepUpHeight); + Position = result.Position; + 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; + + if (input.Forward) + { + forwardCmd = input.Run ? 0x44000007u : 0x45000005u; // RunForward / WalkForward + forwardSpeed = input.Run ? RunSpeed / WalkSpeed : 1.0f; + } + else if (input.Backward) + { + forwardCmd = 0x45000005u; // WalkForward (backward is negative speed) + forwardSpeed = -0.65f; + } + + if (input.StrafeRight) + { + sidestepCmd = 0x6500000Fu; // SideStepRight + sidestepSpeed = speed * 0.5f / WalkSpeed; + } + else if (input.StrafeLeft) + { + sidestepCmd = 0x65000010u; // SideStepLeft + sidestepSpeed = speed * 0.5f / WalkSpeed; + } + + if (input.TurnRight || input.MouseDeltaX > 0.5f) + { + turnCmd = 0x6500000Du; // TurnRight + turnSpeed = TurnSpeed; + } + else if (input.TurnLeft || input.MouseDeltaX < -0.5f) + { + turnCmd = 0x6500000Eu; // TurnLeft + turnSpeed = TurnSpeed; + } + + // 5. Detect motion state change. + bool changed = forwardCmd != _prevForwardCmd + || sidestepCmd != _prevSidestepCmd + || turnCmd != _prevTurnCmd; + _prevForwardCmd = forwardCmd; + _prevSidestepCmd = sidestepCmd; + _prevTurnCmd = turnCmd; + + // 6. Heartbeat timer (only while moving). + bool isMoving = forwardCmd is not null || sidestepCmd is not null || turnCmd is not null; + if (isMoving) + { + _heartbeatAccum += dt; + HeartbeatDue = _heartbeatAccum >= HeartbeatInterval; + if (HeartbeatDue) _heartbeatAccum = 0f; + } + else + { + _heartbeatAccum = 0f; + HeartbeatDue = false; + } + + return new MovementResult( + Position: result.Position, + CellId: result.CellId, + IsOnGround: result.IsOnGround, + MotionStateChanged: changed, + ForwardCommand: forwardCmd, + SidestepCommand: sidestepCmd, + TurnCommand: turnCmd, + ForwardSpeed: forwardSpeed, + SidestepSpeed: sidestepSpeed, + TurnSpeed: turnSpeed); + } +} diff --git a/tests/AcDream.Core.Tests/Input/PlayerMovementControllerTests.cs b/tests/AcDream.Core.Tests/Input/PlayerMovementControllerTests.cs new file mode 100644 index 0000000..aceb1e1 --- /dev/null +++ b/tests/AcDream.Core.Tests/Input/PlayerMovementControllerTests.cs @@ -0,0 +1,103 @@ +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(), + 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); + } +}