Implements IWeenieObject with GetRunRate and GetJumpHeight from decompiled client, cross-referenced against ACE MovementSystem. Default skills (Run=200, Jump=100) used until skill parsing ships. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
389 lines
16 KiB
C#
389 lines
16 KiB
C#
using System;
|
|
using System.Numerics;
|
|
using AcDream.Core.Physics;
|
|
|
|
namespace AcDream.App.Input;
|
|
|
|
/// <summary>
|
|
/// Input state for a single frame of player movement.
|
|
/// </summary>
|
|
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);
|
|
|
|
/// <summary>
|
|
/// Result of a single frame's movement update.
|
|
/// </summary>
|
|
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);
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
public enum PlayerState { InWorld, PortalSpace }
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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;
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
public float StepUpHeight { get; set; } = 5.0f;
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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;
|
|
|
|
/// <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;
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
}
|