@moku-labs/game 0.15.26 → 0.15.27

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-BTAIwlyn.d.mts} +5 -5
  8. package/dist/control.d.mts +4 -4
  9. package/dist/{define-uiyy88hF.d.mts → define-CIg6SOO0.d.mts} +2 -2
  10. package/dist/{headless-PssyKRFN.d.mts → headless-DDymHw9o.d.mts} +1 -1
  11. package/dist/{index-CM1goGHf.d.mts → index-CURQhlzb.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-jtZ4eVu7.d.mts} +1 -1
  19. package/dist/{sources-Dpc4NXvG.mjs → sources-ncFMQIus.mjs} +1 -1
  20. package/dist/{src-DWYFupNb.mjs → src-DLs4b_Vs.mjs} +458 -73
  21. package/dist/testing.d.mts +4 -4
  22. package/dist/testing.mjs +1 -1
  23. package/dist/{types-_OQ6H8WL.d.mts → types--1Zq5_6Q.d.mts} +21 -3
  24. package/dist/{types-C-wAHgpD.d.mts → types-BbuRDHDK.d.mts} +119 -20
  25. package/dist/{types-ChZ4Ej7O.d.mts → types-DFROr3sM.d.mts} +68 -11
  26. package/dist/{types-BdLJ4lTA.d.mts → types-DHxDVC5b.d.mts} +4 -4
  27. package/dist/{types-sfaYGg9l.d.mts → types-WoDZEVQI.d.mts} +2 -2
  28. package/dist/{visual-kEC9nb-o.mjs → visual-ClVRMUAi.mjs} +35 -5
  29. package/dist/{visual-DmDWJEzs.d.mts → visual-Dj1tlgUk.d.mts} +6 -6
  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";
@@ -16195,6 +16195,14 @@ function emptyMap$8() {
16195
16195
  return /* @__PURE__ */ new Map();
16196
16196
  }
16197
16197
  /**
16198
+ * Creates an empty set. Same reason as `emptyRecords`.
16199
+ *
16200
+ * @returns An empty set.
16201
+ */
16202
+ function emptySet$7() {
16203
+ return /* @__PURE__ */ new Set();
16204
+ }
16205
+ /**
16198
16206
  * Creates an empty weak set. Same reason as `emptyRecords`.
16199
16207
  *
16200
16208
  * @returns An empty weak set.
@@ -16229,6 +16237,7 @@ function createKeptState(config) {
16229
16237
  on: allowed && config.debug.kept !== false,
16230
16238
  allowed,
16231
16239
  warnedBudget: false,
16240
+ warnedBlend: emptySet$7(),
16232
16241
  verifySkipped: 0,
16233
16242
  bypassed: false,
16234
16243
  flushPasses: 0,
@@ -16317,6 +16326,7 @@ function stopKept(state) {
16317
16326
  state.flushPasses = 0;
16318
16327
  state.drawnPictures = 0;
16319
16328
  state.warnedBudget = false;
16329
+ state.warnedBlend.clear();
16320
16330
  state.verifySkipped = 0;
16321
16331
  state.bypassed = false;
16322
16332
  }
@@ -16514,7 +16524,64 @@ function ancestorsPlain(kctx, record, stage) {
16514
16524
  function eligible(kctx, record, view, stack) {
16515
16525
  const { top } = record;
16516
16526
  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);
16527
+ return keepingOn(kctx) && stage !== void 0 && stack.enabled > 0 && plainFilters(record.slots) && drawsAsPicture(top, view) && topDrawn(kctx.sync, view) && ancestorsPlain(kctx, record, stage);
16528
+ }
16529
+ /**
16530
+ * Tells whether the kept cache may keep anything this frame: switched on and not bypassed.
16531
+ *
16532
+ * @param kctx - The kept context.
16533
+ * @returns True when pictures may be kept.
16534
+ */
16535
+ function keepingOn(kctx) {
16536
+ return kctx.state.on && !kctx.state.bypassed;
16537
+ }
16538
+ /**
16539
+ * Tells whether a top and its visual draw as a picture does: the top visible and not faded out, both blending
16540
+ * normally (the visual sits inside the wrapper of a kept bare view, D4).
16541
+ *
16542
+ * @param top - The record's top.
16543
+ * @param view - Its view.
16544
+ * @returns True when they do.
16545
+ */
16546
+ function drawsAsPicture(top, view) {
16547
+ return top.visible && top.alpha > 0 && plainBlend(top.blendMode) && plainBlend(view.object.blendMode);
16548
+ }
16549
+ /**
16550
+ * Tells whether a node under a top blends otherwise than normally, at any depth (F68): drawn into a picture, it would
16551
+ * blend with the picture's empty texture, not with what is drawn under the view, and a glow would cover the board under
16552
+ * it instead of lighting it. Recursive, by index over `children`: allocates nothing.
16553
+ *
16554
+ * @param node - The top, or a node under it; its own blend is not read (`eligible` reads the top's and the visual's).
16555
+ * @returns True when a node under it blends otherwise.
16556
+ */
16557
+ function blendsBelow(node) {
16558
+ const { children } = node;
16559
+ for (let index = 0; index < children.length; index += 1) {
16560
+ const child = children[index];
16561
+ if (child !== void 0 && (!plainBlend(child.blendMode) || blendsBelow(child))) return true;
16562
+ }
16563
+ return false;
16564
+ }
16565
+ /**
16566
+ * Tells whether a record's subtree holds a node that blends otherwise than normally, and remembers a yes (`blended`):
16567
+ * a refused record is not walked again until a change under its top clears it (`stepRecord`). The first refusal of a
16568
+ * top is warned once per run, by its label.
16569
+ *
16570
+ * @param kctx - The kept context.
16571
+ * @param record - The record.
16572
+ * @returns True when the record may not be taken.
16573
+ */
16574
+ function blendedBelow(kctx, record) {
16575
+ if (record.blended) return true;
16576
+ if (!blendsBelow(record.top)) return false;
16577
+ record.blended = true;
16578
+ const { warnedBlend } = kctx.state;
16579
+ const label = labelOf$3(record.top);
16580
+ if (!warnedBlend.has(label)) {
16581
+ warnedBlend.add(label);
16582
+ kctx.ctx.log.warn("renderer:kept-blend", { picture: label });
16583
+ }
16584
+ return true;
16518
16585
  }
16519
16586
  /**
16520
16587
  * Tells whether a view of kind `"Display"` hangs in a subtree, the top included: a game's own object it may write
@@ -16582,7 +16649,8 @@ function createRecord$1(kctx, pixi, entity, view, slots, category) {
16582
16649
  refused: 0,
16583
16650
  cut: false,
16584
16651
  direct: false,
16585
- displays: void 0
16652
+ displays: void 0,
16653
+ blended: false
16586
16654
  };
16587
16655
  const { state } = kctx;
16588
16656
  state.records.push(record);
@@ -16761,8 +16829,8 @@ function writeTakeKeys(record, now, box) {
16761
16829
  /**
16762
16830
  * 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
16831
  * 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).
16832
+ * for a `Display` in the subtree without `onDemand`, a node under the top that blends otherwise (F68, warned once per
16833
+ * top), empty bounds, a picture over 4096 device pixels, or a budget that has no room (P6.3, warned once).
16766
16834
  *
16767
16835
  * @param kctx - The kept context.
16768
16836
  * @param pixi - The module that draws.
@@ -16775,6 +16843,7 @@ function writeTakeKeys(record, now, box) {
16775
16843
  function takePicture(kctx, pixi, matrices, record, view, now) {
16776
16844
  const { top } = record;
16777
16845
  if (!kctx.ctx.config.onDemand && holdsDisplay(kctx, top)) return false;
16846
+ if (blendedBelow(kctx, record)) return false;
16778
16847
  const drawn = drawnBounds(pixi, top);
16779
16848
  if (!drawn.isValid) return false;
16780
16849
  const local = clippedToArea(drawn, top.filterArea);
@@ -17114,7 +17183,8 @@ function wrapBare(kctx, record, view) {
17114
17183
  }
17115
17184
  /**
17116
17185
  * 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).
17186
+ * view in a wrapper of its own (D4). A record with a node under its top that blends otherwise stays live, before any
17187
+ * wrap (F68).
17118
17188
  *
17119
17189
  * @param kctx - The kept context.
17120
17190
  * @param pixi - The module that draws.
@@ -17129,6 +17199,7 @@ function stepLive(kctx, pixi, matrices, record, view) {
17129
17199
  record.still = changed ? 0 : record.still + 1;
17130
17200
  if (record.still < (record.calm ? 8 : 2)) return;
17131
17201
  if (record.refused > 0 && state.bytes + record.refused - record.bytes > state.budget) return;
17202
+ if (blendedBelow(kctx, record)) return;
17132
17203
  wrapBare(kctx, record, view);
17133
17204
  if (takePicture(kctx, pixi, matrices, record, view, NOW)) toKept(kctx, record, view);
17134
17205
  else record.still = 0;
@@ -17311,7 +17382,8 @@ function stepCalm(kctx, record) {
17311
17382
  }
17312
17383
  /**
17313
17384
  * 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.
17385
+ * of a kept one; the dirty flags are cleared. A change under the top forgets a blended node found before: the next
17386
+ * take walks the subtree again (F68).
17315
17387
  *
17316
17388
  * @param kctx - The kept context.
17317
17389
  * @param pixi - The module that draws.
@@ -17321,6 +17393,7 @@ function stepCalm(kctx, record) {
17321
17393
  */
17322
17394
  function stepRecord(kctx, pixi, matrices, record, view) {
17323
17395
  noteDrawing(kctx, record, BEFORE);
17396
+ if (record.dirtyS1) record.blended = false;
17324
17397
  NOW.r = kctx.host.resolution();
17325
17398
  readStack(record.slots, NOW.r, pixi.AlphaFilter, NOW);
17326
17399
  if (eligible(kctx, record, view, NOW)) stepEligible(kctx, pixi, matrices, record, view);
@@ -23947,10 +24020,21 @@ const PROBE_SIZE = 4;
23947
24020
  /** The content subjects the renderer warms itself, in queue order; the text follows once a font is installed. */
23948
24021
  const CONTENT = Object.freeze([
23949
24022
  "sprite",
24023
+ "sprite-add",
24024
+ "sprite-screen",
23950
24025
  "graphics",
23951
24026
  "particles-normal",
23952
24027
  "particles-add"
23953
24028
  ]);
24029
+ /** The probe object of each content subject the tree holds from its first warm frame on. */
24030
+ const FIXED_NODES = Object.freeze({
24031
+ sprite: "sprite",
24032
+ "sprite-add": "spriteAdd",
24033
+ "sprite-screen": "spriteScreen",
24034
+ graphics: "graphics",
24035
+ "particles-normal": "particlesNormal",
24036
+ "particles-add": "particlesAdd"
24037
+ });
23954
24038
  /** Every target a WebGPU pipeline is keyed by. */
23955
24039
  const ALL_VARIANTS = Object.freeze([
23956
24040
  "canvas",
@@ -23980,16 +24064,18 @@ function particleProperties() {
23980
24064
  }
23981
24065
  /**
23982
24066
  * A 4 × 4 sprite of Pixi's white texture: the batch pipeline of sprites, nine-slices, plain bitmap text and batched
23983
- * graphics.
24067
+ * graphics, with the blend of a `Sprite` or a `NineSlice` glow (part of a WebGPU pipeline's key).
23984
24068
  *
23985
24069
  * @param pixi - The module that draws.
23986
- * @returns The sprite.
24070
+ * @param blend - `"normal"` (Pixi's own mode left as it is), `"add"` or `"screen"`.
24071
+ * @returns The sprite, labelled `renderer.warm.sprite`, or `renderer.warm.sprite-<blend>` for a glow.
23987
24072
  */
23988
- function whiteSprite(pixi) {
24073
+ function whiteSprite(pixi, blend = "normal") {
23989
24074
  const sprite = new pixi.Sprite(pixi.Texture.WHITE);
23990
- sprite.label = "renderer.warm.sprite";
24075
+ sprite.label = blend === "normal" ? "renderer.warm.sprite" : `renderer.warm.sprite-${blend}`;
23991
24076
  sprite.width = PROBE_SIZE;
23992
24077
  sprite.height = PROBE_SIZE;
24078
+ if (blend !== "normal") sprite.blendMode = blend;
23993
24079
  return sprite;
23994
24080
  }
23995
24081
  /**
@@ -24052,6 +24138,8 @@ function buildObjects(pixi) {
24052
24138
  holder,
24053
24139
  list: [],
24054
24140
  sprite: whiteSprite(pixi),
24141
+ spriteAdd: whiteSprite(pixi, "add"),
24142
+ spriteScreen: whiteSprite(pixi, "screen"),
24055
24143
  graphics,
24056
24144
  particlesNormal: particlesOf(pixi, "normal"),
24057
24145
  particlesAdd: particlesOf(pixi, "add"),
@@ -24075,6 +24163,8 @@ function dropObjects(warm) {
24075
24163
  objects.clipFrame,
24076
24164
  objects.holder,
24077
24165
  objects.sprite,
24166
+ objects.spriteAdd,
24167
+ objects.spriteScreen,
24078
24168
  objects.graphics,
24079
24169
  objects.particlesNormal,
24080
24170
  objects.particlesAdd,
@@ -24251,27 +24341,22 @@ function warmFilter(sctx, filter) {
24251
24341
  * @returns The node, or `undefined` for the text without a font.
24252
24342
  */
24253
24343
  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
- }
24344
+ if (typeof subject !== "string") {
24345
+ objects.list[0] = subject;
24346
+ objects.holder.filters = objects.list;
24347
+ return objects.holder;
24348
+ }
24349
+ if (subject !== "text") return objects[FIXED_NODES[subject]];
24350
+ if (warm.textFont === void 0) return void 0;
24351
+ objects.text ??= new pixi.BitmapText({
24352
+ label: "renderer.warm.text",
24353
+ text: "0",
24354
+ style: {
24355
+ fontFamily: warm.textFont,
24356
+ fontSize: 16
24357
+ }
24358
+ });
24359
+ return objects.text;
24275
24360
  }
24276
24361
  /**
24277
24362
  * 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 +25162,8 @@ function poolKeyOf(kind, textureKey, hollow) {
25077
25162
  return hollow ? `${kind}.hollow:${textureKey}` : `${kind}:${textureKey}`;
25078
25163
  }
25079
25164
  /**
25080
- * Brings the object of a view that left back to the state a fresh one has: detached, drawn, opaque, unmoved, white.
25165
+ * Brings the object of a view that left back to the state a fresh one has: detached, drawn, opaque, unmoved, blending
25166
+ * normally, white.
25081
25167
  * Its hidden reasons go with `clearHidden`, so a pooled object never brings the reason of the view it belonged to; a
25082
25168
  * sprite and a graphics lose their tint (a fill-only shape is coloured by it, spec 26 R2.1).
25083
25169
  *
@@ -25089,14 +25175,15 @@ function resetObject(view) {
25089
25175
  resetNode(view.object);
25090
25176
  }
25091
25177
  /**
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.
25178
+ * Brings a detached object back to a fresh one's pose and look: drawn, opaque, unmoved, blending as its parent (no
25179
+ * glow of the view it drew for), white for a sprite and a graphics.
25094
25180
  *
25095
25181
  * @param object - The object, out of the tree.
25096
25182
  */
25097
25183
  function resetNode(object) {
25098
25184
  object.visible = true;
25099
25185
  object.alpha = 1;
25186
+ object.blendMode = "inherit";
25100
25187
  object.rotation = 0;
25101
25188
  object.zIndex = 0;
25102
25189
  object.position.set(0, 0);
@@ -27197,6 +27284,42 @@ function retargetKey(state, entity, view, key) {
27197
27284
  if (key !== "") trackKey(state, entity, key);
27198
27285
  }
27199
27286
  /**
27287
+ * The Pixi blend mode a `blend` of a `Sprite` or a `NineSlice` draws with: `"add"` and `"screen"` as they are, Pixi's
27288
+ * own blends of the sprite batch; `"inherit"`, what a fresh object holds, for `"normal"` and a value without the field.
27289
+ * Any other string (Pixi's advanced modes, `"multiply"`, a typo) is never handed to Pixi, which would draw an advanced
27290
+ * mode through a filter pass of its own: it draws normally, and each such value is warned once per run.
27291
+ *
27292
+ * @param sctx - Domain context of the sync module.
27293
+ * @param blend - The component's `blend`, as a game wrote it.
27294
+ * @returns The Pixi blend mode.
27295
+ */
27296
+ function blendModeOf(sctx, blend) {
27297
+ if (blend === void 0 || blend === "normal") return "inherit";
27298
+ if (blend === "add" || blend === "screen") return blend;
27299
+ const warned = sctx.ctx.state.sync.blendWarned;
27300
+ if (!warned.has(blend)) {
27301
+ warned.add(blend);
27302
+ sctx.ctx.log.warn("renderer:blend-unknown", { blend });
27303
+ }
27304
+ return "inherit";
27305
+ }
27306
+ /**
27307
+ * Writes the blend of a sprite or a nine-slice on its own object, never on the wrapper: a glow blends its own pixels,
27308
+ * not its children's. Only a mode that differs from what Pixi holds is written, the entity marked before: Pixi rebuilds
27309
+ * the instructions of the render group on a new mode.
27310
+ *
27311
+ * @param sctx - Domain context of the sync module.
27312
+ * @param entity - The entity.
27313
+ * @param object - The view's own object: the sprite, or the nine-slice, filled or hollow.
27314
+ * @param blend - The component's `blend`.
27315
+ */
27316
+ function writeBlend(sctx, entity, object, blend) {
27317
+ const mode = blendModeOf(sctx, blend);
27318
+ if (object.blendMode === mode) return;
27319
+ markView(sctx, entity);
27320
+ object.blendMode = mode;
27321
+ }
27322
+ /**
27200
27323
  * Writes the texture and the tint of a sprite, each only when it differs from what Pixi holds.
27201
27324
  *
27202
27325
  * @param sctx - Domain context of the sync module.
@@ -27325,7 +27448,7 @@ function applyHud(sctx, entity, view, factor) {
27325
27448
  }
27326
27449
  /**
27327
27450
  * 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
27451
+ * sprite, the blend, the stretch into the box, and the box as the hit box, the anchor applied. The stretch and
27329
27452
  * the hit box are written into the view's own objects. A new stretch of a view without a wrapper
27330
27453
  * changes the object's own units, so its filter area is written again (B4); a write that keeps it (an
27331
27454
  * alpha tween, a tint) writes no area.
@@ -27345,7 +27468,7 @@ function applySprite(sctx, entity, view) {
27345
27468
  return stretched;
27346
27469
  }
27347
27470
  /**
27348
- * The body of `applySprite`: the texture or its crop, the stretch, the anchor and the hit box.
27471
+ * The body of `applySprite`: the texture or its crop, the blend, the stretch, the anchor and the hit box.
27349
27472
  *
27350
27473
  * @param sctx - Domain context of the sync module.
27351
27474
  * @param entity - The entity.
@@ -27360,6 +27483,7 @@ function writeSprite(sctx, entity, view) {
27360
27483
  if (texture === void 0) warnMissing(sctx, value.texture);
27361
27484
  view.placeholder = texture === void 0;
27362
27485
  applyAlpha(sctx, entity, view, value);
27486
+ writeBlend(sctx, entity, object, value.blend);
27363
27487
  if (texture === void 0) {
27364
27488
  showCover(sctx, entity, view, "");
27365
27489
  drawPlaceholder(sctx, entity, object, view, value, placeholderTexture(sctx));
@@ -27425,8 +27549,9 @@ function writeSlice(sctx, entity, object, field, value) {
27425
27549
  * Writes the `NineSlice` component onto its display object and recomputes the hit box. Pixi reads
27426
27550
  * a texture's borders only in the constructor, so they are copied here on every write: a pooled
27427
27551
  * 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.
27552
+ * swaps the view's object first (F59), and the blend is written on the object drawn now. The hit box is the whole
27553
+ * panel, hollow or not. A write that changes nothing, or only the size, allocates nothing: the borders, the outline's
27554
+ * shape and the clip's box are written in place.
27430
27555
  *
27431
27556
  * @param sctx - Domain context of the sync module.
27432
27557
  * @param entity - The entity.
@@ -27457,6 +27582,7 @@ function applyNineSlice(sctx, entity, view) {
27457
27582
  changed = writeSlice(sctx, entity, object, "bottomHeight", borders.bottom) || changed;
27458
27583
  changed = writeSlice(sctx, entity, object, "tint", missing ? 16711935 : value.tint) || changed;
27459
27584
  applyAlpha(sctx, entity, view, value);
27585
+ writeBlend(sctx, entity, object, value.blend);
27460
27586
  changed = writeSlice(sctx, entity, object, "width", value.width) || changed;
27461
27587
  changed = writeSlice(sctx, entity, object, "height", value.height) || changed;
27462
27588
  writeHitBox(view.hitBox, value.width, value.height);
@@ -28196,6 +28322,7 @@ function stopSync(state) {
28196
28322
  state.byKey.clear();
28197
28323
  state.invalidated.clear();
28198
28324
  state.warned.clear();
28325
+ state.blendWarned.clear();
28199
28326
  state.added.clear();
28200
28327
  state.removed.clear();
28201
28328
  state.reparented.clear();
@@ -31547,6 +31674,7 @@ function createSyncState(debug) {
31547
31674
  slices: emptyWeakSet(),
31548
31675
  invalidated: emptySet$4(),
31549
31676
  warned: emptySet$4(),
31677
+ blendWarned: emptySet$4(),
31550
31678
  added: emptySet$4(),
31551
31679
  removed: emptySet$4(),
31552
31680
  reparented: emptySet$4(),
@@ -38180,11 +38308,121 @@ function applyAllGains(state) {
38180
38308
  applyGain(state, "sfx");
38181
38309
  }
38182
38310
  //#endregion
38311
+ //#region src/plugins/audio/named.ts
38312
+ /**
38313
+ * @file audio plugin — named sounds: an `sfx` with an `id` plays through a gain of its own into its
38314
+ * bus, so the next sound with that id, `stopSfx` and `app.audio.stop` can fade it out. One entry
38315
+ * per id in `state.named`; the entry object is the token a sound still decoding checks before it
38316
+ * starts. A sound without an id never comes here.
38317
+ */
38318
+ /**
38319
+ * The key a sound with an id waits under in `pendingSfx` while the unlock resumes the context.
38320
+ * The `#` keeps the ids apart from the asset keys, so the latest sound per id waits.
38321
+ *
38322
+ * @param id - The id of the sound.
38323
+ * @returns The key of the waiting sound.
38324
+ * @example
38325
+ * ```ts
38326
+ * waitingKey("voice"); // "#voice"
38327
+ * ```
38328
+ */
38329
+ function waitingKey(id) {
38330
+ return `#${id}`;
38331
+ }
38332
+ /**
38333
+ * Fades one named sound out on the context clock and stops its source at the end of the fade. A
38334
+ * sound still decoding has neither a gain nor a source, so nothing is scheduled.
38335
+ *
38336
+ * @param state - The plugin state.
38337
+ * @param entry - The sound to fade.
38338
+ * @param seconds - Length of the fade, in seconds of the context clock.
38339
+ */
38340
+ function fadeNamed(state, entry, seconds) {
38341
+ const context = state.context;
38342
+ if (context === void 0) return;
38343
+ const now = context.currentTime;
38344
+ if (entry.gain !== void 0) ramp(entry.gain, 0, seconds, now);
38345
+ entry.source?.stop(now + seconds);
38346
+ }
38347
+ /**
38348
+ * Takes an id for a new sound: the sound that plays, or decodes, under it fades out over the fade
38349
+ * of the new one, and a fresh entry takes its place.
38350
+ *
38351
+ * @param state - The plugin state.
38352
+ * @param id - The id of the new sound.
38353
+ * @param seconds - Length of the fade of the replaced sound, in seconds.
38354
+ * @returns The entry of the new sound, its token.
38355
+ */
38356
+ function claimNamed(state, id, seconds) {
38357
+ const playing = state.named.get(id);
38358
+ if (playing !== void 0) fadeNamed(state, playing, seconds);
38359
+ const entry = {
38360
+ gain: void 0,
38361
+ source: void 0
38362
+ };
38363
+ state.named.set(id, entry);
38364
+ return entry;
38365
+ }
38366
+ /**
38367
+ * Starts one named sound: a gain at 1 into the bus, the source into that gain. When the source
38368
+ * ends, its gain is disconnected and the id is freed, unless a later sound took the id meanwhile.
38369
+ *
38370
+ * @param state - The plugin state.
38371
+ * @param context - The running audio context.
38372
+ * @param named - The id, its entry, the decoded buffer and the gain of the bus.
38373
+ * @param named.id - The id the sound plays under.
38374
+ * @param named.entry - The entry `claimNamed` returned for it.
38375
+ * @param named.buffer - The decoded buffer of the sound.
38376
+ * @param named.bus - The gain node of the bus the sound goes to.
38377
+ */
38378
+ function startNamed(state, context, named) {
38379
+ const { id, entry } = named;
38380
+ const gain = context.createGain();
38381
+ const source = context.createBufferSource();
38382
+ gain.gain.value = 1;
38383
+ gain.connect(named.bus);
38384
+ source.buffer = named.buffer;
38385
+ source.connect(gain);
38386
+ source.addEventListener("ended", () => {
38387
+ gain.disconnect();
38388
+ if (state.named.get(id) === entry) state.named.delete(id);
38389
+ }, { once: true });
38390
+ entry.gain = gain;
38391
+ entry.source = source;
38392
+ source.start();
38393
+ }
38394
+ /**
38395
+ * Ends the sound under one id: the stop of `stopSfx` and of `app.audio.stop`. The entry leaves the
38396
+ * map, so a sound still decoding starts nothing, and the one that plays fades out. A sound with
38397
+ * that id waiting for the unlock is dropped. An id nothing plays under changes nothing.
38398
+ *
38399
+ * @param state - The plugin state.
38400
+ * @param id - The id the sound was started with.
38401
+ * @param fadeMs - Length of the fade, in real milliseconds.
38402
+ */
38403
+ function stopNamed(state, id, fadeMs) {
38404
+ state.pendingSfx.delete(waitingKey(id));
38405
+ const entry = state.named.get(id);
38406
+ if (entry === void 0) return;
38407
+ state.named.delete(id);
38408
+ fadeNamed(state, entry, fadeMs / 1e3);
38409
+ }
38410
+ /**
38411
+ * Stops every named sound at once and forgets the ids. `onStop` runs it: a teardown has no time
38412
+ * for a fade, and the context is closed right after.
38413
+ *
38414
+ * @param state - The plugin state.
38415
+ */
38416
+ function stopAllNamed(state) {
38417
+ for (const entry of state.named.values()) entry.source?.stop();
38418
+ state.named.clear();
38419
+ }
38420
+ //#endregion
38183
38421
  //#region src/plugins/audio/api.ts
38184
38422
  /**
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.
38423
+ * @file audio plugin — API factory. Three buses, the unlock flag, the journal of started sounds and the stop of a
38424
+ * named sound. Nothing here is persisted; a game keeps the player's choice in the player state and
38425
+ * `config.volumes` applies it on the next commit.
38188
38426
  */
38189
38427
  /**
38190
38428
  * Brings a value into 0..1. `NaN`, which is what a slider divided by zero produces, becomes 0.
@@ -38226,7 +38464,7 @@ function setBusVolume(state, bus, value) {
38226
38464
  applyGain(state, bus);
38227
38465
  }
38228
38466
  /**
38229
- * Creates the audio API: the three buses, the unlock flag and the journal.
38467
+ * Creates the audio API: the three buses, the unlock flag, the journal and the stop of a named sound.
38230
38468
  *
38231
38469
  * @param ctx - Kernel context of the audio plugin.
38232
38470
  * @returns The plugin API.
@@ -38243,7 +38481,8 @@ function createAudioApi(ctx) {
38243
38481
  },
38244
38482
  muted: (bus) => state.buses[busOf(state, bus)].muted,
38245
38483
  unlocked: () => state.unlocked,
38246
- journal: () => state.journal
38484
+ journal: () => state.journal,
38485
+ stop: (id, options) => stopNamed(state, id, options?.fadeMs ?? ctx.config.sfxFadeMs)
38247
38486
  };
38248
38487
  }
38249
38488
  //#endregion
@@ -38251,13 +38490,15 @@ function createAudioApi(ctx) {
38251
38490
  /**
38252
38491
  * Replaces the journal with a frozen list that ends with one started sound, stamped with the
38253
38492
  * 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.
38493
+ * that is not a positive number, records nothing. The entry has an `id` only when the sound has
38494
+ * one: the caller leaves the field out otherwise, so no entry carries an `undefined` key.
38255
38495
  *
38256
38496
  * @param ctx - Domain context of the plugin.
38257
38497
  * @param sound - What started.
38258
38498
  * @param sound.key - Asset key of the sound.
38259
38499
  * @param sound.bus - The bus it plays on.
38260
38500
  * @param sound.kind - `"sfx"` for a play, `"music"` for a track.
38501
+ * @param sound.id - The id of a named sound; absent for every other sound.
38261
38502
  */
38262
38503
  function recordSound(ctx, sound) {
38263
38504
  const size = Math.floor(ctx.config.journal);
@@ -38385,18 +38626,47 @@ function fieldOf(payload, field) {
38385
38626
  return payload[field];
38386
38627
  }
38387
38628
  /**
38388
- * Reads the key and the bus out of the `sfx` descriptor `anim` builds.
38629
+ * Reads the fade of a descriptor that ends a named sound.
38389
38630
  *
38390
38631
  * @param descriptor - The descriptor the handler was called with.
38632
+ * @param fadeMs - The configured fade, used when the descriptor names none.
38633
+ * @returns The fade, in milliseconds.
38634
+ */
38635
+ function fadeOf(descriptor, fadeMs) {
38636
+ return validFade(fieldOf(descriptor.payload, "fadeMs"), fadeMs);
38637
+ }
38638
+ /**
38639
+ * Takes a fade a descriptor named only when the clock can schedule it: a finite number, 0 or more.
38640
+ * `NaN` or a negative fade would make the ramp and the stop throw inside the handler.
38641
+ *
38642
+ * @param fade - The `fadeMs` the payload carries.
38643
+ * @param fallback - The configured fade.
38644
+ * @returns The fade, in milliseconds.
38645
+ * @example
38646
+ * ```ts
38647
+ * validFade(-5, 120); // 120
38648
+ * ```
38649
+ */
38650
+ function validFade(fade, fallback) {
38651
+ return typeof fade === "number" && Number.isFinite(fade) && fade >= 0 ? fade : fallback;
38652
+ }
38653
+ /**
38654
+ * Reads the key, the bus, the id and the fade out of the `sfx` descriptor `anim` builds.
38655
+ *
38656
+ * @param descriptor - The descriptor the handler was called with.
38657
+ * @param fadeMs - The configured fade of a named sound, used when the descriptor names none.
38391
38658
  * @returns The request, or `undefined` when the descriptor names no key.
38392
38659
  */
38393
- function sfxOf(descriptor) {
38660
+ function sfxOf(descriptor, fadeMs) {
38394
38661
  const key = fieldOf(descriptor.payload, "key");
38395
38662
  const bus = fieldOf(descriptor.payload, "bus");
38663
+ const id = fieldOf(descriptor.payload, "id");
38396
38664
  if (typeof key !== "string") return void 0;
38397
38665
  return {
38398
38666
  key,
38399
- bus: typeof bus === "string" ? bus : DEFAULT_BUS$1
38667
+ bus: typeof bus === "string" ? bus : DEFAULT_BUS$1,
38668
+ id: typeof id === "string" ? id : void 0,
38669
+ fadeMs: fadeOf(descriptor, fadeMs)
38400
38670
  };
38401
38671
  }
38402
38672
  /**
@@ -38408,10 +38678,9 @@ function sfxOf(descriptor) {
38408
38678
  */
38409
38679
  function musicOf(descriptor, fadeMs) {
38410
38680
  const key = fieldOf(descriptor.payload, "key");
38411
- const fade = fieldOf(descriptor.payload, "fadeMs");
38412
38681
  return {
38413
38682
  key: typeof key === "string" ? key : null,
38414
- fadeMs: typeof fade === "number" ? fade : fadeMs
38683
+ fadeMs: validFade(fieldOf(descriptor.payload, "fadeMs"), fadeMs)
38415
38684
  };
38416
38685
  }
38417
38686
  /**
@@ -38470,9 +38739,10 @@ async function bufferOf(ctx, key) {
38470
38739
  /**
38471
38740
  * Plays one sound: the handler of the kind `"sfx"`. Every play is its own source, so the same key
38472
38741
  * 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.
38742
+ * ignores its signal: a sound that began plays to its end, unless a sound with its id or a stop
38743
+ * ends it. While a gesture's `resume()` is pending the sound waits in `pendingSfx`, one per id
38744
+ * and one per key without an id. A locked context no gesture is resuming drops it: the start
38745
+ * attempt of the unlock queues nothing.
38476
38746
  *
38477
38747
  * @param ctx - Domain context of the plugin.
38478
38748
  * @param descriptor - The `sfx` descriptor `anim` built.
@@ -38480,20 +38750,79 @@ async function bufferOf(ctx, key) {
38480
38750
  async function playSfx(ctx, descriptor) {
38481
38751
  const state = ctx.state;
38482
38752
  if (state.context === void 0) return;
38483
- const request = sfxOf(descriptor);
38753
+ const request = sfxOf(descriptor, ctx.config.sfxFadeMs);
38484
38754
  if (request === void 0) return;
38485
38755
  if (!state.unlocked) {
38486
- if (state.resuming) state.pendingSfx.set(request.key, request);
38756
+ queueWhileResuming(state, request);
38487
38757
  return;
38488
38758
  }
38489
38759
  await playRequest(ctx, request);
38490
38760
  }
38491
38761
  /**
38762
+ * Keeps a sound fired while the context is locked, one per key and one per id, when a gesture's
38763
+ * `resume()` is on its way. A locked context nobody resumes drops it.
38764
+ *
38765
+ * @param state - The plugin state.
38766
+ * @param request - The sound that was fired.
38767
+ */
38768
+ function queueWhileResuming(state, request) {
38769
+ if (!state.resuming) return;
38770
+ state.pendingSfx.set(request.id === void 0 ? request.key : waitingKey(request.id), request);
38771
+ }
38772
+ /**
38773
+ * Ends the sound under one id: the handler of the kind `"stopSfx"`, for an effect, an animation
38774
+ * step and a released hint alike. A descriptor without an id ends nothing.
38775
+ *
38776
+ * @param ctx - Domain context of the plugin.
38777
+ * @param descriptor - The `stopSfx` descriptor `anim` built.
38778
+ */
38779
+ function stopNamedSfx(ctx, descriptor) {
38780
+ const id = fieldOf(descriptor.payload, "id");
38781
+ if (typeof id !== "string") return;
38782
+ stopNamed(ctx.state, id, fadeOf(descriptor, ctx.config.sfxFadeMs));
38783
+ }
38784
+ /**
38785
+ * Plays a sound under its id: the sound that has the id fades out over the fade of this one, and
38786
+ * this one starts through its own gain once its buffer is there, unless a later sound with the id
38787
+ * or a stop took its place while it decoded. A file that is missing frees the id.
38788
+ *
38789
+ * @param ctx - Domain context of the plugin.
38790
+ * @param request - The key, the fade and the id of the sound.
38791
+ * @param sound - The id and the bus, both checked.
38792
+ * @param sound.id - The id the sound plays under.
38793
+ * @param sound.bus - The bus it goes to.
38794
+ */
38795
+ async function playNamed(ctx, request, sound) {
38796
+ const state = ctx.state;
38797
+ const { id, bus } = sound;
38798
+ const entry = claimNamed(state, id, request.fadeMs / 1e3);
38799
+ const buffer = await bufferOf(ctx, request.key);
38800
+ if (state.named.get(id) !== entry) return;
38801
+ const context = state.context;
38802
+ const gain = state.buses[bus].gain;
38803
+ if (buffer === void 0 || context === void 0 || gain === void 0) {
38804
+ state.named.delete(id);
38805
+ return;
38806
+ }
38807
+ startNamed(state, context, {
38808
+ id,
38809
+ entry,
38810
+ buffer,
38811
+ bus: gain
38812
+ });
38813
+ recordSound(ctx, {
38814
+ key: request.key,
38815
+ bus,
38816
+ kind: "sfx",
38817
+ id
38818
+ });
38819
+ }
38820
+ /**
38492
38821
  * 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.
38822
+ * every sound that waited for the resume. A sound with an id goes through `playNamed`.
38494
38823
  *
38495
38824
  * @param ctx - Domain context of the plugin.
38496
- * @param request - The key and the bus of the sound.
38825
+ * @param request - The key, the bus, the id and the fade of the sound.
38497
38826
  */
38498
38827
  async function playRequest(ctx, request) {
38499
38828
  const state = ctx.state;
@@ -38503,6 +38832,13 @@ async function playRequest(ctx, request) {
38503
38832
  ctx.log.warn("audio: unknown bus", { bus: request.bus });
38504
38833
  return;
38505
38834
  }
38835
+ if (request.id !== void 0) {
38836
+ await playNamed(ctx, request, {
38837
+ id: request.id,
38838
+ bus: request.bus
38839
+ });
38840
+ return;
38841
+ }
38506
38842
  const buffer = await bufferOf(ctx, request.key);
38507
38843
  const gain = state.buses[request.bus].gain;
38508
38844
  if (buffer === void 0 || gain === void 0) return;
@@ -39027,8 +39363,8 @@ function createContext(config) {
39027
39363
  return typeof browserContext === "function" ? new browserContext() : void 0;
39028
39364
  }
39029
39365
  /**
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
39366
+ * Starts the plugin in `onStart`: the audio session first, before any sound exists, then the
39367
+ * three effect handlers, then the context with its graph and its unlock. The unlock asks the context to
39032
39368
  * run at once: a shell that lets audio play is unlocked with no touch, and a browser that wants a
39033
39369
  * gesture gets the two listeners. The handlers are registered even headless, so a node that
39034
39370
  * awaits a sound resolves in plain Bun exactly as it does in a browser.
@@ -39040,7 +39376,7 @@ function startAudio(ctx) {
39040
39376
  const audio = withDeps$4(ctx);
39041
39377
  const state = audio.state;
39042
39378
  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 }));
39379
+ 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
39380
  const context = createContext(ctx.config);
39045
39381
  if (context === void 0) return;
39046
39382
  state.context = context;
@@ -39049,9 +39385,9 @@ function startAudio(ctx) {
39049
39385
  startUnlock(audio);
39050
39386
  }
39051
39387
  /**
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
39388
+ * Frees everything `onStart` opened: the three handler registrations, the two window listeners,
39389
+ * the music track, every named sound, every streamed track still fading with its timer, the
39390
+ * journal, the audio session and the context itself. The tracks are freed before the close, while their nodes can still
39055
39391
  * disconnect. A browser caps how many contexts a page may hold, so the close is the point of this
39056
39392
  * teardown.
39057
39393
  *
@@ -39062,6 +39398,7 @@ async function stopAudio(state) {
39062
39398
  for (const remove of state.removers.splice(0)) remove();
39063
39399
  removeUnlock(state);
39064
39400
  stopMusic(state);
39401
+ stopAllNamed(state);
39065
39402
  for (const entry of state.retiring) disposeStream(state, entry.stream);
39066
39403
  state.decoded.clear();
39067
39404
  state.journal = Object.freeze([]);
@@ -39241,12 +39578,20 @@ function emptyWarned$2() {
39241
39578
  /**
39242
39579
  * Creates the queue of sounds fired while the unlock is pending.
39243
39580
  *
39244
- * @returns An empty queue, keyed by asset key.
39581
+ * @returns An empty queue, keyed by `"#" + id` or by asset key.
39245
39582
  */
39246
39583
  function emptyPending() {
39247
39584
  return /* @__PURE__ */ new Map();
39248
39585
  }
39249
39586
  /**
39587
+ * Creates the map of the sounds that play under an id.
39588
+ *
39589
+ * @returns An empty map, keyed by id.
39590
+ */
39591
+ function emptyNamed() {
39592
+ return /* @__PURE__ */ new Map();
39593
+ }
39594
+ /**
39250
39595
  * Creates the set of streamed tracks that fade out.
39251
39596
  *
39252
39597
  * @returns An empty set of retiring tracks.
@@ -39291,6 +39636,7 @@ function createAudioState(ctx) {
39291
39636
  unlocked: false,
39292
39637
  resuming: false,
39293
39638
  pendingSfx: emptyPending(),
39639
+ named: emptyNamed(),
39294
39640
  decoded: emptyCache$1(),
39295
39641
  warned: emptyWarned$2(),
39296
39642
  warnedVolumes: false,
@@ -39304,8 +39650,8 @@ function createAudioState(ctx) {
39304
39650
  };
39305
39651
  }
39306
39652
  /**
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.
39653
+ * Audio plugin: `app.audio`, the handlers of the effect kinds `sfx`, `stopSfx` and `music`, and
39654
+ * the three buses a game drives from its player state.
39309
39655
  *
39310
39656
  * @example
39311
39657
  * ```ts
@@ -39333,6 +39679,7 @@ const audioPlugin = /*#__PURE__*/ createPlugin$1("audio", {
39333
39679
  sfx: 1
39334
39680
  },
39335
39681
  musicFadeMs: 600,
39682
+ sfxFadeMs: 120,
39336
39683
  volumes: void 0,
39337
39684
  context: void 0,
39338
39685
  journal: 0,
@@ -48874,24 +49221,61 @@ function spawned(id) {
48874
49221
  }
48875
49222
  /**
48876
49223
  * 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.
49224
+ * and a game without `audio` plays nothing and hears no error. A sound with an `id` is named:
49225
+ * one sound plays per id, a new one with the same id fades the old one out over `fadeMs`, and
49226
+ * `stopSfx(id)` stops it. The payload carries `id` and `fadeMs` only when they are given, so a
49227
+ * sound without them stays `{ key, bus }`.
48878
49228
  *
48879
49229
  * @param key - Asset key of the sound.
48880
- * @param options - The bus to play it on.
49230
+ * @param options - The bus, the name and the fade.
48881
49231
  * @param options.bus - `"sfx"` when omitted.
49232
+ * @param options.id - Name of the sound; one sound per id plays.
49233
+ * @param options.fadeMs - Fade of the sound with the same id this one replaces; `audio`'s
49234
+ * `sfxFadeMs` when omitted.
48882
49235
  * @returns The descriptor, usable as a timeline step and as `fx(...)` in a node.
48883
49236
  * @example
48884
49237
  * ```ts
48885
49238
  * sfx("board.merge"); // { kind: "sfx", payload: { key: "board.merge", bus: "sfx" }, cosmetic: true }
49239
+ * sfx("voice.hello", { id: "voice", fadeMs: 200 });
49240
+ * // { kind: "sfx", payload: { key: "voice.hello", bus: "sfx", id: "voice", fadeMs: 200 }, cosmetic: true }
48886
49241
  * ```
48887
49242
  */
48888
49243
  function sfx(key, options) {
49244
+ const payload = {
49245
+ key,
49246
+ bus: options?.bus ?? DEFAULT_BUS
49247
+ };
49248
+ if (options?.id !== void 0) payload.id = options.id;
49249
+ if (options?.fadeMs !== void 0) payload.fadeMs = options.fadeMs;
48889
49250
  return Object.freeze({
48890
49251
  kind: "sfx",
48891
- payload: Object.freeze({
48892
- key,
48893
- bus: options?.bus ?? DEFAULT_BUS
48894
- }),
49252
+ payload: Object.freeze(payload),
49253
+ cosmetic: true
49254
+ });
49255
+ }
49256
+ /**
49257
+ * Stops the named sound `sfx(key, { id })` started, fading it out over `fadeMs`. `audio` owns the
49258
+ * handler; an id nothing plays is silent. It works as `fx.start(stopSfx("voice"))` in a node, as a
49259
+ * step a timeline reaches, and as the hint `hint("stopSfx", { id: "voice" })`. A timeline that is
49260
+ * finished or cut fires no effect, this one included: a game that must stop the voice then stops
49261
+ * it from the node.
49262
+ *
49263
+ * @param id - The id the sound was played with.
49264
+ * @param options - The fade.
49265
+ * @param options.fadeMs - Fade-out in ms; `audio`'s `sfxFadeMs` when omitted.
49266
+ * @returns The descriptor, usable as a timeline step and as `fx(...)` in a node.
49267
+ * @example
49268
+ * ```ts
49269
+ * stopSfx("voice", { fadeMs: 200 }); // { kind: "stopSfx", payload: { id: "voice", fadeMs: 200 }, cosmetic: true }
49270
+ * stopSfx("voice"); // { kind: "stopSfx", payload: { id: "voice" }, cosmetic: true }
49271
+ * ```
49272
+ */
49273
+ function stopSfx(id, options) {
49274
+ const payload = { id };
49275
+ if (options?.fadeMs !== void 0) payload.fadeMs = options.fadeMs;
49276
+ return Object.freeze({
49277
+ kind: "stopSfx",
49278
+ payload: Object.freeze(payload),
48895
49279
  cosmetic: true
48896
49280
  });
48897
49281
  }
@@ -62466,6 +62850,7 @@ function cubicBezier(x1, y1, x2, y2) {
62466
62850
  * | ui | textInput | { caretWidth: 3, caret: 0x000000, selection: 0x3390ff, selectionAlpha: 0.35, composingUnderline: 3, keyboardMargin: 16 }; shallow merge |
62467
62851
  * | audio | buses | { master: 1, music: 0.6, sfx: 1 }; shallow merge: a game that sets `buses` gives all three fields |
62468
62852
  * | audio | musicFadeMs | 600 |
62853
+ * | audio | sfxFadeMs | 120 |
62469
62854
  * | audio | volumes | undefined, the buses stay at `buses` |
62470
62855
  * | audio | context | undefined, the real AudioContext |
62471
62856
  * | audio | journal | 0, no started sound is kept |
@@ -62597,4 +62982,4 @@ const screen = [
62597
62982
  uiPlugin
62598
62983
  ];
62599
62984
  //#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 };
62985
+ 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 };