@driftengine/ui2d 3.61.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +9 -0
  3. package/README.md +228 -0
  4. package/dist/camera2d.d.ts +47 -0
  5. package/dist/camera2d.js +46 -0
  6. package/dist/index.d.ts +30 -0
  7. package/dist/index.js +21 -0
  8. package/dist/shaders/generated/sprite.wgsl.d.ts +54 -0
  9. package/dist/shaders/generated/sprite.wgsl.js +60 -0
  10. package/dist/shaders/sprite.d.ts +3 -0
  11. package/dist/shaders/sprite.js +98 -0
  12. package/dist/spriteBatch.d.ts +78 -0
  13. package/dist/spriteBatch.js +93 -0
  14. package/dist/spriteGl.d.ts +26 -0
  15. package/dist/spriteGl.js +207 -0
  16. package/dist/spriteGpu.d.ts +31 -0
  17. package/dist/spriteGpu.js +201 -0
  18. package/dist/spritePass.d.ts +54 -0
  19. package/dist/spritePass.js +142 -0
  20. package/dist/spriteSheet.d.ts +64 -0
  21. package/dist/spriteSheet.js +84 -0
  22. package/dist/spriteTexture.d.ts +36 -0
  23. package/dist/spriteTexture.js +5 -0
  24. package/dist/tilemap.d.ts +47 -0
  25. package/dist/tilemap.js +70 -0
  26. package/dist/uiDraw.d.ts +28 -0
  27. package/dist/uiDraw.js +41 -0
  28. package/dist/uiFocus.d.ts +36 -0
  29. package/dist/uiFocus.js +78 -0
  30. package/dist/uiInput.d.ts +51 -0
  31. package/dist/uiInput.js +85 -0
  32. package/dist/uiLayout.d.ts +23 -0
  33. package/dist/uiLayout.js +144 -0
  34. package/dist/uiNode.d.ts +105 -0
  35. package/dist/uiNode.js +79 -0
  36. package/package.json +57 -0
  37. package/src/camera2d.ts +88 -0
  38. package/src/index.ts +55 -0
  39. package/src/shaders/generated/sprite.wgsl.ts +63 -0
  40. package/src/shaders/sprite.ts +102 -0
  41. package/src/spriteBatch.ts +169 -0
  42. package/src/spriteGl.ts +270 -0
  43. package/src/spriteGpu.ts +271 -0
  44. package/src/spritePass.ts +231 -0
  45. package/src/spriteSheet.ts +147 -0
  46. package/src/spriteTexture.ts +42 -0
  47. package/src/tilemap.ts +114 -0
  48. package/src/uiDraw.ts +63 -0
  49. package/src/uiFocus.ts +80 -0
  50. package/src/uiInput.ts +115 -0
  51. package/src/uiLayout.ts +157 -0
  52. package/src/uiNode.ts +186 -0
@@ -0,0 +1,70 @@
1
+ /** A grid of sheet frames, drawn as sprites, culled to what the view can see. */
2
+ import { drawSprite } from './spriteBatch.js';
3
+ import { createSpriteFrame, sheetFrame } from './spriteSheet.js';
4
+ /** A cell holding nothing. Negative, so it can never be a frame index. */
5
+ export const TILE_EMPTY = -1;
6
+ export function createTilemap(columns, rows, tileWidth, tileHeight) {
7
+ const tiles = new Int32Array(columns * rows);
8
+ tiles.fill(TILE_EMPTY);
9
+ return { columns, rows, tiles, tileWidth, tileHeight, x: 0, y: 0 };
10
+ }
11
+ /** The frame in a cell, or `TILE_EMPTY` — including for a cell outside the map. */
12
+ export function tileAt(map, column, row) {
13
+ if (column < 0 || column >= map.columns || row < 0 || row >= map.rows)
14
+ return TILE_EMPTY;
15
+ return map.tiles[row * map.columns + column];
16
+ }
17
+ /** Put a frame in a cell. A cell outside the map is ignored rather than wrapping into another row. */
18
+ export function setTile(map, column, row, tile) {
19
+ if (column < 0 || column >= map.columns || row < 0 || row >= map.rows)
20
+ return;
21
+ map.tiles[row * map.columns + column] = tile;
22
+ }
23
+ /*
24
+ * One frame rectangle, refilled per tile. Module scope because `drawTilemap` is a per-frame hot
25
+ * path and this is the only object in it.
26
+ */
27
+ const FRAME = createSpriteFrame();
28
+ /**
29
+ * Draw the tiles the view can see. Returns how many that was.
30
+ *
31
+ * **The cost is the view rather than the map**, which is the whole reason this is a function and
32
+ * not a loop the caller writes: the visible span is arithmetic on four numbers, so a map of a
33
+ * million cells costs the few hundred on screen. A walk over every cell testing each against the
34
+ * view would draw exactly the same picture and be unusable at the size a tilemap exists for.
35
+ *
36
+ * **Which way the rows run is the affine's business, not this function's.** A cell's corner is
37
+ * `y + row * tileHeight`, so in screen space — where y counts down — row 0 is the top row, and in a
38
+ * 2D world — where y counts up — it is the bottom. That is the same rule `SpritePlacement` states
39
+ * about its own corner, and having one rule rather than a flag is what keeps the two agreeing.
40
+ */
41
+ export function drawTilemap(batch, map, sheet, view, tint) {
42
+ const firstColumn = Math.max(0, Math.floor((view.x - map.x) / map.tileWidth));
43
+ const firstRow = Math.max(0, Math.floor((view.y - map.y) / map.tileHeight));
44
+ /* Exclusive, and `ceil` rather than `floor + 1` so a view ending exactly on a boundary stops. */
45
+ const lastColumn = Math.min(map.columns, Math.ceil((view.x + view.w - map.x) / map.tileWidth));
46
+ const lastRow = Math.min(map.rows, Math.ceil((view.y + view.h - map.y) / map.tileHeight));
47
+ PLACEMENT.w = map.tileWidth;
48
+ PLACEMENT.h = map.tileHeight;
49
+ let drawn = 0;
50
+ for (let row = firstRow; row < lastRow; row += 1) {
51
+ const rowBase = row * map.columns;
52
+ const y = map.y + row * map.tileHeight;
53
+ for (let column = firstColumn; column < lastColumn; column += 1) {
54
+ const tile = map.tiles[rowBase + column];
55
+ if (tile === TILE_EMPTY)
56
+ continue;
57
+ sheetFrame(sheet, tile, FRAME);
58
+ PLACEMENT.x = map.x + column * map.tileWidth;
59
+ PLACEMENT.y = y;
60
+ drawSprite(batch, sheet.texture, PLACEMENT, FRAME, tint);
61
+ drawn += 1;
62
+ }
63
+ }
64
+ return drawn;
65
+ }
66
+ /*
67
+ * The placement handed to `drawSprite`, refilled per tile. Its size never changes within a call, so
68
+ * `drawTilemap` sets it once; the two coordinates move per cell.
69
+ */
70
+ const PLACEMENT = { x: 0, y: 0, w: 0, h: 0 };
@@ -0,0 +1,28 @@
1
+ /** Turn a laid-out interface tree into quads, parents first so children land on top. */
2
+ import type { SpriteBatch } from './spriteBatch.ts';
3
+ import type { UiNode } from './uiNode.ts';
4
+ /**
5
+ * Where a caller draws what a quad cannot be.
6
+ *
7
+ * Text is the whole of it today. This package draws textured quads and core already draws two kinds
8
+ * of text — `drawText` from the pixel font and `drawSdfText` from an atlas — so the honest seam is
9
+ * that the tree says *where* a label goes and the caller says *how* it is drawn. Wrapping either
10
+ * here would mean this package importing the renderer's verb surface to re-export it.
11
+ *
12
+ * Called with the node's resolved rect already filled in, in the order the node is drawn, so a
13
+ * label lands over its own background and under whatever is drawn after it.
14
+ */
15
+ export interface UiContentSink {
16
+ content(node: UiNode): void;
17
+ }
18
+ /**
19
+ * Draw a tree. Returns how many quads it came to.
20
+ *
21
+ * **Parents before children, siblings in order**, which is the same rule the batch already has:
22
+ * there is no depth here, so what is drawn later is what is on top. A node draws its background
23
+ * first and its image second, so an icon lands on its own plate.
24
+ *
25
+ * Allocates nothing. `layoutUiTree` must have run over this root, or every rect is whatever it was
26
+ * left at.
27
+ */
28
+ export declare function drawUiTree(batch: SpriteBatch, root: UiNode, white: number, sink: UiContentSink | null): number;
package/dist/uiDraw.js ADDED
@@ -0,0 +1,41 @@
1
+ /** Turn a laid-out interface tree into quads, parents first so children land on top. */
2
+ import { drawSprite } from './spriteBatch.js';
3
+ /* One placement, refilled per node. This is a per-frame path and it is the only object in it. */
4
+ const PLACEMENT = {
5
+ x: 0,
6
+ y: 0,
7
+ w: 0,
8
+ h: 0,
9
+ };
10
+ /**
11
+ * Draw a tree. Returns how many quads it came to.
12
+ *
13
+ * **Parents before children, siblings in order**, which is the same rule the batch already has:
14
+ * there is no depth here, so what is drawn later is what is on top. A node draws its background
15
+ * first and its image second, so an icon lands on its own plate.
16
+ *
17
+ * Allocates nothing. `layoutUiTree` must have run over this root, or every rect is whatever it was
18
+ * left at.
19
+ */
20
+ export function drawUiTree(batch, root, white, sink) {
21
+ if (root.hidden)
22
+ return 0;
23
+ let drawn = 0;
24
+ PLACEMENT.x = root.rect.x;
25
+ PLACEMENT.y = root.rect.y;
26
+ PLACEMENT.w = root.rect.w;
27
+ PLACEMENT.h = root.rect.h;
28
+ if (root.background !== null) {
29
+ drawSprite(batch, white, PLACEMENT, null, root.background);
30
+ drawn += 1;
31
+ }
32
+ if (root.texture >= 0) {
33
+ drawSprite(batch, root.texture, PLACEMENT, root.frame, root.tint);
34
+ drawn += 1;
35
+ }
36
+ if (sink !== null && root.text !== '')
37
+ sink.content(root);
38
+ for (const child of root.children)
39
+ drawn += drawUiTree(batch, child, white, sink);
40
+ return drawn;
41
+ }
@@ -0,0 +1,36 @@
1
+ /** Which node a point is on, and which node a keyboard should be talking to. */
2
+ import type { UiNode } from './uiNode.ts';
3
+ /**
4
+ * The interactive node under a point, or `null`.
5
+ *
6
+ * **Searched last-drawn first**, because the last thing drawn is the thing on top and a hit test
7
+ * that disagreed with the picture would be a button that responds where it is not.
8
+ *
9
+ * **A node that is not `interactive` does not block what is behind it.** A panel is a backdrop, and
10
+ * a backdrop that swallowed clicks would make every button under a plate dead — which is the
11
+ * failure a caller cannot see and would spend an afternoon on. A caller that wants a modal to
12
+ * swallow clicks marks the modal itself interactive, which says so.
13
+ *
14
+ * Allocates nothing.
15
+ */
16
+ export declare function uiHitTest(root: UiNode, x: number, y: number): UiNode | null;
17
+ /**
18
+ * Every focusable node, in tree order, appended to `out`.
19
+ *
20
+ * Tree order rather than a declared tab index: the order a tree is built in is the order it reads
21
+ * in, and a second ordering is a second thing to keep in step with the first. A caller that wants a
22
+ * different order moves the node.
23
+ *
24
+ * `out` is cleared first and reused, so a caller may hold one array. This is not a per-frame path —
25
+ * focus changes when a key is pressed.
26
+ */
27
+ export declare function uiFocusOrder(root: UiNode, out: UiNode[]): UiNode[];
28
+ /**
29
+ * The next focusable node after `current`, wrapping.
30
+ *
31
+ * `null` for `current` starts at the first, which is what a tree that has never been focused wants.
32
+ * `null` comes back only when nothing at all is focusable.
33
+ */
34
+ export declare function uiFocusNext(root: UiNode, current: UiNode | null): UiNode | null;
35
+ /** The focusable node before `current`, wrapping. */
36
+ export declare function uiFocusPrevious(root: UiNode, current: UiNode | null): UiNode | null;
@@ -0,0 +1,78 @@
1
+ /** Which node a point is on, and which node a keyboard should be talking to. */
2
+ import { uiRectHolds } from './uiNode.js';
3
+ /**
4
+ * The interactive node under a point, or `null`.
5
+ *
6
+ * **Searched last-drawn first**, because the last thing drawn is the thing on top and a hit test
7
+ * that disagreed with the picture would be a button that responds where it is not.
8
+ *
9
+ * **A node that is not `interactive` does not block what is behind it.** A panel is a backdrop, and
10
+ * a backdrop that swallowed clicks would make every button under a plate dead — which is the
11
+ * failure a caller cannot see and would spend an afternoon on. A caller that wants a modal to
12
+ * swallow clicks marks the modal itself interactive, which says so.
13
+ *
14
+ * Allocates nothing.
15
+ */
16
+ export function uiHitTest(root, x, y) {
17
+ if (root.hidden)
18
+ return null;
19
+ for (let i = root.children.length - 1; i >= 0; i -= 1) {
20
+ const hit = uiHitTest(root.children[i], x, y);
21
+ if (hit !== null)
22
+ return hit;
23
+ }
24
+ return root.interactive && uiRectHolds(root, x, y) ? root : null;
25
+ }
26
+ /**
27
+ * Every focusable node, in tree order, appended to `out`.
28
+ *
29
+ * Tree order rather than a declared tab index: the order a tree is built in is the order it reads
30
+ * in, and a second ordering is a second thing to keep in step with the first. A caller that wants a
31
+ * different order moves the node.
32
+ *
33
+ * `out` is cleared first and reused, so a caller may hold one array. This is not a per-frame path —
34
+ * focus changes when a key is pressed.
35
+ */
36
+ export function uiFocusOrder(root, out) {
37
+ out.length = 0;
38
+ gather(root, out);
39
+ return out;
40
+ }
41
+ function gather(node, out) {
42
+ if (node.hidden)
43
+ return;
44
+ if (node.focusable)
45
+ out.push(node);
46
+ for (const child of node.children)
47
+ gather(child, out);
48
+ }
49
+ /* One array, reused by the two step functions below. Neither is reentrant and neither needs to be. */
50
+ const ORDER = [];
51
+ /**
52
+ * The next focusable node after `current`, wrapping.
53
+ *
54
+ * `null` for `current` starts at the first, which is what a tree that has never been focused wants.
55
+ * `null` comes back only when nothing at all is focusable.
56
+ */
57
+ export function uiFocusNext(root, current) {
58
+ return step(root, current, 1);
59
+ }
60
+ /** The focusable node before `current`, wrapping. */
61
+ export function uiFocusPrevious(root, current) {
62
+ return step(root, current, -1);
63
+ }
64
+ function step(root, current, by) {
65
+ uiFocusOrder(root, ORDER);
66
+ if (ORDER.length === 0)
67
+ return null;
68
+ const at = current === null ? -1 : ORDER.indexOf(current);
69
+ /*
70
+ * A `current` that is not in the list — hidden since it was focused, or removed from the tree —
71
+ * lands here as -1 and starts from the beginning going forward, or the end going back. Better
72
+ * than refusing: a node that disappeared under the focus should not take the keyboard with it.
73
+ */
74
+ if (at < 0)
75
+ return (by > 0 ? ORDER[0] : ORDER[ORDER.length - 1]);
76
+ const next = (at + by + ORDER.length) % ORDER.length;
77
+ return ORDER[next];
78
+ }
@@ -0,0 +1,51 @@
1
+ /** Routing: a pointer and a keyboard turned into hover, press, focus and one activation. */
2
+ import type { UiNode } from './uiNode.ts';
3
+ /**
4
+ * What the router remembers between calls.
5
+ *
6
+ * Held by the caller rather than by the tree, because a tree can be shown in two places — a HUD and
7
+ * an editor preview of the same tree — and each has its own pointer. It is also what makes the
8
+ * router testable without a tree at all.
9
+ */
10
+ export interface UiInput {
11
+ hovered: UiNode | null;
12
+ /** The node the pointer went down on, until it comes up again. */
13
+ pressed: UiNode | null;
14
+ focused: UiNode | null;
15
+ /** Whether the pointer was down at the previous call, so an edge can be found. */
16
+ wasDown: boolean;
17
+ }
18
+ export declare function createUiInput(): UiInput;
19
+ /**
20
+ * Route a pointer. Returns the node this call activated, or `null`.
21
+ *
22
+ * **An activation is a press and a release on the same node**, which is what every pointer
23
+ * convention worth copying does and what lets somebody who has pressed the wrong button slide off
24
+ * it and let go. A release somewhere else clears the press and activates nothing.
25
+ *
26
+ * Press moves focus to the node pressed when that node is focusable, and **leaves focus alone
27
+ * otherwise** — clicking the background should not silently take the keyboard away from a field.
28
+ *
29
+ * Allocates nothing, and is meant to be called once per frame with whatever the pointer is doing.
30
+ */
31
+ export declare function routeUiPointer(input: UiInput, root: UiNode, x: number, y: number, down: boolean): UiNode | null;
32
+ /** Move focus, clearing whatever had it. `null` focuses nothing. */
33
+ export declare function setUiFocus(input: UiInput, node: UiNode | null): void;
34
+ /**
35
+ * Route a key. Returns the node it activated, or `null`.
36
+ *
37
+ * Three keys and no more: `Tab` and `Shift+Tab` move focus, `Enter` and `' '` activate what has it.
38
+ * Everything else is the caller's — a text field's own characters, a game's own bindings — and is
39
+ * reported as unhandled by returning `null` so the caller can tell.
40
+ *
41
+ * `key` is a DOM `KeyboardEvent.key`, because that is what a consumer already has and inventing a
42
+ * second spelling of `Tab` would be a table to keep in step.
43
+ */
44
+ export declare function routeUiKey(input: UiInput, root: UiNode, key: string, shift?: boolean): UiNode | null;
45
+ /**
46
+ * Forget everything, and clear the flags this router set on the tree.
47
+ *
48
+ * For a tree going away or a pointer leaving the window. Without it a node keeps the `hovered` it
49
+ * had when the cursor left, and draws lit for ever.
50
+ */
51
+ export declare function resetUiInput(input: UiInput): void;
@@ -0,0 +1,85 @@
1
+ /** Routing: a pointer and a keyboard turned into hover, press, focus and one activation. */
2
+ import { uiFocusNext, uiFocusPrevious, uiHitTest } from './uiFocus.js';
3
+ export function createUiInput() {
4
+ return { hovered: null, pressed: null, focused: null, wasDown: false };
5
+ }
6
+ /**
7
+ * Route a pointer. Returns the node this call activated, or `null`.
8
+ *
9
+ * **An activation is a press and a release on the same node**, which is what every pointer
10
+ * convention worth copying does and what lets somebody who has pressed the wrong button slide off
11
+ * it and let go. A release somewhere else clears the press and activates nothing.
12
+ *
13
+ * Press moves focus to the node pressed when that node is focusable, and **leaves focus alone
14
+ * otherwise** — clicking the background should not silently take the keyboard away from a field.
15
+ *
16
+ * Allocates nothing, and is meant to be called once per frame with whatever the pointer is doing.
17
+ */
18
+ export function routeUiPointer(input, root, x, y, down) {
19
+ const over = uiHitTest(root, x, y);
20
+ if (input.hovered !== over) {
21
+ if (input.hovered !== null)
22
+ input.hovered.hovered = false;
23
+ if (over !== null)
24
+ over.hovered = true;
25
+ input.hovered = over;
26
+ }
27
+ let activated = null;
28
+ if (down && !input.wasDown) {
29
+ input.pressed = over;
30
+ if (over !== null) {
31
+ over.pressed = true;
32
+ if (over.focusable)
33
+ setUiFocus(input, over);
34
+ }
35
+ }
36
+ else if (!down && input.wasDown) {
37
+ if (input.pressed !== null) {
38
+ input.pressed.pressed = false;
39
+ if (input.pressed === over)
40
+ activated = input.pressed;
41
+ }
42
+ input.pressed = null;
43
+ }
44
+ input.wasDown = down;
45
+ return activated;
46
+ }
47
+ /** Move focus, clearing whatever had it. `null` focuses nothing. */
48
+ export function setUiFocus(input, node) {
49
+ input.focused = node;
50
+ }
51
+ /**
52
+ * Route a key. Returns the node it activated, or `null`.
53
+ *
54
+ * Three keys and no more: `Tab` and `Shift+Tab` move focus, `Enter` and `' '` activate what has it.
55
+ * Everything else is the caller's — a text field's own characters, a game's own bindings — and is
56
+ * reported as unhandled by returning `null` so the caller can tell.
57
+ *
58
+ * `key` is a DOM `KeyboardEvent.key`, because that is what a consumer already has and inventing a
59
+ * second spelling of `Tab` would be a table to keep in step.
60
+ */
61
+ export function routeUiKey(input, root, key, shift = false) {
62
+ if (key === 'Tab') {
63
+ setUiFocus(input, shift ? uiFocusPrevious(root, input.focused) : uiFocusNext(root, input.focused));
64
+ return null;
65
+ }
66
+ if ((key === 'Enter' || key === ' ') && input.focused !== null)
67
+ return input.focused;
68
+ return null;
69
+ }
70
+ /**
71
+ * Forget everything, and clear the flags this router set on the tree.
72
+ *
73
+ * For a tree going away or a pointer leaving the window. Without it a node keeps the `hovered` it
74
+ * had when the cursor left, and draws lit for ever.
75
+ */
76
+ export function resetUiInput(input) {
77
+ if (input.hovered !== null)
78
+ input.hovered.hovered = false;
79
+ if (input.pressed !== null)
80
+ input.pressed.pressed = false;
81
+ input.hovered = null;
82
+ input.pressed = null;
83
+ input.focused = null;
84
+ input.wasDown = false;
85
+ }
@@ -0,0 +1,23 @@
1
+ /** Two passes over an interface tree: measure what wants a size, then place what got one. */
2
+ import type { UiNode } from './uiNode.ts';
3
+ /**
4
+ * Lay a tree out into `x`, `y`, `w`, `h`.
5
+ *
6
+ * **Two passes, bottom-up then top-down, and no third.** The measure pass answers what every node
7
+ * comes to on its own; the place pass hands out the space that exists and distributes what is left
8
+ * over. A `fit` size that depended on the space it was given would need a third pass and a rule for
9
+ * when to stop, which is where a layout engine stops being explicable.
10
+ *
11
+ * **Allocates nothing.** Every number it produces is written into a `UiRect` the node already owns,
12
+ * so a tree laid out every frame costs no garbage. That is the rule this whole module is shaped by,
13
+ * and it is why the node is an object with fields rather than a description that gets resolved into
14
+ * one.
15
+ *
16
+ * **The box is what is *available*, and the root resolves its own size against it** like any other
17
+ * node: `grow` fills it, a number is that number, and `fit` — the default — comes to whatever its
18
+ * contents do. So the same call lays out a full-screen HUD and a tooltip, and a caller does not
19
+ * have to measure a menu itself before it can place one. Nothing is clamped to the box: a `fit`
20
+ * root with more in it than fits reports the size it really is, because a silently truncated
21
+ * layout is worse than one that visibly overflows.
22
+ */
23
+ export declare function layoutUiTree(root: UiNode, x: number, y: number, w: number, h: number): void;
@@ -0,0 +1,144 @@
1
+ /** Two passes over an interface tree: measure what wants a size, then place what got one. */
2
+ /**
3
+ * Lay a tree out into `x`, `y`, `w`, `h`.
4
+ *
5
+ * **Two passes, bottom-up then top-down, and no third.** The measure pass answers what every node
6
+ * comes to on its own; the place pass hands out the space that exists and distributes what is left
7
+ * over. A `fit` size that depended on the space it was given would need a third pass and a rule for
8
+ * when to stop, which is where a layout engine stops being explicable.
9
+ *
10
+ * **Allocates nothing.** Every number it produces is written into a `UiRect` the node already owns,
11
+ * so a tree laid out every frame costs no garbage. That is the rule this whole module is shaped by,
12
+ * and it is why the node is an object with fields rather than a description that gets resolved into
13
+ * one.
14
+ *
15
+ * **The box is what is *available*, and the root resolves its own size against it** like any other
16
+ * node: `grow` fills it, a number is that number, and `fit` — the default — comes to whatever its
17
+ * contents do. So the same call lays out a full-screen HUD and a tooltip, and a caller does not
18
+ * have to measure a menu itself before it can place one. Nothing is clamped to the box: a `fit`
19
+ * root with more in it than fits reports the size it really is, because a silently truncated
20
+ * layout is worse than one that visibly overflows.
21
+ */
22
+ export function layoutUiTree(root, x, y, w, h) {
23
+ measure(root);
24
+ place(root, x, y, root.width === 'grow' ? w : root.measuredWidth, root.height === 'grow' ? h : root.measuredHeight);
25
+ }
26
+ /**
27
+ * What a node comes to on its own, written into `measuredWidth` and `measuredHeight`.
28
+ *
29
+ * A `grow` child measures as `fit` here, which is what lets a menu sized to its contents hold a row
30
+ * that fills it: at measure time there is nothing to take a share of, so the honest answer is the
31
+ * child's own natural size. The place pass is where `grow` means anything.
32
+ */
33
+ function measure(node) {
34
+ let along = 0;
35
+ let across = 0;
36
+ let counted = 0;
37
+ const row = node.direction === 'row';
38
+ for (const child of node.children) {
39
+ if (child.hidden)
40
+ continue;
41
+ measure(child);
42
+ if (child.absolute)
43
+ continue;
44
+ const childAlong = row ? child.measuredWidth : child.measuredHeight;
45
+ const childAcross = row ? child.measuredHeight : child.measuredWidth;
46
+ along += childAlong;
47
+ if (childAcross > across)
48
+ across = childAcross;
49
+ counted += 1;
50
+ }
51
+ if (counted > 1)
52
+ along += node.gap * (counted - 1);
53
+ const padX = node.paddingLeft + node.paddingRight;
54
+ const padY = node.paddingTop + node.paddingBottom;
55
+ const contentAlong = counted === 0 ? (row ? node.contentWidth : node.contentHeight) : along;
56
+ const contentAcross = counted === 0 ? (row ? node.contentHeight : node.contentWidth) : across;
57
+ const fitWidth = (row ? contentAlong : contentAcross) + padX;
58
+ const fitHeight = (row ? contentAcross : contentAlong) + padY;
59
+ node.measuredWidth = typeof node.width === 'number' ? node.width : fitWidth;
60
+ node.measuredHeight = typeof node.height === 'number' ? node.height : fitHeight;
61
+ }
62
+ /** Put a node in a box, then put its children in what is left of it. */
63
+ function place(node, x, y, w, h) {
64
+ node.rect.x = x;
65
+ node.rect.y = y;
66
+ node.rect.w = w;
67
+ node.rect.h = h;
68
+ const row = node.direction === 'row';
69
+ const contentX = x + node.paddingLeft;
70
+ const contentY = y + node.paddingTop;
71
+ const contentW = Math.max(0, w - node.paddingLeft - node.paddingRight);
72
+ const contentH = Math.max(0, h - node.paddingTop - node.paddingBottom);
73
+ const contentAlong = row ? contentW : contentH;
74
+ const contentAcross = row ? contentH : contentW;
75
+ /* What the flow children take before anything grows, and how many of them want to grow. */
76
+ let taken = 0;
77
+ let growers = 0;
78
+ let counted = 0;
79
+ for (const child of node.children) {
80
+ if (child.hidden || child.absolute)
81
+ continue;
82
+ const asked = row ? child.width : child.height;
83
+ if (asked === 'grow')
84
+ growers += 1;
85
+ else
86
+ taken += row ? child.measuredWidth : child.measuredHeight;
87
+ counted += 1;
88
+ }
89
+ const gaps = counted > 1 ? node.gap * (counted - 1) : 0;
90
+ const spare = Math.max(0, contentAlong - taken - gaps);
91
+ const share = growers > 0 ? spare / growers : 0;
92
+ /*
93
+ * `justify` spends what is left over, and there is nothing left over once something has grown —
94
+ * which is why a growing child and a `center` on the same node are not a contradiction: the
95
+ * growing child ate the leftover, so centring has nothing to move.
96
+ */
97
+ let cursor = 0;
98
+ let between = 0;
99
+ if (growers === 0) {
100
+ if (node.justify === 'center')
101
+ cursor = spare / 2;
102
+ else if (node.justify === 'end')
103
+ cursor = spare;
104
+ else if (node.justify === 'between' && counted > 1)
105
+ between = spare / (counted - 1);
106
+ }
107
+ for (const child of node.children) {
108
+ if (child.hidden)
109
+ continue;
110
+ if (child.absolute) {
111
+ placeAbsolute(child, contentX, contentY, contentW, contentH);
112
+ continue;
113
+ }
114
+ const askedAlong = row ? child.width : child.height;
115
+ const sizeAlong = askedAlong === 'grow' ? share : row ? child.measuredWidth : child.measuredHeight;
116
+ const askedAcross = row ? child.height : child.width;
117
+ const naturalAcross = row ? child.measuredHeight : child.measuredWidth;
118
+ const sizeAcross = node.align === 'stretch' || askedAcross === 'grow' ? contentAcross : naturalAcross;
119
+ let offAcross = 0;
120
+ if (node.align === 'center')
121
+ offAcross = (contentAcross - sizeAcross) / 2;
122
+ else if (node.align === 'end')
123
+ offAcross = contentAcross - sizeAcross;
124
+ if (row) {
125
+ place(child, contentX + cursor, contentY + offAcross, sizeAlong, sizeAcross);
126
+ }
127
+ else {
128
+ place(child, contentX + offAcross, contentY + cursor, sizeAcross, sizeAlong);
129
+ }
130
+ cursor += sizeAlong + node.gap + between;
131
+ }
132
+ }
133
+ /**
134
+ * A child taken out of the flow: at its own offset inside the parent's content box.
135
+ *
136
+ * Inside the content box rather than the border box, so an absolute badge in a padded panel sits
137
+ * where the panel's contents start — which is where a caller reading the padding expects it, and
138
+ * the only reading under which `x: 0` means the same thing for an absolute child as for a flow one.
139
+ */
140
+ function placeAbsolute(child, contentX, contentY, contentW, contentH) {
141
+ const w = child.width === 'grow' ? contentW : child.measuredWidth;
142
+ const h = child.height === 'grow' ? contentH : child.measuredHeight;
143
+ place(child, contentX + child.x, contentY + child.y, w, h);
144
+ }
@@ -0,0 +1,105 @@
1
+ /** A node of the retained interface tree: what it is, where it wants to be, and what it draws. */
2
+ import type { SpriteFrame } from './spriteSheet.ts';
3
+ /**
4
+ * How big a node asks to be along one axis.
5
+ *
6
+ * A number is that many units. `fit` is whatever its own contents come to. `grow` takes an equal
7
+ * share of what is left along the parent's main axis, and behaves as `fit` across it.
8
+ *
9
+ * **Three cases rather than a percentage or a flex factor**, and the omission is deliberate: a
10
+ * percentage of a parent that is itself `fit` is a cycle, and a weighted `grow` is a fourth case
11
+ * whose only caller so far would be a two-to-one split that two nested nodes already express.
12
+ */
13
+ export type UiSize = number | 'fit' | 'grow';
14
+ /** Where children sit across the parent's main axis. `stretch` gives them the whole cross extent. */
15
+ export type UiAlign = 'start' | 'center' | 'end' | 'stretch';
16
+ /** How the leftover along the main axis is spent when no child asked to `grow`. */
17
+ export type UiJustify = 'start' | 'center' | 'end' | 'between';
18
+ /** A resolved box, in whatever units the tree is laid out in. Written by `layoutUiTree`. */
19
+ export interface UiRect {
20
+ x: number;
21
+ y: number;
22
+ w: number;
23
+ h: number;
24
+ }
25
+ export interface UiNodeOptions {
26
+ readonly direction?: 'row' | 'column';
27
+ readonly width?: UiSize;
28
+ readonly height?: UiSize;
29
+ /** One number for all four sides. Set the four fields for anything else. */
30
+ readonly padding?: number;
31
+ readonly gap?: number;
32
+ readonly align?: UiAlign;
33
+ readonly justify?: UiJustify;
34
+ /** Taken out of the flow and placed at `x`, `y` inside the parent's content box. */
35
+ readonly absolute?: boolean;
36
+ readonly x?: number;
37
+ readonly y?: number;
38
+ /** What a `fit` node measures when it has no children: a label's text, an icon's size. */
39
+ readonly contentWidth?: number;
40
+ readonly contentHeight?: number;
41
+ readonly hidden?: boolean;
42
+ readonly background?: ArrayLike<number> | null;
43
+ /** The sprite slot this node draws from, or `-1` for none. */
44
+ readonly texture?: number;
45
+ readonly frame?: SpriteFrame | null;
46
+ readonly tint?: ArrayLike<number> | null;
47
+ readonly text?: string;
48
+ readonly interactive?: boolean;
49
+ readonly focusable?: boolean;
50
+ /** For a caller to find its own node again. Not read by anything here. */
51
+ readonly name?: string;
52
+ }
53
+ /**
54
+ * A node.
55
+ *
56
+ * **An object with mutable fields rather than a row of typed arrays**, and this is the one place in
57
+ * this package where that is the right answer. An interface tree is tens or hundreds of nodes built
58
+ * once and mutated, not thousands rebuilt per frame — the case struct-of-arrays exists for. What
59
+ * the hot path needs is that laying one out and drawing it allocates nothing, and both write into
60
+ * fields that already exist.
61
+ */
62
+ export interface UiNode {
63
+ readonly children: UiNode[];
64
+ parent: UiNode | null;
65
+ direction: 'row' | 'column';
66
+ width: UiSize;
67
+ height: UiSize;
68
+ paddingLeft: number;
69
+ paddingTop: number;
70
+ paddingRight: number;
71
+ paddingBottom: number;
72
+ gap: number;
73
+ align: UiAlign;
74
+ justify: UiJustify;
75
+ absolute: boolean;
76
+ x: number;
77
+ y: number;
78
+ contentWidth: number;
79
+ contentHeight: number;
80
+ hidden: boolean;
81
+ /** Where this node ended up. Meaningless until `layoutUiTree` has run over its root. */
82
+ readonly rect: UiRect;
83
+ /** What it measured to along each axis, before the parent distributed anything. */
84
+ measuredWidth: number;
85
+ measuredHeight: number;
86
+ background: Float32Array | null;
87
+ texture: number;
88
+ frame: SpriteFrame | null;
89
+ tint: Float32Array | null;
90
+ text: string;
91
+ interactive: boolean;
92
+ focusable: boolean;
93
+ hovered: boolean;
94
+ pressed: boolean;
95
+ name: string;
96
+ }
97
+ export declare function createUiNode(options?: UiNodeOptions): UiNode;
98
+ /** Put `child` at the end of `parent`'s children, detaching it from wherever it was. */
99
+ export declare function addUiChild(parent: UiNode, child: UiNode): UiNode;
100
+ /** Take `child` out of `parent`. A child that is not there is left alone. */
101
+ export declare function removeUiChild(parent: UiNode, child: UiNode): void;
102
+ /** The node named, depth-first from `root`, or `null`. For a caller finding its own tree again. */
103
+ export declare function uiNodeNamed(root: UiNode, name: string): UiNode | null;
104
+ /** Whether a point is inside a node's resolved box. */
105
+ export declare function uiRectHolds(node: UiNode, x: number, y: number): boolean;