@moku-labs/game 0.13.1 → 0.13.2

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 (37) hide show
  1. package/dist/app/page.d.mts +1 -1
  2. package/dist/app/system.d.mts +3 -3
  3. package/dist/app.d.mts +2 -2
  4. package/dist/app.mjs +1 -1
  5. package/dist/assets.d.mts +1 -1
  6. package/dist/assets.mjs +1 -1
  7. package/dist/cli.d.mts +7 -5
  8. package/dist/cli.mjs +74 -36
  9. package/dist/{commands-DvFr8Uwp.d.mts → commands-BrgeMP5y.d.mts} +5 -5
  10. package/dist/control.d.mts +4 -4
  11. package/dist/{define-CCc0ozKe.d.mts → define-ZmWbWxon.d.mts} +2 -2
  12. package/dist/{headless-G4nuZ-xz.d.mts → headless-Dgxvp6Ym.d.mts} +1 -1
  13. package/dist/hot.d.mts +3 -3
  14. package/dist/hot.mjs +11 -9
  15. package/dist/{index-rnOYAriI.d.mts → index-CdbE_jGs.d.mts} +12 -4
  16. package/dist/index.d.mts +5 -5
  17. package/dist/index.mjs +1 -1
  18. package/dist/inspect.d.mts +7 -7
  19. package/dist/jsx-dev-runtime.d.mts +1 -1
  20. package/dist/jsx-runtime.d.mts +1 -1
  21. package/dist/{memory-DwDhfFWK.d.mts → memory-DJ2sPExv.d.mts} +1 -1
  22. package/dist/project.mjs +1 -1
  23. package/dist/{runtime-B276Gg0s.d.mts → runtime-6qakboTm.d.mts} +1 -1
  24. package/dist/{src-SmT-XxVf.mjs → src-CltFFKtc.mjs} +955 -108
  25. package/dist/testing.d.mts +4 -4
  26. package/dist/testing.mjs +1 -1
  27. package/dist/{types-D_2G6Q68.d.mts → types-B3T2X6To.d.mts} +8 -4
  28. package/dist/{types-ZQLYXo5X.d.mts → types-C6y8EfEB.d.mts} +54 -5
  29. package/dist/{types-BrTpz9oL.d.mts → types-CmuwAjkV.d.mts} +19 -6
  30. package/dist/{types-DAdwEJMp.d.mts → types-D57SntUt.d.mts} +2 -2
  31. package/dist/{types-DLghaGEy.d.mts → types-DGIkfZB7.d.mts} +4 -4
  32. package/dist/{types-Bcdo5-2w.d.mts → types-zRGJdFGX.d.mts} +7 -3
  33. package/dist/visual.d.mts +6 -6
  34. package/llms.txt +6 -4
  35. package/package.json +4 -2
  36. package/dist/{assets-DYiKxPBX.mjs → assets-BrKSg1O6.mjs} +1 -1
  37. package/dist/{watch-CB8IWDBi.mjs → watch-CWI_on4T.mjs} +1 -1
@@ -3722,7 +3722,7 @@ function restorePosition(ctx, _modules, bookmark) {
3722
3722
  * @param view - The last view.
3723
3723
  * @returns True while nothing the state shows moved.
3724
3724
  */
3725
- function isCurrent(ctx, view) {
3725
+ function isCurrent$1(ctx, view) {
3726
3726
  const runner = ctx.state.runner;
3727
3727
  return view.running === (runner.running !== void 0) && view.depth === runner.stack.length && view.top === runner.stack.at(-1) && view.open === ctx.state.gate.open && view.mode === ctx.state.fx.mode && view.journalIndex === runner.journalIndex;
3728
3728
  }
@@ -3763,7 +3763,7 @@ function viewOf(ctx) {
3763
3763
  function readState(ctx) {
3764
3764
  const runner = ctx.state.runner;
3765
3765
  const view = runner.view;
3766
- if (view !== void 0 && isCurrent(ctx, view)) return view.state;
3766
+ if (view !== void 0 && isCurrent$1(ctx, view)) return view.state;
3767
3767
  const next = viewOf(ctx);
3768
3768
  runner.view = next;
3769
3769
  return next.state;
@@ -8269,7 +8269,7 @@ function stopHost(state) {
8269
8269
  //#endregion
8270
8270
  //#region src/plugins/renderer/host/readback.ts
8271
8271
  /** Bytes of one pixel: the engine loads PNG and WebP only, so every source is 8-bit RGBA. */
8272
- const BYTES_PER_PIXEL = 4;
8272
+ const BYTES_PER_PIXEL$1 = 4;
8273
8273
  /**
8274
8274
  * Estimates the GPU bytes of one texture source: 4 bytes per pixel, every mip level, a level never
8275
8275
  * smaller than one pixel.
@@ -8293,7 +8293,7 @@ function sourceBytes(source) {
8293
8293
  width = Math.max(1, Math.floor(width / 2));
8294
8294
  height = Math.max(1, Math.floor(height / 2));
8295
8295
  }
8296
- return pixels * BYTES_PER_PIXEL;
8296
+ return pixels * BYTES_PER_PIXEL$1;
8297
8297
  }
8298
8298
  /**
8299
8299
  * Counts the texture sources the GPU holds and sums their estimated bytes. Pixi's
@@ -8561,7 +8561,7 @@ function resetWindow(state) {
8561
8561
  //#endregion
8562
8562
  //#region src/plugins/renderer/monitor/api.ts
8563
8563
  /** Bytes of one MiB. */
8564
- const BYTES_PER_MB = 1048576;
8564
+ const BYTES_PER_MB$1 = 1048576;
8565
8565
  /**
8566
8566
  * Rounds a number to a fixed count of decimals, so a counter reads well in a panel.
8567
8567
  *
@@ -8599,7 +8599,7 @@ function createMonitorApi(ctx, deps) {
8599
8599
  fps: state.lastStart !== void 0 && clock.now() - state.lastStart <= 1e3 ? roundTo(state.fps, 1) : 0,
8600
8600
  frameMs: roundTo(state.frameMs, 2),
8601
8601
  textures: usage.count,
8602
- textureMb: roundTo(usage.bytes / BYTES_PER_MB, 2),
8602
+ textureMb: roundTo(usage.bytes / BYTES_PER_MB$1, 2),
8603
8603
  views: counts.views,
8604
8604
  pooled: counts.pooled,
8605
8605
  renderPasses: deps.sync.renderPasses()
@@ -8937,7 +8937,7 @@ function parseFont(pixi, key, fnt) {
8937
8937
  * @param texture - The page texture `assets` uploaded.
8938
8938
  * @throws {Error} When the renderer does not draw.
8939
8939
  */
8940
- function installFont(sctx, key, fnt, texture) {
8940
+ function installFont$1(sctx, key, fnt, texture) {
8941
8941
  const pixi = sctx.deps.host.pixi();
8942
8942
  if (pixi === void 0) throw new Error("[game] Fonts need a renderer.\n Call fonts.install after renderer.host.ready().");
8943
8943
  const state = sctx.ctx.state.sync;
@@ -10207,7 +10207,9 @@ function clearFrames(state) {
10207
10207
  }
10208
10208
  /**
10209
10209
  * Frees a texture, its source and the crops cut from it. A slice keeps the source: it belongs to
10210
- * the page, which frees it. A texture destroyed twice is a no-op.
10210
+ * the page, which frees it. A source that goes lets go of its `change` listeners first, the ones
10211
+ * of its style too, so no bind group Pixi still keeps is told. A texture destroyed twice is a
10212
+ * no-op.
10211
10213
  *
10212
10214
  * @param state - The sync branch of the plugin state.
10213
10215
  * @param texture - The texture to free.
@@ -10215,7 +10217,13 @@ function clearFrames(state) {
10215
10217
  function destroyTexture(state, texture) {
10216
10218
  releaseFrames$1(state, texture);
10217
10219
  if (texture.destroyed) return;
10218
- texture.destroy(!state.slices.has(texture));
10220
+ if (state.slices.has(texture)) {
10221
+ texture.destroy(false);
10222
+ return;
10223
+ }
10224
+ texture.source.style.removeAllListeners("change");
10225
+ texture.source.removeAllListeners("change");
10226
+ texture.destroy(true);
10219
10227
  }
10220
10228
  //#endregion
10221
10229
  //#region src/plugins/renderer/sync/views.ts
@@ -11839,7 +11847,7 @@ function createSyncApi(ctx, deps) {
11839
11847
  removed: markRemoved
11840
11848
  }) },
11841
11849
  fonts: {
11842
- install: (key, fnt, texture) => installFont(sctx, key, fnt, texture),
11850
+ install: (key, fnt, texture) => installFont$1(sctx, key, fnt, texture),
11843
11851
  installed: (key) => isFontInstalled(state, key)
11844
11852
  },
11845
11853
  debug: {
@@ -14629,6 +14637,12 @@ const TIERS = [
14629
14637
  "feature",
14630
14638
  "lazy"
14631
14639
  ];
14640
+ /** The bytes of one megabyte. */
14641
+ const BYTES_PER_MB = 1048576;
14642
+ /** The bytes one pixel takes in GPU memory: RGBA, no mipmaps. */
14643
+ const BYTES_PER_PIXEL = 4;
14644
+ /** Megabytes are kept to three decimals: a value is rounded to a whole number of these parts. */
14645
+ const PARTS_PER_MB = 1e3;
14632
14646
  /**
14633
14647
  * Creates an empty map. It lives in its own non-exported function because lint rule L5 refuses a
14634
14648
  * collection built inside an exported declaration.
@@ -14923,6 +14937,38 @@ function fileUrl(base, path) {
14923
14937
  return `${base}${path.startsWith("/") ? path.slice(1) : path}`;
14924
14938
  }
14925
14939
  /**
14940
+ * Works out what an image costs in texture memory: `width × height × 4` bytes as megabytes,
14941
+ * rounded to 3 decimals. It is the number the scanner writes for a texture and for a font page;
14942
+ * the dev hot swap writes it again for a file that came back with another size.
14943
+ *
14944
+ * @param width - Width of the image in pixels.
14945
+ * @param height - Height of the image in pixels.
14946
+ * @returns The megabytes.
14947
+ * @example
14948
+ * ```ts
14949
+ * textureMb(256, 128); // 0.125
14950
+ * ```
14951
+ */
14952
+ function textureMb(width, height) {
14953
+ return Math.round(width * height * BYTES_PER_PIXEL / BYTES_PER_MB * PARTS_PER_MB) / PARTS_PER_MB;
14954
+ }
14955
+ /**
14956
+ * Adds up megabytes, rounded to 3 decimals: the pages of a font give the font, the files and the
14957
+ * atlas pages of a bundle give the bundle.
14958
+ *
14959
+ * @param parts - The records to add up.
14960
+ * @returns The sum.
14961
+ * @example
14962
+ * ```ts
14963
+ * sumMb([{ mb: 0.125 }, { mb: 0.063 }, { mb: 0.021 }]); // 0.209
14964
+ * ```
14965
+ */
14966
+ function sumMb(parts) {
14967
+ let total = 0;
14968
+ for (const part of parts) total += part.mb;
14969
+ return Math.round(total * PARTS_PER_MB) / PARTS_PER_MB;
14970
+ }
14971
+ /**
14926
14972
  * Turns the nine-slice metadata of a file into the options `renderer.sync.textures.create` takes.
14927
14973
  *
14928
14974
  * @param file - One file of a bundle.
@@ -14947,7 +14993,8 @@ function nineOf(file) {
14947
14993
  //#region src/plugins/assets/tiers.ts
14948
14994
  /**
14949
14995
  * @file assets plugin — loading one bundle and the boot sequence. One running load per bundle,
14950
- * every caller a waiter: the last one to leave aborts the fetches.
14996
+ * every caller a waiter: the last one to leave aborts the fetches. The loaders of one image, one
14997
+ * font and one sound are exported: the dev hot swap of `swap.ts` reads a changed file through them.
14951
14998
  */
14952
14999
  /** The extension of an AAC sound in an MP4 container; every other sound is an MP3. */
14953
15000
  const M4A = /\.m4a$/i;
@@ -15102,10 +15149,13 @@ function touch(state, record) {
15102
15149
  record.lastUsed = state.useCounter;
15103
15150
  }
15104
15151
  /**
15105
- * Creates the four empty maps a bundle's assets live in. It is its own function because lint
15106
- * rule L5 refuses a collection built inside an exported declaration.
15152
+ * Creates the four empty maps a bundle's assets live in: the home of a running load.
15107
15153
  *
15108
15154
  * @returns Empty maps for textures, fonts, audio and atlas pages.
15155
+ * @example
15156
+ * ```ts
15157
+ * emptyAssets().textures.size; // 0
15158
+ * ```
15109
15159
  */
15110
15160
  function emptyAssets() {
15111
15161
  return {
@@ -15202,6 +15252,22 @@ async function loadFont(run, file) {
15202
15252
  };
15203
15253
  }
15204
15254
  /**
15255
+ * Loads one sound: the bytes exactly as they were fetched, never decoded here, and the type of
15256
+ * their container.
15257
+ *
15258
+ * @param run - The running load.
15259
+ * @param file - The audio file of the manifest.
15260
+ * @returns The bytes and their MIME type.
15261
+ * @throws {Error} When the response is not ok.
15262
+ */
15263
+ async function loadSound(run, file) {
15264
+ const soundPath = pathOf(run.bundle, file);
15265
+ return {
15266
+ bytes: await (await fetchFile(run, soundPath)).arrayBuffer(),
15267
+ mime: mimeOf(soundPath)
15268
+ };
15269
+ }
15270
+ /**
15205
15271
  * Starts the one load of every atlas page of a bundle. A page lands in the `pages` map of the
15206
15272
  * running load, so a failure later frees it with the rest. Nobody may wait for a page, so its
15207
15273
  * failure is marked as handled here; the files on it still reject with it.
@@ -15269,12 +15335,7 @@ async function loadFile(run, file) {
15269
15335
  return;
15270
15336
  }
15271
15337
  if (kind === "audio") {
15272
- const soundPath = pathOf(run.bundle, file);
15273
- const response = await fetchFile(run, soundPath);
15274
- assets.audio.set(file.key, {
15275
- bytes: await response.arrayBuffer(),
15276
- mime: mimeOf(soundPath)
15277
- });
15338
+ assets.audio.set(file.key, await loadSound(run, file));
15278
15339
  return;
15279
15340
  }
15280
15341
  const texture = file.atlas === void 0 ? await loadImage(run, pathOf(run.bundle, file), nineOf(file)) : await sliceFile(run, file, file.atlas);
@@ -16326,6 +16387,8 @@ function releaseAll(state) {
16326
16387
  state.warned.clear();
16327
16388
  state.current = void 0;
16328
16389
  state.sceneBundle = void 0;
16390
+ state.stamps = void 0;
16391
+ state.swapping = void 0;
16329
16392
  state.io = void 0;
16330
16393
  }
16331
16394
  //#endregion
@@ -16472,36 +16535,551 @@ function createAssetsApi(ctx) {
16472
16535
  };
16473
16536
  }
16474
16537
  //#endregion
16538
+ //#region src/plugins/assets/paths.ts
16539
+ /** The name of the folder of a feature or of a layer the scanner reads. */
16540
+ const ASSETS = "assets";
16541
+ /** That folder inside a path. */
16542
+ const ASSETS_FOLDER = `/${ASSETS}/`;
16543
+ /**
16544
+ * Reads the folder the scanner found a manifest path in. The scanner reads the `assets/` folder
16545
+ * of a feature or of a layer, so the path is cut after it; a path with no such folder, as in a
16546
+ * manifest written by hand, gives the folder of the file.
16547
+ *
16548
+ * @param path - A path of the manifest.
16549
+ * @returns The folder, ending in a slash, or `""` for a file at the root.
16550
+ * @example
16551
+ * ```ts
16552
+ * folderOf("features/ui/assets/fx/leaf.webp"); // "features/ui/assets/"
16553
+ * ```
16554
+ */
16555
+ function folderOf(path) {
16556
+ const scanned = path.indexOf(ASSETS_FOLDER);
16557
+ if (scanned !== -1) return path.slice(0, scanned + ASSETS_FOLDER.length);
16558
+ return path.slice(0, path.lastIndexOf("/") + 1);
16559
+ }
16560
+ /**
16561
+ * Tells whether the scanner would read a path, by its shape alone. It mirrors `scanOwner` of
16562
+ * `scan/scan.ts`, which runtime code does not import: the scanner reads `<layer>/assets/` and
16563
+ * `<features>/<feature>/assets/` under the game root, so `assets` is the second or the third
16564
+ * segment and a file follows it. The page knows neither the layers nor the name of the features
16565
+ * folder, so any folder counts as one. This is what finds the first file of an `assets/` folder
16566
+ * the booted manifest names nowhere.
16567
+ *
16568
+ * @param path - A path of the game, root-relative with `/`.
16569
+ * @returns True for a path under the `assets/` of a layer or of a feature.
16570
+ * @example
16571
+ * ```ts
16572
+ * isScannerPath("features/shop/assets/coin.png"); // true
16573
+ * ```
16574
+ */
16575
+ function isScannerPath(path) {
16576
+ const segments = path.split("/");
16577
+ const inLayer = segments[1] === ASSETS && segments.length > 2;
16578
+ const inFeature = segments[2] === ASSETS && segments.length > 3;
16579
+ return inLayer || inFeature;
16580
+ }
16581
+ /**
16582
+ * Builds the path index of a manifest: which file and bundle every path leads to, and the folders
16583
+ * the scanner read. A path under one of those folders that leads nowhere is a new asset.
16584
+ *
16585
+ * @param manifest - The manifest the page booted with.
16586
+ * @returns The index.
16587
+ */
16588
+ function indexPaths(manifest) {
16589
+ const owners = /* @__PURE__ */ new Map();
16590
+ const folders = /* @__PURE__ */ new Set();
16591
+ for (const [bundle, entry] of Object.entries(manifest.bundles)) for (const file of entry.files) {
16592
+ if (file.path === void 0) continue;
16593
+ const owner = {
16594
+ bundle,
16595
+ entry,
16596
+ file,
16597
+ path: file.path
16598
+ };
16599
+ for (const path of [file.path, ...(file.pages ?? []).map((page) => page.path)]) {
16600
+ owners.set(path, owner);
16601
+ folders.add(folderOf(path));
16602
+ }
16603
+ }
16604
+ folders.delete("");
16605
+ return {
16606
+ manifest,
16607
+ owners,
16608
+ folders: [...folders]
16609
+ };
16610
+ }
16611
+ /**
16612
+ * Tells why a stamp cannot be swapped in place: the set of files is not the one of the manifest
16613
+ * the page booted with. A manifest path the watch no longer stamps was removed or renamed, or its
16614
+ * nine-slice tag changed. A changed path the manifest does not know is a new asset when it lies
16615
+ * under a folder the manifest names, or where the scanner reads: the first file of an `assets/`
16616
+ * folder that had none counts too. The watch stamps every image of the game tree, so any other
16617
+ * unknown path (a favicon, an image of a feature outside its `assets/`) refuses nothing.
16618
+ *
16619
+ * @param index - The path index of the manifest.
16620
+ * @param stamps - The stamp that arrived.
16621
+ * @param changed - The paths whose bytes changed.
16622
+ * @returns The reason, or `undefined` when only bytes changed.
16623
+ */
16624
+ function refusalOf$1(index, stamps, changed) {
16625
+ for (const path of index.owners.keys()) if (!Object.hasOwn(stamps.files, path)) return `"${path}" left the game`;
16626
+ for (const path of changed) {
16627
+ if (index.owners.has(path)) continue;
16628
+ if (isScannerPath(path) || index.folders.some((folder) => path.startsWith(folder))) return `"${path}" is not in the manifest`;
16629
+ }
16630
+ }
16631
+ /**
16632
+ * Groups the changed files by their bundle. A font whose `.fnt` and pages changed in one save is
16633
+ * listed once: a font is replaced as a whole. A path the manifest does not know is left out.
16634
+ *
16635
+ * @param index - The path index of the manifest.
16636
+ * @param changed - The paths whose bytes changed.
16637
+ * @returns One batch per bundle, in the order the paths name them.
16638
+ */
16639
+ function batchesOf(index, changed) {
16640
+ const batches = /* @__PURE__ */ new Map();
16641
+ for (const path of changed) {
16642
+ const owner = index.owners.get(path);
16643
+ if (owner === void 0) continue;
16644
+ const { bundle, entry } = owner;
16645
+ const batch = batches.get(bundle) ?? {
16646
+ bundle,
16647
+ entry,
16648
+ owners: []
16649
+ };
16650
+ if (!batch.owners.includes(owner)) batch.owners.push(owner);
16651
+ batches.set(bundle, batch);
16652
+ }
16653
+ return [...batches.values()];
16654
+ }
16655
+ //#endregion
16656
+ //#region src/plugins/assets/stamp.ts
16657
+ /** The module the keys watch writes, as the hot footer reports its path. */
16658
+ const STAMP_FILE = "/.moku/assets-stamp.ts";
16659
+ /**
16660
+ * Tells whether a value maps paths to stamps.
16661
+ *
16662
+ * @param value - The `files` member of a stamp module.
16663
+ * @returns True for a plain object whose values are all strings.
16664
+ * @example
16665
+ * ```ts
16666
+ * isStampMap({ "features/ui/assets/fx-spark.webp": "2554:1791536552578" }); // true
16667
+ * ```
16668
+ */
16669
+ function isStampMap(value) {
16670
+ return typeof value === "object" && value !== null && !Array.isArray(value) && Object.values(value).every((stamp) => typeof stamp === "string");
16671
+ }
16672
+ /**
16673
+ * Tells whether a value is a list of paths.
16674
+ *
16675
+ * @param value - The `changed` member of a stamp module.
16676
+ * @returns True for an array of strings.
16677
+ * @example
16678
+ * ```ts
16679
+ * isPathList(["features/ui/assets/fx-spark.webp"]); // true
16680
+ * ```
16681
+ */
16682
+ function isPathList(value) {
16683
+ return Array.isArray(value) && value.every((path) => typeof path === "string");
16684
+ }
16685
+ /**
16686
+ * Tells the stamp of the keys watch from any other default export.
16687
+ *
16688
+ * @param value - The default export of the stamp module.
16689
+ * @returns True when it carries a `files` map of strings and a `changed` list of strings.
16690
+ * @example
16691
+ * ```ts
16692
+ * isAssetStamps({ files: { "features/ui/assets/popup.mp3": "8120:1791536000000" }, changed: [] }); // true
16693
+ * ```
16694
+ */
16695
+ function isAssetStamps(value) {
16696
+ if (typeof value !== "object" || value === null) return false;
16697
+ if (!("files" in value) || !("changed" in value)) return false;
16698
+ return isStampMap(value.files) && isPathList(value.changed);
16699
+ }
16700
+ /**
16701
+ * Reads the stamp out of a hot swap, when the saved module is the stamp of the keys watch.
16702
+ *
16703
+ * @param payload - The saved file and its new exports.
16704
+ * @returns The stamp, or `undefined` for any other file and for a module that carries none.
16705
+ * @example
16706
+ * ```ts
16707
+ * stampsOf({ file: "/game/features/hud/view.tsx", module: {} }); // undefined
16708
+ * ```
16709
+ */
16710
+ function stampsOf(payload) {
16711
+ if (!payload.file.replaceAll("\\", "/").endsWith(STAMP_FILE)) return void 0;
16712
+ const stamps = payload.module.default;
16713
+ return isAssetStamps(stamps) ? stamps : void 0;
16714
+ }
16715
+ /**
16716
+ * Works out which files a stamp changed, and keeps its map for the next one. Against the map
16717
+ * applied last it is every path whose stamp differs. The first update after boot has no map to
16718
+ * compare with, so the list of the keys watch stands.
16719
+ *
16720
+ * @param state - The plugin state.
16721
+ * @param stamps - The stamp that arrived.
16722
+ * @returns The changed paths.
16723
+ */
16724
+ function changedPaths(state, stamps) {
16725
+ const applied = state.stamps;
16726
+ state.stamps = stamps.files;
16727
+ if (applied === void 0) return stamps.changed;
16728
+ return Object.keys(stamps.files).filter((path) => stamps.files[path] !== applied[path]);
16729
+ }
16730
+ //#endregion
16731
+ //#region src/plugins/assets/swap.ts
16732
+ /**
16733
+ * @file assets plugin — the dev hot swap of asset files. When the stamp of the keys watch changes
16734
+ * (`stamp.ts`), the changed files of the loaded bundles (`paths.ts`) are replaced here: a new
16735
+ * texture, font or sound per key, the old textures destroyed after the new ones are stored, then
16736
+ * `assets:replaced`. A file that fails is logged and its bundle keeps what it had. A changed set
16737
+ * of files reloads the page instead: a hook cannot refuse by throwing, the core bus catches what
16738
+ * a hook throws.
16739
+ */
16740
+ /**
16741
+ * Reloads the page. This is how a swap refuses, and what it does when a replace breaks after its
16742
+ * files arrived: the page boots again with the new manifest and the new files. Headless and in a
16743
+ * test there is no page, so nothing happens.
16744
+ */
16745
+ function reloadPage$1() {
16746
+ globalThis.location?.reload();
16747
+ }
16748
+ /**
16749
+ * Says why a file could not be replaced.
16750
+ *
16751
+ * @param error - What the load threw.
16752
+ * @returns The message of an error, or the value as a string.
16753
+ * @example
16754
+ * ```ts
16755
+ * reasonOf(new Error("The source image could not be decoded.")); // "The source image could not be decoded."
16756
+ * ```
16757
+ */
16758
+ function reasonOf$1(error) {
16759
+ return error instanceof Error ? error.message : String(error);
16760
+ }
16761
+ /**
16762
+ * Copies the io of a swap so every texture it uploads is remembered with the pixel size of its
16763
+ * image. The manifest needs that size, and the loaders of `tiers.ts` answer the texture alone;
16764
+ * the list is also what a swap that does not land gives back. The io is a plain record of
16765
+ * functions, so the copy carries the other four members as they are.
16766
+ *
16767
+ * @param io - The I/O seam of the plugin.
16768
+ * @param sizes - Filled with every texture this io creates.
16769
+ * @returns The io with a measuring `createTexture`.
16770
+ */
16771
+ function measuringIo(io, sizes) {
16772
+ return {
16773
+ ...io,
16774
+ createTexture: (image, options) => {
16775
+ const texture = io.createTexture(image, options);
16776
+ sizes.set(texture, {
16777
+ width: image.width,
16778
+ height: image.height
16779
+ });
16780
+ return texture;
16781
+ }
16782
+ };
16783
+ }
16784
+ /**
16785
+ * Builds the running load the file loaders of `tiers.ts` take, for the files of one bundle.
16786
+ *
16787
+ * @param swap - The running swap.
16788
+ * @param bundle - Name of the bundle.
16789
+ * @param sizes - Filled with every texture the load creates.
16790
+ * @returns The running load.
16791
+ */
16792
+ function loadingOf(swap, bundle, sizes) {
16793
+ const { config } = swap.ctx;
16794
+ return {
16795
+ io: measuringIo(swap.io, sizes),
16796
+ bundle,
16797
+ base: resolveBaseUrl(config.baseUrl, config.manifest),
16798
+ signal: new AbortController().signal,
16799
+ assets: emptyAssets()
16800
+ };
16801
+ }
16802
+ /**
16803
+ * Loads one changed file again, by what the manifest says it is. A font is read as a whole: its
16804
+ * `.fnt` and every page, whichever of them changed.
16805
+ *
16806
+ * @param run - The running load.
16807
+ * @param owner - The file to load.
16808
+ * @returns What the file brought.
16809
+ * @throws {Error} When a fetch or a decode fails, or the font lists no page.
16810
+ */
16811
+ async function loadFresh(run, owner) {
16812
+ const kind = kindOf$2(owner.file);
16813
+ if (kind === "font") return {
16814
+ kind,
16815
+ owner,
16816
+ font: await loadFont(run, owner.file)
16817
+ };
16818
+ if (kind === "audio") return {
16819
+ kind,
16820
+ owner,
16821
+ audio: await loadSound(run, owner.file)
16822
+ };
16823
+ return {
16824
+ kind,
16825
+ owner,
16826
+ texture: await loadImage(run, owner.path, nineOf(owner.file))
16827
+ };
16828
+ }
16829
+ /**
16830
+ * Loads every changed file of one bundle in parallel and waits for all of them, so nothing is
16831
+ * still on the way when the bundle is stored or given back.
16832
+ *
16833
+ * @param run - The running load.
16834
+ * @param owners - The files to load.
16835
+ * @returns What arrived, and the files that failed.
16836
+ */
16837
+ async function loadAll(run, owners) {
16838
+ const fresh = [];
16839
+ const failures = [];
16840
+ await Promise.all(owners.map((owner) => loadFresh(run, owner).then((loaded) => fresh.push(loaded), (error) => failures.push({
16841
+ path: owner.path,
16842
+ reason: reasonOf$1(error)
16843
+ }))));
16844
+ return {
16845
+ fresh,
16846
+ failures
16847
+ };
16848
+ }
16849
+ /**
16850
+ * Reads the record of a bundle once it stands still. A load that runs when the stamp arrives may
16851
+ * have fetched the old bytes, so it lands first.
16852
+ *
16853
+ * @param state - The plugin state.
16854
+ * @param bundle - Name of the bundle.
16855
+ * @returns The record when the bundle is loaded, else `undefined`: its next load reads the new
16856
+ * bytes, the dev server answers every file with `no-store`.
16857
+ */
16858
+ async function settledRecord(state, bundle) {
16859
+ const record = state.records.get(bundle);
16860
+ if (record?.status === "loading") await record.inflight?.promise;
16861
+ return record?.status === "loaded" ? record : void 0;
16862
+ }
16863
+ /**
16864
+ * Writes the pixel size of a new image, and what it costs, into its manifest record.
16865
+ *
16866
+ * @param target - The record of a texture file or of a font page.
16867
+ * @param size - The size of the decoded image.
16868
+ */
16869
+ function resize(target, size) {
16870
+ if (target === void 0 || size === void 0) return;
16871
+ target.width = size.width;
16872
+ target.height = size.height;
16873
+ target.mb = textureMb(size.width, size.height);
16874
+ }
16875
+ /**
16876
+ * Stores a new font under its key and frees the pages of the old one. The record of each page and
16877
+ * of the font follow the new images.
16878
+ *
16879
+ * @param io - The I/O seam of the plugin.
16880
+ * @param record - The record of the loaded bundle.
16881
+ * @param owner - The font file.
16882
+ * @param font - The font that was read again.
16883
+ * @param sizes - The pixel size of every new page.
16884
+ */
16885
+ function storeFont(io, record, owner, font, sizes) {
16886
+ const { file } = owner;
16887
+ const pages = file.pages ?? [];
16888
+ const old = record.fonts.get(file.key);
16889
+ record.fonts.set(file.key, font);
16890
+ for (const page of old?.pages ?? []) io.destroyTexture(page);
16891
+ for (const [at, texture] of font.pages.entries()) resize(pages[at], sizes.get(texture));
16892
+ file.mb = sumMb(pages);
16893
+ }
16894
+ /**
16895
+ * Stores what one changed file brought under its key. An old texture is destroyed only after the
16896
+ * new one is stored, so nothing ever answers a destroyed texture.
16897
+ *
16898
+ * @param io - The I/O seam of the plugin.
16899
+ * @param record - The record of the loaded bundle.
16900
+ * @param fresh - What the file brought.
16901
+ * @param sizes - The pixel size of every new texture.
16902
+ */
16903
+ function store(io, record, fresh, sizes) {
16904
+ const { file } = fresh.owner;
16905
+ if (fresh.kind === "font") {
16906
+ storeFont(io, record, fresh.owner, fresh.font, sizes);
16907
+ return;
16908
+ }
16909
+ if (fresh.kind === "audio") {
16910
+ record.audio.set(file.key, fresh.audio);
16911
+ return;
16912
+ }
16913
+ const old = record.textures.get(file.key);
16914
+ record.textures.set(file.key, fresh.texture);
16915
+ if (old !== void 0) io.destroyTexture(old);
16916
+ resize(file, sizes.get(fresh.texture));
16917
+ }
16918
+ /**
16919
+ * Makes the numbers and the picture follow the files that were stored: the megabytes of the
16920
+ * bundle, the budget, the views of the keys, the frame loop and the event.
16921
+ *
16922
+ * @param ctx - Domain context of the plugin.
16923
+ * @param batch - The bundle and its replaced files.
16924
+ * @param record - The record of the bundle.
16925
+ */
16926
+ function publish(ctx, batch, record) {
16927
+ const { bundle, entry } = batch;
16928
+ const keys = batch.owners.map((owner) => owner.file.key);
16929
+ entry.mb = sumMb([...entry.files, ...entry.pages ?? []]);
16930
+ enforceBudget(ctx);
16931
+ if (record.status !== "loaded") return;
16932
+ ctx.deps.renderer.sync.textures.invalidate(keys);
16933
+ ctx.deps.time.wake();
16934
+ ctx.emit("assets:replaced", {
16935
+ bundle,
16936
+ keys
16937
+ });
16938
+ }
16939
+ /**
16940
+ * Gives the new files of one bundle up: every file that failed is logged, and every texture the
16941
+ * swap uploaded goes back. Nothing was stored, so the bundle keeps what it had.
16942
+ *
16943
+ * @param swap - The running swap.
16944
+ * @param failures - The files that could not be read.
16945
+ * @param sizes - Every new texture of the bundle.
16946
+ */
16947
+ function discard(swap, failures, sizes) {
16948
+ for (const failure of failures) swap.ctx.log.error("assets:replace-failed", failure);
16949
+ for (const texture of sizes.keys()) swap.io.destroyTexture(texture);
16950
+ }
16951
+ /**
16952
+ * Replaces the changed files of one bundle. Every file is loaded first and nothing is stored
16953
+ * before all of them are there. A file that fails is logged and the bundle keeps its old
16954
+ * textures, fonts and bytes: the new textures are given back, nothing is emitted and the page
16955
+ * stays. The stamp of the broken file is applied all the same, so it is read again only when it
16956
+ * is saved again. A bundle that was unloaded on the way gives its new textures back too.
16957
+ *
16958
+ * @param swap - The running swap.
16959
+ * @param batch - The bundle and its changed files.
16960
+ * @throws {Error} What the io, the renderer or the budget throws after the files arrived.
16961
+ */
16962
+ async function replaceBundle(swap, batch) {
16963
+ const { ctx, io } = swap;
16964
+ const record = await settledRecord(ctx.state, batch.bundle);
16965
+ if (record === void 0) return;
16966
+ const sizes = /* @__PURE__ */ new Map();
16967
+ const { fresh, failures } = await loadAll(loadingOf(swap, batch.bundle, sizes), batch.owners);
16968
+ if (failures.length > 0 || record.status !== "loaded") {
16969
+ discard(swap, failures, sizes);
16970
+ return;
16971
+ }
16972
+ for (const loaded of fresh) store(io, record, loaded, sizes);
16973
+ publish(ctx, batch, record);
16974
+ }
16975
+ /**
16976
+ * Runs one swap, bundle after bundle: a bundle with a file that failed does not stop the next
16977
+ * one. It never rejects, so the chain of swaps in the state cannot break. A throw after the files
16978
+ * arrived (from a destroy, the views or the budget) leaves a bundle whose stored state is not
16979
+ * known: every file of it is logged as failed, the page reloads and the swap stops there.
16980
+ *
16981
+ * @param swap - The running swap.
16982
+ * @param batches - The changed files, by bundle.
16983
+ */
16984
+ async function replaceAll(swap, batches) {
16985
+ for (const batch of batches) try {
16986
+ await replaceBundle(swap, batch);
16987
+ } catch (error) {
16988
+ const reason = reasonOf$1(error);
16989
+ for (const { path } of batch.owners) swap.ctx.log.error("assets:replace-failed", {
16990
+ path,
16991
+ reason
16992
+ });
16993
+ swap.reload();
16994
+ return;
16995
+ }
16996
+ }
16997
+ /**
16998
+ * Creates the handler of the dev hot swap of asset files. It acts only on the stamp of the keys
16999
+ * watch, and only with an io: a headless page has no file. What changed and whether the swap is
17000
+ * refused is decided at once; the replace itself waits for the swap before it, so two saves in a
17001
+ * row never overlap.
17002
+ *
17003
+ * @param ctx - Domain context of the plugin.
17004
+ * @param reload - What a refused or a broken swap calls. The page reload by default; a test
17005
+ * passes a spy.
17006
+ * @returns The handler of `ui:hot-swap`.
17007
+ */
17008
+ function createSwap$1(ctx, reload = reloadPage$1) {
17009
+ let index;
17010
+ return (payload) => {
17011
+ const { state } = ctx;
17012
+ const stamps = stampsOf(payload);
17013
+ const io = state.io;
17014
+ if (stamps === void 0 || io === void 0) return;
17015
+ const changed = changedPaths(state, stamps);
17016
+ if (index?.manifest !== state.manifest) index = indexPaths(state.manifest);
17017
+ const reason = refusalOf$1(index, stamps, changed);
17018
+ if (reason !== void 0) {
17019
+ ctx.log.info("assets:swap-refused", { reason });
17020
+ reload();
17021
+ return;
17022
+ }
17023
+ const batches = batchesOf(index, changed);
17024
+ if (batches.length === 0) return;
17025
+ state.swapping = (state.swapping ?? Promise.resolve()).then(() => replaceAll({
17026
+ ctx,
17027
+ io,
17028
+ reload
17029
+ }, batches));
17030
+ };
17031
+ }
17032
+ //#endregion
16475
17033
  //#region src/plugins/assets/handlers.ts
16476
17034
  /**
16477
- * @file assets plugin — event handlers.
17035
+ * @file assets plugin — event handlers: the rest node that settles the memory, and the dev hot
17036
+ * swap that replaces the asset files a save changed.
16478
17037
  */
16479
17038
  /**
16480
17039
  * Creates the hook handlers. `flow:rest` is the one moment the graph stands still, so it is where
16481
17040
  * the memory is settled and the neighbourhood of the position is queued. The loading itself is a
16482
- * background queue in the state and is never awaited here: an engine hook is synchronous.
17041
+ * background queue in the state and is never awaited here: an engine hook is synchronous. The
17042
+ * global `ui:hot-swap` is hooked with no `depends` on `ui`, as `effects` does: in a dev build it
17043
+ * brings the stamp of the keys watch, and the changed files are replaced behind it.
16483
17044
  *
16484
- * The handler builds its domain context when it first fires, not while this factory runs: the
17045
+ * A handler builds its domain context when it first fires, not while this factory runs: the
16485
17046
  * kernel registers hooks before it builds the plugin APIs, so nothing is resolvable yet.
16486
17047
  *
16487
17048
  * @param ctx - Kernel context of the assets plugin.
16488
- * @returns The one hook of the plugin.
17049
+ * @param reload - What a refused or a broken hot swap calls. The kernel passes nothing, which
17050
+ * means the page reload; a test passes a spy.
17051
+ * @returns The two hooks of the plugin.
16489
17052
  */
16490
- function createHandlers$4(ctx) {
17053
+ function createHandlers$4(ctx, reload) {
16491
17054
  let assets;
16492
- return {
16493
- /**
16494
- * Settles the texture memory and rebuilds the background preload.
16495
- *
16496
- * @param _payload - Where the graph rests. The position itself comes from the state.
16497
- */
16498
- "flow:rest": (_payload) => {
16499
- assets ??= withDeps$6(ctx);
16500
- if (assets.state.io === void 0) return;
16501
- enforceBudget(assets);
16502
- if (assets.deps.flow.state().mode === "fast") return;
16503
- startPreload(assets);
16504
- } };
17055
+ let swap;
17056
+ return {
17057
+ /**
17058
+ * Settles the texture memory and rebuilds the background preload.
17059
+ *
17060
+ * @param _payload - Where the graph rests. The position itself comes from the state.
17061
+ */
17062
+ "flow:rest": (_payload) => {
17063
+ assets ??= withDeps$6(ctx);
17064
+ if (assets.state.io === void 0) return;
17065
+ enforceBudget(assets);
17066
+ if (assets.deps.flow.state().mode === "fast") return;
17067
+ startPreload(assets);
17068
+ },
17069
+ /**
17070
+ * Replaces the asset files a dev save changed, when the saved module is the stamp of the keys
17071
+ * watch. Only dev emits the event; the swap answers at once and works behind it.
17072
+ *
17073
+ * @param payload - The saved file and its new exports.
17074
+ */
17075
+ "ui:hot-swap": (payload) => {
17076
+ if (typeof __MOKU_GAME_DEV__ !== "undefined" && __MOKU_GAME_DEV__) {
17077
+ assets ??= withDeps$6(ctx);
17078
+ swap ??= createSwap$1(assets, reload);
17079
+ swap(payload);
17080
+ }
17081
+ }
17082
+ };
16505
17083
  }
16506
17084
  //#endregion
16507
17085
  //#region src/plugins/assets/state.ts
@@ -16550,7 +17128,9 @@ function createAssetsState() {
16550
17128
  current: void 0,
16551
17129
  useCounter: 0,
16552
17130
  warned: emptySet$2(),
16553
- removers: []
17131
+ removers: [],
17132
+ stamps: void 0,
17133
+ swapping: void 0
16554
17134
  };
16555
17135
  }
16556
17136
  /**
@@ -16584,7 +17164,8 @@ const assetsPlugin = /*#__PURE__*/ createPlugin$1("assets", {
16584
17164
  events: (register) => register.map({
16585
17165
  "assets:bundle-loaded": "Every file of a bundle is a texture now",
16586
17166
  "assets:bundle-progress": "One more file of a running bundle load settled",
16587
- "assets:bundle-unloaded": "The textures of a bundle were destroyed"
17167
+ "assets:bundle-unloaded": "The textures of a bundle were destroyed",
17168
+ "assets:replaced": "Files of a loaded bundle were replaced by a dev hot swap"
16588
17169
  }),
16589
17170
  createState: createAssetsState,
16590
17171
  api: createAssetsApi,
@@ -17868,9 +18449,9 @@ async function stopAudio(state) {
17868
18449
  //#endregion
17869
18450
  //#region src/plugins/audio/handlers.ts
17870
18451
  /**
17871
- * @file audio plugin — the four hooks. They are the whole of what drives this plugin: the player
17872
- * sets the volumes, the graph sets the music, the OS sets the pause and the budget sets what is
17873
- * still decoded.
18452
+ * @file audio plugin — the five hooks. They are the whole of what drives this plugin: the player
18453
+ * sets the volumes, the graph sets the music, the OS sets the pause, and the budget and a dev hot
18454
+ * swap set what is still decoded.
17874
18455
  */
17875
18456
  /**
17876
18457
  * Applies the volumes of the committed player. A `volumes` that throws — a save without the
@@ -17921,12 +18502,25 @@ function resumeAfterPause(ctx) {
17921
18502
  });
17922
18503
  }
17923
18504
  /**
17924
- * Creates the four hook handlers. Each one builds the domain context when it first fires, not
18505
+ * Forgets the decoded buffer and the one warning of every key named. A key that is no sound is in
18506
+ * neither, so it costs nothing. A source that is playing holds its own buffer and plays on.
18507
+ *
18508
+ * @param state - The plugin state.
18509
+ * @param keys - Asset keys whose bytes left or changed.
18510
+ */
18511
+ function evict(state, keys) {
18512
+ for (const key of keys) {
18513
+ state.decoded.delete(key);
18514
+ state.warned.delete(key);
18515
+ }
18516
+ }
18517
+ /**
18518
+ * Creates the five hook handlers. Each one builds the domain context when it first fires, not
17925
18519
  * while this factory runs: the kernel registers hooks before it builds the plugin APIs, so
17926
18520
  * nothing is resolvable yet.
17927
18521
  *
17928
18522
  * @param ctx - Kernel context of the audio plugin.
17929
- * @returns The four hooks of the plugin.
18523
+ * @returns The five hooks of the plugin.
17930
18524
  */
17931
18525
  function createHandlers$3(ctx) {
17932
18526
  let audio;
@@ -17986,11 +18580,17 @@ function createHandlers$3(ctx) {
17986
18580
  * @param payload - The bundle that left and the keys it carried.
17987
18581
  */
17988
18582
  "assets:bundle-unloaded": (payload) => {
17989
- const state = domain().state;
17990
- for (const key of payload.keys) {
17991
- state.decoded.delete(key);
17992
- state.warned.delete(key);
17993
- }
18583
+ evict(domain().state, payload.keys);
18584
+ },
18585
+ /**
18586
+ * Drops the decoded sounds a dev hot swap replaced: `assets.audio(key)` already answers the
18587
+ * new bytes, so the next play of a key decodes them. Nothing that is playing is stopped: a
18588
+ * source keeps the buffer it started with and a streamed track keeps its Blob.
18589
+ *
18590
+ * @param payload - The bundle whose files were replaced and the keys with new bytes.
18591
+ */
18592
+ "assets:replaced": (payload) => {
18593
+ evict(domain().state, payload.keys);
17994
18594
  }
17995
18595
  };
17996
18596
  }
@@ -20694,10 +21294,24 @@ function stopEffects(state) {
20694
21294
  //#endregion
20695
21295
  //#region src/plugins/effects/handlers.ts
20696
21296
  /**
20697
- * @file effects plugin — the two hooks: a bundle that left takes the effects drawn with its
20698
- * textures along, particles and `Displacement` maps alike, whatever their space; a dev hot swap
20699
- * replaces the registered emitters its module exports.
21297
+ * @file effects plugin — the three hooks: a bundle that left takes the effects drawn with its
21298
+ * textures along, particles and `Displacement` maps alike, whatever their space; so do the files
21299
+ * a dev hot swap replaced, whose effects are built again from the new textures; a dev hot swap of
21300
+ * a module replaces the registered emitters it exports.
21301
+ */
21302
+ /**
21303
+ * Retires everything drawn with the textures of `keys`: the particle instances and their bakes,
21304
+ * the `Displacement` filters, and the one-shot warning of each key, so it can warn again. The
21305
+ * next frame builds each effect again from the texture its key answers then.
21306
+ *
21307
+ * @param effects - Domain context of the effects plugin.
21308
+ * @param keys - The asset keys whose textures are gone.
20700
21309
  */
21310
+ function retireKeys(effects, keys) {
21311
+ retireParticleKeys(effects, keys);
21312
+ retireFilterMaps(effects, keys);
21313
+ for (const key of keys) effects.state.warned.delete(`texture:${key}`);
21314
+ }
20701
21315
  /**
20702
21316
  * Replaces every registered emitter the saved module exports and drops its bake, so the next
20703
21317
  * instance bakes the new config; a live instance keeps its own bake. An id no feature registered
@@ -20736,9 +21350,19 @@ function createHandlers$2(ctx) {
20736
21350
  */
20737
21351
  "assets:bundle-unloaded": (payload) => {
20738
21352
  effects ??= withDeps$3(ctx);
20739
- retireParticleKeys(effects, payload.keys);
20740
- retireFilterMaps(effects, payload.keys);
20741
- for (const key of payload.keys) effects.state.warned.delete(`texture:${key}`);
21353
+ retireKeys(effects, payload.keys);
21354
+ },
21355
+ /**
21356
+ * Retires the particle instances and the `Displacement` filters drawn with the textures a dev
21357
+ * hot swap replaced and drops their bakes. The old textures are destroyed and the new ones
21358
+ * stand under the same keys, so the next frame builds each effect again from the new one.
21359
+ * Only dev emits it.
21360
+ *
21361
+ * @param payload - The bundle and the keys with new bytes.
21362
+ */
21363
+ "assets:replaced": (payload) => {
21364
+ effects ??= withDeps$3(ctx);
21365
+ retireKeys(effects, payload.keys);
20742
21366
  },
20743
21367
  /**
20744
21368
  * Replaces the registered emitters a dev hot swap brought. Only dev emits it.
@@ -24998,6 +25622,8 @@ function parseTags(source, warn) {
24998
25622
  const CACHE_SEPARATOR = String.fromCodePoint(0);
24999
25623
  /** How many laid-out blocks are kept. The oldest goes when a new one does not fit. */
25000
25624
  const CACHE_LIMIT = 1024;
25625
+ /** How every inline icon tag starts. A resolved text without it holds no icon. */
25626
+ const ICON_OPEN = "<icon=";
25001
25627
  /** The name the system is registered under; it shows up in a world error message. */
25002
25628
  const TEXT_SYSTEM_NAME = "text.resolve";
25003
25629
  /**
@@ -25302,9 +25928,36 @@ function rememberBound(ctx, entity, text, unit) {
25302
25928
  seen.locale = ctx.deps.i18n.locale();
25303
25929
  }
25304
25930
  /**
25931
+ * Writes the string a bound label already shows once more, and measures it again. A mark on a
25932
+ * label whose number stands still means the picture under it moved: its font arrived or was
25933
+ * replaced, or its style was swapped. The display adapter rebuilds only what `changed(Text)`
25934
+ * names, so the same string is written.
25935
+ *
25936
+ * @param ctx - Domain context of the text plugin.
25937
+ * @param entity - The entity.
25938
+ * @param text - Its component value.
25939
+ */
25940
+ function showAgain(ctx, entity, text) {
25941
+ ctx.deps.world.ecs.set(entity, Text, { resolved: text.resolved });
25942
+ ctx.state.measured.set(entity, sizeOf(layoutFor(ctx, text.resolved, text.style)));
25943
+ }
25944
+ /**
25945
+ * Reports a bound label whose style is not a digits style, once per style. An unknown style name
25946
+ * is not reported here: `styleOf` reports it by its name.
25947
+ *
25948
+ * @param ctx - Domain context of the text plugin.
25949
+ * @param styleName - The style name of the bound label.
25950
+ */
25951
+ function warnWithoutDigits(ctx, styleName) {
25952
+ const style = ctx.state.styles.get(styleName);
25953
+ if (style === void 0 || style.digits) return;
25954
+ warnOnce(ctx, `bind-style:${styleName}`, "text: bind on a style without digits", { style: styleName });
25955
+ }
25956
+ /**
25305
25957
  * One frame of a bound label: the value read every frame, the string built and written only
25306
25958
  * when its unit moved. A label that stands still costs a read and a few compares; no tag is
25307
- * parsed, and the locale is read only for a `"duration"`.
25959
+ * parsed, and the locale is read only for a `"duration"`. A marked label that stands still is
25960
+ * written once with the string it shows.
25308
25961
  *
25309
25962
  * @param ctx - Domain context of the text plugin.
25310
25963
  * @param entity - The entity.
@@ -25313,13 +25966,15 @@ function rememberBound(ctx, entity, text, unit) {
25313
25966
  * @param frame - What this run of the system shares.
25314
25967
  */
25315
25968
  function stepBound(ctx, entity, text, bind, frame) {
25316
- const style = ctx.state.styles.get(text.style);
25317
- if (style !== void 0 && !style.digits) warnOnce(ctx, `bind-style:${text.style}`, "text: bind on a style without digits", { style: text.style });
25969
+ warnWithoutDigits(ctx, text.style);
25318
25970
  const value = boundValue(ctx, entity, bind, frame);
25319
25971
  const format = value === void 0 ? "int" : formatOf(ctx, entity, bind, value);
25320
25972
  const unit = value === void 0 ? void 0 : unitOf(value, format);
25321
- ctx.state.dirty.delete(entity);
25322
- if (isShown(ctx, entity, text, unit, format)) return;
25973
+ const marked = ctx.state.dirty.delete(entity);
25974
+ if (isShown(ctx, entity, text, unit, format)) {
25975
+ if (marked) showAgain(ctx, entity, text);
25976
+ return;
25977
+ }
25323
25978
  showBound(ctx, entity, text, value === void 0 ? "" : formatBound(value, format, (ms) => ctx.deps.i18n.duration(ms)), value);
25324
25979
  rememberBound(ctx, entity, text, unit);
25325
25980
  }
@@ -25391,17 +26046,43 @@ function createTextSystem(ctx) {
25391
26046
  });
25392
26047
  }
25393
26048
  /**
25394
- * Marks labels for the next layout phase: every one of them, or only the ones whose content is a
25395
- * message, which is what a locale change touches.
26049
+ * Marks labels for the next layout phase: every one of them, or only the ones `keep` names. A
26050
+ * locale change names the labels whose content is a message; a dev hot swap names the labels
26051
+ * drawn with a replaced font or icon.
25396
26052
  *
25397
26053
  * @param ctx - Domain context of the text plugin.
25398
- * @param onlyMessages - True to leave the plain strings alone.
26054
+ * @param keep - Tells whether a label is marked. Left out, every label is.
26055
+ * @returns How many labels were marked.
25399
26056
  */
25400
- function markDirty(ctx, onlyMessages = false) {
26057
+ function markDirty(ctx, keep = () => true) {
26058
+ let marked = 0;
25401
26059
  for (const [entity, text] of ctx.deps.world.ecs.query(Text)) {
25402
- if (onlyMessages && typeof text.content === "string") continue;
26060
+ if (!keep(text)) continue;
25403
26061
  ctx.state.dirty.add(entity);
26062
+ marked += 1;
25404
26063
  }
26064
+ return marked;
26065
+ }
26066
+ /**
26067
+ * Tells whether a label is drawn with one of the asset keys: a font its style names, or an inline
26068
+ * icon of its resolved text. The text is read through the tag grammar, the way it is drawn, so an
26069
+ * escaped `\<icon=…>` is text and not an icon. A text that holds no `<icon=` at all is not read:
26070
+ * most labels have no icon, and this runs for every label of a swap.
26071
+ *
26072
+ * @param ctx - Domain context of the text plugin.
26073
+ * @param text - The component value of the label.
26074
+ * @param keys - The asset keys to look for.
26075
+ * @returns True when a glyph font or an icon of the label is one of `keys`.
26076
+ */
26077
+ function drawsWith(ctx, text, keys) {
26078
+ const style = styleOf$1(ctx, text.style);
26079
+ if ([
26080
+ style.font,
26081
+ style.bold,
26082
+ style.italic
26083
+ ].some((font) => font !== void 0 && keys.includes(font))) return true;
26084
+ if (!text.resolved.includes(ICON_OPEN)) return false;
26085
+ return parseTags(text.resolved, warnFor(ctx)).some((run) => run.kind === "icon" && keys.includes(run.key));
25405
26086
  }
25406
26087
  /**
25407
26088
  * Forgets an entity whose `Text` left. Works on the state alone, because it is also what the
@@ -25430,6 +26111,40 @@ const SYNTHETIC_ITALIC = -.2;
25430
26111
  /** What a shadow copy is painted with, so its tint alone gives it the shadow colour. */
25431
26112
  const WHITE = 16777215;
25432
26113
  /**
26114
+ * Reads one font when `assets` can answer for it: the advance table first, then the install in the
26115
+ * renderer. A font that has both already is left alone.
26116
+ *
26117
+ * @param ctx - Domain context of the text plugin.
26118
+ * @param key - The asset key of the font.
26119
+ * @returns True when its advance table arrived, so what was measured without it is stale.
26120
+ * @throws {Error} When the `.fnt` is in neither BMFont format.
26121
+ */
26122
+ function installFont(ctx, key) {
26123
+ const renderer = ctx.deps.renderer;
26124
+ const needsTable = !ctx.state.tables.has(key);
26125
+ const needsInstall = renderer.host.ready() && !ctx.state.installed.has(key);
26126
+ if (!needsTable && !needsInstall) return false;
26127
+ const font = ctx.deps.assets.font(key);
26128
+ if (font === void 0) return false;
26129
+ if (needsTable) ctx.state.tables.set(key, parseAdvances(font.fnt, key));
26130
+ if (needsInstall) {
26131
+ renderer.sync.fonts.install(key, font.fnt, font.texture);
26132
+ ctx.state.installed.add(key);
26133
+ }
26134
+ return needsTable;
26135
+ }
26136
+ /**
26137
+ * Forgets one font: its advance table and the mark that the renderer holds it, so the next
26138
+ * `installFont` reads both again.
26139
+ *
26140
+ * @param ctx - Domain context of the text plugin.
26141
+ * @param key - The asset key of the font.
26142
+ */
26143
+ function dropFont(ctx, key) {
26144
+ ctx.state.tables.delete(key);
26145
+ ctx.state.installed.delete(key);
26146
+ }
26147
+ /**
25433
26148
  * Reads the fonts of `fontKeys` that `assets` can answer for: the advance table first, then the
25434
26149
  * install in the renderer. Runs in `onStart` and on every bundle that lands.
25435
26150
  *
@@ -25437,24 +26152,8 @@ const WHITE = 16777215;
25437
26152
  * @throws {Error} When a `.fnt` is in neither BMFont format.
25438
26153
  */
25439
26154
  function installFonts(ctx) {
25440
- const renderer = ctx.deps.renderer;
25441
- const ready = renderer.host.ready();
25442
26155
  let arrived = false;
25443
- for (const key of ctx.state.fontKeys) {
25444
- const needsTable = !ctx.state.tables.has(key);
25445
- const needsInstall = ready && !ctx.state.installed.has(key);
25446
- if (!needsTable && !needsInstall) continue;
25447
- const font = ctx.deps.assets.font(key);
25448
- if (font === void 0) continue;
25449
- if (needsTable) {
25450
- ctx.state.tables.set(key, parseAdvances(font.fnt, key));
25451
- arrived = true;
25452
- }
25453
- if (needsInstall) {
25454
- renderer.sync.fonts.install(key, font.fnt, font.texture);
25455
- ctx.state.installed.add(key);
25456
- }
25457
- }
26156
+ for (const key of ctx.state.fontKeys) if (installFont(ctx, key)) arrived = true;
25458
26157
  if (arrived) refresh(ctx);
25459
26158
  }
25460
26159
  /**
@@ -25467,8 +26166,7 @@ function releaseFonts(ctx, keys) {
25467
26166
  let left = false;
25468
26167
  for (const key of keys) {
25469
26168
  if (!ctx.state.fontKeys.has(key)) continue;
25470
- ctx.state.tables.delete(key);
25471
- ctx.state.installed.delete(key);
26169
+ dropFont(ctx, key);
25472
26170
  left = true;
25473
26171
  }
25474
26172
  if (left) refresh(ctx);
@@ -25483,6 +26181,73 @@ function refresh(ctx) {
25483
26181
  markDirty(ctx);
25484
26182
  }
25485
26183
  /**
26184
+ * Reloads the page. This is how a font replace that failed ends, so the game is not left drawing
26185
+ * with a font whose table was released. Headless and in a test there is no page, so nothing
26186
+ * happens.
26187
+ */
26188
+ function reloadPage() {
26189
+ globalThis.location?.reload();
26190
+ }
26191
+ /**
26192
+ * Says why a font could not be installed again.
26193
+ *
26194
+ * @param error - What the install threw.
26195
+ * @returns The message of an error, or the value as a string.
26196
+ * @example
26197
+ * ```ts
26198
+ * reasonOf(new Error("The page is gone.")); // "The page is gone."
26199
+ * ```
26200
+ */
26201
+ function reasonOf(error) {
26202
+ return error instanceof Error ? error.message : String(error);
26203
+ }
26204
+ /**
26205
+ * Installs every replaced font again, one by one: its table and its mark go, then the new file
26206
+ * and the new page are read. No other font is touched and no label is marked here. The stamp of
26207
+ * the keys watch is written after a failed scan too, so a `.fnt` the scanner refused still gets
26208
+ * here, and reading it throws after its table was released. That font is logged and the page
26209
+ * reloads.
26210
+ *
26211
+ * @param ctx - Domain context of the text plugin.
26212
+ * @param fonts - The font keys among what a dev hot swap replaced.
26213
+ * @param reload - What a font that cannot be installed calls.
26214
+ * @returns False when a font failed, so the page is on its way out.
26215
+ */
26216
+ function reinstallFonts(ctx, fonts, reload) {
26217
+ for (const key of fonts) try {
26218
+ dropFont(ctx, key);
26219
+ installFont(ctx, key);
26220
+ } catch (error) {
26221
+ ctx.log.error("text:font-replace-failed", {
26222
+ key,
26223
+ reason: reasonOf(error)
26224
+ });
26225
+ reload();
26226
+ return false;
26227
+ }
26228
+ return true;
26229
+ }
26230
+ /**
26231
+ * Applies what a dev hot swap replaced. `assets` answers the new font and the new texture of
26232
+ * every key already, and the old pages and textures are destroyed. A font among the keys is
26233
+ * installed again here, so the font registry of the renderer holds the new page at once. Only
26234
+ * the labels drawn with a replaced font or inline icon are marked, and the generation moves: the
26235
+ * next frame writes each of them, and the adapter builds it again from the new textures even when
26236
+ * its string stands still. A label of another font is not written and keeps its objects.
26237
+ *
26238
+ * @param ctx - Domain context of the text plugin.
26239
+ * @param keys - The asset keys with new bytes.
26240
+ * @param reload - What a font that cannot be installed calls. The page reload by default; a test
26241
+ * passes a spy.
26242
+ */
26243
+ function applyReplaced(ctx, keys, reload = reloadPage) {
26244
+ const fonts = keys.filter((key) => ctx.state.fontKeys.has(key));
26245
+ if (fonts.length > 0) ctx.state.cache.clear();
26246
+ if (!reinstallFonts(ctx, fonts, reload)) return;
26247
+ if (markDirty(ctx, (text) => drawsWith(ctx, text, keys)) === 0) return;
26248
+ ctx.state.generation += 1;
26249
+ }
26250
+ /**
25486
26251
  * Where a line starts inside the widest one.
25487
26252
  *
25488
26253
  * @param style - The style of the label.
@@ -25698,7 +26463,7 @@ function buildRun(ctx, pixi, run, style, at) {
25698
26463
  return objects;
25699
26464
  }
25700
26465
  /**
25701
- * The style and the laid-out block of one component value.
26466
+ * The style and the laid-out block of one component value, under the generation of this moment.
25702
26467
  *
25703
26468
  * @param ctx - Domain context of the text plugin.
25704
26469
  * @param value - The component value.
@@ -25707,7 +26472,8 @@ function buildRun(ctx, pixi, run, style, at) {
25707
26472
  function labelOf(ctx, value) {
25708
26473
  return {
25709
26474
  style: styleOf$1(ctx, value.style),
25710
- layout: layoutFor(ctx, value.resolved, value.style)
26475
+ layout: layoutFor(ctx, value.resolved, value.style),
26476
+ generation: ctx.state.generation
25711
26477
  };
25712
26478
  }
25713
26479
  /**
@@ -25821,10 +26587,27 @@ function samePoint(first, second) {
25821
26587
  return first.x === second.x && first.y === second.y;
25822
26588
  }
25823
26589
  /**
26590
+ * Tells whether a container already shows a value: the string, the style name and the anchor it
26591
+ * was last updated with, drawn with the style object that is current and under the generation
26592
+ * that is current. A replaced style is a new object under the same name. A font or an icon a dev
26593
+ * hot swap replaced moves the generation, because the objects hold textures that are destroyed.
26594
+ *
26595
+ * @param ctx - Domain context of the text plugin.
26596
+ * @param container - The container of this label.
26597
+ * @param previous - The value the container was drawn from.
26598
+ * @param next - The value to draw now.
26599
+ * @returns True when nothing has to be drawn.
26600
+ */
26601
+ function isCurrent(ctx, container, previous, next) {
26602
+ const drawn = ctx.state.drawn.get(container);
26603
+ return previous.resolved === next.resolved && previous.style === next.style && samePoint(previous.anchor, next.anchor) && drawn?.style === styleOf$1(ctx, next.style) && drawn.generation === ctx.state.generation;
26604
+ }
26605
+ /**
25824
26606
  * Draws a changed value into a container that holds a label already. A ticking counter keeps its
25825
- * objects: with the same style object, the same anchor and the same lines and runs, the new text
25826
- * and spots are written into them. Anything else frees the children and fills the container
25827
- * again.
26607
+ * objects: with the same style object, the same generation, the same anchor and the same lines
26608
+ * and runs, the new text and spots are written into them. Anything else frees the children and
26609
+ * fills the container again. A label of an older generation is always filled again: writing the
26610
+ * string it shows already into a `BitmapText` rebuilds no glyph.
25828
26611
  *
25829
26612
  * @param ctx - Domain context of the text plugin.
25830
26613
  * @param pixi - The Pixi module the renderer loaded.
@@ -25835,7 +26618,7 @@ function samePoint(first, second) {
25835
26618
  function redraw(ctx, pixi, container, previous, next) {
25836
26619
  const drawn = ctx.state.drawn.get(container);
25837
26620
  const label = labelOf(ctx, next);
25838
- if (drawn?.style === label.style && samePoint(previous.anchor, next.anchor) && sameShape(drawn.layout, label.layout)) {
26621
+ if (drawn?.style === label.style && drawn.generation === label.generation && samePoint(previous.anchor, next.anchor) && sameShape(drawn.layout, label.layout)) {
25839
26622
  retext(ctx, pixi, container, next, label);
25840
26623
  return;
25841
26624
  }
@@ -25869,7 +26652,7 @@ function createTextAdapter(ctx) {
25869
26652
  if (pixi === void 0 || object === void 0) return;
25870
26653
  const container = asContainer(object);
25871
26654
  container.alpha = next.alpha;
25872
- if (previous.resolved === next.resolved && previous.style === next.style && samePoint(previous.anchor, next.anchor) && ctx.state.drawn.get(container)?.style === styleOf$1(ctx, next.style)) return;
26655
+ if (isCurrent(ctx, container, previous, next)) return;
25873
26656
  redraw(ctx, pixi, container, previous, next);
25874
26657
  },
25875
26658
  destroy: (object) => {
@@ -26080,19 +26863,22 @@ function createTextApi(ctx) {
26080
26863
  //#endregion
26081
26864
  //#region src/plugins/text/handlers.ts
26082
26865
  /**
26083
- * @file text plugin — the three hooks. None of them does work in place: a bundle brings fonts, a
26084
- * bundle takes them away, a locale change marks the messages. The layout phase of the next frame
26085
- * is where the strings are built again.
26866
+ * @file text plugin — the four hooks. None of them draws in place: a bundle brings fonts, a
26867
+ * bundle takes them away, a locale change marks the messages, a dev hot swap marks the labels of
26868
+ * a replaced font or icon. The layout phase of the next frame is where the strings are built
26869
+ * again.
26086
26870
  */
26087
26871
  /**
26088
- * Creates the three hook handlers. Each one builds the domain context when it first fires, not
26872
+ * Creates the four hook handlers. Each one builds the domain context when it first fires, not
26089
26873
  * while this factory runs: the kernel registers hooks before it builds the plugin APIs, so
26090
26874
  * nothing is resolvable yet.
26091
26875
  *
26092
26876
  * @param ctx - Kernel context of the text plugin.
26093
- * @returns The three hooks of the plugin.
26877
+ * @param reload - What a font that a dev hot swap replaced and that cannot be installed calls.
26878
+ * The kernel passes nothing, which means the page reload; a test passes a spy.
26879
+ * @returns The four hooks of the plugin.
26094
26880
  */
26095
- function createHandlers(ctx) {
26881
+ function createHandlers(ctx, reload) {
26096
26882
  let text;
26097
26883
  /**
26098
26884
  * The domain context, built on the first hook that fires.
@@ -26122,6 +26908,15 @@ function createHandlers(ctx) {
26122
26908
  releaseFonts(domain(), payload.keys);
26123
26909
  },
26124
26910
  /**
26911
+ * Installs the fonts a dev hot swap replaced again and marks the labels drawn with a replaced
26912
+ * font or inline icon. The next frame builds each of them again from the new textures.
26913
+ *
26914
+ * @param payload - The bundle and the asset keys that have new bytes.
26915
+ */
26916
+ "assets:replaced": (payload) => {
26917
+ applyReplaced(domain(), payload.keys, reload);
26918
+ },
26919
+ /**
26125
26920
  * Marks every label whose content is a message. The next layout phase resolves them again,
26126
26921
  * and `ui` solves once over the new sizes; no projection is re-run.
26127
26922
  *
@@ -26129,7 +26924,7 @@ function createHandlers(ctx) {
26129
26924
  */
26130
26925
  "i18n:locale-changed": (_payload) => {
26131
26926
  const tctx = domain();
26132
- markDirty(tctx, true);
26927
+ markDirty(tctx, (label) => typeof label.content !== "string");
26133
26928
  tctx.state.cache.clear();
26134
26929
  tctx.deps.time.wake();
26135
26930
  }
@@ -26180,6 +26975,7 @@ function createTextState$1() {
26180
26975
  cache: emptyMap$2(),
26181
26976
  drawn: emptyWeakMap(),
26182
26977
  dirty: emptySet$1(),
26978
+ generation: 0,
26183
26979
  warned: emptySet$1(),
26184
26980
  removers: []
26185
26981
  };
@@ -31844,6 +32640,8 @@ function createLayoutApi(ctx) {
31844
32640
  const HOT_GLOBAL = "__moku_hot";
31845
32641
  /** A strings module `assets:keys` generates; the group is its locale. */
31846
32642
  const STRINGS_FILE = /\/generated\/strings\.([\w-]+)\.ts$/;
32643
+ /** The stamp module the keys watch of `moku-game dev` writes: it names asset files, no view. */
32644
+ const ASSETS_STAMP = "/.moku/assets-stamp.ts";
31847
32645
  /**
31848
32646
  * The `kind` of every value a game registers by value at start: ECS component and tag types
31849
32647
  * (filters too), nodes, slots, flows, asset bundles and plugin owners. Any other `kind` marks
@@ -32002,6 +32800,41 @@ function localeOf(file) {
32002
32800
  return STRINGS_FILE.exec(file.replaceAll("\\", "/"))?.[1];
32003
32801
  }
32004
32802
  /**
32803
+ * Tells whether the saved module is the stamp of the keys watch, read from its path with `/`
32804
+ * separators.
32805
+ *
32806
+ * @param file - The path of the saved module.
32807
+ * @returns True for `.moku/assets-stamp.ts` of a game.
32808
+ * @example
32809
+ * ```ts
32810
+ * isAssetsStamp("/game/.moku/assets-stamp.ts"); // true
32811
+ * ```
32812
+ */
32813
+ function isAssetsStamp(file) {
32814
+ return file.replaceAll("\\", "/").endsWith(ASSETS_STAMP);
32815
+ }
32816
+ /**
32817
+ * Starts the swaps of one module: its exports for the event, and nothing swapped yet.
32818
+ *
32819
+ * @param module - The exports of the saved module.
32820
+ * @returns The swaps, every list empty.
32821
+ * @example
32822
+ * ```ts
32823
+ * noSwaps({ GAP: 8 }).components; // []
32824
+ * ```
32825
+ */
32826
+ function noSwaps(module) {
32827
+ return {
32828
+ module,
32829
+ components: [],
32830
+ projections: [],
32831
+ animations: [],
32832
+ emitters: [],
32833
+ strings: [],
32834
+ textStyles: []
32835
+ };
32836
+ }
32837
+ /**
32005
32838
  * Files one export under the kind it is, in the order component, projection, animation, emitter,
32006
32839
  * strings, text styles. The `default` export of a strings file counts as strings.
32007
32840
  *
@@ -32041,15 +32874,7 @@ function sortExports(next, file) {
32041
32874
  const exports = Object.entries(next);
32042
32875
  if (exports.length === 0) return { reason: "no exports" };
32043
32876
  const locale = localeOf(file);
32044
- const swaps = {
32045
- module: Object.fromEntries(exports),
32046
- components: [],
32047
- projections: [],
32048
- animations: [],
32049
- emitters: [],
32050
- strings: [],
32051
- textStyles: []
32052
- };
32877
+ const swaps = noSwaps(Object.fromEntries(exports));
32053
32878
  for (const entry of exports) {
32054
32879
  const reason = fileExport(swaps, entry, locale);
32055
32880
  if (reason !== void 0) return { reason };
@@ -32137,10 +32962,28 @@ function summaryOf(file, swaps) {
32137
32962
  };
32138
32963
  }
32139
32964
  /**
32965
+ * Hands the stamp of the keys watch on: the event with the exports of the module, and the log
32966
+ * line the editor listens to, every list empty. Nothing is sorted, replaced or repainted: the
32967
+ * stamp names asset files, and `assets` hooks the event to replace them. A listener cannot refuse
32968
+ * by throwing, the bus catches what a hook throws, so `assets` reloads the page itself.
32969
+ *
32970
+ * @param ctx - Domain context of the ui plugin.
32971
+ * @param file - The path of the stamp module.
32972
+ * @param next - Its new module namespace.
32973
+ */
32974
+ function forwardStamp(ctx, file, next) {
32975
+ const swaps = noSwaps(Object.fromEntries(Object.entries(next)));
32976
+ ctx.emit("ui:hot-swap", {
32977
+ file,
32978
+ module: swaps.module
32979
+ });
32980
+ ctx.log.info("ui:hot-swap", summaryOf(file, swaps));
32981
+ }
32982
+ /**
32140
32983
  * Builds the handler of one app: sort the exports, run the replaces that may throw, write the
32141
32984
  * components and the text styles, and repaint every view. The repaint runs for every module that
32142
32985
  * is not refused, so a module of styles only, whose bindings Bun already patched, shows on the
32143
- * next frame too.
32986
+ * next frame too. The stamp of the keys watch is the one module that is only forwarded.
32144
32987
  *
32145
32988
  * @param ctx - Domain context of the ui plugin.
32146
32989
  * @param jsx - The jsx module, which owns the component registry and the roots.
@@ -32148,6 +32991,10 @@ function summaryOf(file, swaps) {
32148
32991
  */
32149
32992
  function createSwap(ctx, jsx) {
32150
32993
  return (next, file) => {
32994
+ if (isAssetsStamp(file) && hasMembers(next)) {
32995
+ forwardStamp(ctx, file, next);
32996
+ return;
32997
+ }
32151
32998
  const sorted = sortExports(next, file);
32152
32999
  if ("reason" in sorted) refuse(ctx, file, sorted.reason);
32153
33000
  replaceRefusable(ctx, file, sorted);