incanto 0.43.0 → 0.45.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) {
@@ -13,10 +193,63 @@ function clampPanelRect(x, y, w, h, viewW, viewH, minW = 180, minH = 120) {
13
193
  h: ch
14
194
  };
15
195
  }
196
+ /**
197
+ * Where a dropdown goes relative to the thing that opened it.
198
+ *
199
+ * Left-aligned under the anchor, flipped above it when there is no room below,
200
+ * and never off the side — a menu that opens past the edge of the window is a
201
+ * menu with items nobody can reach. The anchor moves (the ☰ button is
202
+ * draggable), so this cannot be the constant it used to be.
203
+ */
204
+ function anchorDropdown(anchor, menuW, menuH, viewW, viewH, gap = 4) {
205
+ const x = Math.min(Math.max(0, anchor.x), Math.max(0, viewW - menuW));
206
+ const below = anchor.y + anchor.h + gap;
207
+ const above = anchor.y - menuH - gap;
208
+ if (below + menuH <= viewH) return {
209
+ x,
210
+ y: below
211
+ };
212
+ if (above >= 0) return {
213
+ x,
214
+ y: above
215
+ };
216
+ return {
217
+ x,
218
+ y: Math.min(Math.max(0, below), Math.max(0, viewH - menuH))
219
+ };
220
+ }
221
+ /**
222
+ * Make a transient thing dismissible: Esc, or a press anywhere outside it.
223
+ *
224
+ * Returns the disposer. Attaching this is how "you can always get out of it"
225
+ * stops being something each caller has to remember — the overlay had a
226
+ * dismissible capture layer and a dropdown you could only close by choosing
227
+ * something from it.
228
+ */
229
+ function dismissOn(doc, inside, close) {
230
+ const onKey = (event) => {
231
+ if (event.key === "Escape") close();
232
+ };
233
+ const onDown = (event) => {
234
+ const target = event.target;
235
+ if (!target) return;
236
+ for (const el of inside) {
237
+ if (!el) continue;
238
+ if (el === target || el.contains?.(target)) return;
239
+ }
240
+ close();
241
+ };
242
+ doc.addEventListener?.("keydown", onKey);
243
+ doc.addEventListener?.("pointerdown", onDown);
244
+ return () => {
245
+ doc.removeEventListener?.("keydown", onKey);
246
+ doc.removeEventListener?.("pointerdown", onDown);
247
+ };
248
+ }
16
249
  function applyStyle(el, rules) {
17
250
  for (const [key, value] of Object.entries(rules)) el.style[key] = value;
18
251
  }
19
- const PANEL_BG = "rgba(18, 20, 26, 0.92)";
252
+ const PANEL_BG$1 = "rgba(18, 20, 26, 0.92)";
20
253
  const PANEL_BORDER = "1px solid rgba(255,255,255,0.14)";
21
254
  const FONT = "12px ui-monospace, SFMono-Regular, Menlo, monospace";
22
255
  /**
@@ -42,7 +275,7 @@ var FloatingPanel = class {
42
275
  this.el = doc.createElement("div");
43
276
  applyStyle(this.el, {
44
277
  position: "absolute",
45
- background: PANEL_BG,
278
+ background: PANEL_BG$1,
46
279
  border: PANEL_BORDER,
47
280
  borderRadius: "8px",
48
281
  color: "rgba(255,255,255,0.88)",
@@ -152,6 +385,289 @@ var FloatingPanel = class {
152
385
  }
153
386
  };
154
387
  //#endregion
388
+ //#region src/debug/region-select.ts
389
+ /**
390
+ * Choosing a rectangle on screen, and being allowed to change your mind.
391
+ *
392
+ * A drag is a first guess. The rectangle you actually want is a few pixels off,
393
+ * every time, and a tool that makes you re-drag from scratch to fix one edge is
394
+ * a tool people stop reaching for. So the selection stays live after the
395
+ * pointer comes up: eight handles to pull, a middle to grab, a running size, and
396
+ * an explicit Copy — nothing is sent anywhere until you say so.
397
+ *
398
+ * The DOM half of `capture.ts`, whose geometry it drives. Everything decidable
399
+ * without a browser lives there and is tested; what is left here is event
400
+ * plumbing and CSS.
401
+ */
402
+ /** Below this, a drag was a click — not a selection anybody meant to make. */
403
+ const MIN_DRAG = 4;
404
+ const ACCENT = "#6ee7dc";
405
+ const PANEL_BG = "rgba(18,20,26,0.94)";
406
+ const MONO = "12px ui-monospace, Menlo, monospace";
407
+ /** Open the selection layer. Returns the handle that closes it. */
408
+ function openRegionSelect(opts) {
409
+ const { doc, container } = opts;
410
+ const layer = doc.createElement("div");
411
+ applyStyle(layer, {
412
+ position: "absolute",
413
+ inset: "0",
414
+ cursor: "crosshair",
415
+ zIndex: "80",
416
+ pointerEvents: "auto",
417
+ userSelect: "none",
418
+ touchAction: "none"
419
+ });
420
+ const veil = doc.createElement("div");
421
+ applyStyle(veil, {
422
+ position: "absolute",
423
+ inset: "0",
424
+ background: "rgba(10,14,20,0.35)",
425
+ pointerEvents: "none"
426
+ });
427
+ layer.appendChild(veil);
428
+ const box = doc.createElement("div");
429
+ applyStyle(box, {
430
+ position: "absolute",
431
+ outline: `1px solid ${ACCENT}`,
432
+ boxShadow: "0 0 0 9999px rgba(10,14,20,0.35)",
433
+ cursor: "grab",
434
+ display: "none",
435
+ pointerEvents: "auto"
436
+ });
437
+ layer.appendChild(box);
438
+ const size = doc.createElement("div");
439
+ applyStyle(size, {
440
+ position: "absolute",
441
+ padding: "2px 6px",
442
+ borderRadius: "4px",
443
+ background: PANEL_BG,
444
+ color: ACCENT,
445
+ font: MONO,
446
+ pointerEvents: "none",
447
+ display: "none",
448
+ whiteSpace: "nowrap"
449
+ });
450
+ layer.appendChild(size);
451
+ const hint = doc.createElement("div");
452
+ hint.textContent = "drag to select · Esc to cancel";
453
+ applyStyle(hint, {
454
+ position: "absolute",
455
+ top: "10px",
456
+ left: "50%",
457
+ transform: "translateX(-50%)",
458
+ padding: "4px 10px",
459
+ borderRadius: "6px",
460
+ background: PANEL_BG,
461
+ color: "rgba(255,255,255,0.85)",
462
+ font: MONO,
463
+ pointerEvents: "none",
464
+ whiteSpace: "nowrap"
465
+ });
466
+ layer.appendChild(hint);
467
+ let sel = null;
468
+ let mode = "idle";
469
+ let origin = {
470
+ x: 0,
471
+ y: 0
472
+ };
473
+ let startRect = {
474
+ x: 0,
475
+ y: 0,
476
+ w: 0,
477
+ h: 0
478
+ };
479
+ const view = () => ({
480
+ w: container.clientWidth || 1,
481
+ h: container.clientHeight || 1
482
+ });
483
+ for (const id of HANDLE_IDS) {
484
+ const dot = doc.createElement("div");
485
+ const [fx, fy] = HANDLE_ANCHOR[id];
486
+ applyStyle(dot, {
487
+ position: "absolute",
488
+ left: `${fx * 100}%`,
489
+ top: `${fy * 100}%`,
490
+ width: "10px",
491
+ height: "10px",
492
+ marginLeft: "-5px",
493
+ marginTop: "-5px",
494
+ borderRadius: "2px",
495
+ background: ACCENT,
496
+ border: "1px solid rgba(10,14,20,0.75)",
497
+ cursor: HANDLE_CURSOR[id],
498
+ pointerEvents: "auto"
499
+ });
500
+ dot.addEventListener("pointerdown", (event) => {
501
+ begin(event, id);
502
+ });
503
+ box.appendChild(dot);
504
+ }
505
+ const popup = doc.createElement("div");
506
+ applyStyle(popup, {
507
+ position: "absolute",
508
+ display: "none",
509
+ gap: "6px",
510
+ padding: "6px",
511
+ borderRadius: "8px",
512
+ background: PANEL_BG,
513
+ border: "1px solid rgba(255,255,255,0.18)",
514
+ font: MONO,
515
+ pointerEvents: "auto",
516
+ whiteSpace: "nowrap",
517
+ boxShadow: "0 4px 16px rgba(0,0,0,0.45)"
518
+ });
519
+ const button = (label, primary, onClick) => {
520
+ const el = doc.createElement("div");
521
+ el.textContent = label;
522
+ applyStyle(el, {
523
+ padding: "5px 10px",
524
+ borderRadius: "5px",
525
+ cursor: "pointer",
526
+ color: primary ? "#08121a" : "rgba(255,255,255,0.85)",
527
+ background: primary ? ACCENT : "rgba(255,255,255,0.08)",
528
+ userSelect: "none"
529
+ });
530
+ el.addEventListener("pointerdown", (event) => {
531
+ stop(event);
532
+ onClick();
533
+ });
534
+ return el;
535
+ };
536
+ popup.appendChild(button("Copy to clipboard", true, () => confirm()));
537
+ popup.appendChild(button("Cancel", false, () => close()));
538
+ layer.appendChild(popup);
539
+ function stop(event) {
540
+ event.preventDefault?.();
541
+ event.stopPropagation?.();
542
+ }
543
+ function pointAt(event) {
544
+ const e = event;
545
+ const rect = container.getBoundingClientRect?.() ?? {
546
+ left: 0,
547
+ top: 0
548
+ };
549
+ return {
550
+ x: (e.clientX ?? 0) - rect.left,
551
+ y: (e.clientY ?? 0) - rect.top
552
+ };
553
+ }
554
+ function begin(event, next) {
555
+ stop(event);
556
+ origin = pointAt(event);
557
+ mode = next;
558
+ if (next === "create") sel = {
559
+ x: origin.x,
560
+ y: origin.y,
561
+ w: 0,
562
+ h: 0
563
+ };
564
+ else if (sel) startRect = sel;
565
+ applyStyle(popup, { display: "none" });
566
+ applyStyle(box, { cursor: next === "move" ? "grabbing" : "crosshair" });
567
+ const id = event.pointerId;
568
+ if (id !== void 0) layer.setPointerCapture?.(id);
569
+ render();
570
+ }
571
+ function render() {
572
+ if (!sel) {
573
+ applyStyle(box, { display: "none" });
574
+ applyStyle(size, { display: "none" });
575
+ applyStyle(veil, { display: "block" });
576
+ return;
577
+ }
578
+ applyStyle(veil, { display: "none" });
579
+ applyStyle(box, {
580
+ display: "block",
581
+ left: `${sel.x}px`,
582
+ top: `${sel.y}px`,
583
+ width: `${sel.w}px`,
584
+ height: `${sel.h}px`
585
+ });
586
+ const k = opts.scale();
587
+ size.textContent = `${Math.round(sel.w * k)}×${Math.round(sel.h * k)}`;
588
+ const above = sel.y - 24;
589
+ applyStyle(size, {
590
+ display: "block",
591
+ left: `${Math.max(2, sel.x)}px`,
592
+ top: `${above >= 2 ? above : sel.y + 4}px`
593
+ });
594
+ }
595
+ function showPopup() {
596
+ if (!sel || sel.w < MIN_DRAG || sel.h < MIN_DRAG) return;
597
+ applyStyle(popup, { display: "flex" });
598
+ const v = view();
599
+ const w = popup.offsetWidth || 200;
600
+ const h = popup.offsetHeight || 36;
601
+ const p = popupPosition(sel, w, h, v.w, v.h);
602
+ applyStyle(popup, {
603
+ left: `${p.x}px`,
604
+ top: `${p.y}px`
605
+ });
606
+ }
607
+ layer.addEventListener("pointerdown", (event) => {
608
+ begin(event, "create");
609
+ });
610
+ box.addEventListener("pointerdown", (event) => {
611
+ begin(event, "move");
612
+ });
613
+ layer.addEventListener("pointermove", (event) => {
614
+ if (mode === "idle" || !sel) return;
615
+ const p = pointAt(event);
616
+ const v = view();
617
+ if (mode === "create") {
618
+ const r = normalizeDrag({
619
+ x0: origin.x,
620
+ y0: origin.y,
621
+ x1: p.x,
622
+ y1: p.y
623
+ });
624
+ sel = {
625
+ x: Math.max(0, Math.min(r.x, v.w)),
626
+ y: Math.max(0, Math.min(r.y, v.h)),
627
+ w: Math.min(r.w, v.w - Math.max(0, Math.min(r.x, v.w))),
628
+ h: Math.min(r.h, v.h - Math.max(0, Math.min(r.y, v.h)))
629
+ };
630
+ } else if (mode === "move") sel = moveRect(startRect, p.x - origin.x, p.y - origin.y, v.w, v.h);
631
+ else sel = resizeRect(startRect, mode, p.x - origin.x, p.y - origin.y, v.w, v.h);
632
+ render();
633
+ });
634
+ const end = () => {
635
+ if (mode === "idle") return;
636
+ if (mode === "create" && sel && (sel.w < MIN_DRAG || sel.h < MIN_DRAG)) sel = null;
637
+ mode = "idle";
638
+ applyStyle(box, { cursor: "grab" });
639
+ hint.textContent = sel ? "drag the handles or the middle · Enter to copy · Esc to cancel" : "drag to select · Esc to cancel";
640
+ render();
641
+ showPopup();
642
+ };
643
+ layer.addEventListener("pointerup", end);
644
+ layer.addEventListener("pointercancel", end);
645
+ function confirm() {
646
+ const rect = sel;
647
+ close();
648
+ if (rect) opts.onCopy(rect);
649
+ }
650
+ const onKey = (event) => {
651
+ const key = event.key;
652
+ if (key === "Escape") close();
653
+ else if (key === "Enter" && sel) confirm();
654
+ };
655
+ doc.addEventListener?.("keydown", onKey);
656
+ let closed = false;
657
+ function close() {
658
+ if (closed) return;
659
+ closed = true;
660
+ doc.removeEventListener?.("keydown", onKey);
661
+ layer.remove();
662
+ opts.onClose();
663
+ }
664
+ container.appendChild(layer);
665
+ return {
666
+ element: layer,
667
+ close
668
+ };
669
+ }
670
+ //#endregion
155
671
  //#region src/debug/index.ts
156
672
  /**
157
673
  * incanto/debug — the runtime debug overlay: play the game WITH X-ray vision.
@@ -195,13 +711,15 @@ function isPlainObject(value) {
195
711
  /** The slider's ceiling. Beyond this the number box is the tool — a 0..8 slider
196
712
  * makes the 0.1-to-1 range, where slow-motion debugging actually lives, unusable. */
197
713
  const TIME_SLIDER_MAX = 2;
714
+ /** Under this much movement the press was a click, not a grab. */
715
+ const MENU_DRAG_THRESHOLD = 4;
198
716
  /** Attach the overlay; null when no DOM is available (headless). */
199
717
  function attachDebugOverlay(engine, opts = {}) {
200
718
  const doc = opts.doc ?? (typeof document !== "undefined" ? document : null);
201
719
  if (!doc) return null;
202
720
  const container = opts.container ?? (typeof document !== "undefined" ? document.body : null);
203
721
  if (!container || typeof container.appendChild !== "function") return null;
204
- return new DebugOverlay(engine, container, doc, opts.statsSource, opts.actions);
722
+ return new DebugOverlay(engine, container, doc, opts.statsSource, opts.actions, opts.frameSource);
205
723
  }
206
724
  const MAX_LOG_ROWS = 300;
207
725
  const CONSOLE_LEVELS = [
@@ -217,10 +735,17 @@ var DebugOverlay = class {
217
735
  doc;
218
736
  statsSource;
219
737
  actions;
738
+ frameSource;
220
739
  panels = /* @__PURE__ */ new Map();
221
740
  cleanups = [];
222
741
  menuButton;
223
742
  dropdown = null;
743
+ dismissDropdown = null;
744
+ /** Where the ☰ button sits, in container CSS px. Drag moves it; see bindMenuDrag. */
745
+ menuPos = {
746
+ x: 8,
747
+ y: 8
748
+ };
224
749
  selected = null;
225
750
  /** Explorer subtrees the user collapsed (nodes keep identity across frames). */
226
751
  collapsedFlags = /* @__PURE__ */ new Map();
@@ -249,12 +774,13 @@ var DebugOverlay = class {
249
774
  timeEls = null;
250
775
  /** Pointer inside the inspector — it stops refreshing under your hand. */
251
776
  hovering = false;
252
- constructor(engine, container, doc, statsSource, actions = []) {
777
+ constructor(engine, container, doc, statsSource, actions = [], frameSource) {
253
778
  this.engine = engine;
254
779
  this.container = container;
255
780
  this.doc = doc;
256
781
  this.statsSource = statsSource;
257
782
  this.actions = actions;
783
+ this.frameSource = frameSource;
258
784
  this.menuButton = doc.createElement("div");
259
785
  this.menuButton.textContent = "☰ debug";
260
786
  applyStyle(this.menuButton, {
@@ -272,7 +798,7 @@ var DebugOverlay = class {
272
798
  zIndex: "50",
273
799
  pointerEvents: "auto"
274
800
  });
275
- this.menuButton.addEventListener("click", () => this.toggleDropdown());
801
+ this.bindMenuDrag();
276
802
  if (!container.style.position) container.style.position = "relative";
277
803
  container.appendChild(this.menuButton);
278
804
  this.cleanups.push(engine.log.added.connect((entry) => {
@@ -298,6 +824,7 @@ var DebugOverlay = class {
298
824
  }));
299
825
  }
300
826
  isOpen(id) {
827
+ if (id === "copyScene" || id === "captureRegion") return false;
301
828
  if (id === "colliders") return this.colliderMode !== "off";
302
829
  return id === "stats" ? this.statsChip !== null : this.panels.has(id);
303
830
  }
@@ -393,6 +920,14 @@ var DebugOverlay = class {
393
920
  this.panels.delete(id);
394
921
  }
395
922
  toggle(id) {
923
+ if (id === "copyScene") {
924
+ this.copyScene();
925
+ return;
926
+ }
927
+ if (id === "captureRegion") {
928
+ this.captureRegion();
929
+ return;
930
+ }
396
931
  if (id === "colliders") {
397
932
  this.setColliders({
398
933
  off: "all",
@@ -447,8 +982,7 @@ var DebugOverlay = class {
447
982
  for (const cleanup of this.cleanups) cleanup();
448
983
  for (const id of [...this.panels.keys()]) this.close(id);
449
984
  this.close("stats");
450
- this.dropdown?.remove();
451
- this.dropdown = null;
985
+ this.closeDropdown();
452
986
  this.menuButton.remove();
453
987
  }
454
988
  /** `✓ Colliders · selected` — check for on, suffix for the collider mode. */
@@ -456,17 +990,79 @@ var DebugOverlay = class {
456
990
  const suffix = id === "colliders" && this.colliderMode !== "off" ? ` · ${this.colliderMode}` : "";
457
991
  return `${this.isOpen(id) ? "✓ " : ""}${label}${suffix}`;
458
992
  }
993
+ /**
994
+ * The ☰ button is draggable, because top-left is exactly where a game puts
995
+ * its own menu and a debug chip pinned on top of it is a debug chip you
996
+ * cannot use.
997
+ *
998
+ * Drag and click share one pointer, so they are told apart by distance: under
999
+ * the threshold the press was a click and the menu opens, over it the press
1000
+ * was a grab and the menu must NOT open under the finger that just let go.
1001
+ */
1002
+ bindMenuDrag() {
1003
+ let start = null;
1004
+ let dragged = false;
1005
+ this.menuButton.addEventListener("pointerdown", (event) => {
1006
+ const e = event;
1007
+ start = {
1008
+ x: e.clientX ?? 0,
1009
+ y: e.clientY ?? 0,
1010
+ bx: this.menuPos.x,
1011
+ by: this.menuPos.y
1012
+ };
1013
+ dragged = false;
1014
+ if (e.pointerId !== void 0) this.menuButton.setPointerCapture?.(e.pointerId);
1015
+ });
1016
+ this.menuButton.addEventListener("pointermove", (event) => {
1017
+ if (!start) return;
1018
+ const e = event;
1019
+ const dx = (e.clientX ?? 0) - start.x;
1020
+ const dy = (e.clientY ?? 0) - start.y;
1021
+ if (!dragged && Math.hypot(dx, dy) < MENU_DRAG_THRESHOLD) return;
1022
+ if (!dragged) {
1023
+ dragged = true;
1024
+ this.closeDropdown();
1025
+ applyStyle(this.menuButton, { cursor: "grabbing" });
1026
+ }
1027
+ this.moveMenuButton(start.bx + dx, start.by + dy);
1028
+ });
1029
+ const release = () => {
1030
+ if (!start) return;
1031
+ const wasDrag = dragged;
1032
+ start = null;
1033
+ dragged = false;
1034
+ applyStyle(this.menuButton, { cursor: "pointer" });
1035
+ if (!wasDrag) this.toggleDropdown();
1036
+ };
1037
+ this.menuButton.addEventListener("pointerup", release);
1038
+ this.menuButton.addEventListener("pointercancel", release);
1039
+ }
1040
+ moveMenuButton(x, y) {
1041
+ const el = this.menuButton;
1042
+ const box = clampPanelRect(x, y, el.offsetWidth ?? 0, el.offsetHeight ?? 0, this.container.clientWidth || 0, this.container.clientHeight || 0, 0, 0);
1043
+ this.menuPos = {
1044
+ x: box.x,
1045
+ y: box.y
1046
+ };
1047
+ applyStyle(this.menuButton, {
1048
+ left: `${box.x}px`,
1049
+ top: `${box.y}px`
1050
+ });
1051
+ }
1052
+ closeDropdown() {
1053
+ this.dismissDropdown?.();
1054
+ this.dismissDropdown = null;
1055
+ this.dropdown?.remove();
1056
+ this.dropdown = null;
1057
+ }
459
1058
  toggleDropdown() {
460
1059
  if (this.dropdown) {
461
- this.dropdown.remove();
462
- this.dropdown = null;
1060
+ this.closeDropdown();
463
1061
  return;
464
1062
  }
465
1063
  const menu = this.doc.createElement("div");
466
1064
  applyStyle(menu, {
467
1065
  position: "absolute",
468
- top: "34px",
469
- left: "8px",
470
1066
  background: "rgba(18,20,26,0.95)",
471
1067
  border: "1px solid rgba(255,255,255,0.18)",
472
1068
  borderRadius: "6px",
@@ -481,6 +1077,8 @@ var DebugOverlay = class {
481
1077
  ["inspector", "Inspector"],
482
1078
  ["logs", "Logs"],
483
1079
  ["time", "Time"],
1080
+ ["copyScene", "Copy scene JSON"],
1081
+ ["captureRegion", "Capture region"],
484
1082
  ["stats", "Stats"],
485
1083
  ["colliders", "Colliders"]
486
1084
  ]) {
@@ -497,8 +1095,7 @@ var DebugOverlay = class {
497
1095
  item.textContent = this.menuLabel(id, label);
498
1096
  return;
499
1097
  }
500
- this.dropdown?.remove();
501
- this.dropdown = null;
1098
+ this.closeDropdown();
502
1099
  });
503
1100
  menu.appendChild(item);
504
1101
  }
@@ -513,14 +1110,26 @@ var DebugOverlay = class {
513
1110
  color: "rgba(158,232,220,0.95)"
514
1111
  });
515
1112
  item.addEventListener("click", () => {
516
- this.dropdown?.remove();
517
- this.dropdown = null;
1113
+ this.closeDropdown();
518
1114
  action.run();
519
1115
  });
520
1116
  menu.appendChild(item);
521
1117
  }
522
1118
  this.container.appendChild(menu);
523
1119
  this.dropdown = menu;
1120
+ const el = menu;
1121
+ const btn = this.menuButton;
1122
+ const at = anchorDropdown({
1123
+ x: this.menuPos.x,
1124
+ y: this.menuPos.y,
1125
+ w: btn.offsetWidth ?? 0,
1126
+ h: btn.offsetHeight ?? 26
1127
+ }, el.offsetWidth ?? 0, el.offsetHeight ?? 0, this.container.clientWidth || 0, this.container.clientHeight || 0);
1128
+ applyStyle(menu, {
1129
+ left: `${at.x}px`,
1130
+ top: `${at.y}px`
1131
+ });
1132
+ this.dismissDropdown = dismissOn(this.doc, [menu, this.menuButton], () => this.closeDropdown());
524
1133
  }
525
1134
  openStatsChip() {
526
1135
  if (this.statsChip) return;
@@ -878,6 +1487,88 @@ var DebugOverlay = class {
878
1487
  this.renderLogs();
879
1488
  }
880
1489
  /**
1490
+ * The scene, on the clipboard, ready to paste into a conversation.
1491
+ *
1492
+ * This is half of "here is what I am looking at" — the half a screenshot
1493
+ * cannot carry. The other half is the region capture below.
1494
+ */
1495
+ async copyScene() {
1496
+ const ok = await writeClipboardText(sceneStateText(this.engine.scene ?? null));
1497
+ this.toast(ok ? "scene JSON copied" : "could not reach the clipboard");
1498
+ return ok;
1499
+ }
1500
+ /**
1501
+ * Drag a rectangle over the game; the selection copies as an image.
1502
+ *
1503
+ * The pixels come from `frameSource` — the renderer's own end-of-frame read —
1504
+ * and never from `canvas.toDataURL()`, which returns black once the frame has
1505
+ * composited unless `preserveDrawingBuffer` is on (it is not, because it costs
1506
+ * bandwidth on every frame).
1507
+ */
1508
+ captureRegion() {
1509
+ if (this.capture) return;
1510
+ this.captureScale = globalThis.devicePixelRatio ?? 1;
1511
+ this.frameSource?.().then((shot) => {
1512
+ this.captureScale = shot.width / Math.max(1, this.container.clientWidth || shot.width);
1513
+ }).catch(() => {});
1514
+ this.capture = openRegionSelect({
1515
+ doc: this.doc,
1516
+ container: this.container,
1517
+ scale: () => this.captureScale,
1518
+ onCopy: (rect) => void this.copyRegion({
1519
+ x0: rect.x,
1520
+ y0: rect.y,
1521
+ x1: rect.x + rect.w,
1522
+ y1: rect.y + rect.h
1523
+ }),
1524
+ onClose: () => {
1525
+ this.capture = null;
1526
+ }
1527
+ });
1528
+ }
1529
+ capture = null;
1530
+ captureScale = 1;
1531
+ /** Crop the last frame to `drag` and put the image on the clipboard. */
1532
+ async copyRegion(drag) {
1533
+ const source = this.frameSource;
1534
+ if (!source) {
1535
+ this.toast("no renderer to capture from");
1536
+ return false;
1537
+ }
1538
+ try {
1539
+ const shot = await source();
1540
+ const scale = shot.width / Math.max(1, this.container.clientWidth || shot.width);
1541
+ const region = clampRegion(drag, shot.width, shot.height, scale);
1542
+ const cut = cropPixels(shot.pixels, shot.width, shot.height, region);
1543
+ const ok = await writeClipboardImage(this.doc, cut);
1544
+ this.toast(ok ? `copied ${cut.width}×${cut.height}` : "could not copy the image");
1545
+ return ok;
1546
+ } catch (error) {
1547
+ this.toast(`capture failed: ${error instanceof Error ? error.message : String(error)}`);
1548
+ return false;
1549
+ }
1550
+ }
1551
+ /** A line that says what happened and gets out of the way. */
1552
+ toast(text) {
1553
+ const el = this.doc.createElement("div");
1554
+ el.textContent = text;
1555
+ applyStyle(el, {
1556
+ position: "absolute",
1557
+ bottom: "16px",
1558
+ left: "50%",
1559
+ transform: "translateX(-50%)",
1560
+ padding: "6px 12px",
1561
+ borderRadius: "6px",
1562
+ background: "rgba(18,20,26,0.92)",
1563
+ color: "rgba(255,255,255,0.9)",
1564
+ font: "12px ui-monospace, Menlo, monospace",
1565
+ zIndex: "90",
1566
+ pointerEvents: "none"
1567
+ });
1568
+ this.container.appendChild(el);
1569
+ setTimeout(() => el.remove(), 1800);
1570
+ }
1571
+ /**
881
1572
  * Set game time, defensively. `timeScale` multiplies every dt in the engine,
882
1573
  * so a NaN from a text field would poison physics, timers and animation in
883
1574
  * one frame, and a negative would run the simulation backwards through code