incanto 0.43.0 → 0.44.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/debug.js CHANGED
@@ -1,6 +1,186 @@
1
1
  import { t as jsonClone } from "./json-BLk7H2Qa.js";
2
2
  import { s as mergeStaticProps } from "./registry-IyWCGe4q.js";
3
3
  import { t as debugSources } from "./debug-draw-BM3DsvtT.js";
4
+ //#region src/debug/capture.ts
5
+ /**
6
+ * A drag, in CSS pixels, turned into a rectangle inside the pixel buffer.
7
+ *
8
+ * `scale` is `devicePixelRatio`: the canvas backing store is that many times
9
+ * the CSS box, and a crop taken in CSS coordinates cuts out the wrong part of
10
+ * the image on every retina display.
11
+ */
12
+ function clampRegion(drag, width, height, scale = 1) {
13
+ const x0 = Math.min(drag.x0, drag.x1) * scale;
14
+ const x1 = Math.max(drag.x0, drag.x1) * scale;
15
+ const y0 = Math.min(drag.y0, drag.y1) * scale;
16
+ const y1 = Math.max(drag.y0, drag.y1) * scale;
17
+ const x = Math.max(0, Math.min(Math.round(x0), width - 1));
18
+ const y = Math.max(0, Math.min(Math.round(y0), height - 1));
19
+ return {
20
+ x,
21
+ y,
22
+ w: Math.max(1, Math.min(Math.round(x1 - x0), width - x)),
23
+ h: Math.max(1, Math.min(Math.round(y1 - y0), height - y))
24
+ };
25
+ }
26
+ /** Cut a rectangle out of an RGBA buffer, keeping row order. */
27
+ function cropPixels(pixels, width, height, region) {
28
+ const { x, y, w, h } = region;
29
+ const out = new Uint8ClampedArray(w * h * 4);
30
+ for (let row = 0; row < h; row++) {
31
+ const src = ((y + row) * width + x) * 4;
32
+ const dst = row * w * 4;
33
+ out.set(pixels.subarray(src, src + w * 4), dst);
34
+ }
35
+ return {
36
+ pixels: out,
37
+ width: w,
38
+ height: h
39
+ };
40
+ }
41
+ /** The scene, pretty-printed for pasting into a conversation. */
42
+ function sceneStateText(scene) {
43
+ if (!scene) return "// no scene loaded";
44
+ const source = scene.source ?? scene;
45
+ return JSON.stringify(source, null, 2);
46
+ }
47
+ /**
48
+ * Put text on the clipboard, telling the truth about whether it worked.
49
+ *
50
+ * `navigator.clipboard` needs a secure context (https, or localhost — which a
51
+ * dev server is) and a user gesture, which a menu click is. Anything else
52
+ * returns false rather than throwing into the overlay.
53
+ */
54
+ async function writeClipboardText(text) {
55
+ const nav = globalThis.navigator;
56
+ if (!nav?.clipboard?.writeText) return false;
57
+ try {
58
+ await nav.clipboard.writeText(text);
59
+ return true;
60
+ } catch {
61
+ return false;
62
+ }
63
+ }
64
+ /**
65
+ * Put an RGBA crop on the clipboard as a PNG.
66
+ *
67
+ * Encoding needs a canvas, so this goes through one — but a THROWAWAY 2D
68
+ * canvas holding the crop, never the game's WebGL canvas, whose drawing buffer
69
+ * is already gone by the time anything outside a render can read it.
70
+ */
71
+ async function writeClipboardImage(doc, image) {
72
+ const g = globalThis;
73
+ const write = g.navigator?.clipboard?.write;
74
+ if (!write || !g.ImageData || !g.ClipboardItem) return false;
75
+ try {
76
+ const canvas = doc.createElement("canvas");
77
+ canvas.width = image.width;
78
+ canvas.height = image.height;
79
+ const ctx = canvas.getContext("2d");
80
+ if (!ctx) return false;
81
+ ctx.putImageData(new g.ImageData(image.pixels, image.width, image.height), 0, 0);
82
+ const blob = await new Promise((resolve) => canvas.toBlob(resolve, "image/png"));
83
+ if (!blob) return false;
84
+ await write.call(g.navigator?.clipboard, [new g.ClipboardItem({ "image/png": blob })]);
85
+ return true;
86
+ } catch {
87
+ return false;
88
+ }
89
+ }
90
+ const HANDLE_IDS = [
91
+ "nw",
92
+ "n",
93
+ "ne",
94
+ "e",
95
+ "se",
96
+ "s",
97
+ "sw",
98
+ "w"
99
+ ];
100
+ /** Where each handle sits on the rectangle, as fractions of its size. */
101
+ const HANDLE_ANCHOR = {
102
+ nw: [0, 0],
103
+ n: [.5, 0],
104
+ ne: [1, 0],
105
+ e: [1, .5],
106
+ se: [1, 1],
107
+ s: [.5, 1],
108
+ sw: [0, 1],
109
+ w: [0, .5]
110
+ };
111
+ /** The cursor is the affordance: it says which way an edge will move. */
112
+ const HANDLE_CURSOR = {
113
+ nw: "nwse-resize",
114
+ se: "nwse-resize",
115
+ ne: "nesw-resize",
116
+ sw: "nesw-resize",
117
+ n: "ns-resize",
118
+ s: "ns-resize",
119
+ e: "ew-resize",
120
+ w: "ew-resize"
121
+ };
122
+ const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
123
+ /** A drag in any direction, as a top-left rectangle. */
124
+ function normalizeDrag(drag) {
125
+ return {
126
+ x: Math.min(drag.x0, drag.x1),
127
+ y: Math.min(drag.y0, drag.y1),
128
+ w: Math.abs(drag.x1 - drag.x0),
129
+ h: Math.abs(drag.y1 - drag.y0)
130
+ };
131
+ }
132
+ /**
133
+ * Drag one handle by `(dx, dy)`. Only the edges that handle owns move — `n`
134
+ * leaves x and width exactly alone, which is the whole reason edge handles
135
+ * exist next to corner ones.
136
+ *
137
+ * Dragging an edge past its opposite FLIPS the rectangle rather than refusing.
138
+ * Refusing leaves the pointer detached from the thing it is holding, and the
139
+ * user has to work out which invisible rule stopped them.
140
+ */
141
+ function resizeRect(rect, id, dx, dy, viewW, viewH) {
142
+ let left = rect.x;
143
+ let top = rect.y;
144
+ let right = rect.x + rect.w;
145
+ let bottom = rect.y + rect.h;
146
+ if (id.includes("w")) left = clamp(left + dx, 0, viewW);
147
+ if (id.includes("e")) right = clamp(right + dx, 0, viewW);
148
+ if (id.includes("n")) top = clamp(top + dy, 0, viewH);
149
+ if (id.includes("s")) bottom = clamp(bottom + dy, 0, viewH);
150
+ return {
151
+ x: Math.min(left, right),
152
+ y: Math.min(top, bottom),
153
+ w: Math.max(1, Math.abs(right - left)),
154
+ h: Math.max(1, Math.abs(bottom - top))
155
+ };
156
+ }
157
+ /**
158
+ * Slide the whole rectangle. It stops at the edge of the view rather than
159
+ * shrinking against it: a move that silently resizes is a move you have to undo.
160
+ */
161
+ function moveRect(rect, dx, dy, viewW, viewH) {
162
+ return {
163
+ ...rect,
164
+ x: clamp(rect.x + dx, 0, Math.max(0, viewW - rect.w)),
165
+ y: clamp(rect.y + dy, 0, Math.max(0, viewH - rect.h))
166
+ };
167
+ }
168
+ /**
169
+ * Where to float a small panel next to the selection: under it by preference,
170
+ * over it when there is no room below, always fully on screen. It must never
171
+ * cover the selection — the whole point is that you can still see what you
172
+ * chose while you decide.
173
+ */
174
+ function popupPosition(rect, popupW, popupH, viewW, viewH, gap = 8) {
175
+ const x = clamp(rect.x + rect.w / 2 - popupW / 2, gap, Math.max(gap, viewW - popupW - gap));
176
+ const below = rect.y + rect.h + gap;
177
+ const above = rect.y - popupH - gap;
178
+ return {
179
+ x,
180
+ y: below + popupH <= viewH - gap ? below : above >= gap ? above : clamp(below, gap, Math.max(gap, viewH - popupH - gap))
181
+ };
182
+ }
183
+ //#endregion
4
184
  //#region src/debug/panel.ts
5
185
  /** Keep a panel rect on screen and above the minimum usable size. */
6
186
  function clampPanelRect(x, y, w, h, viewW, viewH, minW = 180, minH = 120) {
@@ -16,7 +196,7 @@ function clampPanelRect(x, y, w, h, viewW, viewH, minW = 180, minH = 120) {
16
196
  function applyStyle(el, rules) {
17
197
  for (const [key, value] of Object.entries(rules)) el.style[key] = value;
18
198
  }
19
- const PANEL_BG = "rgba(18, 20, 26, 0.92)";
199
+ const PANEL_BG$1 = "rgba(18, 20, 26, 0.92)";
20
200
  const PANEL_BORDER = "1px solid rgba(255,255,255,0.14)";
21
201
  const FONT = "12px ui-monospace, SFMono-Regular, Menlo, monospace";
22
202
  /**
@@ -42,7 +222,7 @@ var FloatingPanel = class {
42
222
  this.el = doc.createElement("div");
43
223
  applyStyle(this.el, {
44
224
  position: "absolute",
45
- background: PANEL_BG,
225
+ background: PANEL_BG$1,
46
226
  border: PANEL_BORDER,
47
227
  borderRadius: "8px",
48
228
  color: "rgba(255,255,255,0.88)",
@@ -152,6 +332,289 @@ var FloatingPanel = class {
152
332
  }
153
333
  };
154
334
  //#endregion
335
+ //#region src/debug/region-select.ts
336
+ /**
337
+ * Choosing a rectangle on screen, and being allowed to change your mind.
338
+ *
339
+ * A drag is a first guess. The rectangle you actually want is a few pixels off,
340
+ * every time, and a tool that makes you re-drag from scratch to fix one edge is
341
+ * a tool people stop reaching for. So the selection stays live after the
342
+ * pointer comes up: eight handles to pull, a middle to grab, a running size, and
343
+ * an explicit Copy — nothing is sent anywhere until you say so.
344
+ *
345
+ * The DOM half of `capture.ts`, whose geometry it drives. Everything decidable
346
+ * without a browser lives there and is tested; what is left here is event
347
+ * plumbing and CSS.
348
+ */
349
+ /** Below this, a drag was a click — not a selection anybody meant to make. */
350
+ const MIN_DRAG = 4;
351
+ const ACCENT = "#6ee7dc";
352
+ const PANEL_BG = "rgba(18,20,26,0.94)";
353
+ const MONO = "12px ui-monospace, Menlo, monospace";
354
+ /** Open the selection layer. Returns the handle that closes it. */
355
+ function openRegionSelect(opts) {
356
+ const { doc, container } = opts;
357
+ const layer = doc.createElement("div");
358
+ applyStyle(layer, {
359
+ position: "absolute",
360
+ inset: "0",
361
+ cursor: "crosshair",
362
+ zIndex: "80",
363
+ pointerEvents: "auto",
364
+ userSelect: "none",
365
+ touchAction: "none"
366
+ });
367
+ const veil = doc.createElement("div");
368
+ applyStyle(veil, {
369
+ position: "absolute",
370
+ inset: "0",
371
+ background: "rgba(10,14,20,0.35)",
372
+ pointerEvents: "none"
373
+ });
374
+ layer.appendChild(veil);
375
+ const box = doc.createElement("div");
376
+ applyStyle(box, {
377
+ position: "absolute",
378
+ outline: `1px solid ${ACCENT}`,
379
+ boxShadow: "0 0 0 9999px rgba(10,14,20,0.35)",
380
+ cursor: "grab",
381
+ display: "none",
382
+ pointerEvents: "auto"
383
+ });
384
+ layer.appendChild(box);
385
+ const size = doc.createElement("div");
386
+ applyStyle(size, {
387
+ position: "absolute",
388
+ padding: "2px 6px",
389
+ borderRadius: "4px",
390
+ background: PANEL_BG,
391
+ color: ACCENT,
392
+ font: MONO,
393
+ pointerEvents: "none",
394
+ display: "none",
395
+ whiteSpace: "nowrap"
396
+ });
397
+ layer.appendChild(size);
398
+ const hint = doc.createElement("div");
399
+ hint.textContent = "drag to select · Esc to cancel";
400
+ applyStyle(hint, {
401
+ position: "absolute",
402
+ top: "10px",
403
+ left: "50%",
404
+ transform: "translateX(-50%)",
405
+ padding: "4px 10px",
406
+ borderRadius: "6px",
407
+ background: PANEL_BG,
408
+ color: "rgba(255,255,255,0.85)",
409
+ font: MONO,
410
+ pointerEvents: "none",
411
+ whiteSpace: "nowrap"
412
+ });
413
+ layer.appendChild(hint);
414
+ let sel = null;
415
+ let mode = "idle";
416
+ let origin = {
417
+ x: 0,
418
+ y: 0
419
+ };
420
+ let startRect = {
421
+ x: 0,
422
+ y: 0,
423
+ w: 0,
424
+ h: 0
425
+ };
426
+ const view = () => ({
427
+ w: container.clientWidth || 1,
428
+ h: container.clientHeight || 1
429
+ });
430
+ for (const id of HANDLE_IDS) {
431
+ const dot = doc.createElement("div");
432
+ const [fx, fy] = HANDLE_ANCHOR[id];
433
+ applyStyle(dot, {
434
+ position: "absolute",
435
+ left: `${fx * 100}%`,
436
+ top: `${fy * 100}%`,
437
+ width: "10px",
438
+ height: "10px",
439
+ marginLeft: "-5px",
440
+ marginTop: "-5px",
441
+ borderRadius: "2px",
442
+ background: ACCENT,
443
+ border: "1px solid rgba(10,14,20,0.75)",
444
+ cursor: HANDLE_CURSOR[id],
445
+ pointerEvents: "auto"
446
+ });
447
+ dot.addEventListener("pointerdown", (event) => {
448
+ begin(event, id);
449
+ });
450
+ box.appendChild(dot);
451
+ }
452
+ const popup = doc.createElement("div");
453
+ applyStyle(popup, {
454
+ position: "absolute",
455
+ display: "none",
456
+ gap: "6px",
457
+ padding: "6px",
458
+ borderRadius: "8px",
459
+ background: PANEL_BG,
460
+ border: "1px solid rgba(255,255,255,0.18)",
461
+ font: MONO,
462
+ pointerEvents: "auto",
463
+ whiteSpace: "nowrap",
464
+ boxShadow: "0 4px 16px rgba(0,0,0,0.45)"
465
+ });
466
+ const button = (label, primary, onClick) => {
467
+ const el = doc.createElement("div");
468
+ el.textContent = label;
469
+ applyStyle(el, {
470
+ padding: "5px 10px",
471
+ borderRadius: "5px",
472
+ cursor: "pointer",
473
+ color: primary ? "#08121a" : "rgba(255,255,255,0.85)",
474
+ background: primary ? ACCENT : "rgba(255,255,255,0.08)",
475
+ userSelect: "none"
476
+ });
477
+ el.addEventListener("pointerdown", (event) => {
478
+ stop(event);
479
+ onClick();
480
+ });
481
+ return el;
482
+ };
483
+ popup.appendChild(button("Copy to clipboard", true, () => confirm()));
484
+ popup.appendChild(button("Cancel", false, () => close()));
485
+ layer.appendChild(popup);
486
+ function stop(event) {
487
+ event.preventDefault?.();
488
+ event.stopPropagation?.();
489
+ }
490
+ function pointAt(event) {
491
+ const e = event;
492
+ const rect = container.getBoundingClientRect?.() ?? {
493
+ left: 0,
494
+ top: 0
495
+ };
496
+ return {
497
+ x: (e.clientX ?? 0) - rect.left,
498
+ y: (e.clientY ?? 0) - rect.top
499
+ };
500
+ }
501
+ function begin(event, next) {
502
+ stop(event);
503
+ origin = pointAt(event);
504
+ mode = next;
505
+ if (next === "create") sel = {
506
+ x: origin.x,
507
+ y: origin.y,
508
+ w: 0,
509
+ h: 0
510
+ };
511
+ else if (sel) startRect = sel;
512
+ applyStyle(popup, { display: "none" });
513
+ applyStyle(box, { cursor: next === "move" ? "grabbing" : "crosshair" });
514
+ const id = event.pointerId;
515
+ if (id !== void 0) layer.setPointerCapture?.(id);
516
+ render();
517
+ }
518
+ function render() {
519
+ if (!sel) {
520
+ applyStyle(box, { display: "none" });
521
+ applyStyle(size, { display: "none" });
522
+ applyStyle(veil, { display: "block" });
523
+ return;
524
+ }
525
+ applyStyle(veil, { display: "none" });
526
+ applyStyle(box, {
527
+ display: "block",
528
+ left: `${sel.x}px`,
529
+ top: `${sel.y}px`,
530
+ width: `${sel.w}px`,
531
+ height: `${sel.h}px`
532
+ });
533
+ const k = opts.scale();
534
+ size.textContent = `${Math.round(sel.w * k)}×${Math.round(sel.h * k)}`;
535
+ const above = sel.y - 24;
536
+ applyStyle(size, {
537
+ display: "block",
538
+ left: `${Math.max(2, sel.x)}px`,
539
+ top: `${above >= 2 ? above : sel.y + 4}px`
540
+ });
541
+ }
542
+ function showPopup() {
543
+ if (!sel || sel.w < MIN_DRAG || sel.h < MIN_DRAG) return;
544
+ applyStyle(popup, { display: "flex" });
545
+ const v = view();
546
+ const w = popup.offsetWidth || 200;
547
+ const h = popup.offsetHeight || 36;
548
+ const p = popupPosition(sel, w, h, v.w, v.h);
549
+ applyStyle(popup, {
550
+ left: `${p.x}px`,
551
+ top: `${p.y}px`
552
+ });
553
+ }
554
+ layer.addEventListener("pointerdown", (event) => {
555
+ begin(event, "create");
556
+ });
557
+ box.addEventListener("pointerdown", (event) => {
558
+ begin(event, "move");
559
+ });
560
+ layer.addEventListener("pointermove", (event) => {
561
+ if (mode === "idle" || !sel) return;
562
+ const p = pointAt(event);
563
+ const v = view();
564
+ if (mode === "create") {
565
+ const r = normalizeDrag({
566
+ x0: origin.x,
567
+ y0: origin.y,
568
+ x1: p.x,
569
+ y1: p.y
570
+ });
571
+ sel = {
572
+ x: Math.max(0, Math.min(r.x, v.w)),
573
+ y: Math.max(0, Math.min(r.y, v.h)),
574
+ w: Math.min(r.w, v.w - Math.max(0, Math.min(r.x, v.w))),
575
+ h: Math.min(r.h, v.h - Math.max(0, Math.min(r.y, v.h)))
576
+ };
577
+ } else if (mode === "move") sel = moveRect(startRect, p.x - origin.x, p.y - origin.y, v.w, v.h);
578
+ else sel = resizeRect(startRect, mode, p.x - origin.x, p.y - origin.y, v.w, v.h);
579
+ render();
580
+ });
581
+ const end = () => {
582
+ if (mode === "idle") return;
583
+ if (mode === "create" && sel && (sel.w < MIN_DRAG || sel.h < MIN_DRAG)) sel = null;
584
+ mode = "idle";
585
+ applyStyle(box, { cursor: "grab" });
586
+ hint.textContent = sel ? "drag the handles or the middle · Enter to copy · Esc to cancel" : "drag to select · Esc to cancel";
587
+ render();
588
+ showPopup();
589
+ };
590
+ layer.addEventListener("pointerup", end);
591
+ layer.addEventListener("pointercancel", end);
592
+ function confirm() {
593
+ const rect = sel;
594
+ close();
595
+ if (rect) opts.onCopy(rect);
596
+ }
597
+ const onKey = (event) => {
598
+ const key = event.key;
599
+ if (key === "Escape") close();
600
+ else if (key === "Enter" && sel) confirm();
601
+ };
602
+ doc.addEventListener?.("keydown", onKey);
603
+ let closed = false;
604
+ function close() {
605
+ if (closed) return;
606
+ closed = true;
607
+ doc.removeEventListener?.("keydown", onKey);
608
+ layer.remove();
609
+ opts.onClose();
610
+ }
611
+ container.appendChild(layer);
612
+ return {
613
+ element: layer,
614
+ close
615
+ };
616
+ }
617
+ //#endregion
155
618
  //#region src/debug/index.ts
156
619
  /**
157
620
  * incanto/debug — the runtime debug overlay: play the game WITH X-ray vision.
@@ -201,7 +664,7 @@ function attachDebugOverlay(engine, opts = {}) {
201
664
  if (!doc) return null;
202
665
  const container = opts.container ?? (typeof document !== "undefined" ? document.body : null);
203
666
  if (!container || typeof container.appendChild !== "function") return null;
204
- return new DebugOverlay(engine, container, doc, opts.statsSource, opts.actions);
667
+ return new DebugOverlay(engine, container, doc, opts.statsSource, opts.actions, opts.frameSource);
205
668
  }
206
669
  const MAX_LOG_ROWS = 300;
207
670
  const CONSOLE_LEVELS = [
@@ -217,6 +680,7 @@ var DebugOverlay = class {
217
680
  doc;
218
681
  statsSource;
219
682
  actions;
683
+ frameSource;
220
684
  panels = /* @__PURE__ */ new Map();
221
685
  cleanups = [];
222
686
  menuButton;
@@ -249,12 +713,13 @@ var DebugOverlay = class {
249
713
  timeEls = null;
250
714
  /** Pointer inside the inspector — it stops refreshing under your hand. */
251
715
  hovering = false;
252
- constructor(engine, container, doc, statsSource, actions = []) {
716
+ constructor(engine, container, doc, statsSource, actions = [], frameSource) {
253
717
  this.engine = engine;
254
718
  this.container = container;
255
719
  this.doc = doc;
256
720
  this.statsSource = statsSource;
257
721
  this.actions = actions;
722
+ this.frameSource = frameSource;
258
723
  this.menuButton = doc.createElement("div");
259
724
  this.menuButton.textContent = "☰ debug";
260
725
  applyStyle(this.menuButton, {
@@ -298,6 +763,7 @@ var DebugOverlay = class {
298
763
  }));
299
764
  }
300
765
  isOpen(id) {
766
+ if (id === "copyScene" || id === "captureRegion") return false;
301
767
  if (id === "colliders") return this.colliderMode !== "off";
302
768
  return id === "stats" ? this.statsChip !== null : this.panels.has(id);
303
769
  }
@@ -393,6 +859,14 @@ var DebugOverlay = class {
393
859
  this.panels.delete(id);
394
860
  }
395
861
  toggle(id) {
862
+ if (id === "copyScene") {
863
+ this.copyScene();
864
+ return;
865
+ }
866
+ if (id === "captureRegion") {
867
+ this.captureRegion();
868
+ return;
869
+ }
396
870
  if (id === "colliders") {
397
871
  this.setColliders({
398
872
  off: "all",
@@ -481,6 +955,8 @@ var DebugOverlay = class {
481
955
  ["inspector", "Inspector"],
482
956
  ["logs", "Logs"],
483
957
  ["time", "Time"],
958
+ ["copyScene", "Copy scene JSON"],
959
+ ["captureRegion", "Capture region"],
484
960
  ["stats", "Stats"],
485
961
  ["colliders", "Colliders"]
486
962
  ]) {
@@ -878,6 +1354,88 @@ var DebugOverlay = class {
878
1354
  this.renderLogs();
879
1355
  }
880
1356
  /**
1357
+ * The scene, on the clipboard, ready to paste into a conversation.
1358
+ *
1359
+ * This is half of "here is what I am looking at" — the half a screenshot
1360
+ * cannot carry. The other half is the region capture below.
1361
+ */
1362
+ async copyScene() {
1363
+ const ok = await writeClipboardText(sceneStateText(this.engine.scene ?? null));
1364
+ this.toast(ok ? "scene JSON copied" : "could not reach the clipboard");
1365
+ return ok;
1366
+ }
1367
+ /**
1368
+ * Drag a rectangle over the game; the selection copies as an image.
1369
+ *
1370
+ * The pixels come from `frameSource` — the renderer's own end-of-frame read —
1371
+ * and never from `canvas.toDataURL()`, which returns black once the frame has
1372
+ * composited unless `preserveDrawingBuffer` is on (it is not, because it costs
1373
+ * bandwidth on every frame).
1374
+ */
1375
+ captureRegion() {
1376
+ if (this.capture) return;
1377
+ this.captureScale = globalThis.devicePixelRatio ?? 1;
1378
+ this.frameSource?.().then((shot) => {
1379
+ this.captureScale = shot.width / Math.max(1, this.container.clientWidth || shot.width);
1380
+ }).catch(() => {});
1381
+ this.capture = openRegionSelect({
1382
+ doc: this.doc,
1383
+ container: this.container,
1384
+ scale: () => this.captureScale,
1385
+ onCopy: (rect) => void this.copyRegion({
1386
+ x0: rect.x,
1387
+ y0: rect.y,
1388
+ x1: rect.x + rect.w,
1389
+ y1: rect.y + rect.h
1390
+ }),
1391
+ onClose: () => {
1392
+ this.capture = null;
1393
+ }
1394
+ });
1395
+ }
1396
+ capture = null;
1397
+ captureScale = 1;
1398
+ /** Crop the last frame to `drag` and put the image on the clipboard. */
1399
+ async copyRegion(drag) {
1400
+ const source = this.frameSource;
1401
+ if (!source) {
1402
+ this.toast("no renderer to capture from");
1403
+ return false;
1404
+ }
1405
+ try {
1406
+ const shot = await source();
1407
+ const scale = shot.width / Math.max(1, this.container.clientWidth || shot.width);
1408
+ const region = clampRegion(drag, shot.width, shot.height, scale);
1409
+ const cut = cropPixels(shot.pixels, shot.width, shot.height, region);
1410
+ const ok = await writeClipboardImage(this.doc, cut);
1411
+ this.toast(ok ? `copied ${cut.width}×${cut.height}` : "could not copy the image");
1412
+ return ok;
1413
+ } catch (error) {
1414
+ this.toast(`capture failed: ${error instanceof Error ? error.message : String(error)}`);
1415
+ return false;
1416
+ }
1417
+ }
1418
+ /** A line that says what happened and gets out of the way. */
1419
+ toast(text) {
1420
+ const el = this.doc.createElement("div");
1421
+ el.textContent = text;
1422
+ applyStyle(el, {
1423
+ position: "absolute",
1424
+ bottom: "16px",
1425
+ left: "50%",
1426
+ transform: "translateX(-50%)",
1427
+ padding: "6px 12px",
1428
+ borderRadius: "6px",
1429
+ background: "rgba(18,20,26,0.92)",
1430
+ color: "rgba(255,255,255,0.9)",
1431
+ font: "12px ui-monospace, Menlo, monospace",
1432
+ zIndex: "90",
1433
+ pointerEvents: "none"
1434
+ });
1435
+ this.container.appendChild(el);
1436
+ setTimeout(() => el.remove(), 1800);
1437
+ }
1438
+ /**
881
1439
  * Set game time, defensively. `timeScale` multiplies every dt in the engine,
882
1440
  * so a NaN from a text field would poison physics, timers and animation in
883
1441
  * one frame, and a negative would run the simulation backwards through code