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,
bool Jump = false);
///
/// 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);
///
/// Portal-space state for the player movement controller.
/// 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
/// 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;
private readonly PlayerWeenie _weenie;
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.
/// 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;
///
/// Current portal-space state. Set to PortalSpace when the server sends
/// PlayerTeleport (0xF751); set back to InWorld once the destination
/// UpdatePosition arrives and the player is snapped to the new cell.
/// While in PortalSpace, Update returns immediately with a zero-movement
/// result so no WASD input or physics is processed.
///
public PlayerState State { get; set; } = PlayerState.InWorld;
public float Yaw { get; 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;
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;
_body = new PhysicsBody
{
State = PhysicsStateFlags.Gravity | PhysicsStateFlags.ReportCollisions,
};
_weenie = new PlayerWeenie(runSkill: 200, jumpSkill: 100);
_motion = new MotionInterpreter(_body, _weenie);
}
public void SetCharacterSkills(int runSkill, int jumpSkill)
{
_weenie.SetSkills(runSkill, jumpSkill);
}
///
/// Apply a server-echoed run rate (ForwardSpeed from UpdateMotion) to the
/// player's MotionInterpreter. The server broadcasts the real RunRate
/// derived from the character's Run skill; wiring it here ensures
/// get_state_velocity produces the correct speed instead of the default 1.0.
///
public void ApplyServerRunRate(float forwardSpeed)
{
_motion.InterpretedState.ForwardSpeed = forwardSpeed;
_motion.apply_current_movement(cancelMoveTo: false, allowJump: false);
}
public void SetPosition(Vector3 pos, uint cellId)
{
_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)
{
// Portal-space guard: while teleporting, no input is processed and
// no physics is resolved. Return a zero-movement result so the caller
// can detect the frozen state (MotionStateChanged = false, no commands).
if (State == PlayerState.PortalSpace)
{
return new MovementResult(
Position: Position,
CellId: CellId,
IsOnGround: _body.OnWalkable,
MotionStateChanged: false,
ForwardCommand: null,
SidestepCommand: null,
TurnCommand: null,
ForwardSpeed: null,
SidestepSpeed: null,
TurnSpeed: null);
}
// ── 1. Apply turning from keyboard + mouse ────────────────────────────
if (input.TurnRight)
Yaw -= MotionInterpreter.WalkAnimSpeed * 0.5f * dt; // ~90°/s
if (input.TurnLeft)
Yaw += MotionInterpreter.WalkAnimSpeed * 0.5f * dt;
Yaw -= input.MouseDeltaX * MouseTurnSensitivity;
// 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;
// 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);
// ── 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 ? MotionCommand.RunForward : MotionCommand.WalkForward;
forwardCmdSpeed = 1.0f;
}
else if (input.Backward)
{
// 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)
{
outSidestepCmd = MotionCommand.SideStepRight;
outSidestepSpeed = 0.5f;
}
else if (input.StrafeLeft)
{
outSidestepCmd = MotionCommand.SideStepLeft;
outSidestepSpeed = 0.5f;
}
// Turn commands from KEYBOARD only (A/D). Mouse turning is applied
// directly to Yaw above and doesn't generate a turn command — if it
// did, mouse jitter would flip turnCmd between TurnRight/TurnLeft
// every frame, causing stateChanged=True on every frame and flooding
// the server with MoveToState spam.
if (input.TurnRight)
{
outTurnCmd = MotionCommand.TurnRight;
outTurnSpeed = 1.0f;
}
else if (input.TurnLeft)
{
outTurnCmd = MotionCommand.TurnLeft;
outTurnSpeed = 1.0f;
}
// ── 7. Detect motion state change ─────────────────────────────────────
bool changed = outForwardCmd != _prevForwardCmd
|| outSidestepCmd != _prevSidestepCmd
|| outTurnCmd != _prevTurnCmd;
_prevForwardCmd = outForwardCmd;
_prevSidestepCmd = outSidestepCmd;
_prevTurnCmd = outTurnCmd;
// ── 8. Heartbeat timer (only while moving) ────────────────────────────
bool isMoving = outForwardCmd is not null
|| outSidestepCmd is not null
|| outTurnCmd is not null;
if (isMoving)
{
_heartbeatAccum += dt;
HeartbeatDue = _heartbeatAccum >= HeartbeatInterval;
if (HeartbeatDue) _heartbeatAccum = 0f;
}
else
{
_heartbeatAccum = 0f;
HeartbeatDue = false;
}
return new MovementResult(
Position: Position,
CellId: CellId,
IsOnGround: _body.OnWalkable,
MotionStateChanged: changed,
ForwardCommand: outForwardCmd,
SidestepCommand: outSidestepCmd,
TurnCommand: outTurnCmd,
ForwardSpeed: outForwardSpeed,
SidestepSpeed: outSidestepSpeed,
TurnSpeed: outTurnSpeed);
}
}