ignifx
GitHubnpm · soon
All examples

Rebinding

Input3D

  • Keyboard
  • Gamepad
  • Mouse

The three steps a settings screen has. `performInteractiveRebind` listens for the next control the player touches and writes it onto one binding as an override; `saveOverrides()` produces a small `ignifx.inputoverrides` document — the overrides only, not the whole action document — and `app.storage` keeps it, so a rebind is still there when the page is reloaded. Each cap on the rack takes the colour of the device family its binding names, so rebinding one to a gamepad button turns it from orange to green without a word of text, and the puck in front is what the four actions actually do.

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

` devtools

WebGPU: checking…

Try this

  • Press Rebind hop, then press any key: the cap goes orange for a keyboard, and the panel says which key.
  • Rebind one to a gamepad button and watch that cap turn green while the other three stay orange.
  • Reload the page: the rebinding is still there, because it went to app.storage. Reset to defaults clears it.

Read the guide

Show source

Source

main.ts
import { Camera, 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 type { BindingRack } from "./rack.ts";import type { PanelControl } from "../_kit/panel.ts";import type { App, InputOverridesJson } from "ignifx";/** * Press a control, rebind an action, keep it: `performInteractiveRebind`, `saveOverrides` and * `app.storage`, in that order. * * `player.input.json` beside this file gives each of the four actions two bindings — the keyboard * one at index 0 and the gamepad one at index 1 — and the order is load-bearing. An override is * addressed by **binding index**, so reordering an action's bindings invalidates a saved override * loudly (`IGX-0808`) rather than quietly rebinding the wrong control. Every rebind below names * index 0, so the pad binding is never touched. * * ## The three steps a settings screen has * * 1. **Listen.** `performInteractiveRebind` waits for the next control the player actuates and *    writes it onto one binding as an override. It resolves with what happened — a `path`, or *    `canceled`, or `timedOut` — and only one rebind may listen at a time (`IGX-0807`), which is *    what the `listening` guard below is for. * 2. **Save.** `saveOverrides()` produces a small `ignifx.inputoverrides` document — the overrides *    only, not the whole action document — and `app.storage` is where it belongs: the same *    asynchronous store the game's settings and save slots use, scoped by `namespace`. * 3. **Apply, carefully.** A saved document is player data from an older build. It is applied *    inside a `try`, and a document that no longer fits the bindings is dropped rather than *    allowed to stop the game. * * The rack shows the result without a word of text: a key cap takes the colour of the device * family its binding names, so a rebind from the keyboard to a pad turns a cap from orange to * green. `formatBindingPath` — the helper `@ignifx/ui`'s `Menu` rows use — writes the same thing * as words in the panel. *//** 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: index 0 is the keyboard one. */const KEYBOARD_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;/** Where the camera sits and what it looks at, in metres. */const SHOT = { eye: { x: 0, y: 2.6, z: -4.3 }, focus: { x: 0, y: 0.52, z: 0.25 }, fov: 42 } as const;/** The floor, in metres: big enough that its far edge is out of frame behind the rack. */const FLOOR_SIZE = 60;/** * Reads one action's keyboard binding the way a settings row shows it. * * @param app - The running app. * @param name - The action name. * @returns `Keyboard: W`, or `—` when the binding is unbound. */function bindingLabel(app: App, name: string): string {  const path = app.input.actions.find(name)?.bindings[KEYBOARD_BINDING]?.effectivePath ?? "";  return formatBindingPath(path, "");}bootExample({  title: "Rebinding",  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 }) {    // `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);    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: FLOOR_SIZE, color: { r: 0.17, g: 0.19, b: 0.24, a: 1 } });    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);    let listening = "";    let lastResult = "";    /**     * Listens for the next control and binds it to one action's keyboard 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 control…";      rack.setListening(name);      try {        const result = await app.input.performInteractiveRebind(app.input.actions.get(name), {          bindingIndex: KEYBOARD_BINDING,          cancelPath: "<Keyboard>/escape",          // Never let the player bind the keys the panel itself needs.          excludePaths: ["<Keyboard>/escape", "<Keyboard>/enter", "<Keyboard>/tab"],          timeoutSeconds: LISTEN_SECONDS,        });        if (result.canceled) {          lastResult = `${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 = "";        rack.setListening("");        rack.refreshBindings();      }    };    /** "Reset to defaults": drop every override, and the saved document with it. */    const reset = async (): Promise<void> => {      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 => {        void rebind(name);      }),    ]);    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 => {              void reset();            }),            // `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, MeshAsset, MeshRenderer, pbrMaterialDefinition, Script } from "ignifx";import type { App, AssetHandle, ColorLike, Entity, InputAction, MaterialAsset, ScriptCallbacks } 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 (`website/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;/** 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;  /** 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;  /**   * Hands the script the parts {@link attachRack} built.   *   * @param parts - The rack's rows and its puck.   */  install(parts: Parts): void {    this.#parts = parts;  }  /**   * 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 held = (name: string): boolean =>      parts.rows.find((row: Row) => row.name === name)?.action?.isPressed === true;    const push = (held("right") ? 1 : 0) - (held("left") ? 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 = held("brake") ? 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;    position.x = clamp(position.x + this.#speed * dt, -TRACK_LIMIT, TRACK_LIMIT);    if (Math.abs(position.x) >= TRACK_LIMIT) {      this.#speed = 0;    }    const hop = parts.rows.find((row: Row) => row.name === "hop")?.action;    if (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.    parts.puck.transform.rotate({ x: 0, y: 0, z: (-this.#speed * dt * 180) / (Math.PI * PUCK_RADIUS) });  }}/** 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"]); * rack.setListening("hop"); * ``` */export function attachRack(app: App, names: readonly string[]): 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, listening: "" });  rack.refreshBindings();  return rack;}

Uses:app.input.performInteractiveRebindapp.input.saveOverridesapp.input.loadOverridesapp.input.clearOverridesapp.storageBinding.effectivePathformatBindingPath

Assets:everything in this example is created in code.