import type { InputBuffer } from "../input/inputBuffer.js"; /** * Movement core (pure model). * * This module owns the raw key model, the pure translation from held keys * into a frame-independent {@link MovementIntent}, and the kinematics * integration. Keeping it framework-free means the kinematics controller, * the keyboard handler and the on-screen touch D-pad all speak the same * vocabulary, and the translation is unit testable without a renderer. * DOM capture lives in `@jgengine/shell`'s GamePlayerShell (keyboard and * touch controls) and its camera rigs (pointer lock, the per-frame look * channel). */ export type MovementKey = "w" | "a" | "s" | "d" | "shift" | "control" | "c" | "space"; export type MovementKeysState = Record; /** @internal */ export declare function createEmptyMovementKeys(): MovementKeysState; /** * Frame-independent description of what the player is asking the avatar to do. * `forward`/`right` are in the range -1..1 in the avatar's local frame; the * controller rotates them into world space against the camera each frame. */ export interface MovementIntent { forward: number; right: number; crouching: boolean; running: boolean; jumping: boolean; /** True when there is any directional input this frame. */ moving: boolean; } /** Analog movement vector (camera-relative, each axis in [-1, 1]) from a continuous source — a virtual joystick or gamepad stick. When present it replaces the digital WASD axes so a slight tilt walks slightly instead of slamming a full strafe. */ export interface AnalogMoveIntent { forward: number; right: number; } /** * Translate the set of held keys into an intent. When `canMove` is false (a * menu is open, the world is paused) the avatar is fully idle so it never * drifts behind an overlay. An `analog` vector (virtual joystick, gamepad * stick) replaces the digital WASD axes with its fractional deflection (#1370). * @internal */ export declare function resolveMovementIntent(keys: MovementKeysState, canMove: boolean, analog?: AnalogMoveIntent | null): MovementIntent; /** * Kinematics + feel tuning for the first-person controller. Centralised here so * movement feel lives in one place rather than scattered through the renderer. */ export declare const MOVEMENT_TUNING: { readonly standEyeHeight: 1.7; readonly crouchEyeHeight: 1.15; readonly walkSpeedMultiplier: 1.75; readonly runSpeedMultiplier: 2.25; readonly crouchSpeedMultiplier: 0.45; /** Backward (S) travels this fraction of walk speed so retreating reads slower than advancing. */ readonly backpedalSpeedMultiplier: 0.65; readonly groundAcceleration: 26; readonly airAcceleration: 12; readonly groundFriction: 18; readonly jumpVelocity: 7.1; readonly gravityAcceleration: 24; readonly cameraTrackingSpeed: 24; readonly crouchTransitionSpeed: 14; readonly crouchBobRate: 0.5; readonly walkBobRate: 1.5; readonly runBobRate: 2.5; readonly crouchBobAmplitude: 0.02; readonly walkBobAmplitude: 0.045; readonly runBobAmplitude: 0.06; /** Below this horizontal speed the avatar is treated as standing still. */ readonly movingSpeedThreshold: 0.2; /** Largest physics step we integrate so a stalled tab can't teleport the player. */ readonly maxFrameSeconds: 0.05; }; /** * Mutable kinematic state carried between frames by the controller. Kept here so * the velocity / jump / gravity integration is a pure function testable without * a renderer — the controller just owns the ref. */ export interface PlayerMotionState { horizontalVelocityX: number; horizontalVelocityZ: number; verticalVelocity: number; jumpOffset: number; grounded: boolean; jumpHeld: boolean; /** Integrated time, ms; the clock jump buffering and coyote time read. */ clockMs: number; /** `clockMs` of the last grounded frame; `null` after a jump spends it. */ groundedAtMs: number | null; /** `clockMs` of the last landing; `null` before the first. */ landedAtMs: number | null; /** Rising from the player's own jump, so releasing jump may cut it. */ jumpRising: boolean; /** Ended the last frame in the air. */ wasAirborne: boolean; } /** @internal */ export declare function createPlayerMotionState(): PlayerMotionState; /** Horizontal step (world units) the avatar should commit this frame. */ export interface MovementFrameStep { stepX: number; stepZ: number; } /** * Per-game overrides for the gravity/jump feel, sourced from * `GameDefinition.physics`. Omitted fields fall back to {@link MOVEMENT_TUNING}. */ export interface MovementTuningOverrides { gravityAcceleration?: number; jumpVelocity?: number; /** Fraction of walk speed while backpedalling; overrides {@link MOVEMENT_TUNING.backpedalSpeedMultiplier}. */ backpedalSpeedMultiplier?: number; /** Ground velocity response, 1/s: how fast the body reaches its target velocity (default 26). */ groundAcceleration?: number; /** Air velocity response, 1/s: air control (default 12; `0` locks the jump arc). */ airAcceleration?: number; /** Ground stopping response with no input, 1/s (default 18; lower slides). */ groundFriction?: number; /** Sprint speed as a multiple of walk speed (default 2.25). */ runSpeedMultiplier?: number; /** Crouch speed as a multiple of walk speed (default 0.45). */ crouchSpeedMultiplier?: number; /** A jump pressed up to this many ms before landing still fires on landing (default 0). Needs {@link MotionFrameOptions.buffer}. */ jumpBufferMs?: number; /** A jump pressed up to this many ms after walking off a ledge still fires (default 0). */ coyoteMs?: number; /** Rising speed kept when jump is released early, 0..1 (default 1: no cut). Lower makes a tap a hop. */ jumpCutFactor?: number; /** Gravity multiplier near the peak, while vertical speed is under {@link MovementTuningOverrides.apexSpeed} (default 1). */ apexGravityScale?: number; /** Vertical speed under which the apex gravity scale applies, m/s (default 1.5). */ apexSpeed?: number; /** Gravity multiplier while falling (default 1). Above 1 drops faster than it rose. */ fallGravityScale?: number; /** After landing, jumps wait and walk speed ramps back from {@link MovementTuningOverrides.landingSpeedScale} over this many ms (default 0). */ landingRecoveryMs?: number; /** Walk speed share right after landing when `landingRecoveryMs` is set (default 0.5). */ landingSpeedScale?: number; } /** * Per-frame dynamic modifiers for {@link advancePlayerMotion} — state the caller * recomputes each tick (unlike the static {@link MovementTuningOverrides}). Omitted * fields leave the integrator at its default behavior. */ export interface MotionFrameOptions { /** Integrate a caller-clamped simulation step in full, including scaled game time. Default false. */ authoritativeStep?: boolean; /** Extra multiplier applied on top of the resolved target speed (e.g. swimming). Default 1. */ speedScale?: number; /** Suppress gravity/jump integration and hold the avatar afloat this frame (e.g. swimming). Default false. */ floating?: boolean; /** Buffers jump presses for `jumpBufferMs`. Without it a jump fires only on the press frame. */ buffer?: InputBuffer; /** A collision controller supplies grounded state; integrate jump velocity without landing on the synthetic offset origin. */ externalGrounding?: boolean; } /** * Advance one frame of avatar kinematics. Mutates `motion` (velocity, jump, * gravity, grounded) and returns the horizontal step to commit through the * collision-resolving stepper. * * `forwardX`/`forwardZ` are the camera heading projected onto the ground plane * (need not be normalized); the avatar's right axis is derived from it. Pulling * this out of the render loop is what lets us assert "held forward for N frames * crosses the room" in a unit test instead of only in the live game. * @internal */ export declare function advancePlayerMotion(motion: PlayerMotionState, intent: MovementIntent, forwardX: number, forwardZ: number, baseSpeed: number, rawDeltaSeconds: number, tuning?: MovementTuningOverrides, options?: MotionFrameOptions): MovementFrameStep; /** @internal Validate trusted simulation time or apply the standalone frame-stall clamp. */ export declare function motionStepSeconds(seconds: number, authoritativeStep?: boolean): number; /** A placed scene object the walking player collides against as a circle-vs-AABB obstacle. */ export interface CollisionObstacle { position: readonly [number, number, number]; /** Box half-size on each axis. Default `[0.5, 0.5, 0.5]` (unit-box scene objects). Ignored when `boxes` is set. */ halfExtents?: readonly [number, number, number]; /** Local center shift added to `position` for the single-AABB path (the collider's yaw-rotated offset). Ignored when `boxes` is set. */ offset?: readonly [number, number, number]; /** * Optional compound solid: axis-aligned sub-boxes as `min`/`max` **relative to `position`** (add * `position` for world space), already yaw-rotated and conservatively expanded by the caller. When * present the capsule slides against EACH sub-box instead of the single `halfExtents` AABB, so a * concave opening (the gap between an archway's pillars/lintel) lets the capsule pass through. */ boxes?: readonly { min: readonly [number, number, number]; max: readonly [number, number, number]; }[]; } /** @internal Player radius the walking obstruction inflates obstacles by; callers gather within reach of it. */ export declare const DEFAULT_OBSTACLE_PLAYER_RADIUS = 0.3; /** @internal Default feet-to-head span for heightfield obstruction; matches DEFAULT_VOXEL_DIMS.height. */ export declare const DEFAULT_OBSTACLE_PLAYER_HEIGHT = 1.8; /** * Sweep a heightfield walker's vertical step at its accepted XZ position against blocking bounds. * The nearest crossed underside stops ascent; the nearest crossed top catches descent, including * thin geometry crossed in one step. Compound boxes are relative to obstacle.position; offset is * used only for a single AABB. Real starting intersections are reported separately, not repaired. * @internal */ export declare function resolveObstacleVerticalStep(position: readonly [number, number, number], stepY: number, obstacles: readonly CollisionObstacle[], radius?: number, playerHeight?: number): { stepY: number; hitCeiling: boolean; ceilingFeetY: number | null; ceilingTolerance: number; landed: boolean; initialOverlap: boolean; }; /** * Resolve a horizontal step against nearby placed objects with classic * axis-separated sliding (try X, then Z against the post-X position) so * walking into a wall stops the blocked axis but keeps sliding along the * other. Obstacles are treated as circle-vs-AABB in the X/Z plane, inflated * by `playerRadius`; an obstacle whose vertical span misses the player's * feet-to-head span is skipped entirely. Callers should pre-filter to nearby * objects — this also early-exits per obstacle on horizontal distance. * * `stepUpHeight` treats boxes whose top sits within that height above the feet * as walkable ledges instead of walls: they stop blocking here, and the caller * stands the player on them via {@link obstacleSupportHeight} — a curb or stair * tread is stepped up, not bumped into. * @internal */ export declare function resolveObstacleStep(current: readonly [number, number, number], stepX: number, stepZ: number, obstacles: readonly CollisionObstacle[], playerRadius?: number, stepUpHeight?: number, playerHeight?: number): MovementFrameStep; /** * Highest blocking-box top the player can stand on at `(x, z)`: among obstacles whose footprint * (inflated by `playerRadius`, matching the blocking inflation so any horizontal contact that would * block also supports) contains the point, the tallest top face at or below `feetY + maxClimb`. * `null` when nothing supports. This is what turns a blocking collider into a walkable surface — * step up a curb (`maxClimb` = step height while grounded), land a jump on a crate (`maxClimb` = 0 * while airborne, so only tops already below the feet catch), and stand on placed assets instead of * sinking into them and being rubber-banded out by depenetration. * @capability standing-on-objects a player steps up onto low ledges and lands ON a jumped-onto asset's top face instead of clipping inside it. * @internal */ export declare function obstacleSupportHeight(x: number, z: number, feetY: number, maxClimb: number, obstacles: readonly CollisionObstacle[], playerRadius?: number): number | null; /** Placed objects within `radius` (XZ) of `center`, as {@link CollisionObstacle}s to pre-filter for {@link resolveObstacleStep}. * @internal */ export declare function nearbyObstacles(objects: readonly { position: readonly [number, number, number]; }[], center: readonly [number, number, number], radius?: number): CollisionObstacle[]; /** Zeroes the off-axis component so travel is locked to a single world axis (`PlayerMovementConfig.mode: "axis"`). * @internal */ export declare function constrainStepToAxis(stepX: number, stepZ: number, axis: "x" | "z"): MovementFrameStep; /** * Snap a world position to its containing cell's center, matching the * bounds-free form of `navGrid`'s and `tacticalGrid`'s `floor` + half-cell * convention (`PlayerMovementConfig.mode: "grid"`). * @internal */ export declare function snapPositionToGrid(x: number, z: number, cellSize: number): [number, number]; /** Peak jump height from MOVEMENT_TUNING.jumpVelocity + gravity, with small buffer. */ export declare const MAX_JUMP_OFFSET = 1.15; /** Camera yaw looks along -Z; character mesh faces +Z at body rotation.y = 0. * @internal */ export declare function cameraYawToAvatarBodyYaw(cameraYaw: number): number;