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. /// /// /// Wire vs. local animation command. ACE's MovementData /// (ACE.Server/Network/Motion/MovementData.cs) only computes /// interpState.ForwardSpeed for raw WalkForward/ /// WalkBackwards — on every other command the else branch /// passes through command without setting speed, leaving observers with /// speed=0. The client therefore has to send WalkForward /// (with HoldKey.Run for running) and let ACE auto-upgrade to /// RunForward for broadcast. But the LOCAL view wants the run /// cycle immediately, so we carry a separate /// for the player's own renderer. /// /// /// — true when the player is holding Shift to run. /// Used by the GameWindow when building the outbound MoveToState's /// CURRENT_HOLD_KEY (2=Run) vs (1=None). /// /// public readonly record struct MovementResult( Vector3 Position, uint CellId, bool IsOnGround, bool MotionStateChanged, uint? ForwardCommand, // wire-side command (WalkForward / WalkBackward / …) uint? SidestepCommand, uint? TurnCommand, float? ForwardSpeed, float? SidestepSpeed, float? TurnSpeed, bool IsRunning = false, uint? LocalAnimationCommand = null, // which cycle to play on the local player (RunForward when running) bool JustLanded = false, // true on the single frame we transitioned airborne → grounded float? JumpExtent = null, // non-null when a jump was triggered this frame Vector3? JumpVelocity = null); // world-space launch velocity (sent in jump packet) /// /// 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; /// Full 3D world-space velocity of the physics body. Exposed for diagnostic logging. public Vector3 BodyVelocity => _body.Velocity; // Jump charge state. private bool _jumpCharging; private float _jumpExtent; private const float JumpChargeRate = 1.0f; // 0→1 over 1 second // Airborne → grounded transition detection. Flipped on every frame where // the body transitions from airborne to on-walkable; used by the GameWindow // to drive the landing animation cycle. private bool _wasAirborneLastFrame; // Previous frame's motion commands for change detection. private uint? _prevForwardCmd; private uint? _prevSidestepCmd; private uint? _prevTurnCmd; private float? _prevForwardSpeed; private bool _prevRunHold; private uint? _prevLocalAnimCmd; // 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, }; // Default skills — tuned toward mid-retail feel (jump ≈ 3m at full charge, // run rate ≈ 2.4x). Real characters' skills come from PlayerDescription // (0xF7B0/0x0013) which we don't parse yet; override via env vars: // ACDREAM_RUN_SKILL, ACDREAM_JUMP_SKILL int runSkill = int.TryParse(Environment.GetEnvironmentVariable("ACDREAM_RUN_SKILL"), out var rs) ? rs : 200; int jumpSkill = int.TryParse(Environment.GetEnvironmentVariable("ACDREAM_JUMP_SKILL"), out var jsv) ? jsv : 200; _weenie = new PlayerWeenie(runSkill: runSkill, jumpSkill: jumpSkill); _motion = new MotionInterpreter(_body, _weenie); } public void SetCharacterSkills(int runSkill, int jumpSkill) { _weenie.SetSkills(runSkill, jumpSkill); } /// /// Wire the player's AnimationSequencer current cycle velocity into /// . When attached, /// get_state_velocity uses MotionData.Velocity * speedMod /// as the primary forward-axis drive, keeping the body's world velocity /// locked to the animation's baked-in root-motion velocity. /// /// /// Without this accessor, the decompiled constant path /// (RunAnimSpeed * ForwardSpeed) is used — matches retail only /// when the character's MotionTable happens to bake Velocity=4.0 on /// RunForward, which is true for Humanoid but not for arbitrary /// creatures. See /// for the full rationale. /// /// /// /// Called once from GameWindow.CreateAnimatedEntity after the /// player's AnimatedEntity.Sequencer is constructed. /// /// public void AttachCycleVelocityAccessor(Func accessor) { if (accessor is null) throw new ArgumentNullException(nameof(accessor)); _motion.GetCycleVelocity = accessor; } /// /// 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 ─────────────── // Determine the dominant forward/backward command and speed. uint forwardCmd; float forwardCmdSpeed; if (input.Forward) { forwardCmd = input.Run ? MotionCommand.RunForward : MotionCommand.WalkForward; // When running, use the PlayerWeenie's RunRate as ForwardSpeed. // The retail server computes this from Run skill + encumbrance and // broadcasts it in UpdateMotion, but it doesn't echo to the sender. // We compute locally using the same formula. if (input.Run && _weenie.InqRunRate(out float runRate)) forwardCmdSpeed = runRate; else forwardCmdSpeed = 1.0f; } else if (input.Backward) { forwardCmd = MotionCommand.WalkBackward; forwardCmdSpeed = 1.0f; } else { forwardCmd = MotionCommand.Ready; forwardCmdSpeed = 1.0f; } // Update interpreted motion state (needed for animation + server messages). _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); } // Only replace velocity with motion interpreter output when grounded. // While airborne, the physics body's integrated velocity (from LeaveGround) // persists — gravity pulls Z down, horizontal momentum is preserved. // Retail AC works this way: you maintain momentum in the air. if (_body.OnWalkable) { float savedWorldVz = _body.Velocity.Z; var stateVel = _motion.get_state_velocity(); float localY = 0f; float localX = 0f; if (input.Forward) localY = stateVel.Y; else if (input.Backward) localY = -(MotionInterpreter.WalkAnimSpeed * 0.65f); // Full-speed strafe to match retail sidestep pace. if (input.StrafeRight) localX = MotionInterpreter.SidestepAnimSpeed; else if (input.StrafeLeft) localX = -MotionInterpreter.SidestepAnimSpeed; _body.set_local_velocity(new Vector3(localX, localY, savedWorldVz)); } // ── 3. Jump (charged) ───────────────────────────────────────────────── // Hold spacebar to charge (0→1 over JumpChargeRate seconds). // Release to execute: jump(extent) validates + sets JumpExtent, // then LeaveGround() applies the scaled velocity via get_leave_ground_velocity. float? outJumpExtent = null; Vector3? outJumpVelocity = null; if (input.Jump && _body.OnWalkable) { // Spacebar held and on the ground — accumulate charge. if (!_jumpCharging) { _jumpCharging = true; _jumpExtent = 0f; } _jumpExtent = MathF.Min(_jumpExtent + dt * JumpChargeRate, 1.0f); } else if (_jumpCharging) { // Spacebar released (or left ground during charge) — fire jump. var jumpResult = _motion.jump(_jumpExtent); if (jumpResult == WeenieError.None) { _motion.LeaveGround(); outJumpExtent = _jumpExtent; outJumpVelocity = _body.Velocity; // capture after LeaveGround applies it } _jumpCharging = false; _jumpExtent = 0f; } // ── 4. Integrate physics (gravity, friction, sub-stepping) ──────────── var preIntegratePos = _body.Position; _body.calc_acceleration(); _body.UpdatePhysicsInternal(dt); var postIntegratePos = _body.Position; // ── 5. Collision resolution via CTransition sphere-sweep ───────────── // The Transition system subdivides the movement from pre→post into // sphere-radius steps, testing terrain collision at each step. // Falls back to simple Z-snap if transition fails. var resolveResult = _physics.ResolveWithTransition( preIntegratePos, postIntegratePos, CellId, sphereRadius: 0.48f, // human player radius from Setup sphereHeight: 1.2f, // human player height from Setup stepUpHeight: StepUpHeight, stepDownHeight: 0.04f, // retail default isOnGround: _body.OnWalkable, body: _body); // persist ContactPlane across frames for slope tracking // Apply resolved position. _body.Position = resolveResult.Position; bool justLanded = false; if (resolveResult.IsOnGround) { if (_body.Velocity.Z <= 0f) { // Grounded — snap to resolved position and land. bool wasAirborne = !_body.OnWalkable; _body.TransientState |= TransientStateFlags.Contact | TransientStateFlags.OnWalkable; _body.calc_acceleration(); if (_body.Velocity.Z < 0f) _body.Velocity = new Vector3(_body.Velocity.X, _body.Velocity.Y, 0f); if (wasAirborne) { _motion.HitGround(); justLanded = true; } } else { // Moving upward (jump) — stay airborne even though terrain is below. _body.TransientState &= ~(TransientStateFlags.Contact | TransientStateFlags.OnWalkable); _body.calc_acceleration(); } } else { // No ground found — airborne. _body.TransientState &= ~(TransientStateFlags.Contact | TransientStateFlags.OnWalkable); _body.calc_acceleration(); } _wasAirborneLastFrame = !_body.OnWalkable; 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; // Retail-faithful wire commands. ACE's MovementData constructor only // computes interpState.ForwardSpeed for WalkForward / WalkBackwards // (Network/Motion/MovementData.cs:104-119) — for any other command // the else-branch passes through without setting speed, so observers // dead-reckon at speed=0. The wire therefore must be: // - Forward (walk): WalkForward @ 1.0 // - Forward (run): WalkForward @ run_rate + HoldKey.Run // (ACE auto-upgrades to RunForward for observers) // - Backward: WalkBackward @ 1.0 // Our own local animation still wants the actual RunForward cycle // though — that's carried separately in LocalAnimationCommand below. uint? localAnimCmd = null; if (input.Forward) { outForwardCmd = MotionCommand.WalkForward; if (input.Run && _weenie.InqRunRate(out float runRate)) { outForwardSpeed = runRate; localAnimCmd = MotionCommand.RunForward; // local cycle is RunForward } else { outForwardSpeed = 1.0f; localAnimCmd = MotionCommand.WalkForward; } } else if (input.Backward) { outForwardCmd = MotionCommand.WalkBackward; outForwardSpeed = 1.0f; localAnimCmd = MotionCommand.WalkBackward; } // Strafe: retail uses speed=1.0 for SideStep (see holtburger // common.rs::locomotion_command_for_state). 0.5 was our earlier guess // and made strafing feel lethargic; the retail feel is full-speed // sidestep matching the walk forward pace. if (input.StrafeRight) { outSidestepCmd = MotionCommand.SideStepRight; outSidestepSpeed = 1.0f; } else if (input.StrafeLeft) { outSidestepCmd = MotionCommand.SideStepLeft; outSidestepSpeed = 1.0f; } // 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 ───────────────────────────────────── // Bug fix: ForwardCommand can stay the same (WalkForward) while ONLY // ForwardSpeed or the run-hold bit changes. If the user is already // walking (W held), then presses Shift, the outbound wire still has // ForwardCommand=WalkForward but outForwardSpeed jumps from 1.0 to // runRate. Without also tracking speed + hold-key here, no new // MoveToState is sent — the server keeps thinking the player walks, // and retail observers render walking animation despite the local // player's RunForward cycle. // // Similarly LocalAnimationCommand change (Walk→Run on local cycle) // must force a fresh outbound so ACE's BroadcastMovement re-runs // MovementData(this, moveToState) which only reads ForwardCommand + // ForwardSpeed + HoldKey to pick between WalkForward vs RunForward // for remote observers. bool runHold = input.Run; bool changed = outForwardCmd != _prevForwardCmd || outSidestepCmd != _prevSidestepCmd || outTurnCmd != _prevTurnCmd || !FloatsEqual(outForwardSpeed, _prevForwardSpeed) || runHold != _prevRunHold || localAnimCmd != _prevLocalAnimCmd; _prevForwardCmd = outForwardCmd; _prevSidestepCmd = outSidestepCmd; _prevTurnCmd = outTurnCmd; _prevForwardSpeed = outForwardSpeed; _prevRunHold = runHold; _prevLocalAnimCmd = localAnimCmd; static bool FloatsEqual(float? a, float? b) { if (a.HasValue != b.HasValue) return false; if (!a.HasValue || !b.HasValue) return true; return System.Math.Abs(a.Value - b.Value) < 1e-4f; } // ── 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, IsRunning: input.Run && input.Forward, LocalAnimationCommand: localAnimCmd, JustLanded: justLanded, JumpExtent: outJumpExtent, JumpVelocity: outJumpVelocity); } }