diff --git a/.claude/skills/jgengine-gameplay/api.md b/.claude/skills/jgengine-gameplay/api.md index a2e9f4dd6..15d7a8d93 100644 --- a/.claude/skills/jgengine-gameplay/api.md +++ b/.claude/skills/jgengine-gameplay/api.md @@ -694,6 +694,7 @@ - `DirectionalLightingConfig` (interface): interface DirectionalLightingConfig — ⚠ undocumented - `EntitySpriteConfig` (interface): interface EntitySpriteConfig — ⚠ undocumented - `FirstPersonCameraConfig` (interface): interface FirstPersonCameraConfig — ⚠ undocumented +- `FlightConfig` (interface): interface FlightConfig — Free-flight families moved into the walk controller so one seam covers ground + air — creative/spectator/noclip/hover are weightless, aircraft/rotorcraft stay on `flightDynamics`. - `GameCameraConfig` (interface): interface GameCameraConfig — Camera tuning for the shell's rig stack: pick the rig via `rig`, then tune it through its matching config block. All fields optional — the default is the third-person orbit rig. - `GameCaptureConfig` (interface): interface GameCaptureConfig — How a game makes itself screenshottable: the commands that reach live play or a named screen, the framings worth re-capturing, and the progress metrics a bot playtest samples. Declaring these once is what lets a capture host reproduce a view instead of re-deriving it by hand every run. - `GameCaptureView` (interface): interface GameCaptureView — How a screenshot host reaches live gameplay in this game — the data behind `shoot --mode play`. diff --git a/.claude/skills/jgengine-world/api.md b/.claude/skills/jgengine-world/api.md index 7760decb5..b0b7d7de6 100644 --- a/.claude/skills/jgengine-world/api.md +++ b/.claude/skills/jgengine-world/api.md @@ -526,6 +526,26 @@ - `placeFormation` (function): function placeFormation(destination: Vec2, facing: number, count: number, generator: FormationSlotGenerator): Vec2[] — Transform a generator's local slot offsets into world XZ positions around a `destination`, rotated by `facing` (engine yaw). Slot `i` in the returned array is `generator(count)[i]` mapped through the group frame, so it stays aligned with {@link assignFormationSlots}' slot indices. Pure and allocation- light: one array of `count` points, no per-call closures retained. - `wedgeFormation` (function): function wedgeFormation(options: WedgeFormationOptions): FormationSlotGenerator — A "V"/arrowhead with the apex at the destination and arms trailing back — a flying-wedge charge or a goose skein. Slot 0 is the tip; later slots alternate right then left, each rank stepping one `spacing` outward and backward. +## @jgengine/core/movement/freeFlight + +- `CREATIVE_FLIGHT_TUNING` (const): const CREATIVE_FLIGHT_TUNING: FreeFlightTuning — Preset for Minecraft-like creative flight — weightless, collides, yaw-relative strafe. +- `FreeFlightBindings` (interface): interface FreeFlightBindings — Per-axis bindings for free-flight — which actions drive each flight axis. Positive is forward/right/up. +- `FreeFlightController` (interface): interface FreeFlightController — Stateful handle for any free-flight actor with snapshot/restore/retune. +- `FreeFlightIntent` (interface): interface FreeFlightIntent — Intent for one free-flight tick — forward/right from WASD/analog, vertical from jump/crouch. +- `FreeFlightMode` (type): type FreeFlightMode = "creative" | "spectator" | "noclip" | "hover" — Free-flight families — character-scale 6DOF movement, distinct from vehicle aerodynamics. +- `FreeFlightState` (interface): interface FreeFlightState — Velocity state for a free-flight actor — serializable and ownable by the caller. +- `FreeFlightStep` (interface): interface FreeFlightStep — World displacement produced by one free-flight tick. +- `FreeFlightTuning` (interface): interface FreeFlightTuning — Data-first tuning for one free-flight profile. +- `HOVER_FLIGHT_TUNING` (const): const HOVER_FLIGHT_TUNING: FreeFlightTuning — Preset for hover/jetpack — gravity + thrust, useful for short bursts. +- `NOCLIP_FLIGHT_TUNING` (const): const NOCLIP_FLIGHT_TUNING: FreeFlightTuning — Preset for noclip — weightless, noclips, yaw-relative with independent vertical. +- `SPECTATOR_FLIGHT_TUNING` (const): const SPECTATOR_FLIGHT_TUNING: FreeFlightTuning — Preset for spectator — weightless, noclips, flies where the camera looks. +- `advanceFreeFlight` (function): function advanceFreeFlight(state: FreeFlightState, intent: FreeFlightIntent, yaw: number, pitch: number | undefined, dt: number, tuning: FreeFlightTuning): FreeFlightStep — Advance one frame of free-flight kinematics. Horizontal uses yaw-relative strafe (A = left, D = right) — never roll/bank. Vertical is independent of pitch unless `alignWithLook` is true (spectator). +- `createFreeFlightController` (function): function createFreeFlightController(initialTuning: FreeFlightTuning, options: { yaw?: number; pitch?: number; state?: FreeFlightState } = {}): FreeFlightController — Totally free flight for any actor — character, camera, drone, or debug rig. Horizontal is always yaw-relative strafe; vertical is jump/crouch. Use `alignWithLook` for a 6DOF spectator that flies where the camera looks. +- `createFreeFlightState` (function): function createFreeFlightState(): FreeFlightState — Create an empty flight velocity state. +- `resolveFlightStep` (function): function resolveFlightStep(position: readonly [number, number, number], stepX: number, stepY: number, stepZ: number, obstacles: readonly CollisionObstacle[], radius: number = DEFAULT_OBSTACLE_PLAYER_RADIUS): { stepX: number; stepY: number; stepZ: number } — Resolve a flight step against world solids (XZ slide + Y clamp). +- `resolveFreeFlightIntent` (function): function resolveFreeFlightIntent(keys: MovementKeysState, analog: AnalogMoveIntent | null | undefined): FreeFlightIntent — Translate held keys + analog into a free-flight intent. Vertical is `space - (control|c)` so jump ascends and crouch descends — the same keys that walk/jump/crouch use, re-mapped for flight without re-binding. +- `resolveFreeFlightIntentFromInput` (function): function resolveFreeFlightIntentFromInput(isDown: (action: string) => boolean, value: (action: string) => number, pointer: PointerAxisState | null, bindings?: FreeFlightBindings): FreeFlightIntent — Translate live input (held actions + analog + pointer) into a flight intent using per-profile axis bindings. Unlisted bindings fall back to the defaults above so existing games keep WASD/Space/Ctrl/Shift without declaring anything. + ## @jgengine/core/movement/glideModel - `GlideInput` (interface): interface GlideInput — ⚠ undocumented @@ -573,7 +593,7 @@ - `playerMovementHeading` (function): function playerMovementHeading(ctx: GameContext, userId: string): number — One player's current heading (radians), integrated by {@link stepPlayerMovement} — the shell reads it back into its camera/aim yaw. - `resolvePhysicsTuning` (function): function resolvePhysicsTuning(physics: PhysicsConfig | undefined): MovementTuningOverrides | undefined — Maps a game's declared `physics` onto the movement controllers' tuning. `PhysicsConfig.gravity` is a signed world acceleration (negative points down), but the controllers integrate `velocityY -= gravityAcceleration * dt` and expect a positive downward magnitude — so gravity is negated here to keep down-pointing gravity pulling down. - `resolvePlayerMovementTuning` (function): function resolvePlayerMovementTuning(opts: { collision?: VoxelCollisionConfig; movement?: PlayerMovementConfig; physics?: PhysicsConfig; world?: WorldFeature; }): PlayerMovementTuning — Gather a game's collision/movement/physics/world config into a {@link PlayerMovementTuning} — call once per world; both the shell and a host pass the result to {@link stepPlayerMovement}. -- `stepPlayerMovement` (function): function stepPlayerMovement(ctx: GameContext, userId: string, input: InputFrame, dt: number, tuning: PlayerMovementTuning, heading?: number): void — Integrate one player's movement for a tick from their held-input frame and commit the pose — the single genre-agnostic controller both the shell (its local player) and a host (each connected player in `onTick`) call, so single-player and server-authoritative movement are identical. Reads the player's controlled entity, terrain, scene solids, and pending motion impulses; writes the entity pose via `setPose`. Retains heading + kinematic body per `userId` on the `ctx`. Pass `heading` to override the internally-integrated yaw (the shell owns yaw for its camera); omit it and the controller turns from the frame's `turnLeft`/`turnRight` actions. +- `stepPlayerMovement` (function): function stepPlayerMovement(ctx: GameContext, userId: string, input: InputFrame, dt: number, tuning: PlayerMovementTuning, heading?: number, pitch?: number): void — Integrate one player's movement for a tick from their held-input frame and commit the pose — the single genre-agnostic controller both the shell (its local player) and a host (each connected player in `onTick`) call, so single-player and server-authoritative movement are identical. Reads the player's controlled entity, terrain, scene solids, and pending motion impulses; writes the entity pose via `setPose`. Retains heading + kinematic body per `userId` on the `ctx`. Pass `heading` to override the internally-integrated yaw (the shell owns yaw for its camera); omit it and the controller turns from the frame's `turnLeft`/`turnRight` actions. Pass `pitch` for 6DOF spectator flight when `alignWithLook` is set. ## @jgengine/core/movement/poseState @@ -768,6 +788,7 @@ ## @jgengine/core/physics/flightDynamics +- `AircraftAxis` (type): type AircraftAxis = "pitch" | "roll" | "yaw" | "throttle" | "collective" | "airbrake" | "afterburner" | "vectoring" — Axes an aircraft can bind — which actions drive each flight control. - `AircraftDynamics` (interface): interface AircraftDynamics — Stateful six-degree-of-freedom aircraft simulation. - `AircraftKind` (type): type AircraftKind = "fixedWing" | "rotorcraft" | "vtol" — Supported aerodynamic/propulsion families. - `AircraftOptions` (interface): interface AircraftOptions — Spawn state and injectable world-field samplers for an aircraft instance. @@ -1593,6 +1614,7 @@ - `AcquisitionRetention` (interface): interface AcquisitionRetention — Retention hysteresis that keeps an already-held target from flickering under churn. - `AddBodyOptions` (type): type AddBodyOptions = BoxBodyOptions | SphereBodyOptions — ⚠ undocumented - `Aim` (type): type Aim = | { origin: EntityPosition; direction: EntityPosition } | { yaw: number; pitch: number; spread?: number } — ⚠ undocumented +- `AircraftAxis` (type): type AircraftAxis = "pitch" | "roll" | "yaw" | "throttle" | "collective" | "airbrake" | "afterburner" | "vectoring" — Axes an aircraft can bind — which actions drive each flight control. - `AircraftDynamics` (interface): interface AircraftDynamics — Stateful six-degree-of-freedom aircraft simulation. - `AircraftKind` (type): type AircraftKind = "fixedWing" | "rotorcraft" | "vtol" — Supported aerodynamic/propulsion families. - `AircraftOptions` (interface): interface AircraftOptions — Spawn state and injectable world-field samplers for an aircraft instance. @@ -1657,6 +1679,7 @@ - `CITY_TREE_SPECIES` (const): const CITY_TREE_SPECIES: readonly CityTreeSpecies[] — All species, for schema hints and validation. - `CITY_ZONE_KIND` (const): const CITY_ZONE_KIND: "cityzone" — The editor volume kind that locally overrides a city's zone band/mix — a district in a district. - `CITY_ZONE_SCHEMA` (const): const CITY_ZONE_SCHEMA: ParamSchema — Zone-override schema for `cityzone` volumes: pin a band and optionally a bespoke class mix. +- `CREATIVE_FLIGHT_TUNING` (const): const CREATIVE_FLIGHT_TUNING: FreeFlightTuning — Preset for Minecraft-like creative flight — weightless, collides, yaw-relative strafe. - `CameraShakeConfig` (interface): interface CameraShakeConfig — Configuration for {@link createCameraShake}. Every field has a game-feel default. - `CameraShakeController` (interface): interface CameraShakeController — A live, dt-driven, trauma-based camera shake controller. - `CameraShakeOffset` (interface): interface CameraShakeOffset — A per-frame camera shake offset: three positional axes plus three rotational axes (radians). @@ -1765,6 +1788,13 @@ - `ForceVolume` (class): class ForceVolume — A trigger region that pushes bodies passing through it — boost pads (`impulse` + `once`), conveyors (`velocity`), fans/wind (`accelerate`). Call `apply` each tick; `once` mode fires only on entry by tracking membership between ticks. - `FormationSlotGenerator` (type): type FormationSlotGenerator = (count: number) => Vec2[] — Produces `count` slot offsets in the group's local frame — `[right, forward]` where `+forward` points where the group faces. Index order is the slot order; a generator must be pure (same `count` → same offsets) so placement stays deterministic. Sample generators below cover common shapes; games pass their own for anything else (crowds, convoys, sports positions) with no engine edit. - `FramingConfig` (interface): interface FramingConfig — ⚠ undocumented +- `FreeFlightBindings` (interface): interface FreeFlightBindings — Per-axis bindings for free-flight — which actions drive each flight axis. Positive is forward/right/up. +- `FreeFlightController` (interface): interface FreeFlightController — Stateful handle for any free-flight actor with snapshot/restore/retune. +- `FreeFlightIntent` (interface): interface FreeFlightIntent — Intent for one free-flight tick — forward/right from WASD/analog, vertical from jump/crouch. +- `FreeFlightMode` (type): type FreeFlightMode = "creative" | "spectator" | "noclip" | "hover" — Free-flight families — character-scale 6DOF movement, distinct from vehicle aerodynamics. +- `FreeFlightState` (interface): interface FreeFlightState — Velocity state for a free-flight actor — serializable and ownable by the caller. +- `FreeFlightStep` (interface): interface FreeFlightStep — World displacement produced by one free-flight tick. +- `FreeFlightTuning` (interface): interface FreeFlightTuning — Data-first tuning for one free-flight profile. - `FreezeMonitor` (interface): interface FreezeMonitor — ⚠ undocumented - `FreezeViolation` (interface): interface FreezeViolation — ⚠ undocumented - `Frustum` (interface): interface Frustum — ⚠ undocumented @@ -1788,6 +1818,7 @@ - `GroundMode` (type): type GroundMode = "flat" | "round" | "voxel" | "board" — Canonical ground modes after normalization (`stage` → `board`). - `GroundPoint` (type): type GroundPoint = readonly [number, number] — A world-space point on the ground plane as an `[x, z]` pair. - `GuideRegion` (interface): interface GuideRegion — An axis-aligned XZ rectangle to generate terrain-readability guides within. +- `HOVER_FLIGHT_TUNING` (const): const HOVER_FLIGHT_TUNING: FreeFlightTuning — Preset for hover/jetpack — gravity + thrust, useful for short bursts. - `HeatConfig` (interface): interface HeatConfig — Tuning for {@link createHeatState}/{@link advanceHeat} — levels, decay, and pursuit-spawn ring. - `HeatGain` (interface): interface HeatGain — One crime tick's contribution — only `witnessed` gains raise heat (unseen crimes are free, GTA-style). - `HeatLevelDef` (interface): interface HeatLevelDef — One escalation tier — the heat threshold it begins at and the pursuer count it wants active. @@ -1857,6 +1888,7 @@ - `MovementPose` (type): type MovementPose = "standing" | "crouch" | "prone" | "running" — ⚠ undocumented - `MusicInstrument` (type): type MusicInstrument = | "strings" | "flute" | "harp" | "horn" | "choir" | "bell" | "timpani" | "bass" | "stacc" | "pad" | "lute" | "dulcimer" | "frameDrum" | "warDrum" | "reed" | "pipe" | "squareLead" | "woodBlock" | "tinyBell" | "piano" | "shaker" | "brassStab" | "cymSwell" | "oboe" — Named synthesised instrument. Each maps to a voice in the shell's instrument library (`@jgengine/shell/audio/musicVoices`); an unknown name falls back to a plain sine voice so a theme is never silent. - `MusicTheme` (interface): interface MusicTheme — A through-composed, looping music track. `events` need not be sorted; the director schedules them ahead against a fixed anchor so loops are seamless. +- `NOCLIP_FLIGHT_TUNING` (const): const NOCLIP_FLIGHT_TUNING: FreeFlightTuning — Preset for noclip — weightless, noclips, yaw-relative with independent vertical. - `NavGrid` (interface): interface NavGrid — ⚠ undocumented - `NavPoint` (type): type NavPoint = readonly [number, number] — ⚠ undocumented - `NoiseFieldConfig` (interface): interface NoiseFieldConfig — Configuration for {@link noiseField}: seed, amplitude, and fractal noise shaping. @@ -1961,6 +1993,7 @@ - `SHAPE_SPHERE` (const): const SHAPE_SPHERE: 1 — ⚠ undocumented - `SOIL_KIND` (const): const SOIL_KIND: "soil" — The editor volume kind marking a box as a soil crack/moss patch. - `SOIL_SCHEMA` (const): const SOIL_SCHEMA: ParamSchema — The soil parameter schema — drives the inspector and `meta` parse via the studio seam. +- `SPECTATOR_FLIGHT_TUNING` (const): const SPECTATOR_FLIGHT_TUNING: FreeFlightTuning — Preset for spectator — weightless, noclips, flies where the camera looks. - `SampleBatchOptions` (interface): interface SampleBatchOptions

— Inputs for a {@link sampleBatch} draw of many spaced points. - `SampleBatchResult` (interface): interface SampleBatchResult

— Structured batch outcome: the placed points plus whether the full count was met. - `SampleConstraints` (interface): interface SampleConstraints

— Post-draw gates a candidate must clear. All are optional; an empty constraint set accepts every in-region draw. `exclude` discs/spheres are the keep-out distance primitive ("no closer than R to this center"); `accept` is a free-form caller predicate (biome, slope, ownership); `project` snaps a candidate onto a surface/navmesh before validation and may reject by returning `null`. @@ -2127,6 +2160,7 @@ - `WorldXZ` (type): type WorldXZ = readonly [number, number] — ⚠ undocumented - `acquireTarget` (function): function acquireTarget(policy: AcquisitionPolicy, selfId: string, held: string | null = null): AcquisitionResult — Run one acquisition pass and pick the best target under `policy`. Pass the currently `held` target so retention hysteresis (`switchMargin`, `dropRangeScale`) can keep the lock stable; pass `null` for a cold acquire. Pure and allocation-light — the caller owns the held-target state. - `advanceBehaviors` (function): function advanceBehaviors(ctx: GameContext, dt: number): void — Advance every spawned entity carrying a `patrol` or `wander` {@link BehaviorDescriptor} one tick — the engine reads the descriptor, keeps the per-entity nav state itself, and poses the entity, so ambient traffic and idle NPC routes are register-once (attach the behavior at spawn) instead of a per-game per-frame `advancePathFollow` + `setPose` loop. Instances that are paused or disabled through {@link behaviorControl} retain their state and are skipped. The shell/host call this each frame; a game never does. +- `advanceFreeFlight` (function): function advanceFreeFlight(state: FreeFlightState, intent: FreeFlightIntent, yaw: number, pitch: number | undefined, dt: number, tuning: FreeFlightTuning): FreeFlightStep — Advance one frame of free-flight kinematics. Horizontal uses yaw-relative strafe (A = left, D = right) — never roll/bank. Vertical is independent of pitch unless `alignWithLook` is true (spectator). - `advanceInterestGate` (function): function advanceInterestGate(state: InterestGateState, config: InterestSchedulerConfig, dt: number, input: InterestGateInput = {}): InterestGateStep — Advance one gate by `dt` seconds against this tick's `input`, mutating `state` in place and returning what the caller should do. Sleeping skips the expensive work (`active: false`) while the gate's timers keep advancing, so state is preserved; a `wake` signal or crossing `wakeRadius` flips it back to active and fires an immediate tick. - `advancePathFollow` (function): function advancePathFollow(config: PathFollowConfig, state: PathFollowState, dt: number): PathFollowState — Advance a path-follower by `speed * dt` along its authored polyline. Pure — returns the next state. Crosses multiple waypoints in one step, loops when configured, and reports `done` at the end of a non-looping path. No navmesh required (#52); feed it a navmesh route via `pathFromNav` for click-to-move (#51). - `advanceSpawnDirector` (function): function advanceSpawnDirector(config: SpawnDirectorConfig, state: SpawnDirectorState, dt: number, ctx: DirectorContext): DirectorStep — ⚠ undocumented @@ -2193,6 +2227,8 @@ - `createFireGrid` (function): function createFireGrid(config: FireGridConfig): FireGrid — ⚠ undocumented - `createFogField` (function): function createFogField(config: FogConfig): FogField — ⚠ undocumented - `createFootprintGrid` (function): function createFootprintGrid(options: FootprintGridOptions = {}): FootprintGrid — Multi-cell footprint occupancy/reservation on a shared build grid — `world/placementController` only owns the ghost preview; this is the persistent claim a committed placement holds so the next hover's `isFree` check (or another player's, in a shared world) sees it. Bridge into `world/placement`'s `PlacementRules.obstacles` with {@link footprintObstacles} instead of hand-rolling an occupancy map per game. +- `createFreeFlightController` (function): function createFreeFlightController(initialTuning: FreeFlightTuning, options: { yaw?: number; pitch?: number; state?: FreeFlightState } = {}): FreeFlightController — Totally free flight for any actor — character, camera, drone, or debug rig. Horizontal is always yaw-relative strafe; vertical is jump/crouch. Use `alignWithLook` for a 6DOF spectator that flies where the camera looks. +- `createFreeFlightState` (function): function createFreeFlightState(): FreeFlightState — Create an empty flight velocity state. - `createGlideModel` (function): function createGlideModel(config: GlideModelConfig = {}): GlideModel — Gliding/wingsuit descent control — lift, drag, and steering from a launch. - `createGrappleSwing` (function): function createGrappleSwing(config: GrappleSwingConfig = {}): GrappleSwing — Grappling-hook rope swing physics with anchor, pendulum motion, and reel-in. - `createInterestCensus` (function): function createInterestCensus(): InterestCensusAccumulator — Create a census accumulator so the caller can tally active/dormant gates during the loop it already runs, avoiding any separate full-world scan just to report scheduler metrics. @@ -2355,6 +2391,9 @@ - `resolveControlGroupIntent` (function): function resolveControlGroupIntent(input: ControlGroupInput, options: ControlGroupOptions = {}): ControlGroupIntent — Resolve a control-group key press into a {@link ControlGroupIntent}: Ctrl+digit binds, a bare digit recalls, and a second recall of the same group within `doubleTapMs` focuses. Pure — the caller applies the intent against the store and its own focus hook, and records the returned recall for the next call. - `resolveEmitterGain` (function): function resolveEmitterGain(distance: number, sound: Pick, busGain: number): number — ⚠ undocumented - `resolveFacingRotationY` (function): function resolveFacingRotationY(headingDegrees: number, space?: Pick): number — The Three.js Y-rotation that makes a model whose front is authored at `forwardDegrees` visually point `headingDegrees` (engine north = `0`). This is the catalog-owned replacement for per-game corrective yaw: a model authored facing south (`forwardDegrees: 180`) placed toward north resolves to `Math.PI`. +- `resolveFlightStep` (function): function resolveFlightStep(position: readonly [number, number, number], stepX: number, stepY: number, stepZ: number, obstacles: readonly CollisionObstacle[], radius: number = DEFAULT_OBSTACLE_PLAYER_RADIUS): { stepX: number; stepY: number; stepZ: number } — Resolve a flight step against world solids (XZ slide + Y clamp). +- `resolveFreeFlightIntent` (function): function resolveFreeFlightIntent(keys: MovementKeysState, analog: AnalogMoveIntent | null | undefined): FreeFlightIntent — Translate held keys + analog into a free-flight intent. Vertical is `space - (control|c)` so jump ascends and crouch descends — the same keys that walk/jump/crouch use, re-mapped for flight without re-binding. +- `resolveFreeFlightIntentFromInput` (function): function resolveFreeFlightIntentFromInput(isDown: (action: string) => boolean, value: (action: string) => number, pointer: PointerAxisState | null, bindings?: FreeFlightBindings): FreeFlightIntent — Translate live input (held actions + analog + pointer) into a flight intent using per-profile axis bindings. Unlisted bindings fall back to the defaults above so existing games keep WASD/Space/Ctrl/Shift without declaring anything. - `resolveGridInstances` (function): function resolveGridInstances(config: WorldGridConfig | GridWorldFeature): readonly GridInstanceTransform[] — ⚠ undocumented - `resolveLocalAvoidance` (function): function resolveLocalAvoidance(agents: AvoidanceAgent[], options: LocalAvoidanceOptions = {}): number — Resolve overlaps in `agents` in place and return how many overlapping pairs remained on the final pass (`0` = fully separated). Uses a bounded uniform hash grid sized to the largest agent, so only nearby agents are ever compared. Deterministic: corrections are accumulated then applied per pass, independent of agent order. Pass `weights` to pin or differentially push agents. - `resolvePlaceAsset` (function): function resolvePlaceAsset(input: ResolvePlaceAssetInput): PlaceAssetResult — Resolve a place-asset intent into a shared payload (editor + games, one verb). @@ -2405,7 +2444,7 @@ - `sphereRegion` (function): function sphereRegion(center: Point3, radius: number, options: { distribution?: VolumeDistribution } = {}): SampleRegion — A filled ball. `"volume"` is volume-uniform (∛-corrected radius); `"radial"` is radius-uniform. Direction is drawn first (two draws), then radius. - `steerYaw` (function): function steerYaw(yaw: number, steerRight: number, turnRatePerSecond: number, dt: number): number — Integrate one steering step. `steerRight` is the signed steer input (+1 = turn right, matching `DRIVE_AXIS_BINDINGS`' KeyD/ArrowRight), `turnRatePerSecond` is radians per second at full lock. Steering right decreases yaw in the engine frame; this helper owns that sign so game code never re-derives it. - `stepLock` (function): function stepLock(spec: LockSpec, col: number, row: number, action: LockAction): { result: LockStepResult; col: number; row: number } — Authoritative single step. The caller owns the lives economy: a slip/bind/trap does not advance the pick and should cost a life; advanced/success move the pick. -- `stepPlayerMovement` (function): function stepPlayerMovement(ctx: GameContext, userId: string, input: InputFrame, dt: number, tuning: PlayerMovementTuning, heading?: number): void — Integrate one player's movement for a tick from their held-input frame and commit the pose — the single genre-agnostic controller both the shell (its local player) and a host (each connected player in `onTick`) call, so single-player and server-authoritative movement are identical. Reads the player's controlled entity, terrain, scene solids, and pending motion impulses; writes the entity pose via `setPose`. Retains heading + kinematic body per `userId` on the `ctx`. Pass `heading` to override the internally-integrated yaw (the shell owns yaw for its camera); omit it and the controller turns from the frame's `turnLeft`/`turnRight` actions. +- `stepPlayerMovement` (function): function stepPlayerMovement(ctx: GameContext, userId: string, input: InputFrame, dt: number, tuning: PlayerMovementTuning, heading?: number, pitch?: number): void — Integrate one player's movement for a tick from their held-input frame and commit the pose — the single genre-agnostic controller both the shell (its local player) and a host (each connected player in `onTick`) call, so single-player and server-authoritative movement are identical. Reads the player's controlled entity, terrain, scene solids, and pending motion impulses; writes the entity pose via `setPose`. Retains heading + kinematic body per `userId` on the `ctx`. Pass `heading` to override the internally-integrated yaw (the shell owns yaw for its camera); omit it and the controller turns from the frame's `turnLeft`/`turnRight` actions. Pass `pitch` for 6DOF spectator flight when `alignWithLook` is set. - `sumMagnitude` (function): function sumMagnitude

(memberships: readonly AreaMembership

[], magnitudeOf: MagnitudeOf

): number — Sum a numeric magnitude across memberships — additive aggregation for a total field strength (total damage per tick, total slow). A terminal reducer, not a filter, so it returns the number. - `summarizeElevation` (function): function summarizeElevation(sampleHeight: HeightSampler, region: GuideRegion, resolution = 64, maxSamples = 256): ElevationSummary — Summarises elevation across a region on a bounded sample grid: min, max, mean, relief range, and the world points of the extremes — the selection min/max and legend feedback the readability overlay reports, and the input to {@link chooseContourInterval}. Pure math, renderer-agnostic. - `summarizeEnvironment` (function): function summarizeEnvironment(feature: EnvironmentWorldFeature): EnvironmentSummary — ⚠ undocumented diff --git a/.claude/skills/jgengine-world/capabilities.md b/.claude/skills/jgengine-world/capabilities.md index f3f9d228a..c21f8d08f 100644 --- a/.claude/skills/jgengine-world/capabilities.md +++ b/.claude/skills/jgengine-world/capabilities.md @@ -279,6 +279,10 @@ Reach for these before hand-rolling. Each row is *the thing you need* → *the p - `DEFAULT_FORWARD` (const) · `import { DEFAULT_FORWARD } from "@jgengine/core/world"` +## free-flight — camera-relative creative/spectator/noclip/hover flight with correct strafe, sprint, and optional gravity + +- `createFreeFlightController` (function) · `import { createFreeFlightController } from "@jgengine/core/world"` + ## glide — gliding/wingsuit descent control from a launch - `createGlideModel` (function) · `import { createGlideModel } from "@jgengine/core/world"` diff --git a/Games/flight-demo/src/editor.scene.json b/Games/flight-demo/src/editor.scene.json new file mode 100644 index 000000000..ed82dca03 --- /dev/null +++ b/Games/flight-demo/src/editor.scene.json @@ -0,0 +1,42 @@ +{ + "version": 1, + "markers": [ + { "id": "player_spawn", "kind": "player_spawn", "position": { "x": 0, "y": 2, "z": 0 }, "label": "Player spawn — center of lab", "color": "#22d3ee" }, + { "id": "runway_-100", "kind": "prop", "position": { "x": 0, "y": 0, "z": -100 }, "label": "Runway -100", "catalogId": "crate" }, + { "id": "runway_-80", "kind": "prop", "position": { "x": 0, "y": 0, "z": -80 }, "label": "Runway -80", "catalogId": "crate" }, + { "id": "runway_-60", "kind": "prop", "position": { "x": 0, "y": 0, "z": -60 }, "label": "Runway -60", "catalogId": "crate" }, + { "id": "runway_-40", "kind": "prop", "position": { "x": 0, "y": 0, "z": -40 }, "label": "Runway -40", "catalogId": "crate" }, + { "id": "runway_-20", "kind": "prop", "position": { "x": 0, "y": 0, "z": -20 }, "label": "Runway -20", "catalogId": "crate" }, + { "id": "runway_20", "kind": "prop", "position": { "x": 0, "y": 0, "z": 20 }, "label": "Runway +20", "catalogId": "crate" }, + { "id": "runway_40", "kind": "prop", "position": { "x": 0, "y": 0, "z": 40 }, "label": "Runway +40", "catalogId": "crate" }, + { "id": "runway_60", "kind": "prop", "position": { "x": 0, "y": 0, "z": 60 }, "label": "Runway +60", "catalogId": "crate" }, + { "id": "runway_80", "kind": "prop", "position": { "x": 0, "y": 0, "z": 80 }, "label": "Runway +80", "catalogId": "crate" }, + { "id": "runway_100", "kind": "prop", "position": { "x": 0, "y": 0, "z": 100 }, "label": "Runway +100", "catalogId": "crate" }, + { "id": "gate_12", "kind": "prop", "position": { "x": 0, "y": 12, "z": -40 }, "label": "Gate 12m", "catalogId": "crate" }, + { "id": "gate_18", "kind": "prop", "position": { "x": 0, "y": 18, "z": 0 }, "label": "Gate 18m", "catalogId": "crate" }, + { "id": "gate_24", "kind": "prop", "position": { "x": 0, "y": 24, "z": 40 }, "label": "Gate 24m", "catalogId": "crate" }, + { "id": "pillar_10", "kind": "prop", "position": { "x": 40, "y": 10, "z": 0 }, "label": "10m", "catalogId": "crate" }, + { "id": "pillar_20", "kind": "prop", "position": { "x": 40, "y": 20, "z": 0 }, "label": "20m", "catalogId": "crate" }, + { "id": "pillar_30", "kind": "prop", "position": { "x": 40, "y": 30, "z": 0 }, "label": "30m", "catalogId": "crate" }, + { "id": "pillar_50", "kind": "prop", "position": { "x": 40, "y": 50, "z": 0 }, "label": "50m", "catalogId": "crate" }, + { "id": "pillar_80", "kind": "prop", "position": { "x": 40, "y": 80, "z": 0 }, "label": "80m", "catalogId": "crate" }, + { "id": "pillar_100", "kind": "prop", "position": { "x": 40, "y": 100, "z": 0 }, "label": "100m", "catalogId": "crate" }, + { "id": "pillar2_10", "kind": "prop", "position": { "x": -40, "y": 10, "z": 0 }, "label": "10m", "catalogId": "crate" }, + { "id": "pillar2_20", "kind": "prop", "position": { "x": -40, "y": 20, "z": 0 }, "label": "20m", "catalogId": "crate" }, + { "id": "pillar2_30", "kind": "prop", "position": { "x": -40, "y": 30, "z": 0 }, "label": "30m", "catalogId": "crate" }, + { "id": "grid_-60_-60", "kind": "prop", "position": { "x": -60, "y": 0, "z": -60 }, "label": "Grid", "catalogId": "tree" }, + { "id": "grid_0_-60", "kind": "prop", "position": { "x": 0, "y": 0, "z": -60 }, "label": "Grid", "catalogId": "tree" }, + { "id": "grid_60_-60", "kind": "prop", "position": { "x": 60, "y": 0, "z": -60 }, "label": "Grid", "catalogId": "tree" }, + { "id": "grid_-60_0", "kind": "prop", "position": { "x": -60, "y": 0, "z": 0 }, "label": "Grid", "catalogId": "tree" }, + { "id": "grid_60_0", "kind": "prop", "position": { "x": 60, "y": 0, "z": 0 }, "label": "Grid", "catalogId": "tree" }, + { "id": "grid_-60_60", "kind": "prop", "position": { "x": -60, "y": 0, "z": 60 }, "label": "Grid", "catalogId": "tree" }, + { "id": "grid_0_60", "kind": "prop", "position": { "x": 0, "y": 0, "z": 60 }, "label": "Grid", "catalogId": "tree" }, + { "id": "grid_60_60", "kind": "prop", "position": { "x": 60, "y": 0, "z": 60 }, "label": "Grid", "catalogId": "tree" }, + { "id": "wall_150_1", "kind": "prop", "position": { "x": -10, "y": 0, "z": 150 }, "label": "Wall", "catalogId": "crate" }, + { "id": "wall_150_2", "kind": "prop", "position": { "x": 0, "y": 0, "z": 150 }, "label": "Wall", "catalogId": "crate" }, + { "id": "wall_150_3", "kind": "prop", "position": { "x": 10, "y": 0, "z": 150 }, "label": "Wall", "catalogId": "crate" }, + { "id": "arch_low", "kind": "prop", "position": { "x": -15, "y": 8, "z": -20 }, "label": "Arch", "catalogId": "crate" }, + { "id": "arch_high", "kind": "prop", "position": { "x": 15, "y": 15, "z": 20 }, "label": "Arch", "catalogId": "crate" }, + { "id": "goal", "kind": "goal", "position": { "x": 0, "y": 0, "z": 140 }, "label": "Goal — end of runway", "color": "#22c55e", "meta": { "on": "enter", "action": "win", "message": "Runway complete!", "triggerRadius": 4 } } + ] +} diff --git a/Games/flight-demo/src/game.config.ts b/Games/flight-demo/src/game.config.ts new file mode 100644 index 000000000..ff44f87d2 --- /dev/null +++ b/Games/flight-demo/src/game.config.ts @@ -0,0 +1,43 @@ +import { DEFAULT_WALK_CODES, defineGame } from "@jgengine/shell/gameKit"; + +import { editorLayers } from "./editorLayers"; +import { assets } from "./game/assets"; +import { entityModels, objectModels } from "./game/models"; +import { GameUI } from "./game/ui/GameUI"; +import { onNewPlayer, systems } from "./loop"; +import { physics, world } from "./world"; + +export const game = defineGame({ + name: "Flight Lab", + assets, + world, + physics, + // Per-mode bindings + POV live on the thing they control. + // Creative: WASD strafe (A left, D right — never roll), Space/Ctrl vertical, Shift sprint. + // Each mount declares its own bindings + camera; here the player flight declares creative bindings + a slightly wider chase. + input: { ...DEFAULT_WALK_CODES, crouch: ["ControlLeft", "KeyC"], interact: ["KeyE"] }, + movement: { + flight: { + mode: "creative", + speed: 10, + verticalSpeed: 10, + sprintMultiplier: 2.2, + // bindings are per-flight — e.g. Q/E for vertical instead of Space/Ctrl, or Arrow keys for a cockpit: + // bindings: { vertical: { positive: ["KeyQ"], negative: ["KeyE"] } }, + // camera: { distance: 10, height: 3, fov: { base: 68, max: 88, speedForMax: 22 } }, + camera: { distance: 12, height: 4 }, + }, + }, + capture: { + probe: (ctx) => { + const p = ctx.scene.entity.get(ctx.player.userId)?.position ?? [0, 0, 0]; + return { x: p[0], y: p[1], z: p[2] }; + }, + }, + systems, + loop: { onNewPlayer }, + GameUI, + editorLayers, + entityModels, + objectModels, +}); diff --git a/Games/flight-demo/src/game/ui/GameUI.tsx b/Games/flight-demo/src/game/ui/GameUI.tsx new file mode 100644 index 000000000..fc892cb8d --- /dev/null +++ b/Games/flight-demo/src/game/ui/GameUI.tsx @@ -0,0 +1,95 @@ +import { useEffect, useRef, useState } from "react"; +import { HudCanvas, useHudLayout } from "@jgengine/react"; +import { useGameContext, useGameStore, useTicker } from "@jgengine/react"; + +import { outcome } from "../../loop"; + +function usePlayerTelemetry() { + const ctx = useGameContext(); + const tick = useTicker(12); + const prev = useRef<{ pos: readonly [number, number, number]; time: number } | null>(null); + const [telemetry, setTelemetry] = useState({ speed: 0, vspeed: 0, altitude: 0, pos: [0, 0, 0] as const }); + + useEffect(() => { + const player = ctx.scene.entity.get(ctx.player.userId); + if (player === null) return; + const pos = player.position as readonly [number, number, number]; + const ground = ctx.world.groundHeightAt(pos[0], pos[2]); + const altitude = pos[1] - ground; + const now = performance.now(); + let speed = 0; + let vspeed = 0; + if (prev.current !== null) { + const dt = (now - prev.current.time) / 1000; + if (dt > 0.001) { + const dx = pos[0] - prev.current.pos[0]; + const dy = pos[1] - prev.current.pos[1]; + const dz = pos[2] - prev.current.pos[2]; + speed = Math.hypot(dx, dy, dz) / dt; + vspeed = dy / dt; + } + } + prev.current = { pos, time: now }; + setTelemetry({ speed, vspeed, altitude, pos: [pos[0], pos[1], pos[2]] }); + // tick is dependency to re-run every 12hz + void tick; + }, [ctx, tick]); + + return telemetry; +} + +function FlightHud() { + const telemetry = usePlayerTelemetry(); + const pos = telemetry.pos; + return ( +

+
+
FLIGHT LAB
+
+
W/A/S/D — yaw-relative strafe (A left +X, D right -X)
+
Space — ascend  |  Ctrl/C — descend  |  Shift — sprint ×2.2
+
Runway Z ±100 (crate every 20m) · Gates at 12/18/24m · Pillars X=±40 at 10/20/30/50/80/100m
+
Grid 30m · Wall at Z=150 · Terrain hills ±12m · Center flat 55m
+
+
+
+
+
POS
+
{pos[0].toFixed(1)}, {pos[1].toFixed(1)}, {pos[2].toFixed(1)}
+
ALT
+
{telemetry.altitude.toFixed(1)} m
+
SPEED
+
{telemetry.speed.toFixed(1)} m/s · {(telemetry.speed * 3.6).toFixed(0)} km/h
+
V-SPEED
+
{telemetry.vspeed.toFixed(1)} m/s
+
+
+
+ ); +} + +export function GameUI() { + const layout = useHudLayout({ storageKey: "flight-lab" }); + const status = useGameStore((ctx) => { + // outcome is global, but also subscribe to ctx version to re-render on win + void ctx.version(); + return outcome.get(); + }); + + const tone = status.won + ? "bg-emerald-600/90 text-white" + : status.tone === "warn" + ? "bg-amber-600/90 text-white" + : "bg-slate-800/85 text-slate-100"; + return ( + <> + + + {status.message !== null ? ( +
+ {status.message} +
+ ) : null} + + ); +} diff --git a/Games/flight-demo/src/world.ts b/Games/flight-demo/src/world.ts new file mode 100644 index 000000000..ca9ef9cd1 --- /dev/null +++ b/Games/flight-demo/src/world.ts @@ -0,0 +1,29 @@ +import type { PhysicsConfig } from "@jgengine/core/game/defineGame"; +import { sky, terrain } from "@jgengine/core/world/features"; +import { environment } from "@jgengine/shell/gameKit"; + +export const physics: PhysicsConfig = { gravity: -24 }; + +export const world = environment({ + terrain: terrain({ + bounds: { w: 600, d: 600 }, + height: 14, + seed: "flight-lab", + colors: { low: "#5d7a3a", high: "#c8e6a0", waterline: "#7a8a5a" }, + detail: { detailScale: 3.2, macroScale: 64, strength: 0.85, rockSlopeStart: 0.45 }, + segments: 128, + flatten: [ + { center: [0, 0], radius: 55, falloff: 22 }, + { center: [0, -120], radius: 22, falloff: 14 }, + { center: [0, 120], radius: 22, falloff: 14 }, + ], + }), + sky: sky({ + preset: "day", + horizonColor: "#a6c6e0", + zenithColor: "#5f83b8", + sunIntensity: 1.35, + ambientIntensity: 1.05, + fog: { color: "#a6c6e0", near: 280, far: 900 }, + }), +}); diff --git a/packages/core/src/game/playableGame.ts b/packages/core/src/game/playableGame.ts index 57a247ee5..de00637b1 100644 --- a/packages/core/src/game/playableGame.ts +++ b/packages/core/src/game/playableGame.ts @@ -8,7 +8,9 @@ import type { GameSettingsConfig } from "../settings/settingsModel"; import type { GameOrientation } from "../ui/orientation"; import type { HudPlatform, HudViewportConfig } from "../ui/hudScale"; import type { PositionedPrompt } from "../interaction/proximityPrompt"; +import type { ChaseCameraTuning } from "../runtime/cameraDirector"; import type { CatalogEntityRole, GameContext, GameContextContent } from "../runtime/gameContext"; +import type { FreeFlightBindings } from "../movement/freeFlight"; import type { ModelDims } from "../scene/assetCatalog"; import type { PartMotionParams, PartRole } from "./partAnimation"; import type { CollisionMeshData } from "../scene/collisionMesh"; @@ -315,6 +317,34 @@ export interface BackdropConfig { fog?: BackdropFogConfig; } +/** Free-flight families moved into the walk controller so one seam covers ground + air — creative/spectator/noclip/hover are weightless, aircraft/rotorcraft stay on `flightDynamics`. */ +export interface FlightConfig { + /** Which free-flight family to use when flight is active. Default "creative". */ + mode?: "creative" | "spectator" | "noclip" | "hover"; + /** Horizontal base speed (units/s). Default 8. */ + speed?: number; + /** Vertical base speed for ascend/descend. Default = `speed`. */ + verticalSpeed?: number; + /** Sprint multiplier while shift is held. Default 1.8. */ + sprintMultiplier?: number; + /** Lerp rate toward target velocity. Default 20. */ + acceleration?: number; + /** Downward acceleration for hover mode. Default 0 (weightless). */ + gravity?: number; + /** Upward thrust while ascending in hover. Default 32. */ + thrust?: number; + /** When true, W moves along the look pitch (6DOF spectator). Default false. */ + alignWithLook?: boolean; + /** Whether flight slides against world solids. Default true for creative/hover, false for spectator/noclip. */ + collide?: boolean; + /** Axis bindings for this flight — which actions drive each axis. Unlisted axes keep the defaults above. */ + bindings?: FreeFlightBindings; + /** Camera POV while this flight is active — chase tuning overlay applied on mount, cleared on dismount. */ + camera?: ChaseCameraTuning | null; + /** Gate flight behind live game state — creative toggle, stamina check, etc. `false` stays walking. Default true when `flight` is present. */ + canFly?: (ctx: GameContext) => boolean; +} + /** Movement-control levers for the shell-driven local player walk controller. */ export interface PlayerMovementConfig { /** "free" (default) moves camera-relative across the plane; "axis" locks travel to one world axis; "grid" snaps each committed position to cell centers. */ @@ -345,6 +375,15 @@ export interface PlayerMovementConfig { swim?: { speedMultiplier?: number } | boolean; /** Slide the player downhill on terrain steeper than they can stand on (heightfield worlds only). `true` uses defaults; default off. */ slopeSlide?: { maxClimbSlope?: number } | boolean; + /** + * Free-flight — creative/spectator/noclip/hover — folded into the same walk controller so a game + * doesn't hand-roll a second movement loop to get Minecraft-like flight. `true` uses creative defaults; + * an object tunes speed/vertical/sprint/collide/canFly. Horizontal is always yaw-relative strafe + * (A = left, D = right — never roll/bank), vertical is Space/Ctrl, and aircraft/rotorcraft remain + * on `physics/flightDynamics`. The shell drives this automatically when present; `canFly` toggles + * between walking and flying per tick without rebuilding the config. + */ + flight?: FlightConfig | boolean; } /** One frame's movement resolution handed to `PlayerMovementConfig.beforeCommit`. */ diff --git a/packages/core/src/movement/freeFlight.test.ts b/packages/core/src/movement/freeFlight.test.ts new file mode 100644 index 000000000..48aff0f8c --- /dev/null +++ b/packages/core/src/movement/freeFlight.test.ts @@ -0,0 +1,203 @@ +import { describe, expect, test } from "bun:test"; + +import { + advanceFreeFlight, + createFreeFlightController, + createFreeFlightState, + resolveFreeFlightIntent, + resolveFlightStep, +} from "./freeFlight"; +import { createEmptyMovementKeys } from "./movementModel"; + +const DT = 1 / 60; + +function keysFrom(held: string[]): ReturnType { + const k = createEmptyMovementKeys(); + for (const h of held) { + if (h === "moveForward" || h === "w") k.w = true; + if (h === "moveBack" || h === "s") k.s = true; + if (h === "moveLeft" || h === "a") k.a = true; + if (h === "moveRight" || h === "d") k.d = true; + if (h === "jump" || h === "space") k.space = true; + if (h === "crouch" || h === "control" || h === "c") { k.control = true; k.c = true; } + if (h === "sprint" || h === "shift") k.shift = true; + } + return k; +} + +describe("resolveFreeFlightIntent", () => { + test("WASD maps to forward/right with correct strafe sign", () => { + const a = resolveFreeFlightIntent(keysFrom(["a"]), null); + expect(a.right).toBe(-1); + expect(a.forward).toBe(0); + expect(a.vertical).toBe(0); + const d = resolveFreeFlightIntent(keysFrom(["d"]), null); + expect(d.right).toBe(1); + const w = resolveFreeFlightIntent(keysFrom(["w"]), null); + expect(w.forward).toBe(1); + const s = resolveFreeFlightIntent(keysFrom(["s"]), null); + expect(s.forward).toBe(-1); + }); + + test("space ascends, control descends", () => { + const up = resolveFreeFlightIntent(keysFrom(["space"]), null); + expect(up.vertical).toBe(1); + const down = resolveFreeFlightIntent(keysFrom(["control"]), null); + expect(down.vertical).toBe(-1); + const both = resolveFreeFlightIntent(keysFrom(["space", "control"]), null); + expect(both.vertical).toBe(0); + }); +}); + +describe("advanceFreeFlight creative", () => { + test("A moves +X when facing +Z (south), D moves -X — strafe stays yaw-relative, never roll", () => { + const tuning = { mode: "creative" as const, speed: 8, acceleration: 40 }; + const yaw = 0; + let state = createFreeFlightState(); + const aIntent = resolveFreeFlightIntent(keysFrom(["a"]), null); + let step = advanceFreeFlight(state, aIntent, yaw, 0, DT, tuning); + for (let i = 0; i < 20; i++) step = advanceFreeFlight(state, aIntent, yaw, 0, DT, tuning); + expect(step.stepX).toBeGreaterThan(0); + expect(Math.abs(step.stepZ)).toBeLessThan(0.01); + + state = createFreeFlightState(); + const dIntent = resolveFreeFlightIntent(keysFrom(["d"]), null); + step = advanceFreeFlight(state, dIntent, yaw, 0, DT, tuning); + for (let i = 0; i < 20; i++) step = advanceFreeFlight(state, dIntent, yaw, 0, DT, tuning); + expect(step.stepX).toBeLessThan(0); + }); + + test("W moves +Z, S moves -Z when yaw 0", () => { + const tuning = { mode: "creative" as const, speed: 8, acceleration: 40 }; + const yaw = 0; + let state = createFreeFlightState(); + const wIntent = resolveFreeFlightIntent(keysFrom(["w"]), null); + let step = advanceFreeFlight(state, wIntent, yaw, 0, DT, tuning); + for (let i = 0; i < 20; i++) step = advanceFreeFlight(state, wIntent, yaw, 0, DT, tuning); + expect(step.stepZ).toBeGreaterThan(0); + expect(Math.abs(step.stepX)).toBeLessThan(0.01); + + state = createFreeFlightState(); + const sIntent = resolveFreeFlightIntent(keysFrom(["s"]), null); + step = advanceFreeFlight(state, sIntent, yaw, 0, DT, tuning); + for (let i = 0; i < 20; i++) step = advanceFreeFlight(state, sIntent, yaw, 0, DT, tuning); + expect(step.stepZ).toBeLessThan(0); + }); + + test("W with yaw 90° (east) moves +X, A strafe is still yaw-relative", () => { + const tuning = { mode: "creative" as const, speed: 8, acceleration: 40 }; + const yaw = Math.PI / 2; + let state = createFreeFlightState(); + const wIntent = resolveFreeFlightIntent(keysFrom(["w"]), null); + let step = advanceFreeFlight(state, wIntent, yaw, 0, DT, tuning); + for (let i = 0; i < 20; i++) step = advanceFreeFlight(state, wIntent, yaw, 0, DT, tuning); + expect(step.stepX).toBeGreaterThan(0); + expect(Math.abs(step.stepZ)).toBeLessThan(0.02); + + state = createFreeFlightState(); + const aIntent = resolveFreeFlightIntent(keysFrom(["a"]), null); + step = advanceFreeFlight(state, aIntent, yaw, 0, DT, tuning); + for (let i = 0; i < 20; i++) step = advanceFreeFlight(state, aIntent, yaw, 0, DT, tuning); + expect(step.stepZ).toBeLessThan(0); + }); + + test("space ascends, crouch descends, vertical independent of yaw", () => { + const tuning = { mode: "creative" as const, speed: 8, acceleration: 40 }; + const yaw = 0.7; + let state = createFreeFlightState(); + const up = resolveFreeFlightIntent(keysFrom(["space"]), null); + let step = advanceFreeFlight(state, up, yaw, 0, DT, tuning); + for (let i = 0; i < 20; i++) step = advanceFreeFlight(state, up, yaw, 0, DT, tuning); + expect(step.stepY).toBeGreaterThan(0); + expect(Math.abs(step.stepX)).toBeLessThan(0.01); + + state = createFreeFlightState(); + const down = resolveFreeFlightIntent(keysFrom(["control"]), null); + step = advanceFreeFlight(state, down, yaw, 0, DT, tuning); + for (let i = 0; i < 20; i++) step = advanceFreeFlight(state, down, yaw, 0, DT, tuning); + expect(step.stepY).toBeLessThan(0); + }); + + test("sprint multiplies speed, diagonal stays normalized", () => { + const tuning = { mode: "creative" as const, speed: 8, sprintMultiplier: 2, acceleration: 40 }; + const yaw = 0; + let state = createFreeFlightState(); + const w = resolveFreeFlightIntent(keysFrom(["w"]), null); + let step = advanceFreeFlight(state, w, yaw, 0, DT, tuning); + for (let i = 0; i < 30; i++) step = advanceFreeFlight(state, w, yaw, 0, DT, tuning); + const walkZ = step.stepZ; + + state = createFreeFlightState(); + const wSprint = resolveFreeFlightIntent(keysFrom(["w", "shift"]), null); + step = advanceFreeFlight(state, wSprint, yaw, 0, DT, tuning); + for (let i = 0; i < 30; i++) step = advanceFreeFlight(state, wSprint, yaw, 0, DT, tuning); + expect(step.stepZ).toBeGreaterThan(walkZ * 1.8); + + state = createFreeFlightState(); + const diag = resolveFreeFlightIntent(keysFrom(["w", "d"]), null); + step = advanceFreeFlight(state, diag, yaw, 0, DT, tuning); + for (let i = 0; i < 30; i++) step = advanceFreeFlight(state, diag, yaw, 0, DT, tuning); + const diagSpeed = Math.hypot(step.stepX, step.stepZ) / DT; + expect(diagSpeed).toBeLessThan(8.1); + expect(diagSpeed).toBeGreaterThan(7.5); + }); + + test("spectator alignWithLook: forward along pitch climbs", () => { + const tuning = { mode: "spectator" as const, speed: 8, acceleration: 40, alignWithLook: true }; + const yaw = 0; + const pitch = Math.PI / 4; + let state = createFreeFlightState(); + const w = resolveFreeFlightIntent(keysFrom(["w"]), null); + let step = advanceFreeFlight(state, w, yaw, pitch, DT, tuning); + for (let i = 0; i < 30; i++) step = advanceFreeFlight(state, w, yaw, pitch, DT, tuning); + expect(step.stepY).toBeGreaterThan(0.05); + expect(step.stepZ).toBeGreaterThan(0.05); + }); + + test("hover with gravity falls when no input, rises when ascending", () => { + const tuning = { mode: "hover" as const, speed: 6, gravity: 20, thrust: 40, acceleration: 20 }; + let state = createFreeFlightState(); + const idle = resolveFreeFlightIntent(keysFrom([]), null); + let step = advanceFreeFlight(state, idle, 0, 0, DT, tuning); + for (let i = 0; i < 60; i++) step = advanceFreeFlight(state, idle, 0, 0, DT, tuning); + expect(step.stepY).toBeLessThan(0); + + state = createFreeFlightState(); + const up = resolveFreeFlightIntent(keysFrom(["space"]), null); + step = advanceFreeFlight(state, up, 0, 0, DT, tuning); + for (let i = 0; i < 60; i++) step = advanceFreeFlight(state, up, 0, 0, DT, tuning); + expect(step.stepY).toBeGreaterThan(0); + }); +}); + +describe("createFreeFlightController", () => { + test("snapshot/restore round-trip and retune changes speed", () => { + const ctrl = createFreeFlightController({ mode: "creative", speed: 8 }); + const intent = resolveFreeFlightIntent(keysFrom(["w"]), null); + ctrl.tick(DT, intent, 0); + for (let i = 0; i < 10; i++) ctrl.tick(DT, intent, 0); + const snap = ctrl.snapshot(); + expect(snap.vx).toBeDefined(); + ctrl.tick(DT, intent, 0); + ctrl.restore(snap); + expect(ctrl.snapshot()).toEqual(snap); + ctrl.retune({ mode: "creative", speed: 16 }); + let s = ctrl.snapshot(); + expect(s).toEqual(snap); + const stepFast = ctrl.tick(DT, intent, 0); + for (let i = 0; i < 20; i++) ctrl.tick(DT, intent, 0); + const fast = ctrl.velocity(); + expect(Math.hypot(fast[0], fast[2])).toBeGreaterThan(7); + expect(stepFast.stepX).toBeDefined(); + ctrl.reset(); + expect(ctrl.snapshot().vx).toBe(0); + }); + + test("resolveFlightStep clamps vertical ceiling", () => { + const pos: [number, number, number] = [0, 1, 0]; + const stepY = 2; + const obstacles = [{ position: [0, 3, 0] as const, halfExtents: [1, 0.5, 1] as const }]; + const res = resolveFlightStep(pos, 0, stepY, 0, obstacles, 0.3); + expect(res.stepY).toBeLessThan(stepY); + }); +}); diff --git a/packages/core/src/movement/freeFlight.ts b/packages/core/src/movement/freeFlight.ts new file mode 100644 index 000000000..100af04ca --- /dev/null +++ b/packages/core/src/movement/freeFlight.ts @@ -0,0 +1,404 @@ +import { MOVEMENT_TUNING, type AnalogMoveIntent, type CollisionObstacle, type MovementKeysState } from "./movementModel"; +import { DEFAULT_OBSTACLE_PLAYER_RADIUS } from "./movementModel"; +import { sampleAxisBindings, type AxisBinding } from "../input/axisInput"; +import type { PointerAxisState } from "../input/pointerAxis"; +import type { ChaseCameraTuning } from "../runtime/cameraDirector"; + +/** Free-flight families — character-scale 6DOF movement, distinct from vehicle aerodynamics. */ +export type FreeFlightMode = "creative" | "spectator" | "noclip" | "hover"; + +/** Per-axis bindings for free-flight — which actions drive each flight axis. Positive is forward/right/up. */ +export interface FreeFlightBindings { + /** Forward/back — default `moveForward` / `moveBack` (W/S). */ + forward?: AxisBinding; + /** Strafe right/left — default `moveRight` / `moveLeft` (D/A). */ + strafe?: AxisBinding; + /** Ascend/descend — default `jump` / `crouch` (Space / Ctrl+C). */ + vertical?: AxisBinding; + /** Sprint — default `sprint` (Shift) held. */ + sprint?: AxisBinding; +} + +/** Data-first tuning for one free-flight profile. */ +export interface FreeFlightTuning { + mode: FreeFlightMode; + /** Horizontal base speed (units/s) before sprint. Default 8. */ + speed?: number; + /** Vertical base speed for ascend/descend. Default = `speed`. */ + verticalSpeed?: number; + /** Sprint multiplier while shift is held. Default 1.8. */ + sprintMultiplier?: number; + /** Lerp rate toward target velocity. Default 20. */ + acceleration?: number; + /** Extra damping applied when no input (0 = rely on lerp). Default 0. */ + damping?: number; + /** Downward acceleration for hover mode. Default 0 (weightless). */ + gravity?: number; + /** Upward thrust acceleration while ascending in hover. Default 32. */ + thrust?: number; + /** When true, forward moves along the look pitch (spectator). Default false. */ + alignWithLook?: boolean; + /** Top vertical fall speed in hover. Default 20. */ + maxFallSpeed?: number; + /** Whether to slide against world solids. Default true for creative/hover, false for spectator/noclip. */ + collide?: boolean; + /** Axis bindings for this flight profile — which actions drive each axis. Unlisted axes keep the defaults above. */ + bindings?: FreeFlightBindings; + /** Camera POV while this flight is active — chase tuning overlay applied on mount, cleared on dismount. Null clears. */ + camera?: ChaseCameraTuning | null; +} + +/** Velocity state for a free-flight actor — serializable and ownable by the caller. */ +export interface FreeFlightState { + vx: number; + vy: number; + vz: number; +} + +/** Intent for one free-flight tick — forward/right from WASD/analog, vertical from jump/crouch. */ +export interface FreeFlightIntent { + forward: number; + right: number; + vertical: number; + sprint: boolean; + moving: boolean; +} + +/** World displacement produced by one free-flight tick. */ +export interface FreeFlightStep { + stepX: number; + stepY: number; + stepZ: number; +} + +/** Stateful handle for any free-flight actor with snapshot/restore/retune. */ +export interface FreeFlightController { + tick(dt: number, intent: FreeFlightIntent, yaw: number, pitch?: number): FreeFlightStep; + velocity(): readonly [number, number, number]; + snapshot(): FreeFlightState; + restore(next: FreeFlightState): void; + retune(next: FreeFlightTuning): void; + reset(): void; +} + +const ANALOG_EPSILON = 0.02; + +/** Create an empty flight velocity state. */ +export function createFreeFlightState(): FreeFlightState { + return { vx: 0, vy: 0, vz: 0 }; +} + +const DEFAULT_FORWARD_BINDING: AxisBinding = { positive: ["moveForward"], negative: ["moveBack"] }; +const DEFAULT_STRAFE_BINDING: AxisBinding = { positive: ["moveRight"], negative: ["moveLeft"] }; +const DEFAULT_VERTICAL_BINDING: AxisBinding = { positive: ["jump"], negative: ["crouch", "control", "c"] }; +const DEFAULT_SPRINT_BINDING: AxisBinding = { positive: ["sprint"] }; + +/** + * Translate held keys + analog into a free-flight intent. + * Vertical is `space - (control|c)` so jump ascends and crouch descends — the same + * keys that walk/jump/crouch use, re-mapped for flight without re-binding. + */ +export function resolveFreeFlightIntent( + keys: MovementKeysState, + analog: AnalogMoveIntent | null | undefined, +): FreeFlightIntent { + let forward = (keys.w ? 1 : 0) - (keys.s ? 1 : 0); + let right = (keys.d ? 1 : 0) - (keys.a ? 1 : 0); + if (analog !== undefined && analog !== null) { + forward = Math.abs(analog.forward) < ANALOG_EPSILON ? 0 : Math.max(-1, Math.min(1, analog.forward)); + right = Math.abs(analog.right) < ANALOG_EPSILON ? 0 : Math.max(-1, Math.min(1, analog.right)); + } + const vertical = (keys.space ? 1 : 0) - (keys.control || keys.c ? 1 : 0); + const sprint = keys.shift && vertical >= 0; + const moving = forward !== 0 || right !== 0 || vertical !== 0; + return { forward, right, vertical, sprint, moving }; +} + +/** + * Translate live input (held actions + analog + pointer) into a flight intent using + * per-profile axis bindings. Unlisted bindings fall back to the defaults above so + * existing games keep WASD/Space/Ctrl/Shift without declaring anything. + */ +export function resolveFreeFlightIntentFromInput( + isDown: (action: string) => boolean, + value: (action: string) => number, + pointer: PointerAxisState | null, + bindings?: FreeFlightBindings, +): FreeFlightIntent { + const forwardBinding = bindings?.forward ?? DEFAULT_FORWARD_BINDING; + const strafeBinding = bindings?.strafe ?? DEFAULT_STRAFE_BINDING; + const verticalBinding = bindings?.vertical ?? DEFAULT_VERTICAL_BINDING; + const sprintBinding = bindings?.sprint ?? DEFAULT_SPRINT_BINDING; + const forward = sampleAxisBindings({ forward: forwardBinding }, isDown, pointer, undefined, value).forward; + const right = sampleAxisBindings({ strafe: strafeBinding }, isDown, pointer, undefined, value).strafe; + const vertical = sampleAxisBindings({ vertical: verticalBinding }, isDown, pointer, undefined, value).vertical; + const sprintVal = sampleAxisBindings({ sprint: sprintBinding }, isDown, pointer, { sprint: { min: 0, max: 1 } }, value).sprint; + const sprint = sprintVal > 0.5; + const moving = forward !== 0 || right !== 0 || vertical !== 0; + return { forward, right, vertical, sprint, moving }; +} + +/** + * Advance one frame of free-flight kinematics. + * Horizontal uses yaw-relative strafe (A = left, D = right) — never roll/bank. + * Vertical is independent of pitch unless `alignWithLook` is true (spectator). + */ +export function advanceFreeFlight( + state: FreeFlightState, + intent: FreeFlightIntent, + yaw: number, + pitch: number | undefined, + dt: number, + tuning: FreeFlightTuning, +): FreeFlightStep { + const delta = Math.min(dt, MOVEMENT_TUNING.maxFrameSeconds); + if (delta <= 0) return { stepX: 0, stepY: 0, stepZ: 0 }; + const speed = tuning.speed ?? 8; + const verticalSpeed = tuning.verticalSpeed ?? speed; + const sprintMult = tuning.sprintMultiplier ?? 1.8; + const accel = tuning.acceleration ?? 20; + const damping = tuning.damping ?? 0; + const gravity = tuning.gravity ?? 0; + const thrust = tuning.thrust ?? 32; + const maxFall = tuning.maxFallSpeed ?? 20; + const align = tuning.alignWithLook === true && pitch !== undefined; + + const sprintScale = intent.sprint ? sprintMult : 1; + + let targetX = 0; + let targetZ = 0; + let targetY = 0; + + if (align) { + const cp = Math.cos(pitch!); + const sp = Math.sin(pitch!); + const sy = Math.sin(yaw); + const cy = Math.cos(yaw); + const fx = sy * cp; + const fy = sp; + const fz = cy * cp; + const rx = cy; + const rz = -sy; + const rawX = fx * intent.forward + rx * intent.right; + const rawY = fy * intent.forward; + const rawZ = fz * intent.forward + rz * intent.right; + const hLen = Math.hypot(rawX, rawY, rawZ); + if (hLen > 1e-6) { + const scale = Math.min(1, hLen) / hLen; + const sx = rawX * scale; + const sy2 = rawY * scale; + const sz = rawZ * scale; + const horizScale = speed * sprintScale; + const vertScale = verticalSpeed * sprintScale; + targetX = sx * horizScale; + targetY = sy2 * horizScale + intent.vertical * vertScale; + targetZ = sz * horizScale; + } else { + targetY = intent.vertical * verticalSpeed * sprintScale; + } + } else { + const sy = Math.sin(yaw); + const cy = Math.cos(yaw); + let fx = sy; + let fz = cy; + const lenSq = fx * fx + fz * fz; + if (lenSq < 1e-6) { + fx = 0; + fz = -1; + } else { + const inv = 1 / Math.sqrt(lenSq); + fx *= inv; + fz *= inv; + } + const rx = -fz; + const rz = fx; + let rawX = fx * intent.forward + rx * intent.right; + let rawZ = fz * intent.forward + rz * intent.right; + const len = Math.hypot(rawX, rawZ); + if (len > 1e-6) { + const scale = Math.min(1, len) / len; + rawX *= scale; + rawZ *= scale; + } else { + rawX = 0; + rawZ = 0; + } + targetX = rawX * speed * sprintScale; + targetZ = rawZ * speed * sprintScale; + targetY = intent.vertical * verticalSpeed * sprintScale; + } + + if (gravity > 0) { + const alpha = 1 - Math.exp(-accel * delta); + state.vx += (targetX - state.vx) * alpha; + state.vz += (targetZ - state.vz) * alpha; + if (damping > 0 && !intent.moving) { + const damp = Math.exp(-damping * delta); + state.vx *= damp; + state.vz *= damp; + } + const thrustAccel = intent.vertical * thrust; + state.vy += (thrustAccel - gravity) * delta; + if (intent.vertical !== 0) { + const vyTarget = targetY; + const vyAlpha = 1 - Math.exp(-accel * delta); + state.vy += (vyTarget - state.vy) * vyAlpha * 0.5; + } + if (damping > 0 && intent.vertical === 0) { + state.vy *= Math.exp(-damping * delta * 0.5); + } + if (state.vy < -maxFall) state.vy = -maxFall; + } else { + const alpha = 1 - Math.exp(-accel * delta); + state.vx += (targetX - state.vx) * alpha; + state.vy += (targetY - state.vy) * alpha; + state.vz += (targetZ - state.vz) * alpha; + if (damping > 0 && !intent.moving) { + const damp = Math.exp(-damping * delta); + state.vx *= damp; + state.vy *= damp; + state.vz *= damp; + } + } + + return { stepX: state.vx * delta, stepY: state.vy * delta, stepZ: state.vz * delta }; +} + +/** Resolve a flight step against world solids (XZ slide + Y clamp). */ +export function resolveFlightStep( + position: readonly [number, number, number], + stepX: number, + stepY: number, + stepZ: number, + obstacles: readonly CollisionObstacle[], + radius: number = DEFAULT_OBSTACLE_PLAYER_RADIUS, +): { stepX: number; stepY: number; stepZ: number } { + const feetY = position[1]; + const headY = feetY + 1.8; + let clampedY = stepY; + const nextY = feetY + stepY; + const nextHead = nextY + 1.8; + for (const ob of obstacles) { + const ox = ob.position[0] + (ob.offset?.[0] ?? 0); + const oy = ob.position[1] + (ob.offset?.[1] ?? 0); + const oz = ob.position[2] + (ob.offset?.[2] ?? 0); + const hx = ob.halfExtents?.[0] ?? 0.5; + const hy = ob.halfExtents?.[1] ?? 0.5; + const hz = ob.halfExtents?.[2] ?? 0.5; + if (ob.boxes !== undefined && ob.boxes.length > 0) { + for (const box of ob.boxes) { + const minX = ox + box.min[0] - radius; + const maxX = ox + box.max[0] + radius; + const minY = oy + box.min[1]; + const maxY = oy + box.max[1]; + const minZ = oz + box.min[2] - radius; + const maxZ = oz + box.max[2] + radius; + const insideXZ = position[0] > minX && position[0] < maxX && position[2] > minZ && position[2] < maxZ; + if (!insideXZ) continue; + if (stepY > 0 && feetY < maxY && nextHead > minY && nextHead > maxY && feetY < minY) { + clampedY = Math.min(clampedY, minY - headY - 1e-3); + } else if (stepY < 0 && headY > minY && nextY < maxY && nextY < minY && headY > maxY) { + clampedY = Math.max(clampedY, maxY - feetY + 1e-3); + } + } + continue; + } + const minX = ox - hx - radius; + const maxX = ox + hx + radius; + const minY = oy - hy; + const maxY = oy + hy; + const minZ = oz - hz - radius; + const maxZ = oz + hz + radius; + const insideXZ = position[0] > minX && position[0] < maxX && position[2] > minZ && position[2] < maxZ; + if (!insideXZ) continue; + if (stepY > 0 && feetY < maxY && nextHead > minY && nextHead > maxY && feetY < minY) { + clampedY = Math.min(clampedY, minY - headY - 1e-3); + } else if (stepY < 0 && headY > minY && nextY < maxY && nextY < minY && headY > maxY) { + clampedY = Math.max(clampedY, maxY - feetY + 1e-3); + } + } + return { stepX, stepY: clampedY, stepZ }; +} + +/** + * Totally free flight for any actor — character, camera, drone, or debug rig. + * Horizontal is always yaw-relative strafe; vertical is jump/crouch. Use `alignWithLook` + * for a 6DOF spectator that flies where the camera looks. + * @capability free-flight camera-relative creative/spectator/noclip/hover flight with correct strafe, sprint, and optional gravity + */ +export function createFreeFlightController( + initialTuning: FreeFlightTuning, + options: { yaw?: number; pitch?: number; state?: FreeFlightState } = {}, +): FreeFlightController { + let tuning = initialTuning; + const state: FreeFlightState = options.state !== undefined ? { ...options.state } : createFreeFlightState(); + let yaw = options.yaw ?? 0; + let pitch = options.pitch ?? 0; + + return { + tick(dt, intent, nextYaw, nextPitch) { + yaw = nextYaw; + if (nextPitch !== undefined) pitch = nextPitch; + return advanceFreeFlight(state, intent, yaw, pitch, dt, tuning); + }, + velocity: () => [state.vx, state.vy, state.vz], + snapshot: () => ({ ...state }), + restore(next) { + state.vx = next.vx; + state.vy = next.vy; + state.vz = next.vz; + }, + retune(next) { + tuning = next; + }, + reset() { + state.vx = 0; + state.vy = 0; + state.vz = 0; + }, + }; +} + +/** Preset for Minecraft-like creative flight — weightless, collides, yaw-relative strafe. */ +export const CREATIVE_FLIGHT_TUNING: FreeFlightTuning = { + mode: "creative", + speed: 8, + verticalSpeed: 8, + sprintMultiplier: 2.2, + acceleration: 22, + collide: true, +}; + +/** Preset for spectator — weightless, noclips, flies where the camera looks. */ +export const SPECTATOR_FLIGHT_TUNING: FreeFlightTuning = { + mode: "spectator", + speed: 12, + verticalSpeed: 12, + sprintMultiplier: 2.5, + acceleration: 18, + collide: false, + alignWithLook: true, +}; + +/** Preset for noclip — weightless, noclips, yaw-relative with independent vertical. */ +export const NOCLIP_FLIGHT_TUNING: FreeFlightTuning = { + mode: "noclip", + speed: 14, + verticalSpeed: 14, + sprintMultiplier: 3, + acceleration: 18, + collide: false, + alignWithLook: false, +}; + +/** Preset for hover/jetpack — gravity + thrust, useful for short bursts. */ +export const HOVER_FLIGHT_TUNING: FreeFlightTuning = { + mode: "hover", + speed: 6, + verticalSpeed: 6, + sprintMultiplier: 1.6, + acceleration: 16, + gravity: 18, + thrust: 34, + collide: true, +}; diff --git a/packages/core/src/movement/playerMovement.ts b/packages/core/src/movement/playerMovement.ts index 07aab703b..4c6a5b5f5 100644 --- a/packages/core/src/movement/playerMovement.ts +++ b/packages/core/src/movement/playerMovement.ts @@ -21,6 +21,14 @@ import { type MovementTuningOverrides, type PlayerMotionState, } from "./movementModel"; +import { + advanceFreeFlight, + createFreeFlightState, + resolveFlightStep, + resolveFreeFlightIntentFromInput, + type FreeFlightState, + type FreeFlightTuning, +} from "./freeFlight"; import { solidObstaclesNear } from "./solidObstacles"; import { approachYaw, steerYaw } from "./steering"; import { @@ -91,6 +99,7 @@ interface PlayerMovementState { facing: number | null; voxelBody: VoxelPlayerBody | null; motion: PlayerMotionState | null; + flight: FreeFlightState | null; } interface CtxMovementStore { @@ -115,7 +124,7 @@ function storeFor(ctx: GameContext): CtxMovementStore { function stateFor(store: CtxMovementStore, userId: string): PlayerMovementState { let state = store.players.get(userId); if (state === undefined) { - state = { heading: 0, facing: null, voxelBody: null, motion: null }; + state = { heading: 0, facing: null, voxelBody: null, motion: null, flight: null }; store.players.set(userId, state); } return state; @@ -159,13 +168,40 @@ function resolveBodyFacing( return next; } +function flightTuningFor( + movement: PlayerMovementConfig | undefined, + ctx: GameContext, +): FreeFlightTuning | null { + const cfg = movement?.flight; + if (cfg === undefined || cfg === false) return null; + if (cfg === true) return { mode: "creative" }; + if (typeof cfg === "object") { + if (cfg.canFly !== undefined && !cfg.canFly(ctx)) return null; + return { + mode: cfg.mode ?? "creative", + speed: cfg.speed, + verticalSpeed: cfg.verticalSpeed, + sprintMultiplier: cfg.sprintMultiplier, + acceleration: cfg.acceleration, + gravity: cfg.gravity, + thrust: cfg.thrust, + alignWithLook: cfg.alignWithLook, + collide: cfg.collide, + bindings: cfg.bindings, + camera: cfg.camera ?? null, + }; + } + return null; +} + /** * Integrate one player's movement for a tick from their held-input frame and commit the pose — the single * genre-agnostic controller both the shell (its local player) and a host (each connected player in `onTick`) call, * so single-player and server-authoritative movement are identical. Reads the player's controlled entity, terrain, * scene solids, and pending motion impulses; writes the entity pose via `setPose`. Retains heading + kinematic body * per `userId` on the `ctx`. Pass `heading` to override the internally-integrated yaw (the shell owns yaw for its - * camera); omit it and the controller turns from the frame's `turnLeft`/`turnRight` actions. + * camera); omit it and the controller turns from the frame's `turnLeft`/`turnRight` actions. Pass `pitch` for + * 6DOF spectator flight when `alignWithLook` is set. */ export function stepPlayerMovement( ctx: GameContext, @@ -174,6 +210,7 @@ export function stepPlayerMovement( dt: number, tuning: PlayerMovementTuning, heading?: number, + pitch?: number, ): void { const playerId = ctx.player.possession.active(userId); const player = ctx.scene.entity.get(playerId); @@ -219,6 +256,103 @@ export function stepPlayerMovement( const motionBatch = ctx.player.motionFor(userId).takePending(); const walkSpeed = player.movement?.walkSpeed ?? DEFAULT_WALK_SPEED; + const flightTuning = flightTuningFor(tuning.movement, ctx); + if (flightTuning !== null) { + const value = (action: string): number => input.analog?.[action] ?? (isDown(action) ? 1 : 0); + let flightIntent = resolveFreeFlightIntentFromInput(isDown, value, input.pointer, flightTuning.bindings); + if (tuning.movement?.canSprint !== undefined && !tuning.movement.canSprint(ctx)) { + flightIntent = { ...flightIntent, sprint: false }; + } + let flightState = state.flight; + if (flightState === null) { + flightState = createFreeFlightState(); + state.flight = flightState; + } + if (motionBatch !== null) { + flightState.vy = applyMotionImpulses(flightState.vy, motionBatch); + const [hx, hz] = applyHorizontalImpulses(flightState.vx, flightState.vz, motionBatch); + flightState.vx = hx; + flightState.vz = hz; + } + const normalizedFlightTuning: FreeFlightTuning = { + mode: flightTuning.mode, + speed: flightTuning.speed, + verticalSpeed: flightTuning.verticalSpeed, + sprintMultiplier: flightTuning.sprintMultiplier, + acceleration: flightTuning.acceleration, + gravity: flightTuning.gravity, + thrust: flightTuning.thrust, + alignWithLook: flightTuning.alignWithLook, + collide: flightTuning.collide, + bindings: flightTuning.bindings, + camera: flightTuning.camera, + }; + if (normalizedFlightTuning.collide === undefined) { + normalizedFlightTuning.collide = normalizedFlightTuning.mode === "creative" || normalizedFlightTuning.mode === "hover"; + } + if (normalizedFlightTuning.camera !== undefined) { + const cam = (ctx as unknown as { camera?: { setChaseTuning: (t: unknown) => void } }).camera; + cam?.setChaseTuning(normalizedFlightTuning.camera ?? null); + } + const step = advanceFreeFlight(flightState, flightIntent, state.heading, pitch, dt, normalizedFlightTuning); + let stepX = step.stepX; + let stepY = step.stepY; + let stepZ = step.stepZ; + if (normalizedFlightTuning.collide !== false && tuning.movement?.collideObjects !== false) { + const obstacles = gatherMovementObstacles(ctx, player.position, stepX, stepZ); + const verticalResolved = resolveFlightStep(player.position, stepX, stepY, stepZ, obstacles, DEFAULT_OBSTACLE_PLAYER_RADIUS); + stepY = verticalResolved.stepY; + const xzResolved = resolveObstacleStep(player.position, stepX, stepZ, obstacles, DEFAULT_OBSTACLE_PLAYER_RADIUS, 0); + stepX = xzResolved.stepX; + stepZ = xzResolved.stepZ; + const ground = tuning.ground.sampleHeight(player.position[0] + stepX, player.position[2] + stepZ); + const nextY = player.position[1] + stepY; + if (nextY < ground + 0.2) { + stepY = ground + 0.2 - player.position[1]; + if (flightState.vy < 0) flightState.vy = 0; + } + } + let nextX = player.position[0] + stepX; + let nextY = player.position[1] + stepY; + let nextZ = player.position[2] + stepZ; + if (motionBatch !== null && motionBatch.y !== null) { + nextY = motionBatch.y; + } + if (tuning.movement?.beforeCommit !== undefined) { + const frame: MovementCommitFrame = { + entityId: playerId, + current: player.position, + next: [nextX, nextY, nextZ], + dt, + ctx, + }; + const replacement = tuning.movement.beforeCommit(frame); + if (replacement !== undefined) { + nextX = replacement[0]; + nextY = replacement[1]; + nextZ = replacement[2]; + } + } + ctx.scene.entity.setPose(playerId, { + position: [nextX, nextY, nextZ], + rotationY: resolveBodyFacing( + state, + flightIntent.moving, + flightState.vx, + flightState.vz, + player.rotationY, + tuning.movement?.turnSpeed, + dt, + ), + dt, + }); + return; + } + if (flightTuning === null && state.flight !== null) { + const cam = (ctx as unknown as { camera?: { setChaseTuning: (t: unknown) => void } }).camera; + cam?.setChaseTuning(null); + } + if (tuning.collision?.voxel === true) { let body = state.voxelBody; if (body === null) { diff --git a/packages/core/src/physics/flightDynamics.ts b/packages/core/src/physics/flightDynamics.ts index bd15615b5..2f14a5e0f 100644 --- a/packages/core/src/physics/flightDynamics.ts +++ b/packages/core/src/physics/flightDynamics.ts @@ -1,3 +1,5 @@ +import type { AxisBinding } from "../input/axisInput"; +import type { ChaseCameraTuning } from "../runtime/cameraDirector"; import { uniformGravity, type GravityField } from "./gravityField"; /** Three-dimensional world-space vector used by the flight model. */ @@ -28,6 +30,9 @@ export interface FlightControlRates { stability: number; } +/** Axes an aircraft can bind — which actions drive each flight control. */ +export type AircraftAxis = "pitch" | "roll" | "yaw" | "throttle" | "collective" | "airbrake" | "afterburner" | "vectoring"; + /** Data-first physical tuning shared by all aircraft instances of one catalog type. */ export interface AircraftTuning { kind: AircraftKind; @@ -46,6 +51,10 @@ export interface AircraftTuning { groundEffectHeight?: number; vtolTransitionSpeed?: number; groundClearance?: number; + /** Per-axis bindings for this aircraft — which actions drive each control. Unlisted axes keep the defaults (WASD + flightThrottleUp/Down etc.). */ + bindings?: Partial>; + /** Camera POV while this aircraft is piloted — chase tuning overlay applied on mount, cleared on dismount. */ + camera?: ChaseCameraTuning | null; } /** Spawn state and injectable world-field samplers for an aircraft instance. */ diff --git a/packages/core/src/physics/kinematicVehicle.ts b/packages/core/src/physics/kinematicVehicle.ts index c214108ce..c4d7af796 100644 --- a/packages/core/src/physics/kinematicVehicle.ts +++ b/packages/core/src/physics/kinematicVehicle.ts @@ -1,4 +1,5 @@ -import type { AxisInput } from "../input/axisInput"; +import type { AxisBinding, AxisInput } from "../input/axisInput"; +import type { ChaseCameraTuning } from "../runtime/cameraDirector"; import { steerYaw } from "../movement/steering"; import { DEFAULT_GRIP_CURVE, sampleGripCurve, type GripCurve } from "./vehicleBody"; @@ -49,6 +50,10 @@ export interface KinematicVehicleTuning { * Omit to preserve the direct arcade acceleration model exactly. */ chassis?: KinematicChassisTuning; + /** Per-axis bindings for this vehicle — which actions drive throttle/brake/steer/handbrake. Unlisted axes keep car defaults (W/S/A/D/Space). */ + bindings?: Partial>; + /** Camera POV while this vehicle is piloted — chase tuning overlay applied on mount, cleared on dismount. */ + camera?: ChaseCameraTuning | null; } /** diff --git a/packages/core/src/runtime/headlessRunner.ts b/packages/core/src/runtime/headlessRunner.ts index f96ea25d9..24fe0d104 100644 --- a/packages/core/src/runtime/headlessRunner.ts +++ b/packages/core/src/runtime/headlessRunner.ts @@ -36,8 +36,12 @@ export interface HeadlessRunnerOptions