ignifx0.x · unpublished
GitHub

Recipes·skills/ignifx/references/recipes/rebind-a-key-at-runtime.md

Rebind a key at run time

<!-- Generated by `pnpm docs:recipes`. Do not edit by hand. -->

performInteractiveRebind listens for the next control the player touches and writes it onto one binding as an override. It resolves with what happened — the new path, or canceled, or timedOut — and it is the only rebind in flight at a time: a second call while one is listening is IGX-0807, so a settings screen calls cancelInteractiveRebind() when the row closes.

Overrides are addressed by binding index, which is why the .input.json beside this file declares its bindings in a fixed order: reordering them invalidates saved overrides loudly (IGX-0808) instead of quietly rebinding the wrong control. Index 0 of jump is the keyboard binding and index 1 is the pad, so a keyboard rebind leaves the pad alone.

saveOverrides() produces a small ignifx.inputoverrides document — not the whole action document — and app.storage is where it belongs: it is the same asynchronous store the game's settings and save slots use, scoped by namespace.

A .input.json named in input({ actions }) is not awaited: it is delivered like any other asset, in a later frame. Load it yourself and call loadActions before app.start() when the first frame has to have the maps, as here.

typescript
import { createApp, isIgnifxError } from "@ignifx/core";import { input } from "@ignifx/input";import type { InputActionsAsset, InputOverridesJson } from "@ignifx/input";const canvas = document.querySelector("canvas");if (!(canvas instanceof HTMLCanvasElement)) {  throw new Error("ignifx renders into a <canvas> element.");}const app = await createApp({ canvas, settings: { assets: { root: "assets" } }, extensions: [input()] });app.input.loadActions(await app.assets.loadAsync<InputActionsAsset>("input/player.input.json"));const store = app.storage.namespace("input");// Saved overrides are player data from an older build: apply them, but never let them stop the game.const saved = await store.get<InputOverridesJson>("overrides");if (saved !== null) {  try {    app.input.loadOverrides(saved);  } catch (error: unknown) {    app.log.warn("dropping unusable rebindings:", isIgnifxError(error) ? error.code : error);    await store.delete("overrides");  }}/** * Listens for the next control and binds it to one binding of one action. * * @param actionName - The action to rebind, as named in the `.input.json`. * @param bindingIndex - Which of that action's bindings to overwrite; 0 is the keyboard one here. * @returns The path that was bound, or `null` when the player cancelled or the listen timed out. */async function rebind(actionName: string, bindingIndex: number): Promise<string | null> {  const action = app.input.actions.get(actionName);  const result = await app.input.performInteractiveRebind(action, {    bindingIndex,    cancelPath: "<Keyboard>/escape",    // Never let the player bind the keys the menu itself needs.    excludePaths: ["<Keyboard>/escape", "<Keyboard>/enter", "<Pointer>/position"],    timeoutSeconds: 5,  });  if (result.canceled || result.timedOut) {    return null;  }  await store.set<InputOverridesJson>("overrides", app.input.saveOverrides());  return result.path;}/** "Reset to defaults": drop every override, and the saved document with it. */async function resetBindings(): Promise<void> {  app.input.clearOverrides();  await store.delete("overrides");}await app.start();// What a settings screen's "Jump" row does. `binding.effectivePath` is what its label should show.document.querySelector("#rebind-jump")?.addEventListener("click", () => {  void rebind("jump", 0).then((path: string | null) => {    app.log.info("jump is now:", path ?? app.input.actions.get("jump").bindings[0]?.effectivePath);  });});document.querySelector("#reset-bindings")?.addEventListener("click", () => {  void resetBindings();});

Source: examples/recipes/rebind-a-key-at-runtime/main.ts