All examples
First-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. `FirstPersonController` walks, sprints, crouches and jumps on a `CharacterController` capsule, with head bob and pointer lock, and the body owns the yaw while the head owns the pitch. The view model is parented to the rig's `hand` node, so the prop it carries follows the animation; a crosshair ray from the camera through `app.physics.raycast` reports what is in reach on the `Interactable` layer and lights a pedestal when you interact; and the crates are rigid bodies you can push. The title screen, pause menu, settings, key rebinding and save file are all real.

WebGPU: checking…See browser support
Try this
- Click the frame to take pointer lock, then walk with WASD and hold C to crouch.
- Look at a pedestal and press E — the heads-up display says what is in reach before you do.
- 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, FirstPersonController, threeD } from "@ignifx/3d";import { audio, AUDIO_ASSET_TYPE, AUDIO_BUSES_ASSET_TYPE, AudioListener, AudioSource } from "@ignifx/audio";import { Camera, createApp, createMaterialAsset, isIgnifxError, MeshAsset, MeshRenderer, Model, pbrMaterialDefinition, 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 { 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 { AttachToHand } from "./scripts/attach-to-hand.js";import { HudLine } from "./scripts/hud-line.js";import { Interactor } from "./scripts/interactor.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 first-person 3D game: a walking, jumping, sprinting, crouching character whose head owns the * pitch, pointer lock on the first click, a crosshair ray that lights the pedestal it lands on, a * view model with a prop attached to the rig's `hand` node, 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 controller and without pointer lock, places the * head by hand, and stops the clock before the first frame. No fixed step ever runs, so nothing * falls and no clip advances: 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. */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 while standing, in metres. */const STAND_HEIGHT = 1.8;/** How high the eyes sit above the capsule's centre, in metres. */const EYE_OFFSET = 0.72;/** Where the character starts, on the ground. */const PLAYER_SPAWN = { x: 0, y: 0, z: 2.5 } as const;/** Where the capsule's centre starts: the spawn, lifted by half the standing capsule's height. */const PLAYER_SPAWN_POSE = { x: PLAYER_SPAWN.x, y: STAND_HEIGHT / 2, z: PLAYER_SPAWN.z } as const;/** The body's yaw in `?static=1`, in degrees: turned to face the pair of pedestals at `z = -6`. */const STATIC_YAW = 180;/** The head's pitch in `?static=1`, in degrees; a little down, so the floor carries the frame. */const STATIC_PITCH = 7;/** The asset type to load each document as; see the same table in the third-person template. */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 viewmodel: 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 pickup: AssetHandle<AudioClip>; readonly footstep: AssetHandle<AudioClip>; readonly jump: AssetHandle<AudioClip>; readonly land: 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";}/** * Hangs the view model off the head, and a prop off the view model's `hand` node. * * @remarks * The rig is the same generated "box-man" the third-person template walks around with. In first * person you would normally see only arms; this template shows the whole thing, scaled down and * pushed forward and to the right of the eye, because that is what makes * `Model.attachToNode("hand", …)` visible — the point of the exercise is the bone attachment, and a * prop welded to a bone you cannot see demonstrates nothing. * * @param app - The running app. * @param assets - The loaded assets. * @param head - The entity the view model hangs from. */function buildViewModel(app: App, assets: Assets, head: Entity): void { const viewmodel = app.world.createEntity("View Model", { parent: head }); viewmodel.transform.localPosition.set(0.3, -0.84, 0.78); viewmodel.transform.localScale.set(0.38, 0.38, 0.38); viewmodel.transform.localEulerAngles = { x: 0, y: 150, z: 0 }; viewmodel.addComponent(Model, { model: assets.viewmodel.retain(), castShadows: false, receiveShadows: false }); viewmodel.addComponent(Animator, { animator: assets.stateMachine.retain() }); // A small emissive bar standing in for a tool. It is created as a **root** entity and re-parented // by `attachToNode`, which is what puts it under the glTF node rather than under an ignifx entity. const prop = app.world.createEntity("Prop"); prop.addComponent(MeshRenderer, { mesh: MeshAsset.box(app, { width: 0.12, height: 0.12, depth: 0.9 }), materials: [ createMaterialAsset( app, pbrMaterialDefinition({ name: "prop", baseColor: { r: 0.29, g: 0.82, b: 0.94, a: 1 }, emissive: { r: 0.06, g: 0.24, b: 0.3, a: 1 }, metallic: 0.6, roughness: 0.3, }), [], ), ], castShadows: false, receiveShadows: false, }); viewmodel.addComponent(AttachToHand).prop = prop;}/** * Builds the character: a capsule, a head that pitches, and the view model hanging off it. * * @param app - The running app. * @param assets - The loaded assets. * @param isStatic - Whether the scene is being built for a golden. * @returns The head entity, which is where the camera and the listener are. */function buildPlayer(app: App, assets: Assets, isStatic: boolean): { readonly player: Entity; readonly head: Entity } { const player = app.world.createEntity("Player", { position: { x: PLAYER_SPAWN.x, y: STAND_HEIGHT / 2, z: PLAYER_SPAWN.z }, }); player.layer = app.world.layers.requireIndex("Player"); player.addComponent(CharacterController, { height: STAND_HEIGHT, radius: 0.35, slopeLimit: 50 }); // Yaw lives on the body and pitch on this child. Rotating the capsule in pitch would tip the // whole collider over, which is why `FirstPersonController` splits them. const head = app.world.createEntity("Head", { parent: player }); head.transform.localPosition.set(0, EYE_OFFSET, 0); head.addComponent(Camera, { near: 0.05, far: 200, fov: 68 }); // The listener rides the head, so a spatial sound is panned from where the player is looking. head.addComponent(AudioListener); buildViewModel(app, assets, head); if (isStatic) { // The split the controller would otherwise own: yaw on the body, pitch on the head. player.transform.localEulerAngles = { x: 0, y: STATIC_YAW, z: 0 }; head.transform.localEulerAngles = { x: STATIC_PITCH, y: 0, z: 0 }; return { player, head }; } player.addComponent(FirstPersonController, { cameraPivot: head, walkSpeed: 4, sprintSpeed: 7, crouchSpeed: 1.8, standHeight: STAND_HEIGHT, crouchHeight: 1.1, jumpHeight: 1.1, sensitivity: 0.12, headBobAmplitude: 0.035, // The first click on the canvas asks the browser for pointer lock; `<Mouse>/delta` keeps // reporting while it holds, and `Escape` gives it back (which is also what opens the menu). lockPointerOnClick: true, }); return { player, head };}/** What the front end is built over. */interface World { /** The level. */ readonly level: Level; /** The character's body, which carries the `CharacterController`. */ readonly player: Entity; /** The head, which carries the camera and the interaction ray. */ readonly head: Entity; /** The crosshair element, or `null` under an app with no DOM overlay. */ readonly crosshair: HTMLElement | null;}/** * 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 - The level and the character. * @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 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.head.addComponent(PostProcessStack); post.bloom.threshold = 0.8; post.bloom.weight = 0.4; 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; }, }; const interactor = world.head.addComponent(Interactor, { eye: world.head, reach: 3.5 }); interactor.targets = world.level.pedestals; if (clipOrNull(assets.pickup) !== null) { interactor.clip = assets.pickup; } interactor.crosshair = world.crosshair; 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, interactor, world.level.pedestals, 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: "Interact", labelKey: "action.interact" }, { 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; line.render = (): string => app.i18n.t("hud.status", { lit: interactor.litCount, target: interactor.focus === null ? app.i18n.t("hud.nothing") : app.i18n.t("hud.reach"), }); // 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-first-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, assets: { manifest }, // `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); 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 = { viewmodel: app.assets.load<ModelAsset>("viewmodel.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), pickup: app.assets.load<AudioClip>("pickup.wav", CLIP), footstep: app.assets.load<AudioClip>("footstep.wav", CLIP), jump: app.assets.load<AudioClip>("jump.wav", CLIP), land: app.assets.load<AudioClip>("land.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.viewmodel.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.pickup, assets.footstep, assets.jump, assets.land, 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)); }); }, ), ); app.input.loadActions(actions.value); await app.audio.buildBuses(buses.value.buses); const level = buildLevel(app, { floor: assets.floor, wall: assets.wall, crate: assets.crate, sky: assets.sky, emissive: assets.emissive, }); const character = buildPlayer(app, assets, isStatic); let enableEffects: (() => void) | null = null; if (isStatic) { // No fixed step ever runs, so nothing falls, no clip advances and no ray is cast: the frame is // exactly what was authored. app.time.timeScale = 0; } else { enableEffects = await installFrontEnd( app, assets, { level, ...character, crosshair: gameUi.crosshair }, 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", { lit: 0, target: app.i18n.t("hud.nothing") }); } gameUi.loading.hide(); 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-first-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 type { App, AssetHandle, Entity, MaterialAsset } from "@ignifx/core";/** * The room: a floor, a ring of wall panels, two interior stubs, a handful of pushable crates, and * three **interactable** pedestals the crosshair ray can find. * * ## 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. * * ## 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 room, 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 interactable pedestals stand, in metres. Hand-placed, not scattered. */const PEDESTALS: readonly (readonly [number, number])[] = Object.freeze([ [-4, -6], [4, -6], [0, 6],]);/** The edge of a pedestal, in metres. */const PEDESTAL_SIZE = 0.9;/** How high a pedestal's centre sits, in metres. */const PEDESTAL_Y = 1.1;/** How far away the sky sphere is, in metres. Inside the camera's 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 cap on top of each pedestal. */ readonly emissive: AssetHandle<MaterialAsset>;}/** What {@link buildLevel} produced. */export interface Level { /** The pushable crates. */ readonly crates: readonly Entity[]; /** The interactable pedestals, each with a material of its own the ray can recolour. */ readonly pedestals: readonly Interactable[]; /** The sun, so the settings screen can turn its shadows off. */ readonly sun: Light;}/** One thing the crosshair ray can find. */export interface Interactable { /** The entity the ray hits. */ readonly entity: Entity; /** The pedestal's private material clone, so recolouring one does not recolour them all. */ readonly material: AssetHandle<MaterialAsset>; /** The emissive cap, shown only while the pedestal is lit. */ readonly glow: MeshRenderer;}/** * 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; };}/** 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 and a static collider. * * @param app - The running app. * @param panel - The shared mesh and its half-extents. * @param material - The wall material. * @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>, 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 } });}/** * Builds the whole level. * * @param app - The running app. * @param materials - The three materials the level is drawn with. * @returns The crates and the interactable pedestals. */export function buildLevel(app: App, materials: LevelMaterials): Level { const levelLayer = app.world.layers.requireIndex("Level"); const propLayer = app.world.layers.requireIndex("Prop"); // 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 }); app.world.createEntity("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 }, }); 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, `Wall N${String(index)}`, offset, -ARENA_HALF); addPanel(app, alongX, materials.wall, `Wall S${String(index)}`, offset, ARENA_HALF); addPanel(app, alongZ, materials.wall, `Wall W${String(index)}`, -ARENA_HALF, offset); addPanel(app, alongZ, materials.wall, `Wall E${String(index)}`, ARENA_HALF, offset); } // Two interior stubs, so the room has a corner to walk around and something to break the // sightline from one pedestal to the next. addPanel(app, alongX, materials.wall, "Stub A", -2, 3); addPanel(app, alongZ, materials.wall, "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 pedestals. Each carries its **own** clone of the crate material, because // `setBaseColor` writes the Lite material every renderer sharing the asset draws with: without // the clone, lighting one pedestal would light all three. const interactableLayer = app.world.layers.requireIndex("Interactable"); const pedestalMesh = MeshAsset.box(app, { size: PEDESTAL_SIZE }); const pedestals: Interactable[] = []; for (let index = 0; index < PEDESTALS.length; index += 1) { const spot = PEDESTALS[index]; if (spot === undefined) { continue; } const material = materials.crate.value.clone(app); const pedestal = app.world.createEntity(`Pedestal ${String(index)}`, { position: { x: spot[0], y: PEDESTAL_Y, z: spot[1] }, }); pedestal.layer = interactableLayer; pedestal.addComponent(MeshRenderer, { mesh: pedestalMesh.retain(), materials: [material.retain()], castShadows: true, receiveShadows: true, }); pedestal.addComponent(BoxCollider, { size: { x: PEDESTAL_SIZE, y: PEDESTAL_SIZE, z: PEDESTAL_SIZE } }); // A post under it, so a pedestal reads as furniture rather than as a floating box. const post = app.world.createEntity(`Pedestal ${String(index)} Post`, { position: { x: spot[0], y: (PEDESTAL_Y - PEDESTAL_SIZE / 2) / 2, z: spot[1] }, }); post.layer = levelLayer; post.addComponent(MeshRenderer, { mesh: MeshAsset.box(app, { width: 0.3, height: PEDESTAL_Y - PEDESTAL_SIZE / 2, depth: 0.3 }), materials: [materials.wall.retain()], castShadows: true, receiveShadows: true, }); // The emissive cap. It is hidden until the pedestal is lit, which is one boolean rather than a // second material and a second draw call. const cap = app.world.createEntity(`Pedestal ${String(index)} Cap`, { parent: pedestal }); cap.transform.localPosition.set(0, PEDESTAL_SIZE / 2 + 0.06, 0); const glow = cap.addComponent(MeshRenderer, { mesh: MeshAsset.box(app, { width: PEDESTAL_SIZE * 0.6, height: 0.12, depth: PEDESTAL_SIZE * 0.6 }), materials: [materials.emissive.retain()], castShadows: false, receiveShadows: false, }); glow.enabled = false; pedestals.push({ entity: pedestal, material, glow }); } pedestalMesh.release(); return { crates, pedestals, sun: light };}import { entityRef, f32, LayerMask, Script } from "@ignifx/core";import type { AudioClip } from "@ignifx/audio";import type { AssetHandle, ColorLike, Entity, MaterialAsset, MeshRenderer, ScriptCallbacks } from "@ignifx/core";import type { InputAction } from "@ignifx/input";/** * The crosshair ray: what a first-person game does instead of clicking on things. * * ## Where the ray comes from * * The camera's own transform, not the character's. In first person the body owns the yaw and the * head owns the pitch (`FirstPersonController.cameraPivot`), so only the head is looking where the * crosshair points. `transform.forward` on the head entity is that direction, already in world * space, and the ray starts at the head's position because the crosshair sits at the exact centre * of the screen — the projection of the camera's own forward axis. * * A `camera.screenToRay(x, y)` would be the right tool for a cursor that can be anywhere; it takes * **backing-store** pixels, which is the space `@ignifx/input` reports pointer positions in. * * ## Why the mask is a layer and not a component check * * `app.physics.raycast` filters by layer inside Havok, so a ray aimed at the `Interactable` layer * never reports the floor, the walls or the crates and never allocates a hit for them. Walking the * hits and asking "is this a pedestal?" would do the same work in JavaScript, every frame, for * every ray. *//** How the pedestal reads when the crosshair is not on it. */const IDLE_COLOR: ColorLike = Object.freeze({ r: 1, g: 1, b: 1, a: 1 });/** How the pedestal reads once it has been switched on. */const LIT_COLOR: ColorLike = Object.freeze({ r: 0.29, g: 0.82, b: 0.94, a: 1 });/** One thing this script can light up. */export interface InteractorTarget { /** The entity the ray hits. */ readonly entity: Entity; /** That entity's own material, so recolouring one does not recolour the others. */ readonly material: AssetHandle<MaterialAsset>; /** The emissive cap, shown only while the pedestal is lit. */ readonly glow: MeshRenderer;}/** * Casts a ray down the crosshair every frame, and toggles whatever it finds when `Interact` is * pressed. */export class Interactor extends Script.define({ /** The head entity the ray is cast from; usually the camera's entity. */ eye: entityRef<Entity>(), /** How far the player can reach, in metres. */ reach: f32(3.5), }) implements ScriptCallbacks{ static typeId = "first-person/Interactor"; /** The targets the ray may find, by entity. Assigned right after the component is added. */ targets: readonly InteractorTarget[] = []; /** The blip played on a toggle. */ clip: AssetHandle<AudioClip> | null = null; /** The crosshair element, so it can say when something is in reach. */ crosshair: HTMLElement | null = null; /** Called whenever a pedestal is switched. Assigned right after the component is added. */ onToggled: ((name: string, lit: boolean) => void) | null = null; /** The `Interact` action. */ #interact: InputAction | null = null; /** Which layers the ray may hit, resolved once. */ #mask: LayerMask | null = null; /** Which entities are currently lit, by name. */ readonly #lit = new Set<string>(); /** What the crosshair is on, or `null`. */ #focus: InteractorTarget | null = null; awake(): void { this.#interact = this.app.input.actions.find("Interact"); this.#mask = LayerMask.fromNames(this.world.layers, ["Interactable"]); } update(): void { const eye = this.eye; const mask = this.#mask; if (eye === null || eye.isDestroyed || mask === null) { return; } // Every query needs one completed fixed step behind it: Havok builds its broadphase there and // reports `IGX-0902` before that. The app has always stepped by the time a script updates. const hit = this.app.physics.raycast(eye.transform.position, eye.transform.forward, this.reach, { layerMask: mask, }); const found = hit === null ? null : (this.targets.find((target) => target.entity === hit.entity) ?? null); if (found !== this.#focus) { this.#focus = found; // `dataset.target` is `data-target`; `index.html` styles the crosshair off it. if (this.crosshair !== null) { this.crosshair.dataset["target"] = found === null ? "none" : "hit"; } } if (found !== null && this.#interact?.wasPressedThisFrame === true) { this.#toggle(found); } } /** * Whether a pedestal is currently switched on. The HUD counts them. * * @param target - The pedestal to ask about. * @returns `true` when it is lit. */ isLit(target: InteractorTarget): boolean { return this.#lit.has(target.entity.name); } /** * How many pedestals are switched on. * * @returns The count. */ get litCount(): number { return this.#lit.size; } /** * Which pedestals are switched on, so a save file can store them. * * @returns Their entity names, sorted. */ litNames(): readonly string[] { return [...this.#lit].toSorted(); } /** * Switches one pedestal without playing a sound or reporting it. This is what a save restore and * a reset use. * * @param target - The pedestal. * @param lit - Whether it should read as lit. */ setLit(target: InteractorTarget, lit: boolean): void { const name = target.entity.name; if (lit) { this.#lit.add(name); } else { this.#lit.delete(name); } // `setBaseColor` writes the Lite material, which every renderer sharing this asset draws with. // `src/level.ts` gives each pedestal its own `clone`, which is what makes that safe here. target.material.value.setBaseColor(lit ? LIT_COLOR : IDLE_COLOR); target.glow.enabled = lit; } /** * What the crosshair is on. * * @returns The target, or `null` when nothing is in reach. */ get focus(): InteractorTarget | null { return this.#focus; } /** * Switches one pedestal on or off. * * @param target - The pedestal. */ #toggle(target: InteractorTarget): void { const name = target.entity.name; const lit = !this.#lit.has(name); this.setLit(target, lit); const clip = this.clip; if (clip !== null && clip.state === "loaded") { this.app.audio.playOneShot(clip.value, { volume: 0.6, pitch: lit ? 1 : 0.75 }); } this.onToggled?.(name, lit); this.app.log.info("{name} is now {state}", name, lit ? "lit" : "dark"); }}