@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,231 @@
1
+ /** The 2D layer as a pass a consumer registers: one batch of quads, drawn where the caller says. */
2
+
3
+ import type { PassContext, PassDefinition, PassDevice } from '@driftengine/core';
4
+
5
+ import { SPRITE_BINDINGS } from './shaders/generated/sprite.wgsl.ts';
6
+ import { createAffine2D } from './camera2d.ts';
7
+ import type { Affine2D } from './camera2d.ts';
8
+ import { createSpriteBatch, resetSpriteBatch } from './spriteBatch.ts';
9
+ import type { SpriteBatch } from './spriteBatch.ts';
10
+ import {
11
+ createWebgl2Sprites,
12
+ disposeWebgl2Sprites,
13
+ drawWebgl2Sprites,
14
+ setWebgl2SpriteTexture,
15
+ setWebgl2WhiteTexture,
16
+ uploadWebgl2Instances,
17
+ } from './spriteGl.ts';
18
+ import type { Webgl2Sprites } from './spriteGl.ts';
19
+ import {
20
+ createGpuSprites,
21
+ disposeGpuSprites,
22
+ drawGpuSprites,
23
+ setGpuSpriteTexture,
24
+ setGpuWhiteTexture,
25
+ uploadGpuInstances,
26
+ } from './spriteGpu.ts';
27
+ import type { GpuSprites } from './spriteGpu.ts';
28
+ import { DEFAULT_SPRITE_TEXTURE_OPTIONS } from './spriteTexture.ts';
29
+ import type { SpriteImage, SpriteTextureOptions } from './spriteTexture.ts';
30
+
31
+ const VERT = SPRITE_BINDINGS.SPRITE_VERT;
32
+ const FRAG = SPRITE_BINDINGS.SPRITE_FRAG;
33
+
34
+ /** Identity, so a pass that is drawn before it is transformed puts its sprites in clip space. */
35
+ const IDENTITY_CLIP = new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]);
36
+
37
+ /** Sprites a frame, if the caller does not say. Four thousand quads is 224 KB of instance data. */
38
+ export const DEFAULT_SPRITE_CAPACITY = 4096;
39
+
40
+ /**
41
+ * Texture slots, if the caller does not say.
42
+ *
43
+ * Eight is a sheet for the world, one for the interface, and room to spare. It is not a GPU limit
44
+ * — each slot is its own bind group on WebGPU and its own object on WebGL2 — it is the number
45
+ * above which a caller should be asking why its 2D layer has that many atlases.
46
+ */
47
+ export const DEFAULT_SPRITE_SLOTS = 8;
48
+
49
+ export interface SpritePassOptions {
50
+ readonly capacity?: number;
51
+ readonly slots?: number;
52
+ readonly label?: string;
53
+ }
54
+
55
+ export interface SpritePass extends PassDefinition {
56
+ /**
57
+ * The quads for this frame. Reset it, fill it with `drawSprite`, then call `drawPass`.
58
+ *
59
+ * Held by the pass rather than handed in per frame, because its size is the pass's GPU buffer
60
+ * and the two must agree.
61
+ */
62
+ readonly batch: SpriteBatch;
63
+ /**
64
+ * A slot holding one opaque white texel, filled by the pass and not settable.
65
+ *
66
+ * White is the identity of the multiply the shader does, so a quad on this slot draws exactly its
67
+ * tint — which is what a solid rectangle is, and what `fillPanel` in core does with a whole
68
+ * program of its own. A caller drawing a filled box needs no sheet for it.
69
+ *
70
+ * **A background on this slot and an image from a sheet are two runs**, because a slot change is
71
+ * a run and the order may not be regrouped. A caller with many filled boxes and many images
72
+ * interleaved should pack a white texel into its *own* sheet and tint a frame of that instead:
73
+ * then the whole tree is one run. This slot is the convenience, and the sheet is the fast path.
74
+ */
75
+ readonly white: number;
76
+ /** Clear the batch. Call once at the top of a frame. */
77
+ reset(): void;
78
+ /**
79
+ * Where the batch's coordinates are, from `screenToNdc` or `worldToNdc`.
80
+ *
81
+ * Copied, so the caller may reuse its array; and read at draw time, so this can be called before
82
+ * the pass has a device.
83
+ */
84
+ setTransform(affine: Affine2D): void;
85
+ /** Fill a texture slot. Safe before the pass is registered; applied when it gets a device. */
86
+ setTexture(slot: number, source: SpriteImage, options?: SpriteTextureOptions): void;
87
+ }
88
+
89
+ interface PendingTexture {
90
+ readonly slot: number;
91
+ readonly source: SpriteImage;
92
+ readonly options: SpriteTextureOptions;
93
+ }
94
+
95
+ export function createSpritePass(options: SpritePassOptions = {}): SpritePass {
96
+ const capacity = options.capacity ?? DEFAULT_SPRITE_CAPACITY;
97
+ const slots = options.slots ?? DEFAULT_SPRITE_SLOTS;
98
+ const label = options.label ?? 'ui2d.sprites';
99
+ /* One past the caller's slots, so `setTexture` cannot reach it and it cannot be lost. */
100
+ const white = slots;
101
+ const batch = createSpriteBatch(capacity);
102
+ const toNdc = createAffine2D();
103
+ /* Identity as an affine: ndc = (x, y). A caller that never sets one draws in clip space. */
104
+ toNdc[0] = 1;
105
+ toNdc[3] = 1;
106
+
107
+ let gl: Webgl2Sprites | null = null;
108
+ let context: WebGL2RenderingContext | null = null;
109
+ let gpu: GpuSprites | null = null;
110
+ let device: GPUDevice | null = null;
111
+ let clipCorrection: Float32Array = IDENTITY_CLIP;
112
+ const pending: PendingTexture[] = [];
113
+
114
+ const applyTexture = (entry: PendingTexture): void => {
115
+ if (gl !== null && context !== null) {
116
+ setWebgl2SpriteTexture(context, gl, entry.slot, entry.source, entry.options);
117
+ return;
118
+ }
119
+ if (gpu !== null && device !== null) {
120
+ setGpuSpriteTexture(device, gpu, entry.slot, entry.source, entry.options);
121
+ }
122
+ };
123
+
124
+ return {
125
+ label,
126
+ batch,
127
+ white,
128
+
129
+ reset(): void {
130
+ resetSpriteBatch(batch);
131
+ },
132
+
133
+ setTransform(affine: Affine2D): void {
134
+ toNdc.set(affine);
135
+ },
136
+
137
+ setTexture(slot: number, source: SpriteImage, textureOptions?: SpriteTextureOptions): void {
138
+ if (slot < 0 || slot >= slots) {
139
+ throw new RangeError(`${label}: slot ${slot} is outside the ${slots} this pass has`);
140
+ }
141
+ const entry: PendingTexture = {
142
+ slot,
143
+ source,
144
+ options: textureOptions ?? DEFAULT_SPRITE_TEXTURE_OPTIONS,
145
+ };
146
+ if (gl === null && gpu === null) {
147
+ pending.push(entry);
148
+ return;
149
+ }
150
+ applyTexture(entry);
151
+ },
152
+
153
+ init(passDevice: PassDevice): void {
154
+ /*
155
+ * The correction the *matrix* carries, because this stage builds its own clip position and
156
+ * never multiplies by a camera. Without it the generated WGSL's Y negation stands
157
+ * uncancelled and the whole 2D layer lands mirrored — `panel.ts` in core made the same
158
+ * mistake once and its comment is where the reason is written out.
159
+ */
160
+ clipCorrection = passDevice.clipCorrection;
161
+ if (passDevice.backend === 'webgl2') {
162
+ context = passDevice.gl;
163
+ gl = createWebgl2Sprites(passDevice.gl, capacity, slots + 1, label);
164
+ } else {
165
+ device = passDevice.device;
166
+ gpu = createGpuSprites(
167
+ passDevice.device,
168
+ passDevice.format,
169
+ passDevice.depthFormat,
170
+ passDevice.samples,
171
+ capacity,
172
+ slots + 1,
173
+ label,
174
+ );
175
+ }
176
+ if (gl !== null && context !== null) setWebgl2WhiteTexture(context, gl, white);
177
+ if (gpu !== null && device !== null) setGpuWhiteTexture(device, gpu, white);
178
+ for (const entry of pending) applyTexture(entry);
179
+ pending.length = 0;
180
+ },
181
+
182
+ draw(ctx: PassContext): void {
183
+ if (batch.count === 0) return;
184
+ if (ctx.backend === 'webgl2') {
185
+ if (gl === null) return;
186
+ uploadWebgl2Instances(ctx.gl, gl, batch);
187
+ drawWebgl2Sprites(
188
+ ctx.gl,
189
+ gl,
190
+ batch,
191
+ toNdc,
192
+ clipCorrection,
193
+ ctx.outputTransform,
194
+ ctx.outputExposure,
195
+ );
196
+ return;
197
+ }
198
+ if (gpu === null || device === null) return;
199
+ const f = gpu.vertexFloats;
200
+ f[VERT.fields.uToNdc0.offset / 4] = toNdc[0] as number;
201
+ f[VERT.fields.uToNdc0.offset / 4 + 1] = toNdc[1] as number;
202
+ f[VERT.fields.uToNdc0.offset / 4 + 2] = toNdc[2] as number;
203
+ f[VERT.fields.uToNdc0.offset / 4 + 3] = toNdc[3] as number;
204
+ f[VERT.fields.uToNdc1.offset / 4] = toNdc[4] as number;
205
+ f[VERT.fields.uToNdc1.offset / 4 + 1] = toNdc[5] as number;
206
+ f.set(clipCorrection, VERT.fields.uClipCorrection.offset / 4);
207
+ gpu.fragmentInts[FRAG.fields.uOutputTransform.offset / 4] = ctx.outputTransform;
208
+ gpu.fragmentFloats[FRAG.fields.uOutputExposure.offset / 4] = ctx.outputExposure;
209
+ /*
210
+ * Written from inside an open render pass, which is allowed and ordered: a queue write
211
+ * issued now lands before the command buffer this pass is being recorded into is submitted.
212
+ * What it does mean is **one `drawPass` a frame per pass** — a second would overwrite the
213
+ * first's instances before either had executed. A caller that wants the 2D layer in two
214
+ * places in the frame registers two passes, which is also how two splat captures work.
215
+ */
216
+ device.queue.writeBuffer(gpu.vertexUniforms, 0, gpu.vertexScratch);
217
+ device.queue.writeBuffer(gpu.fragmentUniforms, 0, gpu.fragmentScratch);
218
+ uploadGpuInstances(device, gpu, batch);
219
+ drawGpuSprites(ctx.pass, gpu, batch);
220
+ },
221
+
222
+ dispose(): void {
223
+ if (gl !== null && context !== null) disposeWebgl2Sprites(context, gl);
224
+ if (gpu !== null) disposeGpuSprites(gpu);
225
+ gl = null;
226
+ gpu = null;
227
+ context = null;
228
+ device = null;
229
+ },
230
+ };
231
+ }
@@ -0,0 +1,147 @@
1
+ /** A sheet: the rectangles of one texture that each hold a picture, addressed by index or name. */
2
+
3
+ import type { UvRect } from './spriteBatch.ts';
4
+
5
+ /** A `UvRect` a caller owns and refills, so reading a frame allocates nothing. */
6
+ export interface SpriteFrame {
7
+ u0: number;
8
+ v0: number;
9
+ u1: number;
10
+ v1: number;
11
+ }
12
+
13
+ export function createSpriteFrame(): SpriteFrame {
14
+ return { u0: 0, v0: 0, u1: 1, v1: 1 };
15
+ }
16
+
17
+ /** One entry of an atlas, in texels, as a packer emits it. */
18
+ export interface SheetEntry {
19
+ readonly name: string;
20
+ readonly x: number;
21
+ readonly y: number;
22
+ readonly w: number;
23
+ readonly h: number;
24
+ }
25
+
26
+ /**
27
+ * The frames of one texture.
28
+ *
29
+ * Two flat arrays and a name index rather than an array of objects: reading a frame in a draw loop
30
+ * is four `Float32Array` reads and no property lookups, and a tilemap does that per tile.
31
+ */
32
+ export interface SpriteSheet {
33
+ /** The slot on the pass this sheet's texture was set into. */
34
+ readonly texture: number;
35
+ /** The texture's own size, in texels. */
36
+ readonly width: number;
37
+ readonly height: number;
38
+ readonly count: number;
39
+ /** Four per frame: u0, v0, u1, v1. */
40
+ readonly uvs: Float32Array;
41
+ /** Two per frame: width and height in texels, for drawing a frame at its own scale. */
42
+ readonly sizes: Float32Array;
43
+ readonly names: ReadonlyMap<string, number>;
44
+ }
45
+
46
+ function build(
47
+ texture: number,
48
+ width: number,
49
+ height: number,
50
+ entries: readonly SheetEntry[],
51
+ ): SpriteSheet {
52
+ const uvs = new Float32Array(entries.length * 4);
53
+ const sizes = new Float32Array(entries.length * 2);
54
+ const names = new Map<string, number>();
55
+ entries.forEach((entry, index) => {
56
+ uvs[index * 4] = entry.x / width;
57
+ uvs[index * 4 + 1] = entry.y / height;
58
+ uvs[index * 4 + 2] = (entry.x + entry.w) / width;
59
+ uvs[index * 4 + 3] = (entry.y + entry.h) / height;
60
+ sizes[index * 2] = entry.w;
61
+ sizes[index * 2 + 1] = entry.h;
62
+ names.set(entry.name, index);
63
+ });
64
+ return { texture, width, height, count: entries.length, uvs, sizes, names };
65
+ }
66
+
67
+ /**
68
+ * A sheet cut into equal cells, row-major: left to right, then down.
69
+ *
70
+ * **A cell that would run off the edge is not emitted.** A sheet 70 texels wide cut into sixteens
71
+ * has four whole cells and six texels of margin, and a fifth cell reading into that margin is a
72
+ * frame with a stripe of nothing down one side — which reads as a rendering bug rather than as a
73
+ * badly measured atlas.
74
+ *
75
+ * Frames are named `"0"`, `"1"` and so on, so `frameOf` works on a grid too.
76
+ */
77
+ export function gridSheet(
78
+ texture: number,
79
+ width: number,
80
+ height: number,
81
+ cellWidth: number,
82
+ cellHeight: number,
83
+ ): SpriteSheet {
84
+ const columns = Math.floor(width / cellWidth);
85
+ const rows = Math.floor(height / cellHeight);
86
+ const entries: SheetEntry[] = [];
87
+ for (let row = 0; row < rows; row += 1) {
88
+ for (let column = 0; column < columns; column += 1) {
89
+ entries.push({
90
+ name: String(entries.length),
91
+ x: column * cellWidth,
92
+ y: row * cellHeight,
93
+ w: cellWidth,
94
+ h: cellHeight,
95
+ });
96
+ }
97
+ }
98
+ return build(texture, width, height, entries);
99
+ }
100
+
101
+ /** A sheet from an atlas description: whatever rectangles a packer put where. */
102
+ export function namedSheet(
103
+ texture: number,
104
+ width: number,
105
+ height: number,
106
+ entries: readonly SheetEntry[],
107
+ ): SpriteSheet {
108
+ return build(texture, width, height, entries);
109
+ }
110
+
111
+ /** The index a name has, or `-1`. */
112
+ export function frameOf(sheet: SpriteSheet, name: string): number {
113
+ return sheet.names.get(name) ?? -1;
114
+ }
115
+
116
+ /**
117
+ * Read a frame into a rectangle the caller owns. Allocates nothing.
118
+ *
119
+ * **An index this sheet does not have reads as the whole texture rather than throwing**, because
120
+ * this is called per sprite per frame and the rule is that nothing throws in the frame loop. What
121
+ * a caller then sees is the entire atlas drawn where one picture should be, which is unmistakable.
122
+ */
123
+ export function sheetFrame(sheet: SpriteSheet, index: number, out: SpriteFrame): UvRect {
124
+ if (index < 0 || index >= sheet.count) {
125
+ out.u0 = 0;
126
+ out.v0 = 0;
127
+ out.u1 = 1;
128
+ out.v1 = 1;
129
+ return out;
130
+ }
131
+ const at = index * 4;
132
+ out.u0 = sheet.uvs[at] as number;
133
+ out.v0 = sheet.uvs[at + 1] as number;
134
+ out.u1 = sheet.uvs[at + 2] as number;
135
+ out.v1 = sheet.uvs[at + 3] as number;
136
+ return out;
137
+ }
138
+
139
+ /** A frame's width in texels, or 0 for an index the sheet does not have. */
140
+ export function sheetFrameWidth(sheet: SpriteSheet, index: number): number {
141
+ return index < 0 || index >= sheet.count ? 0 : (sheet.sizes[index * 2] as number);
142
+ }
143
+
144
+ /** A frame's height in texels, or 0 for an index the sheet does not have. */
145
+ export function sheetFrameHeight(sheet: SpriteSheet, index: number): number {
146
+ return index < 0 || index >= sheet.count ? 0 : (sheet.sizes[index * 2 + 1] as number);
147
+ }
@@ -0,0 +1,42 @@
1
+ /** What a caller hands over as a sprite texture, and what it says about it. */
2
+
3
+ /**
4
+ * The three sources both backends take without a conversion.
5
+ *
6
+ * Narrower than `TexImageSource` on purpose. WebGPU's `copyExternalImageToTexture` refuses an
7
+ * `ImageData` outright, and an `HTMLImageElement` is accepted by only some implementations — so
8
+ * the union that is honestly portable is a bitmap and the two canvases. A caller with an `<img>`
9
+ * reaches this through one `createImageBitmap`, which is also where the decode's own orientation
10
+ * and premultiply options live, and those are decisions worth making in the open.
11
+ *
12
+ * All three carry their own `width` and `height`, which is the other reason: nothing here has to
13
+ * ask the caller how big the image it just handed over is.
14
+ */
15
+ export type SpriteImage = ImageBitmap | HTMLCanvasElement | OffscreenCanvas;
16
+
17
+ export interface SpriteTextureOptions {
18
+ /**
19
+ * `srgb` is right for anything painted to be looked at, which is every sprite sheet — the
20
+ * sampler decodes and the pass's own output transform re-encodes, so the blend happens in
21
+ * linear light where it belongs. `linear` is for a sheet carrying data rather than colour: a
22
+ * mask, a height, a flow field.
23
+ *
24
+ * The same name and the same two values `SurfaceTexture` already takes, so a consumer that has
25
+ * uploaded a texture to the mesh path does not meet a second vocabulary here.
26
+ */
27
+ readonly colorSpace?: 'linear' | 'srgb';
28
+ /**
29
+ * `nearest` by default, and the default is the argument.
30
+ *
31
+ * A sprite sheet is usually pixel art, where linear filtering is the thing that makes it look
32
+ * wrong; and a sheet that is *not* pixel art is normally drawn near its authored size, where
33
+ * the two filters differ by very little. So the default costs the smooth case almost nothing
34
+ * and saves the sharp case from a blur nobody asked for.
35
+ */
36
+ readonly filter?: 'nearest' | 'linear';
37
+ }
38
+
39
+ export const DEFAULT_SPRITE_TEXTURE_OPTIONS: SpriteTextureOptions = {
40
+ colorSpace: 'srgb',
41
+ filter: 'nearest',
42
+ };
package/src/tilemap.ts ADDED
@@ -0,0 +1,114 @@
1
+ /** A grid of sheet frames, drawn as sprites, culled to what the view can see. */
2
+
3
+ import { drawSprite } from './spriteBatch.ts';
4
+ import type { SpriteBatch } from './spriteBatch.ts';
5
+ import { createSpriteFrame, sheetFrame } from './spriteSheet.ts';
6
+ import type { SpriteSheet } from './spriteSheet.ts';
7
+
8
+ /** A cell holding nothing. Negative, so it can never be a frame index. */
9
+ export const TILE_EMPTY = -1;
10
+
11
+ export interface Tilemap {
12
+ readonly columns: number;
13
+ readonly rows: number;
14
+ /** One frame index per cell, row-major. `TILE_EMPTY` for a cell with nothing in it. */
15
+ readonly tiles: Int32Array;
16
+ /** How big a cell is, in whatever units the batch's affine maps from. */
17
+ readonly tileWidth: number;
18
+ readonly tileHeight: number;
19
+ /**
20
+ * Where cell (0, 0)'s corner sits.
21
+ *
22
+ * Mutable, because scrolling a map is moving it and the alternative is rebuilding one.
23
+ */
24
+ x: number;
25
+ y: number;
26
+ }
27
+
28
+ /** What the view can see, in the same units the map is placed in. */
29
+ export interface ViewRect {
30
+ readonly x: number;
31
+ readonly y: number;
32
+ readonly w: number;
33
+ readonly h: number;
34
+ }
35
+
36
+ export function createTilemap(
37
+ columns: number,
38
+ rows: number,
39
+ tileWidth: number,
40
+ tileHeight: number,
41
+ ): Tilemap {
42
+ const tiles = new Int32Array(columns * rows);
43
+ tiles.fill(TILE_EMPTY);
44
+ return { columns, rows, tiles, tileWidth, tileHeight, x: 0, y: 0 };
45
+ }
46
+
47
+ /** The frame in a cell, or `TILE_EMPTY` — including for a cell outside the map. */
48
+ export function tileAt(map: Tilemap, column: number, row: number): number {
49
+ if (column < 0 || column >= map.columns || row < 0 || row >= map.rows) return TILE_EMPTY;
50
+ return map.tiles[row * map.columns + column] as number;
51
+ }
52
+
53
+ /** Put a frame in a cell. A cell outside the map is ignored rather than wrapping into another row. */
54
+ export function setTile(map: Tilemap, column: number, row: number, tile: number): void {
55
+ if (column < 0 || column >= map.columns || row < 0 || row >= map.rows) return;
56
+ map.tiles[row * map.columns + column] = tile;
57
+ }
58
+
59
+ /*
60
+ * One frame rectangle, refilled per tile. Module scope because `drawTilemap` is a per-frame hot
61
+ * path and this is the only object in it.
62
+ */
63
+ const FRAME = createSpriteFrame();
64
+
65
+ /**
66
+ * Draw the tiles the view can see. Returns how many that was.
67
+ *
68
+ * **The cost is the view rather than the map**, which is the whole reason this is a function and
69
+ * not a loop the caller writes: the visible span is arithmetic on four numbers, so a map of a
70
+ * million cells costs the few hundred on screen. A walk over every cell testing each against the
71
+ * view would draw exactly the same picture and be unusable at the size a tilemap exists for.
72
+ *
73
+ * **Which way the rows run is the affine's business, not this function's.** A cell's corner is
74
+ * `y + row * tileHeight`, so in screen space — where y counts down — row 0 is the top row, and in a
75
+ * 2D world — where y counts up — it is the bottom. That is the same rule `SpritePlacement` states
76
+ * about its own corner, and having one rule rather than a flag is what keeps the two agreeing.
77
+ */
78
+ export function drawTilemap(
79
+ batch: SpriteBatch,
80
+ map: Tilemap,
81
+ sheet: SpriteSheet,
82
+ view: ViewRect,
83
+ tint: ArrayLike<number> | null,
84
+ ): number {
85
+ const firstColumn = Math.max(0, Math.floor((view.x - map.x) / map.tileWidth));
86
+ const firstRow = Math.max(0, Math.floor((view.y - map.y) / map.tileHeight));
87
+ /* Exclusive, and `ceil` rather than `floor + 1` so a view ending exactly on a boundary stops. */
88
+ const lastColumn = Math.min(map.columns, Math.ceil((view.x + view.w - map.x) / map.tileWidth));
89
+ const lastRow = Math.min(map.rows, Math.ceil((view.y + view.h - map.y) / map.tileHeight));
90
+
91
+ PLACEMENT.w = map.tileWidth;
92
+ PLACEMENT.h = map.tileHeight;
93
+ let drawn = 0;
94
+ for (let row = firstRow; row < lastRow; row += 1) {
95
+ const rowBase = row * map.columns;
96
+ const y = map.y + row * map.tileHeight;
97
+ for (let column = firstColumn; column < lastColumn; column += 1) {
98
+ const tile = map.tiles[rowBase + column] as number;
99
+ if (tile === TILE_EMPTY) continue;
100
+ sheetFrame(sheet, tile, FRAME);
101
+ PLACEMENT.x = map.x + column * map.tileWidth;
102
+ PLACEMENT.y = y;
103
+ drawSprite(batch, sheet.texture, PLACEMENT, FRAME, tint);
104
+ drawn += 1;
105
+ }
106
+ }
107
+ return drawn;
108
+ }
109
+
110
+ /*
111
+ * The placement handed to `drawSprite`, refilled per tile. Its size never changes within a call, so
112
+ * `drawTilemap` sets it once; the two coordinates move per cell.
113
+ */
114
+ const PLACEMENT = { x: 0, y: 0, w: 0, h: 0 };
package/src/uiDraw.ts ADDED
@@ -0,0 +1,63 @@
1
+ /** Turn a laid-out interface tree into quads, parents first so children land on top. */
2
+
3
+ import { drawSprite } from './spriteBatch.ts';
4
+ import type { SpriteBatch, SpritePlacement } from './spriteBatch.ts';
5
+ import type { UiNode } from './uiNode.ts';
6
+
7
+ /**
8
+ * Where a caller draws what a quad cannot be.
9
+ *
10
+ * Text is the whole of it today. This package draws textured quads and core already draws two kinds
11
+ * of text — `drawText` from the pixel font and `drawSdfText` from an atlas — so the honest seam is
12
+ * that the tree says *where* a label goes and the caller says *how* it is drawn. Wrapping either
13
+ * here would mean this package importing the renderer's verb surface to re-export it.
14
+ *
15
+ * Called with the node's resolved rect already filled in, in the order the node is drawn, so a
16
+ * label lands over its own background and under whatever is drawn after it.
17
+ */
18
+ export interface UiContentSink {
19
+ content(node: UiNode): void;
20
+ }
21
+
22
+ /* One placement, refilled per node. This is a per-frame path and it is the only object in it. */
23
+ const PLACEMENT: SpritePlacement & { x: number; y: number; w: number; h: number } = {
24
+ x: 0,
25
+ y: 0,
26
+ w: 0,
27
+ h: 0,
28
+ };
29
+
30
+ /**
31
+ * Draw a tree. Returns how many quads it came to.
32
+ *
33
+ * **Parents before children, siblings in order**, which is the same rule the batch already has:
34
+ * there is no depth here, so what is drawn later is what is on top. A node draws its background
35
+ * first and its image second, so an icon lands on its own plate.
36
+ *
37
+ * Allocates nothing. `layoutUiTree` must have run over this root, or every rect is whatever it was
38
+ * left at.
39
+ */
40
+ export function drawUiTree(
41
+ batch: SpriteBatch,
42
+ root: UiNode,
43
+ white: number,
44
+ sink: UiContentSink | null,
45
+ ): number {
46
+ if (root.hidden) return 0;
47
+ let drawn = 0;
48
+ PLACEMENT.x = root.rect.x;
49
+ PLACEMENT.y = root.rect.y;
50
+ PLACEMENT.w = root.rect.w;
51
+ PLACEMENT.h = root.rect.h;
52
+ if (root.background !== null) {
53
+ drawSprite(batch, white, PLACEMENT, null, root.background);
54
+ drawn += 1;
55
+ }
56
+ if (root.texture >= 0) {
57
+ drawSprite(batch, root.texture, PLACEMENT, root.frame, root.tint);
58
+ drawn += 1;
59
+ }
60
+ if (sink !== null && root.text !== '') sink.content(root);
61
+ for (const child of root.children) drawn += drawUiTree(batch, child, white, sink);
62
+ return drawn;
63
+ }
package/src/uiFocus.ts ADDED
@@ -0,0 +1,80 @@
1
+ /** Which node a point is on, and which node a keyboard should be talking to. */
2
+
3
+ import { uiRectHolds } from './uiNode.ts';
4
+ import type { UiNode } from './uiNode.ts';
5
+
6
+ /**
7
+ * The interactive node under a point, or `null`.
8
+ *
9
+ * **Searched last-drawn first**, because the last thing drawn is the thing on top and a hit test
10
+ * that disagreed with the picture would be a button that responds where it is not.
11
+ *
12
+ * **A node that is not `interactive` does not block what is behind it.** A panel is a backdrop, and
13
+ * a backdrop that swallowed clicks would make every button under a plate dead — which is the
14
+ * failure a caller cannot see and would spend an afternoon on. A caller that wants a modal to
15
+ * swallow clicks marks the modal itself interactive, which says so.
16
+ *
17
+ * Allocates nothing.
18
+ */
19
+ export function uiHitTest(root: UiNode, x: number, y: number): UiNode | null {
20
+ if (root.hidden) return null;
21
+ for (let i = root.children.length - 1; i >= 0; i -= 1) {
22
+ const hit = uiHitTest(root.children[i] as UiNode, x, y);
23
+ if (hit !== null) return hit;
24
+ }
25
+ return root.interactive && uiRectHolds(root, x, y) ? root : null;
26
+ }
27
+
28
+ /**
29
+ * Every focusable node, in tree order, appended to `out`.
30
+ *
31
+ * Tree order rather than a declared tab index: the order a tree is built in is the order it reads
32
+ * in, and a second ordering is a second thing to keep in step with the first. A caller that wants a
33
+ * different order moves the node.
34
+ *
35
+ * `out` is cleared first and reused, so a caller may hold one array. This is not a per-frame path —
36
+ * focus changes when a key is pressed.
37
+ */
38
+ export function uiFocusOrder(root: UiNode, out: UiNode[]): UiNode[] {
39
+ out.length = 0;
40
+ gather(root, out);
41
+ return out;
42
+ }
43
+
44
+ function gather(node: UiNode, out: UiNode[]): void {
45
+ if (node.hidden) return;
46
+ if (node.focusable) out.push(node);
47
+ for (const child of node.children) gather(child, out);
48
+ }
49
+
50
+ /* One array, reused by the two step functions below. Neither is reentrant and neither needs to be. */
51
+ const ORDER: UiNode[] = [];
52
+
53
+ /**
54
+ * The next focusable node after `current`, wrapping.
55
+ *
56
+ * `null` for `current` starts at the first, which is what a tree that has never been focused wants.
57
+ * `null` comes back only when nothing at all is focusable.
58
+ */
59
+ export function uiFocusNext(root: UiNode, current: UiNode | null): UiNode | null {
60
+ return step(root, current, 1);
61
+ }
62
+
63
+ /** The focusable node before `current`, wrapping. */
64
+ export function uiFocusPrevious(root: UiNode, current: UiNode | null): UiNode | null {
65
+ return step(root, current, -1);
66
+ }
67
+
68
+ function step(root: UiNode, current: UiNode | null, by: number): UiNode | null {
69
+ uiFocusOrder(root, ORDER);
70
+ if (ORDER.length === 0) return null;
71
+ const at = current === null ? -1 : ORDER.indexOf(current);
72
+ /*
73
+ * A `current` that is not in the list — hidden since it was focused, or removed from the tree —
74
+ * lands here as -1 and starts from the beginning going forward, or the end going back. Better
75
+ * than refusing: a node that disappeared under the focus should not take the keyboard with it.
76
+ */
77
+ if (at < 0) return (by > 0 ? ORDER[0] : ORDER[ORDER.length - 1]) as UiNode;
78
+ const next = (at + by + ORDER.length) % ORDER.length;
79
+ return ORDER[next] as UiNode;
80
+ }