All examples
Character controller
Move a capsule-shaped character around two ramps. The controller climbs slopes within its limit and blocks steeper ones. Adjust the limit or switch to touch controls to see how the same movement system responds.
Off airLivePaused
Character controller
Physics · 3D

WebGPU: checking…See browser support
Try this
- Walk up the pale ramp, then try the dark one: 30° is inside the slope limit and 60° is not.
- Drop the slope limit to 20° and the ramp you just climbed becomes a wall.
- Turn on the on-screen controls: the thumbstick drives the same action a keyboard does.
Show source code
Source
import { Camera, CharacterController, createMaterialAsset, MeshAsset, MeshRenderer, pbrMaterialDefinition, physics,} from "ignifx";import { bootExample } from "../_kit/boot.ts";import { attachOrbit } from "../_kit/orbit.ts";import { bind, button, readout, select, slider, toggle } from "../_kit/panel.ts";import { createLightRig } from "../_kit/stage.ts";import { buildCourse, gentleRampPoint, GENTLE_DEGREES, PLAY_SHOT, POSTER_SHOT, SPAWN, STEEP_DEGREES,} from "./course.ts";import { CHARACTER_ACTIONS, CharacterMotor, createTouchControls, describeSlope, EYE_OFFSET } from "./motor.ts";import type { ColorLike } from "ignifx";/** * A `CharacterController`: a kinematic capsule that collides and slides, walks up a 30° ramp, * refuses a 60° one, stays on the surface on the way back down, and jumps. * * This file is the world and the panel. The lesson — the script that turns two actions into one * displacement per fixed step, and the slope rule it applies itself — is `motor.ts` beside it, and * the course whose angles are exact by construction is `course.ts`. * * The capsule is a `CharacterController` and nothing else: no `Rigidbody`, no collider component. * The controller *is* the body, it is kinematic, and it applies no gravity of its own. *//** The clear colour, in sRGB: the site's dark `--bg`, which the floor fades into. */const CLEAR: ColorLike = { r: 0.051, g: 0.063, b: 0.082, a: 1 };/** The capsule's total height, in metres. */const HEIGHT = 1.8;/** The capsule's radius, in metres. */const RADIUS = 0.35;/** * The slope limits the panel offers, as the labels it shows them under. * * @remarks * Three limits, none of them equal to a ramp's angle. `20°` puts the gentle ramp out of reach and * is the one worth trying: the surface the capsule walked up a moment ago becomes a wall. A limit * exactly equal to a ramp's angle — 30° or 60° — lands on the comparison's own boundary, where * whether the capsule climbs comes down to the last bit of a float, so neither is offered. */const SLOPE_LIMITS: readonly string[] = ["20°", "45°", "65°"];/** The slope limit the panel opens on: between the two ramps, which is the point of the course. */const START_SLOPE_LIMIT = "45°";/** The capsule's colour, in sRGB. */const HERO_COLOR: ColorLike = { r: 0.878, g: 0.412, b: 0.169, a: 1 };/** The character the panel writes an angle with, and strips before reading one back. */const DEGREE_SIGN = "°";bootExample({ title: "Character controller", extensions: [physics()], settings: { rendering: { clearColor: CLEAR, msaaSamples: 4, features: { shadows: true } }, time: { fixedDeltaTime: 1 / 60 }, }, async setup({ app, panel, flags }) { app.registerComponents([CharacterMotor]); app.input.loadActions(CHARACTER_ACTIONS); // `?static=1` never runs a fixed step, so the capsule has to be *authored* where the poster // wants it: half way up the gentle ramp, feet on the surface. const shot = flags.isStatic ? POSTER_SHOT : PLAY_SHOT; const stand = flags.isStatic ? gentleRampPoint(POSTER_SHOT.fraction) : SPAWN; const hero = app.world.createEntity("Hero"); hero.transform.localPosition.set(stand.x, stand.y + HEIGHT / 2, stand.z); const heroMaterial = createMaterialAsset( app, pbrMaterialDefinition({ name: "hero", baseColor: HERO_COLOR, metallic: 0.1, roughness: 0.4 }), [], ); hero.addComponent(MeshRenderer, { mesh: MeshAsset.capsule(app, { height: HEIGHT, radius: RADIUS }), materials: [heroMaterial], castShadows: true, }); const controller = hero.addComponent(CharacterController, { height: HEIGHT, radius: RADIUS, slopeLimit: 45 }); const eye = app.world.createEntity("Main Camera"); eye.addComponent(Camera, { near: 0.1, far: 300, fov: 46 }); const orbit = attachOrbit(app, eye, { yaw: shot.yaw, pitch: shot.pitch, distance: shot.distance, target: flags.isStatic ? POSTER_SHOT.focus : { x: stand.x, y: stand.y + HEIGHT / 2 + EYE_OFFSET, z: stand.z }, minDistance: 3, maxDistance: 18, }); // After the orbit: the floor's fade is sized from how far the camera can pull back. await buildCourse(app); const motor = hero.addComponent(CharacterMotor); // Under `?static=1` the motor keeps no camera, so the shot holds the authored pose above and // frames the whole course instead of following a capsule that will never move. motor.orbit = flags.isStatic ? null : orbit; // The key comes from in front and above, so both ramps' walkable faces are lit rather than // turned away from the only lamp in the scene. createLightRig(app, { focus: { x: 0, y: 0.9, z: 0 }, keyPosition: { x: -4.5, y: 6.5, z: -6 }, rimPosition: { x: 5, y: 3, z: 6 }, keyIntensity: 2.8, shadowMapSize: 2048, }); // The on-screen stick and button write `<Virtual>/joystick` and `<Virtual>/leap`, which the // action document already binds — so nothing else in this file knows a finger from a keyboard. const setTouchControls = createTouchControls(app); const hasTouch = navigator.maxTouchPoints > 0; setTouchControls(hasTouch); window.addEventListener("pagehide", (event: PageTransitionEvent): void => { if (!event.persisted) { setTouchControls(false); } }); panel({ title: "Character controller", groups: [ { label: "Move", controls: [ slider( "Speed", { min: 1, max: 9, step: 0.2, format: (v: number): string => `${v.toFixed(1)} m/s` }, bind(motor, "speed"), ), slider( "Jump", { min: 0.2, max: 2.4, step: 0.1, format: (v: number): string => `${v.toFixed(1)} m` }, bind(motor, "jumpHeight"), ), // A `select` rather than a slider: `slopeLimit` is handed to Havok when the capsule is // built, so changing it rebuilds the controller — three choices means three rebuilds, // not one per pixel of drag. select("Slope limit", SLOPE_LIMITS, { value: START_SLOPE_LIMIT, change: (value: string): void => { controller.slopeLimit = Number(value.replace(DEGREE_SIGN, "")); controller.rebuild(); }, }), toggle("On-screen controls", { value: hasTouch, change: setTouchControls }), button("Respawn", (): void => { motor.respawn(); }), ], }, { label: "Ground", controls: [ // Two answers, because they disagree and the difference is the lesson: what Havok's // own probe says, and what the script decides from the surface normal. readout("Standing", (): string => (motor.onGround ? "yes" : "no")), readout("Havok probe", (): string => controller.supportState), readout("Slope under foot", (): string => describeSlope(controller.groundNormal.y)), readout("Speed", (): string => `${controller.velocity.length().toFixed(1)} m/s`), readout("Air under foot", (): string => Number.isFinite(motor.gap) ? `${motor.gap.toFixed(2)} m` : "out of reach", ), readout("Height", (): string => `${hero.transform.position.y.toFixed(2)} m`), ], }, { label: "Course", collapsed: true, controls: [ readout("Gentle ramp", (): string => `${String(GENTLE_DEGREES)}°`), readout("Steep ramp", (): string => `${String(STEEP_DEGREES)}°`), readout("Last device", (): string => (app.input.currentScheme === "" ? "none" : app.input.currentScheme)), ], }, { label: "Frame", collapsed: true, controls: [readout("Draw calls", (): string => String(app.renderer.drawCalls))], }, ], }); },});/** * The script that walks the capsule, and the actions it reads. * * Three things are worth knowing before reading it. * * **The controller applies no gravity.** A bare `CharacterController` is purely kinematic: * `move(displacement)` is the whole input, and this script owns the vertical speed — which is why * it also owns jump feel, including the coyote window that keeps a jump alive for a moment after * walking off a ledge. * * **Movement is simulation, so it runs in `fixedUpdate`; a press is caught in `update`.** A held * stick reads the same in every fixed step, but `wasPressedThisFrame` is true for one *frame*, and * at 120 or 144 Hz many frames run no fixed step at all. So `update` latches the jump press and the * next fixed step spends it. `move` accumulates within a step, so calling it twice is one * displacement. * * **The slope rule is applied here, not by `isGrounded`.** See {@link isWalkable}; it is the one * thing about `CharacterController` a reader should take away from this example. */import { CharacterController, defineInputActions, degToRad, f32, radToDeg, Script, Vec3, VirtualButton, VirtualJoystick,} from "ignifx";import { SPAWN } from "./course.ts";import type { OrbitCamera } from "../_kit/orbit.ts";import type { App, InputAction, ScriptCallbacks, Vec3Like } from "ignifx";/** How fast the capsule walks, in metres per second. */const START_SPEED = 4.2;/** How high a jump reaches, in metres. */const START_JUMP = 1.1;/** Downward acceleration the script applies itself, in metres per second squared. */const GRAVITY = -19;/** How long after leaving the ground a jump still fires, in seconds. */const COYOTE_SECONDS = 0.12;/** * How far below its feet the capsule will still snap down to, in metres. * * @remarks * Bigger than any lip on the course and smaller than a fall. Inside it the capsule is on the * ground and the gap is closed in one step; outside it the capsule is in the air and gravity has * it. */const SNAP_DISTANCE = 0.4;/** * How hard a grounded capsule is pushed downwards, in metres per second. * * @remarks * The ground snap. Havok's support probe reaches about a step of gravity, so a capsule a hand's width * above the floor still reads as supported, and zeroing its fall speed there leaves it hovering. A * small constant push keeps it on the surface, walking down a ramp included, and costs nothing on * flat ground. Small on purpose: on a slope it resolves along the surface, and a big one would drag * a climbing capsule backwards. */const GROUND_STICK = 2.5;/** Where the camera looks relative to the capsule's centre, in metres. */export const EYE_OFFSET = 0.4;/** Straight down, for the snap ray. A module constant, so the per-step path allocates nothing. */const DOWN: Vec3Like = { x: 0, y: -1, z: 0 };/** The flattest a surface is treated as, when the resting height is worked out from its normal. */const MIN_NORMAL_Y = 0.3;/** * The actions the capsule is driven by, on every device the browser offers. * * @remarks * Its own map, so the kit orbit camera's `KitOrbit` map is untouched — `loadActions` merges by map * name. The control schemes are declared so `app.input.currentScheme` follows whichever device last * produced input, which is what the panel's "Last device" readout shows; scheme tags do not filter * bindings unless `input: { strictSchemes: true }`, so a gamepad works whatever the scheme says. * * `<Virtual>/…` is what `@ignifx/ui`'s `VirtualJoystick` and `VirtualButton` write, so the * on-screen controls need nothing here that a thumbstick does not. */export const CHARACTER_ACTIONS = defineInputActions({ controlSchemes: [ { name: "KeyboardMouse", devices: ["Keyboard", "Mouse"] }, { name: "Gamepad", devices: ["Gamepad"] }, { name: "Touch", devices: ["Touch", "Virtual"] }, ], maps: [ { name: "Character", actions: [ { name: "walk", type: "vector2", bindings: [ { composite: "2DVector", up: "<Keyboard>/w", down: "<Keyboard>/s", left: "<Keyboard>/a", right: "<Keyboard>/d", }, { composite: "2DVector", up: "<Keyboard>/arrowUp", down: "<Keyboard>/arrowDown", left: "<Keyboard>/arrowLeft", right: "<Keyboard>/arrowRight", }, { path: "<Gamepad>/leftStick", processors: ["deadzone(0.15)"] }, { path: "<Gamepad>/dpad" }, // The on-screen stick applies its own dead zone. { path: "<Virtual>/joystick" }, ], }, { name: "leap", type: "button", bindings: [{ path: "<Keyboard>/space" }, { path: "<Gamepad>/buttonSouth" }, { path: "<Virtual>/leap" }], }, ], }, ],});/** * Walks and jumps a `CharacterController` from the `walk` and `leap` actions, relative to where the * camera is looking, and drags the camera's target along behind it. * * @remarks * Camera-relative movement is two lines rather than a matrix: the orbit camera's yaw is the only * rotation in the shot, so forward is `(−sin yaw, 0, cos yaw)` and right is `(cos yaw, 0, sin yaw)` * in ignifx's left-handed, `+Z`-forward space. Reading the field rather than the transform is * deliberate — the transform carries the *damped* yaw, and a control that lags its own camera feels * broken. */export class CharacterMotor extends Script.define({ speed: f32(START_SPEED, { min: 0, tooltip: "Walking speed, in metres per second." }), jumpHeight: f32(START_JUMP, { min: 0, tooltip: "How high a jump reaches, in metres." }), gravity: f32(GRAVITY, { tooltip: "Downward acceleration the script applies, in m/s²." }), coyoteSeconds: f32(COYOTE_SECONDS, { min: 0, tooltip: "Grace period after a ledge in which a jump still fires." }), }) implements ScriptCallbacks{ /** The namespaced registration id. */ static typeId = "character-controller/CharacterMotor"; /** The camera this walks relative to, and whose target it drags along. Assigned after attach. */ orbit: OrbitCamera | null = null; /** The controller, found once. */ #controller: CharacterController | null = null; /** Whether the surface under the capsule was walkable, and within reach, at the last step. */ #onGround = false; /** The gap between the capsule's feet and the surface under them, in metres. */ #gap = 0; /** The vertical speed the script owns, in metres per second. */ #fall = 0; /** How long the capsule has been off the ground, in seconds. */ #airborne = Number.POSITIVE_INFINITY; /** Whether a jump was pressed in a frame no fixed step has handled yet. */ #jumpQueued = false; #walk: InputAction | null = null; #leap: InputAction | null = null; /** Reused so the per-step path allocates nothing (coding standards §7). */ readonly #step = new Vec3(); /** The camera's target, rewritten in place each frame. */ readonly #target = { x: 0, y: 0, z: 0 }; /** Finds the controller and the two actions. */ awake(): void { this.#controller = this.entity.requireComponent(CharacterController); this.#walk = this.app.input.actions.find("walk"); this.#leap = this.app.input.actions.find("leap"); } /** * Reads the frame's input, integrates gravity, and hands the controller one displacement. * * @param dt - The fixed step, in seconds. */ fixedUpdate(dt: number): void { const controller = this.#controller; if (controller === null) { return; } this.#gap = this.#gapBelow(controller); this.#onGround = this.#gap <= SNAP_DISTANCE && isWalkable(controller); this.#airborne = this.#onGround ? 0 : this.#airborne + dt; if (this.#onGround && this.#fall < 0) { // The ground snap. A gap the probe is willing to tolerate is closed in one step; with none // left, a small constant push keeps the capsule on the surface. See {@link GROUND_STICK}. this.#fall = this.#gap > 0 ? -this.#gap / dt : -GROUND_STICK; } const jump = this.#jumpQueued; this.#jumpQueued = false; if (jump && this.#airborne <= this.coyoteSeconds) { // From a height, not a speed: `v = sqrt(2·g·h)` is the jump a designer can actually author. this.#fall = Math.sqrt(2 * Math.abs(this.gravity) * this.jumpHeight); // Spend the window, so one press is one jump. this.#airborne = Number.POSITIVE_INFINITY; } this.#fall += this.gravity * dt; const walkX = this.#walk?.vector.x ?? 0; const walkY = this.#walk?.vector.y ?? 0; const yaw = degToRad(this.orbit?.yaw ?? 0); const forwardX = -Math.sin(yaw); const forwardZ = Math.cos(yaw); this.#step.set( (walkX * forwardZ + walkY * forwardX) * this.speed * dt, this.#fall * dt, (walkY * forwardZ - walkX * forwardX) * this.speed * dt, ); controller.move(this.#step); } /** * Latches a jump press for the next fixed step, and drags the camera's target onto the capsule so * the shot follows without a second component. */ update(): void { if (this.#leap?.wasPressedThisFrame === true) { this.#jumpQueued = true; } const orbit = this.orbit; if (orbit === null) { return; } const at = this.transform.position; this.#target.x = at.x; this.#target.y = at.y + EYE_OFFSET; this.#target.z = at.z; orbit.target = this.#target; } /** * Whether the surface under the capsule is one it can stand on. Read by the panel. * * @returns `true` when the last step found a walkable surface within reach. */ get onGround(): boolean { return this.#onGround; } /** * How much air is under the capsule's feet. Read by the panel. * * @returns The gap in metres; `0` when the capsule is resting, infinite when nothing is in reach. */ get gap(): number { return Math.max(0, this.#gap); } /** * How far the surface under the capsule is below its feet. * * @remarks * One downward ray, and it is what makes the snap exact rather than approximate: Havok's support * probe calls a capsule a hand's width off the ground supported. The ray starts at the capsule's * centre, inside its own Havok body, which a ray cast does not report: the first hit is what is * under it. * * A query answers only after one completed fixed step, so the first step of a run — and every * frame under `?static=1`, where no fixed step ever runs — reports no gap rather than `IGX-0902`. * * @param controller - The capsule, for its height and its skin width. * @returns The gap in metres; `0` when the capsule is resting or nothing is under it. */ #gapBelow(controller: CharacterController): number { if (!this.app.physics.hasStepped) { return 0; } // How far the surface *should* be, straight down, when the capsule is resting on it. On a // slope that is more than half the capsule's height: the bottom cap touches the surface at a // point offset along the normal, so the vertical drop from the centre is the cap's radius // divided by the cosine of the slope. Without that term a capsule standing perfectly still on // the 30° ramp measures a 0.06 m "gap" and the snap below drags it back down the hill. // In the air there is no ground normal (it reads zero), and the drop is the flat-ground one. const normalY = controller.groundNormal.y; const lean = normalY <= 0 ? 1 : Math.max(normalY, MIN_NORMAL_Y); const feet = controller.height / 2 - controller.radius + (controller.radius + controller.skinWidth) / lean; const hit = this.app.physics.raycast(this.transform.position, DOWN, feet + SNAP_DISTANCE); return hit === null ? Number.POSITIVE_INFINITY : Math.max(0, hit.distance - feet); } /** * Puts the capsule back at the start line. * * @remarks * `teleport` rather than an assignment to the transform: it moves the controller without * integrating, and clears the swept motion Havok was carrying. */ respawn(): void { const controller = this.#controller; if (controller === null) { return; } this.#fall = 0; this.#airborne = Number.POSITIVE_INFINITY; controller.teleport({ x: SPAWN.x, y: SPAWN.y + controller.height / 2, z: SPAWN.z }); }}/** * Whether the surface under the capsule is one it can stand on. * * @remarks * **`CharacterController.isGrounded` is not that question.** It reports Havok's own `checkSupport` * classification, and Babylon Lite builds its character with no static friction, so a capsule on * *any* incline counts as sliding down it and `isGrounded` reads `false` the moment the floor tilts. * * So the slope rule is applied here instead, against the surface normal and the controller's own * `slopeLimit` — which is exactly what `ThirdPersonController` in `@ignifx/3d` does. The * `slopeLimit` field still does its other job: Havok stops the capsule dead on anything steeper, * which is why the 60° ramp is refused rather than merely slippery. * * @param controller - The capsule. * @returns `true` when the surface under it is within `slopeLimit`. */function isWalkable(controller: CharacterController): boolean { return ( controller.supportState !== "unsupported" && controller.groundNormal.y >= Math.cos(degToRad(controller.slopeLimit)) );}/** * Writes the angle of the surface under the capsule. * * @param normalY - The ground normal's `y`, which is the cosine of the slope. * @returns The angle in degrees, or a dash when the capsule is in the air. */export function describeSlope(normalY: number): string { if (normalY <= 0) { return "—"; } return `${radToDeg(Math.acos(Math.min(1, normalY))).toFixed(0)}°`;}/** Clear of the bottom safe area on a phone with a home indicator. */const SAFE_BOTTOM = "calc(1.5rem + var(--ignifx-safe-bottom, 0px))";/** * Makes the switch for the on-screen stick and jump button, which write the `<Virtual>/…` controls * {@link CHARACTER_ACTIONS} binds. * * @param app - The running app. * @returns A function that shows the widgets for `true` and disposes them for `false`. */export function createTouchControls(app: App): (on: boolean) => void { let widgets: { dispose(): void }[] = []; return (on: boolean): void => { for (const widget of widgets) { widget.dispose(); } widgets = on ? [ new VirtualJoystick(app, { control: "joystick", ariaLabel: "Walk", style: { left: "1.5rem", bottom: SAFE_BOTTOM }, }), new VirtualButton(app, { control: "leap", label: "▲", ariaLabel: "Jump", style: { right: "1.5rem", bottom: SAFE_BOTTOM }, }), ] : []; };}/** * The course the capsule is asked to walk: two ramps whose angles are exact by construction, and * the plateau one of them reaches. * * It is next to `main.ts` for the reason `pbr-model`'s `shot.ts` is: the character controller is the * lesson and the level is composition. But one thing in here *is* load-bearing, and it is why this * file exists at all: **the site says the ramps are 30° and 60°, so they have to be 30° and 60°.** * * ## How a ramp is placed * * A ramp is a box turned about X. Rather than guess a rotation and a centre, {@link addRamp} is * given the angle, the height to climb, and where the *high* edge of the walkable surface should * sit, and it solves for the rest: * * - the horizontal run is `rise / tan(angle)`, so the surface really does rise at `angle`; * - the box's length along the slope is `rise / sin(angle)`; * - the top surface's normal after a rotation of `-angle` about X is `(0, cos a, -sin a)`, so the * box's centre is the surface's midpoint pushed half a thickness *down* that normal. * * A rotation of `-angle` — not `+angle` — because ignifx is left-handed with `+Z` forward: about X, * `y' = y·cos a - z·sin a`, so a positive angle tips `+Z` **down**. * * ## There is no kerb * * A `CharacterController` has no step height and no auto-step, so collide-and-slide against a * vertical face stops the capsule dead, and a low hump's ridge reads to Havok as a wall. The course * is ramps and a plateau only. */import { BoxCollider, createMaterialAsset, degToRad, MeshAsset, MeshRenderer, pbrMaterialDefinition } from "ignifx";import { createGridGround } from "../_kit/stage.ts";import type { App, AssetHandle, ColorLike, MaterialAsset } from "ignifx";/** The smallest ground plane's edge length, in metres, so a long walk does not reach its edge. */const GROUND_SIZE = 140;/** How far the course reaches from the camera's target, in metres: what the floor's fade keeps clear. */const COURSE_RADIUS = 6;/** The plateau's and the gentle ramp's colour, in sRGB: the surfaces the capsule can walk. */const SLAB_COLOR: ColorLike = { r: 0.38, g: 0.41, b: 0.48, a: 1 };/** The steep ramp's colour, in sRGB: darker, so the frame says which ramp is refused. */const STEEP_COLOR: ColorLike = { r: 0.2, g: 0.22, b: 0.29, a: 1 };/** The grid floor's tint, in sRGB. */const FLOOR_COLOR: ColorLike = { r: 0.26, g: 0.28, b: 0.33, a: 1 };/** How deep the floor's collision box is, in metres. Its top face sits at `y = 0`. */const FLOOR_DEPTH = 1;/** How thick a ramp is, in metres. */const RAMP_THICKNESS = 0.4;/** The gentle ramp's angle, in degrees. Inside the controller's opening 45° slope limit. */export const GENTLE_DEGREES = 30;/** The steep ramp's angle, in degrees. Outside it. */export const STEEP_DEGREES = 60;/** How high both ramps climb, in metres. */export const RISE = 1.6;/** How wide each ramp lane is, in metres. The two lanes very nearly touch. */const LANE_WIDTH = 3;/** Where the gentle ramp's centre line runs, in metres along X. */export const GENTLE_X = -1.6;/** Where the steep ramp's centre line runs, in metres along X. */export const STEEP_X = 1.6;/** Where the walkable surface of the plateau starts, in metres along Z. */const PLATEAU_Z = 4.2;/** How deep the plateau is, in metres. */const PLATEAU_DEPTH = 5;/** * How far each ramp's crest stands above the plateau it delivers the capsule onto, in metres. * * @remarks * Three centimetres, and not cosmetic: with the crest flush, the plateau's top corner sits on the * walkable surface, its contact normal is almost horizontal, and Havok stops the capsule there as * if at a wall. Carrying the ramp just past the plateau buries that corner. The run is derived from * the rise, so 30° is still 30°. */const CREST_LIP = 0.03;/** The opening camera: straight behind the capsule, so forward is up the course, over its shoulder. */export const PLAY_SHOT = { yaw: 0, pitch: 26, distance: 8 } as const;/** * The `?static=1` camera, which frames the whole course three-quarters on so both ramps show their * slope, with the capsule posed `fraction` of the way up the gentle ramp. */export const POSTER_SHOT = { yaw: 26, pitch: 29, distance: 10.5, focus: { x: -0.2, y: 1.05, z: 1.9 }, fraction: 0.55,} as const;/** * Where the character starts, in metres: on the floor, in line with the gentle ramp, so the first * key a visitor holds, W, walks straight up it. Centred between the two lanes, W walked into the * seam where they meet and wedged there. */export const SPAWN = { x: GENTLE_X, y: 0, z: -4.6 } as const;/** What {@link buildCourse} produced. */export interface Course { /** Releases every handle the course holds. */ release(): void;}/** A point on a ramp's walkable surface, in metres. */export interface RampPoint { /** Metres along X. */ readonly x: number; /** Metres above the floor. */ readonly y: number; /** Metres along Z. */ readonly z: number;}/** * Where the gentle ramp's walkable surface is, a fraction of the way up. * * @remarks * `main.ts` uses it to pose the capsule for `?static=1`: the poster has to show the capsule *on* the * ramp, and no fixed step ever runs to put it there. * * @param fraction - `0` at the low edge, `1` at the crest. * @returns The point on the surface. */export function gentleRampPoint(fraction: number): RampPoint { const rise = RISE + CREST_LIP; const run = rise / Math.tan(degToRad(GENTLE_DEGREES)); return { x: GENTLE_X, y: rise * fraction, z: PLATEAU_Z - run * (1 - fraction) };}/** Where one ramp goes and how steep it is. */interface RampPlacement { /** The angle of the walkable surface, in degrees. */ readonly degrees: number; /** How high it climbs, in metres. */ readonly rise: number; /** Where its centre line runs, in metres along X. */ readonly x: number; /** Where the *high* edge of its walkable surface sits, in metres along Z. */ readonly topZ: number;}/** * Adds one static ramp: a box turned about X, sized and placed from its angle and its climb. * * @param app - The running app. * @param material - The ramp's material. * @param place - The angle, the climb, and where the high edge sits. * @returns The mesh handle, so the caller can release it. */function addRamp(app: App, material: AssetHandle<MaterialAsset>, place: RampPlacement): AssetHandle<MeshAsset> { const angle = degToRad(place.degrees); const run = place.rise / Math.tan(angle); const length = place.rise / Math.sin(angle); const mesh = MeshAsset.box(app, { width: LANE_WIDTH, height: RAMP_THICKNESS, depth: length }); const entity = app.world.createEntity(`Ramp ${String(place.degrees)}`); entity.transform.localPosition.set( place.x, place.rise / 2 - (RAMP_THICKNESS / 2) * Math.cos(angle), place.topZ - run / 2 + (RAMP_THICKNESS / 2) * Math.sin(angle), ); entity.transform.localEulerAngles = { x: -place.degrees, y: 0, z: 0 }; entity.addComponent(MeshRenderer, { mesh, materials: [material], castShadows: true }); entity.addComponent(BoxCollider, { size: { x: LANE_WIDTH, y: RAMP_THICKNESS, z: length } }); return mesh;}/** Where one block goes and how big it is. */interface BlockPlacement { /** Where its centre runs, in metres along X. */ readonly x: number; /** Where its centre runs, in metres along Z. */ readonly z: number; /** How wide it is, in metres. */ readonly width: number; /** How deep it is, in metres. */ readonly depth: number; /** How high its top face sits above the floor, in metres. */ readonly top: number;}/** * Adds one static block that stands on the floor, with its top face at `top`. * * @remarks * A solid block rather than a floating slab, so the plateau reads as something you step onto rather * than a sheet hanging in the air — and so a capsule that walks into its side meets a wall. * * @param app - The running app. * @param name - The entity's name. * @param material - The block's material. * @param box - Where it is, how big, and how high its top face sits. * @returns The mesh handle, so the caller can release it. */function addBlock( app: App, name: string, material: AssetHandle<MaterialAsset>, box: BlockPlacement,): AssetHandle<MeshAsset> { const mesh = MeshAsset.box(app, { width: box.width, height: box.top, depth: box.depth }); const entity = app.world.createEntity(name); entity.transform.localPosition.set(box.x, box.top / 2, box.z); entity.addComponent(MeshRenderer, { mesh, materials: [material], castShadows: true }); entity.addComponent(BoxCollider, { size: { x: box.width, y: box.top, z: box.depth } }); return mesh;}/** * Builds the course: the floor, the two ramps, and the plateau they lead to. * * @remarks * Every piece is a collider with **no `Rigidbody`**, which is placed once as a static body. Moving * one afterwards would log `IGX-0901`; scenery never moves, so that is exactly what it wants. * * Call it after the orbit camera is attached: the floor's fade is sized from the orbit's reach. * * @param app - The running app. * @returns The release for the handles this created. */export async function buildCourse(app: App): Promise<Course> { const ground = await createGridGround(app, { size: GROUND_SIZE, color: FLOOR_COLOR, fade: { subjectRadius: COURSE_RADIUS }, }); ground.entity.addComponent(BoxCollider, { size: { x: ground.size, y: FLOOR_DEPTH, z: ground.size }, center: { x: 0, y: -FLOOR_DEPTH / 2, z: 0 }, }); const slab = createMaterialAsset( app, pbrMaterialDefinition({ name: "slab", baseColor: SLAB_COLOR, metallic: 0, roughness: 0.82 }), [], ); const steep = createMaterialAsset( app, pbrMaterialDefinition({ name: "steep", baseColor: STEEP_COLOR, metallic: 0, roughness: 0.86 }), [], ); const meshes = [ addBlock(app, "Plateau", slab, { x: 0, z: PLATEAU_Z + PLATEAU_DEPTH / 2, width: LANE_WIDTH * 2, depth: PLATEAU_DEPTH, top: RISE, }), addRamp(app, slab, { degrees: GENTLE_DEGREES, rise: RISE + CREST_LIP, x: GENTLE_X, topZ: PLATEAU_Z }), addRamp(app, steep, { degrees: STEEP_DEGREES, rise: RISE + CREST_LIP, x: STEEP_X, topZ: PLATEAU_Z }), ]; return { release(): void { for (const mesh of meshes) { mesh.release(); } slab.release(); steep.release(); ground.release(); }, };}