@moku-labs/game 0.15.26 → 0.15.28

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 (32) hide show
  1. package/dist/app/page.d.mts +1 -1
  2. package/dist/app/page.mjs +1 -1
  3. package/dist/app/system.d.mts +3 -3
  4. package/dist/app.d.mts +3 -3
  5. package/dist/app.mjs +1 -1
  6. package/dist/assets.d.mts +1 -1
  7. package/dist/{commands-KWKfdywA.d.mts → commands-BJ4gG5WZ.d.mts} +5 -5
  8. package/dist/control.d.mts +4 -4
  9. package/dist/{define-uiyy88hF.d.mts → define-CVYadV_I.d.mts} +2 -2
  10. package/dist/{headless-PssyKRFN.d.mts → headless-DBYmnluy.d.mts} +1 -1
  11. package/dist/{index-CM1goGHf.d.mts → index-Dn_v0Fqs.d.mts} +7 -5
  12. package/dist/index.d.mts +5 -5
  13. package/dist/index.mjs +3 -3
  14. package/dist/inspect.d.mts +6 -6
  15. package/dist/inspect.mjs +1 -1
  16. package/dist/jsx-dev-runtime.d.mts +1 -1
  17. package/dist/jsx-runtime.d.mts +1 -1
  18. package/dist/{runtime-C0YaA9fC.d.mts → runtime-DnjJ4Zo9.d.mts} +1 -1
  19. package/dist/{sources-Dpc4NXvG.mjs → sources-ncFMQIus.mjs} +1 -1
  20. package/dist/{src-DWYFupNb.mjs → src-DPvSTM8O.mjs} +485 -78
  21. package/dist/testing.d.mts +4 -4
  22. package/dist/testing.mjs +1 -1
  23. package/dist/{types-BdLJ4lTA.d.mts → types-Bsa1nFyU.d.mts} +4 -4
  24. package/dist/{types-C-wAHgpD.d.mts → types-C76NQm-h.d.mts} +119 -20
  25. package/dist/{types-ChZ4Ej7O.d.mts → types-C_nC_M4p.d.mts} +73 -11
  26. package/dist/{types-sfaYGg9l.d.mts → types-DTetCcBC.d.mts} +2 -2
  27. package/dist/{types-_OQ6H8WL.d.mts → types-bEJEga5m.d.mts} +21 -3
  28. package/dist/{visual-DmDWJEzs.d.mts → visual-BMRHt4IB.d.mts} +6 -6
  29. package/dist/{visual-kEC9nb-o.mjs → visual-ClVRMUAi.mjs} +35 -5
  30. package/dist/visual.d.mts +1 -1
  31. package/dist/visual.mjs +1 -1
  32. package/package.json +1 -1
@@ -2,7 +2,7 @@ import { t as __exportAll } from "./rolldown-runtime-8H4AJuhK.mjs";
2
2
  import { r as systemSource, t as memory } from "./memory-CWlqhS7l.mjs";
3
3
  import { a as finishDrafts, i as enableDraftPatches, n as deepFreeze$1, o as openDrafts, r as dropDrafts } from "./drafts-BawpSaVp.mjs";
4
4
  import { _ as pushEntry, c as collectFlows, d as findNode, f as framePath, g as compact, h as slotNames, i as servePaused, l as describeGraph, m as isRestCheckpoint, n as captureFrame, o as epochOf, p as graphHash, r as serveCaptures, s as pageNow$1, t as cancelCaptures } from "./capture-PTJXDpxK.mjs";
5
- import { $ as component, A as localPoseOf, B as OverlayLevel, C as builtInStyles, D as textFor, E as readStyle, F as rootPoseOf, G as arcDefaults, H as Shape, I as Display, J as Exiting, K as componentsFor, L as HudPart, M as poseUnder, N as rootPointOf, O as applyPose, P as rootPoseInto, Q as Tree, R as NineSlice, U as Sprite, V as Parent, W as Transform, X as Now, Y as Layer, Z as Order, _ as uiFor, a as visualOf$1, b as Countdown, c as Covered, d as LocalWrite, f as Scroll, h as UiCounters, i as samePose, it as tag, j as parentOf$1, k as isLeaving, l as DebugTrigger, m as UI_OWNER, n as drawnRect, nt as system, o as visualRectOf, p as ScrollState, r as fitScaleOf, rt as systemsFor, s as Box, t as CONTENT$1, tt as resource, u as Escapable, x as Text, z as Overlay } from "./visual-kEC9nb-o.mjs";
5
+ import { $ as component, A as localPoseOf, B as OverlayLevel, C as builtInStyles, D as textFor, E as readStyle, F as rootPoseOf, G as arcDefaults, H as Shape, I as Display, J as Exiting, K as componentsFor, L as HudPart, M as poseUnder, N as rootPointOf, O as applyPose, P as rootPoseInto, Q as Tree, R as NineSlice, U as Sprite, V as Parent, W as Transform, X as Now, Y as Layer, Z as Order, _ as uiFor, a as visualOf$1, b as Countdown, c as Covered, d as LocalWrite, f as Scroll, h as UiCounters, i as samePose, it as tag, j as parentOf$1, k as isLeaving, l as DebugTrigger, m as UI_OWNER, n as drawnRect, nt as system, o as visualRectOf, p as ScrollState, r as fitScaleOf, rt as systemsFor, s as Box, t as CONTENT$1, tt as resource, u as Escapable, x as Text, z as Overlay } from "./visual-ClVRMUAi.mjs";
6
6
  import { a as isDev } from "./dev-BNndV-hU.mjs";
7
7
  import { n as isComponentDefinition } from "./component-DGg5DqKK.mjs";
8
8
  import { n as flatten } from "./flatten-nUX9PpGo.mjs";
@@ -5872,7 +5872,7 @@ function restorePosition(ctx, _modules, bookmark) {
5872
5872
  * @param view - The last view.
5873
5873
  * @returns True while nothing the state shows moved.
5874
5874
  */
5875
- function isCurrent$1(ctx, view) {
5875
+ function isCurrent$2(ctx, view) {
5876
5876
  const runner = ctx.state.runner;
5877
5877
  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;
5878
5878
  }
@@ -5913,7 +5913,7 @@ function viewOf(ctx) {
5913
5913
  function readState(ctx) {
5914
5914
  const runner = ctx.state.runner;
5915
5915
  const view = runner.view;
5916
- if (view !== void 0 && isCurrent$1(ctx, view)) return view.state;
5916
+ if (view !== void 0 && isCurrent$2(ctx, view)) return view.state;
5917
5917
  const next = viewOf(ctx);
5918
5918
  runner.view = next;
5919
5919
  return next.state;
@@ -11262,6 +11262,7 @@ function reconcileOne(pctx, spec, mounted, snapshot, options, counts, used) {
11262
11262
  return;
11263
11263
  }
11264
11264
  mounted.items = next;
11265
+ mounted.seen = snapshot;
11265
11266
  if (options.direct) flushMounted(pctx, mounted);
11266
11267
  if (options.converge) convergeMounted(pctx, mounted);
11267
11268
  else {
@@ -11271,6 +11272,20 @@ function reconcileOne(pctx, spec, mounted, snapshot, options, counts, used) {
11271
11272
  counts.queued += mounted.queue.length;
11272
11273
  }
11273
11274
  /**
11275
+ * Tells whether a play reconcile would change nothing in a projection: its last reconcile read the same `player` and
11276
+ * `session` trees, and nothing forces, converges or settles it. A scene mounts its projections direct after the commit
11277
+ * of its node; the next frame's reconcile of that commit then skips them (≈2 ms a switch on a Galaxy A26, F61).
11278
+ *
11279
+ * @param mounted - The mounted projection.
11280
+ * @param snapshot - The state of this reconcile.
11281
+ * @param options - How this reconcile runs.
11282
+ * @returns True when the projection is passed over.
11283
+ */
11284
+ function isCurrent$1(mounted, snapshot, options) {
11285
+ const seen = mounted.seen;
11286
+ return seen !== void 0 && !options.direct && !options.force && !options.converge && !options.settle && seen.player === snapshot.player && seen.session === snapshot.session;
11287
+ }
11288
+ /**
11274
11289
  * Runs one reconcile over every mounted projection, or over the named ones only. A converging
11275
11290
  * one then brings the elements other plugins own home and tells their owners.
11276
11291
  *
@@ -11287,7 +11302,7 @@ function reconcile(pctx, options) {
11287
11302
  for (const [name, mounted] of state.mounted) {
11288
11303
  if (options.names !== void 0 && !options.names.includes(name)) continue;
11289
11304
  const spec = state.specs.get(name);
11290
- if (spec === void 0) continue;
11305
+ if (spec === void 0 || isCurrent$1(mounted, snapshot, options)) continue;
11291
11306
  counts.projections += 1;
11292
11307
  reconcileOne(pctx, spec, mounted, snapshot, options, counts, used);
11293
11308
  }
@@ -16195,6 +16210,14 @@ function emptyMap$8() {
16195
16210
  return /* @__PURE__ */ new Map();
16196
16211
  }
16197
16212
  /**
16213
+ * Creates an empty set. Same reason as `emptyRecords`.
16214
+ *
16215
+ * @returns An empty set.
16216
+ */
16217
+ function emptySet$7() {
16218
+ return /* @__PURE__ */ new Set();
16219
+ }
16220
+ /**
16198
16221
  * Creates an empty weak set. Same reason as `emptyRecords`.
16199
16222
  *
16200
16223
  * @returns An empty weak set.
@@ -16229,6 +16252,7 @@ function createKeptState(config) {
16229
16252
  on: allowed && config.debug.kept !== false,
16230
16253
  allowed,
16231
16254
  warnedBudget: false,
16255
+ warnedBlend: emptySet$7(),
16232
16256
  verifySkipped: 0,
16233
16257
  bypassed: false,
16234
16258
  flushPasses: 0,
@@ -16317,6 +16341,7 @@ function stopKept(state) {
16317
16341
  state.flushPasses = 0;
16318
16342
  state.drawnPictures = 0;
16319
16343
  state.warnedBudget = false;
16344
+ state.warnedBlend.clear();
16320
16345
  state.verifySkipped = 0;
16321
16346
  state.bypassed = false;
16322
16347
  }
@@ -16514,7 +16539,64 @@ function ancestorsPlain(kctx, record, stage) {
16514
16539
  function eligible(kctx, record, view, stack) {
16515
16540
  const { top } = record;
16516
16541
  const stage = kctx.host.stage();
16517
- return kctx.state.on && !kctx.state.bypassed && stage !== void 0 && stack.enabled > 0 && plainFilters(record.slots) && top.visible && top.alpha > 0 && plainBlend(top.blendMode) && plainBlend(view.object.blendMode) && topDrawn(kctx.sync, view) && ancestorsPlain(kctx, record, stage);
16542
+ return keepingOn(kctx) && stage !== void 0 && stack.enabled > 0 && plainFilters(record.slots) && drawsAsPicture(top, view) && topDrawn(kctx.sync, view) && ancestorsPlain(kctx, record, stage);
16543
+ }
16544
+ /**
16545
+ * Tells whether the kept cache may keep anything this frame: switched on and not bypassed.
16546
+ *
16547
+ * @param kctx - The kept context.
16548
+ * @returns True when pictures may be kept.
16549
+ */
16550
+ function keepingOn(kctx) {
16551
+ return kctx.state.on && !kctx.state.bypassed;
16552
+ }
16553
+ /**
16554
+ * Tells whether a top and its visual draw as a picture does: the top visible and not faded out, both blending
16555
+ * normally (the visual sits inside the wrapper of a kept bare view, D4).
16556
+ *
16557
+ * @param top - The record's top.
16558
+ * @param view - Its view.
16559
+ * @returns True when they do.
16560
+ */
16561
+ function drawsAsPicture(top, view) {
16562
+ return top.visible && top.alpha > 0 && plainBlend(top.blendMode) && plainBlend(view.object.blendMode);
16563
+ }
16564
+ /**
16565
+ * Tells whether a node under a top blends otherwise than normally, at any depth (F68): drawn into a picture, it would
16566
+ * blend with the picture's empty texture, not with what is drawn under the view, and a glow would cover the board under
16567
+ * it instead of lighting it. Recursive, by index over `children`: allocates nothing.
16568
+ *
16569
+ * @param node - The top, or a node under it; its own blend is not read (`eligible` reads the top's and the visual's).
16570
+ * @returns True when a node under it blends otherwise.
16571
+ */
16572
+ function blendsBelow(node) {
16573
+ const { children } = node;
16574
+ for (let index = 0; index < children.length; index += 1) {
16575
+ const child = children[index];
16576
+ if (child !== void 0 && (!plainBlend(child.blendMode) || blendsBelow(child))) return true;
16577
+ }
16578
+ return false;
16579
+ }
16580
+ /**
16581
+ * Tells whether a record's subtree holds a node that blends otherwise than normally, and remembers a yes (`blended`):
16582
+ * a refused record is not walked again until a change under its top clears it (`stepRecord`). The first refusal of a
16583
+ * top is warned once per run, by its label.
16584
+ *
16585
+ * @param kctx - The kept context.
16586
+ * @param record - The record.
16587
+ * @returns True when the record may not be taken.
16588
+ */
16589
+ function blendedBelow(kctx, record) {
16590
+ if (record.blended) return true;
16591
+ if (!blendsBelow(record.top)) return false;
16592
+ record.blended = true;
16593
+ const { warnedBlend } = kctx.state;
16594
+ const label = labelOf$3(record.top);
16595
+ if (!warnedBlend.has(label)) {
16596
+ warnedBlend.add(label);
16597
+ kctx.ctx.log.warn("renderer:kept-blend", { picture: label });
16598
+ }
16599
+ return true;
16518
16600
  }
16519
16601
  /**
16520
16602
  * Tells whether a view of kind `"Display"` hangs in a subtree, the top included: a game's own object it may write
@@ -16582,7 +16664,8 @@ function createRecord$1(kctx, pixi, entity, view, slots, category) {
16582
16664
  refused: 0,
16583
16665
  cut: false,
16584
16666
  direct: false,
16585
- displays: void 0
16667
+ displays: void 0,
16668
+ blended: false
16586
16669
  };
16587
16670
  const { state } = kctx;
16588
16671
  state.records.push(record);
@@ -16761,8 +16844,8 @@ function writeTakeKeys(record, now, box) {
16761
16844
  /**
16762
16845
  * Takes or retakes the picture of a record: stages 1, 2 (when its first filter has inputs) and 3, at the phase of this
16763
16846
  * frame. The bounds are cut to the filter area of the top when it holds one (F43). Refused, and the view drawn live,
16764
- * for a `Display` in the subtree without `onDemand`, empty bounds, a picture over 4096 device pixels, or a budget that
16765
- * has no room (P6.3, warned once).
16847
+ * for a `Display` in the subtree without `onDemand`, a node under the top that blends otherwise (F68, warned once per
16848
+ * top), empty bounds, a picture over 4096 device pixels, or a budget that has no room (P6.3, warned once).
16766
16849
  *
16767
16850
  * @param kctx - The kept context.
16768
16851
  * @param pixi - The module that draws.
@@ -16775,6 +16858,7 @@ function writeTakeKeys(record, now, box) {
16775
16858
  function takePicture(kctx, pixi, matrices, record, view, now) {
16776
16859
  const { top } = record;
16777
16860
  if (!kctx.ctx.config.onDemand && holdsDisplay(kctx, top)) return false;
16861
+ if (blendedBelow(kctx, record)) return false;
16778
16862
  const drawn = drawnBounds(pixi, top);
16779
16863
  if (!drawn.isValid) return false;
16780
16864
  const local = clippedToArea(drawn, top.filterArea);
@@ -17114,7 +17198,8 @@ function wrapBare(kctx, record, view) {
17114
17198
  }
17115
17199
  /**
17116
17200
  * A live record's frame (P4.2): still frames are counted; at two (eight after churning) the record is taken, a bare
17117
- * view in a wrapper of its own (D4).
17201
+ * view in a wrapper of its own (D4). A record with a node under its top that blends otherwise stays live, before any
17202
+ * wrap (F68).
17118
17203
  *
17119
17204
  * @param kctx - The kept context.
17120
17205
  * @param pixi - The module that draws.
@@ -17129,6 +17214,7 @@ function stepLive(kctx, pixi, matrices, record, view) {
17129
17214
  record.still = changed ? 0 : record.still + 1;
17130
17215
  if (record.still < (record.calm ? 8 : 2)) return;
17131
17216
  if (record.refused > 0 && state.bytes + record.refused - record.bytes > state.budget) return;
17217
+ if (blendedBelow(kctx, record)) return;
17132
17218
  wrapBare(kctx, record, view);
17133
17219
  if (takePicture(kctx, pixi, matrices, record, view, NOW)) toKept(kctx, record, view);
17134
17220
  else record.still = 0;
@@ -17311,7 +17397,8 @@ function stepCalm(kctx, record) {
17311
17397
  }
17312
17398
  /**
17313
17399
  * One drawn frame of a record, inside the flush: eligibility, the keys of this frame, the policy of its life, the quad
17314
- * of a kept one; the dirty flags are cleared.
17400
+ * of a kept one; the dirty flags are cleared. A change under the top forgets a blended node found before: the next
17401
+ * take walks the subtree again (F68).
17315
17402
  *
17316
17403
  * @param kctx - The kept context.
17317
17404
  * @param pixi - The module that draws.
@@ -17321,6 +17408,7 @@ function stepCalm(kctx, record) {
17321
17408
  */
17322
17409
  function stepRecord(kctx, pixi, matrices, record, view) {
17323
17410
  noteDrawing(kctx, record, BEFORE);
17411
+ if (record.dirtyS1) record.blended = false;
17324
17412
  NOW.r = kctx.host.resolution();
17325
17413
  readStack(record.slots, NOW.r, pixi.AlphaFilter, NOW);
17326
17414
  if (eligible(kctx, record, view, NOW)) stepEligible(kctx, pixi, matrices, record, view);
@@ -23947,10 +24035,21 @@ const PROBE_SIZE = 4;
23947
24035
  /** The content subjects the renderer warms itself, in queue order; the text follows once a font is installed. */
23948
24036
  const CONTENT = Object.freeze([
23949
24037
  "sprite",
24038
+ "sprite-add",
24039
+ "sprite-screen",
23950
24040
  "graphics",
23951
24041
  "particles-normal",
23952
24042
  "particles-add"
23953
24043
  ]);
24044
+ /** The probe object of each content subject the tree holds from its first warm frame on. */
24045
+ const FIXED_NODES = Object.freeze({
24046
+ sprite: "sprite",
24047
+ "sprite-add": "spriteAdd",
24048
+ "sprite-screen": "spriteScreen",
24049
+ graphics: "graphics",
24050
+ "particles-normal": "particlesNormal",
24051
+ "particles-add": "particlesAdd"
24052
+ });
23954
24053
  /** Every target a WebGPU pipeline is keyed by. */
23955
24054
  const ALL_VARIANTS = Object.freeze([
23956
24055
  "canvas",
@@ -23980,16 +24079,18 @@ function particleProperties() {
23980
24079
  }
23981
24080
  /**
23982
24081
  * A 4 × 4 sprite of Pixi's white texture: the batch pipeline of sprites, nine-slices, plain bitmap text and batched
23983
- * graphics.
24082
+ * graphics, with the blend of a `Sprite` or a `NineSlice` glow (part of a WebGPU pipeline's key).
23984
24083
  *
23985
24084
  * @param pixi - The module that draws.
23986
- * @returns The sprite.
24085
+ * @param blend - `"normal"` (Pixi's own mode left as it is), `"add"` or `"screen"`.
24086
+ * @returns The sprite, labelled `renderer.warm.sprite`, or `renderer.warm.sprite-<blend>` for a glow.
23987
24087
  */
23988
- function whiteSprite(pixi) {
24088
+ function whiteSprite(pixi, blend = "normal") {
23989
24089
  const sprite = new pixi.Sprite(pixi.Texture.WHITE);
23990
- sprite.label = "renderer.warm.sprite";
24090
+ sprite.label = blend === "normal" ? "renderer.warm.sprite" : `renderer.warm.sprite-${blend}`;
23991
24091
  sprite.width = PROBE_SIZE;
23992
24092
  sprite.height = PROBE_SIZE;
24093
+ if (blend !== "normal") sprite.blendMode = blend;
23993
24094
  return sprite;
23994
24095
  }
23995
24096
  /**
@@ -24052,6 +24153,8 @@ function buildObjects(pixi) {
24052
24153
  holder,
24053
24154
  list: [],
24054
24155
  sprite: whiteSprite(pixi),
24156
+ spriteAdd: whiteSprite(pixi, "add"),
24157
+ spriteScreen: whiteSprite(pixi, "screen"),
24055
24158
  graphics,
24056
24159
  particlesNormal: particlesOf(pixi, "normal"),
24057
24160
  particlesAdd: particlesOf(pixi, "add"),
@@ -24075,6 +24178,8 @@ function dropObjects(warm) {
24075
24178
  objects.clipFrame,
24076
24179
  objects.holder,
24077
24180
  objects.sprite,
24181
+ objects.spriteAdd,
24182
+ objects.spriteScreen,
24078
24183
  objects.graphics,
24079
24184
  objects.particlesNormal,
24080
24185
  objects.particlesAdd,
@@ -24251,27 +24356,22 @@ function warmFilter(sctx, filter) {
24251
24356
  * @returns The node, or `undefined` for the text without a font.
24252
24357
  */
24253
24358
  function nodeOf$1(pixi, warm, objects, subject) {
24254
- switch (subject) {
24255
- case "sprite": return objects.sprite;
24256
- case "graphics": return objects.graphics;
24257
- case "particles-normal": return objects.particlesNormal;
24258
- case "particles-add": return objects.particlesAdd;
24259
- case "text":
24260
- if (warm.textFont === void 0) return void 0;
24261
- objects.text ??= new pixi.BitmapText({
24262
- label: "renderer.warm.text",
24263
- text: "0",
24264
- style: {
24265
- fontFamily: warm.textFont,
24266
- fontSize: 16
24267
- }
24268
- });
24269
- return objects.text;
24270
- default:
24271
- objects.list[0] = subject;
24272
- objects.holder.filters = objects.list;
24273
- return objects.holder;
24274
- }
24359
+ if (typeof subject !== "string") {
24360
+ objects.list[0] = subject;
24361
+ objects.holder.filters = objects.list;
24362
+ return objects.holder;
24363
+ }
24364
+ if (subject !== "text") return objects[FIXED_NODES[subject]];
24365
+ if (warm.textFont === void 0) return void 0;
24366
+ objects.text ??= new pixi.BitmapText({
24367
+ label: "renderer.warm.text",
24368
+ text: "0",
24369
+ style: {
24370
+ fontFamily: warm.textFont,
24371
+ fontSize: 16
24372
+ }
24373
+ });
24374
+ return objects.text;
24275
24375
  }
24276
24376
  /**
24277
24377
  * The name of a probe's filter in the log: the name of its WebGPU program (the definition id of one of `effects`'
@@ -25077,7 +25177,8 @@ function poolKeyOf(kind, textureKey, hollow) {
25077
25177
  return hollow ? `${kind}.hollow:${textureKey}` : `${kind}:${textureKey}`;
25078
25178
  }
25079
25179
  /**
25080
- * Brings the object of a view that left back to the state a fresh one has: detached, drawn, opaque, unmoved, white.
25180
+ * Brings the object of a view that left back to the state a fresh one has: detached, drawn, opaque, unmoved, blending
25181
+ * normally, white.
25081
25182
  * Its hidden reasons go with `clearHidden`, so a pooled object never brings the reason of the view it belonged to; a
25082
25183
  * sprite and a graphics lose their tint (a fill-only shape is coloured by it, spec 26 R2.1).
25083
25184
  *
@@ -25089,20 +25190,25 @@ function resetObject(view) {
25089
25190
  resetNode(view.object);
25090
25191
  }
25091
25192
  /**
25092
- * Brings a detached object back to a fresh one's pose and look: drawn, opaque, unmoved, white for a sprite and a
25093
- * graphics.
25193
+ * Brings a detached object back to a fresh one's pose and look: drawn, opaque, unmoved, blending as its parent (no
25194
+ * glow of the view it drew for), white for a sprite and a graphics, and a graphics without a path.
25094
25195
  *
25095
25196
  * @param object - The object, out of the tree.
25096
25197
  */
25097
25198
  function resetNode(object) {
25098
25199
  object.visible = true;
25099
25200
  object.alpha = 1;
25201
+ object.blendMode = "inherit";
25100
25202
  object.rotation = 0;
25101
25203
  object.zIndex = 0;
25102
25204
  object.position.set(0, 0);
25103
25205
  object.pivot.set(0, 0);
25104
25206
  object.scale.set(1);
25105
- if (isSprite(object) || isGraphics(object)) object.tint = 16777215;
25207
+ if (isSprite(object)) object.tint = 16777215;
25208
+ if (isGraphics(object)) {
25209
+ object.tint = 16777215;
25210
+ object.clear();
25211
+ }
25106
25212
  }
25107
25213
  /**
25108
25214
  * Takes an object out of its pool, if one is waiting.
@@ -27197,6 +27303,42 @@ function retargetKey(state, entity, view, key) {
27197
27303
  if (key !== "") trackKey(state, entity, key);
27198
27304
  }
27199
27305
  /**
27306
+ * The Pixi blend mode a `blend` of a `Sprite` or a `NineSlice` draws with: `"add"` and `"screen"` as they are, Pixi's
27307
+ * own blends of the sprite batch; `"inherit"`, what a fresh object holds, for `"normal"` and a value without the field.
27308
+ * Any other string (Pixi's advanced modes, `"multiply"`, a typo) is never handed to Pixi, which would draw an advanced
27309
+ * mode through a filter pass of its own: it draws normally, and each such value is warned once per run.
27310
+ *
27311
+ * @param sctx - Domain context of the sync module.
27312
+ * @param blend - The component's `blend`, as a game wrote it.
27313
+ * @returns The Pixi blend mode.
27314
+ */
27315
+ function blendModeOf(sctx, blend) {
27316
+ if (blend === void 0 || blend === "normal") return "inherit";
27317
+ if (blend === "add" || blend === "screen") return blend;
27318
+ const warned = sctx.ctx.state.sync.blendWarned;
27319
+ if (!warned.has(blend)) {
27320
+ warned.add(blend);
27321
+ sctx.ctx.log.warn("renderer:blend-unknown", { blend });
27322
+ }
27323
+ return "inherit";
27324
+ }
27325
+ /**
27326
+ * Writes the blend of a sprite or a nine-slice on its own object, never on the wrapper: a glow blends its own pixels,
27327
+ * not its children's. Only a mode that differs from what Pixi holds is written, the entity marked before: Pixi rebuilds
27328
+ * the instructions of the render group on a new mode.
27329
+ *
27330
+ * @param sctx - Domain context of the sync module.
27331
+ * @param entity - The entity.
27332
+ * @param object - The view's own object: the sprite, or the nine-slice, filled or hollow.
27333
+ * @param blend - The component's `blend`.
27334
+ */
27335
+ function writeBlend(sctx, entity, object, blend) {
27336
+ const mode = blendModeOf(sctx, blend);
27337
+ if (object.blendMode === mode) return;
27338
+ markView(sctx, entity);
27339
+ object.blendMode = mode;
27340
+ }
27341
+ /**
27200
27342
  * Writes the texture and the tint of a sprite, each only when it differs from what Pixi holds.
27201
27343
  *
27202
27344
  * @param sctx - Domain context of the sync module.
@@ -27325,7 +27467,7 @@ function applyHud(sctx, entity, view, factor) {
27325
27467
  }
27326
27468
  /**
27327
27469
  * Writes the `Sprite` component onto its display object: the texture, or its crop for a `"cover"`
27328
- * sprite, the stretch into the box, and the box as the hit box, the anchor applied. The stretch and
27470
+ * sprite, the blend, the stretch into the box, and the box as the hit box, the anchor applied. The stretch and
27329
27471
  * the hit box are written into the view's own objects. A new stretch of a view without a wrapper
27330
27472
  * changes the object's own units, so its filter area is written again (B4); a write that keeps it (an
27331
27473
  * alpha tween, a tint) writes no area.
@@ -27345,7 +27487,7 @@ function applySprite(sctx, entity, view) {
27345
27487
  return stretched;
27346
27488
  }
27347
27489
  /**
27348
- * The body of `applySprite`: the texture or its crop, the stretch, the anchor and the hit box.
27490
+ * The body of `applySprite`: the texture or its crop, the blend, the stretch, the anchor and the hit box.
27349
27491
  *
27350
27492
  * @param sctx - Domain context of the sync module.
27351
27493
  * @param entity - The entity.
@@ -27360,6 +27502,7 @@ function writeSprite(sctx, entity, view) {
27360
27502
  if (texture === void 0) warnMissing(sctx, value.texture);
27361
27503
  view.placeholder = texture === void 0;
27362
27504
  applyAlpha(sctx, entity, view, value);
27505
+ writeBlend(sctx, entity, object, value.blend);
27363
27506
  if (texture === void 0) {
27364
27507
  showCover(sctx, entity, view, "");
27365
27508
  drawPlaceholder(sctx, entity, object, view, value, placeholderTexture(sctx));
@@ -27425,8 +27568,9 @@ function writeSlice(sctx, entity, object, field, value) {
27425
27568
  * Writes the `NineSlice` component onto its display object and recomputes the hit box. Pixi reads
27426
27569
  * a texture's borders only in the constructor, so they are copied here on every write: a pooled
27427
27570
  * object, a new key and a reloaded bundle all get the insets of the texture they draw now. A `hollow` that flipped
27428
- * swaps the view's object first (F59). The hit box is the whole panel, hollow or not. A write that changes nothing,
27429
- * or only the size, allocates nothing: the borders, the outline's shape and the clip's box are written in place.
27571
+ * swaps the view's object first (F59), and the blend is written on the object drawn now. The hit box is the whole
27572
+ * panel, hollow or not. A write that changes nothing, or only the size, allocates nothing: the borders, the outline's
27573
+ * shape and the clip's box are written in place.
27430
27574
  *
27431
27575
  * @param sctx - Domain context of the sync module.
27432
27576
  * @param entity - The entity.
@@ -27457,6 +27601,7 @@ function applyNineSlice(sctx, entity, view) {
27457
27601
  changed = writeSlice(sctx, entity, object, "bottomHeight", borders.bottom) || changed;
27458
27602
  changed = writeSlice(sctx, entity, object, "tint", missing ? 16711935 : value.tint) || changed;
27459
27603
  applyAlpha(sctx, entity, view, value);
27604
+ writeBlend(sctx, entity, object, value.blend);
27460
27605
  changed = writeSlice(sctx, entity, object, "width", value.width) || changed;
27461
27606
  changed = writeSlice(sctx, entity, object, "height", value.height) || changed;
27462
27607
  writeHitBox(view.hitBox, value.width, value.height);
@@ -28196,6 +28341,7 @@ function stopSync(state) {
28196
28341
  state.byKey.clear();
28197
28342
  state.invalidated.clear();
28198
28343
  state.warned.clear();
28344
+ state.blendWarned.clear();
28199
28345
  state.added.clear();
28200
28346
  state.removed.clear();
28201
28347
  state.reparented.clear();
@@ -31547,6 +31693,7 @@ function createSyncState(debug) {
31547
31693
  slices: emptyWeakSet(),
31548
31694
  invalidated: emptySet$4(),
31549
31695
  warned: emptySet$4(),
31696
+ blendWarned: emptySet$4(),
31550
31697
  added: emptySet$4(),
31551
31698
  removed: emptySet$4(),
31552
31699
  reparented: emptySet$4(),
@@ -38180,11 +38327,121 @@ function applyAllGains(state) {
38180
38327
  applyGain(state, "sfx");
38181
38328
  }
38182
38329
  //#endregion
38330
+ //#region src/plugins/audio/named.ts
38331
+ /**
38332
+ * @file audio plugin — named sounds: an `sfx` with an `id` plays through a gain of its own into its
38333
+ * bus, so the next sound with that id, `stopSfx` and `app.audio.stop` can fade it out. One entry
38334
+ * per id in `state.named`; the entry object is the token a sound still decoding checks before it
38335
+ * starts. A sound without an id never comes here.
38336
+ */
38337
+ /**
38338
+ * The key a sound with an id waits under in `pendingSfx` while the unlock resumes the context.
38339
+ * The `#` keeps the ids apart from the asset keys, so the latest sound per id waits.
38340
+ *
38341
+ * @param id - The id of the sound.
38342
+ * @returns The key of the waiting sound.
38343
+ * @example
38344
+ * ```ts
38345
+ * waitingKey("voice"); // "#voice"
38346
+ * ```
38347
+ */
38348
+ function waitingKey(id) {
38349
+ return `#${id}`;
38350
+ }
38351
+ /**
38352
+ * Fades one named sound out on the context clock and stops its source at the end of the fade. A
38353
+ * sound still decoding has neither a gain nor a source, so nothing is scheduled.
38354
+ *
38355
+ * @param state - The plugin state.
38356
+ * @param entry - The sound to fade.
38357
+ * @param seconds - Length of the fade, in seconds of the context clock.
38358
+ */
38359
+ function fadeNamed(state, entry, seconds) {
38360
+ const context = state.context;
38361
+ if (context === void 0) return;
38362
+ const now = context.currentTime;
38363
+ if (entry.gain !== void 0) ramp(entry.gain, 0, seconds, now);
38364
+ entry.source?.stop(now + seconds);
38365
+ }
38366
+ /**
38367
+ * Takes an id for a new sound: the sound that plays, or decodes, under it fades out over the fade
38368
+ * of the new one, and a fresh entry takes its place.
38369
+ *
38370
+ * @param state - The plugin state.
38371
+ * @param id - The id of the new sound.
38372
+ * @param seconds - Length of the fade of the replaced sound, in seconds.
38373
+ * @returns The entry of the new sound, its token.
38374
+ */
38375
+ function claimNamed(state, id, seconds) {
38376
+ const playing = state.named.get(id);
38377
+ if (playing !== void 0) fadeNamed(state, playing, seconds);
38378
+ const entry = {
38379
+ gain: void 0,
38380
+ source: void 0
38381
+ };
38382
+ state.named.set(id, entry);
38383
+ return entry;
38384
+ }
38385
+ /**
38386
+ * Starts one named sound: a gain at 1 into the bus, the source into that gain. When the source
38387
+ * ends, its gain is disconnected and the id is freed, unless a later sound took the id meanwhile.
38388
+ *
38389
+ * @param state - The plugin state.
38390
+ * @param context - The running audio context.
38391
+ * @param named - The id, its entry, the decoded buffer and the gain of the bus.
38392
+ * @param named.id - The id the sound plays under.
38393
+ * @param named.entry - The entry `claimNamed` returned for it.
38394
+ * @param named.buffer - The decoded buffer of the sound.
38395
+ * @param named.bus - The gain node of the bus the sound goes to.
38396
+ */
38397
+ function startNamed(state, context, named) {
38398
+ const { id, entry } = named;
38399
+ const gain = context.createGain();
38400
+ const source = context.createBufferSource();
38401
+ gain.gain.value = 1;
38402
+ gain.connect(named.bus);
38403
+ source.buffer = named.buffer;
38404
+ source.connect(gain);
38405
+ source.addEventListener("ended", () => {
38406
+ gain.disconnect();
38407
+ if (state.named.get(id) === entry) state.named.delete(id);
38408
+ }, { once: true });
38409
+ entry.gain = gain;
38410
+ entry.source = source;
38411
+ source.start();
38412
+ }
38413
+ /**
38414
+ * Ends the sound under one id: the stop of `stopSfx` and of `app.audio.stop`. The entry leaves the
38415
+ * map, so a sound still decoding starts nothing, and the one that plays fades out. A sound with
38416
+ * that id waiting for the unlock is dropped. An id nothing plays under changes nothing.
38417
+ *
38418
+ * @param state - The plugin state.
38419
+ * @param id - The id the sound was started with.
38420
+ * @param fadeMs - Length of the fade, in real milliseconds.
38421
+ */
38422
+ function stopNamed(state, id, fadeMs) {
38423
+ state.pendingSfx.delete(waitingKey(id));
38424
+ const entry = state.named.get(id);
38425
+ if (entry === void 0) return;
38426
+ state.named.delete(id);
38427
+ fadeNamed(state, entry, fadeMs / 1e3);
38428
+ }
38429
+ /**
38430
+ * Stops every named sound at once and forgets the ids. `onStop` runs it: a teardown has no time
38431
+ * for a fade, and the context is closed right after.
38432
+ *
38433
+ * @param state - The plugin state.
38434
+ */
38435
+ function stopAllNamed(state) {
38436
+ for (const entry of state.named.values()) entry.source?.stop();
38437
+ state.named.clear();
38438
+ }
38439
+ //#endregion
38183
38440
  //#region src/plugins/audio/api.ts
38184
38441
  /**
38185
- * @file audio plugin — API factory. Three buses, the unlock flag and the journal of started sounds. Nothing here
38186
- * is persisted; a game keeps the player's choice in the player state and `config.volumes` applies
38187
- * it on the next commit.
38442
+ * @file audio plugin — API factory. Three buses, the unlock flag, the journal of started sounds and the stop of a
38443
+ * named sound. Nothing here is persisted; a game keeps the player's choice in the player state and
38444
+ * `config.volumes` applies it on the next commit.
38188
38445
  */
38189
38446
  /**
38190
38447
  * Brings a value into 0..1. `NaN`, which is what a slider divided by zero produces, becomes 0.
@@ -38226,7 +38483,7 @@ function setBusVolume(state, bus, value) {
38226
38483
  applyGain(state, bus);
38227
38484
  }
38228
38485
  /**
38229
- * Creates the audio API: the three buses, the unlock flag and the journal.
38486
+ * Creates the audio API: the three buses, the unlock flag, the journal and the stop of a named sound.
38230
38487
  *
38231
38488
  * @param ctx - Kernel context of the audio plugin.
38232
38489
  * @returns The plugin API.
@@ -38243,7 +38500,8 @@ function createAudioApi(ctx) {
38243
38500
  },
38244
38501
  muted: (bus) => state.buses[busOf(state, bus)].muted,
38245
38502
  unlocked: () => state.unlocked,
38246
- journal: () => state.journal
38503
+ journal: () => state.journal,
38504
+ stop: (id, options) => stopNamed(state, id, options?.fadeMs ?? ctx.config.sfxFadeMs)
38247
38505
  };
38248
38506
  }
38249
38507
  //#endregion
@@ -38251,13 +38509,15 @@ function createAudioApi(ctx) {
38251
38509
  /**
38252
38510
  * Replaces the journal with a frozen list that ends with one started sound, stamped with the
38253
38511
  * elapsed game time, and keeps only the newest `config.journal` entries. A size of 0, or anything
38254
- * that is not a positive number, records nothing.
38512
+ * that is not a positive number, records nothing. The entry has an `id` only when the sound has
38513
+ * one: the caller leaves the field out otherwise, so no entry carries an `undefined` key.
38255
38514
  *
38256
38515
  * @param ctx - Domain context of the plugin.
38257
38516
  * @param sound - What started.
38258
38517
  * @param sound.key - Asset key of the sound.
38259
38518
  * @param sound.bus - The bus it plays on.
38260
38519
  * @param sound.kind - `"sfx"` for a play, `"music"` for a track.
38520
+ * @param sound.id - The id of a named sound; absent for every other sound.
38261
38521
  */
38262
38522
  function recordSound(ctx, sound) {
38263
38523
  const size = Math.floor(ctx.config.journal);
@@ -38385,18 +38645,47 @@ function fieldOf(payload, field) {
38385
38645
  return payload[field];
38386
38646
  }
38387
38647
  /**
38388
- * Reads the key and the bus out of the `sfx` descriptor `anim` builds.
38648
+ * Reads the fade of a descriptor that ends a named sound.
38389
38649
  *
38390
38650
  * @param descriptor - The descriptor the handler was called with.
38651
+ * @param fadeMs - The configured fade, used when the descriptor names none.
38652
+ * @returns The fade, in milliseconds.
38653
+ */
38654
+ function fadeOf(descriptor, fadeMs) {
38655
+ return validFade(fieldOf(descriptor.payload, "fadeMs"), fadeMs);
38656
+ }
38657
+ /**
38658
+ * Takes a fade a descriptor named only when the clock can schedule it: a finite number, 0 or more.
38659
+ * `NaN` or a negative fade would make the ramp and the stop throw inside the handler.
38660
+ *
38661
+ * @param fade - The `fadeMs` the payload carries.
38662
+ * @param fallback - The configured fade.
38663
+ * @returns The fade, in milliseconds.
38664
+ * @example
38665
+ * ```ts
38666
+ * validFade(-5, 120); // 120
38667
+ * ```
38668
+ */
38669
+ function validFade(fade, fallback) {
38670
+ return typeof fade === "number" && Number.isFinite(fade) && fade >= 0 ? fade : fallback;
38671
+ }
38672
+ /**
38673
+ * Reads the key, the bus, the id and the fade out of the `sfx` descriptor `anim` builds.
38674
+ *
38675
+ * @param descriptor - The descriptor the handler was called with.
38676
+ * @param fadeMs - The configured fade of a named sound, used when the descriptor names none.
38391
38677
  * @returns The request, or `undefined` when the descriptor names no key.
38392
38678
  */
38393
- function sfxOf(descriptor) {
38679
+ function sfxOf(descriptor, fadeMs) {
38394
38680
  const key = fieldOf(descriptor.payload, "key");
38395
38681
  const bus = fieldOf(descriptor.payload, "bus");
38682
+ const id = fieldOf(descriptor.payload, "id");
38396
38683
  if (typeof key !== "string") return void 0;
38397
38684
  return {
38398
38685
  key,
38399
- bus: typeof bus === "string" ? bus : DEFAULT_BUS$1
38686
+ bus: typeof bus === "string" ? bus : DEFAULT_BUS$1,
38687
+ id: typeof id === "string" ? id : void 0,
38688
+ fadeMs: fadeOf(descriptor, fadeMs)
38400
38689
  };
38401
38690
  }
38402
38691
  /**
@@ -38408,10 +38697,9 @@ function sfxOf(descriptor) {
38408
38697
  */
38409
38698
  function musicOf(descriptor, fadeMs) {
38410
38699
  const key = fieldOf(descriptor.payload, "key");
38411
- const fade = fieldOf(descriptor.payload, "fadeMs");
38412
38700
  return {
38413
38701
  key: typeof key === "string" ? key : null,
38414
- fadeMs: typeof fade === "number" ? fade : fadeMs
38702
+ fadeMs: validFade(fieldOf(descriptor.payload, "fadeMs"), fadeMs)
38415
38703
  };
38416
38704
  }
38417
38705
  /**
@@ -38470,9 +38758,10 @@ async function bufferOf(ctx, key) {
38470
38758
  /**
38471
38759
  * Plays one sound: the handler of the kind `"sfx"`. Every play is its own source, so the same key
38472
38760
  * twice in one frame is heard twice, and the promise resolves when the sound STARTED. The handler
38473
- * ignores its signal: a sound that began plays to its end. While a gesture's `resume()` is
38474
- * pending the sound waits in `pendingSfx`, one per key. A locked context no gesture is resuming
38475
- * drops it: the start attempt of the unlock queues nothing.
38761
+ * ignores its signal: a sound that began plays to its end, unless a sound with its id or a stop
38762
+ * ends it. While a gesture's `resume()` is pending the sound waits in `pendingSfx`, one per id
38763
+ * and one per key without an id. A locked context no gesture is resuming drops it: the start
38764
+ * attempt of the unlock queues nothing.
38476
38765
  *
38477
38766
  * @param ctx - Domain context of the plugin.
38478
38767
  * @param descriptor - The `sfx` descriptor `anim` built.
@@ -38480,20 +38769,79 @@ async function bufferOf(ctx, key) {
38480
38769
  async function playSfx(ctx, descriptor) {
38481
38770
  const state = ctx.state;
38482
38771
  if (state.context === void 0) return;
38483
- const request = sfxOf(descriptor);
38772
+ const request = sfxOf(descriptor, ctx.config.sfxFadeMs);
38484
38773
  if (request === void 0) return;
38485
38774
  if (!state.unlocked) {
38486
- if (state.resuming) state.pendingSfx.set(request.key, request);
38775
+ queueWhileResuming(state, request);
38487
38776
  return;
38488
38777
  }
38489
38778
  await playRequest(ctx, request);
38490
38779
  }
38491
38780
  /**
38781
+ * Keeps a sound fired while the context is locked, one per key and one per id, when a gesture's
38782
+ * `resume()` is on its way. A locked context nobody resumes drops it.
38783
+ *
38784
+ * @param state - The plugin state.
38785
+ * @param request - The sound that was fired.
38786
+ */
38787
+ function queueWhileResuming(state, request) {
38788
+ if (!state.resuming) return;
38789
+ state.pendingSfx.set(request.id === void 0 ? request.key : waitingKey(request.id), request);
38790
+ }
38791
+ /**
38792
+ * Ends the sound under one id: the handler of the kind `"stopSfx"`, for an effect, an animation
38793
+ * step and a released hint alike. A descriptor without an id ends nothing.
38794
+ *
38795
+ * @param ctx - Domain context of the plugin.
38796
+ * @param descriptor - The `stopSfx` descriptor `anim` built.
38797
+ */
38798
+ function stopNamedSfx(ctx, descriptor) {
38799
+ const id = fieldOf(descriptor.payload, "id");
38800
+ if (typeof id !== "string") return;
38801
+ stopNamed(ctx.state, id, fadeOf(descriptor, ctx.config.sfxFadeMs));
38802
+ }
38803
+ /**
38804
+ * Plays a sound under its id: the sound that has the id fades out over the fade of this one, and
38805
+ * this one starts through its own gain once its buffer is there, unless a later sound with the id
38806
+ * or a stop took its place while it decoded. A file that is missing frees the id.
38807
+ *
38808
+ * @param ctx - Domain context of the plugin.
38809
+ * @param request - The key, the fade and the id of the sound.
38810
+ * @param sound - The id and the bus, both checked.
38811
+ * @param sound.id - The id the sound plays under.
38812
+ * @param sound.bus - The bus it goes to.
38813
+ */
38814
+ async function playNamed(ctx, request, sound) {
38815
+ const state = ctx.state;
38816
+ const { id, bus } = sound;
38817
+ const entry = claimNamed(state, id, request.fadeMs / 1e3);
38818
+ const buffer = await bufferOf(ctx, request.key);
38819
+ if (state.named.get(id) !== entry) return;
38820
+ const context = state.context;
38821
+ const gain = state.buses[bus].gain;
38822
+ if (buffer === void 0 || context === void 0 || gain === void 0) {
38823
+ state.named.delete(id);
38824
+ return;
38825
+ }
38826
+ startNamed(state, context, {
38827
+ id,
38828
+ entry,
38829
+ buffer,
38830
+ bus: gain
38831
+ });
38832
+ recordSound(ctx, {
38833
+ key: request.key,
38834
+ bus,
38835
+ kind: "sfx",
38836
+ id
38837
+ });
38838
+ }
38839
+ /**
38492
38840
  * Starts one source of a sound on its bus. `playSfx` calls it for a live play and the unlock for
38493
- * every sound that waited for the resume.
38841
+ * every sound that waited for the resume. A sound with an id goes through `playNamed`.
38494
38842
  *
38495
38843
  * @param ctx - Domain context of the plugin.
38496
- * @param request - The key and the bus of the sound.
38844
+ * @param request - The key, the bus, the id and the fade of the sound.
38497
38845
  */
38498
38846
  async function playRequest(ctx, request) {
38499
38847
  const state = ctx.state;
@@ -38503,6 +38851,13 @@ async function playRequest(ctx, request) {
38503
38851
  ctx.log.warn("audio: unknown bus", { bus: request.bus });
38504
38852
  return;
38505
38853
  }
38854
+ if (request.id !== void 0) {
38855
+ await playNamed(ctx, request, {
38856
+ id: request.id,
38857
+ bus: request.bus
38858
+ });
38859
+ return;
38860
+ }
38506
38861
  const buffer = await bufferOf(ctx, request.key);
38507
38862
  const gain = state.buses[request.bus].gain;
38508
38863
  if (buffer === void 0 || gain === void 0) return;
@@ -39027,8 +39382,8 @@ function createContext(config) {
39027
39382
  return typeof browserContext === "function" ? new browserContext() : void 0;
39028
39383
  }
39029
39384
  /**
39030
- * Starts the plugin in `onStart`: the audio session first, before any sound exists, then both
39031
- * effect handlers, then the context with its graph and its unlock. The unlock asks the context to
39385
+ * Starts the plugin in `onStart`: the audio session first, before any sound exists, then the
39386
+ * three effect handlers, then the context with its graph and its unlock. The unlock asks the context to
39032
39387
  * run at once: a shell that lets audio play is unlocked with no touch, and a browser that wants a
39033
39388
  * gesture gets the two listeners. The handlers are registered even headless, so a node that
39034
39389
  * awaits a sound resolves in plain Bun exactly as it does in a browser.
@@ -39040,7 +39395,7 @@ function startAudio(ctx) {
39040
39395
  const audio = withDeps$4(ctx);
39041
39396
  const state = audio.state;
39042
39397
  const fx = audio.deps.flow.fx;
39043
- state.removers.push(fx.handle("sfx", (descriptor) => playSfx(audio, descriptor), { runInFast: false }), fx.handle("music", (descriptor) => playMusic(audio, musicOf(descriptor, ctx.config.musicFadeMs)), { runInFast: false }));
39398
+ state.removers.push(fx.handle("sfx", (descriptor) => playSfx(audio, descriptor), { runInFast: false }), fx.handle("stopSfx", (descriptor) => stopNamedSfx(audio, descriptor), { runInFast: false }), fx.handle("music", (descriptor) => playMusic(audio, musicOf(descriptor, ctx.config.musicFadeMs)), { runInFast: false }));
39044
39399
  const context = createContext(ctx.config);
39045
39400
  if (context === void 0) return;
39046
39401
  state.context = context;
@@ -39049,9 +39404,9 @@ function startAudio(ctx) {
39049
39404
  startUnlock(audio);
39050
39405
  }
39051
39406
  /**
39052
- * Frees everything `onStart` opened: the two handler registrations, the two window listeners, the
39053
- * music track, every streamed track still fading with its timer, the journal, the audio session
39054
- * and the context itself. The tracks are freed before the close, while their nodes can still
39407
+ * Frees everything `onStart` opened: the three handler registrations, the two window listeners,
39408
+ * the music track, every named sound, every streamed track still fading with its timer, the
39409
+ * journal, the audio session and the context itself. The tracks are freed before the close, while their nodes can still
39055
39410
  * disconnect. A browser caps how many contexts a page may hold, so the close is the point of this
39056
39411
  * teardown.
39057
39412
  *
@@ -39062,6 +39417,7 @@ async function stopAudio(state) {
39062
39417
  for (const remove of state.removers.splice(0)) remove();
39063
39418
  removeUnlock(state);
39064
39419
  stopMusic(state);
39420
+ stopAllNamed(state);
39065
39421
  for (const entry of state.retiring) disposeStream(state, entry.stream);
39066
39422
  state.decoded.clear();
39067
39423
  state.journal = Object.freeze([]);
@@ -39241,12 +39597,20 @@ function emptyWarned$2() {
39241
39597
  /**
39242
39598
  * Creates the queue of sounds fired while the unlock is pending.
39243
39599
  *
39244
- * @returns An empty queue, keyed by asset key.
39600
+ * @returns An empty queue, keyed by `"#" + id` or by asset key.
39245
39601
  */
39246
39602
  function emptyPending() {
39247
39603
  return /* @__PURE__ */ new Map();
39248
39604
  }
39249
39605
  /**
39606
+ * Creates the map of the sounds that play under an id.
39607
+ *
39608
+ * @returns An empty map, keyed by id.
39609
+ */
39610
+ function emptyNamed() {
39611
+ return /* @__PURE__ */ new Map();
39612
+ }
39613
+ /**
39250
39614
  * Creates the set of streamed tracks that fade out.
39251
39615
  *
39252
39616
  * @returns An empty set of retiring tracks.
@@ -39291,6 +39655,7 @@ function createAudioState(ctx) {
39291
39655
  unlocked: false,
39292
39656
  resuming: false,
39293
39657
  pendingSfx: emptyPending(),
39658
+ named: emptyNamed(),
39294
39659
  decoded: emptyCache$1(),
39295
39660
  warned: emptyWarned$2(),
39296
39661
  warnedVolumes: false,
@@ -39304,8 +39669,8 @@ function createAudioState(ctx) {
39304
39669
  };
39305
39670
  }
39306
39671
  /**
39307
- * Audio plugin: `app.audio`, the handlers of the effect kinds `sfx` and `music`, and the three
39308
- * buses a game drives from its player state.
39672
+ * Audio plugin: `app.audio`, the handlers of the effect kinds `sfx`, `stopSfx` and `music`, and
39673
+ * the three buses a game drives from its player state.
39309
39674
  *
39310
39675
  * @example
39311
39676
  * ```ts
@@ -39333,6 +39698,7 @@ const audioPlugin = /*#__PURE__*/ createPlugin$1("audio", {
39333
39698
  sfx: 1
39334
39699
  },
39335
39700
  musicFadeMs: 600,
39701
+ sfxFadeMs: 120,
39336
39702
  volumes: void 0,
39337
39703
  context: void 0,
39338
39704
  journal: 0,
@@ -48874,24 +49240,61 @@ function spawned(id) {
48874
49240
  }
48875
49241
  /**
48876
49242
  * A sound. `anim` owns the descriptor because it sits below `audio`; `audio` owns the handler,
48877
- * and a game without `audio` plays nothing and hears no error.
49243
+ * and a game without `audio` plays nothing and hears no error. A sound with an `id` is named:
49244
+ * one sound plays per id, a new one with the same id fades the old one out over `fadeMs`, and
49245
+ * `stopSfx(id)` stops it. The payload carries `id` and `fadeMs` only when they are given, so a
49246
+ * sound without them stays `{ key, bus }`.
48878
49247
  *
48879
49248
  * @param key - Asset key of the sound.
48880
- * @param options - The bus to play it on.
49249
+ * @param options - The bus, the name and the fade.
48881
49250
  * @param options.bus - `"sfx"` when omitted.
49251
+ * @param options.id - Name of the sound; one sound per id plays.
49252
+ * @param options.fadeMs - Fade of the sound with the same id this one replaces; `audio`'s
49253
+ * `sfxFadeMs` when omitted.
48882
49254
  * @returns The descriptor, usable as a timeline step and as `fx(...)` in a node.
48883
49255
  * @example
48884
49256
  * ```ts
48885
49257
  * sfx("board.merge"); // { kind: "sfx", payload: { key: "board.merge", bus: "sfx" }, cosmetic: true }
49258
+ * sfx("voice.hello", { id: "voice", fadeMs: 200 });
49259
+ * // { kind: "sfx", payload: { key: "voice.hello", bus: "sfx", id: "voice", fadeMs: 200 }, cosmetic: true }
48886
49260
  * ```
48887
49261
  */
48888
49262
  function sfx(key, options) {
49263
+ const payload = {
49264
+ key,
49265
+ bus: options?.bus ?? DEFAULT_BUS
49266
+ };
49267
+ if (options?.id !== void 0) payload.id = options.id;
49268
+ if (options?.fadeMs !== void 0) payload.fadeMs = options.fadeMs;
48889
49269
  return Object.freeze({
48890
49270
  kind: "sfx",
48891
- payload: Object.freeze({
48892
- key,
48893
- bus: options?.bus ?? DEFAULT_BUS
48894
- }),
49271
+ payload: Object.freeze(payload),
49272
+ cosmetic: true
49273
+ });
49274
+ }
49275
+ /**
49276
+ * Stops the named sound `sfx(key, { id })` started, fading it out over `fadeMs`. `audio` owns the
49277
+ * handler; an id nothing plays is silent. It works as `fx.start(stopSfx("voice"))` in a node, as a
49278
+ * step a timeline reaches, and as the hint `hint("stopSfx", { id: "voice" })`. A timeline that is
49279
+ * finished or cut fires no effect, this one included: a game that must stop the voice then stops
49280
+ * it from the node.
49281
+ *
49282
+ * @param id - The id the sound was played with.
49283
+ * @param options - The fade.
49284
+ * @param options.fadeMs - Fade-out in ms; `audio`'s `sfxFadeMs` when omitted.
49285
+ * @returns The descriptor, usable as a timeline step and as `fx(...)` in a node.
49286
+ * @example
49287
+ * ```ts
49288
+ * stopSfx("voice", { fadeMs: 200 }); // { kind: "stopSfx", payload: { id: "voice", fadeMs: 200 }, cosmetic: true }
49289
+ * stopSfx("voice"); // { kind: "stopSfx", payload: { id: "voice" }, cosmetic: true }
49290
+ * ```
49291
+ */
49292
+ function stopSfx(id, options) {
49293
+ const payload = { id };
49294
+ if (options?.fadeMs !== void 0) payload.fadeMs = options.fadeMs;
49295
+ return Object.freeze({
49296
+ kind: "stopSfx",
49297
+ payload: Object.freeze(payload),
48895
49298
  cosmetic: true
48896
49299
  });
48897
49300
  }
@@ -57245,7 +57648,10 @@ function createReconciler(ctx, modules, fields) {
57245
57648
  function reconcileDirtyRoot(root) {
57246
57649
  const tree = ecs.get(root.entity, Tree);
57247
57650
  const place = ctx.deps.world.projection.keyOf(root.entity);
57248
- if (place !== void 0) root.name = place.projection;
57651
+ if (place !== void 0 && place.projection !== root.name) {
57652
+ root.name = place.projection;
57653
+ hostsMoved(state);
57654
+ }
57249
57655
  if (tree !== void 0) root.tree = tree.node;
57250
57656
  refreshRootOrder(root);
57251
57657
  root.dirty = !modules.layout.loaded();
@@ -62466,6 +62872,7 @@ function cubicBezier(x1, y1, x2, y2) {
62466
62872
  * | ui | textInput | { caretWidth: 3, caret: 0x000000, selection: 0x3390ff, selectionAlpha: 0.35, composingUnderline: 3, keyboardMargin: 16 }; shallow merge |
62467
62873
  * | audio | buses | { master: 1, music: 0.6, sfx: 1 }; shallow merge: a game that sets `buses` gives all three fields |
62468
62874
  * | audio | musicFadeMs | 600 |
62875
+ * | audio | sfxFadeMs | 120 |
62469
62876
  * | audio | volumes | undefined, the buses stay at `buses` |
62470
62877
  * | audio | context | undefined, the real AudioContext |
62471
62878
  * | audio | journal | 0, no started sound is kept |
@@ -62597,4 +63004,4 @@ const screen = [
62597
63004
  uiPlugin
62598
63005
  ];
62599
63006
  //#endregion
62600
- export { sfx as $, refuseStart as $t, types_exports$10 as A, Noise as At, defineMotion as B, Frames as Bt, types_exports$3 as C, effectsPlugin as Ct, types_exports$7 as D, ColorMatrix as Dt, types_exports$6 as E, Blur as Et, types_exports$15 as F, scenesPlugin as Ft, external as G, PERF_CAUSES as Gt, platformPlugin as H, PASS_KINDS as Ht, types_exports$16 as I, assetsPlugin as It, mark as J, PROBLEM_RULES as Jt, frames as K, PERF_GAP_BUCKETS as Kt, types_exports$17 as L, animPlugin as Lt, types_exports$12 as M, defineFilter as Mt, types_exports$13 as N, debugPlugin as Nt, types_exports$8 as O, Displacement as Ot, types_exports$14 as P, audioPlugin as Pt, set as Q, flowPlugin as Qt, uiPlugin as R, AnimPlayer as Rt, types_exports$2 as S, messageDuration as St, types_exports$5 as T, Alpha as Tt, HAPTIC_KINDS as U, frameAt as Ut, textPlugin as V, rendererPlugin as Vt, defineAnimation as W, MONITOR_SECTIONS as Wt, play$1 as X, DEMAND_REASONS as Xt, parallel as Y, SHADER_WHATS as Yt, sequence as Z, worldPlugin as Zt, defineBundles as _, Touchable as _t, cubicBezier as a, SaveUnreadableError as an, wait as at, types_exports as b, i18nPlugin as bt, hint as c, clockPlugin as cn, DropTarget as ct, defineScene as d, Pointer as dt, exit as en, spawn as et, tr as f, PointerOver as ft, music as g, Tappable as gt, defineEmitter as h, Swipeable as ht, screen as i, modelPlugin as in, use as it, types_exports$11 as j, Outline as jt, types_exports$9 as k, Glow as kt, schedule as l, Held as lt, flattenFeatures as m, Pressed as mt, createPlugin as n, to as nn, stagger as nt, defineEffect as o, lifecyclePlugin as on, inputPlugin as ot, defineFeature as p, Pressable as pt, haptic as q, PERF_LOG_VERSION as qt, defineGame as r, type as rn, tween as rt, guide as s, timePlugin as sn, Draggable as st, createApp as t, slot as tn, spawned as tt, projection as u, Hovered as ut, load as v, Traceable as vt, types_exports$4 as w, Emitter as wt, types_exports$1 as x, messageArgument as xt, ready as y, Traced as yt, resolve as z, Animation as zt };
63007
+ export { sfx as $, flowPlugin as $t, types_exports$10 as A, Glow as At, defineMotion as B, Animation as Bt, types_exports$3 as C, messageDuration as Ct, types_exports$7 as D, Blur as Dt, types_exports$6 as E, Alpha as Et, types_exports$15 as F, audioPlugin as Ft, external as G, MONITOR_SECTIONS as Gt, platformPlugin as H, rendererPlugin as Ht, types_exports$16 as I, scenesPlugin as It, mark as J, PERF_LOG_VERSION as Jt, frames as K, PERF_CAUSES as Kt, types_exports$17 as L, assetsPlugin as Lt, types_exports$12 as M, Outline as Mt, types_exports$13 as N, defineFilter as Nt, types_exports$8 as O, ColorMatrix as Ot, types_exports$14 as P, debugPlugin as Pt, set as Q, worldPlugin as Qt, uiPlugin as R, animPlugin as Rt, types_exports$2 as S, messageArgument as St, types_exports$5 as T, Emitter as Tt, HAPTIC_KINDS as U, PASS_KINDS as Ut, textPlugin as V, Frames as Vt, defineAnimation as W, frameAt as Wt, play$1 as X, SHADER_WHATS as Xt, parallel as Y, PROBLEM_RULES as Yt, sequence as Z, DEMAND_REASONS as Zt, defineBundles as _, Tappable as _t, cubicBezier as a, modelPlugin as an, use as at, types_exports as b, Traced as bt, hint as c, timePlugin as cn, Draggable as ct, defineScene as d, Hovered as dt, refuseStart as en, spawn as et, tr as f, Pointer as ft, music as g, Swipeable as gt, defineEmitter as h, Pressed as ht, screen as i, type as in, tween as it, types_exports$11 as j, Noise as jt, types_exports$9 as k, Displacement as kt, schedule as l, clockPlugin as ln, DropTarget as lt, flattenFeatures as m, Pressable as mt, createPlugin as n, slot as nn, stagger as nt, defineEffect as o, SaveUnreadableError as on, wait as ot, defineFeature as p, PointerOver as pt, haptic as q, PERF_GAP_BUCKETS as qt, defineGame as r, to as rn, stopSfx as rt, guide as s, lifecyclePlugin as sn, inputPlugin as st, createApp as t, exit as tn, spawned as tt, projection as u, Held as ut, load as v, Touchable as vt, types_exports$4 as w, effectsPlugin as wt, types_exports$1 as x, i18nPlugin as xt, ready as y, Traceable as yt, resolve as z, AnimPlayer as zt };