All examples
Rebinding
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

WebGPU: checking…See browser support
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 to check that your bindings were saved. Choose Reset to defaults to clear them.
Show source code
Source
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))], }, ], }); },});{ "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" }] } ] } ]}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;}/** * 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;}