ignifx
All examples

Input actions

Input3D

  • Keyboard
  • Mouse
  • Gamepad
  • Touch

Control a rover through named input actions. The dashboard shows movement values, button presses and the active device. Keyboard, gamepad and touch controls share the same configuration file, shown in the source below.

A small orange rover with four black wheels and a dark canopy stands on a grey grid floor in front of a wide dark instrument panel on two legs. The panel carries a cluster of unlit key caps on the left, two round dials with a dot at the centre of each, two narrow vertical tracks, four square lamps, and a row of small status lamps along the bottom, four of them lit green and one orange.
Open standalone

` devtools

WebGPU: checking…

Try this

  • Hold W and A together. The movement display keeps diagonal input at the same strength as a single direction.
  • Squeeze a gamepad trigger slowly and watch the boost bar fill before the action counts as pressed.
  • Press Escape to disable player controls. The device display keeps tracking input.

Read the guide

Show source code

Source

main.ts
import { Camera, validateInputActions } from "ignifx";import { bootExample } from "../_kit/boot.ts";import { bind, button, readout, slider } from "../_kit/panel.ts";import { createGridGround, createLightRig } from "../_kit/stage.ts";import { attachBoard, PLAYER_MAP } from "./board.ts";import actionsSource from "./player.input.json?raw";import { attachRover } from "./rover.ts";import type { InputAction } from "ignifx";/** * Load action maps from `player.input.json` so gameplay reads actions rather than keys. * Import the document as raw text to display it beside this example, then validate it before loading. * Load before `app.start` so the board can resolve its actions on the first frame. *//** * The floor, in metres. Big enough that its far edge is behind the board and out of the frame: a * finite plane whose horizon is visible reads as a raft floating in the void. */const PAD_SIZE = 60;/** Where the camera sits and what it looks at, in metres. */const SHOT = { eye: { x: 0, y: 2.75, z: -4.35 }, focus: { x: 0, y: 0.78, z: 0.35 }, fov: 42 } as const;/** * Writes a `vector2` action as a pair, or `—` when the document declares no such action. * * @param action - The action to read. * @returns The text for a readout cell. */function vectorText(action: InputAction | null): string {  if (action === null) {    return "";  }  return `${action.vector.x.toFixed(2)}, ${action.vector.y.toFixed(2)}`;}/** * Writes an axis or button action as one number and its press state. * * @param action - The action to read. * @returns The text for a readout cell. */function axisText(action: InputAction | null): string {  if (action === null) {    return "";  }  return `${action.axis.toFixed(2)}${action.isPressed ? "" : ""}`;}/** * Writes a slider's value to two places. * * @param value - The value. * @returns The text for the slider's value cell. */function twoPlaces(value: number): string {  return value.toFixed(2);}bootExample({  title: "Input actions",  settings: {    rendering: {      clearColor: { r: 0.035, g: 0.043, b: 0.059, a: 1 },      msaaSamples: 4,      // Read once, when `app.start()` registers the scene; asking afterwards is `IGX-0704`.      features: { shadows: true },    },    time: { fixedDeltaTime: 1 / 60 },  },  async setup({ app, panel }) {    // The document, checked the way the loader checks it. `JSON.parse` answers `any`, so the value    // is typed `unknown` at the boundary and `validateInputActions` is what narrows it.    const parsed: unknown = JSON.parse(actionsSource);    app.input.loadActions(validateInputActions(parsed, "player.input.json"));    const eye = app.world.createEntity("Main Camera");    eye.transform.localPosition.set(SHOT.eye.x, SHOT.eye.y, SHOT.eye.z);    eye.transform.lookAt(SHOT.focus);    // No orbit camera here, on purpose: the mouse belongs to the actions in this example, and a    // camera that ate the drag would take `look`, `fire` and `aim` with it.    const camera = eye.addComponent(Camera, { near: 0.05, far: 120, fov: SHOT.fov });    // Awaited before `app.start()`: a load that completes before the loop runs settles at once,    // and a material binds its textures once — an unawaited grid is an untextured floor.    await createGridGround(app, { size: PAD_SIZE, color: { r: 0.17, g: 0.19, b: 0.24, a: 1 } });    createLightRig(app, {      focus: { x: 0, y: 0.4, z: -0.4 },      keyIntensity: 2.4,      rimIntensity: 0.9,      shadowDarkness: 0.4,    });    const board = attachBoard(app);    const rover = attachRover(app, camera);    // The board and the rover light up from the same actions, so the two halves of the frame never    // disagree: one reads the map, the other drives on it.    const player = app.input.actions.map(PLAYER_MAP);    const find = (name: string): InputAction | null => app.input.actions.find(name);    // A signal rather than a poll: `onPerformed` fires once per press, in `PreUpdate`. The    // connection is owned by the board's entity, so it disconnects when the entity dies — which is    // the whole point of an owner-scoped connection.    find("toggleMap")?.onPerformed.connect(      (): void => {        player.enabled = !player.enabled;      },      { owner: board },    );    panel({      title: "Input actions",      groups: [        {          label: "Actions",          controls: [            readout("move", (): string => vectorText(find("move"))),            readout("look", (): string => vectorText(find("look"))),            readout("spin", (): string => axisText(find("spin"))),            readout("boost", (): string => axisText(find("boost"))),            readout("jump", (): string => axisText(find("jump"))),            readout("fire", (): string => axisText(find("fire"))),            readout("sprint", (): string => axisText(find("sprint"))),          ],        },        {          label: "Devices",          controls: [            readout("Scheme", (): string => (app.input.currentScheme === "" ? "none" : app.input.currentScheme)),            // A browser hides a pad until a button is pressed on it, so this reads 0 with one            // plugged in and nothing touched.            readout("Gamepads", (): string => String(app.input.gamepads.filter((pad) => pad.isConnected).length)),            readout("Events this frame", (): string => String(app.input.events.length)),            readout("Pointer, pixels", (): string => vectorText(find("aim"))),          ],        },        {          label: "Maps",          controls: [            // A button and a readout, not a `toggle`: a checkbox is written once when the panel            // mounts, so it would go stale the moment Escape switched the same map off.            readout("Player map", (): string => (player.enabled ? "enabled" : "disabled")),            button("Toggle Player map", (): void => {              player.enabled = !player.enabled;            }),            // Escape does the same thing. A disabled map hides its actions from            // `app.input.actions.get`, which is why the board and the rover hold theirs from            // `find` — where they read as released instead of throwing `IGX-0801`.            //            // The magnitude at which an analog value counts as pressed. Push it up and a gamepad            // trigger has to be squeezed harder before `jump.isPressed` is true.            slider(              "Press point",              { min: 0.05, max: 0.95, step: 0.05, format: twoPlaces },              bind(app.input, "pressPoint"),            ),          ],        },        {          label: "Rover",          collapsed: true,          controls: [            slider("Speed", { min: 0.5, max: 6, step: 0.1 }, bind(rover, "speed")),            slider("Boost factor", { min: 1, max: 4, step: 0.1 }, bind(rover, "boostFactor")),            readout("Draw calls", (): string => String(app.renderer.drawCalls)),          ],        },      ],    });  },});
player.input.json
{  "format": "ignifx.inputactions",  "formatVersion": 1,  "controlSchemes": [    { "name": "KeyboardMouse", "devices": ["Keyboard", "Mouse"] },    { "name": "Gamepad", "devices": ["Gamepad"] },    { "name": "Touch", "devices": ["Touch"] }  ],  "maps": [    {      "name": "Player",      "enabled": true,      "actions": [        {          "name": "move",          "type": "vector2",          "bindings": [            {              "composite": "2DVector",              "up": "<Keyboard>/w",              "down": "<Keyboard>/s",              "left": "<Keyboard>/a",              "right": "<Keyboard>/d",              "processors": ["normalize"],              "scheme": "KeyboardMouse"            },            {              "composite": "2DVector",              "up": "<Keyboard>/arrowUp",              "down": "<Keyboard>/arrowDown",              "left": "<Keyboard>/arrowLeft",              "right": "<Keyboard>/arrowRight",              "processors": ["normalize"],              "scheme": "KeyboardMouse"            },            { "path": "<Gamepad>/leftStick", "processors": ["deadzone(0.2)"], "scheme": "Gamepad" },            { "path": "<Gamepad>/dpad", "scheme": "Gamepad" },            {              "path": "<Touch>/primaryTouch/delta",              "processors": ["scale(0.02)", "clamp(-1,1)"],              "scheme": "Touch"            }          ]        },        {          "name": "look",          "type": "vector2",          "bindings": [            { "path": "<Mouse>/delta", "processors": ["scale(0.08)", "clamp(-1,1)"], "scheme": "KeyboardMouse" },            { "path": "<Gamepad>/rightStick", "processors": ["deadzone(0.15)"], "scheme": "Gamepad" }          ]        },        {          "name": "aim",          "type": "vector2",          "bindings": [{ "path": "<Pointer>/position" }]        },        {          "name": "spin",          "type": "axis",          "bindings": [            {              "composite": "1DAxis",              "negative": "<Keyboard>/q",              "positive": "<Keyboard>/e",              "scheme": "KeyboardMouse"            },            {              "composite": "1DAxis",              "negative": "<Gamepad>/leftShoulder",              "positive": "<Gamepad>/rightShoulder",              "scheme": "Gamepad"            }          ]        },        {          "name": "boost",          "type": "axis",          "bindings": [            { "path": "<Keyboard>/shiftLeft", "scheme": "KeyboardMouse" },            { "path": "<Gamepad>/rightTrigger", "processors": ["deadzone(0.05)"], "scheme": "Gamepad" }          ]        },        {          "name": "jump",          "type": "button",          "bindings": [            { "path": "<Keyboard>/space", "scheme": "KeyboardMouse" },            { "path": "<Gamepad>/buttonSouth", "scheme": "Gamepad" },            { "path": "<Touch>/touch1/press", "scheme": "Touch" }          ]        },        {          "name": "fire",          "type": "button",          "bindings": [            { "path": "<Mouse>/leftButton", "scheme": "KeyboardMouse" },            { "path": "<Gamepad>/buttonWest", "scheme": "Gamepad" },            { "path": "<Touch>/touch2/press", "scheme": "Touch" }          ]        },        {          "name": "sprint",          "type": "button",          "bindings": [            {              "composite": "ButtonWithModifier",              "modifier": "<Keyboard>/shiftLeft",              "button": "<Keyboard>/w",              "scheme": "KeyboardMouse"            },            { "path": "<Gamepad>/leftStickPress", "scheme": "Gamepad" }          ]        }      ]    },    {      "name": "System",      "enabled": true,      "actions": [        {          "name": "toggleMap",          "type": "button",          "bindings": [            { "path": "<Keyboard>/escape", "scheme": "KeyboardMouse" },            { "path": "<Gamepad>/start", "scheme": "Gamepad" }          ]        }      ]    }  ]}
board.ts
import { clamp, createMaterialAsset, MeshAsset, MeshRenderer, pbrMaterialDefinition, Script } from "ignifx";import type { App, AssetHandle, ColorLike, Entity, InputAction, MaterialAsset, ScriptCallbacks } from "ignifx";/** * The instrument board `input-actions` reads its own input on: a standing panel of unlit geometry * that shows every value `@ignifx/input` resolved this frame. * * It is geometry rather than DOM or text for three reasons, and they are the reasons a game's HUD * is usually geometry too: * * 1. The example's poster and its golden are captured with `?nopanel=1`, so anything that only *    exists in the kit's parameter panel is not in the picture. A board made of meshes is. * 2. `HudText` and `WorldText` need a `FontAsset`, and no font is vendored under *    `website/examples/assets/`. Shape and colour carry the meaning instead — which is why the *    keyboard cluster is drawn as **key caps in the WASD arrangement** rather than as labelled *    boxes: the shape is the label. * 3. The kit is the only part of an example that touches the DOM *    (`website/plan/04-examples-platform.md` §4). A board of entities keeps that true. * * ## Three mesh templates, thirty-odd elements * * A unit box, a unit sphere and a unit ring are built once and shared; every element scales the * template it points at. A `MeshAsset` is a template entities clone, so sharing is the ordinary * thing to do and it keeps the whole panel at three pieces of geometry. * * Materials are **not** shared, because a material is what an element lights up with. * `MaterialAsset.setBaseColor` marks one uniform block dirty and recompiles nothing * (`packages/core/src/render/material-asset.ts`), so a lamp costs a colour write — and * {@link setLevel} skips even that when the value has not moved, which is why a board with nothing * happening on it costs nothing at all. * * Every board material is `unlit`, so a lamp shows exactly the colour it was given whatever the * scene's lights are doing. That is what makes the golden reproducible. *//** The site's dark palette (`website/plan/02-design-system.md` §2.3), decoded to sRGB `0…1`. */const INK = {  /**   * The board's face. A step lighter than `--surface`, because the board is a small object in a   * dark frame rather than a panel filling a screen, and the palette's own surface tone reads as   * black at this size — measured by capturing the poster and looking at it.   */  surface: { r: 0.098, g: 0.118, b: 0.153, a: 1 },  /** A well: a gate's plate, a bar's track. Darker than the face, so an element sits *in* it. */  sunk: { r: 0.035, g: 0.043, b: 0.059, a: 1 },  /** A key cap or a lamp at rest: light enough to read as an object that is currently off. */  rule: { r: 0.235, g: 0.275, b: 0.337, a: 1 },  /** A quiet mark: a ring, a dot at rest. */  quiet: { r: 0.4, g: 0.451, b: 0.522, a: 1 },  /** `--flame`: pressed, and the accent of the whole site. */  flame: { r: 1, g: 0.62, b: 0.29, a: 1 },  /** `--cool`: a resolved value — a stick dot, a bar fill. */  cool: { r: 0.353, g: 0.82, b: 0.784, a: 1 },  /** `--ok`: a device that is producing input, a map that is enabled. */  ok: { r: 0.357, g: 0.831, b: 0.541, a: 1 },  /** White: the frame `wasPressedThisFrame` is true in. */  hot: { r: 1, g: 1, b: 1, a: 1 },} as const;/** The board's face, in metres: wide enough to read at 640×360, and 16:9 in shape. */const FACE = { width: 5, height: 1.98, depth: 0.06 } as const;/** A gate's face, in metres. */const GATE_SIZE = 0.94;/** How far a gate's dot travels for a value of 1, in metres. */const GATE_REACH = 0.38;/** A bar's track height, in metres. */const BAR_HEIGHT = 0.9;/** A bar's track width, in metres. */const BAR_WIDTH = 0.22;/** The smallest scale an element is given, so no transform is ever singular. */const MIN_SCALE = 0.004;/** How long a `wasPressedThisFrame` flash takes to fall back to the held colour, in seconds. */const FLASH_SECONDS = 0.22;/** How long a device lamp stays lit after that device last produced input, in seconds. */const DEVICE_HOLD_SECONDS = 0.35;/** The level a lamp holds while its action is merely held, under the white of a fresh press. */const HELD_LEVEL = 0.58;/** The level a device lamp holds while its device is present but quiet. */const PRESENT_LEVEL = 0.36;/** How many steps a level is quantised to before it is written. */const LEVEL_STEPS = 48;/** How tall the board's legs are, in metres. */const LEG_HEIGHT = 0.3;/** The map the gameplay actions live in; `System` holds the one action that switches it off. */export const PLAYER_MAP = "Player";/** Where the board stands, in world metres, and how far it leans back. */export const BOARD_PLACEMENT = { x: 0, y: FACE.height / 2 + LEG_HEIGHT, z: 1.75, pitch: -6 } as const;/** A point on the board's face, in board-local metres. */interface Spot {  /** Metres right of the board's centre. */  readonly x: number;  /** Metres above the board's centre. */  readonly y: number;}/** A box on the board, in board-local metres. `z` is negative towards the camera. */interface Box {  /** Metres right of the board's centre. */  readonly x: number;  /** Metres above the board's centre. */  readonly y: number;  /** Metres towards the camera from the board's centre plane. */  readonly z: number;  /** Width, in metres. */  readonly w: number;  /** Height, in metres. */  readonly h: number;  /** Depth, in metres. */  readonly d: number;}/** The three mesh templates every element shares. */interface Shapes {  /** A unit box: every plate, cap, track and fill. */  readonly box: AssetHandle<MeshAsset>;  /** A unit-diameter sphere: the gates' dots. */  readonly sphere: AssetHandle<MeshAsset>;  /** A unit-diameter ring, built in the XZ plane, so an element stands it up. */  readonly ring: AssetHandle<MeshAsset>;}/** One element that changes colour: its own material, and the two colours it moves between. */interface Lamp {  /** The element's material. Nothing else uses it. */  readonly material: AssetHandle<MaterialAsset>;  /** The colour at level 0. */  readonly off: ColorLike;  /** The colour at level 1. */  readonly on: ColorLike;  /** The last level written, quantised, so an unchanged lamp costs nothing. */  written: number;}/** One key cap: a lamp lit by whatever the cap's key contributes to an action. */interface Cap {  /** The cap's lamp. */  readonly lamp: Lamp;  /**   * How lit the cap is, `0` to `1`.   *   * @returns The level.   */  readonly read: () => number;}/** One stick gate: a dot that tracks a `vector2` action inside its rings. */interface Gate {  /** The action the dot follows, or `null` when the document declares no such action. */  readonly action: InputAction | null;  /** The dot's entity, moved every frame. */  readonly dot: Entity;  /** The dot's lamp, lit by the action's magnitude. */  readonly lamp: Lamp;}/** One bar: a fill that grows out of a track. */interface Bar {  /** The action the fill follows. */  readonly action: InputAction | null;  /** The fill's entity, scaled and moved every frame. */  readonly fill: Entity;  /** The fill's lamp. */  readonly lamp: Lamp;  /** The track's centre, in board-local metres above the board's centre. */  readonly centreY: number;  /** Whether the fill grows out of the track's centre in both directions. */  readonly bipolar: boolean;}/** One button lamp, and the flash it carries after a press. */interface Button {  /** The action it watches. */  readonly action: InputAction | null;  /** The lamp. */  readonly lamp: Lamp;  /** Seconds of flash left, counted down on the unscaled clock. */  flash: number;}/** Which device family a lamp watches. */type DeviceRow = "keyboard" | "mouse" | "pointer" | "touch" | "gamepad";/** One device lamp: dim while its device is present, bright while it is producing input. */interface DeviceLamp {  /** The family it watches. */  readonly row: DeviceRow;  /** The lamp. */  readonly lamp: Lamp;  /** Seconds of hold left, so a keystroke reads rather than blinking for one frame. */  hold: number;}/** One control-scheme lamp, lit while `app.input.currentScheme` names it. */interface SchemeLamp {  /** The scheme's name, as the document declares it. */  readonly name: string;  /** The lamp. */  readonly lamp: Lamp;}/** Which device families produced input in one frame. */interface Activity {  /** A `keydown` or `keyup` arrived. */  keyboard: boolean;  /** A pointer event from a mouse, or a wheel, arrived. */  mouse: boolean;  /** Any pointer event arrived; every pointing device feeds `<Pointer>`. */  pointer: boolean;  /** A pointer event from a touch arrived. */  touch: boolean;  /** A connected pad is holding something down. Pads are polled, not evented. */  gamepad: boolean;}/** The elements one board is made of, in the order the update walks them. */interface Elements {  /** The WASD cluster, the shift cap and the space bar. */  readonly caps: readonly Cap[];  /** The two stick gates: `move` and `look`. */  readonly gates: readonly Gate[];  /** The two bars: `boost` and `spin`. */  readonly bars: readonly Bar[];  /** The four button lamps. */  readonly buttons: readonly Button[];  /** One lamp per device family. */  readonly devices: readonly DeviceLamp[];  /** One lamp per control scheme, in the document's order. */  readonly schemes: readonly SchemeLamp[];  /** The lamp that says whether the `Player` map is enabled. */  readonly mapLamp: Lamp;}/** * Writes a lamp's colour for a level in `0…1`. * * @remarks * Quantised to {@link LEVEL_STEPS} and skipped when the quantised level has not moved. A material * write marks a uniform block dirty and uploads it, so a still board should cost nothing — which is * what keeps this example inside its frame budget with thirty elements on screen. * * @param lamp - The lamp to write. * @param level - How lit it is, `0` to `1`. */function setLevel(lamp: Lamp, level: number): void {  const step = Math.round(clamp(level, 0, 1) * LEVEL_STEPS);  if (step === lamp.written) {    return;  }  lamp.written = step;  const t = step / LEVEL_STEPS;  lamp.material.value.setBaseColor({    r: lamp.off.r + (lamp.on.r - lamp.off.r) * t,    g: lamp.off.g + (lamp.on.g - lamp.off.g) * t,    b: lamp.off.b + (lamp.on.b - lamp.off.b) * t,    a: 1,  });}/** * Builds an unlit material for one board element. * * @param app - The app the asset belongs to. * @param name - The material's name, which the devtools inspector shows. * @param color - The colour it starts at. * @returns The handle, with one holder. */function createInkMaterial(app: App, name: string, color: ColorLike): AssetHandle<MaterialAsset> {  return createMaterialAsset(    app,    // `unlit` so the colour written is the colour drawn, and `doubleSided` so nothing depends on    // which way a shared template's winding happens to face.    pbrMaterialDefinition({      name: `board/${name}`,      baseColor: color,      metallic: 0,      roughness: 1,      unlit: true,      doubleSided: true,    }),    [],  );}/** * Adds one box to the board. * * @param app - The app the entity and its material belong to. * @param parent - The entity the box is parented to. * @param shapes - The shared templates. * @param name - The entity's name, which is what the devtools scene tree shows. * @param box - Where it sits and how big it is, in the parent's metres. * @param color - Its colour. * @returns The entity and its own material. */function addBox(  app: App,  parent: Entity,  shapes: Shapes,  name: string,  box: Box,  color: ColorLike,): { readonly entity: Entity; readonly material: AssetHandle<MaterialAsset> } {  const material = createInkMaterial(app, name, color);  const entity = app.world.createEntity(name);  entity.setParent(parent);  entity.transform.localPosition.set(box.x, box.y, box.z);  entity.transform.localScale.set(box.w, box.h, box.d);  entity.addComponent(MeshRenderer, {    mesh: shapes.box,    materials: [material],    castShadows: false,    receiveShadows: false,    pickable: false,  });  return { entity, material };}/** * Adds one lamp: a box that changes colour. * * @param app - The app. * @param parent - The board. * @param shapes - The shared templates. * @param name - The entity's name. * @param box - Where it sits and how big it is. * @param off - The colour at level 0. * @param on - The colour at level 1. * @returns The lamp. */function addLamp(  app: App,  parent: Entity,  shapes: Shapes,  name: string,  box: Box,  off: ColorLike,  on: ColorLike,): Lamp {  return { material: addBox(app, parent, shapes, name, box, off).material, off, on, written: 0 };}/** * Adds one ring, standing in the board's face rather than lying in the ground. * * @param app - The app. * @param parent - The gate's group. * @param shapes - The shared templates. * @param name - The entity's name. * @param diameter - The ring's diameter, in metres. * @param color - Its colour. */function addRing(app: App, parent: Entity, shapes: Shapes, name: string, diameter: number, color: ColorLike): void {  const entity = app.world.createEntity(name);  entity.setParent(parent);  // `MeshAsset.torus` is built in the XZ plane — it lies flat, the way the ground does — so a  // quarter turn about X is what stands it up in the face.  entity.transform.localEulerAngles = { x: 90, y: 0, z: 0 };  entity.transform.localScale.set(diameter, 0.05, diameter);  entity.transform.localPosition.set(0, 0, -0.03);  entity.addComponent(MeshRenderer, {    mesh: shapes.ring,    materials: [createInkMaterial(app, name, color)],    castShadows: false,    receiveShadows: false,    pickable: false,  });}/** * Builds one stick gate: a plate, a ring at a value of 1, an optional dead-zone ring, and a dot. * * @param app - The app. * @param parent - The board. * @param shapes - The shared templates. * @param name - The gate's name, used for its entities. * @param at - The gate's centre on the face, in board-local metres. * @param action - The `vector2` action the dot follows. * @param deadzone - The dead zone the action's processors apply, as a fraction of a value of 1; * `0` draws no inner ring. * @returns The gate. */function addGate(  app: App,  parent: Entity,  shapes: Shapes,  name: string,  at: Spot,  action: InputAction | null,  deadzone: number,): Gate {  // A group at scale 1, so every child below is stated in metres. A gate's plate is a sibling of  // its rings rather than their parent, because a non-uniform parent scale distorts a rotated child  // and the rings are rotated.  const group = app.world.createEntity(`${name} gate`);  group.setParent(parent);  group.transform.localPosition.set(at.x, at.y, -FACE.depth / 2);  addBox(app, group, shapes, `${name} plate`, { x: 0, y: 0, z: -0.015, w: GATE_SIZE, h: GATE_SIZE, d: 0.03 }, INK.sunk);  addRing(app, group, shapes, `${name} unit ring`, GATE_REACH * 2, INK.rule);  if (deadzone > 0) {    addRing(app, group, shapes, `${name} dead zone`, GATE_REACH * 2 * deadzone, INK.quiet);  }  const material = createInkMaterial(app, `${name} dot`, INK.quiet);  const dot = app.world.createEntity(`${name} dot`);  dot.setParent(group);  dot.transform.localScale.set(0.15, 0.15, 0.15);  dot.transform.localPosition.set(0, 0, -0.09);  dot.addComponent(MeshRenderer, {    mesh: shapes.sphere,    materials: [material],    castShadows: false,    receiveShadows: false,    pickable: false,  });  return { action, dot, lamp: { material, off: INK.quiet, on: INK.cool, written: 0 } };}/** * Builds one bar: a sunk track with a fill in front of it. * * @param app - The app. * @param parent - The board. * @param shapes - The shared templates. * @param name - The bar's name. * @param at - The track's centre on the face, in board-local metres. * @param action - The action the fill follows. * @param bipolar - Whether the fill grows out of the track's centre in both directions. * @returns The bar. */function addBar(  app: App,  parent: Entity,  shapes: Shapes,  name: string,  at: Spot,  action: InputAction | null,  bipolar: boolean,): Bar {  const face = -FACE.depth / 2;  addBox(    app,    parent,    shapes,    `${name} track`,    { x: at.x, y: at.y, z: face - 0.015, w: BAR_WIDTH, h: BAR_HEIGHT, d: 0.03 },    INK.sunk,  );  const material = createInkMaterial(app, `${name} fill`, INK.quiet);  const fill = app.world.createEntity(`${name} fill`);  fill.setParent(parent);  fill.transform.localPosition.set(at.x, at.y - BAR_HEIGHT / 2, face - 0.05);  fill.transform.localScale.set(BAR_WIDTH * 0.6, MIN_SCALE, 0.06);  fill.addComponent(MeshRenderer, {    mesh: shapes.box,    materials: [material],    castShadows: false,    receiveShadows: false,    pickable: false,  });  return { action, fill, lamp: { material, off: INK.quiet, on: INK.cool, written: 0 }, centreY: at.y, bipolar };}/** * The board's per-frame update: read the actions, write the geometry. * * @remarks * A `Script`, so it runs in `Update` with everything else and the devtools inspector lists it. Its * actions were looked up once with `app.input.actions.find`, which searches **every** map rather * than only the enabled ones — a held action whose map has been disabled reads as released, which * is exactly what the board should show when Escape has taken the `Player` map away. */export class ActionBoard extends Script implements ScriptCallbacks {  /** The namespaced registration id. */  static typeId = "input-actions/ActionBoard";  #elements: Elements | null = null;  /** Reused every frame, so the per-frame path allocates nothing (coding standards §7). */  readonly #activity: Activity = { keyboard: false, mouse: false, pointer: false, touch: false, gamepad: false };  /**   * Hands the script the elements {@link attachBoard} built.   *   * @param elements - The board's elements.   */  install(elements: Elements): void {    this.#elements = elements;  }  /**   * Reads this frame's input and writes it onto the board.   *   * @remarks   * Nothing here is integrated, so the scaled delta is not needed: the flashes and the device holds   * run on `time.unscaledDeltaTime`, which keeps them decaying while the game is slowed and stops   * them dead under `?static=1`, where the clock is frozen before the first frame.   */  update(): void {    const elements = this.#elements;    if (elements === null) {      return;    }    const unscaled = this.app.time.unscaledDeltaTime;    this.#readActivity();    for (const cap of elements.caps) {      setLevel(cap.lamp, cap.read());    }    for (const gate of elements.gates) {      updateGate(gate);    }    for (const bar of elements.bars) {      updateBar(bar);    }    for (const button of elements.buttons) {      updateButton(button, unscaled);    }    for (const device of elements.devices) {      this.#updateDevice(device, unscaled);    }    const current = this.app.input.currentScheme;    for (const scheme of elements.schemes) {      setLevel(scheme.lamp, scheme.name === current ? 1 : 0);    }    setLevel(elements.mapLamp, this.app.input.actions.map(PLAYER_MAP).enabled ? 1 : 0);  }  /**   * Fills {@link ActionBoard.#activity} from this frame's raw events and the polled pads.   *   * @remarks   * `app.input.events` is the frame's event list in arrival order, and its records are **pooled** —   * valid for the frame and recycled after it — so this reads them and keeps none. Whether a device   * is present is a different question, and a duller one: a keyboard always is, and what a visitor   * wants to see is which device the input they just gave came from.   */  #readActivity(): void {    const activity = this.#activity;    activity.keyboard = false;    activity.mouse = false;    activity.pointer = false;    activity.touch = false;    activity.gamepad = false;    const events = this.app.input.events;    for (let index = 0; index < events.length; index += 1) {      const event = events[index];      if (event === undefined) {        continue;      }      if (event.type === "keydown" || event.type === "keyup") {        activity.keyboard = true;        continue;      }      if (event.type === "wheel") {        activity.mouse = true;        continue;      }      activity.pointer = true;      if (event.pointerType === "touch") {        activity.touch = true;      } else {        activity.mouse = true;      }    }    const pads = this.app.input.gamepads;    for (let slot = 0; slot < pads.length; slot += 1) {      const pad = pads[slot];      if (pad === undefined || !pad.isConnected) {        continue;      }      const controls = pad.controls;      for (let index = 0; index < controls.length; index += 1) {        const control = controls[index];        if (control !== undefined && pad.valueAt(control.offset) !== 0) {          activity.gamepad = true;          break;        }      }    }  }  /**   * Lights one device lamp.   *   * @param device - The device lamp to update.   * @param unscaled - The unscaled frame delta, in seconds.   */  #updateDevice(device: DeviceLamp, unscaled: number): void {    if (this.#activity[device.row]) {      device.hold = DEVICE_HOLD_SECONDS;    } else if (device.hold > 0) {      device.hold = Math.max(device.hold - unscaled, 0);    }    if (device.hold > 0) {      setLevel(device.lamp, 1);      return;    }    const present = device.row === "gamepad" ? this.#anyPadConnected() : true;    setLevel(device.lamp, present ? PRESENT_LEVEL : 0);  }  /**   * Whether any gamepad slot is filled.   *   * @returns `true` when a pad has announced itself. A browser hides a pad until a button is   * pressed on it, so this starts `false` even with a pad plugged in.   */  #anyPadConnected(): boolean {    const pads = this.app.input.gamepads;    for (let slot = 0; slot < pads.length; slot += 1) {      if (pads[slot]?.isConnected === true) {        return true;      }    }    return false;  }}/** * Moves one gate's dot and lights it by the action's magnitude. * * @param gate - The gate to update. */function updateGate(gate: Gate): void {  const action = gate.action;  const x = action === null ? 0 : clamp(action.vector.x, -1, 1);  const y = action === null ? 0 : clamp(action.vector.y, -1, 1);  gate.dot.transform.localPosition.set(x * GATE_REACH, y * GATE_REACH, -0.09);  setLevel(gate.lamp, Math.hypot(x, y));}/** * Scales and places one bar's fill. * * @param bar - The bar to update. */function updateBar(bar: Bar): void {  const value = clamp(bar.action?.axis ?? 0, -1, 1);  if (bar.bipolar) {    // A bipolar fill grows out of the middle of its track, which is what an axis from two keys    // looks like: Q one way, E the other, nothing in the middle.    const height = Math.max(Math.abs(value) * (BAR_HEIGHT / 2), MIN_SCALE);    bar.fill.transform.localScale.set(BAR_WIDTH * 0.6, height, 0.06);    bar.fill.transform.localPosition.y = bar.centreY + Math.sign(value) * (height / 2);  } else {    const height = Math.max(value * BAR_HEIGHT, MIN_SCALE);    bar.fill.transform.localScale.set(BAR_WIDTH * 0.6, height, 0.06);    bar.fill.transform.localPosition.y = bar.centreY - BAR_HEIGHT / 2 + height / 2;  }  setLevel(bar.lamp, Math.abs(value));}/** * Lights one button lamp, and flashes it white for {@link FLASH_SECONDS} after the press frame. * * @param button - The button to update. * @param unscaled - The unscaled frame delta, in seconds. */function updateButton(button: Button, unscaled: number): void {  const action = button.action;  if (action !== null && action.wasPressedThisFrame) {    button.flash = FLASH_SECONDS;  } else if (button.flash > 0) {    button.flash = Math.max(button.flash - unscaled, 0);  }  if (button.flash > 0) {    setLevel(button.lamp, 1);    return;  }  setLevel(button.lamp, action?.isPressed === true ? HELD_LEVEL : 0);}/** * Clamps a signed axis to the positive half: what one key of a `2DVector` composite contributes. * * @param value - The axis value. * @returns `0` for a negative value, the clamped value for a positive one. */function positive(value: number): number {  return value > 0 ? Math.min(value, 1) : 0;}/** * Whether an action is held, as a level. * * @param action - The action, or `null`. * @returns `1` while it is pressed. */function held(action: InputAction | null): number {  return action?.isPressed === true ? 1 : 0;}/** * Builds the key-cap cluster: WASD in its cross, a shift cap and a space bar under it. * * @remarks * The caps are lit from the **actions**, not from the raw keys: W is lit by whatever `move` resolved * upwards this frame, which is why pushing a gamepad stick forward lights the W cap too. That is * the whole idea of an action map — the game asks for `"move"`, and the cluster is one view of it. * * @param app - The app. * @param parent - The board. * @param shapes - The shared templates. * @param move - The `move` action. * @param boost - The `boost` action, on the shift cap. * @param jump - The `jump` action, on the space bar. * @returns The caps, in reading order. */function addCaps(  app: App,  parent: Entity,  shapes: Shapes,  move: InputAction | null,  boost: InputAction | null,  jump: InputAction | null,): readonly Cap[] {  const z = -FACE.depth / 2 - 0.055;  const cap = (name: string, x: number, y: number, w: number, read: () => number): Cap => ({    lamp: addLamp(app, parent, shapes, `${name} key`, { x, y, z, w, h: 0.3, d: 0.11 }, INK.rule, INK.flame),    read,  });  return [    cap("W", -1.74, 0.64, 0.3, (): number => positive(move?.vector.y ?? 0)),    cap("A", -2.1, 0.28, 0.3, (): number => positive(-(move?.vector.x ?? 0))),    cap("S", -1.74, 0.28, 0.3, (): number => positive(-(move?.vector.y ?? 0))),    cap("D", -1.38, 0.28, 0.3, (): number => positive(move?.vector.x ?? 0)),    cap("Shift", -2.11, -0.14, 0.66, (): number => clamp(boost?.axis ?? 0, 0, 1)),    cap("Space", -1.33, -0.14, 0.84, (): number => held(jump)),  ];}/** * Builds the device row: one lamp per device family `@ignifx/input` has, left to right. * * @param app - The app. * @param parent - The board. * @param shapes - The shared templates. * @returns The five lamps. */function addDeviceLamps(app: App, parent: Entity, shapes: Shapes): readonly DeviceLamp[] {  const rows: readonly DeviceRow[] = ["keyboard", "mouse", "pointer", "touch", "gamepad"];  const z = -FACE.depth / 2 - 0.06;  return rows.map((row: DeviceRow, index: number): DeviceLamp => {    const name = `${row.charAt(0).toUpperCase()}${row.slice(1)}`;    return {      row,      lamp: addLamp(        app,        parent,        shapes,        `${name} device`,        { x: -2.16 + index * 0.44, y: -0.7, z, w: 0.38, h: 0.3, d: 0.07 },        INK.rule,        INK.ok,      ),      hold: 0,    };  });}/** * Builds the scheme row: one lamp per control scheme the document declares. * * @remarks * `app.input.controlSchemes` is the document's own list, so this row is whatever the `.input.json` * says and nothing is hard-coded here. The active scheme follows the device that produced input * last, which is what a HUD reads to choose its glyphs. * * @param app - The app. * @param parent - The board. * @param shapes - The shared templates. * @returns One lamp per scheme, in the document's order. */function addSchemeLamps(app: App, parent: Entity, shapes: Shapes): readonly SchemeLamp[] {  const z = -FACE.depth / 2 - 0.06;  return app.input.controlSchemes.map((scheme, index): SchemeLamp => ({    name: scheme.name,    lamp: addLamp(      app,      parent,      shapes,      `${scheme.name} scheme`,      { x: 1.0 + index * 0.58, y: -0.7, z, w: 0.52, h: 0.3, d: 0.07 },      INK.rule,      INK.flame,    ),  }));}/** * Builds the board and attaches the script that drives it. * * @remarks * One call, the way the kit's `attachOrbit` is one call. Every action is looked up **once**, here, * because `find` walks the map table and the update runs sixty times a second — and because a name * the `.input.json` does not declare should read as `null` from the start rather than as a lookup * that fails every frame. * * @param app - The running app; needs the `input()` extension and a loaded action document. * @returns The board's root entity, so a caller can move or hide it. * * @example * ```ts * app.input.loadActions(actions); * const board = attachBoard(app); * ``` */export function attachBoard(app: App): Entity {  app.registerComponents([ActionBoard]);  const shapes: Shapes = {    box: MeshAsset.box(app, { size: 1 }),    sphere: MeshAsset.sphere(app, { diameter: 1, segments: 12 }),    ring: MeshAsset.torus(app, { diameter: 1, thickness: 0.075, tessellation: 32 }),  };  const root = app.world.createEntity("Board");  root.transform.localPosition.set(BOARD_PLACEMENT.x, BOARD_PLACEMENT.y, BOARD_PLACEMENT.z);  root.transform.localEulerAngles = { x: BOARD_PLACEMENT.pitch, y: 0, z: 0 };  addBox(app, root, shapes, "Face", { x: 0, y: 0, z: 0, w: FACE.width, h: FACE.height, d: FACE.depth }, INK.surface);  // Two legs, so the panel stands on the pad rather than floating over it. `BOARD_PLACEMENT.y` is  // half the face plus this height, which is what puts their feet on the ground.  for (const side of [-1, 1]) {    addBox(      app,      root,      shapes,      side < 0 ? "Leg left" : "Leg right",      { x: side * 2.16, y: -(FACE.height + LEG_HEIGHT) / 2, z: 0, w: 0.14, h: LEG_HEIGHT, d: 0.14 },      INK.rule,    );  }  // A sunk strip under the status row, so its lamps read against a well rather than against the  // face they are almost the same size as.  addBox(    app,    root,    shapes,    "Status well",    { x: 0, y: -0.7, z: -FACE.depth / 2 - 0.015, w: FACE.width - 0.36, h: 0.44, d: 0.03 },    INK.sunk,  );  const find = (name: string): InputAction | null => app.input.actions.find(name);  const move = find("move");  const jump = find("jump");  const boost = find("boost");  const buttonZ = -FACE.depth / 2 - 0.045;  const buttonBox = (x: number, y: number): Box => ({ x, y, z: buttonZ, w: 0.32, h: 0.32, d: 0.09 });  const buttonLamp = (name: string, x: number, y: number): Lamp =>    addLamp(app, root, shapes, `${name} lamp`, buttonBox(x, y), INK.rule, INK.hot);  const elements: Elements = {    caps: addCaps(app, root, shapes, move, boost, jump),    gates: [      addGate(app, root, shapes, "Move", { x: -0.5, y: 0.22 }, move, 0.2),      addGate(app, root, shapes, "Look", { x: 0.52, y: 0.22 }, find("look"), 0.15),    ],    bars: [      addBar(app, root, shapes, "Boost", { x: 1.16, y: 0.22 }, boost, false),      addBar(app, root, shapes, "Spin", { x: 1.46, y: 0.22 }, find("spin"), true),    ],    buttons: [      { action: jump, lamp: buttonLamp("Jump", 1.84, 0.47), flash: 0 },      { action: find("fire"), lamp: buttonLamp("Fire", 2.22, 0.47), flash: 0 },      { action: find("sprint"), lamp: buttonLamp("Sprint", 1.84, 0.07), flash: 0 },      { action: find("toggleMap"), lamp: buttonLamp("Escape", 2.22, 0.07), flash: 0 },    ],    devices: addDeviceLamps(app, root, shapes),    schemes: addSchemeLamps(app, root, shapes),    mapLamp: addLamp(      app,      root,      shapes,      "Player map",      { x: 0.2, y: -0.7, z: -FACE.depth / 2 - 0.06, w: 0.72, h: 0.3, d: 0.07 },      INK.rule,      INK.ok,    ),  };  root.addComponent(ActionBoard).install(elements);  return root;}
rover.ts
import {  clamp,  createMaterialAsset,  createRay,  f32,  MeshAsset,  MeshRenderer,  pbrMaterialDefinition,  Script,} from "ignifx";import type {  App,  AssetHandle,  Camera,  ColorLike,  Entity,  InputAction,  MaterialAsset,  Ray,  ScriptCallbacks,} from "ignifx";/** * The thing the actions move: a small rover on the pad, and the crosshair the pointer aims with. * * The board next door shows what `@ignifx/input` resolved; this shows what a game does with it. * Everything the rover reads is an **action**, never a key or a button, which is why one script * drives it from a keyboard, a pad or a touch screen with no branch per device: * * | Action   | Effect                                                                       | * | -------- | ---------------------------------------------------------------------------- | * | `move`   | Drives it across the pad and turns it to face where it is going              | * | `spin`   | Turns it in place, from the `1DAxis` composite on Q and E or the shoulders   | * | `boost`  | Multiplies its speed, from an analog trigger or the shift key                | * | `jump`   | One hop, integrated against gravity                                          | * | `fire`   | Flashes the muzzle                                                            | * | `look`   | Yaws and pitches the turret                                                   | * | `aim`    | Places the crosshair, converting backing-store pixels into a point on the pad | * * A disabled `Player` map makes every one of those read as released, so pressing Escape parks the * rover rather than breaking it. That is what "a map is how a game switches context" means. *//** Where the rover's body sits above the pad, in metres. */const RIDE_HEIGHT = 0.22;/** The heading the rover starts on, in degrees; a yaw of zero looks away from the camera. */const START_YAW_DEGREES = 215;/** How far from the pad's centre the rover may go, in metres. */const PAD_LIMIT = { x: 2.2, near: -2.4, far: 0.95 } as const;/** How fast the rover turns to face its heading, in degrees per second. */const TURN_DEGREES_PER_SECOND = 420;/** How fast `spin` turns the rover in place, in degrees per second. */const SPIN_DEGREES_PER_SECOND = 150;/** How far the turret yaws and pitches, in degrees per second at full stick. */const TURRET_DEGREES_PER_SECOND = 90;/** How far the turret may pitch from level, in degrees. */const TURRET_PITCH_LIMIT = 24;/** How long the muzzle stays lit after a `fire` press, in seconds. */const MUZZLE_SECONDS = 0.12;/** How many steps the thruster's glow is quantised to before it is written. */const THRUSTER_STEPS = 24;/** The rover's warm hull, in sRGB. */const HULL: ColorLike = { r: 0.95, g: 0.45, b: 0.14, a: 1 };/** The canopy and the skids: near-black metal, in sRGB. */const METAL: ColorLike = { r: 0.2, g: 0.24, b: 0.3, a: 1 };/** The muzzle and thruster at rest, in sRGB. */const EMBER_OFF: ColorLike = { r: 0.42, g: 0.22, b: 0.11, a: 1 };/** The muzzle and thruster lit, in sRGB. */const EMBER_ON: ColorLike = { r: 1, g: 0.78, b: 0.42, a: 1 };/** The crosshair, in sRGB: the site's `--cool`. */const CROSSHAIR: ColorLike = { r: 0.353, g: 0.82, b: 0.784, a: 1 };/** A point or a size in metres, named so a doc comment does not have to describe three fields. */interface Point3 {  /** Metres along X. */  readonly x: number;  /** Metres along Y. */  readonly y: number;  /** Metres along Z. */  readonly z: number;}/** The actions one rover reads, resolved once. */interface RoverActions {  /** Drives across the pad. */  readonly move: InputAction | null;  /** Turns in place. */  readonly spin: InputAction | null;  /** Multiplies the speed, and lights the thruster. */  readonly boost: InputAction | null;  /** One hop per press. */  readonly jump: InputAction | null;  /** Flashes the muzzle. */  readonly fire: InputAction | null;  /** Aims the turret. */  readonly look: InputAction | null;  /** Places the crosshair. */  readonly aim: InputAction | null;}/** The parts one rover is built from, handed to its script after the entities exist. */interface RoverParts {  /** The turret group, yawed and pitched by `look`. */  readonly turret: Entity;  /** The muzzle, lit for {@link MUZZLE_SECONDS} after a `fire` press. */  readonly muzzle: AssetHandle<MaterialAsset>;  /** The thruster, lit by `boost`. */  readonly thruster: AssetHandle<MaterialAsset>;  /** The crosshair ring, moved to where the pointer meets the pad and hidden when it misses. */  readonly crosshair: Entity;  /** The camera the crosshair's ray is cast through. */  readonly camera: Camera;  /** The actions the rover reads, resolved once by {@link attachRover}. */  readonly actions: RoverActions;}/** * Drives the rover from the `Player` map. * * @remarks * Every field is serialized, so the devtools inspector shows and edits the live numbers and a scene * file could carry them. */export class Rover  extends Script.define({    speed: f32(2.3, { min: 0, tooltip: "Metres per second at full stick, before boost." }),    boostFactor: f32(1.8, { min: 1, tooltip: "What a full boost multiplies the speed by." }),    jumpSpeed: f32(3.1, { min: 0, tooltip: "Upward metres per second at the start of a hop." }),    gravity: f32(9.5, { min: 0, tooltip: "Downward metres per second squared." }),  })  implements ScriptCallbacks{  /** The namespaced registration id. */  static typeId = "input-actions/Rover";  #parts: RoverParts | null = null;  /** Metres per second upward; `0` while the rover is on the pad. */  #lift = 0;  /** Seconds of muzzle flash left. */  #muzzle = 0;  /** The turret's yaw, in degrees. */  #turretYaw = 0;  /** The turret's pitch, in degrees. */  #turretPitch = 0;  /** The last thruster level written, quantised, so an idle thruster uploads nothing. */  #thrusterLevel = -1;  /** Whether the muzzle is currently written lit. */  #muzzleLit = false;  /**   * Reused so the per-frame path allocates nothing (coding standards §7).   *   * @remarks   * `createRay()` rather than an object literal, and the difference is not cosmetic: a ray's   * `length` is how far it reaches, `createRay` sets it to `Number.MAX_VALUE`, and a literal   * that forgets it stops the cast short of everything in the scene.   */  readonly #ray: Ray = createRay();  /**   * Hands the script the parts {@link attachRover} built.   *   * @param parts - The rover's parts, including its already-resolved actions.   */  install(parts: RoverParts): void {    this.#parts = parts;  }  /**   * Reads the frame's actions and moves the rover.   *   * @param dt - Seconds since the previous frame, scaled by `time.timeScale`. Under `?static=1` the   * scale is zero, so the rover holds the pose this file authored.   */  update(dt: number): void {    const parts = this.#parts;    if (parts === null) {      return;    }    const actions = parts.actions;    const boost = clamp(actions.boost?.axis ?? 0, 0, 1);    this.#drive(actions.move, actions.spin, boost, dt);    this.#hop(actions.jump, dt);    this.#aimTurret(actions.look, dt);    this.#flash(actions.fire, parts, boost);    this.#placeCrosshair(actions.aim, parts);  }  /**   * Moves the rover across the pad and turns it.   *   * @param move - The `move` action.   * @param spin - The `spin` action.   * @param boost - The boost level, `0` to `1`.   * @param dt - The scaled frame delta, in seconds.   */  #drive(move: InputAction | null, spin: InputAction | null, boost: number, dt: number): void {    const x = move?.vector.x ?? 0;    const z = move?.vector.y ?? 0;    const speed = this.speed * (1 + boost * (this.boostFactor - 1));    const position = this.transform.localPosition;    position.x = clamp(position.x + x * speed * dt, -PAD_LIMIT.x, PAD_LIMIT.x);    position.z = clamp(position.z + z * speed * dt, PAD_LIMIT.near, PAD_LIMIT.far);    const turn = spin?.axis ?? 0;    if (turn !== 0) {      this.transform.rotate({ x: 0, y: turn * SPIN_DEGREES_PER_SECOND * dt, z: 0 });      return;    }    if (Math.hypot(x, z) < 0.05) {      return;    }    // ignifx is left-handed with +Z forward, so a yaw of zero looks along +Z and the heading of a    // stick vector is `atan2(x, y)` — the same expression a top-down game uses for its character.    const target = Math.atan2(x, z) * (180 / Math.PI);    const current = this.transform.localEulerAngles.y;    const delta = wrapDegrees(target - current);    const step = TURN_DEGREES_PER_SECOND * dt;    this.transform.localEulerAngles = {      x: 0,      y: current + (Math.abs(delta) <= step ? delta : Math.sign(delta) * step),      z: 0,    };  }  /**   * Integrates one hop.   *   * @param jump - The `jump` action.   * @param dt - The scaled frame delta, in seconds.   */  #hop(jump: InputAction | null, dt: number): void {    const position = this.transform.localPosition;    if (jump?.wasPressedThisFrame === true && position.y <= RIDE_HEIGHT + 0.001) {      this.#lift = this.jumpSpeed;    }    if (this.#lift === 0 && position.y <= RIDE_HEIGHT) {      return;    }    this.#lift -= this.gravity * dt;    position.y += this.#lift * dt;    if (position.y <= RIDE_HEIGHT) {      position.y = RIDE_HEIGHT;      this.#lift = 0;    }  }  /**   * Yaws and pitches the turret from `look`.   *   * @param look - The `look` action.   * @param dt - The scaled frame delta, in seconds.   */  #aimTurret(look: InputAction | null, dt: number): void {    const parts = this.#parts;    if (parts === null || look === null) {      return;    }    const step = TURRET_DEGREES_PER_SECOND * dt;    this.#turretYaw = wrapDegrees(this.#turretYaw + look.vector.x * step);    this.#turretPitch = clamp(this.#turretPitch - look.vector.y * step, -TURRET_PITCH_LIMIT, TURRET_PITCH_LIMIT);    parts.turret.transform.localEulerAngles = { x: this.#turretPitch, y: this.#turretYaw, z: 0 };  }  /**   * Lights the muzzle after a `fire` press and the thruster while the rover is boosting.   *   * @param fire - The `fire` action.   * @param parts - The rover's parts.   * @param boost - The boost level, `0` to `1`.   */  #flash(fire: InputAction | null, parts: RoverParts, boost: number): void {    if (fire?.wasPressedThisFrame === true) {      this.#muzzle = MUZZLE_SECONDS;    } else if (this.#muzzle > 0) {      this.#muzzle = Math.max(this.#muzzle - this.app.time.unscaledDeltaTime, 0);    }    // Both writes are guarded: `setBaseColor` marks a uniform block dirty and uploads it, and a    // rover standing still should not be paying for that sixty times a second.    const lit = this.#muzzle > 0;    if (lit !== this.#muzzleLit) {      this.#muzzleLit = lit;      parts.muzzle.value.setBaseColor(lit ? EMBER_ON : EMBER_OFF);    }    const level = Math.round(boost * THRUSTER_STEPS);    if (level !== this.#thrusterLevel) {      this.#thrusterLevel = level;      parts.thruster.value.setBaseColor(mix(EMBER_OFF, EMBER_ON, level / THRUSTER_STEPS));    }  }  /**   * Puts the crosshair where the pointer meets the pad.   *   * @remarks   * This is the conversion worth reading twice. `<Pointer>/position` is in the canvas's   * **backing-store pixels** — the space `Camera.screenToRay`, `worldToScreen` and   * `renderer.pickAsync` all share — so the action's vector goes straight into `screenToRay` with   * no arithmetic. DOM code is the side that divides by `devicePixelRatio`, not this side.   *   * The ray is then met with the pad's plane by hand, because a pad is a plane and not a collider:   * `t = (padY - originY) / directionY`. A ray that misses the pad, or points away from it, hides   * the crosshair rather than parking it on an edge.   *   * @param aim - The `aim` action.   * @param parts - The rover's parts.   */  #placeCrosshair(aim: InputAction | null, parts: RoverParts): void {    if (aim === null || (aim.vector.x === 0 && aim.vector.y === 0)) {      parts.crosshair.active = false;      return;    }    const ray = parts.camera.screenToRay(aim.vector.x, aim.vector.y, this.#ray);    if (ray === null || ray.direction.y >= 0) {      parts.crosshair.active = false;      return;    }    const t = -ray.origin.y / ray.direction.y;    const x = ray.origin.x + ray.direction.x * t;    const z = ray.origin.z + ray.direction.z * t;    if (Math.abs(x) > PAD_LIMIT.x + 0.6 || z < PAD_LIMIT.near - 0.6 || z > PAD_LIMIT.far + 0.6) {      parts.crosshair.active = false;      return;    }    parts.crosshair.active = true;    parts.crosshair.transform.localPosition.set(x, 0.012, z);  }}/** * Wraps an angle into `-180…180`. * * @param degrees - The angle. * @returns The same angle, in the shortest form. */function wrapDegrees(degrees: number): number {  const wrapped = (((degrees + 180) % 360) + 360) % 360;  return wrapped - 180;}/** * Interpolates two sRGB colours. * * @param from - The colour at `t = 0`. * @param to - The colour at `t = 1`. * @param t - Where to sample, `0` to `1`. * @returns The blended colour, opaque. */function mix(from: ColorLike, to: ColorLike, t: number): ColorLike {  return {    r: from.r + (to.r - from.r) * t,    g: from.g + (to.g - from.g) * t,    b: from.b + (to.b - from.b) * t,    a: 1,  };}/** * Builds the rover, the crosshair, and the script that drives them. * * @param app - The running app; needs the `input()` extension and a loaded action document. * @param camera - The camera the crosshair's ray is cast through. * @returns The rover's script, so the panel can bind its fields. * * @example * ```ts * const rover = attachRover(app, camera); * rover.speed = 4; * ``` */export function attachRover(app: App, camera: Camera): Rover {  app.registerComponents([Rover]);  const lit = (name: string, color: ColorLike, metallic: number, roughness: number): AssetHandle<MaterialAsset> =>    createMaterialAsset(      app,      pbrMaterialDefinition({ name: `rover/${name}`, baseColor: color, metallic, roughness }),      [],    );  const glow = (name: string, color: ColorLike): AssetHandle<MaterialAsset> =>    createMaterialAsset(      app,      pbrMaterialDefinition({ name: `rover/${name}`, baseColor: color, metallic: 0, roughness: 1, unlit: true }),      [],    );  const box = MeshAsset.box(app, { size: 1 });  const sphere = MeshAsset.sphere(app, { diameter: 1, segments: 16 });  const tube = MeshAsset.cylinder(app, { diameter: 1, height: 1, tessellation: 16 });  const hull = lit("hull", HULL, 0.35, 0.42);  const metal = lit("metal", METAL, 0.25, 0.38);  const muzzle = glow("muzzle", EMBER_OFF);  const thruster = glow("thruster", EMBER_OFF);  const root = app.world.createEntity("Rover");  root.transform.localPosition.set(0, RIDE_HEIGHT, -0.9);  // Three-quarters on to the camera at the start, so a frozen capture shows the nose, the mast and  // one flank rather than the back of a box. `#drive` only turns it once the player moves.  root.transform.localEulerAngles = { x: 0, y: START_YAW_DEGREES, z: 0 };  // The camera looks down on the pad, so the rover is built to read from above: a flat deck, four  // wheels outside its silhouette, a light bar at the nose and a glowing vent at the tail.  addPart(app, root, "Deck", box, hull, { x: 0, y: 0, z: 0 }, { x: 0.62, y: 0.18, z: 0.94 }, true);  addPart(app, root, "Nose", box, hull, { x: 0, y: -0.02, z: 0.56 }, { x: 0.44, y: 0.13, z: 0.22 }, true);  addPart(app, root, "Light bar", box, muzzle, { x: 0, y: 0.03, z: 0.66 }, { x: 0.34, y: 0.06, z: 0.05 }, false);  for (const side of [-1, 1]) {    for (const end of [-1, 1]) {      const label = `${side < 0 ? "Left" : "Right"} ${end < 0 ? "rear" : "front"} wheel`;      addPart(        app,        root,        label,        tube,        metal,        { x: side * 0.37, y: -0.05, z: end * 0.31 },        // Scale first, then rotate: a unit cylinder is a disc of diameter 1 in XZ standing 1 along        // Y, so 0.26 across and 0.12 tall is a wheel, and the quarter turn lays it on its axle.        { x: 0.26, y: 0.12, z: 0.26 },        true,        // `MeshAsset.cylinder` stands along Y, so a quarter turn about Z lays a wheel on its axle.        { x: 0, y: 0, z: 90 },      );    }  }  addPart(app, root, "Vent", tube, thruster, { x: 0, y: 0.02, z: -0.5 }, { x: 0.3, y: 0.08, z: 0.3 }, false, {    x: 90,    y: 0,    z: 0,  });  const turret = app.world.createEntity("Turret");  turret.setParent(root);  turret.transform.localPosition.set(0, 0.11, -0.06);  addPart(app, turret, "Canopy", sphere, metal, { x: 0, y: 0, z: 0 }, { x: 0.32, y: 0.2, z: 0.36 }, true);  addPart(app, turret, "Mast", box, metal, { x: 0, y: 0.03, z: 0.28 }, { x: 0.07, y: 0.06, z: 0.36 }, true);  addPart(app, turret, "Muzzle", box, muzzle, { x: 0, y: 0.03, z: 0.47 }, { x: 0.1, y: 0.07, z: 0.09 }, false);  // A flat ring on the pad. `MeshAsset.torus` is built in the XZ plane, which is where a mark on  // the ground wants to be, so this one needs no rotation at all.  const crosshair = app.world.createEntity("Crosshair");  crosshair.transform.localScale.set(0.34, 0.06, 0.34);  crosshair.addComponent(MeshRenderer, {    mesh: MeshAsset.torus(app, { diameter: 1, thickness: 0.12, tessellation: 24 }),    materials: [glow("crosshair", CROSSHAIR)],    castShadows: false,    receiveShadows: false,    pickable: false,  });  crosshair.active = false;  // `find` rather than `get`: `get` searches the *enabled* maps and throws `IGX-0801` the moment  // Escape disables `Player`, while a handle taken this way keeps working and reads as released.  const find = (name: string): InputAction | null => app.input.actions.find(name);  const rover = root.addComponent(Rover);  rover.install({    turret,    muzzle,    thruster,    crosshair,    camera,    actions: {      move: find("move"),      spin: find("spin"),      boost: find("boost"),      jump: find("jump"),      fire: find("fire"),      look: find("look"),      aim: find("aim"),    },  });  return rover;}/** * Adds one part of the rover. * * @param app - The app. * @param parent - The entity the part hangs from. * @param name - The part's name. * @param mesh - The template it clones. * @param material - The material it wears. * @param at - Where its centre sits, in the parent's metres. * @param size - Its size along each axis, in metres. * @param casts - Whether it casts a shadow. * @param spin - Euler angles in degrees, for a part whose template stands the wrong way. */function addPart(  app: App,  parent: Entity,  name: string,  mesh: AssetHandle<MeshAsset>,  material: AssetHandle<MaterialAsset>,  at: Point3,  size: Point3,  casts: boolean,  spin?: Point3,): void {  const entity = app.world.createEntity(name);  entity.setParent(parent);  entity.transform.localPosition.set(at.x, at.y, at.z);  entity.transform.localScale.set(size.x, size.y, size.z);  if (spin !== undefined) {    entity.transform.localEulerAngles = spin;  }  entity.addComponent(MeshRenderer, {    mesh,    materials: [material],    castShadows: casts,    receiveShadows: false,    pickable: false,  });}

Uses:app.input.loadActionsvalidateInputActionsInputActionActionMapapp.input.eventsapp.input.currentSchemeCamera.screenToRay

Assets:everything in this example is created in code.