Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .claude/skills/jgengine-gameplay/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
43 changes: 41 additions & 2 deletions .claude/skills/jgengine-world/api.md

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions .claude/skills/jgengine-world/capabilities.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
42 changes: 42 additions & 0 deletions Games/flight-demo/src/editor.scene.json
Original file line number Diff line number Diff line change
@@ -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 } }
]
}
43 changes: 43 additions & 0 deletions Games/flight-demo/src/game.config.ts
Original file line number Diff line number Diff line change
@@ -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,
});
95 changes: 95 additions & 0 deletions Games/flight-demo/src/game/ui/GameUI.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="pointer-events-none absolute left-3 top-3 z-30 flex flex-col gap-2 font-mono text-xs">
<div className="rounded bg-slate-900/85 px-3 py-2 text-slate-100 shadow">
<div className="font-sans text-[11px] font-bold tracking-widest text-cyan-300">FLIGHT LAB</div>
<div className="mt-1 space-y-0.5 text-[11px] leading-tight">
<div>W/A/S/D — yaw-relative strafe (A left +X, D right -X)</div>
<div>Space — ascend &nbsp;|&nbsp; Ctrl/C — descend &nbsp;|&nbsp; Shift — sprint ×2.2</div>
<div className="pt-1 text-slate-400">Runway Z ±100 (crate every 20m) · Gates at 12/18/24m · Pillars X=±40 at 10/20/30/50/80/100m</div>
<div className="text-slate-400">Grid 30m · Wall at Z=150 · Terrain hills ±12m · Center flat 55m</div>
</div>
</div>
<div className="rounded bg-slate-900/85 px-3 py-2 text-slate-100 shadow">
<div className="grid grid-cols-2 gap-x-4 gap-y-1">
<div className="text-slate-400">POS</div>
<div>{pos[0].toFixed(1)}, {pos[1].toFixed(1)}, {pos[2].toFixed(1)}</div>
<div className="text-slate-400">ALT</div>
<div>{telemetry.altitude.toFixed(1)} m</div>
<div className="text-slate-400">SPEED</div>
<div>{telemetry.speed.toFixed(1)} m/s · {(telemetry.speed * 3.6).toFixed(0)} km/h</div>
<div className="text-slate-400">V-SPEED</div>
<div>{telemetry.vspeed.toFixed(1)} m/s</div>
</div>
</div>
</div>
);
}

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 (
<>
<HudCanvas layout={layout} className="z-20 font-sans text-slate-100" />
<FlightHud />
{status.message !== null ? (
<div className={"pointer-events-none absolute left-1/2 top-6 z-30 -translate-x-1/2 rounded-lg px-4 py-2 text-center font-sans text-sm font-semibold shadow-lg " + tone}>
{status.message}
</div>
) : null}
</>
);
}
29 changes: 29 additions & 0 deletions Games/flight-demo/src/world.ts
Original file line number Diff line number Diff line change
@@ -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 },
}),
});
39 changes: 39 additions & 0 deletions packages/core/src/game/playableGame.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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. */
Expand Down Expand Up @@ -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`. */
Expand Down
Loading
Loading