All examples

Third-person 3D game

Templates3D
  • Keyboard
  • Gamepad
  • Touch
  • Mouse

A playable starting point for a third-person game. Explore a courtyard, push crates and light beacons to save your progress. An animated character, a camera that adjusts around walls and a pathfinding companion are already set up, along with menus and settings.

Off airLivePaused

Third-person 3D game

Templates · 3D

A grey tiled courtyard ringed by dark panelled walls under a pale sky: the rigged character stands in the middle among scattered wooden crates, three beacon posts sit around the floor, and a chip in the top corner reads the player's speed and the companion's distance.
Open standalone

` devtools

WebGPU: checking…

Try this

  1. Click the frame to take the pointer, then walk with WASD, look with the mouse and hold Shift to sprint; the rig blends idle, walk and run.
  2. Walk into one of the three beacons to light it — that scores a point and autosaves the run.
  3. Press Escape, then Settings, and turn shadows or post-processing off while the game runs.
Show source code
src/main.ts
// 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 { NavMeshAgent, ThirdPersonCamera, ThirdPersonController } from "@ignifx/3d";import { AUDIO_ASSET_TYPE, AUDIO_BUSES_ASSET_TYPE, AudioSource } from "@ignifx/audio";import { createApp, isIgnifxError, Light, PostProcessStack, Script } from "@ignifx/core";import { INPUT_ACTIONS_ASSET_TYPE } from "@ignifx/input";import { Rigidbody } from "@ignifx/physics";import { I18N_ASSET_TYPE } from "@ignifx/ui";// Vite virtual modules the plugins serve, typed by `@ignifx/vite-plugin/client` and// `@ignifx/editor-host/client`.import { attachEditorPlayControls } from "virtual:ignifx/editor";import { manifest } from "virtual:ignifx/manifest";import { acceptHotReload, scripts } from "virtual:ignifx/scripts";import { installDesktopProbe } from "./desktop-probe.js";import { extensions } from "./extensions.js";import { installFrameTimeProbe } from "./frame-time-probe.js";import { createGameUi, hasTouch } from "./game-ui.js";import { installGameplayProbe } from "./gameplay-probe.js";import { createGameMenus } from "./menus/game-menus.js";import { applySettings, loadInputOverrides, loadSettings } from "./menus/settings-store.js";import { bakeNavigation } from "./navigation.js";import { createRun } from "./run.js";import { scriptType } from "./script-types.js";import type { GameMenus } from "./menus/game-menus.js";import type { GraphicsHooks } from "./menus/settings-store.js";// Types only: they are erased before the dev server sees this file, so no script module joins the// entry's graph and an edit to one hot-patches it instead of reloading the page (`script-types.ts`).import type { Beacon } from "./scripts/beacon.js";import type { Companion } from "./scripts/companion.js";import type { HeroAnimation } from "./scripts/hero-animation.js";import type { HudLine } from "./scripts/hud-line.js";import type { LockHint } from "./scripts/lock-hint.js";import type { MenuController } from "./scripts/menu-controller.js";import type { SaveGame } from "./scripts/save-game.js";import type { AudioBusesAsset, AudioClip } from "@ignifx/audio";import type { App, AssetHandle, Entity } 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 level is data: `assets/scenes/main.scene.json` holds every entity, component and reference, * and this file loads it. The shape of this file is the shape of every ignifx game: * * 1. `createApp` with `extensions()` from `./extensions.ts`; `ignifx.config.ts` carries the settings. * 2. Register the scripts from `virtual:ignifx/scripts`, accept hot reloads, and hand the app to *    the ignifx editor's play controls. * 3. Load what the scene's scripts read while it loads (input actions, audio buses), then the *    scene itself. **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`. * 4. The post-load steps a scene file cannot express — the README's "The level" lists them. * 5. `app.start()`. * * ## Query flags * * - `?static=1` loads the same scene, switches every script and the companion's agent off, 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";/** The level, by its asset address. */const MAIN_SCENE = "scenes/main.scene.json";/** How many animation frames the scene is given before the image is called settled. */const SETTLE_FRAMES = 12;/** The tallest agent the navmesh has to clear: the character's capsule height, in metres. */const AGENT_HEIGHT = 1.5;/** 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 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;/** The sounds, which stay out of the scene file: each one is optional (see `clipOrNull`). */interface Sounds {  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 entities of the loaded scene that the code below wires up, found by name. */interface Level {  /** The character. */  readonly player: Entity;  /** The companion. */  readonly companion: Entity;  /** The camera. */  readonly eye: Entity;  /** The sun, so the settings screen can turn its shadows off. */  readonly sun: Light;}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;});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";}/** * Finds an entity of the loaded scene by name. * * @param app - The running app. * @param name - The entity's name in `assets/scenes/main.scene.json`. * @returns The entity. * @throws Error when the scene has no entity of that name. */function requireEntity(app: App, name: string): Entity {  const entity = app.world.findByName(name);  if (entity === null) {    throw new Error(`${MAIN_SCENE} has no entity named "${name}".`);  }  return entity;}/** * Finds the entities the code below wires up. * * @param app - The app the scene was loaded in. * @returns The character, the companion, the camera and the sun. */function findLevel(app: App): Level {  const sun = requireEntity(app, "Sun").getComponent(Light);  if (sun === null) {    throw new Error(`The Sun in ${MAIN_SCENE} has no Light.`);  }  return {    player: requireEntity(app, "Player"),    companion: requireEntity(app, "Companion"),    eye: requireEntity(app, "Main Camera"),    sun,  };}/** * Turns the loaded scene into the still frame the goldens are taken of. * * @remarks * The scene is the game's, controllers and camera rig included, and loading it has already run * their `awake` — which reads, but writes nothing. What would move the picture is their per-frame * work, so every script and the companion's agent are switched off before the first frame, and the * camera is placed at a vantage chosen to frame the whole courtyard. * * @param app - The app the scene was loaded in. * @param level - The scene's entities. */function freezeForGolden(app: App, level: Level): void {  // 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;  for (const script of app.world.components(Script)) {    script.enabled = false;  }  for (const agent of app.world.components(NavMeshAgent)) {    agent.enabled = false;  }  level.eye.transform.position = STATIC_EYE;  level.eye.transform.lookAt(STATIC_FOCUS);}/** * Builds the front end and the save file over a level that is already loaded. * * @param app - The running app. * @param sounds - The sounds that decoded, and the ones that did not. * @param level - The scene's entities. * @param hud - The HUD element, or `null` under an app with no DOM overlay. * @param hint - The "click to look" line, or `null` on a device with no pointer to lock. * @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,  sounds: Sounds,  level: Level,  hud: HTMLDivElement | null,  hint: 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. That ordering is  // also why the stack is added here and not authored in the scene file.  const post = level.eye.addComponent(PostProcessStack);  post.bloom.threshold = 0.85;  post.bloom.weight = 0.35;  const sun = 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(scriptType<SaveGame>(app, "third-person/SaveGame"));  let menus: GameMenus | null = null;  const beacons = app.world.components(scriptType<Beacon>(app, "third-person/Beacon"));  // Where the run restarts: wherever the scene puts the character.  const here = level.player.transform.position;  const spawn = { x: here.x, y: here.y, z: here.z };  const run = createRun(level.player, beacons, spawn, (): 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(sounds.uiClick), hover: clipOrNull(sounds.uiHover) },    onStartNew: (): void => {      saveGame.restart();    },    onContinue: (save): void => {      saveGame.restore(save);    },    onSaveNow: (): Promise<boolean> => saveGame.save(),    onQuitToTitle: (): void => {      saveGame.restart();    },  });  host.addComponent(scriptType<MenuController>(app, "third-person/MenuController")).menus = menus;  const line = host.addComponent(scriptType<HudLine>(app, "third-person/HudLine"));  line.element = hud;  host.addComponent(scriptType<LockHint>(app, "third-person/LockHint")).element = hint;  const controller = level.player.requireComponent(ThirdPersonController);  const companion = level.companion.getComponent(scriptType<Companion>(app, "third-person/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(sounds.ambient) !== null) {    app.world.createEntity("Ambience").addComponent(AudioSource, {      clip: sounds.ambient,      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;  };}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 },      extensions: extensions(),    });  } 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. It  // is the only way code reaches a script: the scene names them by `typeId`, and so does  // `scriptType`. In development the registry hot-reloads edited scripts through `app.hotReload`  // (`"patch"` by default).  app.registerComponents(scripts);  acceptHotReload(app);  // Under `vite dev`, lets the ignifx editor's play view play, pause, step and stop this app. In a  // production build it is an empty function (`vite.config.ts`).  attachEditorPlayControls(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"),    lockHint: app.i18n.t("hint.lock"),    touch: !isStatic && !isBench && hasTouch(),    touchLabels: {      move: app.i18n.t("action.move"),      look: app.i18n.t("action.look"),      jump: app.i18n.t("action.jump"),      sprint: app.i18n.t("action.sprint"),      pause: app.i18n.t("action.pause"),    },  });  // 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 sounds: Sounds = {    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([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(    Object.values(sounds).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 scene loads: its controllers resolve their action names in `awake`, which  // runs while `loadScene` builds them.  app.input.loadActions(actions.value);  await app.audio.buildBuses(buses.value.buses);  // Then the level itself: the loader fetches every mesh, model, material and animator the file  // names before it builds an entity.  await app.world.loadScene(MAIN_SCENE);  const level = findLevel(app);  let enableEffects: (() => void) | null = null;  if (isStatic) {    freezeForGolden(app, level);  } else {    // A clip is a runtime field rather than a scene field (`src/scripts/hero-animation.ts`).    level.player.requireComponent(scriptType<HeroAnimation>(app, "third-person/HeroAnimation")).footstep =      sounds.footstep;    // 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, AGENT_HEIGHT);    enableEffects = await installFrontEnd(app, sounds, level, gameUi.hud, gameUi.hint, 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 opening state is written    // once: standing still, with the companion as far away as it starts.    const player = level.player.transform.position;    const companion = level.companion.transform.position;    const distance = Math.hypot(companion.x - player.x, companion.z - player.z);    gameUi.hud.textContent = app.i18n.t("hud.status", { speed: "0.0", distance: distance.toFixed(1) });  }  gameUi.loading.hide();  // Two test-only hooks, and only under `?probe=1`: `tests/visual/tests/desktop.spec.ts` uses the  // first for the Phase 9 device-loss exit criterion, and the gameplay half of  // `tests/visual/tests/templates.spec.ts` uses the second to read the camera and the character as  // numbers rather than as pixels. See `src/desktop-probe.ts` and `src/gameplay-probe.ts`.  if (flags.get("probe") === "1") {    installDesktopProbe(      app,      app.world.components(Rigidbody).map((body) => body.entity),    );    const rig = level.eye.getComponent(ThirdPersonCamera);    if (rig !== null && !isStatic) {      installGameplayProbe(app, level.player, rig);    }  }  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; 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);});
src/navigation.ts
import { NavMeshSurface } from "@ignifx/3d";import { BoxCollider } from "@ignifx/physics";import type { App } from "@ignifx/core";/** * Bakes the navmesh from the level's static colliders, after the scene has loaded. * * @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 has no navmesh * serialization, so every bake is a runtime bake; keeping the source geometry this small is what * keeps that cheap. * * The soup is every `BoxCollider` on the `Level` layer — the floor slab, the wall panels and the * two stubs — read off the loaded scene, so a wall added to `assets/scenes/main.scene.json` is * walked around without touching this file. The level's colliders are axis-aligned, so a box is * its entity's position, the collider's `center` and half its `size`; a rotated one would need its * corners rotated too. * * @param app - The app the level is loaded in. * @param agentHeight - The tallest agent the mesh must clear, in metres. * @returns A promise that settles once the crowd exists. */export async function bakeNavigation(app: App, agentHeight: number): Promise<void> {  const nav = new NavSource();  const level = app.world.layers.requireIndex("Level");  const colliders = app.world.components(BoxCollider);  for (const collider of colliders) {    if (collider.entity.layer !== level) {      continue;    }    const position = collider.entity.transform.position;    const { center, size } = collider;    nav.box(position.x + center.x, position.y + center.y, position.z + center.z, size.x / 2, size.y / 2, size.z / 2);  }  const surface = app.world.createEntity("Navigation").addComponent(NavMeshSurface, {    bakeOnAwake: false,    agentRadius: 0.4,    agentHeight,    agentClimb: 0.4,    cellSize: 0.2,    cellHeight: 0.2,    maxAgents: 8,  });  const soup = nav.build();  surface.addSource(soup.positions, soup.indices, null);  await surface.bake();}/** 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,]);
src/scripts/hero-animation.ts
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` after the scene loads. Not a schema field: a scene file   * that named a sound would fail to load in a browser that cannot decode it.   */  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 });    }  }}
Uses
CharacterControllerThirdPersonControllerThirdPersonCameraAnimatorNavMeshSurfaceNavMeshAgentPostProcessStackMenuMenuStackapp.storage
Assets
Template rig, textures and audio — Apache-2.0, Astrum Forge Studios