@moku-labs/game 0.0.2 → 0.1.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 (35) hide show
  1. package/README.md +107 -31
  2. package/dist/assets.d.mts +58 -6
  3. package/dist/assets.mjs +1630 -234
  4. package/dist/commands-C0BljsM5.d.mts +99 -0
  5. package/dist/control.d.mts +3 -92
  6. package/dist/control.mjs +3 -757
  7. package/dist/define-CjP_4H0G.d.mts +51 -0
  8. package/dist/{registry-DlpRCibU.mjs → dev-CcwFrwok.mjs} +30 -1
  9. package/dist/index.d.mts +362 -303
  10. package/dist/index.mjs +12717 -6654
  11. package/dist/inspect.d.mts +10 -8
  12. package/dist/inspect.mjs +2 -518
  13. package/dist/jsx-dev-runtime.d.mts +1 -1
  14. package/dist/jsx-runtime.d.mts +1 -1
  15. package/dist/{rolldown-runtime-D7D4PA-g.mjs → rolldown-runtime-8H4AJuhK.mjs} +1 -0
  16. package/dist/run-tlcn1w7y.mjs +944 -0
  17. package/dist/{runtime-DiOTkDZz.d.mts → runtime-Bn5TXNxP.d.mts} +1 -1
  18. package/dist/sources-BooL2MsX.mjs +545 -0
  19. package/dist/testing.d.mts +328 -3
  20. package/dist/testing.mjs +2126 -2
  21. package/dist/types-BZ1OLf24.d.mts +5999 -0
  22. package/dist/{types-DWILGrPn.d.mts → types-C47fKNcn.d.mts} +93 -20
  23. package/dist/types-CqkrEY06.d.mts +1734 -0
  24. package/dist/types-CxZy7Pi4.d.mts +1210 -0
  25. package/dist/{define-sFoO3y6X.d.mts → types-DmhTHRVW.d.mts} +180 -51
  26. package/dist/{types-yg_ywtT-.d.mts → types-X9jXjka3.d.mts} +230 -31
  27. package/dist/{visual-BDUHSRvf.mjs → visual-NPrDkvUk.mjs} +19 -13
  28. package/package.json +22 -2
  29. package/dist/headless-CvamCUcR.mjs +0 -186
  30. package/dist/headless-KaSWcd0s.d.mts +0 -181
  31. package/dist/types-BxkNNYul.d.mts +0 -2840
  32. package/dist/types-CTPS9GBu.d.mts +0 -742
  33. package/dist/types-DD-QrG_z.d.mts +0 -588
  34. package/dist/types-DYLnSgMI.d.mts +0 -570
  35. package/dist/types-JNc_UQBo.d.mts +0 -2896
package/dist/assets.mjs CHANGED
@@ -1,7 +1,323 @@
1
- import path from "node:path";
1
+ import { createRequire } from "node:module";
2
2
  import { createBrandConsole } from "@moku-labs/common/cli";
3
- import { mkdir, open, readFile, readdir, stat, writeFile } from "node:fs/promises";
3
+ import { mkdir, open, readFile, readdir, rm, rmdir, stat, writeFile } from "node:fs/promises";
4
+ import path from "node:path";
5
+ import { createHash } from "node:crypto";
6
+ import { MaxRectsBin, MaxRectsPacker } from "maxrects-packer";
4
7
  import { pathToFileURL } from "node:url";
8
+ /** Bytes per pixel of the decoded images: RGBA, 8 bits each. */
9
+ const CHANNELS = 4;
10
+ /**
11
+ * Copies the rows of one decoded texture into the pixels of a page. A copy, not a blend: the
12
+ * page is transparent everywhere else, so the frame holds exactly the source's pixels.
13
+ *
14
+ * @param page - The RGBA pixels of the page.
15
+ * @param pageWidth - Width of the page.
16
+ * @param part - The texture and its place.
17
+ */
18
+ function blit(page, pageWidth, part) {
19
+ const { pixels, x, y } = part;
20
+ const rowBytes = pixels.width * CHANNELS;
21
+ for (let row = 0; row < pixels.height; row += 1) {
22
+ const from = row * rowBytes;
23
+ page.set(pixels.data.subarray(from, from + rowBytes), ((y + row) * pageWidth + x) * CHANNELS);
24
+ }
25
+ }
26
+ /**
27
+ * Builds the encoder over the `sharp` factory.
28
+ *
29
+ * @param sharp - The default export of the `sharp` module.
30
+ * @returns The encoder.
31
+ */
32
+ function encoderOf(sharp) {
33
+ const webpOptions = {
34
+ quality: 80,
35
+ alphaQuality: 80
36
+ };
37
+ return {
38
+ version: sharp.versions.sharp ?? "unknown",
39
+ decode: async (bytes) => {
40
+ const { data, info } = await sharp(bytes).toColourspace("srgb").ensureAlpha().raw().toBuffer({ resolveWithObject: true });
41
+ return {
42
+ width: info.width,
43
+ height: info.height,
44
+ data: new Uint8Array(data)
45
+ };
46
+ },
47
+ page: async (width, height, parts) => {
48
+ const pixels = new Uint8Array(width * height * CHANNELS);
49
+ for (const part of parts) blit(pixels, width, part);
50
+ const encoded = await sharp(pixels, { raw: {
51
+ width,
52
+ height,
53
+ channels: CHANNELS
54
+ } }).webp(webpOptions).toBuffer();
55
+ return new Uint8Array(encoded);
56
+ },
57
+ webp: async (bytes) => new Uint8Array(await sharp(bytes).webp(webpOptions).toBuffer())
58
+ };
59
+ }
60
+ /**
61
+ * Loads `sharp` and builds the encoder. `sharp` is an optional peer dependency of the engine,
62
+ * so a game installs it for its pack command only.
63
+ *
64
+ * @returns The encoder.
65
+ * @throws {Error} When `sharp` is not installed, with the command that installs it.
66
+ * @example
67
+ * ```ts
68
+ * const encoder = await loadEncoder();
69
+ * encoder.version; // the installed sharp version, such as "0.34.5"
70
+ * ```
71
+ */
72
+ async function loadEncoder() {
73
+ try {
74
+ return encoderOf((await import("sharp")).default);
75
+ } catch (error) {
76
+ throw new Error("[game] assets: \"--pack\" needs sharp.\n Run \"bun add -d sharp\".", { cause: error });
77
+ }
78
+ }
79
+ //#endregion
80
+ //#region src/plugins/assets/scan/pack/layout.ts
81
+ /**
82
+ * @file assets packer, build time — where each texture of an atlas group lies on its pages. Pure
83
+ * over sizes: `maxrects-packer` places rectangles, nothing here reads a pixel. This is the one
84
+ * file that imports `maxrects-packer` (lint rule L11); its version decides the layout, so the
85
+ * package is pinned and the cache key carries it.
86
+ */
87
+ /** The largest page, in pixels per side. A page is only as large as its content. */
88
+ const PAGE_SIZE = 2048;
89
+ /**
90
+ * Compares two members the way the packer likes them: the longest side first, a larger area
91
+ * next, the key last, so the same members give the same layout in any input order.
92
+ *
93
+ * @param left - One member.
94
+ * @param right - Another member.
95
+ * @returns The sort order.
96
+ */
97
+ function packingOrder(left, right) {
98
+ return Math.max(right.width, right.height) - Math.max(left.width, left.height) || right.width * right.height - left.width * left.height || left.key.localeCompare(right.key);
99
+ }
100
+ /**
101
+ * Tells whether a texture fits a page at all, inside the border.
102
+ *
103
+ * @param member - The texture.
104
+ * @returns True when both sides fit.
105
+ * @example
106
+ * ```ts
107
+ * fitsPage({ key: "ui.fx-rays", width: 504, height: 512 }); // true
108
+ * ```
109
+ */
110
+ function fitsPage(member) {
111
+ const room = 2044;
112
+ return member.width <= room && member.height <= room;
113
+ }
114
+ /**
115
+ * Reads the frames of one bin of the packer, sorted by key.
116
+ *
117
+ * @param rects - The rectangles the packer placed on the bin.
118
+ * @returns The frames.
119
+ */
120
+ function framesOf(rects) {
121
+ return rects.map((rect) => ({
122
+ key: rect.data.key,
123
+ x: rect.x,
124
+ y: rect.y,
125
+ width: rect.width,
126
+ height: rect.height
127
+ })).toSorted((left, right) => left.key.localeCompare(right.key));
128
+ }
129
+ /**
130
+ * Names every texture that fits no page, one sentence each, sorted by key.
131
+ *
132
+ * @param members - The textures of a group.
133
+ * @returns One problem per texture that is too large.
134
+ * @example
135
+ * ```ts
136
+ * unfitProblems([{ key: "ui.sky", width: 4096, height: 64 }]); // ['the texture "ui.sky" (4096×64) fits no 2048×2048 page with a 2 px border.']
137
+ * ```
138
+ */
139
+ function unfitProblems(members) {
140
+ return members.filter((member) => !fitsPage(member)).toSorted((left, right) => left.key.localeCompare(right.key)).map((member) => `the texture "${member.key}" (${member.width}×${member.height}) fits no ${PAGE_SIZE}×${PAGE_SIZE} page with a 2 px border.`);
141
+ }
142
+ /**
143
+ * Lays the members of one atlas group out on pages of at most 2048 × 2048 pixels: 2 px of
144
+ * padding between frames and a 2 px border, never trimmed and never rotated, so every frame keeps
145
+ * the size of its source and a nine-slice keeps its borders. A texture that fits no page is a
146
+ * problem naming its key, never a silent drop; an `fx` group that needs more than one page is a
147
+ * problem naming the bundle and the count, because a particle emitter binds one page.
148
+ *
149
+ * @param bundle - Name of the bundle, for the messages.
150
+ * @param group - The atlas group.
151
+ * @param members - Its textures, with their sizes.
152
+ * @returns The pages and the problems.
153
+ * @example
154
+ * ```ts
155
+ * layoutGroup("ui", "main", [{ key: "ui.dot", width: 8, height: 8 }]).pages; // [{ width: 12, height: 12, frames: [{ key: "ui.dot", x: 2, y: 2, width: 8, height: 8 }] }]
156
+ * ```
157
+ */
158
+ function layoutGroup(bundle, group, members) {
159
+ const problems = unfitProblems(members);
160
+ const packer = new MaxRectsPacker(PAGE_SIZE, PAGE_SIZE, 2, {
161
+ smart: true,
162
+ pot: false,
163
+ square: false,
164
+ allowRotation: false,
165
+ border: 2
166
+ });
167
+ for (const member of members.filter((entry) => fitsPage(entry)).toSorted(packingOrder)) packer.add(member.width, member.height, { key: member.key });
168
+ const pages = packer.bins.filter((bin) => bin instanceof MaxRectsBin && bin.rects.length > 0).map((bin) => ({
169
+ width: bin.width,
170
+ height: bin.height,
171
+ frames: framesOf(bin.rects)
172
+ }));
173
+ if (group === "fx" && pages.length > 1) problems.push(`the fx textures of bundle "${bundle}" need ${pages.length} pages; a particle emitter binds one. Make them smaller, or move some to another bundle.`);
174
+ return {
175
+ pages,
176
+ problems
177
+ };
178
+ }
179
+ //#endregion
180
+ //#region src/plugins/assets/scan/pack/cache.ts
181
+ /**
182
+ * @file assets packer, build time — the content-hash cache. One entry per atlas group and per
183
+ * encoded loose texture, keyed by everything that decides its bytes: the format of the entry, the
184
+ * versions of `sharp` and `maxrects-packer`, the packing constants and the members (key, SHA-256
185
+ * of the source, nine). A hit replays the frames and the encoded bytes: no decode, no encode.
186
+ * Entries are content-addressed, so the cache is never pruned.
187
+ */
188
+ /** The cache folder under the working directory. */
189
+ const CACHE_FOLDER = path.join("node_modules", ".cache", "moku-game-pack");
190
+ /** Changes when the shape of an entry changes, so an old entry is never read as a new one. */
191
+ const FORMAT = "pack-1";
192
+ /**
193
+ * Hashes bytes into the hex SHA-256 the cache keys and members use.
194
+ *
195
+ * @param bytes - Anything.
196
+ * @returns 64 lowercase hex characters.
197
+ * @example
198
+ * ```ts
199
+ * sha256(new TextEncoder().encode("abc")).slice(0, 10); // "ba7816bf8f"
200
+ * ```
201
+ */
202
+ function sha256(bytes) {
203
+ return createHash("sha256").update(bytes).digest("hex");
204
+ }
205
+ /**
206
+ * Reads the version of `maxrects-packer` that is installed: its layout decides the frames, so a
207
+ * new version is a new cache.
208
+ *
209
+ * @returns The version from its `package.json`.
210
+ */
211
+ function packerVersion() {
212
+ const manifest = createRequire(import.meta.url)("maxrects-packer/package.json");
213
+ return typeof manifest.version === "string" ? manifest.version : "unknown";
214
+ }
215
+ /**
216
+ * Computes the cache key of one entry from everything that decides its bytes.
217
+ *
218
+ * @param input - The kind, the encoder version and the members.
219
+ * @returns The key: 64 hex characters, also the file name of the entry.
220
+ * @example
221
+ * ```ts
222
+ * cacheKey({ kind: "loose", sharp: "0.34.5", members: [] }).length; // 64, the same key on every run
223
+ * ```
224
+ */
225
+ function cacheKey(input) {
226
+ const members = input.members.toSorted((left, right) => left.key.localeCompare(right.key)).map((member) => [
227
+ member.key,
228
+ member.source,
229
+ member.nine === void 0 ? void 0 : [
230
+ member.nine.left,
231
+ member.nine.top,
232
+ member.nine.right,
233
+ member.nine.bottom
234
+ ]
235
+ ]);
236
+ return sha256(JSON.stringify({
237
+ format: FORMAT,
238
+ kind: input.kind,
239
+ sharp: input.sharp,
240
+ packer: packerVersion(),
241
+ page: PAGE_SIZE,
242
+ border: 2,
243
+ padding: 2,
244
+ quality: 80,
245
+ alphaQuality: 80,
246
+ members
247
+ }));
248
+ }
249
+ /**
250
+ * Tells whether a value read from an entry file is one frame.
251
+ *
252
+ * @param value - Anything the JSON carried.
253
+ * @returns True for a frame.
254
+ */
255
+ function isFrame(value) {
256
+ if (typeof value !== "object" || value === null) return false;
257
+ const frame = value;
258
+ return typeof frame.key === "string" && [
259
+ "x",
260
+ "y",
261
+ "width",
262
+ "height"
263
+ ].every((field) => typeof frame[field] === "number");
264
+ }
265
+ /**
266
+ * Reads the JSON file of an entry. A file that is missing, broken or of another shape is a miss.
267
+ *
268
+ * @param file - Path of the JSON file.
269
+ * @returns The images without their bytes, or `undefined`.
270
+ */
271
+ async function readEntryFile(file) {
272
+ try {
273
+ const parsed = JSON.parse(await readFile(file, "utf8"));
274
+ if (!Array.isArray(parsed.images)) return void 0;
275
+ const images = parsed.images;
276
+ return images.every((image) => typeof image.width === "number" && typeof image.height === "number" && Array.isArray(image.frames) && image.frames.every((frame) => isFrame(frame))) ? images : void 0;
277
+ } catch {
278
+ return;
279
+ }
280
+ }
281
+ /**
282
+ * Reads one entry of the cache.
283
+ *
284
+ * @param folder - The cache folder, or `false` when the cache is off.
285
+ * @param key - The cache key of the entry.
286
+ * @returns The images with their bytes, or `undefined` on a miss.
287
+ */
288
+ async function readCache(folder, key) {
289
+ if (folder === false) return void 0;
290
+ const images = await readEntryFile(path.join(folder, `${key}.json`));
291
+ if (images === void 0) return void 0;
292
+ try {
293
+ return await Promise.all(images.map(async (image, index) => ({
294
+ ...image,
295
+ bytes: new Uint8Array(await readFile(path.join(folder, `${key}-${index}.webp`)))
296
+ })));
297
+ } catch {
298
+ return;
299
+ }
300
+ }
301
+ /**
302
+ * Writes one entry of the cache: the bytes of every image first, the JSON file last, so a run
303
+ * that stops half way leaves a miss, never a broken hit.
304
+ *
305
+ * @param folder - The cache folder, or `false` when the cache is off.
306
+ * @param key - The cache key of the entry.
307
+ * @param images - The encoded images and their frames.
308
+ */
309
+ async function writeCache(folder, key, images) {
310
+ if (folder === false) return;
311
+ await mkdir(folder, { recursive: true });
312
+ for (const [index, image] of images.entries()) await writeFile(path.join(folder, `${key}-${index}.webp`), image.bytes);
313
+ const entry = { images: images.map(({ width, height, frames }) => ({
314
+ width,
315
+ height,
316
+ frames
317
+ })) };
318
+ await writeFile(path.join(folder, `${key}.json`), JSON.stringify(entry));
319
+ }
320
+ //#endregion
5
321
  //#region src/plugins/assets/scan/emit.ts
6
322
  const HEADER$1 = "// generated by @moku-labs/game/assets, do not edit";
7
323
  /** How the generated table ends, so the keys stay literal types for the game. */
@@ -17,42 +333,63 @@ function byName$2(left, right) {
17
333
  return left.localeCompare(right);
18
334
  }
19
335
  /**
20
- * Writes one file entry with a fixed field order. A texture says nothing about its kind, which
21
- * is what keeps a manifest written before fonts and audio readable. The reserved `atlas` field
22
- * is dropped: this version never packs an atlas.
336
+ * Writes one file entry in the field order of the manifest: `key`, `path`, `kind`, `width`,
337
+ * `height`, `mb`, `pages`, `nine`, `atlas`. A field the file does not carry is left out: a
338
+ * texture says nothing about its kind, which keeps a manifest written before fonts and audio
339
+ * readable, and a texture packed in an atlas has no `path`. A font and a sound carry no size.
23
340
  *
24
341
  * @param file - The file.
25
342
  * @returns The entry as JSON data.
26
343
  */
27
344
  function plainFile(file) {
28
345
  const kind = file.kind ?? "texture";
346
+ const path = file.path === void 0 ? {} : { path: file.path };
29
347
  if (kind !== "texture") return {
30
348
  key: file.key,
31
- path: file.path,
349
+ ...path,
32
350
  kind,
33
351
  mb: file.mb,
34
352
  ...file.pages === void 0 ? {} : { pages: file.pages.map((page) => ({ ...page })) }
35
353
  };
36
354
  return {
37
355
  key: file.key,
38
- path: file.path,
356
+ ...path,
39
357
  width: file.width,
40
358
  height: file.height,
41
359
  mb: file.mb,
42
- ...file.nine === void 0 ? {} : { nine: file.nine }
360
+ ...file.nine === void 0 ? {} : { nine: file.nine },
361
+ ...file.atlas === void 0 ? {} : { atlas: { ...file.atlas } }
362
+ };
363
+ }
364
+ /**
365
+ * Writes one atlas page with a fixed field order.
366
+ *
367
+ * @param page - The page.
368
+ * @returns The page as JSON data.
369
+ */
370
+ function plainPage(page) {
371
+ return {
372
+ id: page.id,
373
+ path: page.path,
374
+ width: page.width,
375
+ height: page.height,
376
+ mb: page.mb
43
377
  };
44
378
  }
45
379
  /**
46
- * Writes one bundle with a fixed field order and its files sorted by key.
380
+ * Writes one bundle with a fixed field order, its pages sorted by id when it has any and its
381
+ * files sorted by key.
47
382
  *
48
383
  * @param bundle - The bundle.
49
384
  * @returns The bundle as JSON data.
50
385
  */
51
386
  function plainBundle(bundle) {
387
+ const pages = bundle.pages?.toSorted((left, right) => byName$2(left.id, right.id)).map((page) => plainPage(page));
52
388
  return {
53
389
  feature: bundle.feature,
54
390
  tier: bundle.tier,
55
391
  mb: bundle.mb,
392
+ ...pages === void 0 ? {} : { pages },
56
393
  files: bundle.files.toSorted((left, right) => byName$2(left.key, right.key)).map((file) => plainFile(file))
57
394
  };
58
395
  }
@@ -124,271 +461,1238 @@ function nineTable(entries) {
124
461
  return `export const nineSlice = {\n${entries.toSorted((left, right) => byName$2(left.key, right.key)).map(({ key, nine }) => ` "${key}": { left: ${nine.left}, top: ${nine.top}, right: ${nine.right}, bottom: ${nine.bottom} }`).join(",\n")}\n}${FROZEN}`;
125
462
  }
126
463
  /**
127
- * Writes `manifest.json`: two-space JSON with a trailing newline, bundles sorted by name and
128
- * files sorted by key.
464
+ * Writes `manifest.json`: two-space JSON with a trailing newline, bundles sorted by name, pages
465
+ * by id and files by key. The version is written as given: `1` from the scanner, `2` from the
466
+ * packer.
467
+ *
468
+ * @param manifest - What the scan produced.
469
+ * @returns The text of the file.
470
+ * @example
471
+ * ```ts
472
+ * emitManifest({ version: 1, bundles: {} }); // '{\n "version": 1,\n "bundles": {}\n}\n'
473
+ * ```
474
+ */
475
+ function emitManifest(manifest) {
476
+ const bundles = {};
477
+ for (const [name, bundle] of sortedBundles(manifest)) bundles[name] = plainBundle(bundle);
478
+ const plain = {
479
+ version: manifest.version,
480
+ bundles
481
+ };
482
+ return `${JSON.stringify(plain, void 0, 2)}\n`;
483
+ }
484
+ /**
485
+ * Writes `generated/assets.ts`: `AssetKey` over every kind, the narrower `FontKey` and `AudioKey`
486
+ * next to it, `BundleKey`, and the nine-slice table. A game commits this file, so `defineGame` can
487
+ * type `Sprite`, a text style, a sound, `defineBundles` and the `load` effect from it.
488
+ *
489
+ * @param manifest - What the scan produced.
490
+ * @returns The text of the module.
491
+ * @example
492
+ * ```ts
493
+ * emitKeys({ version: 1, bundles: {} });
494
+ * // the header, four unions that are all "never" and an empty nine-slice table
495
+ * ```
496
+ */
497
+ function emitKeys(manifest) {
498
+ const files = allFiles(manifest);
499
+ const nine = [];
500
+ for (const file of files) if (file.nine !== void 0) nine.push({
501
+ key: file.key,
502
+ nine: file.nine
503
+ });
504
+ return `${[
505
+ HEADER$1,
506
+ union("AssetKey", files.map((file) => file.key)),
507
+ union("FontKey", keysOfKind(files, "font")),
508
+ union("AudioKey", keysOfKind(files, "audio")),
509
+ union("BundleKey", bundleNames(manifest)),
510
+ nineTable(nine)
511
+ ].join("\n\n")}\n`;
512
+ }
513
+ const PNG_SIGNATURE = [
514
+ 137,
515
+ 80,
516
+ 78,
517
+ 71,
518
+ 13,
519
+ 10,
520
+ 26,
521
+ 10
522
+ ];
523
+ const VP8_SYNC = [
524
+ 157,
525
+ 1,
526
+ 42
527
+ ];
528
+ const VP8L_SIGNATURE = 47;
529
+ /** Both WebP frame headers carry the width and the height in 14 bits each. */
530
+ const SIZE_MASK = 16383;
531
+ const BYTES_PER_PIXEL = 4;
532
+ const BYTES_PER_MB = 1048576;
533
+ /**
534
+ * Wraps a scanner problem in the message shape of the framework.
535
+ *
536
+ * @param message - One sentence naming the file.
537
+ * @returns The error to throw.
538
+ */
539
+ function problem$5(message) {
540
+ return /* @__PURE__ */ new Error(`[game] assets: ${message}`);
541
+ }
542
+ /**
543
+ * Reads the bytes as a data view.
544
+ *
545
+ * @param head - The first bytes of the file.
546
+ * @returns A view over exactly those bytes.
547
+ */
548
+ function viewOf(head) {
549
+ return new DataView(head.buffer, head.byteOffset, head.byteLength);
550
+ }
551
+ /**
552
+ * Tells whether a run of bytes stands at an offset.
553
+ *
554
+ * @param head - The first bytes of the file.
555
+ * @param offset - Where to look.
556
+ * @param bytes - What to find.
557
+ * @returns True when every byte matches.
558
+ */
559
+ function hasBytes(head, offset, bytes) {
560
+ return bytes.every((byte, index) => head[offset + index] === byte);
561
+ }
562
+ /**
563
+ * Reads a run of bytes as ASCII, which is what the chunk names of these formats are.
564
+ *
565
+ * @param head - The first bytes of the file.
566
+ * @param offset - Where to start.
567
+ * @param length - How many bytes to read.
568
+ * @returns The text.
569
+ */
570
+ function ascii(head, offset, length) {
571
+ return String.fromCodePoint(...head.subarray(offset, offset + length));
572
+ }
573
+ /**
574
+ * Reads a 24-bit little-endian number, the shape the `VP8X` canvas size uses.
575
+ *
576
+ * @param view - A view over the header bytes.
577
+ * @param offset - Where the number starts.
578
+ * @returns The number.
579
+ */
580
+ function uint24Le(view, offset) {
581
+ return view.getUint16(offset, true) | view.getUint8(offset + 2) << 16;
582
+ }
583
+ /**
584
+ * Reads the size out of the IHDR chunk of a PNG.
585
+ *
586
+ * @param head - The first bytes of the file.
587
+ * @returns The size.
588
+ */
589
+ function readPng(head) {
590
+ const view = viewOf(head);
591
+ return {
592
+ width: view.getUint32(16),
593
+ height: view.getUint32(20)
594
+ };
595
+ }
596
+ /**
597
+ * Reads the size out of the frame header of a WebP, whichever of the three chunks it carries.
598
+ *
599
+ * @param head - The first bytes of the file.
600
+ * @param file - Path of the file, for the messages.
601
+ * @returns The size.
602
+ * @throws {Error} When the chunk is unknown or its frame header is damaged.
603
+ */
604
+ function readWebp(head, file) {
605
+ const view = viewOf(head);
606
+ const chunk = ascii(head, 12, 4);
607
+ if (chunk === "VP8 ") {
608
+ if (!hasBytes(head, 23, VP8_SYNC)) throw problem$5(`the VP8 frame of "${file}" is damaged.`);
609
+ return {
610
+ width: view.getUint16(26, true) & SIZE_MASK,
611
+ height: view.getUint16(28, true) & SIZE_MASK
612
+ };
613
+ }
614
+ if (chunk === "VP8L") {
615
+ if (head[20] !== VP8L_SIGNATURE) throw problem$5(`the VP8L frame of "${file}" is damaged.`);
616
+ const bits = view.getUint32(21, true);
617
+ return {
618
+ width: (bits & SIZE_MASK) + 1,
619
+ height: (bits >>> 14 & SIZE_MASK) + 1
620
+ };
621
+ }
622
+ if (chunk === "VP8X") return {
623
+ width: uint24Le(view, 24) + 1,
624
+ height: uint24Le(view, 27) + 1
625
+ };
626
+ throw problem$5(`"${file}" carries the WebP chunk "${chunk}", which this version cannot read.`);
627
+ }
628
+ /**
629
+ * Reads the pixel size of an image out of its header bytes.
630
+ *
631
+ * @param head - The first `HEADER_BYTES` bytes of the file.
632
+ * @param file - Path of the file, for the messages.
633
+ * @returns The size.
634
+ * @throws {Error} When the bytes are neither a PNG nor a WebP the scanner reads.
635
+ * @example
636
+ * ```ts
637
+ * readImageSize(await readHead("panel.png"), "features/ui/assets/panel.png");
638
+ * // { width: 256, height: 128 }
639
+ * ```
640
+ */
641
+ function readImageSize(head, file) {
642
+ if (head.length >= 30) {
643
+ if (hasBytes(head, 0, PNG_SIGNATURE) && ascii(head, 12, 4) === "IHDR") return readPng(head);
644
+ if (ascii(head, 0, 4) === "RIFF" && ascii(head, 8, 4) === "WEBP") return readWebp(head, file);
645
+ }
646
+ throw problem$5(`"${file}" is not a PNG or a WebP file.`);
647
+ }
648
+ /**
649
+ * Estimates what one image costs on the GPU: four bytes per pixel, no mipmaps, rounded to three
650
+ * decimals so two scans of the same tree write the same bytes.
651
+ *
652
+ * @param width - Width in pixels.
653
+ * @param height - Height in pixels.
654
+ * @returns The estimated texture memory in MB.
655
+ * @example
656
+ * ```ts
657
+ * textureMb(256, 128); // 0.125
658
+ * ```
659
+ */
660
+ function textureMb(width, height) {
661
+ return Math.round(width * height * BYTES_PER_PIXEL / BYTES_PER_MB * 1e3) / 1e3;
662
+ }
663
+ /**
664
+ * What a file costs in memory when it is kept as it is: an audio file is held undecoded, so its
665
+ * bytes are the estimate. Rounded to three decimals, like every other number of the manifest.
666
+ *
667
+ * @param bytes - Size of the file in bytes.
668
+ * @returns The size in MB.
669
+ * @example
670
+ * ```ts
671
+ * bytesMb(8192); // 0.008
672
+ * ```
673
+ */
674
+ function bytesMb(bytes) {
675
+ return Math.round(bytes / BYTES_PER_MB * 1e3) / 1e3;
676
+ }
677
+ //#endregion
678
+ //#region src/plugins/assets/scan/fonts.ts
679
+ /**
680
+ * @file assets plugin, build time — the page images a BMFont file names. A `.fnt` and its pages
681
+ * are one asset, so the scanner has to read the file to know which images belong to it. Pure and
682
+ * free of the file system: the three formats a font exporter writes are all text.
683
+ */
684
+ /** `page id=0 file="body_0.png"` in the text format, `<page id="0" file="body_0.png"/>` in XML. */
685
+ const PAGE_FILE = /\bpage\b[^\n>]*?\bfile\s*=\s*"([^"]+)"/g;
686
+ /**
687
+ * Wraps a scanner problem in the message shape of the framework.
688
+ *
689
+ * @param message - One sentence naming the file.
690
+ * @returns The error to throw.
691
+ */
692
+ function problem$4(message) {
693
+ return /* @__PURE__ */ new Error(`[game] assets: ${message}`);
694
+ }
695
+ /**
696
+ * Tells whether a parsed JSON value is an object, so its fields can be read.
697
+ *
698
+ * @param value - A value of the parsed font.
699
+ * @returns `true` for an object or an array, `false` for `null` and every primitive.
700
+ */
701
+ function isObject(value) {
702
+ return typeof value === "object" && value !== null;
703
+ }
704
+ /**
705
+ * Tells whether a parsed BMFont JSON value carries a list of pages.
706
+ *
707
+ * @param font - The parsed file.
708
+ * @returns `true` when `font.pages` is an array.
709
+ */
710
+ function hasPageList(font) {
711
+ return isObject(font) && "pages" in font && Array.isArray(font.pages);
712
+ }
713
+ /**
714
+ * Reads the file name of one BMFont JSON page: the page itself when it is a name, its `file`
715
+ * field when it is an object with a name there.
716
+ *
717
+ * @param page - One entry of the `pages` list.
718
+ * @returns The file name, or `undefined` when the page names none.
719
+ */
720
+ function pageFile(page) {
721
+ if (typeof page === "string") return page;
722
+ if (isObject(page) && "file" in page && typeof page.file === "string") return page.file;
723
+ }
724
+ /**
725
+ * Parses a BMFont JSON file.
726
+ *
727
+ * @param source - The file contents.
728
+ * @param message - The problem to throw when the JSON cannot be read.
729
+ * @returns The parsed file, still to be narrowed.
730
+ * @throws {Error} When the JSON cannot be read.
731
+ */
732
+ function parseFont(source, message) {
733
+ try {
734
+ return JSON.parse(source);
735
+ } catch {
736
+ throw problem$4(message);
737
+ }
738
+ }
739
+ /**
740
+ * Reads the page names of a BMFont JSON file. A page is a file name, or an object with one.
741
+ *
742
+ * @param source - The file contents.
743
+ * @param file - Path of the file, for the message.
744
+ * @returns The page file names, in the order the font declares them.
745
+ * @throws {Error} When the JSON cannot be read.
746
+ */
747
+ function jsonPages(source, file) {
748
+ const font = parseFont(source, `the font "${file}" is not readable BMFont JSON.`);
749
+ if (!hasPageList(font)) return [];
750
+ return font.pages.map((page) => pageFile(page)).filter((page) => page !== void 0);
751
+ }
752
+ /**
753
+ * Reads the page images one `.fnt` file names, whichever of the three BMFont formats it is in.
754
+ *
755
+ * @param source - The file contents.
756
+ * @param file - Path of the file from the scan root, for the messages.
757
+ * @returns The page file names, relative to the folder of the font, in declaration order.
758
+ * @throws {Error} When the file names no page, or its JSON cannot be read.
759
+ * @example
760
+ * ```ts
761
+ * pagesOfFont('page id=0 file="body_0.png"', "features/ui/assets/body.fnt"); // ["body_0.png"]
762
+ * ```
763
+ */
764
+ function pagesOfFont(source, file) {
765
+ const text = source.trimStart();
766
+ const pages = text.startsWith("{") ? jsonPages(text, file) : [...source.matchAll(PAGE_FILE)].map((match) => match[1] ?? "");
767
+ if (pages.length === 0) throw problem$4(`the font "${file}" declares no page.`);
768
+ return pages;
769
+ }
770
+ /**
771
+ * Renames the pages of a BMFont JSON file, in declaration order. A page is a file name, or an
772
+ * object with one; the object keeps its other fields.
773
+ *
774
+ * @param source - The file contents, starting with `{`.
775
+ * @param names - The new page names, one per declared page.
776
+ * @returns The file as compact JSON.
777
+ * @throws {Error} When the JSON cannot be read.
778
+ */
779
+ function renameJsonPages(source, names) {
780
+ const font = parseFont(source, "a font to rename is not readable BMFont JSON.");
781
+ if (!hasPageList(font)) return JSON.stringify(font);
782
+ const pages = font.pages.map((page, index) => {
783
+ const name = names[index];
784
+ if (name === void 0) return page;
785
+ if (typeof page === "string") return name;
786
+ return isObject(page) ? {
787
+ ...page,
788
+ file: name
789
+ } : { file: name };
790
+ });
791
+ return JSON.stringify({
792
+ ...font,
793
+ pages
794
+ });
795
+ }
796
+ /**
797
+ * Points a `.fnt` file at new page images, in the order it declares them: the packer renames
798
+ * every page to its hashed name and rewrites the font, so nothing renames a page behind it. In
799
+ * the text and XML formats only the `file="…"` of each page line changes; a JSON font is written
800
+ * back compact.
801
+ *
802
+ * @param source - The file contents.
803
+ * @param names - The new page names, relative to the folder of the font, one per declared page.
804
+ * @returns The rewritten file contents.
805
+ * @throws {Error} When a JSON font cannot be read.
806
+ * @example
807
+ * ```ts
808
+ * renamePages('page id=0 file="body_0.png"', ["ui.body-0-c81f3e2d55.png"]);
809
+ * // 'page id=0 file="ui.body-0-c81f3e2d55.png"'
810
+ * ```
811
+ */
812
+ function renamePages(source, names) {
813
+ const text = source.trimStart();
814
+ if (text.startsWith("{")) return renameJsonPages(text, names);
815
+ let index = 0;
816
+ return source.replaceAll(PAGE_FILE, (line, file) => {
817
+ const name = names[index] ?? file;
818
+ index += 1;
819
+ return `${line.slice(0, line.length - file.length - 1)}${name}"`;
820
+ });
821
+ }
822
+ //#endregion
823
+ //#region src/plugins/assets/scan/pack/names.ts
824
+ /**
825
+ * @file assets packer, build time — the content hash and the file name rules of the pack folder.
826
+ * Pure: a name is built from a bundle, a key and the hash of the bytes it holds, and is never
827
+ * parsed back. The manifest is the only reader of the folder.
828
+ */
829
+ /** How many hex characters of the SHA-256 a file name carries. */
830
+ const HASH_LENGTH = 10;
831
+ /**
832
+ * Hashes the bytes of one output file: the first 10 hex characters of their SHA-256. A file that
833
+ * changes gets a new name, so a CDN may cache every name forever.
834
+ *
835
+ * @param bytes - The bytes as they are written.
836
+ * @returns Ten lowercase hex characters.
837
+ * @example
838
+ * ```ts
839
+ * contentHash(new TextEncoder().encode("abc")); // "ba7816bf8f"
840
+ * ```
841
+ */
842
+ function contentHash(bytes) {
843
+ return createHash("sha256").update(bytes).digest("hex").slice(0, HASH_LENGTH);
844
+ }
845
+ /**
846
+ * Names an atlas page in the manifest: the id an `atlas` frame points at.
847
+ *
848
+ * @param bundle - Name of the bundle.
849
+ * @param group - The atlas group of the page.
850
+ * @param index - Its index inside the group, from 0.
851
+ * @returns The page id.
852
+ * @example
853
+ * ```ts
854
+ * pageId("ui", "main", 0); // "ui/main-0"
855
+ * ```
856
+ */
857
+ function pageId(bundle, group, index) {
858
+ return `${bundle}/${group}-${index}`;
859
+ }
860
+ /**
861
+ * Names the file of an atlas page.
862
+ *
863
+ * @param bundle - Name of the bundle.
864
+ * @param group - The atlas group of the page.
865
+ * @param index - Its index inside the group, from 0.
866
+ * @param hash - The content hash of the WebP bytes.
867
+ * @returns The path inside the pack folder.
868
+ * @example
869
+ * ```ts
870
+ * pageName("ui", "fx", 0, "2a7f9c04e1"); // "ui/fx-0-2a7f9c04e1.webp"
871
+ * ```
872
+ */
873
+ function pageName(bundle, group, index, hash) {
874
+ return `${pageId(bundle, group, index)}-${hash}.webp`;
875
+ }
876
+ /**
877
+ * Names the file of a loose texture.
878
+ *
879
+ * @param bundle - Name of the bundle.
880
+ * @param key - Asset key of the texture.
881
+ * @param hash - The content hash of the bytes.
882
+ * @param extension - The extension of the written format, without the dot.
883
+ * @returns The path inside the pack folder.
884
+ * @example
885
+ * ```ts
886
+ * looseName("ui", "ui.bg-splash", "5e0a71bd42", "webp"); // "ui/ui.bg-splash-5e0a71bd42.webp"
887
+ * ```
888
+ */
889
+ function looseName(bundle, key, hash, extension) {
890
+ return `${bundle}/${key}-${hash}.${extension}`;
891
+ }
892
+ /**
893
+ * Names the `.fnt` file of a font.
894
+ *
895
+ * @param bundle - Name of the bundle.
896
+ * @param key - Asset key of the font.
897
+ * @param hash - The content hash of the rewritten `.fnt` bytes.
898
+ * @returns The path inside the pack folder.
899
+ * @example
900
+ * ```ts
901
+ * fontName("ui", "ui.font-body", "7d2c90f1ab"); // "ui/ui.font-body-7d2c90f1ab.fnt"
902
+ * ```
903
+ */
904
+ function fontName(bundle, key, hash) {
905
+ return `${bundle}/${key}-${hash}.fnt`;
906
+ }
907
+ /**
908
+ * Names one page image of a font. The page keeps its format: MSDF needs lossless pixels.
909
+ *
910
+ * @param bundle - Name of the bundle.
911
+ * @param key - Asset key of the font.
912
+ * @param index - The index of the page, in the order the font declares them.
913
+ * @param hash - The content hash of the page bytes.
914
+ * @param extension - The extension of the source page, without the dot.
915
+ * @returns The path inside the pack folder.
916
+ * @example
917
+ * ```ts
918
+ * fontPageName("ui", "ui.font-body", 0, "c81f3e2d55", "png"); // "ui/ui.font-body-0-c81f3e2d55.png"
919
+ * ```
920
+ */
921
+ function fontPageName(bundle, key, index, hash, extension) {
922
+ return `${bundle}/${key}-${index}-${hash}.${extension}`;
923
+ }
924
+ /**
925
+ * Names the file of a sound.
926
+ *
927
+ * @param bundle - Name of the bundle.
928
+ * @param key - Asset key of the sound.
929
+ * @param hash - The content hash of the bytes.
930
+ * @returns The path inside the pack folder.
931
+ * @example
932
+ * ```ts
933
+ * audioName("ui", "ui.click", "9c4e2b7a10"); // "ui/ui.click-9c4e2b7a10.mp3"
934
+ * ```
935
+ */
936
+ function audioName(bundle, key, hash) {
937
+ return `${bundle}/${key}-${hash}.mp3`;
938
+ }
939
+ //#endregion
940
+ //#region src/plugins/assets/scan/pack/copy.ts
941
+ /**
942
+ * @file assets packer, build time — the files that are copied, not encoded: a font (its `.fnt`
943
+ * rewritten to name its hashed pages, the pages byte for byte, so MSDF stays lossless), a sound
944
+ * and a loose WebP texture (no second generation of loss). Copies are cheap and are not cached.
945
+ */
946
+ /**
947
+ * Reads one source file of the scan.
948
+ *
949
+ * @param root - The game source root.
950
+ * @param posixPath - The path of the manifest, relative to the root.
951
+ * @returns The bytes.
952
+ */
953
+ async function readSource(root, posixPath) {
954
+ return new Uint8Array(await readFile(path.join(root, ...posixPath.split("/"))));
955
+ }
956
+ /**
957
+ * Reads the extension of a POSIX path, without the dot.
958
+ *
959
+ * @param posixPath - A path of the manifest.
960
+ * @returns The extension, in lower case.
961
+ * @example
962
+ * ```ts
963
+ * extensionOf("features/ui/assets/body_0.png"); // "png"
964
+ * ```
965
+ */
966
+ function extensionOf(posixPath) {
967
+ return path.posix.extname(posixPath).slice(1).toLowerCase();
968
+ }
969
+ /**
970
+ * Copies one font: every page byte for byte under its hashed name, then the `.fnt` rewritten to
971
+ * name those pages, hashed by its new bytes.
972
+ *
973
+ * @param root - The game source root.
974
+ * @param bundle - Name of the bundle.
975
+ * @param file - The font of the scanned manifest, with its `path` and `pages`.
976
+ * @param fontPath - The path of its `.fnt` file.
977
+ * @returns The packed entry and the files to write.
978
+ */
979
+ async function copyFont(root, bundle, file, fontPath) {
980
+ const outputs = [];
981
+ const pages = [];
982
+ for (const [index, page] of (file.pages ?? []).entries()) {
983
+ const bytes = await readSource(root, page.path);
984
+ const name = fontPageName(bundle, file.key, index, contentHash(bytes), extensionOf(page.path));
985
+ outputs.push({
986
+ path: name,
987
+ bytes
988
+ });
989
+ pages.push({
990
+ ...page,
991
+ path: name
992
+ });
993
+ }
994
+ const text = renamePages(new TextDecoder().decode(await readSource(root, fontPath)), pages.map((page) => path.posix.basename(page.path)));
995
+ const bytes = new TextEncoder().encode(text);
996
+ const name = fontName(bundle, file.key, contentHash(bytes));
997
+ outputs.push({
998
+ path: name,
999
+ bytes
1000
+ });
1001
+ return {
1002
+ file: {
1003
+ ...file,
1004
+ path: name,
1005
+ pages
1006
+ },
1007
+ outputs
1008
+ };
1009
+ }
1010
+ /**
1011
+ * Copies one sound byte for byte under its hashed name.
1012
+ *
1013
+ * @param root - The game source root.
1014
+ * @param bundle - Name of the bundle.
1015
+ * @param file - The sound of the scanned manifest.
1016
+ * @param soundPath - The path of its `.mp3` file.
1017
+ * @returns The packed entry and the file to write.
1018
+ */
1019
+ async function copyAudio(root, bundle, file, soundPath) {
1020
+ const bytes = await readSource(root, soundPath);
1021
+ const name = audioName(bundle, file.key, contentHash(bytes));
1022
+ return {
1023
+ file: {
1024
+ ...file,
1025
+ path: name
1026
+ },
1027
+ outputs: [{
1028
+ path: name,
1029
+ bytes
1030
+ }]
1031
+ };
1032
+ }
1033
+ /**
1034
+ * Copies one loose texture that already is a WebP, byte for byte under its hashed name.
1035
+ *
1036
+ * @param bundle - Name of the bundle.
1037
+ * @param file - The texture of the scanned manifest.
1038
+ * @param bytes - Its WebP bytes, as read from the source.
1039
+ * @returns The packed entry and the file to write.
1040
+ */
1041
+ function copyLoose(bundle, file, bytes) {
1042
+ const name = looseName(bundle, file.key, contentHash(bytes), "webp");
1043
+ return {
1044
+ file: {
1045
+ ...file,
1046
+ path: name
1047
+ },
1048
+ outputs: [{
1049
+ path: name,
1050
+ bytes
1051
+ }]
1052
+ };
1053
+ }
1054
+ //#endregion
1055
+ //#region src/plugins/assets/scan/pack/groups.ts
1056
+ /** A texture with a side above this stays loose: packing it would cost more memory than it saves. */
1057
+ const LOOSE_SIDE = 512;
1058
+ /** The last segment of a key that names a particle texture starts with this. */
1059
+ const FX_PREFIX = "fx-";
1060
+ /** The groups in the order a bundle packs them. */
1061
+ const GROUP_ORDER = ["fx", "main"];
1062
+ /**
1063
+ * Picks the atlas group of one texture. A key whose last segment starts with `fx-` goes to `fx`
1064
+ * whatever its size; any other texture goes to `main` when no side is above 512 px.
1065
+ *
1066
+ * @param texture - The key and the size of the texture.
1067
+ * @returns The group, or `undefined` when the texture stays loose by its size.
1068
+ * @example
1069
+ * ```ts
1070
+ * groupOf({ key: "ui.fx-rays", width: 504, height: 512 }); // "fx"
1071
+ * ```
1072
+ */
1073
+ function groupOf(texture) {
1074
+ if (texture.key.slice(texture.key.lastIndexOf(".") + 1).startsWith(FX_PREFIX)) return "fx";
1075
+ return texture.width <= LOOSE_SIDE && texture.height <= LOOSE_SIDE ? "main" : void 0;
1076
+ }
1077
+ /**
1078
+ * Compares two textures by key, so every list of the plan has one order.
1079
+ *
1080
+ * @param left - One texture.
1081
+ * @param right - Another texture.
1082
+ * @returns The sort order.
1083
+ */
1084
+ function byKey(left, right) {
1085
+ return left.key.localeCompare(right.key);
1086
+ }
1087
+ /**
1088
+ * Collects the atlas groups of sorted textures. A group of one is no group: its texture joins the
1089
+ * loose ones.
1090
+ *
1091
+ * @param sorted - The textures of a bundle, sorted by key.
1092
+ * @param loose - Where a texture of a group of one goes.
1093
+ * @returns Group name to its members, in the order a bundle packs them.
1094
+ */
1095
+ function collectGroups(sorted, loose) {
1096
+ const groups = /* @__PURE__ */ new Map();
1097
+ for (const group of GROUP_ORDER) {
1098
+ const members = sorted.filter((texture) => groupOf(texture) === group);
1099
+ if (members.length > 1) groups.set(group, members);
1100
+ else loose.push(...members);
1101
+ }
1102
+ return groups;
1103
+ }
1104
+ /**
1105
+ * Splits the textures of one bundle into atlas groups and loose files. A group of one stays
1106
+ * loose as well: an atlas of one file is one request either way, and costs its padding.
1107
+ *
1108
+ * @param textures - The textures of the bundle, in any order.
1109
+ * @returns The groups and the loose textures, each sorted by key.
1110
+ * @example
1111
+ * ```ts
1112
+ * planGroups([{ key: "orders.card", width: 218, height: 304 }]);
1113
+ * // { groups: Map {}, loose: [{ key: "orders.card", width: 218, height: 304 }] }
1114
+ * ```
1115
+ */
1116
+ function planGroups(textures) {
1117
+ const sorted = textures.toSorted(byKey);
1118
+ const loose = sorted.filter((texture) => groupOf(texture) === void 0);
1119
+ return {
1120
+ groups: collectGroups(sorted, loose),
1121
+ loose: loose.toSorted(byKey)
1122
+ };
1123
+ }
1124
+ //#endregion
1125
+ //#region src/plugins/assets/scan/pack/pack.ts
1126
+ /** The pack folder keeps a file of this name even when the manifest goes elsewhere. */
1127
+ const MANIFEST_NAME = "manifest.json";
1128
+ /** What the problems of a file call its kind. */
1129
+ const KIND_WORD = {
1130
+ texture: "texture",
1131
+ font: "font",
1132
+ audio: "sound"
1133
+ };
1134
+ /**
1135
+ * Creates an empty map. It lives in its own non-exported function because lint rule L5 refuses a
1136
+ * collection built inside an exported declaration.
1137
+ *
1138
+ * @returns An empty map.
1139
+ */
1140
+ function emptyMap() {
1141
+ return /* @__PURE__ */ new Map();
1142
+ }
1143
+ /**
1144
+ * Lists the keys of two maps once each, for the same reason as `emptyMap`.
1145
+ *
1146
+ * @param left - One map.
1147
+ * @param right - Another map.
1148
+ * @returns Every key of either, without repeats.
1149
+ */
1150
+ function keysOfBoth(left, right) {
1151
+ return [.../* @__PURE__ */ new Set([...left.keys(), ...right.keys()])];
1152
+ }
1153
+ /**
1154
+ * Reads the message of anything that was thrown.
1155
+ *
1156
+ * @param failure - What the `catch` caught.
1157
+ * @returns The message.
1158
+ */
1159
+ function messageOf$1(failure) {
1160
+ return failure instanceof Error ? failure.message : String(failure);
1161
+ }
1162
+ /**
1163
+ * Collects every problem of a pack into one error, so a game fixes its art in one round.
1164
+ *
1165
+ * @param problems - The problems, in pack order.
1166
+ * @returns The error to reject with.
1167
+ */
1168
+ function collected$2(problems) {
1169
+ const count = problems.length;
1170
+ const lines = problems.map((text) => ` ${text}`).join("\n");
1171
+ return /* @__PURE__ */ new Error(`[game] assets: the pack found ${count} problem${count === 1 ? "" : "s"}.\n${lines}\n Fix them and run "bun run assets:pack" again.`);
1172
+ }
1173
+ /**
1174
+ * Adds up what a list of entries costs.
1175
+ *
1176
+ * @param entries - Pages and files, each with an `mb`.
1177
+ * @returns The sum in MB, rounded to three decimals.
1178
+ */
1179
+ function sumMb$1(entries) {
1180
+ let total = 0;
1181
+ for (const entry of entries) total += entry.mb;
1182
+ return Math.round(total * 1e3) / 1e3;
1183
+ }
1184
+ /**
1185
+ * Reads the path of a file of the scan. A file without one comes from a packed manifest, which
1186
+ * the packer cannot read its sources from.
1187
+ *
1188
+ * @param run - The pack run, which collects the problem.
1189
+ * @param file - A file of the scanned manifest.
1190
+ * @returns The path, or `undefined` after a problem.
1191
+ */
1192
+ function sourcePathOf(run, file) {
1193
+ if (file.path !== void 0) return file.path;
1194
+ run.problems.push(`the ${KIND_WORD[file.kind ?? "texture"]} "${file.key}" has no path: pack the manifest of the scan.`);
1195
+ }
1196
+ /**
1197
+ * Reads the source of every texture of a bundle and hashes it.
1198
+ *
1199
+ * @param run - The pack run.
1200
+ * @param bundle - The bundle of the scan.
1201
+ * @returns The textures with their bytes, in manifest order.
1202
+ */
1203
+ async function readTextures(run, bundle) {
1204
+ const sources = [];
1205
+ for (const file of bundle.files.filter((entry) => (entry.kind ?? "texture") === "texture")) {
1206
+ const at = sourcePathOf(run, file);
1207
+ if (at === void 0) continue;
1208
+ try {
1209
+ const bytes = await readSource(run.root, at);
1210
+ sources.push({
1211
+ key: file.key,
1212
+ width: file.width,
1213
+ height: file.height,
1214
+ file,
1215
+ bytes,
1216
+ sha: sha256(bytes)
1217
+ });
1218
+ } catch (error) {
1219
+ run.problems.push(`the file "${at}" could not be read: ${messageOf$1(error)}`);
1220
+ }
1221
+ }
1222
+ return sources;
1223
+ }
1224
+ /**
1225
+ * Decodes the textures of one layout page and composes the page.
1226
+ *
1227
+ * @param run - The pack run.
1228
+ * @param page - The size and the frames of the page.
1229
+ * @param page.width - Width of the page.
1230
+ * @param page.height - Height of the page.
1231
+ * @param page.frames - Where each texture lies.
1232
+ * @param sources - The members of the group, by key.
1233
+ * @returns The page, encoded.
1234
+ */
1235
+ async function encodePage(run, page, sources) {
1236
+ const parts = [];
1237
+ for (const frame of page.frames) {
1238
+ const source = sources.get(frame.key);
1239
+ if (source === void 0) continue;
1240
+ const pixels = await run.encoder.decode(source.bytes);
1241
+ if (pixels.width !== frame.width || pixels.height !== frame.height) {
1242
+ run.problems.push(`the texture "${frame.key}" decodes to ${pixels.width}×${pixels.height}, not the ${frame.width}×${frame.height} its header says.`);
1243
+ continue;
1244
+ }
1245
+ parts.push({
1246
+ pixels,
1247
+ x: frame.x,
1248
+ y: frame.y
1249
+ });
1250
+ }
1251
+ const bytes = await run.encoder.page(page.width, page.height, parts);
1252
+ return {
1253
+ width: page.width,
1254
+ height: page.height,
1255
+ frames: page.frames,
1256
+ bytes
1257
+ };
1258
+ }
1259
+ /**
1260
+ * Packs one atlas group into pages: from the cache when every member is unchanged, otherwise laid
1261
+ * out, composed and encoded, and stored in the cache when nothing went wrong.
1262
+ *
1263
+ * @param run - The pack run.
1264
+ * @param bundle - Name of the bundle.
1265
+ * @param group - The atlas group.
1266
+ * @param members - Its textures, sorted by key.
1267
+ * @returns The encoded pages with their frames.
1268
+ */
1269
+ async function packGroup(run, bundle, group, members) {
1270
+ const key = cacheKey({
1271
+ kind: "atlas",
1272
+ sharp: run.encoder.version,
1273
+ members: members.map((member) => ({
1274
+ key: member.key,
1275
+ source: member.sha,
1276
+ nine: member.file.nine
1277
+ }))
1278
+ });
1279
+ const cached = await readCache(run.cache, key);
1280
+ if (cached !== void 0) {
1281
+ run.cacheHits += cached.length;
1282
+ return cached;
1283
+ }
1284
+ const open = run.problems.length;
1285
+ const layout = layoutGroup(bundle, group, members);
1286
+ const sources = new Map(members.map((member) => [member.key, member]));
1287
+ const images = [];
1288
+ run.problems.push(...layout.problems);
1289
+ for (const page of layout.pages) images.push(await encodePage(run, page, sources));
1290
+ if (run.problems.length === open) await writeCache(run.cache, key, images);
1291
+ return images;
1292
+ }
1293
+ /**
1294
+ * Writes the entry of a texture cut out of a page: no `path`, no cost of its own, the frame.
1295
+ *
1296
+ * @param file - The texture of the scan.
1297
+ * @param page - The id of its page.
1298
+ * @param frame - Where it lies on the page.
1299
+ * @returns The packed entry.
1300
+ */
1301
+ function packedFile(file, page, frame) {
1302
+ return {
1303
+ key: file.key,
1304
+ width: file.width,
1305
+ height: file.height,
1306
+ mb: 0,
1307
+ ...file.nine === void 0 ? {} : { nine: file.nine },
1308
+ atlas: {
1309
+ page,
1310
+ x: frame.x,
1311
+ y: frame.y,
1312
+ width: frame.width,
1313
+ height: frame.height
1314
+ }
1315
+ };
1316
+ }
1317
+ /**
1318
+ * Writes one loose texture: a WebP source is copied byte for byte, a PNG source is encoded to
1319
+ * WebP, through the cache.
1320
+ *
1321
+ * @param run - The pack run.
1322
+ * @param bundle - Name of the bundle.
1323
+ * @param source - The texture and its bytes.
1324
+ * @returns The packed entry and the file it writes.
1325
+ */
1326
+ async function looseTexture(run, bundle, source) {
1327
+ if (extensionOf(source.file.path ?? "") === "webp") return copyLoose(bundle, source.file, source.bytes);
1328
+ const key = cacheKey({
1329
+ kind: "loose",
1330
+ sharp: run.encoder.version,
1331
+ members: [{
1332
+ key: source.key,
1333
+ source: source.sha,
1334
+ nine: source.file.nine
1335
+ }]
1336
+ });
1337
+ const cached = await readCache(run.cache, key);
1338
+ const bytes = cached?.[0]?.bytes ?? await run.encoder.webp(source.bytes);
1339
+ if (cached === void 0) await writeCache(run.cache, key, [{
1340
+ width: source.width,
1341
+ height: source.height,
1342
+ frames: [],
1343
+ bytes
1344
+ }]);
1345
+ const name = looseName(bundle, source.key, contentHash(bytes), "webp");
1346
+ return {
1347
+ file: {
1348
+ ...source.file,
1349
+ path: name
1350
+ },
1351
+ outputs: [{
1352
+ path: name,
1353
+ bytes
1354
+ }]
1355
+ };
1356
+ }
1357
+ /**
1358
+ * Copies the fonts and the sounds of a bundle.
1359
+ *
1360
+ * @param run - The pack run.
1361
+ * @param bundle - Name of the bundle.
1362
+ * @param files - The files of the bundle.
1363
+ * @returns What each copy wrote.
1364
+ */
1365
+ async function copyOthers(run, bundle, files) {
1366
+ const copies = [];
1367
+ for (const file of files.filter((entry) => entry.kind === "font" || entry.kind === "audio")) {
1368
+ const at = sourcePathOf(run, file);
1369
+ if (at === void 0) continue;
1370
+ try {
1371
+ copies.push(file.kind === "font" ? await copyFont(run.root, bundle, file, at) : await copyAudio(run.root, bundle, file, at));
1372
+ } catch (error) {
1373
+ run.problems.push(`the ${KIND_WORD[file.kind ?? "texture"]} "${file.key}" could not be copied: ${messageOf$1(error)}`);
1374
+ }
1375
+ }
1376
+ return copies;
1377
+ }
1378
+ /**
1379
+ * Hands one encoded page to the pack folder under its hashed name.
129
1380
  *
130
- * @param manifest - What the scan produced.
131
- * @returns The text of the file.
132
- * @example
133
- * ```ts
134
- * emitManifest({ version: 1, bundles: {} }); // '{\n "version": 1,\n "bundles": {}\n}\n'
135
- * ```
136
- */
137
- function emitManifest(manifest) {
138
- const bundles = {};
139
- for (const [name, bundle] of sortedBundles(manifest)) bundles[name] = plainBundle(bundle);
140
- return `${JSON.stringify({
141
- version: manifest.version,
142
- bundles
143
- }, void 0, 2)}\n`;
1381
+ * @param run - The pack run, which collects the files to write.
1382
+ * @param id - The id of the page.
1383
+ * @param file - Its hashed file name.
1384
+ * @param image - The encoded page.
1385
+ * @returns The page entry of the manifest.
1386
+ */
1387
+ function writePage(run, id, file, image) {
1388
+ run.outputs.set(file, image.bytes);
1389
+ return {
1390
+ id,
1391
+ path: file,
1392
+ width: image.width,
1393
+ height: image.height,
1394
+ mb: textureMb(image.width, image.height)
1395
+ };
144
1396
  }
145
1397
  /**
146
- * Writes `generated/assets.ts`: `AssetKey` over every kind, the narrower `FontKey` and `AudioKey`
147
- * next to it, `BundleKey`, and the nine-slice table. A game commits this file, so `defineGame` can
148
- * type `Sprite`, a text style, a sound, `defineBundles` and the `load` effect from it.
1398
+ * Writes the entries of the textures cut out of one page.
149
1399
  *
150
- * @param manifest - What the scan produced.
151
- * @returns The text of the module.
152
- * @example
153
- * ```ts
154
- * emitKeys({ version: 1, bundles: {} });
155
- * // the header, four unions that are all "never" and an empty nine-slice table
156
- * ```
157
- */
158
- function emitKeys(manifest) {
159
- const files = allFiles(manifest);
160
- const nine = [];
161
- for (const file of files) if (file.nine !== void 0) nine.push({
162
- key: file.key,
163
- nine: file.nine
1400
+ * @param image - The encoded page with its frames.
1401
+ * @param page - The id of the page.
1402
+ * @param byKey - The textures of the scan in its group, by key.
1403
+ * @returns One packed entry per frame of a known texture.
1404
+ */
1405
+ function framedFiles(image, page, byKey) {
1406
+ return image.frames.flatMap((frame) => {
1407
+ const source = byKey.get(frame.key);
1408
+ return source === void 0 ? [] : [packedFile(source, page, frame)];
164
1409
  });
165
- return `${[
166
- HEADER$1,
167
- union("AssetKey", files.map((file) => file.key)),
168
- union("FontKey", keysOfKind(files, "font")),
169
- union("AudioKey", keysOfKind(files, "audio")),
170
- union("BundleKey", bundleNames(manifest)),
171
- nineTable(nine)
172
- ].join("\n\n")}\n`;
173
1410
  }
174
- //#endregion
175
- //#region src/plugins/assets/scan/fonts.ts
176
1411
  /**
177
- * @file assets plugin, build time — the page images a BMFont file names. A `.fnt` and its pages
178
- * are one asset, so the scanner has to read the file to know which images belong to it. Pure and
179
- * free of the file system: the three formats a font exporter writes are all text.
180
- */
181
- /** `page id=0 file="body_0.png"` in the text format, `<page id="0" file="body_0.png"/>` in XML. */
182
- const PAGE_FILE = /\bpage\b[^\n>]*?\bfile\s*=\s*"([^"]+)"/g;
1412
+ * Packs the atlas groups of one bundle into pages, and writes the entry of every texture on them.
1413
+ *
1414
+ * @param run - The pack run, which collects the files to write.
1415
+ * @param bundle - Name of the bundle.
1416
+ * @param groups - The atlas groups of the bundle with their textures.
1417
+ * @returns The pages and the packed textures.
1418
+ */
1419
+ async function packGroups(run, bundle, groups) {
1420
+ const packed = {
1421
+ pages: [],
1422
+ files: []
1423
+ };
1424
+ for (const [group, members] of groups) {
1425
+ const images = await packGroup(run, bundle, group, members);
1426
+ const byKey = new Map(members.map((member) => [member.key, member.file]));
1427
+ for (const [index, image] of images.entries()) {
1428
+ const name = pageName(bundle, group, index, contentHash(image.bytes));
1429
+ const page = writePage(run, pageId(bundle, group, index), name, image);
1430
+ packed.pages.push(page);
1431
+ packed.files.push(...framedFiles(image, page.id, byKey));
1432
+ }
1433
+ }
1434
+ return packed;
1435
+ }
183
1436
  /**
184
- * Wraps a scanner problem in the message shape of the framework.
1437
+ * Writes what stays a file of its own: the loose textures as WebP, the fonts and the sounds as
1438
+ * copies.
185
1439
  *
186
- * @param message - One sentence naming the file.
187
- * @returns The error to throw.
1440
+ * @param run - The pack run, which collects the files to write.
1441
+ * @param bundle - Name of the bundle.
1442
+ * @param loose - The textures no atlas group took.
1443
+ * @param files - Every file of the bundle in the scan.
1444
+ * @returns The entries of the written files.
188
1445
  */
189
- function problem$5(message) {
190
- return /* @__PURE__ */ new Error(`[game] assets: ${message}`);
1446
+ async function packLoose(run, bundle, loose, files) {
1447
+ const copies = [...await Promise.all(loose.map((source) => looseTexture(run, bundle, source))), ...await copyOthers(run, bundle, files)];
1448
+ for (const copy of copies) for (const output of copy.outputs) run.outputs.set(output.path, output.bytes);
1449
+ return copies.map((copy) => copy.file);
191
1450
  }
192
1451
  /**
193
- * Reads the page names of a BMFont JSON file. A page is a file name, or an object with one.
1452
+ * Packs one bundle: its atlas groups become pages, the rest is written loose or copied.
194
1453
  *
195
- * @param source - The file contents.
196
- * @param file - Path of the file, for the message.
197
- * @returns The page file names, in the order the font declares them.
198
- * @throws {Error} When the JSON cannot be read.
199
- */
200
- function jsonPages(source, file) {
201
- let parsed;
202
- try {
203
- parsed = JSON.parse(source);
204
- } catch {
205
- throw problem$5(`the font "${file}" is not readable BMFont JSON.`);
206
- }
207
- const pages = parsed.pages;
208
- if (!Array.isArray(pages)) return [];
209
- return pages.map((page) => typeof page === "string" ? page : page.file).filter((page) => typeof page === "string");
1454
+ * @param run - The pack run, which collects the files to write.
1455
+ * @param name - Name of the bundle.
1456
+ * @param bundle - The bundle of the scan.
1457
+ * @returns The packed bundle.
1458
+ */
1459
+ async function packBundle(run, name, bundle) {
1460
+ const plan = planGroups(await readTextures(run, bundle));
1461
+ const packed = await packGroups(run, name, plan.groups);
1462
+ const files = [...packed.files, ...await packLoose(run, name, plan.loose, bundle.files)];
1463
+ const mb = sumMb$1([...packed.pages, ...files.filter((file) => file.atlas === void 0)]);
1464
+ return {
1465
+ feature: bundle.feature,
1466
+ tier: bundle.tier,
1467
+ mb,
1468
+ ...packed.pages.length === 0 ? {} : { pages: packed.pages.toSorted((left, right) => left.id.localeCompare(right.id)) },
1469
+ files: files.toSorted((left, right) => left.key.localeCompare(right.key))
1470
+ };
210
1471
  }
211
1472
  /**
212
- * Reads the page images one `.fnt` file names, whichever of the three BMFont formats it is in.
1473
+ * Checks one packed texture against its page and its source.
213
1474
  *
214
- * @param source - The file contents.
215
- * @param file - Path of the file from the scan root, for the messages.
216
- * @returns The page file names, relative to the folder of the font, in declaration order.
217
- * @throws {Error} When the file names no page, or its JSON cannot be read.
1475
+ * @param bundle - Name of the bundle.
1476
+ * @param entry - The packed bundle.
1477
+ * @param file - One of its textures.
1478
+ * @param source - The same texture in the scan.
1479
+ * @returns The problems, empty when the file is right.
1480
+ */
1481
+ function checkTexture(bundle, entry, file, source) {
1482
+ const atlas = file.atlas;
1483
+ if (atlas === void 0 === (file.path === void 0)) return [`the texture "${file.key}" must have either a path or an atlas frame.`];
1484
+ if (atlas === void 0) return [];
1485
+ const page = entry.pages?.find((candidate) => candidate.id === atlas.page);
1486
+ if (page === void 0) return [`the texture "${file.key}" names page "${atlas.page}", which bundle "${bundle}" does not list.`];
1487
+ const problems = [];
1488
+ if (!(atlas.x >= 0 && atlas.y >= 0 && atlas.x + atlas.width <= page.width && atlas.y + atlas.height <= page.height)) problems.push(`the frame of "${file.key}" lies outside page "${page.id}" (${page.width}×${page.height}).`);
1489
+ if (atlas.width !== source?.width || atlas.height !== source.height) problems.push(`the frame of "${file.key}" is ${atlas.width}×${atlas.height}, its source ${source?.width ?? 0}×${source?.height ?? 0}.`);
1490
+ return problems;
1491
+ }
1492
+ /**
1493
+ * Checks a packed manifest against the scan it came from: every key landed exactly once, as a
1494
+ * page frame or a file; every frame names a page of its own bundle, lies inside it and has the
1495
+ * size of its source, so `nine` stays valid as it is.
1496
+ *
1497
+ * @param source - The manifest of the scan.
1498
+ * @param packed - The packed manifest.
1499
+ * @returns One sentence per problem, empty when the pack is right.
218
1500
  * @example
219
1501
  * ```ts
220
- * pagesOfFont('page id=0 file="body_0.png"', "features/ui/assets/body.fnt"); // ["body_0.png"]
1502
+ * checkPacked(emptyManifest(), { version: 2, bundles: {} }); // []
221
1503
  * ```
222
1504
  */
223
- function pagesOfFont(source, file) {
224
- const text = source.trimStart();
225
- const pages = text.startsWith("{") ? jsonPages(text, file) : [...source.matchAll(PAGE_FILE)].map((match) => match[1] ?? "");
226
- if (pages.length === 0) throw problem$5(`the font "${file}" declares no page.`);
227
- return pages;
1505
+ function checkPacked(source, packed) {
1506
+ const problems = [];
1507
+ const scanned = emptyMap();
1508
+ const landed = emptyMap();
1509
+ for (const bundle of Object.values(source.bundles)) for (const file of bundle.files) scanned.set(file.key, file);
1510
+ for (const [name, entry] of Object.entries(packed.bundles)) for (const file of entry.files) {
1511
+ landed.set(file.key, (landed.get(file.key) ?? 0) + 1);
1512
+ if ((file.kind ?? "texture") === "texture") problems.push(...checkTexture(name, entry, file, scanned.get(file.key)));
1513
+ }
1514
+ for (const key of keysOfBoth(scanned, landed)) {
1515
+ const count = landed.get(key) ?? 0;
1516
+ if (!scanned.has(key)) problems.push(`the key "${key}" is in the pack but not in the scan.`);
1517
+ else if (count !== 1) problems.push(`the key "${key}" landed ${count} times, not once.`);
1518
+ }
1519
+ return problems;
228
1520
  }
229
- const PNG_SIGNATURE = [
230
- 137,
231
- 80,
232
- 78,
233
- 71,
234
- 13,
235
- 10,
236
- 26,
237
- 10
238
- ];
239
- const VP8_SYNC = [
240
- 157,
241
- 1,
242
- 42
243
- ];
244
- const VP8L_SIGNATURE = 47;
245
- /** Both WebP frame headers carry the width and the height in 14 bits each. */
246
- const SIZE_MASK = 16383;
247
- const BYTES_PER_PIXEL = 4;
248
- const BYTES_PER_MB = 1048576;
249
1521
  /**
250
- * Wraps a scanner problem in the message shape of the framework.
1522
+ * Tells whether a file already holds these bytes, so an unchanged file is not written again.
251
1523
  *
252
- * @param message - One sentence naming the file.
253
- * @returns The error to throw.
1524
+ * @param file - Path of the file.
1525
+ * @param bytes - What it should hold.
1526
+ * @returns True when it does.
254
1527
  */
255
- function problem$4(message) {
256
- return /* @__PURE__ */ new Error(`[game] assets: ${message}`);
1528
+ async function holds(file, bytes) {
1529
+ try {
1530
+ return Buffer.from(await readFile(file)).equals(bytes);
1531
+ } catch {
1532
+ return false;
1533
+ }
257
1534
  }
258
1535
  /**
259
- * Reads the bytes as a data view.
1536
+ * Writes one file when its bytes changed.
260
1537
  *
261
- * @param head - The first bytes of the file.
262
- * @returns A view over exactly those bytes.
1538
+ * @param file - Path of the file.
1539
+ * @param bytes - What it should hold.
263
1540
  */
264
- function viewOf(head) {
265
- return new DataView(head.buffer, head.byteOffset, head.byteLength);
1541
+ async function writeChanged(file, bytes) {
1542
+ if (await holds(file, bytes)) return;
1543
+ await mkdir(path.dirname(file), { recursive: true });
1544
+ await writeFile(file, bytes);
266
1545
  }
267
1546
  /**
268
- * Tells whether a run of bytes stands at an offset.
1547
+ * Prunes one entry of a folder: a sub-folder is pruned and removed once empty, a kept file stays,
1548
+ * any other file is deleted.
269
1549
  *
270
- * @param head - The first bytes of the file.
271
- * @param offset - Where to look.
272
- * @param bytes - What to find.
273
- * @returns True when every byte matches.
274
- */
275
- function hasBytes(head, offset, bytes) {
276
- return bytes.every((byte, index) => head[offset + index] === byte);
1550
+ * @param folder - The folder that holds the entry.
1551
+ * @param entry - The entry as `readdir` listed it.
1552
+ * @param keep - Absolute paths of the files to keep.
1553
+ * @returns True when the entry is gone afterwards.
1554
+ */
1555
+ async function pruneEntry(folder, entry, keep) {
1556
+ const target = path.join(folder, entry.name);
1557
+ if (entry.isDirectory()) {
1558
+ if (!await prune(target, keep)) return false;
1559
+ await rmdir(target);
1560
+ return true;
1561
+ }
1562
+ if (keep.has(target)) return false;
1563
+ await rm(target, { force: true });
1564
+ return true;
277
1565
  }
278
1566
  /**
279
- * Reads a run of bytes as ASCII, which is what the chunk names of these formats are.
1567
+ * Deletes every file under a folder that is not kept, and every folder that ends up empty.
280
1568
  *
281
- * @param head - The first bytes of the file.
282
- * @param offset - Where to start.
283
- * @param length - How many bytes to read.
284
- * @returns The text.
1569
+ * @param folder - The folder to prune.
1570
+ * @param keep - Absolute paths of the files to keep.
1571
+ * @returns True when the folder is empty afterwards.
285
1572
  */
286
- function ascii(head, offset, length) {
287
- return String.fromCodePoint(...head.subarray(offset, offset + length));
1573
+ async function prune(folder, keep) {
1574
+ let empty = true;
1575
+ for (const entry of await readdir(folder, { withFileTypes: true })) if (!await pruneEntry(folder, entry, keep)) empty = false;
1576
+ return empty;
288
1577
  }
289
1578
  /**
290
- * Reads a 24-bit little-endian number, the shape the `VP8X` canvas size uses.
1579
+ * Writes the pack folder: every output whose bytes changed, then the manifest, then the prune
1580
+ * that leaves exactly what the manifest references, plus `manifest.json`.
291
1581
  *
292
- * @param view - A view over the header bytes.
293
- * @param offset - Where the number starts.
294
- * @returns The number.
295
- */
296
- function uint24Le(view, offset) {
297
- return view.getUint16(offset, true) | view.getUint8(offset + 2) << 16;
1582
+ * @param options - The pack folder and the manifest file.
1583
+ * @param options.out - The pack folder.
1584
+ * @param options.manifestFile - Where the packed manifest goes.
1585
+ * @param outputs - POSIX path inside the pack folder to the bytes it gets.
1586
+ * @param manifestText - The packed manifest as `emitManifest` wrote it.
1587
+ * @returns The bytes of every output.
1588
+ */
1589
+ async function writePack(options, outputs, manifestText) {
1590
+ const out = path.resolve(options.out);
1591
+ const manifestFile = path.resolve(options.manifestFile);
1592
+ const keep = /* @__PURE__ */ new Set([manifestFile, path.join(out, MANIFEST_NAME)]);
1593
+ let bytes = 0;
1594
+ for (const [relative, content] of outputs) {
1595
+ const file = path.join(out, ...relative.split("/"));
1596
+ keep.add(file);
1597
+ bytes += content.byteLength;
1598
+ await writeChanged(file, content);
1599
+ }
1600
+ await writeChanged(manifestFile, new TextEncoder().encode(manifestText));
1601
+ await mkdir(out, { recursive: true });
1602
+ await prune(out, keep);
1603
+ return bytes;
298
1604
  }
299
1605
  /**
300
- * Reads the size out of the IHDR chunk of a PNG.
1606
+ * Tells whether the pack folder holds the game sources: pruning it would delete them.
301
1607
  *
302
- * @param head - The first bytes of the file.
303
- * @returns The size.
1608
+ * @param out - The pack folder.
1609
+ * @param root - The game source root.
1610
+ * @returns True when the root is the pack folder or inside it.
304
1611
  */
305
- function readPng(head) {
306
- const view = viewOf(head);
307
- return {
308
- width: view.getUint32(16),
309
- height: view.getUint32(20)
310
- };
1612
+ function holdsSources(out, root) {
1613
+ const relative = path.relative(path.resolve(out), path.resolve(root));
1614
+ return relative === "" || !relative.startsWith("..") && !path.isAbsolute(relative);
311
1615
  }
312
1616
  /**
313
- * Reads the size out of the frame header of a WebP, whichever of the three chunks it carries.
1617
+ * Tells whether a packed file is a texture written as a file of its own, not cut out of a page.
314
1618
  *
315
- * @param head - The first bytes of the file.
316
- * @param file - Path of the file, for the messages.
317
- * @returns The size.
318
- * @throws {Error} When the chunk is unknown or its frame header is damaged.
1619
+ * @param file - A file of the packed manifest.
1620
+ * @returns `true` for a loose texture.
319
1621
  */
320
- function readWebp(head, file) {
321
- const view = viewOf(head);
322
- const chunk = ascii(head, 12, 4);
323
- if (chunk === "VP8 ") {
324
- if (!hasBytes(head, 23, VP8_SYNC)) throw problem$4(`the VP8 frame of "${file}" is damaged.`);
325
- return {
326
- width: view.getUint16(26, true) & SIZE_MASK,
327
- height: view.getUint16(28, true) & SIZE_MASK
328
- };
329
- }
330
- if (chunk === "VP8L") {
331
- if (head[20] !== VP8L_SIGNATURE) throw problem$4(`the VP8L frame of "${file}" is damaged.`);
332
- const bits = view.getUint32(21, true);
333
- return {
334
- width: (bits & SIZE_MASK) + 1,
335
- height: (bits >>> 14 & SIZE_MASK) + 1
336
- };
337
- }
338
- if (chunk === "VP8X") return {
339
- width: uint24Le(view, 24) + 1,
340
- height: uint24Le(view, 27) + 1
341
- };
342
- throw problem$4(`"${file}" carries the WebP chunk "${chunk}", which this version cannot read.`);
1622
+ function isLooseFile(file) {
1623
+ return file.kind === void 0 && file.path !== void 0;
343
1624
  }
344
1625
  /**
345
- * Reads the pixel size of an image out of its header bytes.
1626
+ * Counts the atlas pages of the packed bundles.
346
1627
  *
347
- * @param head - The first `HEADER_BYTES` bytes of the file.
348
- * @param file - Path of the file, for the messages.
349
- * @returns The size.
350
- * @throws {Error} When the bytes are neither a PNG nor a WebP the scanner reads.
351
- * @example
352
- * ```ts
353
- * readImageSize(await readHead("panel.png"), "features/ui/assets/panel.png");
354
- * // { width: 256, height: 128 }
355
- * ```
1628
+ * @param bundles - The packed bundles.
1629
+ * @returns How many pages they hold together.
356
1630
  */
357
- function readImageSize(head, file) {
358
- if (head.length >= 30) {
359
- if (hasBytes(head, 0, PNG_SIGNATURE) && ascii(head, 12, 4) === "IHDR") return readPng(head);
360
- if (ascii(head, 0, 4) === "RIFF" && ascii(head, 8, 4) === "WEBP") return readWebp(head, file);
361
- }
362
- throw problem$4(`"${file}" is not a PNG or a WebP file.`);
1631
+ function countPages(bundles) {
1632
+ return bundles.reduce((count, bundle) => count + (bundle.pages?.length ?? 0), 0);
363
1633
  }
364
1634
  /**
365
- * Estimates what one image costs on the GPU: four bytes per pixel, no mipmaps, rounded to three
366
- * decimals so two scans of the same tree write the same bytes.
1635
+ * Counts the loose textures of the packed bundles.
367
1636
  *
368
- * @param width - Width in pixels.
369
- * @param height - Height in pixels.
370
- * @returns The estimated texture memory in MB.
371
- * @example
372
- * ```ts
373
- * textureMb(256, 128); // 0.125
374
- * ```
1637
+ * @param bundles - The packed bundles.
1638
+ * @returns How many textures stayed files of their own.
375
1639
  */
376
- function textureMb(width, height) {
377
- return Math.round(width * height * BYTES_PER_PIXEL / BYTES_PER_MB * 1e3) / 1e3;
1640
+ function countLoose(bundles) {
1641
+ return bundles.reduce((count, bundle) => count + bundle.files.filter((file) => isLooseFile(file)).length, 0);
378
1642
  }
379
1643
  /**
380
- * What a file costs in memory when it is kept as it is: an audio file is held undecoded, so its
381
- * bytes are the estimate. Rounded to three decimals, like every other number of the manifest.
1644
+ * Packs the art of a game for production: per bundle, the textures of the `fx` group and of the
1645
+ * `main` group go onto WebP atlas pages (2048 px at most, 2 px padding and border, never trimmed
1646
+ * or rotated), a texture with a side above 512 px and a group of one stay loose files, fonts and
1647
+ * sounds are copied, every file name carries the hash of its bytes, and the packed manifest
1648
+ * (version 2) names it all. Nothing is written while a problem is open; afterwards the pack folder
1649
+ * holds exactly what the manifest references, plus `manifest.json`. Two runs on the same tree and
1650
+ * the same `sharp` write the same bytes.
382
1651
  *
383
- * @param bytes - Size of the file in bytes.
384
- * @returns The size in MB.
1652
+ * @param options - The sources, the scanned manifest, the pack folder, the manifest file, the cache.
1653
+ * @returns The packed manifest and what was written.
1654
+ * @throws {Error} One error that lists every problem, when `sharp` is missing, or when the pack
1655
+ * folder holds the sources.
385
1656
  * @example
386
1657
  * ```ts
387
- * bytesMb(8192); // 0.008
1658
+ * // After `scanAssets` read the features of the game. `cache: false` skips the cache.
1659
+ * const { manifest, pages, cacheHits } = await packAssets({
1660
+ * root: "src", manifest: scanned, cache: "node_modules/.cache/moku-game-pack",
1661
+ * out: "dist/assets", manifestFile: "dist/assets/manifest.json"
1662
+ * });
1663
+ * // manifest.version: 2; on a second run with the same art, cacheHits equals pages
388
1664
  * ```
389
1665
  */
390
- function bytesMb(bytes) {
391
- return Math.round(bytes / BYTES_PER_MB * 1e3) / 1e3;
1666
+ async function packAssets(options) {
1667
+ if (holdsSources(options.out, options.root)) throw new Error(`[game] assets: the pack folder "${options.out}" holds the game sources.\n Give the pack a folder of its own, like "dist/assets".`);
1668
+ const run = {
1669
+ root: options.root,
1670
+ cache: options.cache,
1671
+ encoder: await loadEncoder(),
1672
+ outputs: emptyMap(),
1673
+ problems: [],
1674
+ cacheHits: 0
1675
+ };
1676
+ const bundles = {};
1677
+ for (const name of Object.keys(options.manifest.bundles).toSorted((left, right) => left.localeCompare(right))) {
1678
+ const bundle = options.manifest.bundles[name];
1679
+ if (bundle !== void 0) bundles[name] = await packBundle(run, name, bundle);
1680
+ }
1681
+ const manifest = {
1682
+ version: 2,
1683
+ bundles
1684
+ };
1685
+ if (run.problems.length === 0) run.problems.push(...checkPacked(options.manifest, manifest));
1686
+ if (run.problems.length > 0) throw collected$2(run.problems);
1687
+ const bytes = await writePack(options, run.outputs, emitManifest(manifest));
1688
+ const all = Object.values(bundles);
1689
+ return {
1690
+ manifest,
1691
+ pages: countPages(all),
1692
+ loose: countLoose(all),
1693
+ bytes,
1694
+ cacheHits: run.cacheHits
1695
+ };
392
1696
  }
393
1697
  //#endregion
394
1698
  //#region src/plugins/assets/scan/keys.ts
@@ -1122,11 +2426,17 @@ async function readCurrent$1(file) {
1122
2426
  }
1123
2427
  }
1124
2428
  /**
1125
- * Writes the outputs whose text changed, and tells whether any of them differed.
2429
+ * Writes the outputs whose text changed, and tells whether any of them differed. The pack run of
2430
+ * the CLI writes the key module through it, so the keys get the same bytes in both modes.
1126
2431
  *
1127
2432
  * @param outputs - Where each output goes and what it should contain.
1128
2433
  * @param write - False for a check run: nothing is written.
1129
2434
  * @returns True when an output on disk differs from what the scan produced.
2435
+ * @example
2436
+ * ```ts
2437
+ * await applyOutputs([{ file: "src/generated/assets.ts", text: keysSource }], true);
2438
+ * // true when the file was missing or held other text, and it holds keysSource now
2439
+ * ```
1130
2440
  */
1131
2441
  async function applyOutputs(outputs, write) {
1132
2442
  let changed = false;
@@ -1180,11 +2490,12 @@ async function scanAssets(options) {
1180
2490
  //#endregion
1181
2491
  //#region src/plugins/assets/scan/cli.ts
1182
2492
  /**
1183
- * @file assets plugin, build time — the command line of the scanner. It is the only file here
1184
- * that touches the terminal: the scan, the emitters and the key rule are pure. Output goes
1185
- * through the branded console of `@moku-labs/common`, and `runCli` returns the exit code
1186
- * instead of taking it. The string compiler of i18n comes in as a parameter: i18n sits above
1187
- * assets, so the door `src/assets.ts` hands it over instead of this file importing it.
2493
+ * @file assets plugin, build time — the command line of the scanner and the packer. It is the
2494
+ * only file here that touches the terminal: the scan, the emitters, the key rule and the packer
2495
+ * report back instead of printing. Output goes through the branded console of
2496
+ * `@moku-labs/common`, and `runCli` returns the exit code instead of taking it. The string
2497
+ * compiler of i18n comes in as a parameter: i18n sits above assets, so the door `src/assets.ts`
2498
+ * hands it over instead of this file importing it.
1188
2499
  */
1189
2500
  const MANIFEST_FILE = "manifest.json";
1190
2501
  const KEYS_FILE = path.join("generated", "assets.ts");
@@ -1210,31 +2521,34 @@ function messageOf(failure) {
1210
2521
  * Tells whether an argument is one of the flags that take a path.
1211
2522
  *
1212
2523
  * @param flag - The argument.
1213
- * @returns True for `--root`, `--manifest` and `--keys`.
2524
+ * @returns True for `--root`, `--manifest`, `--keys` and `--pack`.
1214
2525
  */
1215
2526
  function isPathFlag(flag) {
1216
- return flag === "--root" || flag === "--manifest" || flag === "--keys";
2527
+ return flag === "--root" || flag === "--manifest" || flag === "--keys" || flag === "--pack";
1217
2528
  }
1218
2529
  /**
1219
- * Reads the flags. `--manifest` and `--keys` default to `manifest.json` and `generated/assets.ts`
1220
- * inside the root.
2530
+ * Reads the flags. `--keys` defaults to `generated/assets.ts` inside the root; `--manifest` to
2531
+ * `manifest.json` inside the root, or inside the pack folder with `--pack`.
1221
2532
  *
1222
2533
  * @param argv - The arguments after the script name.
1223
- * @returns The resolved paths and whether this is a check run.
2534
+ * @returns The resolved paths, the pack folder and the two switches.
1224
2535
  * @throws {Error} When an option is unknown or misses its value.
1225
2536
  */
1226
2537
  function parseArgv(argv) {
1227
2538
  const given = {
1228
2539
  "--root": void 0,
1229
2540
  "--manifest": void 0,
1230
- "--keys": void 0
2541
+ "--keys": void 0,
2542
+ "--pack": void 0
1231
2543
  };
1232
2544
  let check = false;
2545
+ let cache = true;
1233
2546
  let index = 0;
1234
2547
  while (index < argv.length) {
1235
2548
  const flag = argv[index];
1236
- if (flag === "--check") {
1237
- check = true;
2549
+ if (flag === "--check" || flag === "--no-cache") {
2550
+ if (flag === "--check") check = true;
2551
+ else cache = false;
1238
2552
  index += 1;
1239
2553
  continue;
1240
2554
  }
@@ -1245,11 +2559,14 @@ function parseArgv(argv) {
1245
2559
  index += 2;
1246
2560
  }
1247
2561
  const root = path.resolve(given["--root"] ?? ".");
2562
+ const pack = given["--pack"] === void 0 ? void 0 : path.resolve(given["--pack"]);
1248
2563
  return {
1249
2564
  root,
1250
- manifest: path.resolve(given["--manifest"] ?? path.join(root, MANIFEST_FILE)),
2565
+ manifest: path.resolve(given["--manifest"] ?? path.join(pack ?? root, MANIFEST_FILE)),
1251
2566
  keys: path.resolve(given["--keys"] ?? path.join(root, KEYS_FILE)),
1252
- check
2567
+ check,
2568
+ pack,
2569
+ cache
1253
2570
  };
1254
2571
  }
1255
2572
  /**
@@ -1301,23 +2618,101 @@ function stringsSummary(report) {
1301
2618
  return `${report.keys.length} strings in ${report.locales.length} locales (${report.locales.join(", ")}).`;
1302
2619
  }
1303
2620
  /**
2621
+ * One line about a packed bundle.
2622
+ *
2623
+ * @param name - Name of the bundle.
2624
+ * @param bundle - The packed bundle.
2625
+ * @returns The line for the console.
2626
+ * @example
2627
+ * ```ts
2628
+ * bundleLine("orders", { feature: "orders", tier: "scene", mb: 0.253, files: [] });
2629
+ * // "packed orders: 0 pages, 0 loose, 0 fonts, 0 sounds, 0.253 MB"
2630
+ * ```
2631
+ */
2632
+ function bundleLine(name, bundle) {
2633
+ const pages = bundle.pages?.length ?? 0;
2634
+ const loose = bundle.files.filter((file) => file.kind === void 0 && file.path !== void 0);
2635
+ const fonts = bundle.files.filter((file) => file.kind === "font").length;
2636
+ const sounds = bundle.files.filter((file) => file.kind === "audio").length;
2637
+ return `packed ${name}: ${plural(pages, "page")}, ${loose.length} loose, ${plural(fonts, "font")}, ${plural(sounds, "sound")}, ${bundle.mb} MB`;
2638
+ }
2639
+ /**
2640
+ * The closing line of a pack: where the manifest went, what the folder holds and what the cache
2641
+ * gave.
2642
+ *
2643
+ * @param file - Path of the packed manifest.
2644
+ * @param result - What the pack wrote.
2645
+ * @param cache - False after `--no-cache`.
2646
+ * @returns The line for the console.
2647
+ */
2648
+ function packSummary(file, result, cache) {
2649
+ const files = Object.values(result.manifest.bundles).flatMap((bundle) => bundle.files);
2650
+ const fonts = files.filter((entry) => entry.kind === "font").length;
2651
+ const sounds = files.filter((entry) => entry.kind === "audio").length;
2652
+ const hits = cache ? `${result.cacheHits} of ${plural(result.pages, "page")}` : "off";
2653
+ return `wrote "${file}": ${plural(result.pages, "page")}, ${plural(result.loose, "loose file")}, ${plural(fonts, "font")}, ${plural(sounds, "sound")}, ${Math.round(result.bytes / 1024)} KB. cache: ${hits}.`;
2654
+ }
2655
+ /**
2656
+ * The `--pack` run: the same scan and key list as a dev run, the strings compile, then the packer.
2657
+ * The keys are written as a dev run writes them; the dev manifest is not touched, the packed one
2658
+ * goes to `--manifest`.
2659
+ *
2660
+ * @param options - The parsed flags, with a pack folder.
2661
+ * @param pack - The pack folder.
2662
+ * @param compile - The strings compiler.
2663
+ * @param ui - Where the lines go.
2664
+ * @returns The exit code.
2665
+ * @throws {Error} With `--check`, and when the scan, the compile or the pack fails.
2666
+ */
2667
+ async function runPack(options, pack, compile, ui) {
2668
+ if (options.check) throw problem$1("\"--pack\" writes files; drop \"--check\".");
2669
+ const scan = await scanAssets({
2670
+ root: options.root,
2671
+ manifest: options.manifest,
2672
+ keys: options.keys,
2673
+ write: false
2674
+ });
2675
+ await applyOutputs([{
2676
+ file: options.keys,
2677
+ text: scan.keysSource
2678
+ }], true);
2679
+ const strings = await compile(options.root, path.dirname(options.keys), { check: false });
2680
+ for (const note of [...scan.notes, ...strings.notes]) ui.warn(note);
2681
+ const result = await packAssets({
2682
+ root: options.root,
2683
+ manifest: scan.manifest,
2684
+ out: pack,
2685
+ manifestFile: options.manifest,
2686
+ cache: options.cache ? path.join(process.cwd(), CACHE_FOLDER) : false
2687
+ });
2688
+ for (const [name, bundle] of Object.entries(result.manifest.bundles)) ui.info(bundleLine(name, bundle));
2689
+ ui.info(packSummary(options.manifest, result, options.cache));
2690
+ return 0;
2691
+ }
2692
+ /**
1304
2693
  * Runs the asset key scanner: walk the features, write the manifest and the key module, then
1305
- * compile the feature strings next to the key module, or check that all of it is current.
2694
+ * compile the feature strings next to the key module, or check that all of it is current. With
2695
+ * `--pack <dir>` it packs the art for production on the same scan (`--no-cache` for a cold run).
1306
2696
  * Nothing here calls `process.exit`; the caller does.
1307
2697
  *
1308
2698
  * @param argv - The arguments after the script name.
1309
2699
  * @param compile - The strings compiler, `compileStrings` of i18n.
1310
2700
  * @param ui - Where the lines go. The branded console by default.
1311
- * @returns The exit code: `1` when the scan or the compile failed or `--check` found a difference, else `0`.
2701
+ * @returns The exit code: `1` when the scan, the compile or the pack failed or `--check` found a
2702
+ * difference, else `0`.
1312
2703
  * @example
1313
2704
  * ```ts
1314
2705
  * await runCli(["--root", "src", "--manifest", "public/assets/manifest.json", "--check"], compileStrings);
1315
2706
  * // 1 when public/assets/manifest.json is older than the files in src/features
2707
+ *
2708
+ * await runCli(["--root", "src", "--keys", "src/generated/assets.ts", "--pack", "dist/assets"], compileStrings);
2709
+ * // 0, and dist/assets holds the pages, the loose files and the packed manifest.json
1316
2710
  * ```
1317
2711
  */
1318
2712
  async function runCli(argv, compile, ui = createBrandConsole()) {
1319
2713
  try {
1320
2714
  const options = parseArgv(argv);
2715
+ if (options.pack !== void 0) return await runPack(options, options.pack, compile, ui);
1321
2716
  const { manifest, changed, notes } = await scanAssets({
1322
2717
  root: options.root,
1323
2718
  manifest: options.manifest,
@@ -4719,11 +6114,12 @@ async function checkStrings(root, out) {
4719
6114
  //#region src/assets.ts
4720
6115
  /**
4721
6116
  * @file The node-side door of the engine (subpath `./assets`, node and bun only): asset and file
4722
- * work that has no place in a browser bundle. It holds the asset key scanner and the string
4723
- * compiler; a game runs both as `bun run assets:keys`, the editor imports the same functions later.
6117
+ * work that has no place in a browser bundle. It holds the asset key scanner, the production
6118
+ * packer and the string compiler; a game runs them as `bun run assets:keys` and
6119
+ * `bun run assets:pack` (`--pack <dir>`), the editor imports the same functions later.
4724
6120
  * Re-exports only: the code lives in `plugins/assets/scan/` and `plugins/i18n/compile/`, and this is
4725
6121
  * the one door to them in `src/`.
4726
6122
  */
4727
6123
  if (import.meta.main) process.exitCode = await runCli(process.argv.slice(2), compileStrings);
4728
6124
  //#endregion
4729
- export { checkStrings, compileStrings, emitKeys, emitManifest, runCli, scanAssets };
6125
+ export { checkStrings, compileStrings, emitKeys, emitManifest, packAssets, runCli, scanAssets };