All examples

Rebinding

Input3D
  • Keyboard
  • Gamepad
  • Mouse

Rebind an action to a key or gamepad button, then try the new control. The rack shows each binding and its device type. Your changes are saved locally, so they remain after a page reload.

Off airLivePaused

Rebinding

Input · 3D

Four dark plinths stand in a row on a grey grid floor that fades into the dark behind them, each capped with a bright orange block, and an orange ball rests on a long dark track in front of them.
Open standalone

WebGPU: checking…

Try this

  1. Press Rebind hop, then press any key: the cap goes orange for a keyboard, and the panel says which key.
  2. Rebind one to a gamepad button and watch that cap turn green while the other three stay orange.
  3. Reload the page to check that your bindings were saved. Choose Reset to defaults to clear them.
Read the guide
Show source code
main.ts
import {  Camera,  controlPath,  defineInputActions,  formatBindingPath,  isIgnifxError,  validateInputActions,} from "ignifx";import { bootExample } from "../_kit/boot.ts";import { button, readout } from "../_kit/panel.ts";import { createGridGround, createLightRig } from "../_kit/stage.ts";import actionsSource from "./player.input.json?raw";import { attachRack } from "./rack.ts";import { FixedShot, SHOT } from "./shot.ts";import type { BindingRack } from "./rack.ts";import type { PanelControl } from "../_kit/panel.ts";import type { App, InputOverridesJson } from "ignifx";/** * Rebind the first binding of each action — a keyboard key until the player picks otherwise — and * leave the second, the gamepad's, as `player.input.json` declares it. Any key or gamepad control is * accepted, so a gamepad button bound here moves that action's first slot to the pad; a click or a * tap on the frame cancels, as Escape does. * Only one interactive rebind can listen at a time (`IGX-0807`). Save overrides through app storage * and catch incompatible saved documents (`IGX-0808`) so an old binding layout cannot stop the game. *//** The actions this example rebinds, left to right on the rack. */const ACTIONS = ["left", "right", "hop", "brake"] as const;/** Which binding of each action a rebind overwrites: the first, a keyboard key by default. */const REBOUND_BINDING = 0;/** How long a rebind listens before giving up, in unscaled seconds. */const LISTEN_SECONDS = 5;/** Where the saved overrides live: `app.storage`, under this namespace and key. */const STORE = { namespace: "rebinding", key: "overrides" } as const;/** * The prompt's own action: a press on the frame, which cancels a rebind that is listening. Its own * map, so `player.input.json` stays the four actions the rack rebinds. */const PROMPT_ACTIONS = defineInputActions({  maps: [{ name: "Prompt", actions: [{ name: "dismiss", type: "button", bindings: [{ path: "<Pointer>/press" }] }] }],});/** The clear colour, in sRGB: the site's dark `--bg`, which the floor fades into behind the rack. */const CLEAR = { r: 0.051, g: 0.063, b: 0.082, a: 1 } as const;/** Where the floor's fade begins and ends, in metres from the fixed camera, and the floor's size. */const FADE = { start: 10, end: 22, groundSize: 60 } as const;/** * Reads one action's keyboard binding the way a settings row shows it. * * @param app - The running app. * @param name - The action name. * @returns E.g. `Keyboard: W`, or `—` when the binding is unbound. */function bindingLabel(app: App, name: string): string {  const path = app.input.actions.find(name)?.bindings[REBOUND_BINDING]?.effectivePath ?? "";  return formatBindingPath(path, "—");}bootExample({  title: "Rebinding",  settings: {    rendering: {      clearColor: CLEAR,      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 }) {    // `JSON.parse` answers `any`, so the document is `unknown` at the boundary and    // `validateInputActions` — the same check the asset loader runs — is what narrows it.    const parsed: unknown = JSON.parse(actionsSource);    app.input.loadActions(validateInputActions(parsed, "player.input.json"));    const store = app.storage.namespace(STORE.namespace);    const saved = await store.get<InputOverridesJson>(STORE.key);    if (saved !== null) {      try {        app.input.loadOverrides(saved);      } catch (error: unknown) {        // Player data from an older build: drop it, say so, and carry on with the defaults.        app.log.warn("dropping unusable rebindings:", isIgnifxError(error) ? error.code : error);        await store.delete(STORE.key);      }    }    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);    const camera = eye.addComponent(Camera, { near: 0.05, far: 120, fov: SHOT.fov });    // A fixed shot, which stands back and widens on a phone held upright.    app.registerComponents([FixedShot]);    eye.addComponent(FixedShot);    // 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: FADE.groundSize, color: { r: 0.26, g: 0.28, b: 0.33, a: 1 }, fade: FADE });    createLightRig(app, { focus: { x: 0, y: 0.4, z: 0 }, keyIntensity: 2.4, rimIntensity: 0.9, shadowDarkness: 0.4 });    const rack: BindingRack = attachRack(app, ACTIONS, camera);    let listening = "";    let lastResult = "—";    // Set when Reset to defaults cancels a rebind, so the cancelled rebind does not report itself    // over the reset.    let cancelledByReset = false;    // Never let the player bind the keys the panel itself needs, nor anything a click or a tap    // presses: a visitor who clicks the frame to dismiss the prompt means "never mind", not "bind    // this to my mouse". Taken from the devices, so every button they have is covered.    const devices = app.input.devices;    const excluded = ["<Keyboard>/escape", "<Keyboard>/enter", "<Keyboard>/tab"];    for (const device of [devices.mouse, devices.pointer, devices.touch]) {      for (const control of device.controls) {        excluded.push(controlPath(device, control));      }    }    // And a press on the frame cancels, as Escape does. `<Pointer>` is a mouse, a pen or the first    // touch, so one binding covers all three.    app.input.loadActions(PROMPT_ACTIONS);    app.input.actions.find("dismiss")?.onPerformed.connect((): void => {      if (listening !== "") {        app.input.cancelInteractiveRebind();      }    });    /**     * Listens for the next control and binds it to one action's first binding.     *     * @param name - The action to rebind.     */    const rebind = async (name: string): Promise<void> => {      // One rebind at a time: a second call while one is listening is `IGX-0807`, so a settings      // screen guards rather than catches.      if (listening !== "") {        return;      }      listening = name;      lastResult = "press a key or a pad button…";      rack.setListening(name);      try {        const result = await app.input.performInteractiveRebind(app.input.actions.get(name), {          bindingIndex: REBOUND_BINDING,          cancelPath: "<Keyboard>/escape",          excludePaths: excluded,          timeoutSeconds: LISTEN_SECONDS,        });        if (result.canceled) {          lastResult = cancelledByReset ? "reset to defaults" : `${name}: cancelled`;        } else if (result.timedOut) {          lastResult = `${name}: timed out`;        } else {          lastResult = `${name}: ${formatBindingPath(result.path ?? "", "—")}`;          await store.set<InputOverridesJson>(STORE.key, app.input.saveOverrides());        }      } finally {        listening = "";        cancelledByReset = false;        rack.setListening("");        rack.refreshBindings();      }    };    /**     * "Reset to defaults": stop any rebind still listening, so the next key is not bound over the     * fresh defaults, then drop every override, and the saved document with it.     */    const reset = async (): Promise<void> => {      if (listening !== "") {        cancelledByReset = true;        app.input.cancelInteractiveRebind();      }      app.input.clearOverrides();      await store.delete(STORE.key);      rack.refreshBindings();      lastResult = "reset to defaults";    };    const rows: PanelControl[] = ACTIONS.flatMap((name: string): readonly PanelControl[] => [      readout(name, (): string => bindingLabel(app, name)),      button(`Rebind ${name}`, (): void => {        // Saving rejects when the browser blocks storage; the binding still applies for this visit.        rebind(name).catch((error: unknown): void => {          app.log.warn(`rebinding ${name} failed:`, error);        });      }),    ]);    panel({      title: "Rebinding",      groups: [        { label: "Bindings", controls: rows },        {          label: "Overrides",          controls: [            readout("Last rebind", (): string => lastResult),            readout("Listening", (): string => (listening === "" ? "no" : listening)),            button("Reset to defaults", (): void => {              reset().catch((error: unknown): void => {                app.log.warn("could not clear the saved rebindings:", error);              });            }),            // `saveOverrides()` is cheap and always current — it walks the maps and collects the            // bindings that carry an override — so the readout asks it rather than caching a count            // that a rebind or a reset could leave stale.            readout("Overrides held", (): string => {              const count = app.input.saveOverrides().overrides.length;              return count === 0 ? "none — using the defaults" : `${String(count)}, saved to app.storage`;            }),          ],        },        {          label: "Frame",          collapsed: true,          controls: [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"] }  ],  "maps": [    {      "name": "Player",      "enabled": true,      "actions": [        {          "name": "left",          "type": "button",          "bindings": [{ "path": "<Keyboard>/a" }, { "path": "<Gamepad>/dpad/left" }]        },        {          "name": "right",          "type": "button",          "bindings": [{ "path": "<Keyboard>/d" }, { "path": "<Gamepad>/dpad/right" }]        },        {          "name": "hop",          "type": "button",          "bindings": [{ "path": "<Keyboard>/w" }, { "path": "<Gamepad>/buttonSouth" }]        },        {          "name": "brake",          "type": "button",          "bindings": [{ "path": "<Keyboard>/space" }, { "path": "<Gamepad>/buttonEast" }]        }      ]    }  ]}
rack.ts
import {  clamp,  createMaterialAsset,  createRay,  MeshAsset,  MeshRenderer,  pbrMaterialDefinition,  Script,  Vec3,} from "ignifx";import { uncoveredRect } from "./shot.ts";import type {  App,  AssetHandle,  Camera,  ColorLike,  Entity,  InputAction,  MaterialAsset,  Ray,  ScriptCallbacks,  Vec3Like,} from "ignifx";/** * The settings rack `rebinding` shows its bindings on, and the puck the four actions push around. * * The panel says what each action is bound to in words (`formatBindingPath`, the same helper * `@ignifx/ui`'s `Menu` uses). The rack says it in colour: **a key cap takes the colour of the * device family its binding now names**, so rebinding `hop` from the W key to a gamepad button * turns that cap from orange to green, and you can see a rebind land without reading anything. * * A cap lights up while its action is held, and pulses white while a rebind is listening for the * next control. The puck at the front is what the actions actually do, so a rebind can be felt as * well as seen. *//** The site's dark palette (`plan/02-design-system.md` §2.3), decoded to sRGB `0…1`. */const INK = {  /** A plinth. */  surface: { r: 0.098, g: 0.118, b: 0.153, a: 1 },  /** A well: the puck's track. A shade above the clear colour, so it is a groove, not a hole. */  sunk: { r: 0.055, g: 0.067, b: 0.088, a: 1 },  /** A cap bound to nothing at all. */  rule: { r: 0.235, g: 0.275, b: 0.337, a: 1 },  /** White: the frame an action was pressed in, and the peak of the listening pulse. */  hot: { r: 1, g: 1, b: 1, a: 1 },} as const;/** * The colour a cap takes for each device family, so a rebind is visible without a word of text. * * @remarks * Keyed by the `DeviceKind` names `@ignifx/input` uses in a binding path — `<Keyboard>/w` is the * `Keyboard` family — so a path this table does not know still gets a colour rather than nothing. */const DEVICE_COLOURS: Readonly<Record<string, ColorLike>> = {  /** `--flame`. */  Keyboard: { r: 1, g: 0.62, b: 0.29, a: 1 },  /** `--cool`. */  Mouse: { r: 0.353, g: 0.82, b: 0.784, a: 1 },  /** `--cool`, a shade down: the unified pointer is a mouse, a pen or the first touch. */  Pointer: { r: 0.24, g: 0.62, b: 0.62, a: 1 },  /** `--ok`. */  Gamepad: { r: 0.357, g: 0.831, b: 0.541, a: 1 },  /** `--warn`. */  Touch: { r: 0.949, g: 0.757, b: 0.306, a: 1 },};/** How far apart the four plinths stand, in metres. */const RACK_PITCH = 1.12;/** Where the rack stands, in metres forward of the origin. */const RACK_Z = 1.35;/** How tall a plinth is, in metres. */const PLINTH_HEIGHT = 0.72;/** How fast a listening cap pulses, in cycles per second. */const LISTEN_PULSE_HZ = 1.4;/** How long a `wasPressedThisFrame` flash takes to fall back to the held colour, in seconds. */const FLASH_SECONDS = 0.18;/** The level a cap holds while its action is merely held. */const HELD_LEVEL = 1;/** The level a cap rests at: its device colour, dimmed. */const REST_LEVEL = 0.42;/** How many steps a level is quantised to before it is written. */const LEVEL_STEPS = 48;/** How far the puck may roll from the centre of its track, in metres. */const TRACK_LIMIT = 2.15;/** The puck's radius, in metres. */const PUCK_RADIUS = 0.26;/** How much clear floor the puck keeps between itself and the frame's edge, in metres. */const EDGE_AIR = 0.15;/** One row of the rack: an action, its cap and the colour that cap currently rests at. */interface Row {  /** The action name, as `player.input.json` declares it. */  readonly name: string;  /** The action, or `null` when the document declares no such action. */  readonly action: InputAction | null;  /** The cap's material. */  readonly material: AssetHandle<MaterialAsset>;  /** The colour the cap rests at: the family of the binding it currently carries. */  device: ColorLike;  /** The last level written, quantised, so an idle cap uploads nothing. */  written: number;  /** Seconds of press flash left. */  flash: number;}/** What one rack drives. */interface Parts {  /** The four rows, left to right. */  readonly rows: readonly Row[];  /** The puck the actions push. */  readonly puck: Entity;  /** The camera the frame is seen through, which the puck stays in view of. */  readonly camera: Camera;  /** Which row is listening for a control, or `""` when none is. */  listening: string;}/** * Writes a cap's colour by blending its device colour towards white. * * @param row - The row to write. * @param level - How lit it is: `REST_LEVEL` at rest, `1` while pressed or at the pulse's peak. */function setLevel(row: Row, level: number): void {  const step = Math.round(clamp(level, 0, 1) * LEVEL_STEPS);  if (step === row.written) {    return;  }  row.written = step;  // Below `REST_LEVEL` the cap fades towards black, above it towards white — so one number carries  // both "this is a keyboard binding" and "this control is down right now".  const t = step / LEVEL_STEPS;  const from = t < REST_LEVEL ? { r: 0, g: 0, b: 0, a: 1 } : row.device;  const to = t < REST_LEVEL ? row.device : INK.hot;  const span = t < REST_LEVEL ? t / REST_LEVEL : (t - REST_LEVEL) / (1 - REST_LEVEL);  row.material.value.setBaseColor({    r: from.r + (to.r - from.r) * span,    g: from.g + (to.g - from.g) * span,    b: from.b + (to.b - from.b) * span,    a: 1,  });}/** * The colour a binding path implies. * * @param path - A binding path such as `<Keyboard>/w`, or `""` for an unbound binding. * @returns The device family's colour, or the neutral rule colour. */export function colourForPath(path: string): ColorLike {  const slash = path.indexOf("/");  const device = slash > 0 ? path.slice(1, slash - 1) : "";  return DEVICE_COLOURS[device] ?? INK.rule;}/** * Drives the rack: the caps' colours, the press flashes, the listening pulse and the puck. * * @remarks * A `Script`, so it runs in `Update` with everything else and the devtools inspector lists it. */export class BindingRack extends Script implements ScriptCallbacks {  /** The namespaced registration id. */  static typeId = "rebinding/BindingRack";  #parts: Parts | null = null;  /** The puck's speed along the track, in metres per second. */  #speed = 0;  /** The puck's vertical speed, in metres per second. */  #lift = 0;  /** The four actions the puck reads, resolved once in {@link BindingRack.install}. */  #left: InputAction | null = null;  #right: InputAction | null = null;  #hop: InputAction | null = null;  #brake: InputAction | null = null;  /** The puck's roll, reused so the frame allocates nothing. */  readonly #roll = { x: 0, y: 0, z: 0 };  /** The puck's travel, reused so the frame allocates nothing. */  readonly #span: [number, number] = [-TRACK_LIMIT, TRACK_LIMIT];  /** Where the puck lands on the canvas. */  readonly #screen = new Vec3();  /** The line of sight through an edge of the frame; `createRay`, so its length is unbounded. */  readonly #ray: Ray = createRay();  /**   * Hands the script the parts {@link attachRack} built.   *   * @param parts - The rack's rows and its puck.   */  install(parts: Parts): void {    this.#parts = parts;    const action = (name: string): InputAction | null =>      parts.rows.find((row: Row) => row.name === name)?.action ?? null;    this.#left = action("left");    this.#right = action("right");    this.#hop = action("hop");    this.#brake = action("brake");  }  /**   * Tells the rack which row is listening for a control, so its cap pulses.   *   * @param name - The action name, or `""` when no rebind is in flight.   */  setListening(name: string): void {    if (this.#parts !== null) {      this.#parts.listening = name;    }  }  /**   * Re-reads the colour every cap should rest at, after a rebind or a reset changed the bindings.   *   * @remarks   * `binding.effectivePath` is the override when there is one and the document's path when there   * is not, which is exactly what a settings row shows and exactly what this colours by.   */  refreshBindings(): void {    const parts = this.#parts;    if (parts === null) {      return;    }    for (const row of parts.rows) {      row.device = colourForPath(row.action?.bindings[0]?.effectivePath ?? "");      // Forces the next `setLevel` to write, because the level may not have moved even though the      // colour it interpolates between did.      row.written = -1;    }  }  /**   * Lights the caps and moves the puck.   *   * @param dt - Seconds since the previous frame, scaled by `time.timeScale`. Under `?static=1` the   * scale is zero, so the puck holds the pose this file authored and the pulse stands still.   */  update(dt: number): void {    const parts = this.#parts;    if (parts === null) {      return;    }    const unscaled = this.app.time.unscaledDeltaTime;    const pulse = 0.5 + 0.5 * Math.sin(this.app.time.time * LISTEN_PULSE_HZ * Math.PI * 2);    for (const row of parts.rows) {      updateRow(row, parts.listening === row.name, pulse, unscaled);    }    this.#movePuck(parts, dt);  }  /**   * Rolls, hops and brakes the puck from the four actions.   *   * @param parts - The rack's parts.   * @param dt - The scaled frame delta, in seconds.   */  #movePuck(parts: Parts, dt: number): void {    const push = (this.#right?.isPressed === true ? 1 : 0) - (this.#left?.isPressed === true ? 1 : 0);    this.#speed += push * PUSH_METRES_PER_SECOND_SQUARED * dt;    // Braking is a hard drag rather than a stop, so a tap slows the puck and a hold parks it.    const drag = this.#brake?.isPressed === true ? BRAKE_DRAG : ROLL_DRAG;    this.#speed -= this.#speed * Math.min(drag * dt, 1);    this.#speed = clamp(this.#speed, -MAX_SPEED, MAX_SPEED);    const position = parts.puck.transform.localPosition;    const [low, high] = this.#travel(parts, position);    position.x = clamp(position.x + this.#speed * dt, low, high);    if (position.x <= low || position.x >= high) {      this.#speed = 0;    }    if (this.#hop?.wasPressedThisFrame === true && position.y <= PUCK_RADIUS + 0.001) {      this.#lift = HOP_METRES_PER_SECOND;    }    if (this.#lift !== 0 || position.y > PUCK_RADIUS) {      this.#lift -= GRAVITY * dt;      position.y += this.#lift * dt;      if (position.y <= PUCK_RADIUS) {        position.y = PUCK_RADIUS;        this.#lift = 0;      }    }    // Rolling is the puck's own rotation about Z, so the direction reads even on a flat colour.    this.#roll.z = (-this.#speed * dt * 180) / (Math.PI * PUCK_RADIUS);    parts.puck.transform.rotate(this.#roll);  }  /**   * How far along its track the puck may roll: the track, narrowed to what the frame shows.   *   * @remarks   * The camera is fixed, and on a 16:9 frame the expanded panel covers the right end of the track,   * so a puck rolled right would park behind it. The limit is measured from the camera each frame,   * so it follows a resize, a phone held upright, and the panel opening or closing: where the two   * ends of the uncovered part of the canvas, on the puck's own row, meet the puck's height. The   * shot never yaws or rolls, so that row is a line along the track.   *   * @param parts - The rack's parts, for the camera.   * @param position - The puck's position.   * @returns The lowest and highest centre, in metres along X; reused between calls.   */  #travel(parts: Parts, position: Vec3Like): readonly [number, number] {    this.#span[0] = -TRACK_LIMIT;    this.#span[1] = TRACK_LIMIT;    const rect = uncoveredRect(this.app);    if (rect === null || !parts.camera.worldToScreen(position, this.#screen)) {      return this.#span;    }    const left = this.#acrossAt(parts.camera, rect.left, this.#screen.y, position.y);    const right = this.#acrossAt(parts.camera, rect.right, this.#screen.y, position.y);    const low = Math.max(-TRACK_LIMIT, (left ?? -TRACK_LIMIT) + PUCK_RADIUS + EDGE_AIR);    const high = Math.min(TRACK_LIMIT, (right ?? TRACK_LIMIT) - PUCK_RADIUS - EDGE_AIR);    if (low <= high) {      this.#span[0] = low;      this.#span[1] = high;    }    return this.#span;  }  /**   * Where a canvas pixel's line of sight crosses a height, along X.   *   * @param camera - The camera to cast through.   * @param x - The backing-store pixel x.   * @param y - The backing-store pixel y.   * @param height - The height to meet, in metres.   * @returns The X there, or `null` when the line of sight never comes down to it.   */  #acrossAt(camera: Camera, x: number, y: number, height: number): number | null {    const ray = camera.screenToRay(x, y, this.#ray);    if (ray === null || ray.direction.y >= 0) {      return null;    }    const t = (height - ray.origin.y) / ray.direction.y;    return t < 0 ? null : ray.origin.x + ray.direction.x * t;  }}/** How hard `left` and `right` push the puck, in metres per second squared. */const PUSH_METRES_PER_SECOND_SQUARED = 6;/** How fast the puck may roll, in metres per second. */const MAX_SPEED = 3.2;/** The drag a free-rolling puck feels, per second. */const ROLL_DRAG = 0.9;/** The drag `brake` adds, per second. */const BRAKE_DRAG = 9;/** The puck's upward speed at the start of a hop, in metres per second. */const HOP_METRES_PER_SECOND = 3;/** Downward acceleration, in metres per second squared. */const GRAVITY = 9.5;/** * Lights one cap. * * @param row - The row to update. * @param listening - Whether a rebind is waiting for this row's next control. * @param pulse - The listening pulse, `0` to `1`. * @param unscaled - The unscaled frame delta, in seconds. */function updateRow(row: Row, listening: boolean, pulse: number, unscaled: number): void {  if (row.action?.wasPressedThisFrame === true) {    row.flash = FLASH_SECONDS;  } else if (row.flash > 0) {    row.flash = Math.max(row.flash - unscaled, 0);  }  if (listening) {    setLevel(row, REST_LEVEL + (1 - REST_LEVEL) * pulse);    return;  }  if (row.flash > 0 || row.action?.isPressed === true) {    setLevel(row, HELD_LEVEL);    return;  }  setLevel(row, REST_LEVEL);}/** * Builds an unlit material. * * @param app - The app the asset belongs to. * @param name - The material's name. * @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,    pbrMaterialDefinition({      name: `rack/${name}`,      baseColor: color,      metallic: 0,      roughness: 1,      unlit: true,      doubleSided: true,    }),    [],  );}/** Where a part sits and how big it is, in metres. */interface Placement {  /** Metres along X. */  readonly x: number;  /** Metres along Y. */  readonly y: number;  /** Metres along Z. */  readonly z: number;  /** Scale along X. */  readonly sx: number;  /** Scale along Y. */  readonly sy: number;  /** Scale along Z. */  readonly sz: number;}/** * Adds one part of the rack. * * @param app - The app. * @param name - The entity's name, which the devtools scene tree shows. * @param mesh - The template it clones. * @param material - The material it wears. * @param at - Where it sits and how big it is. * @param casts - Whether it casts a shadow. * @returns The entity. */function addPart(  app: App,  name: string,  mesh: AssetHandle<MeshAsset>,  material: AssetHandle<MaterialAsset>,  at: Placement,  casts = false,): Entity {  const entity = app.world.createEntity(name);  entity.transform.localPosition.set(at.x, at.y, at.z);  entity.transform.localScale.set(at.sx, at.sy, at.sz);  entity.addComponent(MeshRenderer, {    mesh,    materials: [material],    castShadows: casts,    receiveShadows: false,    pickable: false,  });  return entity;}/** * Builds the rack and the puck, and attaches the script that drives them. * * @param app - The running app; needs the `input()` extension and a loaded action document. * @param names - The four action names, left to right. * @returns The script, so the example can tell it what is listening and when a binding changed. * * @example * ```ts * const rack = attachRack(app, ["left", "right", "hop", "brake"], camera); * rack.setListening("hop"); * ``` */export function attachRack(app: App, names: readonly string[], camera: Camera): BindingRack {  app.registerComponents([BindingRack]);  const box = MeshAsset.box(app, { size: 1 });  const sphere = MeshAsset.sphere(app, { diameter: 1, segments: 20 });  const rows: Row[] = names.map((name: string, index: number): Row => {    const x = (index - (names.length - 1) / 2) * RACK_PITCH;    addPart(app, `${name} plinth`, box, createInkMaterial(app, `${name} plinth`, INK.surface), {      x,      y: PLINTH_HEIGHT / 2,      z: RACK_Z,      sx: 0.5,      sy: PLINTH_HEIGHT,      sz: 0.5,    });    const material = createInkMaterial(app, `${name} cap`, INK.rule);    addPart(app, `${name} cap`, box, material, {      x,      y: PLINTH_HEIGHT + 0.07,      z: RACK_Z,      sx: 0.48,      sy: 0.14,      sz: 0.48,    });    return {      name,      action: app.input.actions.find(name),      material,      device: INK.rule,      written: -1,      flash: 0,    };  });  addPart(app, "Track", box, createInkMaterial(app, "track", INK.sunk), {    x: 0,    y: 0.015,    z: -0.95,    sx: TRACK_LIMIT * 2 + 0.7,    sy: 0.03,    sz: 0.66,  });  const puck = addPart(    app,    "Puck",    sphere,    createMaterialAsset(      app,      pbrMaterialDefinition({        name: "rack/puck",        baseColor: { r: 0.95, g: 0.45, b: 0.14, a: 1 },        metallic: 0.2,        roughness: 0.4,      }),      [],    ),    { x: 0, y: PUCK_RADIUS, z: -0.95, sx: PUCK_RADIUS * 2, sy: PUCK_RADIUS * 2, sz: PUCK_RADIUS * 2 },    true,  );  const rack = app.world.createEntity("Rack").addComponent(BindingRack);  rack.install({ rows, puck, camera, listening: "" });  rack.refreshBindings();  return rack;}
shot.ts
/** * The fixed camera, and what of the canvas a visitor can actually see. * * There is no orbit camera here: a rebinding screen should not move under the visitor while they * press keys at it. So two things the kit's orbit does for every other 3D example are done here by * hand: * * - **Portrait frames.** A shot is framed by its vertical field of view, so a phone held upright *   shows far less across than a 16:9 frame: one and a half plinths, and half the puck. While the *   canvas is portrait, {@link FixedShot} centres the shot on the rack, stands back a little, and *   widens the lens until it shows across what a square frame would. * - **The panel.** {@link uncoveredRect} is the part of the canvas no kit overlay covers, which the *   puck keeps itself inside. */import { Camera, degToRad, radToDeg, Script } from "ignifx";import { frameInset } from "../_kit/orbit.ts";import type { App, ScriptCallbacks, Vec3Like } from "ignifx";/** * Where the camera sits and what it looks at on a landscape frame, in metres. Both are slid along * +X, so the rack sits in the part of a 16:9 frame the expanded panel leaves uncovered. */export const SHOT = { eye: { x: 0.6, y: 2.6, z: -4.3 }, focus: { x: 0.6, y: 0.52, z: 0.25 }, fov: 42 } as const;/** * How a portrait frame changes {@link SHOT}: the rack's centre line to look along, and how far to * stand back along the line of sight, in metres. The panel is a title chip or a bottom sheet there, * never a column down the right, so the slide is undone. */const PORTRAIT = { x: 0, standBack: 1.2 } as const;/** The widest vertical field of view a portrait frame is given, in degrees. */const MAX_PORTRAIT_FOV = 96;/** A rectangle of the canvas, in backing-store pixels from its top-left corner. */export interface ScreenRect {  /** The left edge. */  readonly left: number;  /** The right edge. */  readonly right: number;  /** The top edge. */  readonly top: number;  /** The bottom edge. */  readonly bottom: number;}/** * Holds the camera on {@link SHOT}, or on its portrait form while the canvas is taller than wide. * * @remarks * Re-posed only when the canvas's aspect changes, so a steady frame writes nothing. */export class FixedShot extends Script implements ScriptCallbacks {  /** The namespaced registration id. */  static typeId = "rebinding/FixedShot";  #camera: Camera | null = null;  /** The aspect the camera was last posed for; `0` before the first pose. */  #aspect = 0;  /** The eye, reused so a re-pose allocates nothing. */  readonly #eye = { x: 0, y: 0, z: 0 };  /** The focus, reused likewise. */  readonly #focus = { x: 0, y: 0, z: 0 };  /** Finds the camera on this entity. */  awake(): void {    this.#camera = this.entity.requireComponent(Camera);  }  /** Poses the camera for the canvas as it is this frame. */  update(): void {    const camera = this.#camera;    const surface = this.app.renderer.surface;    if (camera === null || surface === null || surface.width <= 0 || surface.height <= 0) {      return;    }    const aspect = surface.width / surface.height;    if (aspect === this.#aspect) {      return;    }    this.#aspect = aspect;    copy(SHOT.eye, this.#eye);    copy(SHOT.focus, this.#focus);    if (aspect >= 1) {      camera.fov = SHOT.fov;    } else {      // Back along the line of sight, so the rack's height in the frame shrinks with its width      // rather than the lens doing all of it; then a lens wide enough that the frame shows across      // what a square one would: `tan(vfov / 2) = tan(fov / 2) / aspect`.      const dx = SHOT.focus.x - SHOT.eye.x;      const dy = SHOT.focus.y - SHOT.eye.y;      const dz = SHOT.focus.z - SHOT.eye.z;      const back = PORTRAIT.standBack / Math.hypot(dx, dy, dz);      this.#eye.x = PORTRAIT.x;      this.#focus.x = PORTRAIT.x;      this.#eye.y -= dy * back;      this.#eye.z -= dz * back;      const widened = 2 * Math.atan(Math.tan(degToRad(SHOT.fov) / 2) / aspect);      camera.fov = Math.min(radToDeg(widened), MAX_PORTRAIT_FOV);    }    this.transform.localPosition.set(this.#eye.x, this.#eye.y, this.#eye.z);    this.transform.lookAt(this.#focus);  }}/** * The part of the canvas the kit's overlays leave uncovered: the expanded panel's column on a wide * frame, its sheet along the bottom of a narrow one, and the devtools when they are open. * * @param app - The running app. * @returns The rectangle, in backing-store pixels — the space `Camera.screenToRay` takes — or * `null` before there is a surface. */export function uncoveredRect(app: App): ScreenRect | null {  const surface = app.renderer.surface;  if (surface === null || surface.width <= 0 || surface.height <= 0) {    return null;  }  // `frameInset` is in CSS pixels; the canvas's layout width says how many backing pixels one is.  const layoutWidth = "clientWidth" in surface ? surface.clientWidth : 0;  const scale = layoutWidth > 0 ? surface.width / layoutWidth : 1;  const inset = frameInset(app);  return {    left: inset.left * scale,    right: surface.width - inset.right * scale,    top: inset.top * scale,    bottom: surface.height - inset.bottom * scale,  };}/** * Copies a point. * * @param from - The point to read. * @param to - The point to write. */function copy(from: Vec3Like, to: { x: number; y: number; z: number }): void {  to.x = from.x;  to.y = from.y;  to.z = from.z;}
Uses
app.input.performInteractiveRebindapp.input.saveOverridesapp.input.loadOverridesapp.input.clearOverridesapp.storageBinding.effectivePathformatBindingPath
Assets
Everything in this example is created in code.