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); } }