ignifx
GitHubnpm · soon
All examples

Input actions

Input3D

  • Keyboard
  • Mouse
  • Gamepad
  • Touch

Nothing in this program names a key. `player.input.json` beside `main.ts` is the whole input configuration — two maps, three control schemes, all three composites (`2DVector` for WASD, `1DAxis` for Q and E, `ButtonWithModifier` for shift-W) and the processors that make a stick and a keyboard agree. The board behind the rover is that document made visible: key caps light from the `move` action, the two dials are its `vector2` values inside their dead-zone rings, the bars are its axes, and the strip along the bottom shows which device produced input last, which control scheme that selected, and whether the `Player` map is enabled. The rover in front reads the same actions and drives on them.

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 dot sits on the ring, not past it, because a normalise processor keeps a diagonal at one.
  • Squeeze a gamepad trigger slowly and watch the boost bar fill before the action counts as pressed.
  • Press Escape: the Player map switches off, every gate and lamp on it goes dead, and the device row keeps reading.

Read the guide

Show source

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";/** * Every device and every action, live: an `.input.json` document, the board it lights up, and the * rover it drives. * * Read `player.input.json` beside this file first — it is the whole input configuration, and * nothing in this program names a key. Two maps: `Player`, which holds the gameplay actions, and * `System`, which holds the one action that switches `Player` off. Inside `Player` are the three * composites (`2DVector` for WASD, `1DAxis` for Q and E, `ButtonWithModifier` for shift-W), the * processors that make a stick and a keyboard agree (`deadzone`, `normalize`, `scale`, `clamp`), and * three control schemes. * * ## The document is imported, not addressed * * `?raw` hands the file over as text, `JSON.parse` turns it into a value and `validateInputActions` * turns that into the definition `loadActions` takes — the **same** check the asset loader runs, so * a typo in the document is an error here rather than a silently missing action. The alternative is * to put the file under `website/examples/assets/` and load it by address, which is what a game * does; it is imported here so the viewer page can show it as a tab next to this file, which is the * point of the example. * * A document named in `input({ actions })` is **not** awaited: it is delivered like any other asset, * in a later frame (`packages/input/skills/input/SKILL.md`). Loading it before `app.start()` is what * makes the first frame have its maps — and the board resolve its actions in `attachBoard`. *//** * 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.