All examples
Third-person 3D game
- Keyboard
- Gamepad
- Touch
- Mouse
One of the four templates `create-ignifx` scaffolds, running here exactly as it runs on a developer's machine. The character is a `CharacterController` capsule driven by `ThirdPersonController`; the orbit camera sweeps a sphere along its boom, so geometry in the way pulls the camera in rather than letting it sit inside a wall; an `Animator` state machine blends idle, walk and run on a rigged model and triggers the jump; the companion is a `NavMeshAgent` on a surface baked from the geometry the level builds in code; and the crates are rigid bodies you can push. Walking up to a beacon lights it, scores a point and autosaves. The title screen, pause menu, settings, key rebinding and save file are all real.

WebGPU: checking…See browser support
Try this
- Walk with WASD, look with the mouse and hold Shift to sprint; the rig blends idle, walk and run.
- Walk into one of the three beacons to light it — that scores a point and autosaves the run.
- Press Escape, then Settings, and turn shadows or post-processing off while the game runs.
Show source
Source
// oxlint-disable no-underscore-dangle -- `window.__ignifxReady` is a test hook, and the double// underscore is what says it is not part of the game's API. The visual suite reads it by name.import { Animator, ANIMATOR_ASSET_TYPE, NavMeshAgent, NavMeshSurface, ThirdPersonCamera, ThirdPersonController, threeD,} from "@ignifx/3d";import { audio, AUDIO_ASSET_TYPE, AUDIO_BUSES_ASSET_TYPE, AudioListener, AudioSource } from "@ignifx/audio";import { Camera, createApp, isIgnifxError, Model, PostProcessStack } from "@ignifx/core";import { electron } from "@ignifx/electron";import { input, INPUT_ACTIONS_ASSET_TYPE } from "@ignifx/input";import { CharacterController, physics } from "@ignifx/physics";import { I18N_ASSET_TYPE, ui } from "@ignifx/ui";// Vite virtual modules the plugin serves, typed by `@ignifx/vite-plugin/client`.import { manifest } from "virtual:ignifx/manifest";import { acceptHotReload, scripts } from "virtual:ignifx/scripts";import { installDesktopProbe } from "./desktop-probe.js";import { installFrameTimeProbe } from "./frame-time-probe.js";import { createGameUi, hasTouch } from "./game-ui.js";import { buildLevel } from "./level.js";import { createGameMenus } from "./menus/game-menus.js";import { applySettings, loadInputOverrides, loadSettings } from "./menus/settings-store.js";import { createRun } from "./run.js";import { Companion } from "./scripts/companion.js";import { HeroAnimation } from "./scripts/hero-animation.js";import { HudLine } from "./scripts/hud-line.js";import { MenuController } from "./scripts/menu-controller.js";import { SaveGame } from "./scripts/save-game.js";import type { Level } from "./level.js";import type { GameMenus } from "./menus/game-menus.js";import type { GraphicsHooks } from "./menus/settings-store.js";import type { AnimatorAsset } from "@ignifx/3d";import type { AudioBusesAsset, AudioClip } from "@ignifx/audio";import type { App, AssetHandle, Entity, MaterialAsset, ModelAsset } from "@ignifx/core";import type { InputActionsAsset } from "@ignifx/input";import type { LocaleAsset } from "@ignifx/ui";/** * A third-person 3D game: a walking, jumping, sprinting character on a `CharacterController`, an * orbit camera that will not clip through a wall, an `Animator` state machine on a rigged model, a * navmesh-driven companion, pushable crates, a loading screen and a pause menu. * * The shape of this file is the shape of every ignifx game: * * 1. `createApp` with the extensions the game uses; `ignifx.config.ts` carries the settings. * 2. Load every asset and **await it before `app.start()`**. A load awaited before the loop runs * settles as soon as it finishes; once the loop is running, delivery waits for a `PreUpdate`, * so awaiting after `start()` needs frames to be pumped. * 3. Build the world. * 4. `app.start()`. * * ## Query flags * * - `?static=1` builds the scene **without** the controllers, the camera rig and the navigating * companion, places the camera by hand, and stops the clock before the first frame. No fixed step * ever runs, so nothing falls, no clip advances and no path is computed: the frame is exactly * what was authored. It is what the visual golden suite in `tests/visual/` opens. * - `?locale=fr` switches `app.i18n` before the menus are built, which is the whole demonstration * that the UI strings live in `assets/strings.i18n.json` and not in this file. */declare global { interface Window { /** Resolves once the game has presented a settled frame. See the module comment. */ __ignifxReady: Promise<AppStatus>; }}/** What `window.__ignifxReady` resolves to. */type AppStatus = "ready" | "unsupported";/** How many animation frames the scene is given before the image is called settled. */const SETTLE_FRAMES = 12;/** The character's capsule height, in metres. The rig is about 1.45 m tall. */const PLAYER_HEIGHT = 1.5;/** Where the character starts, on the ground. */const PLAYER_SPAWN = { x: 0, y: 0, z: -3 } as const;/** Where the capsule's centre starts: the spawn, lifted by half the capsule's height. */const PLAYER_SPAWN_POSE = { x: PLAYER_SPAWN.x, y: PLAYER_HEIGHT / 2, z: PLAYER_SPAWN.z } as const;/** Where the companion starts, on the ground. */const COMPANION_SPAWN = { x: 5, y: 0, z: 6 } as const;/** The camera pose `?static=1` uses, chosen once so the golden frames the whole courtyard. */const STATIC_EYE = { x: 0, y: 3.6, z: -10.5 } as const;/** What the `?static=1` camera looks at. */const STATIC_FOCUS = { x: 0, y: 1, z: -2.5 } as const;/** * The asset type to load each document as. * * `@ignifx/vite-plugin` types a file by its extension and gets every one of these right, so the * overrides are documentation rather than necessity: they say at the call site which loader is * meant, and they keep working if the file is ever renamed. */const MODEL = { type: "model" } as const;const MATERIAL = { type: "material" } as const;const ANIMATOR = { type: ANIMATOR_ASSET_TYPE } as const;const ACTIONS = { type: INPUT_ACTIONS_ASSET_TYPE } as const;const BUSES = { type: AUDIO_BUSES_ASSET_TYPE } as const;const CLIP = { type: AUDIO_ASSET_TYPE } as const;const STRINGS = { type: I18N_ASSET_TYPE } as const;/** Everything the world is built from. */interface Assets { readonly player: AssetHandle<ModelAsset>; readonly companion: AssetHandle<ModelAsset>; readonly stateMachine: AssetHandle<AnimatorAsset>; readonly floor: AssetHandle<MaterialAsset>; readonly wall: AssetHandle<MaterialAsset>; readonly crate: AssetHandle<MaterialAsset>; readonly sky: AssetHandle<MaterialAsset>; readonly emissive: AssetHandle<MaterialAsset>; readonly footstep: AssetHandle<AudioClip>; readonly jump: AssetHandle<AudioClip>; readonly land: AssetHandle<AudioClip>; readonly pickup: AssetHandle<AudioClip>; readonly uiClick: AssetHandle<AudioClip>; readonly uiHover: AssetHandle<AudioClip>; readonly ambient: AssetHandle<AudioClip>;}/** The placeholder `announceReady` holds until the promise below hands over its resolver. */function noop(): void { // Nothing to do: the promise executor runs synchronously and replaces this on the next line.}let announceReady: (status: AppStatus) => void = noop;window.__ignifxReady = new Promise<AppStatus>((resolve) => { announceReady = resolve;});/** * Waits for one animation frame. * * @returns A promise that resolves inside the next frame callback. */function nextFrame(): Promise<void> { return new Promise<void>((resolve) => { requestAnimationFrame(() => { resolve(); }); });}/** * Waits for several animation frames, so a newly built scene has presented. * * @param frames - How many frames to wait for. * @returns A promise that resolves after the last of them. */function settle(frames: number): Promise<void> { let chain = Promise.resolve(); for (let index = 0; index < frames; index += 1) { chain = chain.then(nextFrame); } return chain;}/** * The clip behind a handle, or `null` when the browser refused to decode it. * * @remarks * `AssetHandle.value` is only meaningful once the handle is `"loaded"`; every sound in this * template is optional, so a failed decode costs the game that sound and nothing else. * * @param handle - The handle to read. * @returns The clip, or `null`. */function clipOrNull(handle: AssetHandle<AudioClip>): AudioClip | null { return handle.state === "loaded" ? handle.value : null;}/** Swaps the canvas for the "no WebGPU here" panel in `index.html`. */function showUnsupported(): void { document.body.dataset["webgpu"] = "unavailable";}/** * Builds the character: a capsule that walks, a child that carries the model, and the state * machine that poses it. * * @remarks * The model hangs off a **child** entity. A `CharacterController`'s capsule is centred on its own * entity, and the rig's origin is between its feet, so the two cannot share one transform without * the character being buried to the waist. * * @param app - The running app. * @param assets - The loaded assets. * @param isStatic - Whether the scene is being built for a golden. * @returns The character's entity. */function buildPlayer(app: App, assets: Assets, isStatic: boolean): Entity { const player = app.world.createEntity("Player", { position: { x: PLAYER_SPAWN.x, y: PLAYER_HEIGHT / 2, z: PLAYER_SPAWN.z }, }); player.layer = app.world.layers.requireIndex("Player"); player.addComponent(CharacterController, { height: PLAYER_HEIGHT, radius: 0.35, slopeLimit: 50 }); const body = app.world.createEntity("Player Body", { parent: player }); body.transform.localPosition.set(0, -PLAYER_HEIGHT / 2, 0); body.addComponent(Model, { model: assets.player.retain(), castShadows: true, receiveShadows: true }); // One `Animator` per **model asset**: `player.glb` and `companion.glb` are two copies of one // generated rig for exactly this reason. Babylon Lite binds an animation group to a single // manager, so two animators over one asset would fight over one pose. body.addComponent(Animator, { animator: assets.stateMachine.retain() }); if (!isStatic) { player.addComponent(ThirdPersonController, { walkSpeed: 3.5, sprintSpeed: 6.5, jumpHeight: 1.1, stepHeight: 0.35, rotateToMovement: true, }); // The animation script lives on the same entity as the controller it reads, and reaches the // `Animator` on the child through the entity tree. const animation = player.addComponent(HeroAnimation); animation.footstep = assets.footstep; } return player;}/** * Builds the companion: a second copy of the rig, a crowd agent, and the follow script. * * @param app - The running app. * @param assets - The loaded assets. * @param player - The entity to follow. * @param isStatic - Whether the scene is being built for a golden. * @returns The companion's follow script, or `null` in a static scene. */function buildCompanion(app: App, assets: Assets, player: Entity, isStatic: boolean): Companion | null { const companion = app.world.createEntity("Companion", { position: COMPANION_SPAWN }); companion.layer = app.world.layers.requireIndex("Companion"); companion.addComponent(Model, { model: assets.companion.retain(), castShadows: true, receiveShadows: true }); companion.addComponent(Animator, { animator: assets.stateMachine.retain() }); if (isStatic) { return null; } companion.addComponent(NavMeshAgent, { speed: 3.6, acceleration: 12, radius: 0.4, stoppingDistance: 0.4 }); return companion.addComponent(Companion, { target: player, followDistance: 2.5 });}/** * Builds the camera: the orbit rig in a running game, a fixed vantage in a golden. * * @param app - The running app. * @param player - The entity the rig orbits. * @param isStatic - Whether the scene is being built for a golden. * @returns The camera's entity. */function buildCamera(app: App, player: Entity, isStatic: boolean): Entity { const eye = app.world.createEntity("Main Camera"); eye.addComponent(Camera, { near: 0.1, far: 200, fov: 55 }); // The listener rides the camera, so a spatial sound is panned from where the player is looking. eye.addComponent(AudioListener); if (isStatic) { eye.transform.position = STATIC_EYE; eye.transform.lookAt(STATIC_FOCUS); return eye; } // `ThirdPersonCamera.awake` reads the entity's current facing as the starting orbit, so the // downward tilt is authored here rather than as a field. eye.transform.localEulerAngles = { x: 12, y: 0, z: 0 }; eye.addComponent(ThirdPersonCamera, { target: player, distance: 4.5, shoulderOffset: { x: 0.4, y: 0.55, z: 0 }, damping: 0.06, minPitch: -20, maxPitch: 55, sensitivity: 0.12, collisionEnabled: true, collisionRadius: 0.25, // Only scenery shortens the boom. Leaving this empty would let the character's own capsule and // the companion pull the camera in whenever one drifted behind it. collisionLayers: ["Level", "Prop"], }); return eye;}/** * Builds the world. * * @param app - The running app. * @param assets - The loaded assets. * @param isStatic - Whether the scene is being built for a golden. * @returns The level, the character, the companion's script and the camera. */function buildWorld(app: App, assets: Assets, isStatic: boolean): World { const level = buildLevel(app, { floor: assets.floor, wall: assets.wall, crate: assets.crate, sky: assets.sky, emissive: assets.emissive, }); const player = buildPlayer(app, assets, isStatic); const companion = buildCompanion(app, assets, player, isStatic); const eye = buildCamera(app, player, isStatic); return { level, player, companion, eye };}/** * Bakes the navmesh from the level's own geometry. * * @remarks * From `addSource` triangles rather than from the scene's `MeshRenderer`s: the soup is world-space * and known before the first frame, so the bake does not depend on anything having been uploaded * to the GPU, and it is the same on a machine with no GPU at all. Babylon Lite 1.27.0 has no * navmesh serialization, so every bake is a runtime bake (`NavMeshSurface.prebaked` says so and * bakes anyway); keeping the source geometry this small is what keeps that cheap. * * @param app - The running app. * @param level - The level whose geometry is baked. * @returns A promise that settles once the crowd exists. */async function bakeNavigation(app: App, level: Level): Promise<void> { const surface = app.world.createEntity("Navigation").addComponent(NavMeshSurface, { bakeOnAwake: false, agentRadius: 0.4, agentHeight: PLAYER_HEIGHT, agentClimb: 0.4, cellSize: 0.2, cellHeight: 0.2, maxAgents: 8, }); surface.addSource(level.navPositions, level.navIndices, null); await surface.bake();}/** What {@link buildWorld} produced. */interface World { /** The level. */ readonly level: Level; /** The character. */ readonly player: Entity; /** The companion's follow script, or `null` in a static scene. */ readonly companion: Companion | null; /** The camera. */ readonly eye: Entity;}/** * Builds the front end and the save file over a world that is already standing. * * @param app - The running app. * @param assets - The loaded assets. * @param world - What {@link buildWorld} produced. * @param hud - The HUD element, or `null` under an app with no DOM overlay. * @param isBench - Whether the frame-time harness is driving, in which case the game starts * immediately instead of waiting on a title screen. * @returns A promise for the callback that switches the post-process effects on; it has to run * after `app.start()` (see below). */async function installFrontEnd( app: App, assets: Assets, world: World, hud: HTMLDivElement | null, isBench: boolean,): Promise<() => void> { // The chain is built once and switched with `enabled`, because `rendering.features.postProcessing` // is read when `app.start()` registers the scene and asking for it later is `IGX-0704`. The // template pays for the offscreen target either way; the toggle only decides whether the two // effects run. // // The two effects are switched on by the callback this function returns, **after** `app.start()`. // Enabling them earlier makes Lite record the first bloom task against a source that is still the // swapchain, and the frame is rejected with `sourceTexture has no color texture`: the offscreen // target the presenter builds does not exist until the scene is registered. const post = world.eye.addComponent(PostProcessStack); post.bloom.threshold = 0.85; post.bloom.weight = 0.35; const sun = world.level.sun; const graphics: GraphicsHooks = { supportsShadows: true, supportsPostProcessing: true, setShadows: (enabled: boolean): void => { // `rendering.features.shadows` stays on: it is what compiled the shadow pass. What a player // turns off is this light's own casting, which is a live flag. sun.shadows.enabled = enabled; }, setPostProcessing: (enabled: boolean): void => { post.enabled = enabled; }, }; await loadInputOverrides(app); const settings = await loadSettings(app, app.i18n.locale); applySettings(app, settings, graphics); const host = app.world.createEntity("Game UI"); const saveGame = host.addComponent(SaveGame); let menus: GameMenus | null = null; const run = createRun(world.player, world.level.beacons, PLAYER_SPAWN_POSE, (): void => { if (saveGame.checkpoint()) { menus?.toast(app.i18n.t("toast.checkpoint")); } }); saveGame.run = run.state; menus = createGameMenus(app, { settings, graphics, gameplayMap: "Player", rebindable: [ { action: "Jump", labelKey: "action.jump" }, { action: "Sprint", labelKey: "action.sprint" }, { action: "Pause", labelKey: "action.pause" }, ], creditKeys: ["credits.engine", "credits.art", "credits.license"], sounds: { click: clipOrNull(assets.uiClick), hover: clipOrNull(assets.uiHover) }, onStartNew: (): void => { saveGame.restart(); }, onContinue: (save): void => { saveGame.restore(save); }, onSaveNow: (): Promise<boolean> => saveGame.save(), onQuitToTitle: (): void => { saveGame.restart(); }, }); host.addComponent(MenuController).menus = menus; const line = host.addComponent(HudLine); line.element = hud; const controller = world.player.requireComponent(ThirdPersonController); const companion = world.companion; line.render = (): string => app.i18n.t("hud.status", { speed: controller.speed.toFixed(1), distance: (companion?.distanceToTarget() ?? 0).toFixed(1), }); // The ambient pad loops on the `Music` bus, which `game.audio.json` marks as not pausable so the // menus can duck it rather than silence it. if (clipOrNull(assets.ambient) !== null) { app.world.createEntity("Ambience").addComponent(AudioSource, { clip: assets.ambient.retain(), bus: "Music", loop: true, playOnAwake: true, volume: 0.9, }); } if (!isBench) { // The game boots into its title screen. `MenuController` reconciles `app.pause()` against the // screen stack every frame, so this one call is what stops the world until "New game". The // frame-time harness measures a *running* game, so it skips this and nothing else. menus.show("title"); app.pause(); } return (): void => { post.bloom.enabled = true; post.smaa.enabled = true; post.enabled = settings.postProcessing; };}/** * Builds and runs the game. * * @returns The status `window.__ignifxReady` resolves to. */async function main(): Promise<AppStatus> { const canvas = document.querySelector("#game"); if (!(canvas instanceof HTMLCanvasElement)) { throw new Error('3d-third-person needs a <canvas id="game"> element on the page.'); } const flags = new URLSearchParams(window.location.search); const isStatic = flags.get("static") === "1"; const isBench = flags.get("bench") === "1"; const showOverlay = (!isStatic || flags.get("hud") === "1") && !isBench; let app: App; try { app = await createApp({ canvas, settings: import.meta.env.IGNIFX_CONFIG, // The address-to-URL table `@ignifx/vite-plugin` built from `assets/`. Importing the virtual // module rather than fetching `assets.manifest.json` means the table is in the bundle, so // the first asset request needs no round trip. assets: { manifest }, // Order matters twice: `threeD()` requires `physics()` and `input()` to be registered before // it, and `ui()` finds `@ignifx/input`'s focus flags structurally at registration time. // `electron()` is registered in **both** builds. Without a preload bridge it is inert — one // debug line, and an `app.desktop` that answers `isElectron === false` — so the browser build // is unchanged and the desktop build needs no second entry point. It goes first because it // only requires core, and because `app.storage` should be the file backend before any other // extension reads a setting from it. extensions: [electron(), physics(), input(), audio(), threeD(), ui()], }); } catch (error) { // IGX-0701 is the one failure a shipped game must handle itself: the browser has no WebGPU and // ignifx has no fallback renderer by decision (ADR-0001). if (isIgnifxError(error) && error.code === "IGX-0701") { showUnsupported(); return "unsupported"; } throw error; } // Every class under `src/scripts/**` with a `static typeId`, from the plugin's virtual registry; in // development the registry hot-reloads edited scripts through `app.hotReload` (`"patch"` by default). app.registerComponents(scripts); acceptHotReload(app); // The strings come first and alone: every label below is read out of them, and the document is // under a kilobyte, so nothing is gained by making the loading screen wait for it. const strings = app.assets.load<LocaleAsset>("strings.i18n.json", STRINGS); await strings.promise; await app.i18n.load(strings); const locale = flags.get("locale"); if (locale !== null && app.i18n.availableLocales.includes(locale)) { app.i18n.locale = locale; } const gameUi = createGameUi(app, { loadingLabel: app.i18n.t("loading.label"), touch: !isStatic && !isBench && hasTouch(), }); // The golden is about the rendered scene, not about how this machine draws a system font, and a // frame-time run should not be measuring DOM layout either. app.ui.visible = showOverlay; const assets: Assets = { player: app.assets.load<ModelAsset>("player.glb", MODEL), companion: app.assets.load<ModelAsset>("companion.glb", MODEL), stateMachine: app.assets.load<AnimatorAsset>("hero.animator.json", ANIMATOR), floor: app.assets.load<MaterialAsset>("floor.material.json", MATERIAL), wall: app.assets.load<MaterialAsset>("wall.material.json", MATERIAL), crate: app.assets.load<MaterialAsset>("crate.material.json", MATERIAL), sky: app.assets.load<MaterialAsset>("sky.material.json", MATERIAL), emissive: app.assets.load<MaterialAsset>("emissive.material.json", MATERIAL), footstep: app.assets.load<AudioClip>("footstep.wav", CLIP), jump: app.assets.load<AudioClip>("jump.wav", CLIP), land: app.assets.load<AudioClip>("land.wav", CLIP), pickup: app.assets.load<AudioClip>("pickup.wav", CLIP), uiClick: app.assets.load<AudioClip>("ui-click.wav", CLIP), uiHover: app.assets.load<AudioClip>("ui-hover.wav", CLIP), ambient: app.assets.load<AudioClip>("ambient.wav", CLIP), }; const actions = app.assets.load<InputActionsAsset>("game.input.json", ACTIONS); const buses = app.assets.load<AudioBusesAsset>("game.audio.json", BUSES); await Promise.all([ assets.player.promise, assets.companion.promise, assets.stateMachine.promise, assets.floor.promise, assets.wall.promise, assets.crate.promise, assets.sky.promise, assets.emissive.promise, actions.promise, buses.promise, ]); // The clips are awaited together and separately from the rest: a browser that refuses to decode // a sound should cost the game its audio, not its first frame. await Promise.all( [assets.footstep, assets.jump, assets.land, assets.pickup, assets.uiClick, assets.uiHover, assets.ambient].map( async (handle: AssetHandle<AudioClip>): Promise<void> => { await handle.promise.catch((error: unknown) => { app.log.warn("a sound could not be decoded: {error}", String(error)); }); }, ), ); // Installed here rather than through `input.actions` in `ignifx.config.ts`, so that the maps // exist before the first `update` runs. See the comment in that file. app.input.loadActions(actions.value); await app.audio.buildBuses(buses.value.buses); const world = buildWorld(app, assets, isStatic); let enableEffects: (() => void) | null = null; if (isStatic) { // Stopping the clock *before* `start()` means no fixed step ever runs, so nothing falls, // nothing animates and no path is computed: the frame is exactly what was authored. That is // stronger than freezing the scene after a frame or two, and it needs no navmesh at all. app.time.timeScale = 0; } else { // Recast is WebAssembly in a chunk of its own; this is the only thing that fetches it, and the // loading screen is still up while it does. await bakeNavigation(app, world.level); enableEffects = await installFrontEnd(app, assets, world, gameUi.hud, isBench); } if (isStatic && showOverlay && gameUi.hud !== null) { // `?static=1` leaves the front end out, so nothing writes the HUD. `?hud=1` says the overlay is // wanted anyway — the gallery capture asks for exactly that — so the zero state is written once. gameUi.hud.textContent = app.i18n.t("hud.status", { speed: "0.0", distance: "0.0" }); } gameUi.loading.hide(); // A test-only hook, and only under `?probe=1`: `tests/visual/tests/desktop.spec.ts` uses it for // the Phase 9 device-loss exit criterion. See `src/desktop-probe.ts`. if (flags.get("probe") === "1") { installDesktopProbe(app, world.level.crates); } if (isBench) { installFrameTimeProbe(app); } await app.start(); // The post-process chain is switched on only now: its source is the offscreen target the // presenter builds while `start()` registers the scene, and a task recorded before that samples // the swapchain, which WebGPU rejects. enableEffects?.(); await settle(SETTLE_FRAMES); app.log.info("3d-third-person running: {calls} draw calls", app.renderer.drawCalls); return "ready";}void main().then(announceReady, (error: unknown) => { showUnsupported(); announceReady("unsupported"); // Rethrown out of the promise chain so it reaches `window.onerror` as an uncaught error rather // than a swallowed rejection. Templates never log through `console` (coding standards §6). setTimeout(() => { throw error; }, 0);});import { Light, MeshAsset, MeshRenderer } from "@ignifx/core";import { BoxCollider, Rigidbody } from "@ignifx/physics";import { Beacon } from "./scripts/beacon.js";import type { App, AssetHandle, Entity, MaterialAsset } from "@ignifx/core";/** * The courtyard: a floor, a ring of wall panels, two interior stubs for the camera to collide * with, and a handful of pushable crates. * * ## Why the walls are panels rather than four long boxes * * `MeshAsset.box` gives every face the unit UV square, so a single 24 by 3 metre wall would stretch * one 128-pixel texture over 24 metres. Six 4-metre panels share **one** mesh and one material * instead, so the texture reads at its authored density, the draw calls stay in the same range, and * each panel is its own collider — which is also what lets the navmesh bake carve the ring out. * * ## Why the crate placement is seeded * * `?static=1` has to produce the same picture on every run and on every machine. A seeded generator * is the cheapest way to have scattered-looking props that are nevertheless in exactly the same * place in the golden as they were when it was taken. *//** Half the courtyard, in metres: the floor spans `[-12, 12]` on both axes. */export const ARENA_HALF = 12;/** How tall the perimeter is, in metres. */const WALL_HEIGHT = 3;/** How thick a wall panel is, in metres. */const WALL_THICKNESS = 0.5;/** How wide one wall panel is, in metres. `ARENA_HALF * 2` divided by this must be a whole number. */const PANEL_WIDTH = 4;/** The edge of a crate, in metres. */const CRATE_SIZE = 1;/** How many crates are scattered. */const CRATE_COUNT = 6;/** The seed the crate scatter uses, so the authored scene is the same on every run. */const CRATE_SEED = 0x1f35_a7c1;/** * Where the three beacons stand, in metres. * * Hand-placed at a radius of about ten, which is outside the 4.5-to-8.5 ring the crates scatter in * and inside the twelve-metre wall — so a beacon never lands on a crate, and all three are in front * of the `?static=1` camera, which looks down `+z` from `z = -10.5`. */const BEACONS: readonly (readonly [number, number])[] = Object.freeze([ [-9.5, 3], [9.5, 3], [0, 10],]);/** How wide a beacon's base is, in metres. */const BEACON_BASE = 0.8;/** How tall a lit beacon's column is, in metres. */const BEACON_HEIGHT = 2.4;/** How far away the sky sphere is, in metres. Inside the camera's 200 m far plane. */const SKY_DIAMETER = 180;/** The materials a level is built from. */export interface LevelMaterials { /** The floor slab. */ readonly floor: AssetHandle<MaterialAsset>; /** The perimeter and the interior stubs. */ readonly wall: AssetHandle<MaterialAsset>; /** The pushable crates. */ readonly crate: AssetHandle<MaterialAsset>; /** The unlit gradient drawn on the inside of the sky sphere. */ readonly sky: AssetHandle<MaterialAsset>; /** The glowing column of a lit beacon. */ readonly emissive: AssetHandle<MaterialAsset>;}/** What {@link buildLevel} produced. */export interface Level { /** World-space triangle soup for `NavMeshSurface.addSource`, in Recast's flat layout. */ readonly navPositions: Float32Array; /** The indices into {@link Level.navPositions}. */ readonly navIndices: Uint32Array; /** The crates, so the HUD can count the ones still standing. */ readonly crates: readonly Entity[]; /** The sun, so the settings screen can turn its shadows off. */ readonly sun: Light; /** The beacons, which are what the run's progress is counted in. */ readonly beacons: readonly Beacon[];}/** * A tiny deterministic generator, so a regenerated scene is the scene the golden was taken of. * * @param seed - The starting state. * @returns A function returning the next value in `[0, 1)`. */function rng(seed: number): () => number { let state = seed >>> 0; return (): number => { state = (Math.imul(state, 1_664_525) + 1_013_904_223) >>> 0; return state / 4_294_967_296; };}/** Accumulates world-space triangles for the navmesh bake. */class NavSource { readonly #positions: number[] = []; readonly #indices: number[] = []; /** * Adds an axis-aligned box's twelve triangles, in the winding Recast reads as "outward". * * @param cx - The centre's x. * @param cy - The centre's y. * @param cz - The centre's z. * @param hx - Half the width. * @param hy - Half the height. * @param hz - Half the depth. */ box(cx: number, cy: number, cz: number, hx: number, hy: number, hz: number): void { const base = this.#positions.length / 3; for (let corner = 0; corner < 8; corner += 1) { this.#positions.push( cx + ((corner & 1) === 0 ? -hx : hx), cy + ((corner & 2) === 0 ? -hy : hy), cz + ((corner & 4) === 0 ? -hz : hz), ); } for (let index = 0; index < BOX_TRIANGLES.length; index += 1) { this.#indices.push(base + (BOX_TRIANGLES[index] ?? 0)); } } /** * Freezes the accumulated soup into the typed arrays Recast reads. * * @returns The positions and the indices. */ build(): { readonly positions: Float32Array; readonly indices: Uint32Array } { return { positions: Float32Array.from(this.#positions), indices: Uint32Array.from(this.#indices) }; }}/** * The twelve triangles of a box, indexing the eight corners {@link NavSource.box} emits: corner * `c` is `-x` unless bit 0 is set, `-y` unless bit 1 is set, `-z` unless bit 2 is set. * * The winding is **clockwise seen from outside**, which is what Babylon Lite's Recast bridge reads * as an outward normal — its own documented example, a floor quad wound * `(-x,-z) (+x,-z) (+x,+z) (-x,+z)` with indices `0 1 2 / 0 2 3`, has a cross product pointing at * `-y` and still bakes as walkable ground. A box wound the other way makes every face point inward, * Recast finds no walkable triangle at all, and `createNavMesh` fails outright rather than * returning an empty mesh. That is worth stating here because it is the one thing about * `addSource` that a caller cannot guess. */const BOX_TRIANGLES: readonly number[] = Object.freeze([ 0, 1, 2, 1, 3, 2, 4, 6, 5, 5, 6, 7, 0, 4, 1, 1, 4, 5, 2, 3, 6, 3, 7, 6, 0, 2, 4, 2, 6, 4, 1, 5, 3, 3, 5, 7,]);/** One shared wall mesh and the half-extents that go with it. */interface Panel { /** The mesh every panel of this orientation shares. */ readonly mesh: AssetHandle<MeshAsset>; /** Half the panel's width along x. */ readonly halfX: number; /** Half the panel's depth along z. */ readonly halfZ: number;}/** * Adds one wall panel: a mesh instance, a static collider, and a box in the navigation soup. * * @param app - The running app. * @param panel - The shared mesh and its half-extents. * @param material - The wall material. * @param nav - The navigation soup being accumulated. * @param name - The entity name. * @param x - The panel centre's x. * @param z - The panel centre's z. */function addPanel( app: App, panel: Panel, material: AssetHandle<MaterialAsset>, nav: NavSource, name: string, x: number, z: number,): void { const entity = app.world.createEntity(name, { position: { x, y: WALL_HEIGHT / 2, z } }); entity.layer = app.world.layers.requireIndex("Level"); entity.addComponent(MeshRenderer, { mesh: panel.mesh.retain(), materials: [material.retain()], castShadows: true, receiveShadows: true, }); // No `Rigidbody`: a collider on its own is placed once as an implicit static body, which is // exactly right for scenery that never moves (`IGX-0901` is what a moving one would report). entity.addComponent(BoxCollider, { size: { x: panel.halfX * 2, y: WALL_HEIGHT, z: panel.halfZ * 2 } }); nav.box(x, WALL_HEIGHT / 2, z, panel.halfX, WALL_HEIGHT / 2, panel.halfZ);}/** * Builds the whole level. * * @param app - The running app. * @param materials - The three materials the level is drawn with. * @returns The navigation geometry and the crates. */export function buildLevel(app: App, materials: LevelMaterials): Level { const levelLayer = app.world.layers.requireIndex("Level"); const propLayer = app.world.layers.requireIndex("Prop"); const nav = new NavSource(); // The sky: one inverted sphere with an unlit gradient. `Environment.skybox` wants a `.dds` or // `.env` cube map, which cannot be generated from arithmetic the way every other asset in this // repository is (CONSTITUTION.md §11.3) — a big sphere with `unlit` and `doubleSided` on gives // the same horizon for one draw call and one 2 KB PNG. const skyMesh = MeshAsset.sphere(app, { diameter: SKY_DIAMETER, segments: 24 }); const sky = app.world.createEntity("Sky"); sky.addComponent(MeshRenderer, { mesh: skyMesh.retain(), materials: [materials.sky.retain()], castShadows: false, receiveShadows: false, }); skyMesh.release(); const sun = app.world.createEntity("Sun", { position: { x: -8, y: 14, z: -6 } }); sun.transform.lookAt({ x: 0, y: 0, z: 0 }); const light = sun.addComponent(Light, { type: "directional", intensity: 2.9 }); light.shadows.enabled = true; // ESM, not PCF: Babylon Lite can rebuild ESM shadow maps after a lost device (`lib/shadow/shadow-recovery.js` // refuses every other technique), and a desktop build must survive a GPU reset (Phase 9). light.shadows.technique = "esm"; light.shadows.mapSize = 1024; light.shadows.normalBias = 0.02; app.world.createEntity("Fill").addComponent(Light, { type: "hemispheric", intensity: 0.45, color: { r: 0.72, g: 0.79, b: 1, a: 1 }, }); const floor = app.world.createEntity("Floor"); floor.layer = levelLayer; floor.addComponent(MeshRenderer, { // `uvScale` is why the 128-pixel slab texture is not smeared across 24 metres: the ground's // UVs are multiplied so one tile covers about two and a half metres. mesh: MeshAsset.ground(app, { width: ARENA_HALF * 2, height: ARENA_HALF * 2, subdivisions: 1, uvScale: [10, 10] }), materials: [materials.floor.retain()], castShadows: false, receiveShadows: true, }); // A slab rather than an infinite plane: Havok has no plane shape in this build, and a thick box // is what stops a fast faller tunnelling through. floor.addComponent(BoxCollider, { size: { x: ARENA_HALF * 2, y: 1, z: ARENA_HALF * 2 }, center: { x: 0, y: -0.5, z: 0 }, }); nav.box(0, -0.5, 0, ARENA_HALF, 0.5, ARENA_HALF); const alongX: Panel = { mesh: MeshAsset.box(app, { width: PANEL_WIDTH, height: WALL_HEIGHT, depth: WALL_THICKNESS }), halfX: PANEL_WIDTH / 2, halfZ: WALL_THICKNESS / 2, }; const alongZ: Panel = { mesh: MeshAsset.box(app, { width: WALL_THICKNESS, height: WALL_HEIGHT, depth: PANEL_WIDTH }), halfX: WALL_THICKNESS / 2, halfZ: PANEL_WIDTH / 2, }; const perSide = (ARENA_HALF * 2) / PANEL_WIDTH; for (let index = 0; index < perSide; index += 1) { const offset = -ARENA_HALF + PANEL_WIDTH / 2 + index * PANEL_WIDTH; addPanel(app, alongX, materials.wall, nav, `Wall N${String(index)}`, offset, -ARENA_HALF); addPanel(app, alongX, materials.wall, nav, `Wall S${String(index)}`, offset, ARENA_HALF); addPanel(app, alongZ, materials.wall, nav, `Wall W${String(index)}`, -ARENA_HALF, offset); addPanel(app, alongZ, materials.wall, nav, `Wall E${String(index)}`, ARENA_HALF, offset); } // The two interior stubs. They are what the third-person camera's boom collides with when the // player backs into the corner they make, and what the companion's path has to walk around. addPanel(app, alongX, materials.wall, nav, "Stub A", -2, 3); addPanel(app, alongZ, materials.wall, nav, "Stub B", -4, 5); // The handles the two shared meshes were created with; every panel took its own reference. alongX.mesh.release(); alongZ.mesh.release(); const crateMesh = MeshAsset.box(app, { size: CRATE_SIZE }); const random = rng(CRATE_SEED); const crates: Entity[] = []; for (let index = 0; index < CRATE_COUNT; index += 1) { const angle = random() * Math.PI * 2; // The ring starts outside the player's spawn, so no crate ever lands on the character. const radius = 4.5 + random() * 4; const crate = app.world.createEntity(`Crate ${String(index)}`, { position: { x: Math.cos(angle) * radius, // Exactly half a crate above the slab, so nothing has to fall before the frame is honest: // that is what makes `?static=1` show a settled scene without a single fixed step. y: CRATE_SIZE / 2, z: Math.sin(angle) * radius, }, }); crate.layer = propLayer; crate.transform.localEulerAngles = { x: 0, y: random() * 90, z: 0 }; crate.addComponent(MeshRenderer, { mesh: crateMesh.retain(), materials: [materials.crate.retain()], castShadows: true, receiveShadows: true, }); crate.addComponent(BoxCollider, { size: { x: CRATE_SIZE, y: CRATE_SIZE, z: CRATE_SIZE } }); crate.addComponent(Rigidbody, { mass: 12 }); crates.push(crate); } crateMesh.release(); // The beacons. Each is a dark base with an emissive column that is hidden until the player // reaches it; `src/scripts/beacon.ts` explains why the reach test is a distance and not a // trigger volume. const baseMesh = MeshAsset.box(app, { width: BEACON_BASE, height: 0.25, depth: BEACON_BASE }); const columnMesh = MeshAsset.cylinder(app, { diameter: 0.28, height: BEACON_HEIGHT }); const beacons: Beacon[] = []; for (let index = 0; index < BEACONS.length; index += 1) { const spot = BEACONS[index]; if (spot === undefined) { continue; } const entity = app.world.createEntity(`Beacon ${String(index)}`, { position: { x: spot[0], y: 0.125, z: spot[1] }, }); entity.layer = propLayer; entity.addComponent(MeshRenderer, { mesh: baseMesh.retain(), materials: [materials.wall.retain()], castShadows: true, receiveShadows: true, }); // Two columns in the same place, one dark and one emissive, and exactly one of them enabled. // A `MeshRenderer` has no tint, and swapping a material list at run time rebuilds the renderer; // two renderers and a boolean is the cheaper and clearer way to say "this one is lit". const dark = app.world.createEntity(`Beacon ${String(index)} Column`, { parent: entity }); dark.transform.localPosition.set(0, BEACON_HEIGHT / 2 + 0.125, 0); const darkRenderer = dark.addComponent(MeshRenderer, { mesh: columnMesh.retain(), materials: [materials.wall.retain()], castShadows: true, receiveShadows: true, }); const column = app.world.createEntity(`Beacon ${String(index)} Light`, { parent: entity }); column.transform.localPosition.set(0, BEACON_HEIGHT / 2 + 0.125, 0); const renderer = column.addComponent(MeshRenderer, { mesh: columnMesh.retain(), materials: [materials.emissive.retain()], castShadows: false, receiveShadows: false, }); const beacon = entity.addComponent(Beacon); beacon.column = renderer; beacon.unlitColumn = darkRenderer; beacon.setLit(false); beacons.push(beacon); } baseMesh.release(); columnMesh.release(); const soup = nav.build(); return { navPositions: soup.positions, navIndices: soup.indices, crates, sun: light, beacons };}import { Animator, ThirdPersonController } from "@ignifx/3d";import { f32, Script } from "@ignifx/core";import type { AudioClip } from "@ignifx/audio";import type { AssetHandle, ScriptCallbacks } from "@ignifx/core";/** * The one script that connects the character controller to the animation state machine, and plays * a footstep while the character is walking. * * `@ignifx/3d` deliberately does not do this for you: an `Animator` reads a document whose * parameter names are the game's, not the engine's, so the mapping from "how fast is this character * moving" to "what is the `speed` parameter called" belongs in the game. This is that mapping, and * `assets/hero.animator.json` is the other half of it. * * Everything here runs in `update`, not `fixedUpdate`. The controller has already moved on the * fixed step; the animator runs in `PostUpdate`, after `update`. Writing the parameters on the * frame clock therefore reaches the same frame's pose, and doing it on the fixed clock would write * the same values twice whenever a frame carried two steps. */export class HeroAnimation extends Script.define({ /** How far the character walks between footsteps, in metres. */ strideMetres: f32(1.9), /** Below this speed, in m/s, the character is standing still and no footstep plays. */ walkThreshold: f32(0.6), }) implements ScriptCallbacks{ static typeId = "third-person/HeroAnimation"; /** The footstep clip. Assigned by `main.ts`, because an asset handle is not a schema field here. */ footstep: AssetHandle<AudioClip> | null = null; /** The state machine this drives. */ #animator: Animator | null = null; /** The controller whose state the machine reads. */ #controller: ThirdPersonController | null = null; /** Metres walked since the last footstep. */ #stride = 0; /** Whether the jump trigger has already been set for the current airborne moment. */ #jumpArmed = true; awake(): void { // `getComponentInChildren` and not `getComponent`: the `Animator` sits on the child entity that // carries the model, because a `CharacterController`'s capsule is centred on its own entity and // the rig's origin is between its feet. The two cannot share one transform. this.#animator = this.entity.getComponentInChildren(Animator); this.#controller = this.entity.requireComponent(ThirdPersonController); this.#animator?.onEvent.connect( (name: string): void => { if (name === "landed") { this.app.log.debug("the hero lands"); } }, { owner: this }, ); } update(dt: number): void { const controller = this.#controller; if (controller === null) { return; } const grounded = controller.isGrounded; const speed = controller.speed; const animator = this.#animator; if (animator !== null) { animator.setFloat("speed", speed); animator.setBool("grounded", grounded); // The trigger is armed on the ground and fired once on the way up, so a long hang time does // not re-enter the jump state every frame. if (grounded) { this.#jumpArmed = true; } else if (this.#jumpArmed && controller.verticalVelocity > 0) { this.#jumpArmed = false; animator.setTrigger("jump"); } } if (!grounded || speed < this.walkThreshold) { this.#stride = 0; return; } this.#stride += speed * dt; if (this.#stride < this.strideMetres) { return; } this.#stride -= this.strideMetres; const clip = this.footstep; if (clip !== null) { // `playOneShot` routes into the `SFX` bus of `game.audio.json`. A browser keeps its audio // context suspended until the player has interacted with the page, so the first footstep may // be the one that unlocks it rather than the one that is heard. this.app.audio.playOneShot(clip.value, { volume: 0.5 }); } }}