Examples

Interface

A pause menu and options over a running scene: laid out, themed, clipped, in DriftScript.

Starts examples/interface in this page, on WebGPU where your browser has it.

Open on its own · Source

Interfaces is the chapter that walks through it.

From a checkout of the engine, npm run examples serves it at /interface/.

Source

main.ts

examples/interface/main.ts
/**
 * A pause menu and an options screen over a scene that keeps running behind them.
 *
 * The page builds the interface as a tree of nodes, lays nothing out by hand, and draws it as one
 * sprite pass with the pixel font over it. `menu.drs` routes the pointer and the keys to the tree,
 * answers what was activated, and paints each button from its own state: Resume closes the menu,
 * Options opens a second panel whose toggles change the scene, and its list scrolls inside its own
 * box. The mouse, the keyboard and a gamepad all reach it. The switches move the menu and change its
 * theme on the running page.
 */
import {
  DEFAULT_TEXT_STYLE,
  InputSource,
  MeshBuilder,
  computeLightMatrix,
  createEnvironment,
} from '@driftengine/core';
import type { TextHandle, TextStyle } from '@driftengine/core';
import {
  addUiChild,
  createAffine2D,
  createSpritePass,
  createTheme,
  createUiNode,
  deriveTheme,
  drawUiTree,
  routeScrollWheel,
  screenToNdc,
  themeRgba,
} from '@driftengine/ui2d';
import type { UiNode, UiNodeOptions, UiRect } from '@driftengine/ui2d';
import { patchModule } from 'driftscript';
import { exported, hostScript } from '../common/script';
import { controls, flag, openStage } from '../common/stage';
import * as menuScript from './menu.drs';

const stage = await openStage({ directionalShadows: true, outputTransform: 'aces' });
const { renderer, camera, canvas } = stage;

// #region tree
/* The interface as nodes: a root that fills the screen, the pause menu, and the options panel with
   its toggles and a list that clips and scrolls. Sizes are numbers, `fit` or `grow`; nothing is
   placed by hand. */
const BUTTON: UiNodeOptions = { width: 260, height: 34, interactive: true, focusable: true };
const root = createUiNode({
  direction: 'column',
  width: 'grow',
  height: 'grow',
  justify: 'center',
  padding: 40,
});
const add = (parent: UiNode, options: UiNodeOptions): UiNode =>
  addUiChild(parent, createUiNode(options));
const menu = add(root, {
  direction: 'column',
  padding: 18,
  gap: 8,
  name: 'menu',
  background: [0, 0, 0, 1],
});
add(menu, { width: 260, height: 36, text: 'PAUSED', name: 'title' });
add(menu, { ...BUTTON, text: 'RESUME', name: 'resume' });
add(menu, { ...BUTTON, text: 'OPTIONS', name: 'options' });
add(menu, { ...BUTTON, text: 'RESTART', name: 'restart' });
const options = add(root, {
  direction: 'column',
  padding: 18,
  gap: 8,
  name: 'optionsPanel',
  hidden: true,
  background: [0, 0, 0, 1],
});
add(options, { width: 260, height: 36, text: 'OPTIONS', name: 'optionsTitle' });
add(options, { ...BUTTON, text: 'SPIN  ON', name: 'spin' });
add(options, { ...BUTTON, text: 'SHADOWS  ON', name: 'shadows' });
const help = add(options, {
  direction: 'column',
  width: 260,
  height: 112,
  padding: 8,
  gap: 6,
  clip: true,
  name: 'help',
  background: [0, 0, 0, 1],
});
for (const line of [
  'THE WHEEL SCROLLS IT',
  'TAB OR ARROWS MOVE',
  'ENTER OR SPACE PRESS',
  'ESCAPE GOES BACK',
  'A PAD: D-PAD AND A',
  'START PAUSES',
  'THE LIST CLIPS ROWS',
  'AND SCROLLS IN PLACE',
]) {
  add(help, { width: 'grow', height: 16, text: line });
}
add(options, { ...BUTTON, text: 'BACK', name: 'back' });
// #endregion

// #region theme
/* Colours by name, packed as 0xRRGGBBAA. The light theme is the dark one with four overrides. */
const DARK = createTheme({
  panel: 0x14161dee,
  well: 0x0b0c10ff,
  text: 0xe6e8eeff,
  title: 0xf0b45cff,
});
const LIGHT = deriveTheme(DARK, {
  panel: 0xf2f3f6ee,
  well: 0xdfe2e8ff,
  text: 0x1a1c22ff,
  title: 0xb4602aff,
});
let theme = flag('theme', 'dark') === 'light' ? LIGHT : DARK;
const textColour = new Float32Array(4);
let bodyStyle: TextStyle = DEFAULT_TEXT_STYLE;
let titleStyle: TextStyle = DEFAULT_TEXT_STYLE;
/* Written into the nodes' own colours, and the label styles built here, once a change of theme. */
function applyTheme(): void {
  themeRgba(theme, 'panel', 0x000000ff, menu.background as Float32Array);
  themeRgba(theme, 'panel', 0x000000ff, options.background as Float32Array);
  themeRgba(theme, 'well', 0x000000ff, help.background as Float32Array);
  themeRgba(theme, 'text', 0xffffffff, textColour);
  bodyStyle = {
    ...DEFAULT_TEXT_STYLE,
    cellSize: 2,
    color: [textColour[0] ?? 1, textColour[1] ?? 1, textColour[2] ?? 1],
  };
  themeRgba(theme, 'title', 0xffffffff, textColour);
  titleStyle = {
    ...DEFAULT_TEXT_STYLE,
    cellSize: 3,
    color: [textColour[0] ?? 1, textColour[1] ?? 1, textColour[2] ?? 1],
  };
}
applyTheme();
// #endregion

// #region script
const script = hostScript(menuScript);
interface Menu {
  open: boolean;
  spin: boolean;
  shadows: boolean;
  light: boolean;
  restarts: number;
}
const state = exported<() => Menu>(script, 'createMenu')();
state.light = theme === LIGHT;
exported<(tree: UiNode) => void>(script, 'begin')(root);
type Update = (
  menu: Menu,
  tree: UiNode,
  width: number,
  height: number,
  x: number,
  y: number,
  down: boolean,
  key: string,
) => void;
if (import.meta.hot) {
  import.meta.hot.accept('./menu.drs', (next) => {
    if (next !== undefined) patchModule(script, next as Record<string, unknown>, { Menu: [state] });
  });
}
// #endregion

// #region devices
/* The pointer in CSS pixels, the wheel, the one key pressed this frame, and a pad's buttons named
   as the keys they stand for. The script is handed all of it and routes it to the tree. */
const pointer = { x: -1, y: -1, down: false };
let pressedKey = '';
const ROUTED = new Set(['Escape', 'ArrowDown', 'ArrowUp', 'Enter', ' ', 'Tab']);
canvas.addEventListener('pointermove', (event) => {
  pointer.x = event.offsetX;
  pointer.y = event.offsetY;
});
canvas.addEventListener('pointerdown', () => {
  pointer.down = true;
});
addEventListener('pointerup', () => {
  pointer.down = false;
});
canvas.addEventListener(
  'wheel',
  (event) => {
    event.preventDefault();
    routeScrollWheel(root, event.offsetX, event.offsetY, event.deltaX, event.deltaY);
  },
  { passive: false },
);
addEventListener('keydown', (event) => {
  if (!ROUTED.has(event.key)) return;
  event.preventDefault();
  pressedKey = event.key;
});
const input = new InputSource(canvas);
function padKey(): string {
  const pad = input.pad(0);
  if (pad === null) return '';
  if (pad.pressed('dpadDown')) return 'ArrowDown';
  if (pad.pressed('dpadUp')) return 'ArrowUp';
  if (pad.pressed('faceDown')) return 'Enter';
  if (pad.pressed('faceRight') || pad.pressed('start')) return 'Escape';
  return '';
}
// #endregion

controls([
  {
    key: 'align',
    label: 'menu',
    value: flag('align', 'centre'),
    options: ['centre', 'left'].map((a) => ({ text: a, value: a })),
    change: (value) => {
      root.align = value === 'left' ? 'start' : 'center';
    },
  },
  {
    key: 'theme',
    label: 'theme',
    value: theme === LIGHT ? 'light' : 'dark',
    options: ['dark', 'light'].map((t) => ({ text: t, value: t })),
    change: (value) => {
      theme = value === 'light' ? LIGHT : DARK;
      state.light = theme === LIGHT;
      applyTheme();
    },
  },
]);
root.align = flag('align', 'centre') === 'left' ? 'start' : 'center';

/* The scene behind it: a sculpture on a plinth, which spins while the menu says so. */
const scene = renderer.createMesh(
  new MeshBuilder()
    .addCylinder([0, 0.2, 0], 1.6, 0.2, 'y', [0.42, 0.42, 0.45], 0, 48)
    .addBox([0, -0.05, 0], [30, 0.05, 30], [0.3, 0.33, 0.38])
    .build(),
);
const sculpture = renderer.createMesh(
  new MeshBuilder()
    .addBox([0, 1.1, 0], [0.5, 0.5, 0.5], [0.85, 0.45, 0.2])
    .addBox([0, 1.95, 0], [0.32, 0.32, 0.32], [0.25, 0.55, 0.85])
    .addSphere([0, 2.55, 0], 0.22, [0.9, 0.9, 0.92], 0, 16, 8, 0.6)
    .build(),
);
const env = createEnvironment({
  directionalDir: [0.5, 0.8, 0.3],
  directionalColor: [1.6, 1.55, 1.45],
  ambient: [0.38, 0.4, 0.48],
  ambientGround: [0.14, 0.14, 0.15],
});
const lightMatrix = new Float32Array(16);
env.lightViewProj = lightMatrix;
env.shadowDepthSpan = computeLightMatrix(
  env.directionalDir,
  0,
  1,
  0,
  4,
  renderer.shadowMapSize,
  lightMatrix,
);
const IDENTITY = new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]);
const model = new Float32Array(IDENTITY);

// #region draw
/* One sprite pass for the panels and buttons, and the pixel font over them: the tree says where
   each label goes and how much of it a clip leaves, and the page draws it. */
const pass = createSpritePass({ capacity: 256, slots: 1, label: 'interface' });
const handle = renderer.registerPass(pass);
const affine = createAffine2D();
const labels = new Map<UiNode, TextHandle>();
const queued: UiNode[] = [];
const sink = {
  content(node: UiNode, visible: UiRect): void {
    /* A label in a row a clip has cut is left out, rather than drawn over the list's edge. */
    if (visible.h < node.rect.h) return;
    queued.push(node);
  },
};
function drawLabels(width: number, height: number, time: number): void {
  for (const node of queued) {
    let label = labels.get(node);
    if (label === undefined) {
      label = renderer.createText();
      labels.set(node, label);
    }
    renderer.setText(label, node.text);
    const big = node.name === 'title' || node.name === 'optionsTitle';
    const style = big ? titleStyle : bodyStyle;
    const cell = big ? 3 : 2;
    const x = node.rect.x + (node.interactive ? 12 : 4);
    const baseline = node.rect.y + node.rect.h / 2 + (cell * 7) / 2;
    renderer.drawText(label, width, height, x, baseline, style, time);
  }
  queued.length = 0;
}
// #endregion

let time = 0;
let angle = 0;
stage.run({
  simulate(dt) {
    time += dt;
    if (state.spin) angle += dt * 0.6;
  },
  render() {
    const width = canvas.clientWidth;
    const height = canvas.clientHeight;
    // #region frame
    const key = pressedKey !== '' ? pressedKey : padKey();
    pressedKey = '';
    exported<Update>(script, 'update')(
      state,
      root,
      width,
      height,
      pointer.x,
      pointer.y,
      pointer.down,
      key,
    );
    pass.reset();
    pass.setTransform(screenToNdc(width, height, affine));
    drawUiTree(pass.batch, root, pass.white, sink);
    // #endregion

    camera.fovYDeg = 45;
    camera.position[0] = 4.5;
    camera.position[1] = 3;
    camera.position[2] = 5.5;
    /* Framed off to the right, so the sculpture shows beside the menu rather than behind it. */
    camera.lookAt(-1.6, 1.4, 0);
    env.shadowStrength = state.shadows ? 0.7 : 0;
    const c = Math.cos(angle);
    const s = Math.sin(angle);
    model.set([c, 0, s, 0, 0, 1, 0, 0, -s, 0, c, 0, 0, 0, 0, 1]);
    renderer.beginShadowPass(lightMatrix, 'static');
    renderer.drawShadowCasters((caster) => caster.mesh(sculpture, model));
    renderer.endShadowPass();
    renderer.beginFrame([0.52, 0.6, 0.72]);
    renderer.bindMeshPass(camera, env);
    renderer.drawMesh(scene, IDENTITY);
    renderer.drawMesh(sculpture, model);
    renderer.drawPass(handle);
    drawLabels(width, height, time);
    if (!state.open) {
      const prompt = labels.get(root) ?? renderer.createText();
      labels.set(root, prompt);
      renderer.setText(prompt, 'ESCAPE OR START TO PAUSE');
      renderer.drawText(prompt, width, height, 24, height - 90, bodyStyle, time);
    }
    renderer.endFrame();
  },
});

menu.drs

examples/interface/menu.drs
// The pause menu: routing the pointer and the keys to the tree, answering what was activated, and
// painting each button from its own state. The page builds the tree and draws it; this decides what
// it does.
//
// Under `npm run examples`, change a rule and save: the menu answers by it from the next frame. Try
// arrow keys that wrap round, a button that says how many times it was pressed, or other colours.

import { activated, focus, focused, hovered, key, layout, point, pressed, setText, show, tint } from "drift/ui"

data Menu {
    open: bool = true
    inOptions: bool = false
    spin: bool = true
    shadows: bool = true
    // Whether the page's theme is the light one, which the button colours follow.
    light: bool = false
    restarts: u32 = 0
}

// The keyboard starts on Resume, so the menu can be used without touching the mouse.
fn begin(tree: UiTree) {
    ui.focus(tree, "resume")
}

// #region route
// Lay the tree out in the space it has, then give it the pointer and the key pressed this frame,
// and answer whatever either of them activated. Arrow keys move the focus as Tab does.
fn update(menu: mut Menu, tree: UiTree, width: f32, height: f32, px: f32, py: f32, down: bool, pressedKey: String) {
    ui.layout(tree, 0, 0, width, height)
    if pressedKey == "Escape" {
        if menu.inOptions {
            leaveOptions(menu, tree)
        } else {
            toggle(menu, tree)
        }
    }
    if !menu.open {
        return
    }
    ui.point(tree, px, py, down)
    respond(menu, tree)
    if pressedKey == "ArrowDown" {
        ui.key(tree, "Tab", false)
    } else if pressedKey == "ArrowUp" {
        ui.key(tree, "Tab", true)
    } else if pressedKey == "Enter" || pressedKey == " " || pressedKey == "Tab" {
        ui.key(tree, pressedKey, false)
    }
    respond(menu, tree)
    paint(menu, tree, "resume")
    paint(menu, tree, "options")
    paint(menu, tree, "restart")
    paint(menu, tree, "spin")
    paint(menu, tree, "shadows")
    paint(menu, tree, "back")
}
// #endregion

// #region respond
fn respond(menu: mut Menu, tree: UiTree) {
    if ui.activated(tree, "resume") {
        toggle(menu, tree)
    }
    if ui.activated(tree, "options") {
        menu.inOptions = true
        ui.show(tree, "menu", false)
        ui.show(tree, "optionsPanel", true)
        ui.focus(tree, "spin")
    }
    if ui.activated(tree, "back") {
        leaveOptions(menu, tree)
    }
    if ui.activated(tree, "restart") {
        menu.restarts += 1
    }
    if ui.activated(tree, "spin") {
        menu.spin = !menu.spin
        label(tree, "spin", menu.spin, "SPIN  ON", "SPIN  OFF")
    }
    if ui.activated(tree, "shadows") {
        menu.shadows = !menu.shadows
        label(tree, "shadows", menu.shadows, "SHADOWS  ON", "SHADOWS  OFF")
    }
}
// #endregion

fn leaveOptions(menu: mut Menu, tree: UiTree) {
    menu.inOptions = false
    ui.show(tree, "optionsPanel", false)
    ui.show(tree, "menu", true)
    ui.focus(tree, "options")
}

fn toggle(menu: mut Menu, tree: UiTree) {
    menu.open = !menu.open
    ui.show(tree, "menu", menu.open && !menu.inOptions)
    ui.show(tree, "optionsPanel", menu.open && menu.inOptions)
    if menu.open {
        ui.focus(tree, "resume")
    }
}

fn label(tree: UiTree, name: String, on: bool, yes: String, no: String) {
    if on {
        ui.setText(tree, name, yes)
    } else {
        ui.setText(tree, name, no)
    }
}

// #region paint
// A button paints itself from its own state: held down, under the pointer or holding the keyboard,
// or at rest, in the colours of the theme the page is showing.
fn paint(menu: Menu, tree: UiTree, name: String) {
    if ui.pressed(tree, name) {
        ui.tint(tree, name, 0.9, 0.5, 0.25, 1)
    } else if ui.hovered(tree, name) || ui.focused(tree, name) {
        if menu.light {
            ui.tint(tree, name, 0.95, 0.75, 0.45, 1)
        } else {
            ui.tint(tree, name, 0.85, 0.55, 0.3, 1)
        }
    } else if menu.light {
        ui.tint(tree, name, 0.86, 0.87, 0.9, 1)
    } else {
        ui.tint(tree, name, 0.17, 0.19, 0.24, 1)
    }
}
// #endregion