@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
package/src/uiInput.ts ADDED
@@ -0,0 +1,115 @@
1
+ /** Routing: a pointer and a keyboard turned into hover, press, focus and one activation. */
2
+
3
+ import { uiFocusNext, uiFocusPrevious, uiHitTest } from './uiFocus.ts';
4
+ import type { UiNode } from './uiNode.ts';
5
+
6
+ /**
7
+ * What the router remembers between calls.
8
+ *
9
+ * Held by the caller rather than by the tree, because a tree can be shown in two places — a HUD and
10
+ * an editor preview of the same tree — and each has its own pointer. It is also what makes the
11
+ * router testable without a tree at all.
12
+ */
13
+ export interface UiInput {
14
+ hovered: UiNode | null;
15
+ /** The node the pointer went down on, until it comes up again. */
16
+ pressed: UiNode | null;
17
+ focused: UiNode | null;
18
+ /** Whether the pointer was down at the previous call, so an edge can be found. */
19
+ wasDown: boolean;
20
+ }
21
+
22
+ export function createUiInput(): UiInput {
23
+ return { hovered: null, pressed: null, focused: null, wasDown: false };
24
+ }
25
+
26
+ /**
27
+ * Route a pointer. Returns the node this call activated, or `null`.
28
+ *
29
+ * **An activation is a press and a release on the same node**, which is what every pointer
30
+ * convention worth copying does and what lets somebody who has pressed the wrong button slide off
31
+ * it and let go. A release somewhere else clears the press and activates nothing.
32
+ *
33
+ * Press moves focus to the node pressed when that node is focusable, and **leaves focus alone
34
+ * otherwise** — clicking the background should not silently take the keyboard away from a field.
35
+ *
36
+ * Allocates nothing, and is meant to be called once per frame with whatever the pointer is doing.
37
+ */
38
+ export function routeUiPointer(
39
+ input: UiInput,
40
+ root: UiNode,
41
+ x: number,
42
+ y: number,
43
+ down: boolean,
44
+ ): UiNode | null {
45
+ const over = uiHitTest(root, x, y);
46
+ if (input.hovered !== over) {
47
+ if (input.hovered !== null) input.hovered.hovered = false;
48
+ if (over !== null) over.hovered = true;
49
+ input.hovered = over;
50
+ }
51
+
52
+ let activated: UiNode | null = null;
53
+ if (down && !input.wasDown) {
54
+ input.pressed = over;
55
+ if (over !== null) {
56
+ over.pressed = true;
57
+ if (over.focusable) setUiFocus(input, over);
58
+ }
59
+ } else if (!down && input.wasDown) {
60
+ if (input.pressed !== null) {
61
+ input.pressed.pressed = false;
62
+ if (input.pressed === over) activated = input.pressed;
63
+ }
64
+ input.pressed = null;
65
+ }
66
+ input.wasDown = down;
67
+ return activated;
68
+ }
69
+
70
+ /** Move focus, clearing whatever had it. `null` focuses nothing. */
71
+ export function setUiFocus(input: UiInput, node: UiNode | null): void {
72
+ input.focused = node;
73
+ }
74
+
75
+ /**
76
+ * Route a key. Returns the node it activated, or `null`.
77
+ *
78
+ * Three keys and no more: `Tab` and `Shift+Tab` move focus, `Enter` and `' '` activate what has it.
79
+ * Everything else is the caller's — a text field's own characters, a game's own bindings — and is
80
+ * reported as unhandled by returning `null` so the caller can tell.
81
+ *
82
+ * `key` is a DOM `KeyboardEvent.key`, because that is what a consumer already has and inventing a
83
+ * second spelling of `Tab` would be a table to keep in step.
84
+ */
85
+ export function routeUiKey(
86
+ input: UiInput,
87
+ root: UiNode,
88
+ key: string,
89
+ shift = false,
90
+ ): UiNode | null {
91
+ if (key === 'Tab') {
92
+ setUiFocus(
93
+ input,
94
+ shift ? uiFocusPrevious(root, input.focused) : uiFocusNext(root, input.focused),
95
+ );
96
+ return null;
97
+ }
98
+ if ((key === 'Enter' || key === ' ') && input.focused !== null) return input.focused;
99
+ return null;
100
+ }
101
+
102
+ /**
103
+ * Forget everything, and clear the flags this router set on the tree.
104
+ *
105
+ * For a tree going away or a pointer leaving the window. Without it a node keeps the `hovered` it
106
+ * had when the cursor left, and draws lit for ever.
107
+ */
108
+ export function resetUiInput(input: UiInput): void {
109
+ if (input.hovered !== null) input.hovered.hovered = false;
110
+ if (input.pressed !== null) input.pressed.pressed = false;
111
+ input.hovered = null;
112
+ input.pressed = null;
113
+ input.focused = null;
114
+ input.wasDown = false;
115
+ }
@@ -0,0 +1,157 @@
1
+ /** Two passes over an interface tree: measure what wants a size, then place what got one. */
2
+
3
+ import type { UiNode } from './uiNode.ts';
4
+
5
+ /**
6
+ * Lay a tree out into `x`, `y`, `w`, `h`.
7
+ *
8
+ * **Two passes, bottom-up then top-down, and no third.** The measure pass answers what every node
9
+ * comes to on its own; the place pass hands out the space that exists and distributes what is left
10
+ * over. A `fit` size that depended on the space it was given would need a third pass and a rule for
11
+ * when to stop, which is where a layout engine stops being explicable.
12
+ *
13
+ * **Allocates nothing.** Every number it produces is written into a `UiRect` the node already owns,
14
+ * so a tree laid out every frame costs no garbage. That is the rule this whole module is shaped by,
15
+ * and it is why the node is an object with fields rather than a description that gets resolved into
16
+ * one.
17
+ *
18
+ * **The box is what is *available*, and the root resolves its own size against it** like any other
19
+ * node: `grow` fills it, a number is that number, and `fit` — the default — comes to whatever its
20
+ * contents do. So the same call lays out a full-screen HUD and a tooltip, and a caller does not
21
+ * have to measure a menu itself before it can place one. Nothing is clamped to the box: a `fit`
22
+ * root with more in it than fits reports the size it really is, because a silently truncated
23
+ * layout is worse than one that visibly overflows.
24
+ */
25
+ export function layoutUiTree(root: UiNode, x: number, y: number, w: number, h: number): void {
26
+ measure(root);
27
+ place(
28
+ root,
29
+ x,
30
+ y,
31
+ root.width === 'grow' ? w : root.measuredWidth,
32
+ root.height === 'grow' ? h : root.measuredHeight,
33
+ );
34
+ }
35
+
36
+ /**
37
+ * What a node comes to on its own, written into `measuredWidth` and `measuredHeight`.
38
+ *
39
+ * A `grow` child measures as `fit` here, which is what lets a menu sized to its contents hold a row
40
+ * that fills it: at measure time there is nothing to take a share of, so the honest answer is the
41
+ * child's own natural size. The place pass is where `grow` means anything.
42
+ */
43
+ function measure(node: UiNode): void {
44
+ let along = 0;
45
+ let across = 0;
46
+ let counted = 0;
47
+ const row = node.direction === 'row';
48
+ for (const child of node.children) {
49
+ if (child.hidden) continue;
50
+ measure(child);
51
+ if (child.absolute) continue;
52
+ const childAlong = row ? child.measuredWidth : child.measuredHeight;
53
+ const childAcross = row ? child.measuredHeight : child.measuredWidth;
54
+ along += childAlong;
55
+ if (childAcross > across) across = childAcross;
56
+ counted += 1;
57
+ }
58
+ if (counted > 1) along += node.gap * (counted - 1);
59
+
60
+ const padX = node.paddingLeft + node.paddingRight;
61
+ const padY = node.paddingTop + node.paddingBottom;
62
+ const contentAlong = counted === 0 ? (row ? node.contentWidth : node.contentHeight) : along;
63
+ const contentAcross = counted === 0 ? (row ? node.contentHeight : node.contentWidth) : across;
64
+
65
+ const fitWidth = (row ? contentAlong : contentAcross) + padX;
66
+ const fitHeight = (row ? contentAcross : contentAlong) + padY;
67
+ node.measuredWidth = typeof node.width === 'number' ? node.width : fitWidth;
68
+ node.measuredHeight = typeof node.height === 'number' ? node.height : fitHeight;
69
+ }
70
+
71
+ /** Put a node in a box, then put its children in what is left of it. */
72
+ function place(node: UiNode, x: number, y: number, w: number, h: number): void {
73
+ node.rect.x = x;
74
+ node.rect.y = y;
75
+ node.rect.w = w;
76
+ node.rect.h = h;
77
+
78
+ const row = node.direction === 'row';
79
+ const contentX = x + node.paddingLeft;
80
+ const contentY = y + node.paddingTop;
81
+ const contentW = Math.max(0, w - node.paddingLeft - node.paddingRight);
82
+ const contentH = Math.max(0, h - node.paddingTop - node.paddingBottom);
83
+ const contentAlong = row ? contentW : contentH;
84
+ const contentAcross = row ? contentH : contentW;
85
+
86
+ /* What the flow children take before anything grows, and how many of them want to grow. */
87
+ let taken = 0;
88
+ let growers = 0;
89
+ let counted = 0;
90
+ for (const child of node.children) {
91
+ if (child.hidden || child.absolute) continue;
92
+ const asked = row ? child.width : child.height;
93
+ if (asked === 'grow') growers += 1;
94
+ else taken += row ? child.measuredWidth : child.measuredHeight;
95
+ counted += 1;
96
+ }
97
+ const gaps = counted > 1 ? node.gap * (counted - 1) : 0;
98
+ const spare = Math.max(0, contentAlong - taken - gaps);
99
+ const share = growers > 0 ? spare / growers : 0;
100
+
101
+ /*
102
+ * `justify` spends what is left over, and there is nothing left over once something has grown —
103
+ * which is why a growing child and a `center` on the same node are not a contradiction: the
104
+ * growing child ate the leftover, so centring has nothing to move.
105
+ */
106
+ let cursor = 0;
107
+ let between = 0;
108
+ if (growers === 0) {
109
+ if (node.justify === 'center') cursor = spare / 2;
110
+ else if (node.justify === 'end') cursor = spare;
111
+ else if (node.justify === 'between' && counted > 1) between = spare / (counted - 1);
112
+ }
113
+
114
+ for (const child of node.children) {
115
+ if (child.hidden) continue;
116
+ if (child.absolute) {
117
+ placeAbsolute(child, contentX, contentY, contentW, contentH);
118
+ continue;
119
+ }
120
+ const askedAlong = row ? child.width : child.height;
121
+ const sizeAlong =
122
+ askedAlong === 'grow' ? share : row ? child.measuredWidth : child.measuredHeight;
123
+ const askedAcross = row ? child.height : child.width;
124
+ const naturalAcross = row ? child.measuredHeight : child.measuredWidth;
125
+ const sizeAcross =
126
+ node.align === 'stretch' || askedAcross === 'grow' ? contentAcross : naturalAcross;
127
+ let offAcross = 0;
128
+ if (node.align === 'center') offAcross = (contentAcross - sizeAcross) / 2;
129
+ else if (node.align === 'end') offAcross = contentAcross - sizeAcross;
130
+
131
+ if (row) {
132
+ place(child, contentX + cursor, contentY + offAcross, sizeAlong, sizeAcross);
133
+ } else {
134
+ place(child, contentX + offAcross, contentY + cursor, sizeAcross, sizeAlong);
135
+ }
136
+ cursor += sizeAlong + node.gap + between;
137
+ }
138
+ }
139
+
140
+ /**
141
+ * A child taken out of the flow: at its own offset inside the parent's content box.
142
+ *
143
+ * Inside the content box rather than the border box, so an absolute badge in a padded panel sits
144
+ * where the panel's contents start — which is where a caller reading the padding expects it, and
145
+ * the only reading under which `x: 0` means the same thing for an absolute child as for a flow one.
146
+ */
147
+ function placeAbsolute(
148
+ child: UiNode,
149
+ contentX: number,
150
+ contentY: number,
151
+ contentW: number,
152
+ contentH: number,
153
+ ): void {
154
+ const w = child.width === 'grow' ? contentW : child.measuredWidth;
155
+ const h = child.height === 'grow' ? contentH : child.measuredHeight;
156
+ place(child, contentX + child.x, contentY + child.y, w, h);
157
+ }
package/src/uiNode.ts ADDED
@@ -0,0 +1,186 @@
1
+ /** A node of the retained interface tree: what it is, where it wants to be, and what it draws. */
2
+
3
+ import type { SpriteFrame } from './spriteSheet.ts';
4
+
5
+ /**
6
+ * How big a node asks to be along one axis.
7
+ *
8
+ * A number is that many units. `fit` is whatever its own contents come to. `grow` takes an equal
9
+ * share of what is left along the parent's main axis, and behaves as `fit` across it.
10
+ *
11
+ * **Three cases rather than a percentage or a flex factor**, and the omission is deliberate: a
12
+ * percentage of a parent that is itself `fit` is a cycle, and a weighted `grow` is a fourth case
13
+ * whose only caller so far would be a two-to-one split that two nested nodes already express.
14
+ */
15
+ export type UiSize = number | 'fit' | 'grow';
16
+
17
+ /** Where children sit across the parent's main axis. `stretch` gives them the whole cross extent. */
18
+ export type UiAlign = 'start' | 'center' | 'end' | 'stretch';
19
+
20
+ /** How the leftover along the main axis is spent when no child asked to `grow`. */
21
+ export type UiJustify = 'start' | 'center' | 'end' | 'between';
22
+
23
+ /** A resolved box, in whatever units the tree is laid out in. Written by `layoutUiTree`. */
24
+ export interface UiRect {
25
+ x: number;
26
+ y: number;
27
+ w: number;
28
+ h: number;
29
+ }
30
+
31
+ export interface UiNodeOptions {
32
+ readonly direction?: 'row' | 'column';
33
+ readonly width?: UiSize;
34
+ readonly height?: UiSize;
35
+ /** One number for all four sides. Set the four fields for anything else. */
36
+ readonly padding?: number;
37
+ readonly gap?: number;
38
+ readonly align?: UiAlign;
39
+ readonly justify?: UiJustify;
40
+ /** Taken out of the flow and placed at `x`, `y` inside the parent's content box. */
41
+ readonly absolute?: boolean;
42
+ readonly x?: number;
43
+ readonly y?: number;
44
+ /** What a `fit` node measures when it has no children: a label's text, an icon's size. */
45
+ readonly contentWidth?: number;
46
+ readonly contentHeight?: number;
47
+ readonly hidden?: boolean;
48
+ readonly background?: ArrayLike<number> | null;
49
+ /** The sprite slot this node draws from, or `-1` for none. */
50
+ readonly texture?: number;
51
+ readonly frame?: SpriteFrame | null;
52
+ readonly tint?: ArrayLike<number> | null;
53
+ readonly text?: string;
54
+ readonly interactive?: boolean;
55
+ readonly focusable?: boolean;
56
+ /** For a caller to find its own node again. Not read by anything here. */
57
+ readonly name?: string;
58
+ }
59
+
60
+ /**
61
+ * A node.
62
+ *
63
+ * **An object with mutable fields rather than a row of typed arrays**, and this is the one place in
64
+ * this package where that is the right answer. An interface tree is tens or hundreds of nodes built
65
+ * once and mutated, not thousands rebuilt per frame — the case struct-of-arrays exists for. What
66
+ * the hot path needs is that laying one out and drawing it allocates nothing, and both write into
67
+ * fields that already exist.
68
+ */
69
+ export interface UiNode {
70
+ readonly children: UiNode[];
71
+ parent: UiNode | null;
72
+
73
+ direction: 'row' | 'column';
74
+ width: UiSize;
75
+ height: UiSize;
76
+ paddingLeft: number;
77
+ paddingTop: number;
78
+ paddingRight: number;
79
+ paddingBottom: number;
80
+ gap: number;
81
+ align: UiAlign;
82
+ justify: UiJustify;
83
+ absolute: boolean;
84
+ x: number;
85
+ y: number;
86
+ contentWidth: number;
87
+ contentHeight: number;
88
+ hidden: boolean;
89
+
90
+ /** Where this node ended up. Meaningless until `layoutUiTree` has run over its root. */
91
+ readonly rect: UiRect;
92
+ /** What it measured to along each axis, before the parent distributed anything. */
93
+ measuredWidth: number;
94
+ measuredHeight: number;
95
+
96
+ background: Float32Array | null;
97
+ texture: number;
98
+ frame: SpriteFrame | null;
99
+ tint: Float32Array | null;
100
+ text: string;
101
+
102
+ interactive: boolean;
103
+ focusable: boolean;
104
+ hovered: boolean;
105
+ pressed: boolean;
106
+ name: string;
107
+ }
108
+
109
+ function rgba(source: ArrayLike<number> | null | undefined): Float32Array | null {
110
+ if (source === null || source === undefined) return null;
111
+ const out = new Float32Array(4);
112
+ out[0] = source[0] as number;
113
+ out[1] = source[1] as number;
114
+ out[2] = source[2] as number;
115
+ out[3] = (source[3] ?? 1) as number;
116
+ return out;
117
+ }
118
+
119
+ export function createUiNode(options: UiNodeOptions = {}): UiNode {
120
+ const padding = options.padding ?? 0;
121
+ return {
122
+ children: [],
123
+ parent: null,
124
+ direction: options.direction ?? 'column',
125
+ width: options.width ?? 'fit',
126
+ height: options.height ?? 'fit',
127
+ paddingLeft: padding,
128
+ paddingTop: padding,
129
+ paddingRight: padding,
130
+ paddingBottom: padding,
131
+ gap: options.gap ?? 0,
132
+ align: options.align ?? 'start',
133
+ justify: options.justify ?? 'start',
134
+ absolute: options.absolute ?? false,
135
+ x: options.x ?? 0,
136
+ y: options.y ?? 0,
137
+ contentWidth: options.contentWidth ?? 0,
138
+ contentHeight: options.contentHeight ?? 0,
139
+ hidden: options.hidden ?? false,
140
+ rect: { x: 0, y: 0, w: 0, h: 0 },
141
+ measuredWidth: 0,
142
+ measuredHeight: 0,
143
+ background: rgba(options.background),
144
+ texture: options.texture ?? -1,
145
+ frame: options.frame ?? null,
146
+ tint: rgba(options.tint),
147
+ text: options.text ?? '',
148
+ interactive: options.interactive ?? false,
149
+ focusable: options.focusable ?? false,
150
+ hovered: false,
151
+ pressed: false,
152
+ name: options.name ?? '',
153
+ };
154
+ }
155
+
156
+ /** Put `child` at the end of `parent`'s children, detaching it from wherever it was. */
157
+ export function addUiChild(parent: UiNode, child: UiNode): UiNode {
158
+ if (child.parent !== null) removeUiChild(child.parent, child);
159
+ child.parent = parent;
160
+ parent.children.push(child);
161
+ return child;
162
+ }
163
+
164
+ /** Take `child` out of `parent`. A child that is not there is left alone. */
165
+ export function removeUiChild(parent: UiNode, child: UiNode): void {
166
+ const at = parent.children.indexOf(child);
167
+ if (at < 0) return;
168
+ parent.children.splice(at, 1);
169
+ child.parent = null;
170
+ }
171
+
172
+ /** The node named, depth-first from `root`, or `null`. For a caller finding its own tree again. */
173
+ export function uiNodeNamed(root: UiNode, name: string): UiNode | null {
174
+ if (root.name === name) return root;
175
+ for (const child of root.children) {
176
+ const hit = uiNodeNamed(child, name);
177
+ if (hit !== null) return hit;
178
+ }
179
+ return null;
180
+ }
181
+
182
+ /** Whether a point is inside a node's resolved box. */
183
+ export function uiRectHolds(node: UiNode, x: number, y: number): boolean {
184
+ const r = node.rect;
185
+ return x >= r.x && x < r.x + r.w && y >= r.y && y < r.y + r.h;
186
+ }