ignifx
GitHubnpm · soon
All examples

UI overlay

UI3D

  • Mouse
  • Keyboard
  • Touch
  • Gamepad

Game UI in ignifx is HTML. `@ignifx/ui` puts one positioned element over the canvas and hands your game named layers inside it, so a HUD is a `div`, a modal is a `Dialog` and a message is a `Toast` — real fonts, real layout, screen readers, any framework you already know. The score in the corner is the exception: `HudText` is drawn by the GPU in backing-store pixels, for text that has to line up with a screenshot. The Scaling select decides what a UI unit is, and the notch toggle shows a desktop what a phone reports.

Three copper shapes turning on a grid floor, with an orange pixel-font score reading SCORE 001200 in the top-left corner and a small readout panel below it listing the overlay's scaling mode, unit, size and scale.
Open standalone

` devtools

WebGPU: checking…

Try this

  • Switch Scaling to fit: every HTML part of the overlay rescales together, and the GPU score does not.
  • Turn on “Simulate a notch” and watch the HUD move inside the four safe-area insets.
  • Open the dialog. It pauses the game, and the toast it raises still expires — that script runs while paused.
Show source

Source

main.ts
import { Camera, Dialog, FONT_ASSET_TYPE, HudText, Script, Toast } from "ignifx";import { bootExample } from "../_kit/boot.ts";import { attachOrbit } from "../_kit/orbit.ts";import { button, readout, select, toggle } from "../_kit/panel.ts";import { createGridGround, createLightRig } from "../_kit/stage.ts";import { createHud } from "./hud.ts";import { createProps, Spinner } from "./props.ts";import type { Hud } from "./hud.ts";import type { AssetHandle, FontAsset, ScriptCallbacks, UiScalingMode } from "ignifx";/** * The overlay, all four of its parts, over a scene you can still see. * * `@ignifx/ui` puts one absolutely positioned `<div>` over the canvas and hands a game named * layers inside it. **Game UI in ignifx is HTML**, which is what buys real fonts, real layout, * screen readers and any framework the team already knows. So: * * - the HUD panel and its safe-area frame are plain DOM in `app.ui.layer("hud")` (`hud.ts`); * - `Dialog` and `Toast` are DOM helpers with no styling opinions, in the `menu` and `overlay` *   layers, stacked by layer rather than by luck; * - `HudText` is the exception — **GPU** text, positioned in backing-store pixels, which is the one *   tool for text that has to line up with `captureScreenshot()`. * * The Scaling select is the part worth playing with. It decides what a UI unit *is*: a CSS pixel * (`css`), a pixel of a fixed reference resolution (`fit`, letterboxed), or a backing-store pixel * (`dpi`). Switch it and the DOM half of the overlay changes size while the `HudText` above it does * not, because the two live in different spaces — `app.ui.pixelMapping` is the conversion between * them. The parameter panel is DOM in a layer too, so it resizes with the HUD. * * One gotcha is load-bearing and this file shows the fix rather than describing it: **a `Toast` * runs on the game clock and nothing advances it for you.** The script that does declares * `static updateWhenPaused = true` and passes `app.time.unscaledDeltaTime`, so the message the * dialog raises still expires — the dialog paused the game to ask. *//** The font `HudText` draws with. Vendored, OFL-1.1; see `assets/ATTRIBUTION.md`. */const FONT_ADDRESS = "fonts/share-tech-mono.ttf";/** The reference resolution `"fit"` letterboxes to: the size this HUD was drawn for. */const REFERENCE: readonly [number, number] = [960, 540];/** The scaling modes, in the order the select offers them. */const MODES: readonly UiScalingMode[] = ["css", "fit", "dpi"];/** What the score reads before anything has been scored. */const START_SCORE = 1200;/** * Advances the toasts and re-reads the HUD's figures, whether the game is running or not. * * @remarks * `updateWhenPaused` is the whole point of the class. `app.pause()` stops `update` for every * ordinary script, so a "Back in" toast raised from a modal that paused the game would sit on * screen for ever — and the HUD's figures would freeze with it. */class OverlayClock extends Script implements ScriptCallbacks {  /** The namespaced registration id. */  static typeId = "ui-overlay/OverlayClock";  /** Keeps running while the game is paused, which is when a menu raises a toast. */  static updateWhenPaused = true;  /** The toast stack to advance. Assigned in code; not something a scene file could carry. */  toasts: Toast | null = null;  /** The HUD to refresh, or `null` on an app with no overlay. */  hud: Hud | null = null;  /** Advances every message's timer and rewrites the HUD's figures. */  update(): void {    this.toasts?.advance(this.app.time.unscaledDeltaTime);    this.hud?.update(this.app.ui.layout);  }}bootExample({  title: "UI overlay",  settings: {    rendering: {      clearColor: { r: 0.043, g: 0.059, b: 0.094, a: 1 },      msaaSamples: 4,      features: { shadows: true },    },    // The overlay's own settings section. Both are live on `app.ui` as well, which is what the    // Scaling select writes.    ui: { scaling: "css", referenceResolution: [...REFERENCE] },    time: { fixedDeltaTime: 1 / 60 },  },  async setup({ app, panel }) {    app.registerComponents([OverlayClock, Spinner]);    const focus = { x: 0, y: 0.55, z: 0 };    const eye = app.world.createEntity("Main Camera");    eye.addComponent(Camera, { near: 0.1, far: 200, fov: 40 });    attachOrbit(app, eye, { yaw: 18, pitch: 24, distance: 3.9, target: focus, minDistance: 2, maxDistance: 12 });    createLightRig(app, { focus, shadows: true, shadowDarkness: 0.3 });    await createGridGround(app, { size: 24 });    createProps(app);    // Awaited before `app.start()`, so the glyphs are shaped for the first frame rather than a    // frame or two later — which is what makes a capture of this example reproducible.    const font: AssetHandle<FontAsset> = app.assets.load(FONT_ADDRESS, { type: FONT_ASSET_TYPE });    await font.promise;    const score = app.world.createEntity("Score").addComponent(HudText, {      font,      text: `SCORE ${String(START_SCORE).padStart(6, "0")}`,      anchor: "topLeft",      position: { x: 24, y: 22 },      fontSize: 30,      color: { r: 1, g: 0.72, b: 0.42, a: 1 },    });    const hud = createHud(app.ui);    const toasts = new Toast(app.ui, { duration: 3, maxVisible: 3 });    // Re-used rather than rebuilt per question: a `Dialog` is always above the other roots of its    // layer, and one per question leaves a stack of modals in the document.    const dialog = new Dialog(app.ui, {      title: "Leave the run?",      message: "Progress since the last checkpoint is lost. A Dialog is plain DOM with no styling opinions.",      buttons: [        { id: "stay", label: "Keep playing" },        { id: "leave", label: "Leave" },      ],      dismissOnBackdrop: true,      visible: false,    });    dialog.onChosen.connect((id: string): void => {      dialog.hide();      app.resume();      toasts.show(id === "leave" ? "Run abandoned" : "Back in");    });    const clock = app.world.createEntity("Overlay").addComponent(OverlayClock);    clock.toasts = toasts;    clock.hud = hud;    panel({      title: "UI overlay",      groups: [        {          label: "Overlay",          controls: [            // One assignment is the whole switch: the host re-lays out the root, republishes the            // scale and emits `onLayoutChanged`.            select("Scaling", [...MODES], {              value: app.ui.scaling,              change: (mode: string): void => {                app.ui.scaling = MODES.find((name: UiScalingMode): boolean => name === mode) ?? "css";              },            }),            toggle("Simulate a notch", {              value: false,              change: (on: boolean): void => {                hud?.setNotch(on);              },            }),            // One layer, not the whole overlay: `app.ui.visible = false` would take the parameter            // panel with it — it is mounted in a layer of its own — and leave nothing to turn it            // back on with. Hiding a layer is what a game does for a cutscene anyway.            toggle("HUD layer", {              value: true,              change: (on: boolean): void => {                app.ui.layer("hud").visible = on;              },            }),          ],        },        {          label: "Widgets",          controls: [            button("Open the dialog", (): void => {              // A modal question is what a game pauses for, and the widget does not do it for you.              app.pause();              dialog.show();            }),            button("Show a toast", (): void => {              toasts.show("Checkpoint reached");            }),            button("Score +50", (): void => {              // Changing `text` re-shapes the block and nothing else; changing `fontSize` would              // rebuild it, which is why a per-frame counter is cheap and a per-frame size is not.              const next = Number(score.text.slice(6)) + 50;              score.text = `SCORE ${String(next).padStart(6, "0")}`;            }),          ],        },        {          label: "Focus",          collapsed: true,          controls: [            readout("Toasts on screen", (): string => String(toasts.messages.length)),            readout("Pointer over UI", (): string => (app.ui.pointerOverUi ? "yes" : "no")),            readout("Keyboard captured", (): string => (app.ui.keyboardHasFocus ? "yes" : "no")),          ],        },      ],    });  },});
hud.ts
/** * The DOM half of the overlay: the HUD panel `app.ui.layer("hud")` carries, and the safe-area * override the "Simulate a notch" toggle writes. * * @remarks * **This is not the kit reaching into the document.** `04-examples-platform.md` §4 says the kit is * the only place an example touches the DOM, and `@ignifx/ui` is the one exception the rule was * written around: game UI in ignifx *is* HTML, and `app.ui.layer(name).element` is the engine * handing a game the `<div>` it is meant to build into. Everything below goes through that element. * * Two things are worth reading before copying it. * * `ignifx-ui-interactive` is what turns pointer events back on. The overlay root and its layers are * `pointer-events: none` so a click reaches the canvas; a HUD that wants a button says so with that * class, and `UI_CLASS_NAMES` is where the name comes from rather than a string literal. * * The four safe-area insets are published by the host as CSS custom properties on the overlay root * — `env(safe-area-inset-*)` with a `0px` fallback — so game CSS reads a notch with * `var(--ignifx-safe-top)` and never measures anything. A desktop browser reports zero for all * four, which is why this example can set them on its own subtree: the same declarations, the same * cascade, and a laptop can see what a phone would do. */import { UI_CLASS_NAMES, UI_CSS_VARIABLES } from "ignifx";import type { UiHost, UiLayout } from "ignifx";/** The inset a simulated notch reports on the top and bottom edges. */const NOTCH_BLOCK = "34px";/** The inset a simulated notch reports on the left and right edges. */const NOTCH_INLINE = "18px";/** The HUD, mounted, with the two things `main.ts` drives from the panel. */export interface Hud {  /** The outermost element, so a caller can read it in a test. */  readonly element: HTMLElement;  /**   * Writes the live figures. Called every frame, and a no-op on a frame where nothing changed.   *   * @param layout - What `app.ui.layout` currently says.   */  readonly update: (layout: UiLayout) => void;  /**   * Overrides the four safe-area insets on this subtree, or gives them back to the host.   *   * @param on - `true` to report a notch, `false` to inherit the device's own insets.   */  readonly setNotch: (on: boolean) => void;  /** Removes the HUD. */  readonly dispose: () => void;}/** * Builds one labelled figure. * * @param label - The label text. * @returns The row, and the cell {@link Hud.update} rewrites. */function buildRow(label: string): { row: HTMLElement; value: HTMLElement } {  const row = document.createElement("div");  row.className = "hud-row";  const name = document.createElement("span");  name.textContent = label;  const value = document.createElement("span");  value.className = "hud-value";  row.append(name, value);  return { row, value };}/** * Mounts the HUD into the overlay's `hud` layer. * * @remarks * Returns `null` on an app with no DOM overlay — a headless app — so the caller needs no branch. * The layer is created for you: `layer(name)` is get-or-create. * * @param ui - The overlay host, normally `app.ui`. * @returns The HUD, or `null` when there is no overlay to mount into. * * @example * ```ts * const hud = createHud(app.ui); * hud?.update(app.ui.layout); * ``` */export function createHud(ui: UiHost): Hud | null {  const host = ui.layer("hud").element;  if (host === null) {    return null;  }  const element = document.createElement("section");  // Interactive because the HUD carries a button; a HUD that only reads would leave the class off  // and let every click through to the game.  element.className = `${UI_CLASS_NAMES.interactive} hud`;  element.setAttribute("aria-label", "Heads-up display");  const title = document.createElement("h2");  title.className = "hud-title";  title.textContent = "app.ui";  element.append(title);  const mode = buildRow("scaling");  const unit = buildRow("one UI unit");  const size = buildRow("root, UI units");  const scale = buildRow("root scale");  const inset = buildRow("safe area, top");  element.append(mode.row, unit.row, size.row, scale.row, inset.row);  const note = document.createElement("p");  note.className = "hud-note";  note.textContent = "Plain DOM in app.ui.layer(“hud”). The score above it is HudText, drawn by the GPU.";  element.append(note);  host.append(element);  const words: Readonly<Record<UiLayout["mode"], string>> = {    css: "one CSS pixel",    fit: "one reference pixel",    dpi: "one backing-store pixel",  };  /**   * Reads the resolved top inset back out of the cascade.   *   * @remarks   * Called when the notch toggle moves and once at mount, never per frame: `getComputedStyle`   * forces a style recalculation, and a HUD that did it every frame would be the most expensive   * thing on the page. It is read back rather than remembered so the figure is what the cascade   * resolved — `env(safe-area-inset-top, 0px)` on a desktop, the override when one is set.   *   * @returns The inset as CSS wrote it, e.g. `"0px"`.   */  const readInset = (): string => getComputedStyle(element).getPropertyValue(UI_CSS_VARIABLES.safeTop).trim() || "0px";  // The last layout written, so a per-frame refresh with nothing to say touches no DOM at all.  let written = "";  inset.value.textContent = readInset();  return {    element,    update(layout: UiLayout): void {      const key = `${layout.mode}|${String(layout.width)}|${String(layout.height)}|${String(layout.scale)}`;      if (key === written) {        return;      }      written = key;      mode.value.textContent = layout.mode;      unit.value.textContent = words[layout.mode];      size.value.textContent = `${String(Math.round(layout.width))} × ${String(Math.round(layout.height))}`;      scale.value.textContent = layout.scale.toFixed(3);    },    setNotch(on: boolean): void {      const style = element.style;      for (const [name, value] of [        [UI_CSS_VARIABLES.safeTop, NOTCH_BLOCK],        [UI_CSS_VARIABLES.safeBottom, NOTCH_BLOCK],        [UI_CSS_VARIABLES.safeLeft, NOTCH_INLINE],        [UI_CSS_VARIABLES.safeRight, NOTCH_INLINE],      ] as const) {        if (on) {          style.setProperty(name, value);        } else {          style.removeProperty(name);        }      }      inset.value.textContent = readInset();    },    dispose(): void {      element.remove();    },  };}
props.ts
/** * The scene behind the overlay: three shapes turning on the grid, so the overlay has something to * sit in front of and the point of an overlay is visible. * * @remarks * A separate file for the reason `pbr-model/shot.ts` is: none of it is a lesson about UI. */import { createMaterialAsset, f32, MeshAsset, MeshRenderer, pbrMaterialDefinition, Script } from "ignifx";import type { App, ScriptCallbacks } from "ignifx";/** Spins its entity about Y, in degrees per second. */export class Spinner  extends Script.define({ speed: f32(30, { tooltip: "Degrees per second about Y." }) })  implements ScriptCallbacks{  /** The namespaced registration id. */  static typeId = "ui-overlay/Spinner";  /** Reused so the per-frame path allocates nothing (coding standards §7). */  readonly #step = { x: 0, y: 0, z: 0 };  /**   * Advances the rotation.   *   * @param dt - Seconds since the previous frame, scaled — so `?static=1` holds the pose and a   * capture of this example is the same frame every time.   */  update(dt: number): void {    this.#step.y = this.speed * dt;    this.transform.rotate(this.#step);  }}/** * Builds the three props. * * @remarks * `app.registerComponents([Spinner])` is the caller's job, because a game registers every component * it uses in one place. * * @param app - The app the entities and the assets belong to. * * @example * ```ts * app.registerComponents([Spinner]); * createProps(app); * ``` */export function createProps(app: App): void {  const material = createMaterialAsset(    app,    pbrMaterialDefinition({      name: "ui-overlay/prop",      baseColor: { r: 0.55, g: 0.36, b: 0.22, a: 1 },      metallic: 0.35,      roughness: 0.4,    }),    [],  );  const meshes = [    MeshAsset.box(app, { size: 0.62 }),    MeshAsset.torus(app, { diameter: 0.8, thickness: 0.22, tessellation: 24 }),    MeshAsset.capsule(app, { height: 0.9, radius: 0.22, tessellation: 16 }),  ];  for (const [index, mesh] of meshes.entries()) {    const prop = app.world.createEntity(`Prop ${String(index + 1)}`);    prop.transform.localPosition.set((index - 1) * 1.15, index === 1 ? 0.24 : 0.45, 0);    prop.transform.localEulerAngles = { x: 0, y: 25 * index, z: index === 1 ? 90 : 0 };    prop.addComponent(MeshRenderer, { mesh, materials: [material], castShadows: true });    prop.addComponent(Spinner, { speed: 18 + index * 14 });  }}
hud.css
/*
 * The HUD panel's own styling, in the site's tokens (`02-design-system.md` §2.3), transcribed for
 * the same reason `_kit/kit.css` transcribes them: a frame is a separate document and the site's
 * stylesheet is emitted with a content hash this build cannot know.
 *
 * One deviation from `08-execution.md` §4.2, which asks that every `<slug>/index.html` be identical
 * in shape: this example links a second stylesheet. It is the one example whose subject is DOM UI,
 * and the alternative — thirty `element.style.setProperty` calls in `hud.ts` — would bury the
 * lesson under styling. The frames' CSP allows `style-src 'self'`, so a linked stylesheet needs no
 * relaxation.
 *
 * The four `--ignifx-safe-*` custom properties are published by `@ignifx/ui` on the overlay root
 * from `env(safe-area-inset-*)`. Reading them here, rather than reading `env()` directly, is what
 * lets the panel's "Simulate a notch" toggle show a laptop what a phone would do.
 */

.hud {
  position: absolute;
  top: calc(88px + var(--ignifx-safe-top, 0px));
  left: calc(20px + var(--ignifx-safe-left, 0px));
  box-sizing: border-box;
  width: 19rem;
  max-width: calc(100% - 40px);
  padding: 12px 14px;
  border: 1px solid #262d38;
  border-radius: 6px;
  background: rgb(20 24 31 / 88%);
  color: #e7eaf0;
  font:
    13px/1.5 ui-monospace,
    sfmono-regular,
    "SF Mono",
    menlo,
    consolas,
    monospace;
}

@media (prefers-color-scheme: light) {
  .hud {
    border-color: #d2d8e2;
    background: rgb(250 251 252 / 92%);
    color: #14181f;
  }
}

.hud-title {
  margin: 0 0 8px;
  color: #ff9e4a;
  font-size: 0.8125rem;
  font-weight: 600;
  letter-spacing: 0.06em;
  text-transform: uppercase;
}

@media (prefers-color-scheme: light) {
  .hud-title {
    color: #a63d07;
  }
}

.hud-row {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 12px;
  padding: 1px 0;
}

.hud-value {
  color: #5ad1c8;
  font-variant-numeric: tabular-nums;
  text-align: right;
}

@media (prefers-color-scheme: light) {
  .hud-value {
    color: #0b6a78;
  }
}

.hud-note {
  margin: 10px 0 0;
  color: #98a2b3;
  font-size: 0.75rem;
  line-height: 1.45;
}

@media (prefers-color-scheme: light) {
  .hud-note {
    color: #4b5464;
  }
}

Uses:app.uiDialogToastHudTextUI_CSS_VARIABLESScript

Assets:Share Tech Mono — OFL-1.1, Carrois Type Design, Ralph du Carrois