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/bin/incanto-frame.mjs +55 -11
- package/dist/3d.d.ts +14 -40
- package/dist/3d.js +5 -4
- package/dist/{create-game-B3vBWgVD.js → create-game-CCFUOWfb.js} +14 -79
- package/dist/debug.d.ts +59 -3
- package/dist/debug.js +706 -15
- package/dist/{environment-presets-Ds5kXLoF.js → environment-presets-2GgTlPt1.js} +173 -36
- package/dist/frame-report-Ct8XgsmV.d.ts +80 -0
- package/dist/frame-report-Lr3VO24R.js +197 -0
- package/dist/index.js +1 -1
- package/dist/{physics-3d-CLPFv99o.js → physics-3d-nLI_8bUR.js} +1 -1
- package/dist/react.js +1 -1
- package/dist/{src-CwYxzZKl.js → src-CV_uN7j4.js} +1 -1
- package/dist/{test-BMgiiD5i.js → test-R8JCuDlv.js} +3 -3
- package/dist/test.js +1 -1
- package/dist/vite.d.ts +15 -1
- package/dist/vite.js +49 -6
- package/editor/assets/{agent8-Cl3qFuBB.js → agent8-Cw3Qoi5e.js} +1 -1
- package/editor/assets/debug-Mac205vz.js +3 -0
- package/editor/assets/{index-Dk2ZlO68.js → index-BGYeGNEh.js} +49 -49
- package/editor/index.html +1 -1
- package/package.json +1 -1
- package/skills/incanto-verifying-your-game.md +60 -4
- package/templates-app/beacon-isle-3d/package.json +1 -1
- package/templates-app/tps-3d/package.json +1 -1
- package/templates-app/village-quest-3d/package.json +1 -1
- package/editor/assets/debug-u3yLCciO.js +0 -3
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|