@moku-labs/game 0.15.39 → 0.15.40

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.
@@ -2444,7 +2444,7 @@ var SaveUnreadableError = class extends Error {
2444
2444
  * isRecord(["key"]); // false: an array is not a record
2445
2445
  * ```
2446
2446
  */
2447
- function isRecord$4(value) {
2447
+ function isRecord$5(value) {
2448
2448
  return Boolean(value) && typeof value === "object" && !Array.isArray(value);
2449
2449
  }
2450
2450
  /**
@@ -2459,7 +2459,7 @@ function isRecord$4(value) {
2459
2459
  * ```
2460
2460
  */
2461
2461
  function readStreams(value) {
2462
- if (!isRecord$4(value)) return void 0;
2462
+ if (!isRecord$5(value)) return void 0;
2463
2463
  const streams = {};
2464
2464
  for (const [id, state] of Object.entries(value)) {
2465
2465
  if (typeof state !== "number") return void 0;
@@ -2483,9 +2483,9 @@ function readStreams(value) {
2483
2483
  * ```
2484
2484
  */
2485
2485
  function readSaveDoc(state, savedVersion, schemaVersion) {
2486
- const record = isRecord$4(state) ? state : void 0;
2486
+ const record = isRecord$5(state) ? state : void 0;
2487
2487
  const branch = record ? record.rng : void 0;
2488
- const rng = isRecord$4(branch) ? branch : void 0;
2488
+ const rng = isRecord$5(branch) ? branch : void 0;
2489
2489
  const streams = readStreams(rng ? rng.streams : void 0);
2490
2490
  if (!record || record.player === void 0 || !rng || typeof rng.seed !== "number" || !streams) throw new SaveUnreadableError(savedVersion, schemaVersion, /* @__PURE__ */ new Error("[game] The save is not a document of the shape { player, rng }.\n Restore the backup or start a new save."));
2491
2491
  return {
@@ -5759,7 +5759,7 @@ function enterBookmark(ctx, modules, bookmark, safe) {
5759
5759
  * @returns `"stop"` when the runner was stopped, `"continue"` otherwise.
5760
5760
  * @throws {Error} When the position names no node of the graph.
5761
5761
  */
5762
- async function advance(ctx, modules, safe) {
5762
+ async function advance$1(ctx, modules, safe) {
5763
5763
  const state = ctx.state.runner;
5764
5764
  if (stopped(state)) return "stop";
5765
5765
  const restoring = state.seam?.restoring;
@@ -5806,7 +5806,7 @@ async function runLoop(ctx, modules) {
5806
5806
  recover: false
5807
5807
  };
5808
5808
  const loop = { running: true };
5809
- while (loop.running) loop.running = await advance(ctx, modules, safe) === "continue";
5809
+ while (loop.running) loop.running = await advance$1(ctx, modules, safe) === "continue";
5810
5810
  }
5811
5811
  /**
5812
5812
  * Restores a bookmark through the running loop: the active node is aborted with reason
@@ -24939,7 +24939,7 @@ function finishRun(warm) {
24939
24939
  * @param pixi - The module that draws.
24940
24940
  * @returns The tree, or `undefined` when it cannot be built.
24941
24941
  */
24942
- function objectsOf(sctx, pixi) {
24942
+ function objectsOf$1(sctx, pixi) {
24943
24943
  const { warm } = sctx.ctx.state.sync;
24944
24944
  if (warm.objects !== void 0) return warm.objects;
24945
24945
  try {
@@ -24967,7 +24967,7 @@ function warmStep(sctx) {
24967
24967
  const { warm } = sctx.ctx.state.sync;
24968
24968
  if (warm.head >= warm.queue.length) return false;
24969
24969
  const pixi = sctx.deps.host.pixi();
24970
- const objects = pixi === void 0 ? void 0 : objectsOf(sctx, pixi);
24970
+ const objects = pixi === void 0 ? void 0 : objectsOf$1(sctx, pixi);
24971
24971
  if (pixi === void 0 || objects === void 0) return false;
24972
24972
  const budget = sctx.ctx.config.warm;
24973
24973
  let drawn = 0;
@@ -26026,6 +26026,1003 @@ function clearLayers(state) {
26026
26026
  state.hudLayer = void 0;
26027
26027
  }
26028
26028
  //#endregion
26029
+ //#region src/plugins/renderer/sync/shape-path.ts
26030
+ /**
26031
+ * @file renderer/sync — the path of a `Shape`: a rectangle, a rounded rectangle or a triangle,
26032
+ * its fill, and its stroke, solid or dashed, or an arc, a stroke alone. A dashed stroke walks the
26033
+ * outline (straight edges, and rounded corners sampled as arcs) and strokes one open line per dash.
26034
+ * An arc runs on the circle inscribed in its box, from 12 o'clock clockwise.
26035
+ */
26036
+ /** Chords per rounded corner of a dashed outline: under 0.3 u off the arc at a 48 u radius. */
26037
+ const CORNER_SAMPLES = 8;
26038
+ /** The gap between two dashes, as a fraction of the dash length. */
26039
+ const GAP_RATIO = .5;
26040
+ /** Segments of a full ring: a ring of 28 u across strays under 0.03 u off its circle. */
26041
+ const RING_SEGMENTS = 48;
26042
+ /** The fewest segments of an arc that draws: a sliver still bends. */
26043
+ const ARC_MIN_SEGMENTS = 2;
26044
+ /** 12 o'clock, where an arc starts: Pixi's angles run clockwise from 3 o'clock. */
26045
+ const TWELVE = -Math.PI / 2;
26046
+ /**
26047
+ * The corner radius the rectangle is drawn with: Pixi clamps it to half the shorter side.
26048
+ *
26049
+ * @param value - The shape value.
26050
+ * @returns The radius in reference units, 0 for square corners.
26051
+ * @example
26052
+ * ```ts
26053
+ * radiusOf({ ...Shape.defaults, w: 100, h: 20, radius: 40 }); // 10
26054
+ * ```
26055
+ */
26056
+ function radiusOf(value) {
26057
+ return Math.max(0, Math.min(value.radius, value.w / 2, value.h / 2));
26058
+ }
26059
+ /**
26060
+ * The closed outline of a shape as points, clockwise from the top left (from the end of the top
26061
+ * left corner on a rounded rectangle); the last point repeats the first.
26062
+ *
26063
+ * @param value - The shape value.
26064
+ * @returns The points of the outline.
26065
+ * @example
26066
+ * ```ts
26067
+ * outlineOf({ ...Shape.defaults, kind: "triangle", w: 60, h: 40 });
26068
+ * // [{ x: 0, y: 0 }, { x: 60, y: 20 }, { x: 0, y: 40 }, { x: 0, y: 0 }]
26069
+ * ```
26070
+ */
26071
+ function outlineOf(value) {
26072
+ const { w, h } = value;
26073
+ if (value.kind === "triangle") return [
26074
+ {
26075
+ x: 0,
26076
+ y: 0
26077
+ },
26078
+ {
26079
+ x: w,
26080
+ y: h / 2
26081
+ },
26082
+ {
26083
+ x: 0,
26084
+ y: h
26085
+ },
26086
+ {
26087
+ x: 0,
26088
+ y: 0
26089
+ }
26090
+ ];
26091
+ const radius = radiusOf(value);
26092
+ if (radius === 0) return [
26093
+ {
26094
+ x: 0,
26095
+ y: 0
26096
+ },
26097
+ {
26098
+ x: w,
26099
+ y: 0
26100
+ },
26101
+ {
26102
+ x: w,
26103
+ y: h
26104
+ },
26105
+ {
26106
+ x: 0,
26107
+ y: h
26108
+ },
26109
+ {
26110
+ x: 0,
26111
+ y: 0
26112
+ }
26113
+ ];
26114
+ const corners = [
26115
+ {
26116
+ edge: {
26117
+ x: w - radius,
26118
+ y: 0
26119
+ },
26120
+ center: {
26121
+ x: w - radius,
26122
+ y: radius
26123
+ }
26124
+ },
26125
+ {
26126
+ edge: {
26127
+ x: w,
26128
+ y: h - radius
26129
+ },
26130
+ center: {
26131
+ x: w - radius,
26132
+ y: h - radius
26133
+ }
26134
+ },
26135
+ {
26136
+ edge: {
26137
+ x: radius,
26138
+ y: h
26139
+ },
26140
+ center: {
26141
+ x: radius,
26142
+ y: h - radius
26143
+ }
26144
+ },
26145
+ {
26146
+ edge: {
26147
+ x: 0,
26148
+ y: radius
26149
+ },
26150
+ center: {
26151
+ x: radius,
26152
+ y: radius
26153
+ }
26154
+ }
26155
+ ];
26156
+ const points = [{
26157
+ x: radius,
26158
+ y: 0
26159
+ }];
26160
+ for (const [index, { edge, center }] of corners.entries()) {
26161
+ const from = -Math.PI / 2 + index * Math.PI / 2;
26162
+ points.push(edge);
26163
+ for (let sample = 1; sample <= CORNER_SAMPLES; sample += 1) {
26164
+ const angle = from + sample * Math.PI / 2 / CORNER_SAMPLES;
26165
+ points.push({
26166
+ x: center.x + radius * Math.cos(angle),
26167
+ y: center.y + radius * Math.sin(angle)
26168
+ });
26169
+ }
26170
+ }
26171
+ return points;
26172
+ }
26173
+ /**
26174
+ * Adds a point to a line unless the line already ends there.
26175
+ *
26176
+ * @param line - The line.
26177
+ * @param point - The point.
26178
+ */
26179
+ function extend(line, point) {
26180
+ const last = line.at(-1);
26181
+ if (last?.x !== point.x || last.y !== point.y) line.push(point);
26182
+ }
26183
+ /**
26184
+ * Cuts a polyline into dashes of `dash` length with gaps of half a dash, starting with a dash at
26185
+ * its first point. A dash bends round the corners it crosses; the last one may be shorter.
26186
+ *
26187
+ * @param outline - The points of the polyline.
26188
+ * @param dash - The dash length, above 0.
26189
+ * @returns One open line per dash.
26190
+ * @example
26191
+ * ```ts
26192
+ * dashesOf([{ x: 0, y: 0 }, { x: 40, y: 0 }], 10);
26193
+ * // [[{ x: 0, y: 0 }, { x: 10, y: 0 }], [{ x: 15, y: 0 }, { x: 25, y: 0 }], [{ x: 30, y: 0 }, { x: 40, y: 0 }]]
26194
+ * ```
26195
+ */
26196
+ function dashesOf(outline, dash) {
26197
+ const first = outline[0];
26198
+ if (first === void 0) return [];
26199
+ const dashes = [];
26200
+ let line = [first];
26201
+ let left = dash;
26202
+ for (let index = 1; index < outline.length; index += 1) {
26203
+ let from = outline[index - 1] ?? first;
26204
+ const to = outline[index] ?? first;
26205
+ let length = Math.hypot(to.x - from.x, to.y - from.y);
26206
+ while (length > left) {
26207
+ from = {
26208
+ x: from.x + (to.x - from.x) * left / length,
26209
+ y: from.y + (to.y - from.y) * left / length
26210
+ };
26211
+ length -= left;
26212
+ if (line === void 0) {
26213
+ line = [from];
26214
+ left = dash;
26215
+ } else {
26216
+ extend(line, from);
26217
+ dashes.push(line);
26218
+ line = void 0;
26219
+ left = dash * GAP_RATIO;
26220
+ }
26221
+ }
26222
+ left -= length;
26223
+ if (line !== void 0) extend(line, to);
26224
+ }
26225
+ if (line !== void 0 && line.length > 1) dashes.push(line);
26226
+ return dashes;
26227
+ }
26228
+ /**
26229
+ * Draws the path of a shape and fills it in a colour at the alpha given. At alpha 0 the path stays unfilled, so only
26230
+ * a stroke shows. A clip mask is drawn here too, never dashed; the path of an arc here is its box, which is what it
26231
+ * clips its children to. A fill-only shape is drawn white and coloured by the object's tint, so a colour change draws
26232
+ * no path (spec 26 R2.1).
26233
+ *
26234
+ * @param object - The graphics to draw on.
26235
+ * @param value - The shape value.
26236
+ * @param fillAlpha - The alpha of the fill: the value's own for the shape, 1 for a clip mask.
26237
+ * @param color - The colour of the fill: the value's own by default, white for a tinted shape.
26238
+ */
26239
+ function drawShapePath(object, value, fillAlpha, color = value.fill) {
26240
+ object.clear();
26241
+ if (value.kind === "triangle") object.moveTo(0, 0).lineTo(value.w, value.h / 2).lineTo(0, value.h).closePath();
26242
+ else if (value.kind === "rect" && value.radius > 0) object.roundRect(0, 0, value.w, value.h, value.radius);
26243
+ else object.rect(0, 0, value.w, value.h);
26244
+ if (fillAlpha <= 0) return;
26245
+ object.fill(fillAlpha < 1 ? {
26246
+ color,
26247
+ alpha: fillAlpha
26248
+ } : { color });
26249
+ }
26250
+ /**
26251
+ * Strokes the outline drawn by `drawShapePath`: solid over the same path, or, with a `dash`, as
26252
+ * one open line per dash on a new path. Nothing without a stroke width.
26253
+ *
26254
+ * @param object - The graphics the shape was drawn on.
26255
+ * @param value - The shape value.
26256
+ */
26257
+ function strokeShape(object, value) {
26258
+ if (value.strokeWidth <= 0) return;
26259
+ const style = {
26260
+ color: value.stroke,
26261
+ width: value.strokeWidth
26262
+ };
26263
+ if (value.dash <= 0) {
26264
+ object.stroke(style);
26265
+ return;
26266
+ }
26267
+ object.beginPath();
26268
+ for (const [start, ...rest] of dashesOf(outlineOf(value), value.dash)) {
26269
+ if (start === void 0) continue;
26270
+ object.moveTo(start.x, start.y);
26271
+ for (const point of rest) object.lineTo(point.x, point.y);
26272
+ }
26273
+ object.stroke(style);
26274
+ }
26275
+ /**
26276
+ * The share of the circle an arc sweeps, clamped to 0..1: anything not above 0, `NaN` too, is 0; a whole ring when
26277
+ * the value leaves it out.
26278
+ *
26279
+ * @param value - The shape value.
26280
+ * @returns The share.
26281
+ * @example
26282
+ * ```ts
26283
+ * sweepOf({ ...Shape.defaults, kind: "arc", sweep: 1.4 }); // 1
26284
+ * ```
26285
+ */
26286
+ function sweepOf(value) {
26287
+ const sweep = value.sweep ?? arcDefaults.sweep;
26288
+ if (Number.isNaN(sweep) || sweep <= 0) return 0;
26289
+ return Math.min(sweep, 1);
26290
+ }
26291
+ /**
26292
+ * The radius of the circle an arc runs on: inscribed in the shorter side of its box, inside its stroke.
26293
+ *
26294
+ * @param value - The shape value.
26295
+ * @returns The radius in reference units; 0 or below when the stroke leaves no circle.
26296
+ * @example
26297
+ * ```ts
26298
+ * arcRadiusOf({ ...Shape.defaults, kind: "arc", w: 28, h: 40, strokeWidth: 2 }); // 13
26299
+ * ```
26300
+ */
26301
+ function arcRadiusOf(value) {
26302
+ return (Math.min(value.w, value.h) - value.strokeWidth) / 2;
26303
+ }
26304
+ /**
26305
+ * Tells whether an arc draws nothing: no stroke width, no sweep, or no circle inside its stroke.
26306
+ *
26307
+ * @param value - The shape value, an arc.
26308
+ * @returns True for an arc whose `Graphics` would hold no instruction.
26309
+ * @example
26310
+ * ```ts
26311
+ * isEmptyArc({ ...Shape.defaults, kind: "arc", w: 28, h: 28, strokeWidth: 2, sweep: 0 }); // true
26312
+ * ```
26313
+ */
26314
+ function isEmptyArc(value) {
26315
+ return value.strokeWidth <= 0 || sweepOf(value) <= 0 || arcRadiusOf(value) <= 0;
26316
+ }
26317
+ /**
26318
+ * Strokes an arc: from 12 o'clock clockwise over its share of the circle, in segments as many as its angle needs (48
26319
+ * for a ring, at least 2), ended by its `cap`. A full ring is closed and has no ends. Nothing for an empty arc. Drawn
26320
+ * only when the shape changed: never rebuilt per frame.
26321
+ *
26322
+ * @param object - The graphics, cleared.
26323
+ * @param value - The shape value, an arc.
26324
+ */
26325
+ function strokeArc(object, value) {
26326
+ if (isEmptyArc(value)) return;
26327
+ const sweep = sweepOf(value);
26328
+ const radius = arcRadiusOf(value);
26329
+ const ring = sweep >= 1;
26330
+ const segments = Math.max(ARC_MIN_SEGMENTS, Math.ceil(sweep * RING_SEGMENTS));
26331
+ const step = sweep * 2 * Math.PI / segments;
26332
+ const centreX = value.w / 2;
26333
+ const centreY = value.h / 2;
26334
+ const last = ring ? segments - 1 : segments;
26335
+ object.moveTo(centreX, centreY - radius);
26336
+ for (let index = 1; index <= last; index += 1) {
26337
+ const angle = TWELVE + index * step;
26338
+ object.lineTo(centreX + radius * Math.cos(angle), centreY + radius * Math.sin(angle));
26339
+ }
26340
+ if (ring) {
26341
+ object.closePath();
26342
+ object.stroke({
26343
+ color: value.stroke,
26344
+ width: value.strokeWidth
26345
+ });
26346
+ return;
26347
+ }
26348
+ object.stroke({
26349
+ color: value.stroke,
26350
+ width: value.strokeWidth,
26351
+ cap: value.cap ?? arcDefaults.cap
26352
+ });
26353
+ }
26354
+ /**
26355
+ * Paints a shape on its graphics: an arc as its stroke alone, every other kind as its path, its fill in a colour at its
26356
+ * own fill alpha, and its stroke. The one call that draws a shape's visual.
26357
+ *
26358
+ * @param object - The graphics.
26359
+ * @param value - The shape value.
26360
+ * @param color - The colour of the fill: white for a tinted shape, the value's own otherwise.
26361
+ */
26362
+ function paintShape(object, value, color) {
26363
+ if (value.kind === "arc") {
26364
+ object.clear();
26365
+ strokeArc(object, value);
26366
+ return;
26367
+ }
26368
+ drawShapePath(object, value, value.fillAlpha, color);
26369
+ strokeShape(object, value);
26370
+ }
26371
+ //#endregion
26372
+ //#region src/plugins/renderer/sync/textures.ts
26373
+ /** The colour a missing texture is drawn in. */
26374
+ const PLACEHOLDER_TINT = 16711935;
26375
+ /**
26376
+ * Creates an empty set. It lives in its own non-exported function because lint rule L5 refuses a
26377
+ * collection built inside an exported declaration.
26378
+ *
26379
+ * @returns An empty set.
26380
+ */
26381
+ function emptySet$5() {
26382
+ return /* @__PURE__ */ new Set();
26383
+ }
26384
+ /**
26385
+ * Asks the providers from the newest to the oldest. The first answer wins, so a bundle loaded
26386
+ * later shadows an older one.
26387
+ *
26388
+ * @param sctx - Domain context of the sync module.
26389
+ * @param key - The asset key.
26390
+ * @returns The texture, or `undefined` when nothing answered.
26391
+ */
26392
+ function resolveTexture(sctx, key) {
26393
+ const providers = sctx.ctx.state.sync.providers;
26394
+ for (let index = providers.length - 1; index >= 0; index -= 1) {
26395
+ const texture = providers[index]?.(key);
26396
+ if (texture !== void 0) return texture;
26397
+ }
26398
+ }
26399
+ /**
26400
+ * The 1x1 white texture the placeholder is drawn with.
26401
+ *
26402
+ * @param sctx - Domain context of the sync module.
26403
+ * @returns The texture, or `undefined` while inert.
26404
+ */
26405
+ function placeholderTexture(sctx) {
26406
+ return sctx.deps.host.pixi()?.Texture.WHITE;
26407
+ }
26408
+ /**
26409
+ * Reports a key no provider answers. One key warns once, however many views use it.
26410
+ *
26411
+ * @param sctx - Domain context of the sync module.
26412
+ * @param key - The asset key.
26413
+ */
26414
+ function warnMissing(sctx, key) {
26415
+ const warned = sctx.ctx.state.sync.warned;
26416
+ if (key === "" || warned.has(key)) return;
26417
+ warned.add(key);
26418
+ sctx.ctx.log.warn("renderer: no texture for asset key", { key });
26419
+ }
26420
+ /**
26421
+ * Remembers that an entity draws a key, so `invalidate` finds it again.
26422
+ *
26423
+ * @param state - The sync branch of the plugin state.
26424
+ * @param entity - The entity.
26425
+ * @param key - The asset key.
26426
+ */
26427
+ function trackKey(state, entity, key) {
26428
+ const users = state.byKey.get(key) ?? emptySet$5();
26429
+ users.add(entity);
26430
+ state.byKey.set(key, users);
26431
+ }
26432
+ /**
26433
+ * Forgets that an entity draws a key.
26434
+ *
26435
+ * @param state - The sync branch of the plugin state.
26436
+ * @param entity - The entity.
26437
+ * @param key - The asset key.
26438
+ */
26439
+ function untrackKey(state, entity, key) {
26440
+ const users = state.byKey.get(key);
26441
+ if (users === void 0) return;
26442
+ users.delete(entity);
26443
+ if (users.size === 0) state.byKey.delete(key);
26444
+ }
26445
+ /**
26446
+ * The Pixi module of a renderer that draws. A texture made while it does not would be lost with
26447
+ * the device, so the member that asked throws.
26448
+ *
26449
+ * @param sctx - Domain context of the sync module.
26450
+ * @param member - The `textures` member that asked, for the message.
26451
+ * @returns The Pixi module.
26452
+ * @throws {Error} When the renderer does not draw.
26453
+ */
26454
+ function drawingPixi(sctx, member) {
26455
+ const pixi = sctx.deps.host.pixi();
26456
+ if (pixi === void 0 || !sctx.deps.host.ready()) throw new Error(`[game] renderer.sync.textures.${member} needs a ready renderer.\n Check app.renderer.host.ready() first.`);
26457
+ return pixi;
26458
+ }
26459
+ /**
26460
+ * The default borders of a texture, from the four numbers `nine` gives.
26461
+ *
26462
+ * @param nine - Left, top, right and bottom in pixels.
26463
+ * @returns The borders, as Pixi reads them.
26464
+ * @example
26465
+ * ```ts
26466
+ * defaultBordersOf([24, 20, 16, 12]); // { left: 24, top: 20, right: 16, bottom: 12 }
26467
+ * ```
26468
+ */
26469
+ function defaultBordersOf(nine) {
26470
+ return {
26471
+ left: nine[0],
26472
+ top: nine[1],
26473
+ right: nine[2],
26474
+ bottom: nine[3]
26475
+ };
26476
+ }
26477
+ /**
26478
+ * Makes a Pixi texture out of a decoded image, on behalf of `assets`.
26479
+ *
26480
+ * @param sctx - Domain context of the sync module.
26481
+ * @param image - The decoded image.
26482
+ * @param options - Nine-slice borders in pixels, and whether Pixi generates the mip chain.
26483
+ * @returns The new texture.
26484
+ * @throws {Error} When the renderer does not draw.
26485
+ */
26486
+ function createTexture(sctx, image, options) {
26487
+ const pixi = drawingPixi(sctx, "create");
26488
+ const base = options?.mips === true ? pixi.Texture.from({
26489
+ resource: image,
26490
+ autoGenerateMipmaps: true
26491
+ }) : pixi.Texture.from(image);
26492
+ const nine = options?.nine;
26493
+ if (nine === void 0) return base;
26494
+ const bordered = new pixi.Texture({
26495
+ source: base.source,
26496
+ defaultBorders: defaultBordersOf(nine)
26497
+ });
26498
+ base.destroy(false);
26499
+ return bordered;
26500
+ }
26501
+ /**
26502
+ * Tells whether a frame lies inside a page of the given size. A negative number, or one that is
26503
+ * not a number, lies outside.
26504
+ *
26505
+ * @param frame - The frame in page pixels.
26506
+ * @param page - The size of the page.
26507
+ * @param page.width - Width of the page in pixels.
26508
+ * @param page.height - Height of the page in pixels.
26509
+ * @returns True when the whole frame is on the page.
26510
+ * @example
26511
+ * ```ts
26512
+ * frameFits({ x: 256, y: 384, width: 256, height: 128 }, { width: 512, height: 512 }); // true
26513
+ * ```
26514
+ */
26515
+ function frameFits(frame, page) {
26516
+ return frame.x >= 0 && frame.y >= 0 && frame.width >= 0 && frame.height >= 0 && frame.x + frame.width <= page.width && frame.y + frame.height <= page.height;
26517
+ }
26518
+ /**
26519
+ * Cuts a texture out of an atlas page, on behalf of `assets`. The slice shares the page's source,
26520
+ * its frame is offset by the page's own frame, and it is marked as a slice so `destroyTexture`
26521
+ * frees only the wrapper.
26522
+ *
26523
+ * @param sctx - Domain context of the sync module.
26524
+ * @param page - The page texture.
26525
+ * @param frame - The frame in page pixels.
26526
+ * @param options - Nine-slice borders, in pixels.
26527
+ * @returns The slice.
26528
+ * @throws {Error} When the renderer does not draw, and when the frame does not fit in the page.
26529
+ */
26530
+ function sliceTexture(sctx, page, frame, options) {
26531
+ const pixi = drawingPixi(sctx, "slice");
26532
+ if (!frameFits(frame, page)) throw new Error(`[game] renderer.sync.textures.slice: frame ${frame.x},${frame.y} ${frame.width}x${frame.height} is outside page ${page.width}x${page.height}.\n Run "bun run assets:pack".`);
26533
+ const nine = options?.nine;
26534
+ const slice = new pixi.Texture({
26535
+ source: page.source,
26536
+ frame: new pixi.Rectangle(page.frame.x + frame.x, page.frame.y + frame.y, frame.width, frame.height),
26537
+ ...nine === void 0 ? {} : { defaultBorders: defaultBordersOf(nine) }
26538
+ });
26539
+ sctx.ctx.state.sync.slices.add(slice);
26540
+ return slice;
26541
+ }
26542
+ /**
26543
+ * The cache key of one crop: the asset key and the box it covers.
26544
+ *
26545
+ * @param key - The asset key.
26546
+ * @param width - Width of the box.
26547
+ * @param height - Height of the box.
26548
+ * @returns The key of the crop.
26549
+ * @example
26550
+ * ```ts
26551
+ * frameKeyOf("board.bg", 1080, 1920); // "board.bg@1080x1920"
26552
+ * ```
26553
+ */
26554
+ function frameKeyOf(key, width, height) {
26555
+ return `${key}@${width}x${height}`;
26556
+ }
26557
+ /**
26558
+ * The texture a `"cover"` sprite draws: the part of `base` inside `crop`, sharing its source. One
26559
+ * crop per asset key and box, shared by every entity that shows it; the entity is added to its
26560
+ * users. A key that answers a new base has been invalidated, so every view of it resolves again
26561
+ * in this pass and joins the new crop; the old crop goes at once.
26562
+ *
26563
+ * @param sctx - Domain context of the sync module.
26564
+ * @param entity - The entity that shows the crop.
26565
+ * @param key - The asset key the base was resolved for.
26566
+ * @param base - The texture the key resolves to.
26567
+ * @param box - The box the sprite covers, which names the crop.
26568
+ * @param box.width - Width of the box.
26569
+ * @param box.height - Height of the box.
26570
+ * @param crop - The part of the base to show, in texture pixels.
26571
+ * @returns The crop and its `frames` key, or the base and `""` while inert.
26572
+ */
26573
+ function coverTexture(sctx, entity, key, base, box, crop) {
26574
+ const frames = sctx.ctx.state.sync.frames;
26575
+ const frameKey = frameKeyOf(key, box.width, box.height);
26576
+ const cached = frames.get(frameKey);
26577
+ if (cached?.base === base) {
26578
+ cached.users.add(entity);
26579
+ return {
26580
+ texture: cached.texture,
26581
+ frameKey
26582
+ };
26583
+ }
26584
+ const pixi = sctx.deps.host.pixi();
26585
+ if (pixi === void 0) return {
26586
+ texture: base,
26587
+ frameKey: ""
26588
+ };
26589
+ if (cached !== void 0) cached.texture.destroy(false);
26590
+ const texture = new pixi.Texture({
26591
+ source: base.source,
26592
+ frame: new pixi.Rectangle(base.frame.x + crop.x, base.frame.y + crop.y, crop.width, crop.height)
26593
+ });
26594
+ const users = emptySet$5();
26595
+ users.add(entity);
26596
+ frames.set(frameKey, {
26597
+ base,
26598
+ texture,
26599
+ users
26600
+ });
26601
+ return {
26602
+ texture,
26603
+ frameKey
26604
+ };
26605
+ }
26606
+ /**
26607
+ * Lets an entity go of the crop it showed. The last user frees the crop, keeping the source it
26608
+ * shares with its base.
26609
+ *
26610
+ * @param state - The sync branch of the plugin state.
26611
+ * @param entity - The entity that no longer shows it.
26612
+ * @param frameKey - The `frames` key of the crop; `""` for none.
26613
+ */
26614
+ function releaseCover(state, entity, frameKey) {
26615
+ const frame = state.frames.get(frameKey);
26616
+ if (frame === void 0) return;
26617
+ frame.users.delete(entity);
26618
+ if (frame.users.size > 0) return;
26619
+ frame.texture.destroy(false);
26620
+ state.frames.delete(frameKey);
26621
+ }
26622
+ /**
26623
+ * Frees the crops cut from one texture, keeping the source they share with it.
26624
+ *
26625
+ * @param state - The sync branch of the plugin state.
26626
+ * @param base - The texture that goes.
26627
+ */
26628
+ function releaseFrames$1(state, base) {
26629
+ for (const [frameKey, frame] of state.frames) {
26630
+ if (frame.base !== base) continue;
26631
+ frame.texture.destroy(false);
26632
+ state.frames.delete(frameKey);
26633
+ }
26634
+ }
26635
+ /**
26636
+ * Frees every crop. Works on the state alone, because `onStop` has no context.
26637
+ *
26638
+ * @param state - The sync branch of the plugin state.
26639
+ */
26640
+ function clearFrames(state) {
26641
+ for (const frame of state.frames.values()) frame.texture.destroy(false);
26642
+ state.frames.clear();
26643
+ }
26644
+ /**
26645
+ * Frees a texture, its source and the crops cut from it. A slice keeps the source: it belongs to
26646
+ * the page, which frees it. A source that goes lets go of its `change` listeners first, the ones
26647
+ * of its style too, so no bind group Pixi still keeps is told. A texture destroyed twice is a
26648
+ * no-op.
26649
+ *
26650
+ * @param state - The sync branch of the plugin state.
26651
+ * @param texture - The texture to free.
26652
+ */
26653
+ function destroyTexture(state, texture) {
26654
+ releaseFrames$1(state, texture);
26655
+ if (texture.destroyed) return;
26656
+ if (state.slices.has(texture)) {
26657
+ texture.destroy(false);
26658
+ return;
26659
+ }
26660
+ texture.source.style.removeAllListeners("change");
26661
+ texture.source.removeAllListeners("change");
26662
+ texture.destroy(true);
26663
+ }
26664
+ //#endregion
26665
+ //#region src/plugins/renderer/sync/leave.ts
26666
+ /** Label of the container the look hangs in, last on the stage. */
26667
+ const TOP_LABEL = "renderer.leave";
26668
+ /** Clock milliseconds of one turn of the loader. */
26669
+ const TURN_MS = 1e3;
26670
+ /** A whole turn, in radians. */
26671
+ const TURN$1 = 2 * Math.PI;
26672
+ /** The engine's ring: three quarters of a white circle 96 u across, 10 u wide, round ends, turned about its centre. */
26673
+ const RING = Object.freeze({
26674
+ kind: "arc",
26675
+ w: 96,
26676
+ h: 96,
26677
+ fill: 16777215,
26678
+ fillAlpha: 0,
26679
+ alpha: 1,
26680
+ radius: 0,
26681
+ stroke: 16777215,
26682
+ strokeWidth: 10,
26683
+ dash: 0,
26684
+ clip: false,
26685
+ sweep: .75,
26686
+ cap: "round"
26687
+ });
26688
+ /**
26689
+ * Makes the engine's objects of the picture, once per application: the top container with the veil and the frame, and
26690
+ * in the frame the holder of a lifted layer, the ring and the spinner. Nothing hangs on the stage yet.
26691
+ *
26692
+ * @param sctx - Domain context of the sync module.
26693
+ * @returns The objects, or `undefined` while nothing draws (inert, a lost device).
26694
+ */
26695
+ function objectsOf(sctx) {
26696
+ const leave = sctx.ctx.state.sync.leave;
26697
+ if (leave.objects !== void 0) return leave.objects;
26698
+ const pixi = sctx.deps.host.pixi();
26699
+ if (pixi === void 0 || sctx.deps.host.stage() === void 0) return void 0;
26700
+ const top = new pixi.Container();
26701
+ const veil = new pixi.Sprite(pixi.Texture.WHITE);
26702
+ const frame = new pixi.Container();
26703
+ const held = new pixi.Container();
26704
+ const ring = new pixi.Graphics();
26705
+ const spinner = new pixi.Sprite(pixi.Texture.WHITE);
26706
+ top.label = TOP_LABEL;
26707
+ veil.label = `${TOP_LABEL}.cover`;
26708
+ veil.anchor.set(0, 0);
26709
+ held.label = `${TOP_LABEL}.layer`;
26710
+ ring.label = `${TOP_LABEL}.ring`;
26711
+ paintShape(ring, RING, RING.stroke);
26712
+ ring.pivot.set(RING.w / 2, RING.h / 2);
26713
+ spinner.label = `${TOP_LABEL}.texture`;
26714
+ spinner.anchor.set(.5, .5);
26715
+ frame.addChild(held);
26716
+ frame.addChild(ring);
26717
+ frame.addChild(spinner);
26718
+ top.addChild(veil);
26719
+ top.addChild(frame);
26720
+ leave.objects = {
26721
+ top,
26722
+ veil,
26723
+ frame,
26724
+ held,
26725
+ ring,
26726
+ spinner
26727
+ };
26728
+ return leave.objects;
26729
+ }
26730
+ /**
26731
+ * What the loader of a look draws once due: a game texture, a layer above the veil, or the engine's ring. A texture key
26732
+ * no provider answers is warned once and draws the ring.
26733
+ *
26734
+ * @param sctx - Domain context of the sync module.
26735
+ * @param look - The look being shown.
26736
+ * @returns The loader's kind, and the texture of a `"texture"` loader.
26737
+ */
26738
+ function loaderOf$1(sctx, look) {
26739
+ const loader = look.loader;
26740
+ if (loader === void 0) return { loader: "none" };
26741
+ if (loader.texture === void 0) return { loader: loader.due === void 0 ? "ring" : "layer" };
26742
+ const texture = resolveTexture(sctx, loader.texture);
26743
+ if (texture !== void 0) return {
26744
+ loader: "texture",
26745
+ texture
26746
+ };
26747
+ warnMissing(sctx, loader.texture);
26748
+ return { loader: "ring" };
26749
+ }
26750
+ /**
26751
+ * Puts a layer the look drew above the veil back under the root, at the place of its layer: the layer list of the
26752
+ * scene orders the root's children. A layer the list of a swap took back already, or let go, is left alone.
26753
+ *
26754
+ * @param sctx - Domain context of the sync module.
26755
+ * @param run - The look whose layer goes back.
26756
+ */
26757
+ function unlift(sctx, run) {
26758
+ const lifted = run.lifted;
26759
+ if (lifted === void 0) return;
26760
+ delete run.lifted;
26761
+ const state = sctx.ctx.state.sync;
26762
+ if (lifted.parent !== state.leave.objects?.held) return;
26763
+ const name = run.layer;
26764
+ const at = state.layerList?.findIndex((spec) => spec.name === name) ?? -1;
26765
+ const root = state.root;
26766
+ state.passesVersion += 1;
26767
+ if (root === void 0 || at === -1) {
26768
+ detach$1(lifted);
26769
+ return;
26770
+ }
26771
+ root.addChildAt(lifted, Math.min(at, root.children.length));
26772
+ }
26773
+ /**
26774
+ * Takes the look down at once: its layer goes back, its objects leave the stage and are kept for the next look, and the
26775
+ * spinner lets the game's texture go. A frame is asked for, so the screen without it is drawn.
26776
+ *
26777
+ * @param sctx - Domain context of the sync module.
26778
+ */
26779
+ function takeDown(sctx) {
26780
+ const leave = sctx.ctx.state.sync.leave;
26781
+ const run = leave.run;
26782
+ if (run !== void 0) unlift(sctx, run);
26783
+ delete leave.run;
26784
+ const objects = leave.objects;
26785
+ if (objects !== void 0) {
26786
+ detach$1(objects.top);
26787
+ const white = placeholderTexture(sctx);
26788
+ if (white !== void 0) objects.spinner.texture = white;
26789
+ }
26790
+ sctx.deps.demand.request();
26791
+ }
26792
+ /**
26793
+ * Moves the veil's level on the clock: up to 1 over `inMs` before the swap, down to 0 over `outMs` after it.
26794
+ *
26795
+ * @param run - The running look.
26796
+ * @param now - The clock now, ms.
26797
+ * @returns True when the level moved: the frame is drawn.
26798
+ */
26799
+ function advance(run, now) {
26800
+ const cover = run.look.cover;
26801
+ const elapsed = Math.max(0, now - run.steppedAt);
26802
+ const before = run.level;
26803
+ run.steppedAt = now;
26804
+ if (cover === void 0) return false;
26805
+ if (run.landed) run.level = cover.outMs > 0 ? Math.max(0, before - elapsed / cover.outMs) : 0;
26806
+ else run.level = cover.inMs > 0 ? Math.min(1, before + elapsed / cover.inMs) : 1;
26807
+ return run.level !== before;
26808
+ }
26809
+ /**
26810
+ * Hangs the look last on the stage and over the canvas as it stands: the veil covers the screen, the frame carries the
26811
+ * root's transform, so what it holds lands where the scene's reference units do.
26812
+ *
26813
+ * @param sctx - Domain context of the sync module.
26814
+ * @param objects - The engine's objects.
26815
+ * @param stage - The stage of the application.
26816
+ * @param screen - The canvas in CSS pixels, the stage's own space.
26817
+ * @param screen.x - Its left edge.
26818
+ * @param screen.y - Its top edge.
26819
+ * @param screen.width - Its width.
26820
+ * @param screen.height - Its height.
26821
+ */
26822
+ function hang$1(sctx, objects, stage, screen) {
26823
+ if (stage.children.at(-1) !== objects.top) stage.addChild(objects.top);
26824
+ objects.veil.position.set(screen.x, screen.y);
26825
+ objects.veil.scale.set(screen.width, screen.height);
26826
+ sctx.deps.viewport.apply(objects.frame);
26827
+ }
26828
+ /**
26829
+ * Asks a `"layer"` loader's `due`, once, the first frame it is due: what it draws is made now, and it answers the
26830
+ * layer that holds it. No layer, or a throw (logged), draws the ring instead.
26831
+ *
26832
+ * @param sctx - Domain context of the sync module.
26833
+ * @param run - The running look, its loader due.
26834
+ */
26835
+ function ask(sctx, run) {
26836
+ run.asked = true;
26837
+ let layer;
26838
+ try {
26839
+ layer = run.look.loader?.due?.();
26840
+ } catch (error) {
26841
+ sctx.ctx.log.error("renderer: the loader of a scene being left threw", { error });
26842
+ }
26843
+ if (layer === void 0) run.loader = "ring";
26844
+ else run.layer = layer;
26845
+ }
26846
+ /**
26847
+ * Writes the veil of a running look: its colour, and its alpha at the level it stands at; hidden for a look without one.
26848
+ *
26849
+ * @param objects - The engine's objects.
26850
+ * @param run - The running look.
26851
+ */
26852
+ function paintVeil(objects, run) {
26853
+ const cover = run.look.cover;
26854
+ objects.veil.visible = cover !== void 0;
26855
+ if (cover === void 0) return;
26856
+ objects.veil.tint = cover.color;
26857
+ objects.veil.alpha = cover.alpha * run.level;
26858
+ }
26859
+ /**
26860
+ * Keeps the layer of a shown `"layer"` loader in the holder above the veil until the swap. A layer the scene does not
26861
+ * declare draws nothing.
26862
+ *
26863
+ * @param sctx - Domain context of the sync module.
26864
+ * @param objects - The engine's objects.
26865
+ * @param run - The running look, its loader shown.
26866
+ */
26867
+ function lift(sctx, objects, run) {
26868
+ const state = sctx.ctx.state.sync;
26869
+ const container = run.layer === void 0 ? void 0 : layerContainer(state, run.layer);
26870
+ if (container === void 0 || container.parent === objects.held) return;
26871
+ objects.held.addChild(container);
26872
+ run.lifted = container;
26873
+ state.passesVersion += 1;
26874
+ }
26875
+ /**
26876
+ * Turns the ring or the spinner at the centre of the frame: one turn a second from the switch start.
26877
+ *
26878
+ * @param objects - The engine's objects.
26879
+ * @param run - The running look, its loader shown.
26880
+ * @param now - The clock now, ms.
26881
+ * @param size - The frame in reference units.
26882
+ */
26883
+ function turn(objects, run, now, size) {
26884
+ const angle = (now - run.startedAt) % TURN_MS / TURN_MS * TURN$1;
26885
+ const turned = run.loader === "ring" ? objects.ring : objects.spinner;
26886
+ if (run.texture !== void 0 && objects.spinner.texture !== run.texture) objects.spinner.texture = run.texture;
26887
+ turned.position.set(size.width / 2, size.height / 2);
26888
+ turned.rotation = angle;
26889
+ }
26890
+ /**
26891
+ * Shows the loader once the switch took `afterMs`, until the swap: a layer loader asks its `due` then and lifts the
26892
+ * layer it answers above the veil; a ring or a texture turns. A loader that comes or goes moves the walks of `shown`,
26893
+ * so the loops of a lifted layer's views run while it shows.
26894
+ *
26895
+ * @param sctx - Domain context of the sync module.
26896
+ * @param objects - The engine's objects.
26897
+ * @param run - The running look.
26898
+ * @param now - The clock now, ms.
26899
+ * @returns True when the loader came, went or turns in this frame: the frame is drawn.
26900
+ */
26901
+ function paintLoader(sctx, objects, run, now) {
26902
+ const afterMs = run.look.loader?.afterMs ?? 0;
26903
+ const shown = !run.landed && run.loader !== "none" && now - run.startedAt >= afterMs;
26904
+ const due = shown && run.loader === "layer";
26905
+ if (due && !run.asked) ask(sctx, run);
26906
+ if (due) lift(sctx, objects, run);
26907
+ const toggled = shown !== run.shown;
26908
+ if (toggled) {
26909
+ run.shown = shown;
26910
+ sctx.ctx.state.sync.passesVersion += 1;
26911
+ }
26912
+ const isLayer = shown && run.loader === "layer";
26913
+ objects.held.visible = isLayer;
26914
+ objects.ring.visible = shown && run.loader === "ring";
26915
+ objects.spinner.visible = shown && run.loader === "texture";
26916
+ if (!shown || isLayer) return toggled;
26917
+ turn(objects, run, now, sctx.deps.viewport.size());
26918
+ return true;
26919
+ }
26920
+ /**
26921
+ * Shows a look from now, in place of one that runs: the veil fades in from where it stands (0, or the level of a veil
26922
+ * still fading out after the last swap), the loader waits for its `afterMs`. Inert, nothing is kept.
26923
+ *
26924
+ * @param sctx - Domain context of the sync module.
26925
+ * @param look - The veil and the loader, resolved.
26926
+ */
26927
+ function showLeave(sctx, look) {
26928
+ const state = sctx.ctx.state.sync;
26929
+ if (state.root === void 0) return;
26930
+ const before = state.leave.run;
26931
+ if (before !== void 0) unlift(sctx, before);
26932
+ const now = sctx.ctx.deps.clock.now();
26933
+ const level = before?.look.cover === void 0 ? 0 : before.level;
26934
+ const loader = loaderOf$1(sctx, look);
26935
+ state.leave.run = {
26936
+ look,
26937
+ startedAt: now,
26938
+ steppedAt: now,
26939
+ level,
26940
+ landed: false,
26941
+ asked: false,
26942
+ shown: false,
26943
+ ...loader
26944
+ };
26945
+ sctx.deps.demand.request();
26946
+ }
26947
+ /**
26948
+ * The swap came: the loader goes at once and the veil fades out over its `outMs` from where it stands; a look without
26949
+ * a veil, or with `outMs: 0`, is taken down at once. Nothing runs, or the swap was told already: nothing happens.
26950
+ *
26951
+ * @param sctx - Domain context of the sync module.
26952
+ */
26953
+ function landLeave(sctx) {
26954
+ const run = sctx.ctx.state.sync.leave.run;
26955
+ if (run === void 0 || run.landed) return;
26956
+ const cover = run.look.cover;
26957
+ if (cover === void 0 || cover.outMs <= 0) {
26958
+ takeDown(sctx);
26959
+ return;
26960
+ }
26961
+ unlift(sctx, run);
26962
+ run.landed = true;
26963
+ run.steppedAt = sctx.ctx.deps.clock.now();
26964
+ sctx.deps.demand.request();
26965
+ }
26966
+ /**
26967
+ * The switch ended without a swap: the veil and the loader go at once. Nothing runs: nothing happens.
26968
+ *
26969
+ * @param sctx - Domain context of the sync module.
26970
+ */
26971
+ function dropLeave(sctx) {
26972
+ if (sctx.ctx.state.sync.leave.run === void 0) return;
26973
+ takeDown(sctx);
26974
+ }
26975
+ /**
26976
+ * The look of this frame, before the decision: the veil's level from the clock, the loader once due, both placed over
26977
+ * the canvas as it stands, and a frame asked for while either moves. A look whose veil faded out after the swap is
26978
+ * taken down. One field read and no allocation while no look runs; a lost device leaves the look waiting for the
26979
+ * restore, which makes its objects again.
26980
+ *
26981
+ * @param sctx - Domain context of the sync module.
26982
+ */
26983
+ function stepLeave(sctx) {
26984
+ const run = sctx.ctx.state.sync.leave.run;
26985
+ if (run === void 0) return;
26986
+ const objects = objectsOf(sctx);
26987
+ const stage = sctx.deps.host.stage();
26988
+ const screen = sctx.deps.host.screen();
26989
+ if (objects === void 0 || stage === void 0 || screen === void 0) return;
26990
+ const now = sctx.ctx.deps.clock.now();
26991
+ const moved = advance(run, now);
26992
+ if (run.landed && run.level <= 0) {
26993
+ takeDown(sctx);
26994
+ return;
26995
+ }
26996
+ hang$1(sctx, objects, stage, screen);
26997
+ paintVeil(objects, run);
26998
+ const loading = paintLoader(sctx, objects, run, now);
26999
+ if (moved || loading) sctx.deps.demand.request();
27000
+ }
27001
+ /**
27002
+ * Forgets the objects of a lost application: they go with its stage, and so does a layer lifted above the veil. The
27003
+ * running look stays: the step makes its objects again on the restored device.
27004
+ *
27005
+ * @param state - The sync branch of the plugin state.
27006
+ */
27007
+ function forgetLeave$1(state) {
27008
+ delete state.leave.objects;
27009
+ if (state.leave.run !== void 0) delete state.leave.run.lifted;
27010
+ }
27011
+ /**
27012
+ * Drops the look and frees the engine's objects when the plugin stops. A lifted layer is the layer table's and the
27013
+ * spinner's texture is the game's: neither is freed with them.
27014
+ *
27015
+ * @param state - The sync branch of the plugin state.
27016
+ */
27017
+ function stopLeave(state) {
27018
+ const objects = state.leave.objects;
27019
+ delete state.leave.run;
27020
+ delete state.leave.objects;
27021
+ if (objects === void 0) return;
27022
+ objects.held.removeChildren();
27023
+ objects.top.destroy({ children: true });
27024
+ }
27025
+ //#endregion
26029
27026
  //#region src/plugins/renderer/sync/overlay-range.ts
26030
27027
  /** A range retaken for a mark in this many frames in a row is drawn live instead (O8.4; spec 23's `CHURN_LIMIT`). */
26031
27028
  const RETAKE_STREAK = 3;
@@ -26718,349 +27715,6 @@ function stopOverlays(state) {
26718
27715
  overlay.range = void 0;
26719
27716
  }
26720
27717
  //#endregion
26721
- //#region src/plugins/renderer/sync/shape-path.ts
26722
- /**
26723
- * @file renderer/sync — the path of a `Shape`: a rectangle, a rounded rectangle or a triangle,
26724
- * its fill, and its stroke, solid or dashed, or an arc, a stroke alone. A dashed stroke walks the
26725
- * outline (straight edges, and rounded corners sampled as arcs) and strokes one open line per dash.
26726
- * An arc runs on the circle inscribed in its box, from 12 o'clock clockwise.
26727
- */
26728
- /** Chords per rounded corner of a dashed outline: under 0.3 u off the arc at a 48 u radius. */
26729
- const CORNER_SAMPLES = 8;
26730
- /** The gap between two dashes, as a fraction of the dash length. */
26731
- const GAP_RATIO = .5;
26732
- /** Segments of a full ring: a ring of 28 u across strays under 0.03 u off its circle. */
26733
- const RING_SEGMENTS = 48;
26734
- /** The fewest segments of an arc that draws: a sliver still bends. */
26735
- const ARC_MIN_SEGMENTS = 2;
26736
- /** 12 o'clock, where an arc starts: Pixi's angles run clockwise from 3 o'clock. */
26737
- const TWELVE = -Math.PI / 2;
26738
- /**
26739
- * The corner radius the rectangle is drawn with: Pixi clamps it to half the shorter side.
26740
- *
26741
- * @param value - The shape value.
26742
- * @returns The radius in reference units, 0 for square corners.
26743
- * @example
26744
- * ```ts
26745
- * radiusOf({ ...Shape.defaults, w: 100, h: 20, radius: 40 }); // 10
26746
- * ```
26747
- */
26748
- function radiusOf(value) {
26749
- return Math.max(0, Math.min(value.radius, value.w / 2, value.h / 2));
26750
- }
26751
- /**
26752
- * The closed outline of a shape as points, clockwise from the top left (from the end of the top
26753
- * left corner on a rounded rectangle); the last point repeats the first.
26754
- *
26755
- * @param value - The shape value.
26756
- * @returns The points of the outline.
26757
- * @example
26758
- * ```ts
26759
- * outlineOf({ ...Shape.defaults, kind: "triangle", w: 60, h: 40 });
26760
- * // [{ x: 0, y: 0 }, { x: 60, y: 20 }, { x: 0, y: 40 }, { x: 0, y: 0 }]
26761
- * ```
26762
- */
26763
- function outlineOf(value) {
26764
- const { w, h } = value;
26765
- if (value.kind === "triangle") return [
26766
- {
26767
- x: 0,
26768
- y: 0
26769
- },
26770
- {
26771
- x: w,
26772
- y: h / 2
26773
- },
26774
- {
26775
- x: 0,
26776
- y: h
26777
- },
26778
- {
26779
- x: 0,
26780
- y: 0
26781
- }
26782
- ];
26783
- const radius = radiusOf(value);
26784
- if (radius === 0) return [
26785
- {
26786
- x: 0,
26787
- y: 0
26788
- },
26789
- {
26790
- x: w,
26791
- y: 0
26792
- },
26793
- {
26794
- x: w,
26795
- y: h
26796
- },
26797
- {
26798
- x: 0,
26799
- y: h
26800
- },
26801
- {
26802
- x: 0,
26803
- y: 0
26804
- }
26805
- ];
26806
- const corners = [
26807
- {
26808
- edge: {
26809
- x: w - radius,
26810
- y: 0
26811
- },
26812
- center: {
26813
- x: w - radius,
26814
- y: radius
26815
- }
26816
- },
26817
- {
26818
- edge: {
26819
- x: w,
26820
- y: h - radius
26821
- },
26822
- center: {
26823
- x: w - radius,
26824
- y: h - radius
26825
- }
26826
- },
26827
- {
26828
- edge: {
26829
- x: radius,
26830
- y: h
26831
- },
26832
- center: {
26833
- x: radius,
26834
- y: h - radius
26835
- }
26836
- },
26837
- {
26838
- edge: {
26839
- x: 0,
26840
- y: radius
26841
- },
26842
- center: {
26843
- x: radius,
26844
- y: radius
26845
- }
26846
- }
26847
- ];
26848
- const points = [{
26849
- x: radius,
26850
- y: 0
26851
- }];
26852
- for (const [index, { edge, center }] of corners.entries()) {
26853
- const from = -Math.PI / 2 + index * Math.PI / 2;
26854
- points.push(edge);
26855
- for (let sample = 1; sample <= CORNER_SAMPLES; sample += 1) {
26856
- const angle = from + sample * Math.PI / 2 / CORNER_SAMPLES;
26857
- points.push({
26858
- x: center.x + radius * Math.cos(angle),
26859
- y: center.y + radius * Math.sin(angle)
26860
- });
26861
- }
26862
- }
26863
- return points;
26864
- }
26865
- /**
26866
- * Adds a point to a line unless the line already ends there.
26867
- *
26868
- * @param line - The line.
26869
- * @param point - The point.
26870
- */
26871
- function extend(line, point) {
26872
- const last = line.at(-1);
26873
- if (last?.x !== point.x || last.y !== point.y) line.push(point);
26874
- }
26875
- /**
26876
- * Cuts a polyline into dashes of `dash` length with gaps of half a dash, starting with a dash at
26877
- * its first point. A dash bends round the corners it crosses; the last one may be shorter.
26878
- *
26879
- * @param outline - The points of the polyline.
26880
- * @param dash - The dash length, above 0.
26881
- * @returns One open line per dash.
26882
- * @example
26883
- * ```ts
26884
- * dashesOf([{ x: 0, y: 0 }, { x: 40, y: 0 }], 10);
26885
- * // [[{ x: 0, y: 0 }, { x: 10, y: 0 }], [{ x: 15, y: 0 }, { x: 25, y: 0 }], [{ x: 30, y: 0 }, { x: 40, y: 0 }]]
26886
- * ```
26887
- */
26888
- function dashesOf(outline, dash) {
26889
- const first = outline[0];
26890
- if (first === void 0) return [];
26891
- const dashes = [];
26892
- let line = [first];
26893
- let left = dash;
26894
- for (let index = 1; index < outline.length; index += 1) {
26895
- let from = outline[index - 1] ?? first;
26896
- const to = outline[index] ?? first;
26897
- let length = Math.hypot(to.x - from.x, to.y - from.y);
26898
- while (length > left) {
26899
- from = {
26900
- x: from.x + (to.x - from.x) * left / length,
26901
- y: from.y + (to.y - from.y) * left / length
26902
- };
26903
- length -= left;
26904
- if (line === void 0) {
26905
- line = [from];
26906
- left = dash;
26907
- } else {
26908
- extend(line, from);
26909
- dashes.push(line);
26910
- line = void 0;
26911
- left = dash * GAP_RATIO;
26912
- }
26913
- }
26914
- left -= length;
26915
- if (line !== void 0) extend(line, to);
26916
- }
26917
- if (line !== void 0 && line.length > 1) dashes.push(line);
26918
- return dashes;
26919
- }
26920
- /**
26921
- * Draws the path of a shape and fills it in a colour at the alpha given. At alpha 0 the path stays unfilled, so only
26922
- * a stroke shows. A clip mask is drawn here too, never dashed; the path of an arc here is its box, which is what it
26923
- * clips its children to. A fill-only shape is drawn white and coloured by the object's tint, so a colour change draws
26924
- * no path (spec 26 R2.1).
26925
- *
26926
- * @param object - The graphics to draw on.
26927
- * @param value - The shape value.
26928
- * @param fillAlpha - The alpha of the fill: the value's own for the shape, 1 for a clip mask.
26929
- * @param color - The colour of the fill: the value's own by default, white for a tinted shape.
26930
- */
26931
- function drawShapePath(object, value, fillAlpha, color = value.fill) {
26932
- object.clear();
26933
- if (value.kind === "triangle") object.moveTo(0, 0).lineTo(value.w, value.h / 2).lineTo(0, value.h).closePath();
26934
- else if (value.kind === "rect" && value.radius > 0) object.roundRect(0, 0, value.w, value.h, value.radius);
26935
- else object.rect(0, 0, value.w, value.h);
26936
- if (fillAlpha <= 0) return;
26937
- object.fill(fillAlpha < 1 ? {
26938
- color,
26939
- alpha: fillAlpha
26940
- } : { color });
26941
- }
26942
- /**
26943
- * Strokes the outline drawn by `drawShapePath`: solid over the same path, or, with a `dash`, as
26944
- * one open line per dash on a new path. Nothing without a stroke width.
26945
- *
26946
- * @param object - The graphics the shape was drawn on.
26947
- * @param value - The shape value.
26948
- */
26949
- function strokeShape(object, value) {
26950
- if (value.strokeWidth <= 0) return;
26951
- const style = {
26952
- color: value.stroke,
26953
- width: value.strokeWidth
26954
- };
26955
- if (value.dash <= 0) {
26956
- object.stroke(style);
26957
- return;
26958
- }
26959
- object.beginPath();
26960
- for (const [start, ...rest] of dashesOf(outlineOf(value), value.dash)) {
26961
- if (start === void 0) continue;
26962
- object.moveTo(start.x, start.y);
26963
- for (const point of rest) object.lineTo(point.x, point.y);
26964
- }
26965
- object.stroke(style);
26966
- }
26967
- /**
26968
- * The share of the circle an arc sweeps, clamped to 0..1: anything not above 0, `NaN` too, is 0; a whole ring when
26969
- * the value leaves it out.
26970
- *
26971
- * @param value - The shape value.
26972
- * @returns The share.
26973
- * @example
26974
- * ```ts
26975
- * sweepOf({ ...Shape.defaults, kind: "arc", sweep: 1.4 }); // 1
26976
- * ```
26977
- */
26978
- function sweepOf(value) {
26979
- const sweep = value.sweep ?? arcDefaults.sweep;
26980
- if (Number.isNaN(sweep) || sweep <= 0) return 0;
26981
- return Math.min(sweep, 1);
26982
- }
26983
- /**
26984
- * The radius of the circle an arc runs on: inscribed in the shorter side of its box, inside its stroke.
26985
- *
26986
- * @param value - The shape value.
26987
- * @returns The radius in reference units; 0 or below when the stroke leaves no circle.
26988
- * @example
26989
- * ```ts
26990
- * arcRadiusOf({ ...Shape.defaults, kind: "arc", w: 28, h: 40, strokeWidth: 2 }); // 13
26991
- * ```
26992
- */
26993
- function arcRadiusOf(value) {
26994
- return (Math.min(value.w, value.h) - value.strokeWidth) / 2;
26995
- }
26996
- /**
26997
- * Tells whether an arc draws nothing: no stroke width, no sweep, or no circle inside its stroke.
26998
- *
26999
- * @param value - The shape value, an arc.
27000
- * @returns True for an arc whose `Graphics` would hold no instruction.
27001
- * @example
27002
- * ```ts
27003
- * isEmptyArc({ ...Shape.defaults, kind: "arc", w: 28, h: 28, strokeWidth: 2, sweep: 0 }); // true
27004
- * ```
27005
- */
27006
- function isEmptyArc(value) {
27007
- return value.strokeWidth <= 0 || sweepOf(value) <= 0 || arcRadiusOf(value) <= 0;
27008
- }
27009
- /**
27010
- * Strokes an arc: from 12 o'clock clockwise over its share of the circle, in segments as many as its angle needs (48
27011
- * for a ring, at least 2), ended by its `cap`. A full ring is closed and has no ends. Nothing for an empty arc. Drawn
27012
- * only when the shape changed: never rebuilt per frame.
27013
- *
27014
- * @param object - The graphics, cleared.
27015
- * @param value - The shape value, an arc.
27016
- */
27017
- function strokeArc(object, value) {
27018
- if (isEmptyArc(value)) return;
27019
- const sweep = sweepOf(value);
27020
- const radius = arcRadiusOf(value);
27021
- const ring = sweep >= 1;
27022
- const segments = Math.max(ARC_MIN_SEGMENTS, Math.ceil(sweep * RING_SEGMENTS));
27023
- const step = sweep * 2 * Math.PI / segments;
27024
- const centreX = value.w / 2;
27025
- const centreY = value.h / 2;
27026
- const last = ring ? segments - 1 : segments;
27027
- object.moveTo(centreX, centreY - radius);
27028
- for (let index = 1; index <= last; index += 1) {
27029
- const angle = TWELVE + index * step;
27030
- object.lineTo(centreX + radius * Math.cos(angle), centreY + radius * Math.sin(angle));
27031
- }
27032
- if (ring) {
27033
- object.closePath();
27034
- object.stroke({
27035
- color: value.stroke,
27036
- width: value.strokeWidth
27037
- });
27038
- return;
27039
- }
27040
- object.stroke({
27041
- color: value.stroke,
27042
- width: value.strokeWidth,
27043
- cap: value.cap ?? arcDefaults.cap
27044
- });
27045
- }
27046
- /**
27047
- * Paints a shape on its graphics: an arc as its stroke alone, every other kind as its path, its fill in a colour at its
27048
- * own fill alpha, and its stroke. The one call that draws a shape's visual.
27049
- *
27050
- * @param object - The graphics.
27051
- * @param value - The shape value.
27052
- * @param color - The colour of the fill: white for a tinted shape, the value's own otherwise.
27053
- */
27054
- function paintShape(object, value, color) {
27055
- if (value.kind === "arc") {
27056
- object.clear();
27057
- strokeArc(object, value);
27058
- return;
27059
- }
27060
- drawShapePath(object, value, value.fillAlpha, color);
27061
- strokeShape(object, value);
27062
- }
27063
- //#endregion
27064
27718
  //#region src/plugins/renderer/sync/sizing.ts
27065
27719
  /**
27066
27720
  * Creates a layout to write into, every field present.
@@ -27196,299 +27850,6 @@ function layoutSpriteInto(out, texture, value) {
27196
27850
  return out;
27197
27851
  }
27198
27852
  //#endregion
27199
- //#region src/plugins/renderer/sync/textures.ts
27200
- /** The colour a missing texture is drawn in. */
27201
- const PLACEHOLDER_TINT = 16711935;
27202
- /**
27203
- * Creates an empty set. It lives in its own non-exported function because lint rule L5 refuses a
27204
- * collection built inside an exported declaration.
27205
- *
27206
- * @returns An empty set.
27207
- */
27208
- function emptySet$5() {
27209
- return /* @__PURE__ */ new Set();
27210
- }
27211
- /**
27212
- * Asks the providers from the newest to the oldest. The first answer wins, so a bundle loaded
27213
- * later shadows an older one.
27214
- *
27215
- * @param sctx - Domain context of the sync module.
27216
- * @param key - The asset key.
27217
- * @returns The texture, or `undefined` when nothing answered.
27218
- */
27219
- function resolveTexture(sctx, key) {
27220
- const providers = sctx.ctx.state.sync.providers;
27221
- for (let index = providers.length - 1; index >= 0; index -= 1) {
27222
- const texture = providers[index]?.(key);
27223
- if (texture !== void 0) return texture;
27224
- }
27225
- }
27226
- /**
27227
- * The 1x1 white texture the placeholder is drawn with.
27228
- *
27229
- * @param sctx - Domain context of the sync module.
27230
- * @returns The texture, or `undefined` while inert.
27231
- */
27232
- function placeholderTexture(sctx) {
27233
- return sctx.deps.host.pixi()?.Texture.WHITE;
27234
- }
27235
- /**
27236
- * Reports a key no provider answers. One key warns once, however many views use it.
27237
- *
27238
- * @param sctx - Domain context of the sync module.
27239
- * @param key - The asset key.
27240
- */
27241
- function warnMissing(sctx, key) {
27242
- const warned = sctx.ctx.state.sync.warned;
27243
- if (key === "" || warned.has(key)) return;
27244
- warned.add(key);
27245
- sctx.ctx.log.warn("renderer: no texture for asset key", { key });
27246
- }
27247
- /**
27248
- * Remembers that an entity draws a key, so `invalidate` finds it again.
27249
- *
27250
- * @param state - The sync branch of the plugin state.
27251
- * @param entity - The entity.
27252
- * @param key - The asset key.
27253
- */
27254
- function trackKey(state, entity, key) {
27255
- const users = state.byKey.get(key) ?? emptySet$5();
27256
- users.add(entity);
27257
- state.byKey.set(key, users);
27258
- }
27259
- /**
27260
- * Forgets that an entity draws a key.
27261
- *
27262
- * @param state - The sync branch of the plugin state.
27263
- * @param entity - The entity.
27264
- * @param key - The asset key.
27265
- */
27266
- function untrackKey(state, entity, key) {
27267
- const users = state.byKey.get(key);
27268
- if (users === void 0) return;
27269
- users.delete(entity);
27270
- if (users.size === 0) state.byKey.delete(key);
27271
- }
27272
- /**
27273
- * The Pixi module of a renderer that draws. A texture made while it does not would be lost with
27274
- * the device, so the member that asked throws.
27275
- *
27276
- * @param sctx - Domain context of the sync module.
27277
- * @param member - The `textures` member that asked, for the message.
27278
- * @returns The Pixi module.
27279
- * @throws {Error} When the renderer does not draw.
27280
- */
27281
- function drawingPixi(sctx, member) {
27282
- const pixi = sctx.deps.host.pixi();
27283
- if (pixi === void 0 || !sctx.deps.host.ready()) throw new Error(`[game] renderer.sync.textures.${member} needs a ready renderer.\n Check app.renderer.host.ready() first.`);
27284
- return pixi;
27285
- }
27286
- /**
27287
- * The default borders of a texture, from the four numbers `nine` gives.
27288
- *
27289
- * @param nine - Left, top, right and bottom in pixels.
27290
- * @returns The borders, as Pixi reads them.
27291
- * @example
27292
- * ```ts
27293
- * defaultBordersOf([24, 20, 16, 12]); // { left: 24, top: 20, right: 16, bottom: 12 }
27294
- * ```
27295
- */
27296
- function defaultBordersOf(nine) {
27297
- return {
27298
- left: nine[0],
27299
- top: nine[1],
27300
- right: nine[2],
27301
- bottom: nine[3]
27302
- };
27303
- }
27304
- /**
27305
- * Makes a Pixi texture out of a decoded image, on behalf of `assets`.
27306
- *
27307
- * @param sctx - Domain context of the sync module.
27308
- * @param image - The decoded image.
27309
- * @param options - Nine-slice borders in pixels, and whether Pixi generates the mip chain.
27310
- * @returns The new texture.
27311
- * @throws {Error} When the renderer does not draw.
27312
- */
27313
- function createTexture(sctx, image, options) {
27314
- const pixi = drawingPixi(sctx, "create");
27315
- const base = options?.mips === true ? pixi.Texture.from({
27316
- resource: image,
27317
- autoGenerateMipmaps: true
27318
- }) : pixi.Texture.from(image);
27319
- const nine = options?.nine;
27320
- if (nine === void 0) return base;
27321
- const bordered = new pixi.Texture({
27322
- source: base.source,
27323
- defaultBorders: defaultBordersOf(nine)
27324
- });
27325
- base.destroy(false);
27326
- return bordered;
27327
- }
27328
- /**
27329
- * Tells whether a frame lies inside a page of the given size. A negative number, or one that is
27330
- * not a number, lies outside.
27331
- *
27332
- * @param frame - The frame in page pixels.
27333
- * @param page - The size of the page.
27334
- * @param page.width - Width of the page in pixels.
27335
- * @param page.height - Height of the page in pixels.
27336
- * @returns True when the whole frame is on the page.
27337
- * @example
27338
- * ```ts
27339
- * frameFits({ x: 256, y: 384, width: 256, height: 128 }, { width: 512, height: 512 }); // true
27340
- * ```
27341
- */
27342
- function frameFits(frame, page) {
27343
- return frame.x >= 0 && frame.y >= 0 && frame.width >= 0 && frame.height >= 0 && frame.x + frame.width <= page.width && frame.y + frame.height <= page.height;
27344
- }
27345
- /**
27346
- * Cuts a texture out of an atlas page, on behalf of `assets`. The slice shares the page's source,
27347
- * its frame is offset by the page's own frame, and it is marked as a slice so `destroyTexture`
27348
- * frees only the wrapper.
27349
- *
27350
- * @param sctx - Domain context of the sync module.
27351
- * @param page - The page texture.
27352
- * @param frame - The frame in page pixels.
27353
- * @param options - Nine-slice borders, in pixels.
27354
- * @returns The slice.
27355
- * @throws {Error} When the renderer does not draw, and when the frame does not fit in the page.
27356
- */
27357
- function sliceTexture(sctx, page, frame, options) {
27358
- const pixi = drawingPixi(sctx, "slice");
27359
- if (!frameFits(frame, page)) throw new Error(`[game] renderer.sync.textures.slice: frame ${frame.x},${frame.y} ${frame.width}x${frame.height} is outside page ${page.width}x${page.height}.\n Run "bun run assets:pack".`);
27360
- const nine = options?.nine;
27361
- const slice = new pixi.Texture({
27362
- source: page.source,
27363
- frame: new pixi.Rectangle(page.frame.x + frame.x, page.frame.y + frame.y, frame.width, frame.height),
27364
- ...nine === void 0 ? {} : { defaultBorders: defaultBordersOf(nine) }
27365
- });
27366
- sctx.ctx.state.sync.slices.add(slice);
27367
- return slice;
27368
- }
27369
- /**
27370
- * The cache key of one crop: the asset key and the box it covers.
27371
- *
27372
- * @param key - The asset key.
27373
- * @param width - Width of the box.
27374
- * @param height - Height of the box.
27375
- * @returns The key of the crop.
27376
- * @example
27377
- * ```ts
27378
- * frameKeyOf("board.bg", 1080, 1920); // "board.bg@1080x1920"
27379
- * ```
27380
- */
27381
- function frameKeyOf(key, width, height) {
27382
- return `${key}@${width}x${height}`;
27383
- }
27384
- /**
27385
- * The texture a `"cover"` sprite draws: the part of `base` inside `crop`, sharing its source. One
27386
- * crop per asset key and box, shared by every entity that shows it; the entity is added to its
27387
- * users. A key that answers a new base has been invalidated, so every view of it resolves again
27388
- * in this pass and joins the new crop; the old crop goes at once.
27389
- *
27390
- * @param sctx - Domain context of the sync module.
27391
- * @param entity - The entity that shows the crop.
27392
- * @param key - The asset key the base was resolved for.
27393
- * @param base - The texture the key resolves to.
27394
- * @param box - The box the sprite covers, which names the crop.
27395
- * @param box.width - Width of the box.
27396
- * @param box.height - Height of the box.
27397
- * @param crop - The part of the base to show, in texture pixels.
27398
- * @returns The crop and its `frames` key, or the base and `""` while inert.
27399
- */
27400
- function coverTexture(sctx, entity, key, base, box, crop) {
27401
- const frames = sctx.ctx.state.sync.frames;
27402
- const frameKey = frameKeyOf(key, box.width, box.height);
27403
- const cached = frames.get(frameKey);
27404
- if (cached?.base === base) {
27405
- cached.users.add(entity);
27406
- return {
27407
- texture: cached.texture,
27408
- frameKey
27409
- };
27410
- }
27411
- const pixi = sctx.deps.host.pixi();
27412
- if (pixi === void 0) return {
27413
- texture: base,
27414
- frameKey: ""
27415
- };
27416
- if (cached !== void 0) cached.texture.destroy(false);
27417
- const texture = new pixi.Texture({
27418
- source: base.source,
27419
- frame: new pixi.Rectangle(base.frame.x + crop.x, base.frame.y + crop.y, crop.width, crop.height)
27420
- });
27421
- const users = emptySet$5();
27422
- users.add(entity);
27423
- frames.set(frameKey, {
27424
- base,
27425
- texture,
27426
- users
27427
- });
27428
- return {
27429
- texture,
27430
- frameKey
27431
- };
27432
- }
27433
- /**
27434
- * Lets an entity go of the crop it showed. The last user frees the crop, keeping the source it
27435
- * shares with its base.
27436
- *
27437
- * @param state - The sync branch of the plugin state.
27438
- * @param entity - The entity that no longer shows it.
27439
- * @param frameKey - The `frames` key of the crop; `""` for none.
27440
- */
27441
- function releaseCover(state, entity, frameKey) {
27442
- const frame = state.frames.get(frameKey);
27443
- if (frame === void 0) return;
27444
- frame.users.delete(entity);
27445
- if (frame.users.size > 0) return;
27446
- frame.texture.destroy(false);
27447
- state.frames.delete(frameKey);
27448
- }
27449
- /**
27450
- * Frees the crops cut from one texture, keeping the source they share with it.
27451
- *
27452
- * @param state - The sync branch of the plugin state.
27453
- * @param base - The texture that goes.
27454
- */
27455
- function releaseFrames$1(state, base) {
27456
- for (const [frameKey, frame] of state.frames) {
27457
- if (frame.base !== base) continue;
27458
- frame.texture.destroy(false);
27459
- state.frames.delete(frameKey);
27460
- }
27461
- }
27462
- /**
27463
- * Frees every crop. Works on the state alone, because `onStop` has no context.
27464
- *
27465
- * @param state - The sync branch of the plugin state.
27466
- */
27467
- function clearFrames(state) {
27468
- for (const frame of state.frames.values()) frame.texture.destroy(false);
27469
- state.frames.clear();
27470
- }
27471
- /**
27472
- * Frees a texture, its source and the crops cut from it. A slice keeps the source: it belongs to
27473
- * the page, which frees it. A source that goes lets go of its `change` listeners first, the ones
27474
- * of its style too, so no bind group Pixi still keeps is told. A texture destroyed twice is a
27475
- * no-op.
27476
- *
27477
- * @param state - The sync branch of the plugin state.
27478
- * @param texture - The texture to free.
27479
- */
27480
- function destroyTexture(state, texture) {
27481
- releaseFrames$1(state, texture);
27482
- if (texture.destroyed) return;
27483
- if (state.slices.has(texture)) {
27484
- texture.destroy(false);
27485
- return;
27486
- }
27487
- texture.source.style.removeAllListeners("change");
27488
- texture.source.removeAllListeners("change");
27489
- texture.destroy(true);
27490
- }
27491
- //#endregion
27492
27853
  //#region src/plugins/renderer/sync/upload.ts
27493
27854
  /** The calls of a source nobody waits for: none. */
27494
27855
  const NOBODY = Object.freeze([]);
@@ -28875,6 +29236,7 @@ function stopSync(state) {
28875
29236
  state.redraws = 0;
28876
29237
  state.overdraw.listeners.length = 0;
28877
29238
  clearLayers(state);
29239
+ stopLeave(state);
28878
29240
  destroyPools(state);
28879
29241
  state.root?.destroy({ children: true });
28880
29242
  state.root = void 0;
@@ -29044,6 +29406,7 @@ function renderFrame(ctx, modules) {
29044
29406
  modules.sync.stepFilters();
29045
29407
  modules.sync.stepOverlays();
29046
29408
  modules.sync.drainOverlayEvents(modules.monitor);
29409
+ modules.sync.stepLeave();
29047
29410
  modules.kept.collect();
29048
29411
  const overdraw = modules.sync.overdrawing();
29049
29412
  if (overdraw) modules.demand.request();
@@ -29847,7 +30210,7 @@ function boundsOf(sctx, entity) {
29847
30210
  * @param entity - The entity.
29848
30211
  * @returns The layer name, or `undefined` when the root has no `Layer`.
29849
30212
  */
29850
- function layerOf(sctx, entity) {
30213
+ function layerOf$1(sctx, entity) {
29851
30214
  const ecs = sctx.ctx.deps.world.ecs;
29852
30215
  let top = entity;
29853
30216
  for (let depth = 0; depth < MAX_DEPTH$3; depth += 1) {
@@ -30474,7 +30837,7 @@ function stepParts(sctx) {
30474
30837
  const view = state.views.get(entity);
30475
30838
  if (view === void 0) continue;
30476
30839
  const key = ecs.get(entity, HudPart)?.key ?? "";
30477
- if (hud === void 0 || layerOf(sctx, entity) !== hud) {
30840
+ if (hud === void 0 || layerOf$1(sctx, entity) !== hud) {
30478
30841
  warnOutside(sctx, entity, key);
30479
30842
  applyHud(sctx, entity, view, 1);
30480
30843
  continue;
@@ -31519,6 +31882,7 @@ function createSyncApi(ctx, deps) {
31519
31882
  destroyPools(state);
31520
31883
  clearLayers(state);
31521
31884
  forgetBuild(state);
31885
+ forgetLeave$1(state);
31522
31886
  state.root = void 0;
31523
31887
  state.passesVersion += 1;
31524
31888
  };
@@ -31612,11 +31976,17 @@ function createSyncApi(ctx, deps) {
31612
31976
  area: (entity, area) => setArea(sctx, entity, area),
31613
31977
  warm: (filter) => warmFilter(sctx, filter)
31614
31978
  },
31979
+ leave: {
31980
+ show: (look) => showLeave(sctx, look),
31981
+ land: () => landLeave(sctx),
31982
+ drop: () => dropLeave(sctx)
31983
+ },
31615
31984
  renderPasses: () => renderPassesOf(state, ctx.state.kept),
31616
31985
  root: () => state.root,
31617
31986
  layerContainer: (name) => layerContainer(state, name),
31618
- layerOf: (entity) => layerOf(sctx, entity),
31987
+ layerOf: (entity) => layerOf$1(sctx, entity),
31619
31988
  stepOverlays: () => stepOverlays(sctx),
31989
+ stepLeave: () => stepLeave(sctx),
31620
31990
  stepFilters: () => applySwitched(state, deps.kept),
31621
31991
  drainOverlayEvents: (monitor) => drainOverlayEvents(sctx, monitor),
31622
31992
  warmStep: () => warmStep(sctx),
@@ -32205,6 +32575,7 @@ function exposeSync(sync) {
32205
32575
  fonts: sync.fonts,
32206
32576
  debug: sync.debug,
32207
32577
  filters: sync.filters,
32578
+ leave: sync.leave,
32208
32579
  renderPasses: sync.renderPasses,
32209
32580
  loopsHeld: sync.loopsHeld,
32210
32581
  displayOf: sync.displayOf
@@ -32446,8 +32817,8 @@ function createAsideQueue() {
32446
32817
  }
32447
32818
  /**
32448
32819
  * Creates the initial sync state: no root, no layers, no view, no orphan, no pool, no provider, no filter, no
32449
- * filter area, no slice, no hidden mask, no overlay, no warm-up, no upload, no redraw counted, no unseen walk kept and
32450
- * no scene build.
32820
+ * filter area, no slice, no hidden mask, no overlay, no warm-up, no upload, no redraw counted, no unseen walk kept,
32821
+ * no scene build and no picture of a scene being left.
32451
32822
  *
32452
32823
  * @param debug - The debug switches of the config; copied, so the frozen config stays untouched.
32453
32824
  * @returns The sync branch of the plugin state.
@@ -32512,6 +32883,7 @@ function createSyncState(debug) {
32512
32883
  count: 0,
32513
32884
  reported: false
32514
32885
  },
32886
+ leave: {},
32515
32887
  cleanups: []
32516
32888
  };
32517
32889
  }
@@ -35179,7 +35551,7 @@ const NO_ANIMATION_ID = "[game] A play effect carries no animation id.\n Build
35179
35551
  * isRecord([1, 2]); // false
35180
35552
  * ```
35181
35553
  */
35182
- function isRecord$3(value) {
35554
+ function isRecord$4(value) {
35183
35555
  return Boolean(value) && typeof value === "object" && !Array.isArray(value);
35184
35556
  }
35185
35557
  /**
@@ -35304,7 +35676,7 @@ function stepAnim(actx, rt, time) {
35304
35676
  * @returns True when it carries an id and a build function.
35305
35677
  */
35306
35678
  function isAnimation(entry) {
35307
- return isRecord$3(entry) && typeof entry.id === "string" && typeof entry.build === "function";
35679
+ return isRecord$4(entry) && typeof entry.id === "string" && typeof entry.build === "function";
35308
35680
  }
35309
35681
  /**
35310
35682
  * Reads the `animations` of every feature into the registry, in feature order.
@@ -35331,7 +35703,7 @@ function registerAnimations(actx) {
35331
35703
  */
35332
35704
  function readTarget$1(entry) {
35333
35705
  if (typeof entry === "number") return entry;
35334
- if (!isRecord$3(entry)) return void 0;
35706
+ if (!isRecord$4(entry)) return void 0;
35335
35707
  if (typeof entry.projection === "string" && typeof entry.key === "string") return {
35336
35708
  projection: entry.projection,
35337
35709
  key: entry.key
@@ -35347,7 +35719,7 @@ function readTarget$1(entry) {
35347
35719
  */
35348
35720
  function readSlots(value) {
35349
35721
  const slots = {};
35350
- if (!isRecord$3(value)) return slots;
35722
+ if (!isRecord$4(value)) return slots;
35351
35723
  for (const [name, entry] of Object.entries(value)) {
35352
35724
  if (Array.isArray(entry)) {
35353
35725
  slots[name] = entry.map((item) => readTarget$1(item)).filter((target) => target !== void 0);
@@ -35365,7 +35737,7 @@ function readSlots(value) {
35365
35737
  * @returns The animation id and its slots, or `undefined` for a foreign payload.
35366
35738
  */
35367
35739
  function readPlayPayload(payload) {
35368
- if (!isRecord$3(payload) || typeof payload.animation !== "string") return void 0;
35740
+ if (!isRecord$4(payload) || typeof payload.animation !== "string") return void 0;
35369
35741
  return {
35370
35742
  animation: payload.animation,
35371
35743
  slots: readSlots(payload.slots)
@@ -35689,7 +36061,7 @@ function emptyManifest() {
35689
36061
  * @param value - Anything that came out of JSON.
35690
36062
  * @returns True for an object that is not an array.
35691
36063
  */
35692
- function isRecord$2(value) {
36064
+ function isRecord$3(value) {
35693
36065
  return typeof value === "object" && value !== null && !Array.isArray(value);
35694
36066
  }
35695
36067
  /**
@@ -35729,7 +36101,7 @@ function stringAt(source, key) {
35729
36101
  * @returns The four borders, or `undefined`.
35730
36102
  */
35731
36103
  function parseNine(value) {
35732
- if (!isRecord$2(value)) return void 0;
36104
+ if (!isRecord$3(value)) return void 0;
35733
36105
  return {
35734
36106
  left: numberAt(value, "left"),
35735
36107
  top: numberAt(value, "top"),
@@ -35744,7 +36116,7 @@ function parseNine(value) {
35744
36116
  * @returns The frame, or `undefined`.
35745
36117
  */
35746
36118
  function parseAtlas(value) {
35747
- if (!isRecord$2(value)) return void 0;
36119
+ if (!isRecord$3(value)) return void 0;
35748
36120
  return {
35749
36121
  page: stringAt(value, "page"),
35750
36122
  x: numberAt(value, "x"),
@@ -35771,7 +36143,7 @@ function parseKind(value) {
35771
36143
  */
35772
36144
  function parseFontPages(value) {
35773
36145
  if (!Array.isArray(value)) return void 0;
35774
- return value.filter((entry) => isRecord$2(entry)).map((entry) => ({
36146
+ return value.filter((entry) => isRecord$3(entry)).map((entry) => ({
35775
36147
  path: stringAt(entry, "path"),
35776
36148
  width: numberAt(entry, "width"),
35777
36149
  height: numberAt(entry, "height"),
@@ -35786,7 +36158,7 @@ function parseFontPages(value) {
35786
36158
  */
35787
36159
  function parseAtlasPages(value) {
35788
36160
  if (!Array.isArray(value)) return void 0;
35789
- return value.filter((entry) => isRecord$2(entry)).map((entry) => ({
36161
+ return value.filter((entry) => isRecord$3(entry)).map((entry) => ({
35790
36162
  id: stringAt(entry, "id"),
35791
36163
  path: stringAt(entry, "path"),
35792
36164
  width: numberAt(entry, "width"),
@@ -35802,7 +36174,7 @@ function parseAtlasPages(value) {
35802
36174
  * @returns The file, or `undefined` when the element is not an object.
35803
36175
  */
35804
36176
  function parseFile(raw) {
35805
- if (!isRecord$2(raw)) return void 0;
36177
+ if (!isRecord$3(raw)) return void 0;
35806
36178
  const kind = parseKind(raw.kind);
35807
36179
  const pages = parseFontPages(raw.pages);
35808
36180
  const nine = parseNine(raw.nine);
@@ -35873,7 +36245,7 @@ function textAt(source, key) {
35873
36245
  * @throws {Error} When the entry is not an object or its tier is unknown.
35874
36246
  */
35875
36247
  function parseBundle(name, raw) {
35876
- if (!isRecord$2(raw)) throw new Error(`[game] assets: bundle "${name}" of the manifest is not an object.\n Rebuild the manifest with "bun run assets:keys".`);
36248
+ if (!isRecord$3(raw)) throw new Error(`[game] assets: bundle "${name}" of the manifest is not an object.\n Rebuild the manifest with "bun run assets:keys".`);
35877
36249
  const files = (Array.isArray(raw.files) ? raw.files : []).map((entry) => parseFile(entry)).filter((file) => file !== void 0).toSorted((left, right) => left.key.localeCompare(right.key));
35878
36250
  const pages = parseAtlasPages(raw.pages);
35879
36251
  const parent = textAt(raw, "parent");
@@ -35914,9 +36286,9 @@ function parseVersion(value) {
35914
36286
  * ```
35915
36287
  */
35916
36288
  function parseManifest(raw) {
35917
- if (!isRecord$2(raw)) throw new Error("[game] assets: the manifest is not an object.\n Point \"manifest\" at a manifest.json, or pass the parsed object.");
36289
+ if (!isRecord$3(raw)) throw new Error("[game] assets: the manifest is not an object.\n Point \"manifest\" at a manifest.json, or pass the parsed object.");
35918
36290
  const version = parseVersion(raw.version);
35919
- const source = isRecord$2(raw.bundles) ? raw.bundles : {};
36291
+ const source = isRecord$3(raw.bundles) ? raw.bundles : {};
35920
36292
  const bundles = {};
35921
36293
  for (const name of Object.keys(source).toSorted((left, right) => left.localeCompare(right))) bundles[name] = parseBundle(name, source[name]);
35922
36294
  return {
@@ -39101,6 +39473,468 @@ function dropBuild(build) {
39101
39473
  build.resolve();
39102
39474
  }
39103
39475
  //#endregion
39476
+ //#region src/plugins/scenes/leave.ts
39477
+ /**
39478
+ * What a game gets when it writes nothing: black at 45 %, 150 ms in and out, the engine's ring once the switch took
39479
+ * 400 ms. The level under `config.leave`, and where a part written over `false` starts. `index.ts` writes the same
39480
+ * value inline as the plugin's default config; a test pins the two equal.
39481
+ */
39482
+ const FALLBACK_LEAVE = Object.freeze({
39483
+ cover: Object.freeze({
39484
+ color: 0,
39485
+ alpha: .45,
39486
+ inMs: 150,
39487
+ outMs: 150
39488
+ }),
39489
+ loader: Object.freeze({ afterMs: 400 })
39490
+ });
39491
+ /**
39492
+ * Merges one level's veil over the one below, field by field. `false` turns it off; a veil written over `false`
39493
+ * starts from the engine's.
39494
+ *
39495
+ * @param below - The veil of the levels below, or `false`.
39496
+ * @param over - What this level writes: nothing, `false` or fields.
39497
+ * @returns The veil, or `false`.
39498
+ * @example
39499
+ * ```ts
39500
+ * coverOver(false, { alpha: 0.6 }); // { color: 0, alpha: 0.6, inMs: 150, outMs: 150 }
39501
+ * ```
39502
+ */
39503
+ function coverOver(below, over) {
39504
+ if (over === void 0) return below;
39505
+ if (over === false) return false;
39506
+ const base = below === false ? FALLBACK_LEAVE.cover : below;
39507
+ return {
39508
+ color: over.color ?? base.color,
39509
+ alpha: over.alpha ?? base.alpha,
39510
+ inMs: over.inMs ?? base.inMs,
39511
+ outMs: over.outMs ?? base.outMs
39512
+ };
39513
+ }
39514
+ /**
39515
+ * The look of a loader: its projection, else its texture, else nothing (the engine's ring).
39516
+ *
39517
+ * @param loader - A level that names a look, or the level below.
39518
+ * @returns At most one of the two fields.
39519
+ * @example
39520
+ * ```ts
39521
+ * lookOf({ texture: "ui.loader.ring", projection: "loading" }); // { projection: "loading" }
39522
+ * ```
39523
+ */
39524
+ function lookOf(loader) {
39525
+ if (loader.projection !== void 0) return { projection: loader.projection };
39526
+ if (loader.texture !== void 0) return { texture: loader.texture };
39527
+ return {};
39528
+ }
39529
+ /**
39530
+ * Merges one level's loader over the one below. `afterMs` is a field of its own; `texture` and `projection` are one
39531
+ * choice: a level that names either replaces both of the level below. `false` turns it off; a loader written over
39532
+ * `false` starts from the engine's.
39533
+ *
39534
+ * @param below - The loader of the levels below, or `false`.
39535
+ * @param over - What this level writes: nothing, `false` or fields.
39536
+ * @returns The loader, or `false`.
39537
+ * @example
39538
+ * ```ts
39539
+ * loaderOver({ afterMs: 400, projection: "loading" }, { texture: "ui.loader.ring" }); // { afterMs: 400, texture: "ui.loader.ring" }
39540
+ * ```
39541
+ */
39542
+ function loaderOver(below, over) {
39543
+ if (over === void 0) return below;
39544
+ if (over === false) return false;
39545
+ const base = below === false ? FALLBACK_LEAVE.loader : below;
39546
+ const named = over.texture !== void 0 || over.projection !== void 0;
39547
+ return {
39548
+ afterMs: over.afterMs ?? base.afterMs,
39549
+ ...lookOf(named ? over : base)
39550
+ };
39551
+ }
39552
+ /**
39553
+ * Resolves what a switch shows over the scene being left: the scene's `leave` over the config's over the engine's,
39554
+ * field by field. `false` at a level turns both parts off there.
39555
+ *
39556
+ * @param config - `config.leave`.
39557
+ * @param scene - The `leave` of the scene being left.
39558
+ * @returns Both parts, each resolved or `false`; `undefined` when both are off.
39559
+ * @example
39560
+ * ```ts
39561
+ * leaveOf({ loader: false }, { cover: { alpha: 0.6 } }); // { cover: { color: 0, alpha: 0.6, inMs: 150, outMs: 150 }, loader: false }
39562
+ * ```
39563
+ */
39564
+ function leaveOf(config, scene) {
39565
+ let cover = FALLBACK_LEAVE.cover;
39566
+ let loader = FALLBACK_LEAVE.loader;
39567
+ for (const level of [config, scene]) if (level === false) {
39568
+ cover = false;
39569
+ loader = false;
39570
+ } else if (level !== void 0) {
39571
+ cover = coverOver(cover, level.cover);
39572
+ loader = loaderOver(loader, level.loader);
39573
+ }
39574
+ if (cover === false && loader === false) return void 0;
39575
+ return {
39576
+ cover,
39577
+ loader
39578
+ };
39579
+ }
39580
+ /**
39581
+ * The layer a mounted projection's views draw in, read from its first view.
39582
+ *
39583
+ * @param ctx - Domain context of the plugin.
39584
+ * @param name - The projection.
39585
+ * @returns The layer, or `undefined` while it has no view.
39586
+ */
39587
+ function layerOf(ctx, name) {
39588
+ const { projection, ecs } = ctx.deps.world;
39589
+ const entity = projection.entitiesOf(name)[0];
39590
+ return entity === void 0 ? void 0 : ecs.get(entity, Layer)?.name;
39591
+ }
39592
+ /**
39593
+ * Unmounts the loader projection a switch mounted, once: one it did not mount is left alone.
39594
+ *
39595
+ * @param ctx - Domain context of the plugin.
39596
+ * @param leaving - The look of the switch.
39597
+ */
39598
+ function unmountLoader(ctx, leaving) {
39599
+ const name = leaving.projection;
39600
+ if (name === void 0) return;
39601
+ delete leaving.projection;
39602
+ ctx.deps.world.projection.unmount([name]);
39603
+ }
39604
+ /**
39605
+ * Mounts a loader projection for the switch, unless it is mounted already (by another owner): such a one is drawn as it
39606
+ * is and never unmounted by the switch. A mount that throws (an unknown name, a layer the scene being left does not
39607
+ * declare) is warned.
39608
+ *
39609
+ * @param ctx - Domain context of the plugin.
39610
+ * @param name - The loader projection.
39611
+ * @param from - The scene being left.
39612
+ * @param leaving - The look of the switch: it keeps the name to unmount at the end.
39613
+ * @returns False when the mount threw.
39614
+ */
39615
+ function mountOnce(ctx, name, from, leaving) {
39616
+ const projection = ctx.deps.world.projection;
39617
+ if (projection.entitiesOf(name).length > 0) return true;
39618
+ try {
39619
+ projection.mount([name], ctx.state.owner);
39620
+ } catch (error) {
39621
+ ctx.log.warn("scenes:loader-unmounted", {
39622
+ projection: name,
39623
+ scene: from.id,
39624
+ error
39625
+ });
39626
+ return false;
39627
+ }
39628
+ leaving.projection = name;
39629
+ return true;
39630
+ }
39631
+ /**
39632
+ * Tells whether a projection of the scene being left draws in a layer: lifted above the veil, that layer would take
39633
+ * the scene's own views with it.
39634
+ *
39635
+ * @param ctx - Domain context of the plugin.
39636
+ * @param from - The scene being left.
39637
+ * @param layer - The layer of the loader.
39638
+ * @returns True when one of its projections has a view in that layer.
39639
+ */
39640
+ function sharesLayer(ctx, from, layer) {
39641
+ return from.projections.some((other) => layerOf(ctx, other) === layer);
39642
+ }
39643
+ /**
39644
+ * Mounts a loader projection once its loader is due, called by the renderer in that frame, and answers the layer its
39645
+ * views draw in, which the renderer draws above the veil. A mount that throws, a layer a projection of the scene being
39646
+ * left draws in too, and a projection with no view give the engine's ring. A switch that ended meanwhile mounts
39647
+ * nothing.
39648
+ *
39649
+ * @param ctx - Domain context of the plugin.
39650
+ * @param name - The loader projection.
39651
+ * @param from - The scene being left.
39652
+ * @param leaving - The look of the switch: it keeps the name to unmount at the end.
39653
+ * @returns The layer, or `undefined` for the ring.
39654
+ */
39655
+ function mountLoader(ctx, name, from, leaving) {
39656
+ if (leaving.done || !mountOnce(ctx, name, from, leaving)) return void 0;
39657
+ const layer = layerOf(ctx, name);
39658
+ if (layer === void 0 || !sharesLayer(ctx, from, layer)) return layer;
39659
+ ctx.log.warn("scenes:loader-layer-shared", {
39660
+ projection: name,
39661
+ layer,
39662
+ scene: from.id
39663
+ });
39664
+ unmountLoader(ctx, leaving);
39665
+ }
39666
+ /**
39667
+ * The renderer's loader of a resolved one: a texture as it is, nothing for the ring, and for a projection the `due`
39668
+ * the renderer calls once the loader is due, which mounts it then: a switch done sooner never runs its `from` and
39669
+ * `view`. A projection a scene of the switch lists is no loader: warned, the ring.
39670
+ *
39671
+ * @param ctx - Domain context of the plugin.
39672
+ * @param loader - The resolved loader.
39673
+ * @param between - The scene being left and the scene being switched to.
39674
+ * @param between.from - The scene being left.
39675
+ * @param between.to - The scene being switched to.
39676
+ * @param leaving - The look of the switch.
39677
+ * @returns What the renderer draws.
39678
+ */
39679
+ function loaderLook(ctx, loader, between, leaving) {
39680
+ const { afterMs, texture, projection } = loader;
39681
+ const { from, to } = between;
39682
+ if (texture !== void 0) return {
39683
+ afterMs,
39684
+ texture
39685
+ };
39686
+ if (projection === void 0) return { afterMs };
39687
+ if (from.projections.includes(projection) || to.projections.includes(projection)) {
39688
+ ctx.log.warn("scenes:loader-listed", {
39689
+ projection,
39690
+ from: from.id,
39691
+ to: to.id
39692
+ });
39693
+ return { afterMs };
39694
+ }
39695
+ return {
39696
+ afterMs,
39697
+ due: () => mountLoader(ctx, projection, from, leaving)
39698
+ };
39699
+ }
39700
+ /**
39701
+ * Tells whether a switch shows anything over the scene being left: a real change of scene, a live run that was not
39702
+ * aborted, a world that runs live, a drawing renderer. The first scene of a run, a fast walk, a world walking fast or
39703
+ * paused and a headless app show nothing.
39704
+ *
39705
+ * @param ctx - Domain context of the plugin.
39706
+ * @param run - The runner's mode and signal.
39707
+ * @returns The scene being left, or `undefined` when nothing is shown.
39708
+ */
39709
+ function leftScene(ctx, run) {
39710
+ const current = ctx.state.current;
39711
+ if (current === void 0 || run.mode !== "live" || run.signal.aborted) return void 0;
39712
+ if (ctx.deps.world.ecs.mode() !== "live" || !ctx.deps.renderer.host.ready()) return void 0;
39713
+ return ctx.state.scenes.get(current);
39714
+ }
39715
+ /**
39716
+ * Shows the veil and the loader of a switch that starts now, before its bundles are awaited: the scene being left
39717
+ * decides. A loader projection is mounted only once its loader is due. An abort of the node drops both at once, from
39718
+ * its listener.
39719
+ *
39720
+ * @param ctx - Domain context of the plugin.
39721
+ * @param scene - The scene being switched to.
39722
+ * @param run - The runner's mode and signal.
39723
+ * @returns The look of the switch, or `undefined` when it shows nothing.
39724
+ */
39725
+ function startLeave(ctx, scene, run) {
39726
+ const from = leftScene(ctx, run);
39727
+ if (from === void 0) return void 0;
39728
+ const leave = leaveOf(ctx.config.leave, from.leave);
39729
+ if (leave === void 0) return void 0;
39730
+ const listening = new AbortController();
39731
+ const leaving = {
39732
+ done: false,
39733
+ off: () => listening.abort()
39734
+ };
39735
+ const look = {};
39736
+ if (leave.cover !== false) look.cover = leave.cover;
39737
+ if (leave.loader !== false) look.loader = loaderLook(ctx, leave.loader, {
39738
+ from,
39739
+ to: scene
39740
+ }, leaving);
39741
+ ctx.deps.renderer.sync.leave.show(look);
39742
+ run.signal.addEventListener("abort", () => endLeave(ctx, leaving, "drop"), {
39743
+ once: true,
39744
+ signal: listening.signal
39745
+ });
39746
+ ctx.state.leaving = leaving;
39747
+ return leaving;
39748
+ }
39749
+ /**
39750
+ * Ends the look of a switch once: `"land"` at the swap (the loader goes, the veil fades out over the next scene),
39751
+ * `"drop"` on an abort or a throw (both go at once). A loader projection mounted for the wait is unmounted. A look
39752
+ * ended already, or a switch that showed nothing, is left alone.
39753
+ *
39754
+ * @param ctx - Domain context of the plugin.
39755
+ * @param leaving - The look of the switch.
39756
+ * @param how - `"land"` after the swap, `"drop"` without one.
39757
+ */
39758
+ function endLeave(ctx, leaving, how) {
39759
+ if (leaving === void 0 || leaving.done) return;
39760
+ leaving.done = true;
39761
+ leaving.off();
39762
+ if (ctx.state.leaving === leaving) delete ctx.state.leaving;
39763
+ const leave = ctx.deps.renderer.sync.leave;
39764
+ if (how === "land") leave.land();
39765
+ else leave.drop();
39766
+ unmountLoader(ctx, leaving);
39767
+ }
39768
+ /**
39769
+ * Forgets the look of a running switch when the plugin stops: its abort listener goes and a later end does nothing.
39770
+ * Nothing else is called: a teardown context may not call another plugin; the renderer drops the look at its own stop,
39771
+ * and `world.onStop` the loader projection.
39772
+ *
39773
+ * @param state - The plugin state.
39774
+ */
39775
+ function forgetLeave(state) {
39776
+ const leaving = state.leaving;
39777
+ if (leaving === void 0) return;
39778
+ leaving.done = true;
39779
+ leaving.off();
39780
+ delete state.leaving;
39781
+ }
39782
+ //#endregion
39783
+ //#region src/plugins/scenes/leave-check.ts
39784
+ /** The largest colour, `0xffffff`. */
39785
+ const MAX_COLOR = 16777215;
39786
+ /**
39787
+ * Tells whether a value is a finite number of at least 0: a duration.
39788
+ *
39789
+ * @param value - The value.
39790
+ * @returns True for a finite number `>= 0`.
39791
+ * @example
39792
+ * ```ts
39793
+ * isDuration(-1); // false
39794
+ * ```
39795
+ */
39796
+ function isDuration(value) {
39797
+ return typeof value === "number" && Number.isFinite(value) && value >= 0;
39798
+ }
39799
+ /**
39800
+ * Tells whether a value is an alpha: a number from 0 to 1.
39801
+ *
39802
+ * @param value - The value.
39803
+ * @returns True for a number in `[0, 1]`.
39804
+ * @example
39805
+ * ```ts
39806
+ * isAlpha(1.2); // false
39807
+ * ```
39808
+ */
39809
+ function isAlpha(value) {
39810
+ return typeof value === "number" && value >= 0 && value <= 1;
39811
+ }
39812
+ /**
39813
+ * Tells whether a value is a colour `0xRRGGBB`: a whole number from 0 to `0xffffff`.
39814
+ *
39815
+ * @param value - The value.
39816
+ * @returns True for a colour.
39817
+ * @example
39818
+ * ```ts
39819
+ * isColor(0x0b1020); // true
39820
+ * ```
39821
+ */
39822
+ function isColor$1(value) {
39823
+ return Number.isInteger(value) && typeof value === "number" && value >= 0 && value <= MAX_COLOR;
39824
+ }
39825
+ /**
39826
+ * Tells whether a value is a name: a string that is not empty.
39827
+ *
39828
+ * @param value - The value.
39829
+ * @returns True for a string with at least one character.
39830
+ * @example
39831
+ * ```ts
39832
+ * isName(""); // false
39833
+ * ```
39834
+ */
39835
+ function isName(value) {
39836
+ return typeof value === "string" && value.length > 0;
39837
+ }
39838
+ /**
39839
+ * Tells whether a value is a plain object a field can be read from.
39840
+ *
39841
+ * @param value - The value.
39842
+ * @returns True for an object that is no array and not `null`.
39843
+ * @example
39844
+ * ```ts
39845
+ * isRecord([]); // false
39846
+ * ```
39847
+ */
39848
+ function isRecord$2(value) {
39849
+ return typeof value === "object" && value !== null && !Array.isArray(value);
39850
+ }
39851
+ /** The fields of a veil. */
39852
+ const COVER_RULES = Object.freeze({
39853
+ color: {
39854
+ ok: isColor$1,
39855
+ hint: "a colour from 0x000000 to 0xffffff"
39856
+ },
39857
+ alpha: {
39858
+ ok: isAlpha,
39859
+ hint: "the alpha the veil fades in to, a number from 0 to 1"
39860
+ },
39861
+ inMs: {
39862
+ ok: isDuration,
39863
+ hint: "the milliseconds of the fade-in, 0 or more"
39864
+ },
39865
+ outMs: {
39866
+ ok: isDuration,
39867
+ hint: "the milliseconds of the fade-out, 0 or more"
39868
+ }
39869
+ });
39870
+ /** The fields of a loader. */
39871
+ const LOADER_RULES = Object.freeze({
39872
+ afterMs: {
39873
+ ok: isDuration,
39874
+ hint: "the milliseconds the loader waits for, 0 or more"
39875
+ },
39876
+ texture: {
39877
+ ok: isName,
39878
+ hint: "an asset key such as \"ui.loader.ring\""
39879
+ },
39880
+ projection: {
39881
+ ok: isName,
39882
+ hint: "the name of a registered projection such as \"loading\""
39883
+ }
39884
+ });
39885
+ /**
39886
+ * Builds the error of a field of the wrong shape.
39887
+ *
39888
+ * @param where - Where the part was written.
39889
+ * @param value - What the field holds.
39890
+ * @param hint - What to give instead.
39891
+ * @returns The error, in the engine's two-line format.
39892
+ * @example
39893
+ * ```ts
39894
+ * wrong("scenes.leave.cover.alpha", 2, "a number from 0 to 1").message;
39895
+ * // '[game] scenes.leave.cover.alpha is 2.\n Give a number from 0 to 1.'
39896
+ * ```
39897
+ */
39898
+ function wrong(where, value, hint) {
39899
+ return /* @__PURE__ */ new Error(`[game] ${where} is ${String(value)}.\n Give ${hint}.`);
39900
+ }
39901
+ /**
39902
+ * Checks one part of a `leave`: nothing, `false`, or an object whose every field it knows has the right shape.
39903
+ *
39904
+ * @param where - Where the part was written, `scenes.leave.cover`.
39905
+ * @param part - What the part holds.
39906
+ * @param rules - The fields of the part.
39907
+ * @throws {Error} For a part that is neither `false` nor an object, and for the first field of the wrong shape.
39908
+ */
39909
+ function checkPart(where, part, rules) {
39910
+ if (part === void 0 || part === false) return;
39911
+ if (!isRecord$2(part)) throw wrong(where, part, "an object of its fields, or false");
39912
+ for (const [field, rule] of Object.entries(rules)) {
39913
+ const value = part[field];
39914
+ if (value !== void 0 && !rule.ok(value)) throw wrong(`${where}.${field}`, value, rule.hint);
39915
+ }
39916
+ }
39917
+ /**
39918
+ * Checks a `leave` a game wrote: nothing, `false`, or an object whose `cover` and `loader` are each nothing, `false`
39919
+ * or an object of the right fields: a colour, an alpha from 0 to 1, durations of 0 or more, and names that are not
39920
+ * empty.
39921
+ *
39922
+ * @param where - Where it was written, for the message: `scenes.leave`, or `Scene "board": leave`.
39923
+ * @param leave - What was written.
39924
+ * @throws {Error} For the first part or field of the wrong shape.
39925
+ * @example
39926
+ * ```ts
39927
+ * checkLeave("scenes.leave", { cover: { alpha: 2 } });
39928
+ * // throws '[game] scenes.leave.cover.alpha is 2.\n Give the alpha the veil fades in to, a number from 0 to 1.'
39929
+ * ```
39930
+ */
39931
+ function checkLeave(where, leave) {
39932
+ if (leave === void 0 || leave === false) return;
39933
+ if (!isRecord$2(leave)) throw wrong(where, leave, "{ cover, loader } or false");
39934
+ checkPart(`${where}.cover`, leave.cover, COVER_RULES);
39935
+ checkPart(`${where}.loader`, leave.loader, LOADER_RULES);
39936
+ }
39937
+ //#endregion
39104
39938
  //#region src/plugins/scenes/switch.ts
39105
39939
  /**
39106
39940
  * A promise that resolves when the node was aborted. Already aborted resolves at once.
@@ -39298,11 +40132,31 @@ async function standUp(ctx, scene, named, run) {
39298
40132
  swapIn(ctx, scene, named);
39299
40133
  }
39300
40134
  /**
39301
- * Switches to another scene: it reads what the scene names from the committed state, awaits all
39302
- * of it and only then builds the scene. In live mode it waits between the two for the textures of
39303
- * those bundles to reach the GPU; a fast walk does not, so tests and restores keep their pace. An
39304
- * aborted node, or a plugin stopped during the wait, returns without touching anything. A live
39305
- * switch with a drawing renderer resolves at the commit of the scene built aside, frames later.
40135
+ * Awaits every bundle the scene names and only then builds the scene. In live mode it waits between
40136
+ * the two for the textures of those bundles to reach the GPU; a fast walk does not, so tests and
40137
+ * restores keep their pace. An aborted node, or a plugin stopped during the wait, returns without
40138
+ * touching anything. A live switch with a drawing renderer resolves at the commit of the scene
40139
+ * built aside, frames later.
40140
+ *
40141
+ * @param ctx - Domain context of the plugin.
40142
+ * @param scene - The scene to mount.
40143
+ * @param named - The bundles it names, read from the committed state.
40144
+ * @param run - The runner's mode and signal.
40145
+ * @returns A promise that resolves once the scene stands, or the node was aborted.
40146
+ * @throws {Error} Whatever a load, the mount or the build threw.
40147
+ */
40148
+ async function loadAndStand(ctx, scene, named, run) {
40149
+ await untilAborted(ctx, named, run.signal);
40150
+ if (run.mode === "live" && !run.signal.aborted) await untilWarmed(ctx, named, run.signal);
40151
+ const stopped = ctx.state.teardown === void 0;
40152
+ if (run.signal.aborted || stopped) return;
40153
+ await standUp(ctx, scene, named, run);
40154
+ }
40155
+ /**
40156
+ * Switches to another scene: it reads what the scene names from the committed state, shows the
40157
+ * veil and the loader of the scene being left at once (a cold first visit reacts in the next
40158
+ * frame), then awaits the bundles and builds the scene (`loadAndStand`). The look lands when the
40159
+ * scene stands, and is dropped when the switch ends without it or throws.
39306
40160
  *
39307
40161
  * @param ctx - Domain context of the plugin.
39308
40162
  * @param target - Id of the scene to mount.
@@ -39314,11 +40168,15 @@ async function switchTo(ctx, target, run) {
39314
40168
  const scene = ctx.state.scenes.get(target);
39315
40169
  if (scene === void 0) throw new Error(`[game] Scene "${target}" is not registered.\n List it in the scenes of a feature.`);
39316
40170
  const named = namedBundles(scene, committedState(ctx));
39317
- await untilAborted(ctx, named, run.signal);
39318
- if (run.mode === "live" && !run.signal.aborted) await untilWarmed(ctx, named, run.signal);
39319
- const stopped = ctx.state.teardown === void 0;
39320
- if (run.signal.aborted || stopped) return;
39321
- await standUp(ctx, scene, named, run);
40171
+ let leaving;
40172
+ try {
40173
+ leaving = startLeave(ctx, scene, run);
40174
+ await loadAndStand(ctx, scene, named, run);
40175
+ } catch (error) {
40176
+ endLeave(ctx, leaving, "drop");
40177
+ throw error;
40178
+ }
40179
+ endLeave(ctx, leaving, ctx.state.current === scene.id ? "land" : "drop");
39322
40180
  }
39323
40181
  /**
39324
40182
  * Reads again what the mounted scene names, at a node that keeps it: a bundle it names now and
@@ -39432,15 +40290,17 @@ function readScenes(ctx) {
39432
40290
  }
39433
40291
  }
39434
40292
  /**
39435
- * Refuses a building budget a frame cannot count down, in `onInit`, so `createApp` throws: neither
39436
- * `"auto"` nor a finite number above 0. A game written in plain JS passes anything, so the config
39437
- * is a boundary.
40293
+ * Refuses a config a switch cannot run with, in `onInit`, so `createApp` throws: a building budget
40294
+ * that is neither `"auto"` nor a finite number above 0, and a `leave` of the wrong shape. A game
40295
+ * written in plain JS passes anything, so the config is a boundary.
39438
40296
  *
39439
40297
  * @param config - The resolved config of the plugin.
39440
- * @throws {Error} When `buildMs` is neither `"auto"` nor a finite number above 0.
40298
+ * @throws {Error} When `buildMs` is neither `"auto"` nor a finite number above 0, and for a `leave`
40299
+ * `checkLeave` refuses.
39441
40300
  */
39442
40301
  function checkConfig(config) {
39443
40302
  const value = config.buildMs;
40303
+ checkLeave("scenes.leave", config.leave);
39444
40304
  if (value === "auto") return;
39445
40305
  if (isPositiveFinite(value)) return;
39446
40306
  throw new Error(`[game] scenes.buildMs is ${String(value)}.\n Give "auto" or the milliseconds a frame may spend on the next scene, a finite number above 0 such as 4.`);
@@ -39464,7 +40324,8 @@ function startScenes(ctx) {
39464
40324
  }
39465
40325
  /**
39466
40326
  * Removes the `onEnter` callback in `onStop` and forgets every scene and the bundles the mounted
39467
- * one named. A running build loses its frame callback and its switch returns. Nothing is unmounted
40327
+ * one named. A running build loses its frame callback and its switch returns; the look of a running
40328
+ * switch loses its abort listener (the renderer drops it at its own stop). Nothing is unmounted
39468
40329
  * or discarded: a teardown context may not call another plugin, and `world.onStop` clears every
39469
40330
  * entity and the build table anyway.
39470
40331
  *
@@ -39482,6 +40343,7 @@ function stopScenes(teardown) {
39482
40343
  state.teardown = void 0;
39483
40344
  dropBuild(state.build);
39484
40345
  delete state.build;
40346
+ forgetLeave(state);
39485
40347
  state.scenes.clear();
39486
40348
  state.current = void 0;
39487
40349
  state.pending = void 0;
@@ -39550,6 +40412,9 @@ function createScenesState() {
39550
40412
  *
39551
40413
  * // A game gives the next scene a fixed 3 ms of each frame (two thirds of it by default) while the old one stays on the screen.
39552
40414
  * createApp({ plugins: [...screen, boardFeature], pluginConfigs: { scenes: { buildMs: 3 } } });
40415
+ *
40416
+ * // No veil and no loader over a scene being left, on any switch.
40417
+ * createApp({ plugins: [...screen, boardFeature], pluginConfigs: { scenes: { leave: false } } });
39553
40418
  * ```
39554
40419
  */
39555
40420
  const scenesPlugin = /*#__PURE__*/ createPlugin$1("scenes", {
@@ -39561,7 +40426,18 @@ const scenesPlugin = /*#__PURE__*/ createPlugin$1("scenes", {
39561
40426
  modelPlugin,
39562
40427
  rendererPlugin
39563
40428
  ],
39564
- config: { buildMs: "auto" },
40429
+ config: {
40430
+ buildMs: "auto",
40431
+ leave: {
40432
+ cover: {
40433
+ color: 0,
40434
+ alpha: .45,
40435
+ inMs: 150,
40436
+ outMs: 150
40437
+ },
40438
+ loader: { afterMs: 400 }
40439
+ }
40440
+ },
39565
40441
  events: (register) => register.map({ "scenes:changed": "The mounted scene changed" }),
39566
40442
  createState: createScenesState,
39567
40443
  api: createScenesApi,
@@ -64680,14 +65556,15 @@ function namesOf(id, layers, projections) {
64680
65556
  * layer names come from the keys of `layers` and that `ui`, so a projection whose `layer` or
64681
65557
  * `lift` is not among them does not compile — and throws here for a caller without types.
64682
65558
  * `bundles` names bundles that depend on the committed state: the scene is mounted once those
64683
- * are in too.
65559
+ * are in too. `leave` is what a live switch shows over the scene when it is left, merged over the
65560
+ * config's: `false` shows nothing.
64684
65561
  *
64685
65562
  * @param id - Id of the scene. A node names it with `defineNode({ scene: id })`.
64686
- * @param scene - The bundle, the optional bundles of state, the layers, the projections and the
64687
- * optional music key.
65563
+ * @param scene - The bundle, the optional bundles of state, the layers, the projections, the
65564
+ * optional music key and the optional `leave`.
64688
65565
  * @returns The scene as frozen data, ready for the `scenes` key of a feature.
64689
65566
  * @throws {Error} For an integer-like layer name, for a `layer` or `lift` that is not declared, for two HUD layers,
64690
- * an `above` other than `"overlays"`, and a `ui` declared after the HUD layer.
65567
+ * an `above` other than `"overlays"`, a `ui` declared after the HUD layer, and a `leave` of the wrong shape.
64691
65568
  * @example
64692
65569
  * ```ts
64693
65570
  * // features/board/view/scene.ts of a game.
@@ -64712,18 +65589,28 @@ function namesOf(id, layers, projections) {
64712
65589
  * projections: [wardRoom, wardGlows]
64713
65590
  * });
64714
65591
  * wardScene.bundles?.({ player: {}, session: { episode: "ep02" } }); // ["ep02"]
65592
+ *
65593
+ * // Leaving the board dims it in navy and turns the game's ring texture once the switch took 400 ms.
65594
+ * defineScene("board", {
65595
+ * bundle: "board",
65596
+ * layers: { cells: {} },
65597
+ * projections: [],
65598
+ * leave: { cover: { color: 0x0b1020, alpha: 0.6 }, loader: { texture: "ui.loader.ring" } }
65599
+ * }).leave; // { cover: { color: 0x0b1020, alpha: 0.6 }, loader: { texture: "ui.loader.ring" } }
64715
65600
  * ```
64716
65601
  */
64717
65602
  function defineScene(id, scene) {
64718
65603
  const layers = layersOf(id, scene.layers);
64719
65604
  const projections = scene.projections;
65605
+ checkLeave(`Scene "${id}": leave`, scene.leave);
64720
65606
  return Object.freeze({
64721
65607
  id,
64722
65608
  bundle: scene.bundle,
64723
65609
  music: scene.music,
64724
65610
  layers: Object.freeze(layers),
64725
65611
  projections: Object.freeze(namesOf(id, layers, projections)),
64726
- ...scene.bundles === void 0 ? {} : { bundles: scene.bundles }
65612
+ ...scene.bundles === void 0 ? {} : { bundles: scene.bundles },
65613
+ ...scene.leave === void 0 ? {} : { leave: scene.leave }
64727
65614
  });
64728
65615
  }
64729
65616
  /**
@@ -65156,6 +66043,7 @@ function cubicBezier(x1, y1, x2, y2) {
65156
66043
  * | assets | baseUrl | undefined |
65157
66044
  * | assets | io | undefined, the browser fetch and the renderer textures |
65158
66045
  * | scenes | buildMs | "auto", two thirds of a frame ((1000 / time.rate().fps) * 2 / 3, 11.1 ms at 60 Hz), read at each build start; a number fixes it |
66046
+ * | scenes | leave | { cover: { color: 0x000000, alpha: 0.45, inMs: 150, outMs: 150 }, loader: { afterMs: 400 } }, what a live switch shows over the scene being left: a veil from its start and a loader (the engine's ring, a `texture` or a `projection`) once it took `afterMs`; a scene's `leave` is merged over it field by field, a field left out keeps this default, `false` turns a part or the whole off |
65159
66047
  * | anim | maxTracks | 2000 |
65160
66048
  * | anim | reducedMotion | false |
65161
66049
  * | i18n | locale | "en" |