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

WebGPU: checking…See browser support
Try this
- Hold W and A together: the dot sits on the ring, not past it, because a normalise processor keeps a diagonal at one.
- Squeeze a gamepad trigger slowly and watch the boost bar fill before the action counts as pressed.
- Press Escape: the Player map switches off, every gate and lamp on it goes dead, and the device row keeps reading.
Show source
Source
import { Camera, validateInputActions } from "ignifx";import { bootExample } from "../_kit/boot.ts";import { bind, button, readout, slider } from "../_kit/panel.ts";import { createGridGround, createLightRig } from "../_kit/stage.ts";import { attachBoard, PLAYER_MAP } from "./board.ts";import actionsSource from "./player.input.json?raw";import { attachRover } from "./rover.ts";import type { InputAction } from "ignifx";/** * Every device and every action, live: an `.input.json` document, the board it lights up, and the * rover it drives. * * Read `player.input.json` beside this file first — it is the whole input configuration, and * nothing in this program names a key. Two maps: `Player`, which holds the gameplay actions, and * `System`, which holds the one action that switches `Player` off. Inside `Player` are the three * composites (`2DVector` for WASD, `1DAxis` for Q and E, `ButtonWithModifier` for shift-W), the * processors that make a stick and a keyboard agree (`deadzone`, `normalize`, `scale`, `clamp`), and * three control schemes. * * ## The document is imported, not addressed * * `?raw` hands the file over as text, `JSON.parse` turns it into a value and `validateInputActions` * turns that into the definition `loadActions` takes — the **same** check the asset loader runs, so * a typo in the document is an error here rather than a silently missing action. The alternative is * to put the file under `website/examples/assets/` and load it by address, which is what a game * does; it is imported here so the viewer page can show it as a tab next to this file, which is the * point of the example. * * A document named in `input({ actions })` is **not** awaited: it is delivered like any other asset, * in a later frame (`packages/input/skills/input/SKILL.md`). Loading it before `app.start()` is what * makes the first frame have its maps — and the board resolve its actions in `attachBoard`. *//** * The floor, in metres. Big enough that its far edge is behind the board and out of the frame: a * finite plane whose horizon is visible reads as a raft floating in the void. */const PAD_SIZE = 60;/** Where the camera sits and what it looks at, in metres. */const SHOT = { eye: { x: 0, y: 2.75, z: -4.35 }, focus: { x: 0, y: 0.78, z: 0.35 }, fov: 42 } as const;/** * Writes a `vector2` action as a pair, or `—` when the document declares no such action. * * @param action - The action to read. * @returns The text for a readout cell. */function vectorText(action: InputAction | null): string { if (action === null) { return "—"; } return `${action.vector.x.toFixed(2)}, ${action.vector.y.toFixed(2)}`;}/** * Writes an axis or button action as one number and its press state. * * @param action - The action to read. * @returns The text for a readout cell. */function axisText(action: InputAction | null): string { if (action === null) { return "—"; } return `${action.axis.toFixed(2)}${action.isPressed ? " ▪" : ""}`;}/** * Writes a slider's value to two places. * * @param value - The value. * @returns The text for the slider's value cell. */function twoPlaces(value: number): string { return value.toFixed(2);}bootExample({ title: "Input actions", settings: { rendering: { clearColor: { r: 0.035, g: 0.043, b: 0.059, a: 1 }, msaaSamples: 4, // Read once, when `app.start()` registers the scene; asking afterwards is `IGX-0704`. features: { shadows: true }, }, time: { fixedDeltaTime: 1 / 60 }, }, async setup({ app, panel }) { // The document, checked the way the loader checks it. `JSON.parse` answers `any`, so the value // is typed `unknown` at the boundary and `validateInputActions` is what narrows it. const parsed: unknown = JSON.parse(actionsSource); app.input.loadActions(validateInputActions(parsed, "player.input.json")); const eye = app.world.createEntity("Main Camera"); eye.transform.localPosition.set(SHOT.eye.x, SHOT.eye.y, SHOT.eye.z); eye.transform.lookAt(SHOT.focus); // No orbit camera here, on purpose: the mouse belongs to the actions in this example, and a // camera that ate the drag would take `look`, `fire` and `aim` with it. const camera = eye.addComponent(Camera, { near: 0.05, far: 120, fov: SHOT.fov }); // Awaited before `app.start()`: a load that completes before the loop runs settles at once, // and a material binds its textures once — an unawaited grid is an untextured floor. await createGridGround(app, { size: PAD_SIZE, color: { r: 0.17, g: 0.19, b: 0.24, a: 1 } }); createLightRig(app, { focus: { x: 0, y: 0.4, z: -0.4 }, keyIntensity: 2.4, rimIntensity: 0.9, shadowDarkness: 0.4, }); const board = attachBoard(app); const rover = attachRover(app, camera); // The board and the rover light up from the same actions, so the two halves of the frame never // disagree: one reads the map, the other drives on it. const player = app.input.actions.map(PLAYER_MAP); const find = (name: string): InputAction | null => app.input.actions.find(name); // A signal rather than a poll: `onPerformed` fires once per press, in `PreUpdate`. The // connection is owned by the board's entity, so it disconnects when the entity dies — which is // the whole point of an owner-scoped connection. find("toggleMap")?.onPerformed.connect( (): void => { player.enabled = !player.enabled; }, { owner: board }, ); panel({ title: "Input actions", groups: [ { label: "Actions", controls: [ readout("move", (): string => vectorText(find("move"))), readout("look", (): string => vectorText(find("look"))), readout("spin", (): string => axisText(find("spin"))), readout("boost", (): string => axisText(find("boost"))), readout("jump", (): string => axisText(find("jump"))), readout("fire", (): string => axisText(find("fire"))), readout("sprint", (): string => axisText(find("sprint"))), ], }, { label: "Devices", controls: [ readout("Scheme", (): string => (app.input.currentScheme === "" ? "none" : app.input.currentScheme)), // A browser hides a pad until a button is pressed on it, so this reads 0 with one // plugged in and nothing touched. readout("Gamepads", (): string => String(app.input.gamepads.filter((pad) => pad.isConnected).length)), readout("Events this frame", (): string => String(app.input.events.length)), readout("Pointer, pixels", (): string => vectorText(find("aim"))), ], }, { label: "Maps", controls: [ // A button and a readout, not a `toggle`: a checkbox is written once when the panel // mounts, so it would go stale the moment Escape switched the same map off. readout("Player map", (): string => (player.enabled ? "enabled" : "disabled")), button("Toggle Player map", (): void => { player.enabled = !player.enabled; }), // Escape does the same thing. A disabled map hides its actions from // `app.input.actions.get`, which is why the board and the rover hold theirs from // `find` — where they read as released instead of throwing `IGX-0801`. // // The magnitude at which an analog value counts as pressed. Push it up and a gamepad // trigger has to be squeezed harder before `jump.isPressed` is true. slider( "Press point", { min: 0.05, max: 0.95, step: 0.05, format: twoPlaces }, bind(app.input, "pressPoint"), ), ], }, { label: "Rover", collapsed: true, controls: [ slider("Speed", { min: 0.5, max: 6, step: 0.1 }, bind(rover, "speed")), slider("Boost factor", { min: 1, max: 4, step: 0.1 }, bind(rover, "boostFactor")), readout("Draw calls", (): string => String(app.renderer.drawCalls)), ], }, ], }); },});{ "format": "ignifx.inputactions", "formatVersion": 1, "controlSchemes": [ { "name": "KeyboardMouse", "devices": ["Keyboard", "Mouse"] }, { "name": "Gamepad", "devices": ["Gamepad"] }, { "name": "Touch", "devices": ["Touch"] } ], "maps": [ { "name": "Player", "enabled": true, "actions": [ { "name": "move", "type": "vector2", "bindings": [ { "composite": "2DVector", "up": "<Keyboard>/w", "down": "<Keyboard>/s", "left": "<Keyboard>/a", "right": "<Keyboard>/d", "processors": ["normalize"], "scheme": "KeyboardMouse" }, { "composite": "2DVector", "up": "<Keyboard>/arrowUp", "down": "<Keyboard>/arrowDown", "left": "<Keyboard>/arrowLeft", "right": "<Keyboard>/arrowRight", "processors": ["normalize"], "scheme": "KeyboardMouse" }, { "path": "<Gamepad>/leftStick", "processors": ["deadzone(0.2)"], "scheme": "Gamepad" }, { "path": "<Gamepad>/dpad", "scheme": "Gamepad" }, { "path": "<Touch>/primaryTouch/delta", "processors": ["scale(0.02)", "clamp(-1,1)"], "scheme": "Touch" } ] }, { "name": "look", "type": "vector2", "bindings": [ { "path": "<Mouse>/delta", "processors": ["scale(0.08)", "clamp(-1,1)"], "scheme": "KeyboardMouse" }, { "path": "<Gamepad>/rightStick", "processors": ["deadzone(0.15)"], "scheme": "Gamepad" } ] }, { "name": "aim", "type": "vector2", "bindings": [{ "path": "<Pointer>/position" }] }, { "name": "spin", "type": "axis", "bindings": [ { "composite": "1DAxis", "negative": "<Keyboard>/q", "positive": "<Keyboard>/e", "scheme": "KeyboardMouse" }, { "composite": "1DAxis", "negative": "<Gamepad>/leftShoulder", "positive": "<Gamepad>/rightShoulder", "scheme": "Gamepad" } ] }, { "name": "boost", "type": "axis", "bindings": [ { "path": "<Keyboard>/shiftLeft", "scheme": "KeyboardMouse" }, { "path": "<Gamepad>/rightTrigger", "processors": ["deadzone(0.05)"], "scheme": "Gamepad" } ] }, { "name": "jump", "type": "button", "bindings": [ { "path": "<Keyboard>/space", "scheme": "KeyboardMouse" }, { "path": "<Gamepad>/buttonSouth", "scheme": "Gamepad" }, { "path": "<Touch>/touch1/press", "scheme": "Touch" } ] }, { "name": "fire", "type": "button", "bindings": [ { "path": "<Mouse>/leftButton", "scheme": "KeyboardMouse" }, { "path": "<Gamepad>/buttonWest", "scheme": "Gamepad" }, { "path": "<Touch>/touch2/press", "scheme": "Touch" } ] }, { "name": "sprint", "type": "button", "bindings": [ { "composite": "ButtonWithModifier", "modifier": "<Keyboard>/shiftLeft", "button": "<Keyboard>/w", "scheme": "KeyboardMouse" }, { "path": "<Gamepad>/leftStickPress", "scheme": "Gamepad" } ] } ] }, { "name": "System", "enabled": true, "actions": [ { "name": "toggleMap", "type": "button", "bindings": [ { "path": "<Keyboard>/escape", "scheme": "KeyboardMouse" }, { "path": "<Gamepad>/start", "scheme": "Gamepad" } ] } ] } ]}import { clamp, createMaterialAsset, MeshAsset, MeshRenderer, pbrMaterialDefinition, Script } from "ignifx";import type { App, AssetHandle, ColorLike, Entity, InputAction, MaterialAsset, ScriptCallbacks } from "ignifx";/** * The instrument board `input-actions` reads its own input on: a standing panel of unlit geometry * that shows every value `@ignifx/input` resolved this frame. * * It is geometry rather than DOM or text for three reasons, and they are the reasons a game's HUD * is usually geometry too: * * 1. The example's poster and its golden are captured with `?nopanel=1`, so anything that only * exists in the kit's parameter panel is not in the picture. A board made of meshes is. * 2. `HudText` and `WorldText` need a `FontAsset`, and no font is vendored under * `website/examples/assets/`. Shape and colour carry the meaning instead — which is why the * keyboard cluster is drawn as **key caps in the WASD arrangement** rather than as labelled * boxes: the shape is the label. * 3. The kit is the only part of an example that touches the DOM * (`website/plan/04-examples-platform.md` §4). A board of entities keeps that true. * * ## Three mesh templates, thirty-odd elements * * A unit box, a unit sphere and a unit ring are built once and shared; every element scales the * template it points at. A `MeshAsset` is a template entities clone, so sharing is the ordinary * thing to do and it keeps the whole panel at three pieces of geometry. * * Materials are **not** shared, because a material is what an element lights up with. * `MaterialAsset.setBaseColor` marks one uniform block dirty and recompiles nothing * (`packages/core/src/render/material-asset.ts`), so a lamp costs a colour write — and * {@link setLevel} skips even that when the value has not moved, which is why a board with nothing * happening on it costs nothing at all. * * Every board material is `unlit`, so a lamp shows exactly the colour it was given whatever the * scene's lights are doing. That is what makes the golden reproducible. *//** The site's dark palette (`website/plan/02-design-system.md` §2.3), decoded to sRGB `0…1`. */const INK = { /** * The board's face. A step lighter than `--surface`, because the board is a small object in a * dark frame rather than a panel filling a screen, and the palette's own surface tone reads as * black at this size — measured by capturing the poster and looking at it. */ surface: { r: 0.098, g: 0.118, b: 0.153, a: 1 }, /** A well: a gate's plate, a bar's track. Darker than the face, so an element sits *in* it. */ sunk: { r: 0.035, g: 0.043, b: 0.059, a: 1 }, /** A key cap or a lamp at rest: light enough to read as an object that is currently off. */ rule: { r: 0.235, g: 0.275, b: 0.337, a: 1 }, /** A quiet mark: a ring, a dot at rest. */ quiet: { r: 0.4, g: 0.451, b: 0.522, a: 1 }, /** `--flame`: pressed, and the accent of the whole site. */ flame: { r: 1, g: 0.62, b: 0.29, a: 1 }, /** `--cool`: a resolved value — a stick dot, a bar fill. */ cool: { r: 0.353, g: 0.82, b: 0.784, a: 1 }, /** `--ok`: a device that is producing input, a map that is enabled. */ ok: { r: 0.357, g: 0.831, b: 0.541, a: 1 }, /** White: the frame `wasPressedThisFrame` is true in. */ hot: { r: 1, g: 1, b: 1, a: 1 },} as const;/** The board's face, in metres: wide enough to read at 640×360, and 16:9 in shape. */const FACE = { width: 5, height: 1.98, depth: 0.06 } as const;/** A gate's face, in metres. */const GATE_SIZE = 0.94;/** How far a gate's dot travels for a value of 1, in metres. */const GATE_REACH = 0.38;/** A bar's track height, in metres. */const BAR_HEIGHT = 0.9;/** A bar's track width, in metres. */const BAR_WIDTH = 0.22;/** The smallest scale an element is given, so no transform is ever singular. */const MIN_SCALE = 0.004;/** How long a `wasPressedThisFrame` flash takes to fall back to the held colour, in seconds. */const FLASH_SECONDS = 0.22;/** How long a device lamp stays lit after that device last produced input, in seconds. */const DEVICE_HOLD_SECONDS = 0.35;/** The level a lamp holds while its action is merely held, under the white of a fresh press. */const HELD_LEVEL = 0.58;/** The level a device lamp holds while its device is present but quiet. */const PRESENT_LEVEL = 0.36;/** How many steps a level is quantised to before it is written. */const LEVEL_STEPS = 48;/** How tall the board's legs are, in metres. */const LEG_HEIGHT = 0.3;/** The map the gameplay actions live in; `System` holds the one action that switches it off. */export const PLAYER_MAP = "Player";/** Where the board stands, in world metres, and how far it leans back. */export const BOARD_PLACEMENT = { x: 0, y: FACE.height / 2 + LEG_HEIGHT, z: 1.75, pitch: -6 } as const;/** A point on the board's face, in board-local metres. */interface Spot { /** Metres right of the board's centre. */ readonly x: number; /** Metres above the board's centre. */ readonly y: number;}/** A box on the board, in board-local metres. `z` is negative towards the camera. */interface Box { /** Metres right of the board's centre. */ readonly x: number; /** Metres above the board's centre. */ readonly y: number; /** Metres towards the camera from the board's centre plane. */ readonly z: number; /** Width, in metres. */ readonly w: number; /** Height, in metres. */ readonly h: number; /** Depth, in metres. */ readonly d: number;}/** The three mesh templates every element shares. */interface Shapes { /** A unit box: every plate, cap, track and fill. */ readonly box: AssetHandle<MeshAsset>; /** A unit-diameter sphere: the gates' dots. */ readonly sphere: AssetHandle<MeshAsset>; /** A unit-diameter ring, built in the XZ plane, so an element stands it up. */ readonly ring: AssetHandle<MeshAsset>;}/** One element that changes colour: its own material, and the two colours it moves between. */interface Lamp { /** The element's material. Nothing else uses it. */ readonly material: AssetHandle<MaterialAsset>; /** The colour at level 0. */ readonly off: ColorLike; /** The colour at level 1. */ readonly on: ColorLike; /** The last level written, quantised, so an unchanged lamp costs nothing. */ written: number;}/** One key cap: a lamp lit by whatever the cap's key contributes to an action. */interface Cap { /** The cap's lamp. */ readonly lamp: Lamp; /** * How lit the cap is, `0` to `1`. * * @returns The level. */ readonly read: () => number;}/** One stick gate: a dot that tracks a `vector2` action inside its rings. */interface Gate { /** The action the dot follows, or `null` when the document declares no such action. */ readonly action: InputAction | null; /** The dot's entity, moved every frame. */ readonly dot: Entity; /** The dot's lamp, lit by the action's magnitude. */ readonly lamp: Lamp;}/** One bar: a fill that grows out of a track. */interface Bar { /** The action the fill follows. */ readonly action: InputAction | null; /** The fill's entity, scaled and moved every frame. */ readonly fill: Entity; /** The fill's lamp. */ readonly lamp: Lamp; /** The track's centre, in board-local metres above the board's centre. */ readonly centreY: number; /** Whether the fill grows out of the track's centre in both directions. */ readonly bipolar: boolean;}/** One button lamp, and the flash it carries after a press. */interface Button { /** The action it watches. */ readonly action: InputAction | null; /** The lamp. */ readonly lamp: Lamp; /** Seconds of flash left, counted down on the unscaled clock. */ flash: number;}/** Which device family a lamp watches. */type DeviceRow = "keyboard" | "mouse" | "pointer" | "touch" | "gamepad";/** One device lamp: dim while its device is present, bright while it is producing input. */interface DeviceLamp { /** The family it watches. */ readonly row: DeviceRow; /** The lamp. */ readonly lamp: Lamp; /** Seconds of hold left, so a keystroke reads rather than blinking for one frame. */ hold: number;}/** One control-scheme lamp, lit while `app.input.currentScheme` names it. */interface SchemeLamp { /** The scheme's name, as the document declares it. */ readonly name: string; /** The lamp. */ readonly lamp: Lamp;}/** Which device families produced input in one frame. */interface Activity { /** A `keydown` or `keyup` arrived. */ keyboard: boolean; /** A pointer event from a mouse, or a wheel, arrived. */ mouse: boolean; /** Any pointer event arrived; every pointing device feeds `<Pointer>`. */ pointer: boolean; /** A pointer event from a touch arrived. */ touch: boolean; /** A connected pad is holding something down. Pads are polled, not evented. */ gamepad: boolean;}/** The elements one board is made of, in the order the update walks them. */interface Elements { /** The WASD cluster, the shift cap and the space bar. */ readonly caps: readonly Cap[]; /** The two stick gates: `move` and `look`. */ readonly gates: readonly Gate[]; /** The two bars: `boost` and `spin`. */ readonly bars: readonly Bar[]; /** The four button lamps. */ readonly buttons: readonly Button[]; /** One lamp per device family. */ readonly devices: readonly DeviceLamp[]; /** One lamp per control scheme, in the document's order. */ readonly schemes: readonly SchemeLamp[]; /** The lamp that says whether the `Player` map is enabled. */ readonly mapLamp: Lamp;}/** * Writes a lamp's colour for a level in `0…1`. * * @remarks * Quantised to {@link LEVEL_STEPS} and skipped when the quantised level has not moved. A material * write marks a uniform block dirty and uploads it, so a still board should cost nothing — which is * what keeps this example inside its frame budget with thirty elements on screen. * * @param lamp - The lamp to write. * @param level - How lit it is, `0` to `1`. */function setLevel(lamp: Lamp, level: number): void { const step = Math.round(clamp(level, 0, 1) * LEVEL_STEPS); if (step === lamp.written) { return; } lamp.written = step; const t = step / LEVEL_STEPS; lamp.material.value.setBaseColor({ r: lamp.off.r + (lamp.on.r - lamp.off.r) * t, g: lamp.off.g + (lamp.on.g - lamp.off.g) * t, b: lamp.off.b + (lamp.on.b - lamp.off.b) * t, a: 1, });}/** * Builds an unlit material for one board element. * * @param app - The app the asset belongs to. * @param name - The material's name, which the devtools inspector shows. * @param color - The colour it starts at. * @returns The handle, with one holder. */function createInkMaterial(app: App, name: string, color: ColorLike): AssetHandle<MaterialAsset> { return createMaterialAsset( app, // `unlit` so the colour written is the colour drawn, and `doubleSided` so nothing depends on // which way a shared template's winding happens to face. pbrMaterialDefinition({ name: `board/${name}`, baseColor: color, metallic: 0, roughness: 1, unlit: true, doubleSided: true, }), [], );}/** * Adds one box to the board. * * @param app - The app the entity and its material belong to. * @param parent - The entity the box is parented to. * @param shapes - The shared templates. * @param name - The entity's name, which is what the devtools scene tree shows. * @param box - Where it sits and how big it is, in the parent's metres. * @param color - Its colour. * @returns The entity and its own material. */function addBox( app: App, parent: Entity, shapes: Shapes, name: string, box: Box, color: ColorLike,): { readonly entity: Entity; readonly material: AssetHandle<MaterialAsset> } { const material = createInkMaterial(app, name, color); const entity = app.world.createEntity(name); entity.setParent(parent); entity.transform.localPosition.set(box.x, box.y, box.z); entity.transform.localScale.set(box.w, box.h, box.d); entity.addComponent(MeshRenderer, { mesh: shapes.box, materials: [material], castShadows: false, receiveShadows: false, pickable: false, }); return { entity, material };}/** * Adds one lamp: a box that changes colour. * * @param app - The app. * @param parent - The board. * @param shapes - The shared templates. * @param name - The entity's name. * @param box - Where it sits and how big it is. * @param off - The colour at level 0. * @param on - The colour at level 1. * @returns The lamp. */function addLamp( app: App, parent: Entity, shapes: Shapes, name: string, box: Box, off: ColorLike, on: ColorLike,): Lamp { return { material: addBox(app, parent, shapes, name, box, off).material, off, on, written: 0 };}/** * Adds one ring, standing in the board's face rather than lying in the ground. * * @param app - The app. * @param parent - The gate's group. * @param shapes - The shared templates. * @param name - The entity's name. * @param diameter - The ring's diameter, in metres. * @param color - Its colour. */function addRing(app: App, parent: Entity, shapes: Shapes, name: string, diameter: number, color: ColorLike): void { const entity = app.world.createEntity(name); entity.setParent(parent); // `MeshAsset.torus` is built in the XZ plane — it lies flat, the way the ground does — so a // quarter turn about X is what stands it up in the face. entity.transform.localEulerAngles = { x: 90, y: 0, z: 0 }; entity.transform.localScale.set(diameter, 0.05, diameter); entity.transform.localPosition.set(0, 0, -0.03); entity.addComponent(MeshRenderer, { mesh: shapes.ring, materials: [createInkMaterial(app, name, color)], castShadows: false, receiveShadows: false, pickable: false, });}/** * Builds one stick gate: a plate, a ring at a value of 1, an optional dead-zone ring, and a dot. * * @param app - The app. * @param parent - The board. * @param shapes - The shared templates. * @param name - The gate's name, used for its entities. * @param at - The gate's centre on the face, in board-local metres. * @param action - The `vector2` action the dot follows. * @param deadzone - The dead zone the action's processors apply, as a fraction of a value of 1; * `0` draws no inner ring. * @returns The gate. */function addGate( app: App, parent: Entity, shapes: Shapes, name: string, at: Spot, action: InputAction | null, deadzone: number,): Gate { // A group at scale 1, so every child below is stated in metres. A gate's plate is a sibling of // its rings rather than their parent, because a non-uniform parent scale distorts a rotated child // and the rings are rotated. const group = app.world.createEntity(`${name} gate`); group.setParent(parent); group.transform.localPosition.set(at.x, at.y, -FACE.depth / 2); addBox(app, group, shapes, `${name} plate`, { x: 0, y: 0, z: -0.015, w: GATE_SIZE, h: GATE_SIZE, d: 0.03 }, INK.sunk); addRing(app, group, shapes, `${name} unit ring`, GATE_REACH * 2, INK.rule); if (deadzone > 0) { addRing(app, group, shapes, `${name} dead zone`, GATE_REACH * 2 * deadzone, INK.quiet); } const material = createInkMaterial(app, `${name} dot`, INK.quiet); const dot = app.world.createEntity(`${name} dot`); dot.setParent(group); dot.transform.localScale.set(0.15, 0.15, 0.15); dot.transform.localPosition.set(0, 0, -0.09); dot.addComponent(MeshRenderer, { mesh: shapes.sphere, materials: [material], castShadows: false, receiveShadows: false, pickable: false, }); return { action, dot, lamp: { material, off: INK.quiet, on: INK.cool, written: 0 } };}/** * Builds one bar: a sunk track with a fill in front of it. * * @param app - The app. * @param parent - The board. * @param shapes - The shared templates. * @param name - The bar's name. * @param at - The track's centre on the face, in board-local metres. * @param action - The action the fill follows. * @param bipolar - Whether the fill grows out of the track's centre in both directions. * @returns The bar. */function addBar( app: App, parent: Entity, shapes: Shapes, name: string, at: Spot, action: InputAction | null, bipolar: boolean,): Bar { const face = -FACE.depth / 2; addBox( app, parent, shapes, `${name} track`, { x: at.x, y: at.y, z: face - 0.015, w: BAR_WIDTH, h: BAR_HEIGHT, d: 0.03 }, INK.sunk, ); const material = createInkMaterial(app, `${name} fill`, INK.quiet); const fill = app.world.createEntity(`${name} fill`); fill.setParent(parent); fill.transform.localPosition.set(at.x, at.y - BAR_HEIGHT / 2, face - 0.05); fill.transform.localScale.set(BAR_WIDTH * 0.6, MIN_SCALE, 0.06); fill.addComponent(MeshRenderer, { mesh: shapes.box, materials: [material], castShadows: false, receiveShadows: false, pickable: false, }); return { action, fill, lamp: { material, off: INK.quiet, on: INK.cool, written: 0 }, centreY: at.y, bipolar };}/** * The board's per-frame update: read the actions, write the geometry. * * @remarks * A `Script`, so it runs in `Update` with everything else and the devtools inspector lists it. Its * actions were looked up once with `app.input.actions.find`, which searches **every** map rather * than only the enabled ones — a held action whose map has been disabled reads as released, which * is exactly what the board should show when Escape has taken the `Player` map away. */export class ActionBoard extends Script implements ScriptCallbacks { /** The namespaced registration id. */ static typeId = "input-actions/ActionBoard"; #elements: Elements | null = null; /** Reused every frame, so the per-frame path allocates nothing (coding standards §7). */ readonly #activity: Activity = { keyboard: false, mouse: false, pointer: false, touch: false, gamepad: false }; /** * Hands the script the elements {@link attachBoard} built. * * @param elements - The board's elements. */ install(elements: Elements): void { this.#elements = elements; } /** * Reads this frame's input and writes it onto the board. * * @remarks * Nothing here is integrated, so the scaled delta is not needed: the flashes and the device holds * run on `time.unscaledDeltaTime`, which keeps them decaying while the game is slowed and stops * them dead under `?static=1`, where the clock is frozen before the first frame. */ update(): void { const elements = this.#elements; if (elements === null) { return; } const unscaled = this.app.time.unscaledDeltaTime; this.#readActivity(); for (const cap of elements.caps) { setLevel(cap.lamp, cap.read()); } for (const gate of elements.gates) { updateGate(gate); } for (const bar of elements.bars) { updateBar(bar); } for (const button of elements.buttons) { updateButton(button, unscaled); } for (const device of elements.devices) { this.#updateDevice(device, unscaled); } const current = this.app.input.currentScheme; for (const scheme of elements.schemes) { setLevel(scheme.lamp, scheme.name === current ? 1 : 0); } setLevel(elements.mapLamp, this.app.input.actions.map(PLAYER_MAP).enabled ? 1 : 0); } /** * Fills {@link ActionBoard.#activity} from this frame's raw events and the polled pads. * * @remarks * `app.input.events` is the frame's event list in arrival order, and its records are **pooled** — * valid for the frame and recycled after it — so this reads them and keeps none. Whether a device * is present is a different question, and a duller one: a keyboard always is, and what a visitor * wants to see is which device the input they just gave came from. */ #readActivity(): void { const activity = this.#activity; activity.keyboard = false; activity.mouse = false; activity.pointer = false; activity.touch = false; activity.gamepad = false; const events = this.app.input.events; for (let index = 0; index < events.length; index += 1) { const event = events[index]; if (event === undefined) { continue; } if (event.type === "keydown" || event.type === "keyup") { activity.keyboard = true; continue; } if (event.type === "wheel") { activity.mouse = true; continue; } activity.pointer = true; if (event.pointerType === "touch") { activity.touch = true; } else { activity.mouse = true; } } const pads = this.app.input.gamepads; for (let slot = 0; slot < pads.length; slot += 1) { const pad = pads[slot]; if (pad === undefined || !pad.isConnected) { continue; } const controls = pad.controls; for (let index = 0; index < controls.length; index += 1) { const control = controls[index]; if (control !== undefined && pad.valueAt(control.offset) !== 0) { activity.gamepad = true; break; } } } } /** * Lights one device lamp. * * @param device - The device lamp to update. * @param unscaled - The unscaled frame delta, in seconds. */ #updateDevice(device: DeviceLamp, unscaled: number): void { if (this.#activity[device.row]) { device.hold = DEVICE_HOLD_SECONDS; } else if (device.hold > 0) { device.hold = Math.max(device.hold - unscaled, 0); } if (device.hold > 0) { setLevel(device.lamp, 1); return; } const present = device.row === "gamepad" ? this.#anyPadConnected() : true; setLevel(device.lamp, present ? PRESENT_LEVEL : 0); } /** * Whether any gamepad slot is filled. * * @returns `true` when a pad has announced itself. A browser hides a pad until a button is * pressed on it, so this starts `false` even with a pad plugged in. */ #anyPadConnected(): boolean { const pads = this.app.input.gamepads; for (let slot = 0; slot < pads.length; slot += 1) { if (pads[slot]?.isConnected === true) { return true; } } return false; }}/** * Moves one gate's dot and lights it by the action's magnitude. * * @param gate - The gate to update. */function updateGate(gate: Gate): void { const action = gate.action; const x = action === null ? 0 : clamp(action.vector.x, -1, 1); const y = action === null ? 0 : clamp(action.vector.y, -1, 1); gate.dot.transform.localPosition.set(x * GATE_REACH, y * GATE_REACH, -0.09); setLevel(gate.lamp, Math.hypot(x, y));}/** * Scales and places one bar's fill. * * @param bar - The bar to update. */function updateBar(bar: Bar): void { const value = clamp(bar.action?.axis ?? 0, -1, 1); if (bar.bipolar) { // A bipolar fill grows out of the middle of its track, which is what an axis from two keys // looks like: Q one way, E the other, nothing in the middle. const height = Math.max(Math.abs(value) * (BAR_HEIGHT / 2), MIN_SCALE); bar.fill.transform.localScale.set(BAR_WIDTH * 0.6, height, 0.06); bar.fill.transform.localPosition.y = bar.centreY + Math.sign(value) * (height / 2); } else { const height = Math.max(value * BAR_HEIGHT, MIN_SCALE); bar.fill.transform.localScale.set(BAR_WIDTH * 0.6, height, 0.06); bar.fill.transform.localPosition.y = bar.centreY - BAR_HEIGHT / 2 + height / 2; } setLevel(bar.lamp, Math.abs(value));}/** * Lights one button lamp, and flashes it white for {@link FLASH_SECONDS} after the press frame. * * @param button - The button to update. * @param unscaled - The unscaled frame delta, in seconds. */function updateButton(button: Button, unscaled: number): void { const action = button.action; if (action !== null && action.wasPressedThisFrame) { button.flash = FLASH_SECONDS; } else if (button.flash > 0) { button.flash = Math.max(button.flash - unscaled, 0); } if (button.flash > 0) { setLevel(button.lamp, 1); return; } setLevel(button.lamp, action?.isPressed === true ? HELD_LEVEL : 0);}/** * Clamps a signed axis to the positive half: what one key of a `2DVector` composite contributes. * * @param value - The axis value. * @returns `0` for a negative value, the clamped value for a positive one. */function positive(value: number): number { return value > 0 ? Math.min(value, 1) : 0;}/** * Whether an action is held, as a level. * * @param action - The action, or `null`. * @returns `1` while it is pressed. */function held(action: InputAction | null): number { return action?.isPressed === true ? 1 : 0;}/** * Builds the key-cap cluster: WASD in its cross, a shift cap and a space bar under it. * * @remarks * The caps are lit from the **actions**, not from the raw keys: W is lit by whatever `move` resolved * upwards this frame, which is why pushing a gamepad stick forward lights the W cap too. That is * the whole idea of an action map — the game asks for `"move"`, and the cluster is one view of it. * * @param app - The app. * @param parent - The board. * @param shapes - The shared templates. * @param move - The `move` action. * @param boost - The `boost` action, on the shift cap. * @param jump - The `jump` action, on the space bar. * @returns The caps, in reading order. */function addCaps( app: App, parent: Entity, shapes: Shapes, move: InputAction | null, boost: InputAction | null, jump: InputAction | null,): readonly Cap[] { const z = -FACE.depth / 2 - 0.055; const cap = (name: string, x: number, y: number, w: number, read: () => number): Cap => ({ lamp: addLamp(app, parent, shapes, `${name} key`, { x, y, z, w, h: 0.3, d: 0.11 }, INK.rule, INK.flame), read, }); return [ cap("W", -1.74, 0.64, 0.3, (): number => positive(move?.vector.y ?? 0)), cap("A", -2.1, 0.28, 0.3, (): number => positive(-(move?.vector.x ?? 0))), cap("S", -1.74, 0.28, 0.3, (): number => positive(-(move?.vector.y ?? 0))), cap("D", -1.38, 0.28, 0.3, (): number => positive(move?.vector.x ?? 0)), cap("Shift", -2.11, -0.14, 0.66, (): number => clamp(boost?.axis ?? 0, 0, 1)), cap("Space", -1.33, -0.14, 0.84, (): number => held(jump)), ];}/** * Builds the device row: one lamp per device family `@ignifx/input` has, left to right. * * @param app - The app. * @param parent - The board. * @param shapes - The shared templates. * @returns The five lamps. */function addDeviceLamps(app: App, parent: Entity, shapes: Shapes): readonly DeviceLamp[] { const rows: readonly DeviceRow[] = ["keyboard", "mouse", "pointer", "touch", "gamepad"]; const z = -FACE.depth / 2 - 0.06; return rows.map((row: DeviceRow, index: number): DeviceLamp => { const name = `${row.charAt(0).toUpperCase()}${row.slice(1)}`; return { row, lamp: addLamp( app, parent, shapes, `${name} device`, { x: -2.16 + index * 0.44, y: -0.7, z, w: 0.38, h: 0.3, d: 0.07 }, INK.rule, INK.ok, ), hold: 0, }; });}/** * Builds the scheme row: one lamp per control scheme the document declares. * * @remarks * `app.input.controlSchemes` is the document's own list, so this row is whatever the `.input.json` * says and nothing is hard-coded here. The active scheme follows the device that produced input * last, which is what a HUD reads to choose its glyphs. * * @param app - The app. * @param parent - The board. * @param shapes - The shared templates. * @returns One lamp per scheme, in the document's order. */function addSchemeLamps(app: App, parent: Entity, shapes: Shapes): readonly SchemeLamp[] { const z = -FACE.depth / 2 - 0.06; return app.input.controlSchemes.map((scheme, index): SchemeLamp => ({ name: scheme.name, lamp: addLamp( app, parent, shapes, `${scheme.name} scheme`, { x: 1.0 + index * 0.58, y: -0.7, z, w: 0.52, h: 0.3, d: 0.07 }, INK.rule, INK.flame, ), }));}/** * Builds the board and attaches the script that drives it. * * @remarks * One call, the way the kit's `attachOrbit` is one call. Every action is looked up **once**, here, * because `find` walks the map table and the update runs sixty times a second — and because a name * the `.input.json` does not declare should read as `null` from the start rather than as a lookup * that fails every frame. * * @param app - The running app; needs the `input()` extension and a loaded action document. * @returns The board's root entity, so a caller can move or hide it. * * @example * ```ts * app.input.loadActions(actions); * const board = attachBoard(app); * ``` */export function attachBoard(app: App): Entity { app.registerComponents([ActionBoard]); const shapes: Shapes = { box: MeshAsset.box(app, { size: 1 }), sphere: MeshAsset.sphere(app, { diameter: 1, segments: 12 }), ring: MeshAsset.torus(app, { diameter: 1, thickness: 0.075, tessellation: 32 }), }; const root = app.world.createEntity("Board"); root.transform.localPosition.set(BOARD_PLACEMENT.x, BOARD_PLACEMENT.y, BOARD_PLACEMENT.z); root.transform.localEulerAngles = { x: BOARD_PLACEMENT.pitch, y: 0, z: 0 }; addBox(app, root, shapes, "Face", { x: 0, y: 0, z: 0, w: FACE.width, h: FACE.height, d: FACE.depth }, INK.surface); // Two legs, so the panel stands on the pad rather than floating over it. `BOARD_PLACEMENT.y` is // half the face plus this height, which is what puts their feet on the ground. for (const side of [-1, 1]) { addBox( app, root, shapes, side < 0 ? "Leg left" : "Leg right", { x: side * 2.16, y: -(FACE.height + LEG_HEIGHT) / 2, z: 0, w: 0.14, h: LEG_HEIGHT, d: 0.14 }, INK.rule, ); } // A sunk strip under the status row, so its lamps read against a well rather than against the // face they are almost the same size as. addBox( app, root, shapes, "Status well", { x: 0, y: -0.7, z: -FACE.depth / 2 - 0.015, w: FACE.width - 0.36, h: 0.44, d: 0.03 }, INK.sunk, ); const find = (name: string): InputAction | null => app.input.actions.find(name); const move = find("move"); const jump = find("jump"); const boost = find("boost"); const buttonZ = -FACE.depth / 2 - 0.045; const buttonBox = (x: number, y: number): Box => ({ x, y, z: buttonZ, w: 0.32, h: 0.32, d: 0.09 }); const buttonLamp = (name: string, x: number, y: number): Lamp => addLamp(app, root, shapes, `${name} lamp`, buttonBox(x, y), INK.rule, INK.hot); const elements: Elements = { caps: addCaps(app, root, shapes, move, boost, jump), gates: [ addGate(app, root, shapes, "Move", { x: -0.5, y: 0.22 }, move, 0.2), addGate(app, root, shapes, "Look", { x: 0.52, y: 0.22 }, find("look"), 0.15), ], bars: [ addBar(app, root, shapes, "Boost", { x: 1.16, y: 0.22 }, boost, false), addBar(app, root, shapes, "Spin", { x: 1.46, y: 0.22 }, find("spin"), true), ], buttons: [ { action: jump, lamp: buttonLamp("Jump", 1.84, 0.47), flash: 0 }, { action: find("fire"), lamp: buttonLamp("Fire", 2.22, 0.47), flash: 0 }, { action: find("sprint"), lamp: buttonLamp("Sprint", 1.84, 0.07), flash: 0 }, { action: find("toggleMap"), lamp: buttonLamp("Escape", 2.22, 0.07), flash: 0 }, ], devices: addDeviceLamps(app, root, shapes), schemes: addSchemeLamps(app, root, shapes), mapLamp: addLamp( app, root, shapes, "Player map", { x: 0.2, y: -0.7, z: -FACE.depth / 2 - 0.06, w: 0.72, h: 0.3, d: 0.07 }, INK.rule, INK.ok, ), }; root.addComponent(ActionBoard).install(elements); return root;}import { clamp, createMaterialAsset, createRay, f32, MeshAsset, MeshRenderer, pbrMaterialDefinition, Script,} from "ignifx";import type { App, AssetHandle, Camera, ColorLike, Entity, InputAction, MaterialAsset, Ray, ScriptCallbacks,} from "ignifx";/** * The thing the actions move: a small rover on the pad, and the crosshair the pointer aims with. * * The board next door shows what `@ignifx/input` resolved; this shows what a game does with it. * Everything the rover reads is an **action**, never a key or a button, which is why one script * drives it from a keyboard, a pad or a touch screen with no branch per device: * * | Action | Effect | * | -------- | ---------------------------------------------------------------------------- | * | `move` | Drives it across the pad and turns it to face where it is going | * | `spin` | Turns it in place, from the `1DAxis` composite on Q and E or the shoulders | * | `boost` | Multiplies its speed, from an analog trigger or the shift key | * | `jump` | One hop, integrated against gravity | * | `fire` | Flashes the muzzle | * | `look` | Yaws and pitches the turret | * | `aim` | Places the crosshair, converting backing-store pixels into a point on the pad | * * A disabled `Player` map makes every one of those read as released, so pressing Escape parks the * rover rather than breaking it. That is what "a map is how a game switches context" means. *//** Where the rover's body sits above the pad, in metres. */const RIDE_HEIGHT = 0.22;/** The heading the rover starts on, in degrees; a yaw of zero looks away from the camera. */const START_YAW_DEGREES = 215;/** How far from the pad's centre the rover may go, in metres. */const PAD_LIMIT = { x: 2.2, near: -2.4, far: 0.95 } as const;/** How fast the rover turns to face its heading, in degrees per second. */const TURN_DEGREES_PER_SECOND = 420;/** How fast `spin` turns the rover in place, in degrees per second. */const SPIN_DEGREES_PER_SECOND = 150;/** How far the turret yaws and pitches, in degrees per second at full stick. */const TURRET_DEGREES_PER_SECOND = 90;/** How far the turret may pitch from level, in degrees. */const TURRET_PITCH_LIMIT = 24;/** How long the muzzle stays lit after a `fire` press, in seconds. */const MUZZLE_SECONDS = 0.12;/** How many steps the thruster's glow is quantised to before it is written. */const THRUSTER_STEPS = 24;/** The rover's warm hull, in sRGB. */const HULL: ColorLike = { r: 0.95, g: 0.45, b: 0.14, a: 1 };/** The canopy and the skids: near-black metal, in sRGB. */const METAL: ColorLike = { r: 0.2, g: 0.24, b: 0.3, a: 1 };/** The muzzle and thruster at rest, in sRGB. */const EMBER_OFF: ColorLike = { r: 0.42, g: 0.22, b: 0.11, a: 1 };/** The muzzle and thruster lit, in sRGB. */const EMBER_ON: ColorLike = { r: 1, g: 0.78, b: 0.42, a: 1 };/** The crosshair, in sRGB: the site's `--cool`. */const CROSSHAIR: ColorLike = { r: 0.353, g: 0.82, b: 0.784, a: 1 };/** A point or a size in metres, named so a doc comment does not have to describe three fields. */interface Point3 { /** Metres along X. */ readonly x: number; /** Metres along Y. */ readonly y: number; /** Metres along Z. */ readonly z: number;}/** The actions one rover reads, resolved once. */interface RoverActions { /** Drives across the pad. */ readonly move: InputAction | null; /** Turns in place. */ readonly spin: InputAction | null; /** Multiplies the speed, and lights the thruster. */ readonly boost: InputAction | null; /** One hop per press. */ readonly jump: InputAction | null; /** Flashes the muzzle. */ readonly fire: InputAction | null; /** Aims the turret. */ readonly look: InputAction | null; /** Places the crosshair. */ readonly aim: InputAction | null;}/** The parts one rover is built from, handed to its script after the entities exist. */interface RoverParts { /** The turret group, yawed and pitched by `look`. */ readonly turret: Entity; /** The muzzle, lit for {@link MUZZLE_SECONDS} after a `fire` press. */ readonly muzzle: AssetHandle<MaterialAsset>; /** The thruster, lit by `boost`. */ readonly thruster: AssetHandle<MaterialAsset>; /** The crosshair ring, moved to where the pointer meets the pad and hidden when it misses. */ readonly crosshair: Entity; /** The camera the crosshair's ray is cast through. */ readonly camera: Camera; /** The actions the rover reads, resolved once by {@link attachRover}. */ readonly actions: RoverActions;}/** * Drives the rover from the `Player` map. * * @remarks * Every field is serialized, so the devtools inspector shows and edits the live numbers and a scene * file could carry them. */export class Rover extends Script.define({ speed: f32(2.3, { min: 0, tooltip: "Metres per second at full stick, before boost." }), boostFactor: f32(1.8, { min: 1, tooltip: "What a full boost multiplies the speed by." }), jumpSpeed: f32(3.1, { min: 0, tooltip: "Upward metres per second at the start of a hop." }), gravity: f32(9.5, { min: 0, tooltip: "Downward metres per second squared." }), }) implements ScriptCallbacks{ /** The namespaced registration id. */ static typeId = "input-actions/Rover"; #parts: RoverParts | null = null; /** Metres per second upward; `0` while the rover is on the pad. */ #lift = 0; /** Seconds of muzzle flash left. */ #muzzle = 0; /** The turret's yaw, in degrees. */ #turretYaw = 0; /** The turret's pitch, in degrees. */ #turretPitch = 0; /** The last thruster level written, quantised, so an idle thruster uploads nothing. */ #thrusterLevel = -1; /** Whether the muzzle is currently written lit. */ #muzzleLit = false; /** * Reused so the per-frame path allocates nothing (coding standards §7). * * @remarks * `createRay()` rather than an object literal, and the difference is not cosmetic: a ray's * `length` is how far it reaches, `createRay` sets it to `Number.MAX_VALUE`, and a literal * that forgets it stops the cast short of everything in the scene. */ readonly #ray: Ray = createRay(); /** * Hands the script the parts {@link attachRover} built. * * @param parts - The rover's parts, including its already-resolved actions. */ install(parts: RoverParts): void { this.#parts = parts; } /** * Reads the frame's actions and moves the rover. * * @param dt - Seconds since the previous frame, scaled by `time.timeScale`. Under `?static=1` the * scale is zero, so the rover holds the pose this file authored. */ update(dt: number): void { const parts = this.#parts; if (parts === null) { return; } const actions = parts.actions; const boost = clamp(actions.boost?.axis ?? 0, 0, 1); this.#drive(actions.move, actions.spin, boost, dt); this.#hop(actions.jump, dt); this.#aimTurret(actions.look, dt); this.#flash(actions.fire, parts, boost); this.#placeCrosshair(actions.aim, parts); } /** * Moves the rover across the pad and turns it. * * @param move - The `move` action. * @param spin - The `spin` action. * @param boost - The boost level, `0` to `1`. * @param dt - The scaled frame delta, in seconds. */ #drive(move: InputAction | null, spin: InputAction | null, boost: number, dt: number): void { const x = move?.vector.x ?? 0; const z = move?.vector.y ?? 0; const speed = this.speed * (1 + boost * (this.boostFactor - 1)); const position = this.transform.localPosition; position.x = clamp(position.x + x * speed * dt, -PAD_LIMIT.x, PAD_LIMIT.x); position.z = clamp(position.z + z * speed * dt, PAD_LIMIT.near, PAD_LIMIT.far); const turn = spin?.axis ?? 0; if (turn !== 0) { this.transform.rotate({ x: 0, y: turn * SPIN_DEGREES_PER_SECOND * dt, z: 0 }); return; } if (Math.hypot(x, z) < 0.05) { return; } // ignifx is left-handed with +Z forward, so a yaw of zero looks along +Z and the heading of a // stick vector is `atan2(x, y)` — the same expression a top-down game uses for its character. const target = Math.atan2(x, z) * (180 / Math.PI); const current = this.transform.localEulerAngles.y; const delta = wrapDegrees(target - current); const step = TURN_DEGREES_PER_SECOND * dt; this.transform.localEulerAngles = { x: 0, y: current + (Math.abs(delta) <= step ? delta : Math.sign(delta) * step), z: 0, }; } /** * Integrates one hop. * * @param jump - The `jump` action. * @param dt - The scaled frame delta, in seconds. */ #hop(jump: InputAction | null, dt: number): void { const position = this.transform.localPosition; if (jump?.wasPressedThisFrame === true && position.y <= RIDE_HEIGHT + 0.001) { this.#lift = this.jumpSpeed; } if (this.#lift === 0 && position.y <= RIDE_HEIGHT) { return; } this.#lift -= this.gravity * dt; position.y += this.#lift * dt; if (position.y <= RIDE_HEIGHT) { position.y = RIDE_HEIGHT; this.#lift = 0; } } /** * Yaws and pitches the turret from `look`. * * @param look - The `look` action. * @param dt - The scaled frame delta, in seconds. */ #aimTurret(look: InputAction | null, dt: number): void { const parts = this.#parts; if (parts === null || look === null) { return; } const step = TURRET_DEGREES_PER_SECOND * dt; this.#turretYaw = wrapDegrees(this.#turretYaw + look.vector.x * step); this.#turretPitch = clamp(this.#turretPitch - look.vector.y * step, -TURRET_PITCH_LIMIT, TURRET_PITCH_LIMIT); parts.turret.transform.localEulerAngles = { x: this.#turretPitch, y: this.#turretYaw, z: 0 }; } /** * Lights the muzzle after a `fire` press and the thruster while the rover is boosting. * * @param fire - The `fire` action. * @param parts - The rover's parts. * @param boost - The boost level, `0` to `1`. */ #flash(fire: InputAction | null, parts: RoverParts, boost: number): void { if (fire?.wasPressedThisFrame === true) { this.#muzzle = MUZZLE_SECONDS; } else if (this.#muzzle > 0) { this.#muzzle = Math.max(this.#muzzle - this.app.time.unscaledDeltaTime, 0); } // Both writes are guarded: `setBaseColor` marks a uniform block dirty and uploads it, and a // rover standing still should not be paying for that sixty times a second. const lit = this.#muzzle > 0; if (lit !== this.#muzzleLit) { this.#muzzleLit = lit; parts.muzzle.value.setBaseColor(lit ? EMBER_ON : EMBER_OFF); } const level = Math.round(boost * THRUSTER_STEPS); if (level !== this.#thrusterLevel) { this.#thrusterLevel = level; parts.thruster.value.setBaseColor(mix(EMBER_OFF, EMBER_ON, level / THRUSTER_STEPS)); } } /** * Puts the crosshair where the pointer meets the pad. * * @remarks * This is the conversion worth reading twice. `<Pointer>/position` is in the canvas's * **backing-store pixels** — the space `Camera.screenToRay`, `worldToScreen` and * `renderer.pickAsync` all share — so the action's vector goes straight into `screenToRay` with * no arithmetic. DOM code is the side that divides by `devicePixelRatio`, not this side. * * The ray is then met with the pad's plane by hand, because a pad is a plane and not a collider: * `t = (padY - originY) / directionY`. A ray that misses the pad, or points away from it, hides * the crosshair rather than parking it on an edge. * * @param aim - The `aim` action. * @param parts - The rover's parts. */ #placeCrosshair(aim: InputAction | null, parts: RoverParts): void { if (aim === null || (aim.vector.x === 0 && aim.vector.y === 0)) { parts.crosshair.active = false; return; } const ray = parts.camera.screenToRay(aim.vector.x, aim.vector.y, this.#ray); if (ray === null || ray.direction.y >= 0) { parts.crosshair.active = false; return; } const t = -ray.origin.y / ray.direction.y; const x = ray.origin.x + ray.direction.x * t; const z = ray.origin.z + ray.direction.z * t; if (Math.abs(x) > PAD_LIMIT.x + 0.6 || z < PAD_LIMIT.near - 0.6 || z > PAD_LIMIT.far + 0.6) { parts.crosshair.active = false; return; } parts.crosshair.active = true; parts.crosshair.transform.localPosition.set(x, 0.012, z); }}/** * Wraps an angle into `-180…180`. * * @param degrees - The angle. * @returns The same angle, in the shortest form. */function wrapDegrees(degrees: number): number { const wrapped = (((degrees + 180) % 360) + 360) % 360; return wrapped - 180;}/** * Interpolates two sRGB colours. * * @param from - The colour at `t = 0`. * @param to - The colour at `t = 1`. * @param t - Where to sample, `0` to `1`. * @returns The blended colour, opaque. */function mix(from: ColorLike, to: ColorLike, t: number): ColorLike { return { r: from.r + (to.r - from.r) * t, g: from.g + (to.g - from.g) * t, b: from.b + (to.b - from.b) * t, a: 1, };}/** * Builds the rover, the crosshair, and the script that drives them. * * @param app - The running app; needs the `input()` extension and a loaded action document. * @param camera - The camera the crosshair's ray is cast through. * @returns The rover's script, so the panel can bind its fields. * * @example * ```ts * const rover = attachRover(app, camera); * rover.speed = 4; * ``` */export function attachRover(app: App, camera: Camera): Rover { app.registerComponents([Rover]); const lit = (name: string, color: ColorLike, metallic: number, roughness: number): AssetHandle<MaterialAsset> => createMaterialAsset( app, pbrMaterialDefinition({ name: `rover/${name}`, baseColor: color, metallic, roughness }), [], ); const glow = (name: string, color: ColorLike): AssetHandle<MaterialAsset> => createMaterialAsset( app, pbrMaterialDefinition({ name: `rover/${name}`, baseColor: color, metallic: 0, roughness: 1, unlit: true }), [], ); const box = MeshAsset.box(app, { size: 1 }); const sphere = MeshAsset.sphere(app, { diameter: 1, segments: 16 }); const tube = MeshAsset.cylinder(app, { diameter: 1, height: 1, tessellation: 16 }); const hull = lit("hull", HULL, 0.35, 0.42); const metal = lit("metal", METAL, 0.25, 0.38); const muzzle = glow("muzzle", EMBER_OFF); const thruster = glow("thruster", EMBER_OFF); const root = app.world.createEntity("Rover"); root.transform.localPosition.set(0, RIDE_HEIGHT, -0.9); // Three-quarters on to the camera at the start, so a frozen capture shows the nose, the mast and // one flank rather than the back of a box. `#drive` only turns it once the player moves. root.transform.localEulerAngles = { x: 0, y: START_YAW_DEGREES, z: 0 }; // The camera looks down on the pad, so the rover is built to read from above: a flat deck, four // wheels outside its silhouette, a light bar at the nose and a glowing vent at the tail. addPart(app, root, "Deck", box, hull, { x: 0, y: 0, z: 0 }, { x: 0.62, y: 0.18, z: 0.94 }, true); addPart(app, root, "Nose", box, hull, { x: 0, y: -0.02, z: 0.56 }, { x: 0.44, y: 0.13, z: 0.22 }, true); addPart(app, root, "Light bar", box, muzzle, { x: 0, y: 0.03, z: 0.66 }, { x: 0.34, y: 0.06, z: 0.05 }, false); for (const side of [-1, 1]) { for (const end of [-1, 1]) { const label = `${side < 0 ? "Left" : "Right"} ${end < 0 ? "rear" : "front"} wheel`; addPart( app, root, label, tube, metal, { x: side * 0.37, y: -0.05, z: end * 0.31 }, // Scale first, then rotate: a unit cylinder is a disc of diameter 1 in XZ standing 1 along // Y, so 0.26 across and 0.12 tall is a wheel, and the quarter turn lays it on its axle. { x: 0.26, y: 0.12, z: 0.26 }, true, // `MeshAsset.cylinder` stands along Y, so a quarter turn about Z lays a wheel on its axle. { x: 0, y: 0, z: 90 }, ); } } addPart(app, root, "Vent", tube, thruster, { x: 0, y: 0.02, z: -0.5 }, { x: 0.3, y: 0.08, z: 0.3 }, false, { x: 90, y: 0, z: 0, }); const turret = app.world.createEntity("Turret"); turret.setParent(root); turret.transform.localPosition.set(0, 0.11, -0.06); addPart(app, turret, "Canopy", sphere, metal, { x: 0, y: 0, z: 0 }, { x: 0.32, y: 0.2, z: 0.36 }, true); addPart(app, turret, "Mast", box, metal, { x: 0, y: 0.03, z: 0.28 }, { x: 0.07, y: 0.06, z: 0.36 }, true); addPart(app, turret, "Muzzle", box, muzzle, { x: 0, y: 0.03, z: 0.47 }, { x: 0.1, y: 0.07, z: 0.09 }, false); // A flat ring on the pad. `MeshAsset.torus` is built in the XZ plane, which is where a mark on // the ground wants to be, so this one needs no rotation at all. const crosshair = app.world.createEntity("Crosshair"); crosshair.transform.localScale.set(0.34, 0.06, 0.34); crosshair.addComponent(MeshRenderer, { mesh: MeshAsset.torus(app, { diameter: 1, thickness: 0.12, tessellation: 24 }), materials: [glow("crosshair", CROSSHAIR)], castShadows: false, receiveShadows: false, pickable: false, }); crosshair.active = false; // `find` rather than `get`: `get` searches the *enabled* maps and throws `IGX-0801` the moment // Escape disables `Player`, while a handle taken this way keeps working and reads as released. const find = (name: string): InputAction | null => app.input.actions.find(name); const rover = root.addComponent(Rover); rover.install({ turret, muzzle, thruster, crosshair, camera, actions: { move: find("move"), spin: find("spin"), boost: find("boost"), jump: find("jump"), fire: find("fire"), look: find("look"), aim: find("aim"), }, }); return rover;}/** * Adds one part of the rover. * * @param app - The app. * @param parent - The entity the part hangs from. * @param name - The part's name. * @param mesh - The template it clones. * @param material - The material it wears. * @param at - Where its centre sits, in the parent's metres. * @param size - Its size along each axis, in metres. * @param casts - Whether it casts a shadow. * @param spin - Euler angles in degrees, for a part whose template stands the wrong way. */function addPart( app: App, parent: Entity, name: string, mesh: AssetHandle<MeshAsset>, material: AssetHandle<MaterialAsset>, at: Point3, size: Point3, casts: boolean, spin?: Point3,): void { const entity = app.world.createEntity(name); entity.setParent(parent); entity.transform.localPosition.set(at.x, at.y, at.z); entity.transform.localScale.set(size.x, size.y, size.z); if (spin !== undefined) { entity.transform.localEulerAngles = spin; } entity.addComponent(MeshRenderer, { mesh, materials: [material], castShadows: casts, receiveShadows: false, pickable: false, });}