castle-web-cli 0.4.82 → 0.4.84
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/agent-failures.d.ts +17 -0
- package/dist/agent-failures.js +151 -0
- package/dist/agent.d.ts +27 -0
- package/dist/agent.js +614 -57
- package/dist/ide.js +150 -1
- package/dist/native/loop.js +40 -1
- package/dist/native/openrouter.d.ts +12 -1
- package/dist/native/openrouter.js +45 -2
- package/dist/native/types.d.ts +6 -0
- package/dist/native/types.js +0 -38
- package/dist/openrouter-catalog.d.ts +28 -0
- package/dist/openrouter-catalog.js +299 -0
- package/dist/shell/assets/index-BOgm5T3W.js +144 -0
- package/dist/shell/assets/index-DonnH--m.css +1 -0
- package/dist/shell/index.html +2 -2
- package/dist/shell/operator.png +0 -0
- package/kits/basic-2d/CLAUDE.md +27 -23
- package/kits/basic-2d/behaviors/Collider.jsx +24 -30
- package/kits/basic-2d/behaviors/Layout.jsx +9 -6
- package/kits/basic-2d/behaviors/Sprite.jsx +137 -7
- package/kits/basic-2d/blueprints/cauldron.scene +3 -5
- package/kits/basic-2d/editors/BlueprintLibrary.jsx +11 -11
- package/kits/basic-2d/editors/SceneEditor.jsx +212 -50
- package/kits/basic-2d/editors/SelectionOverlay.jsx +73 -54
- package/kits/basic-2d/editors/inspectorSheet.js +5 -1
- package/kits/basic-2d/engine/ScenePlayer.jsx +98 -7
- package/kits/basic-2d/engine/autoInspector.jsx +26 -7
- package/kits/basic-2d/engine/blueprint.js +35 -8
- package/kits/basic-2d/engine/collider.js +146 -0
- package/kits/basic-2d/engine/scene.js +53 -30
- package/kits/basic-2d/engine/spriteGeometry.js +32 -0
- package/kits/basic-2d/engine/ui.jsx +89 -30
- package/kits/basic-2d/engine/ui.module.css +157 -53
- package/kits/basic-2d/scenes/main.scene +3 -3
- package/package.json +2 -1
- package/dist/shell/assets/index-ByhgiJoP.js +0 -141
- package/dist/shell/assets/index-D6hM_VlW.css +0 -1
|
@@ -7,7 +7,7 @@ import { cx, Icon, styles, useElementSize } from '../engine/ui';
|
|
|
7
7
|
// as a sibling of the `<canvas>` and mirrors the `SceneUI` overlay approach:
|
|
8
8
|
//
|
|
9
9
|
// - A ResizeObserver measures the canvas-sized box so a card-unit (500x700)
|
|
10
|
-
// layer can be `scale()`d onto it -- the chrome (
|
|
10
|
+
// layer can be `scale()`d onto it -- the chrome (solid box, corner dots,
|
|
11
11
|
// center pivot) lives in card units inside that scaled layer and is
|
|
12
12
|
// translated by -camera so it tracks the edit camera exactly like the canvas
|
|
13
13
|
// draw path (`ctx.translate(-camera.x, -camera.y)`).
|
|
@@ -27,6 +27,11 @@ const ACTION_GAP_PX = 22; // px from a box edge to clone/delete buttons
|
|
|
27
27
|
const ROTATE_STEP = 15; // degrees -- rotation snaps to this increment when grid snap is on
|
|
28
28
|
const ROTATE_MAGNET = 7; // degrees -- within this band of the pre-drag angle, snap back to it exactly
|
|
29
29
|
const MIN_LAYOUT_SIZE = 4; // card units; prevents inverted / zero-size Layout boxes
|
|
30
|
+
// Below this on-screen box extent (px) along an axis, the mid-edge scale handle
|
|
31
|
+
// for that axis would collide with the corner handles, so we drop it and keep
|
|
32
|
+
// only the corners. Gated per-axis: n/s depend on the box's px width, e/w on its
|
|
33
|
+
// px height (a wide, short box can keep n/s while dropping e/w, and vice versa).
|
|
34
|
+
const EDGE_HANDLE_MIN_PX = 40;
|
|
30
35
|
// Move/grab handle is intentionally hidden for now (its drag logic + helpers
|
|
31
36
|
// stay wired below so it can be re-enabled later); flip this to render it.
|
|
32
37
|
const SHOW_MOVE_HANDLE = false;
|
|
@@ -61,6 +66,7 @@ export function SelectionOverlay({
|
|
|
61
66
|
editCameraRef,
|
|
62
67
|
sceneData,
|
|
63
68
|
previewSceneData,
|
|
69
|
+
sprites,
|
|
64
70
|
selectedActorIds,
|
|
65
71
|
snap,
|
|
66
72
|
onArrange,
|
|
@@ -107,14 +113,14 @@ export function SelectionOverlay({
|
|
|
107
113
|
if (!canvas || !sceneData || selectedActorIds.length === 0) return null;
|
|
108
114
|
const startPoint = screenToCard(canvas, event.clientX, event.clientY);
|
|
109
115
|
const starts = collectLayoutStarts(previewSceneData, selectedActorIds);
|
|
110
|
-
const state = { recorded: false };
|
|
116
|
+
const state = { recorded: false, lastKey: '0,0' };
|
|
111
117
|
return {
|
|
112
118
|
onMove: (moveEvent) => {
|
|
113
119
|
const point = screenToCard(canvas, moveEvent.clientX, moveEvent.clientY);
|
|
114
120
|
const dx = snapDelta(point.x - startPoint.x, snap);
|
|
115
121
|
const dy = snapDelta(point.y - startPoint.y, snap);
|
|
116
122
|
const next = moveSelected(sceneData, previewSceneData, selectedActorIds, starts, dx, dy);
|
|
117
|
-
applyDragResult(next, sceneData, state, recordSnapshot, applyScene);
|
|
123
|
+
applyDragResult(next, sceneData, `${dx},${dy}`, state, recordSnapshot, applyScene);
|
|
118
124
|
},
|
|
119
125
|
};
|
|
120
126
|
});
|
|
@@ -139,11 +145,11 @@ export function SelectionOverlay({
|
|
|
139
145
|
// pointer-down snapshot. `onMove` closes over the `sceneData` captured at
|
|
140
146
|
// drag-start, where the actor still sits at its original angle, so when
|
|
141
147
|
// the magnet pulls `delta` back to 0 `rotateSelected` returns that
|
|
142
|
-
// unchanged snapshot
|
|
143
|
-
//
|
|
144
|
-
//
|
|
145
|
-
//
|
|
146
|
-
//
|
|
148
|
+
// unchanged snapshot; an identity guard would treat that as a no-op and
|
|
149
|
+
// skip it, stranding the actor on the last grid step -- the reason
|
|
150
|
+
// returning to the original angle felt impossible. Re-applying whenever
|
|
151
|
+
// the delta changes lets delta 0 actually restore the start angle.
|
|
152
|
+
// (Scale/move share this contract via `applyDragResult`'s `key` param.)
|
|
147
153
|
if (delta === state.lastDelta) return;
|
|
148
154
|
state.lastDelta = delta;
|
|
149
155
|
setGroupFrameRotation(normalizeAngle(startFrameRotation + delta));
|
|
@@ -165,7 +171,7 @@ export function SelectionOverlay({
|
|
|
165
171
|
const startTransform = makeBoundsTransform(frame, frame.rotation);
|
|
166
172
|
const startPointer = pointerLocal(canvas, editCameraRef, event, startTransform);
|
|
167
173
|
const starts = collectLayoutStarts(previewSceneData, selectedActorIds);
|
|
168
|
-
const state = { recorded: false };
|
|
174
|
+
const state = { recorded: false, lastKey: '0,0,0' };
|
|
169
175
|
return {
|
|
170
176
|
onMove: (moveEvent) => {
|
|
171
177
|
const point = pointerLocal(canvas, editCameraRef, moveEvent, startTransform);
|
|
@@ -179,7 +185,8 @@ export function SelectionOverlay({
|
|
|
179
185
|
moveEvent.shiftKey
|
|
180
186
|
);
|
|
181
187
|
const next = scaleSelected(sceneData, previewSceneData, selectedActorIds, starts, startTransform, nextBounds);
|
|
182
|
-
|
|
188
|
+
// Include shift (uniform) in the key so toggling it without moving still re-applies.
|
|
189
|
+
applyDragResult(next, sceneData, `${dx},${dy},${moveEvent.shiftKey ? 1 : 0}`, state, recordSnapshot, applyScene);
|
|
183
190
|
},
|
|
184
191
|
};
|
|
185
192
|
});
|
|
@@ -190,7 +197,7 @@ export function SelectionOverlay({
|
|
|
190
197
|
}
|
|
191
198
|
|
|
192
199
|
const geometry = getOverlayGeometry(frame, box, camera);
|
|
193
|
-
const colliderFrames = getSelectedColliderFrames(previewSceneData, selectedActorIds);
|
|
200
|
+
const colliderFrames = getSelectedColliderFrames(previewSceneData, selectedActorIds, sprites);
|
|
194
201
|
|
|
195
202
|
return (
|
|
196
203
|
<div ref={rootRef} className={styles.selOverlayRoot}>
|
|
@@ -221,17 +228,6 @@ export function SelectionOverlay({
|
|
|
221
228
|
}}
|
|
222
229
|
/>
|
|
223
230
|
))}
|
|
224
|
-
{/* Rotate about the actor center, in card units, exactly like the
|
|
225
|
-
canvas. Sits between the scale + camera-translate layers and the
|
|
226
|
-
card-unit chrome. */}
|
|
227
|
-
<div
|
|
228
|
-
className={styles.selChromeRotate}
|
|
229
|
-
style={{
|
|
230
|
-
transformOrigin: `${geometry.centerCardX}px ${geometry.centerCardY}px`,
|
|
231
|
-
transform: `rotate(${geometry.rotation}deg)`,
|
|
232
|
-
}}>
|
|
233
|
-
<SelectionChrome frame={frame} stemLength={geometry.stemLength} />
|
|
234
|
-
</div>
|
|
235
231
|
</div>
|
|
236
232
|
</div>
|
|
237
233
|
|
|
@@ -239,11 +235,21 @@ export function SelectionOverlay({
|
|
|
239
235
|
readable. The rotate handle orbits via its anchor; clone/delete flip
|
|
240
236
|
above/below to stay clear of it. */}
|
|
241
237
|
<div className={styles.selButtonLayer}>
|
|
242
|
-
{/*
|
|
243
|
-
rather than the chrome layer, so their stroke
|
|
244
|
-
and pivot size stay constant with zoom. Their px dimensions
|
|
245
|
-
projected from the box's screen extents, so the box still traces
|
|
246
|
-
actual bounds.
|
|
238
|
+
{/* Solid bounding box, connector stem, and center pivot render here
|
|
239
|
+
(un-scaled px layer) rather than the chrome layer, so their stroke
|
|
240
|
+
width and pivot size stay constant with zoom. Their px dimensions
|
|
241
|
+
are projected from the box's screen extents, so the box still traces
|
|
242
|
+
the actual bounds. The stem is anchored at the box's rotated
|
|
243
|
+
right-center edge and rotates about that point out to the handle. */}
|
|
244
|
+
<div
|
|
245
|
+
className={styles.selStem}
|
|
246
|
+
style={{
|
|
247
|
+
left: geometry.stemStart.x,
|
|
248
|
+
top: geometry.stemStart.y,
|
|
249
|
+
width: geometry.stemLengthPx,
|
|
250
|
+
transform: `translateY(-50%) rotate(${geometry.rotation}deg)`,
|
|
251
|
+
}}
|
|
252
|
+
/>
|
|
247
253
|
<div
|
|
248
254
|
className={styles.selBox}
|
|
249
255
|
style={{
|
|
@@ -332,22 +338,6 @@ export function SelectionOverlay({
|
|
|
332
338
|
);
|
|
333
339
|
}
|
|
334
340
|
|
|
335
|
-
// Chrome in card units: dashed bounding box, scale handles, connector stem, and
|
|
336
|
-
// center pivot dot. The layer itself ignores pointer events; only the handles
|
|
337
|
-
// opt back in so the canvas still receives ordinary selection gestures.
|
|
338
|
-
function SelectionChrome({ frame, stemLength }) {
|
|
339
|
-
const left = frame.x;
|
|
340
|
-
const top = frame.y;
|
|
341
|
-
const width = frame.width;
|
|
342
|
-
const height = frame.height;
|
|
343
|
-
const centerY = top + height / 2;
|
|
344
|
-
if (stemLength <= 0) return null;
|
|
345
|
-
// Connector stem from the right-center edge out to the rotate handle. Lives in
|
|
346
|
-
// the rotated chrome so it swings with the actor. (The box + pivot render in
|
|
347
|
-
// the un-scaled px layer so their stroke/size stay constant with zoom.)
|
|
348
|
-
return <div className={styles.selStem} style={{ left: left + width, top: centerY, width: stemLength }} />;
|
|
349
|
-
}
|
|
350
|
-
|
|
351
341
|
// `sceneData` here is always the MERGED preview scene (see the note on
|
|
352
342
|
// `SelectionOverlay` above) -- every Layout read in this file is.
|
|
353
343
|
function getSelectionFrame(sceneData, actorIds, preferredRotation = null) {
|
|
@@ -381,20 +371,28 @@ function getOverlayGeometry(frame, box, camera) {
|
|
|
381
371
|
const halfW = (frame.width / 2 + HANDLE_OFFSET) * sx;
|
|
382
372
|
const halfH = (frame.height / 2 + HANDLE_OFFSET) * sy;
|
|
383
373
|
const rotateAnchor = getRotateAnchor({ centerX, centerY, halfW, rotation });
|
|
374
|
+
// Stem starts at the box's rotated right-center edge and runs out to the
|
|
375
|
+
// rotate handle. Projected to screen px (like the box + handles) so its
|
|
376
|
+
// thickness stays constant with zoom instead of scaling with the chrome layer.
|
|
377
|
+
const rad = (rotation * Math.PI) / 180;
|
|
378
|
+
const halfWpx = (frame.width / 2) * sx;
|
|
379
|
+
const stemStart = {
|
|
380
|
+
x: centerX + halfWpx * Math.cos(rad),
|
|
381
|
+
y: centerY + halfWpx * Math.sin(rad),
|
|
382
|
+
};
|
|
384
383
|
return {
|
|
385
384
|
sx,
|
|
386
385
|
sy,
|
|
387
386
|
camX,
|
|
388
387
|
camY,
|
|
389
388
|
rotation,
|
|
390
|
-
centerCardX,
|
|
391
|
-
centerCardY,
|
|
392
389
|
centerX,
|
|
393
390
|
centerY,
|
|
394
391
|
halfH,
|
|
395
392
|
rotateAnchor,
|
|
396
393
|
cloneAnchor: getActionAnchor({ centerX, centerY, halfH, rotateAnchor }),
|
|
397
|
-
|
|
394
|
+
stemStart,
|
|
395
|
+
stemLengthPx: Math.hypot(rotateAnchor.x - stemStart.x, rotateAnchor.y - stemStart.y),
|
|
398
396
|
boxWidthPx: frame.width * sx,
|
|
399
397
|
boxHeightPx: frame.height * sy,
|
|
400
398
|
scaleHandles: getScaleHandleAnchors({
|
|
@@ -407,13 +405,23 @@ function getOverlayGeometry(frame, box, camera) {
|
|
|
407
405
|
};
|
|
408
406
|
}
|
|
409
407
|
|
|
410
|
-
// Project the
|
|
411
|
-
//
|
|
408
|
+
// Project the scale handles from the box's rotated corners/edges into screen px
|
|
409
|
+
// so they can render in the un-scaled button layer at a constant size. Corners
|
|
410
|
+
// are always shown; mid-edge handles are dropped once the box is too small along
|
|
411
|
+
// their axis (see EDGE_HANDLE_MIN_PX) so they don't overlap the corners.
|
|
412
412
|
function getScaleHandleAnchors({ centerX, centerY, halfWpx, halfHpx, rotation }) {
|
|
413
413
|
const rad = (rotation * Math.PI) / 180;
|
|
414
414
|
const cosR = Math.cos(rad);
|
|
415
415
|
const sinR = Math.sin(rad);
|
|
416
|
-
|
|
416
|
+
const boxWidthPx = halfWpx * 2;
|
|
417
|
+
const boxHeightPx = halfHpx * 2;
|
|
418
|
+
return SCALE_HANDLES.filter((handle) => {
|
|
419
|
+
if (handle.corner) return true;
|
|
420
|
+
// n/s run along the box's width; e/w run along its height.
|
|
421
|
+
return handle.x === 0
|
|
422
|
+
? boxWidthPx >= EDGE_HANDLE_MIN_PX
|
|
423
|
+
: boxHeightPx >= EDGE_HANDLE_MIN_PX;
|
|
424
|
+
}).map((handle) => {
|
|
417
425
|
const lx = handle.x * halfWpx;
|
|
418
426
|
const ly = handle.y * halfHpx;
|
|
419
427
|
return {
|
|
@@ -447,7 +455,7 @@ function getActionAnchor({ centerX, centerY, halfH, rotateAnchor }) {
|
|
|
447
455
|
};
|
|
448
456
|
}
|
|
449
457
|
|
|
450
|
-
function getSelectedColliderFrames(sceneData, actorIds) {
|
|
458
|
+
function getSelectedColliderFrames(sceneData, actorIds, sprites) {
|
|
451
459
|
if (!sceneData || !actorIds || actorIds.length === 0) return [];
|
|
452
460
|
const wanted = new Set(actorIds);
|
|
453
461
|
return sceneData.actors
|
|
@@ -455,7 +463,7 @@ function getSelectedColliderFrames(sceneData, actorIds) {
|
|
|
455
463
|
.map((actor) => {
|
|
456
464
|
const layout = actor.components.Layout;
|
|
457
465
|
const collider = actor.components.Collider;
|
|
458
|
-
const rect = getColliderRect(actor);
|
|
466
|
+
const rect = getColliderRect(actor, sprites);
|
|
459
467
|
if (!layout || !collider || !rect) return null;
|
|
460
468
|
return {
|
|
461
469
|
actorId: actor.id,
|
|
@@ -598,9 +606,20 @@ function usePointerDragHandle(begin) {
|
|
|
598
606
|
}, []);
|
|
599
607
|
}
|
|
600
608
|
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
609
|
+
// Gate the commit on the gesture's snapped input (`key`), not on
|
|
610
|
+
// `next === sceneData`. During a drag the handlers compute `next` relative to
|
|
611
|
+
// the pointer-down `sceneData`, so returning the handle to its start value
|
|
612
|
+
// makes `next` identity-equal to that snapshot -- an identity guard would treat
|
|
613
|
+
// that as a no-op and skip `applyScene`, stranding the actor on the last grid
|
|
614
|
+
// step (the reason handles couldn't be dragged back to their origin). Keying on
|
|
615
|
+
// the snapped input re-applies whenever it changes, including back to the start
|
|
616
|
+
// key, so the origin is reachable. The identity check now only decides whether
|
|
617
|
+
// to record the undo snapshot, so a pure no-op drag never touches history. This
|
|
618
|
+
// mirrors the rotate handle's delta-keyed commit.
|
|
619
|
+
function applyDragResult(next, sceneData, key, state, recordSnapshot, applyScene) {
|
|
620
|
+
if (key === state.lastKey) return;
|
|
621
|
+
state.lastKey = key;
|
|
622
|
+
if (next !== sceneData && !state.recorded) {
|
|
604
623
|
recordSnapshot();
|
|
605
624
|
state.recorded = true;
|
|
606
625
|
}
|
|
@@ -5,5 +5,9 @@ import { styles, useMobileSheet } from '../engine/ui';
|
|
|
5
5
|
// visibility; the sheet hook owns the resize height and the settle-on-release
|
|
6
6
|
// behavior (drag the grab handle to resize, tap it to toggle peek / expanded).
|
|
7
7
|
export function useInspectorSheet(inspectorOpen) {
|
|
8
|
-
return useMobileSheet({
|
|
8
|
+
return useMobileSheet({
|
|
9
|
+
open: inspectorOpen,
|
|
10
|
+
baseClassName: styles.inspector,
|
|
11
|
+
storageKey: 'pxart-inspector',
|
|
12
|
+
});
|
|
9
13
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import React, { useCallback, useEffect, useRef } from 'react';
|
|
1
|
+
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
2
|
import { configureSceneCanvas, makeScene } from './scene';
|
|
3
3
|
import { SceneUI } from './SceneUI';
|
|
4
4
|
// Engine-level scene player: mount a `SceneRuntime` against a canvas, wire
|
|
@@ -9,6 +9,12 @@ import { SceneUI } from './SceneUI';
|
|
|
9
9
|
export function ScenePlayer({ sceneData, sprites, files, behaviorClasses, onFirstFrame }) {
|
|
10
10
|
const canvasRef = useRef(null);
|
|
11
11
|
const runtimeRef = useRef(null);
|
|
12
|
+
// An error thrown from a behavior's update/draw kills the raf loop (it can't
|
|
13
|
+
// schedule the next frame), which otherwise just black-screens the preview
|
|
14
|
+
// with the throw buried in the console. Capture it here and show a redbox
|
|
15
|
+
// overlay instead. `runId` bumps to remount the loop on Restart.
|
|
16
|
+
const [error, setError] = useState(null);
|
|
17
|
+
const [runId, setRunId] = useState(0);
|
|
12
18
|
const getRuntime = useCallback(() => runtimeRef.current, []);
|
|
13
19
|
useEffect(() => {
|
|
14
20
|
const canvas = canvasRef.current;
|
|
@@ -55,11 +61,17 @@ export function ScenePlayer({ sceneData, sprites, files, behaviorClasses, onFirs
|
|
|
55
61
|
canvas.addEventListener('pointerup', onPointerUp);
|
|
56
62
|
canvas.addEventListener('pointercancel', onPointerUp);
|
|
57
63
|
// Don't steal focus from the embedding shell (e.g. the chat composer) on
|
|
58
|
-
// reload/remount. A real standalone/
|
|
64
|
+
// reload/remount. A real standalone/play, or an iframe that's
|
|
59
65
|
// already focused, still auto-focuses; a click into the game always
|
|
60
66
|
// focuses via onPointerDown above.
|
|
61
67
|
if (document.hasFocus() || window.parent === window) canvas.focus();
|
|
62
|
-
const
|
|
68
|
+
const onLoopError = (err) => {
|
|
69
|
+
// Forward to the console (the serve log picks it up) and surface it.
|
|
70
|
+
// eslint-disable-next-line no-console
|
|
71
|
+
console.error(err);
|
|
72
|
+
setError(err);
|
|
73
|
+
};
|
|
74
|
+
const stopLoop = startPlayerLoop(canvas, ctx, runtime, onFirstFrame, onLoopError);
|
|
63
75
|
return () => {
|
|
64
76
|
stopLoop();
|
|
65
77
|
window.removeEventListener('keydown', onKeyDown);
|
|
@@ -71,6 +83,10 @@ export function ScenePlayer({ sceneData, sprites, files, behaviorClasses, onFirs
|
|
|
71
83
|
runtimeRef.current = null;
|
|
72
84
|
};
|
|
73
85
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
86
|
+
}, [runId]);
|
|
87
|
+
const onRestart = useCallback(() => {
|
|
88
|
+
setError(null);
|
|
89
|
+
setRunId((n) => n + 1);
|
|
74
90
|
}, []);
|
|
75
91
|
return (
|
|
76
92
|
<div style={{ position: 'fixed', inset: 0, background: '#000' }}>
|
|
@@ -80,19 +96,28 @@ export function ScenePlayer({ sceneData, sprites, files, behaviorClasses, onFirs
|
|
|
80
96
|
style={{ width: '100%', height: '100%', display: 'block', outline: 'none' }}
|
|
81
97
|
/>
|
|
82
98
|
<SceneUI getRuntime={getRuntime} />
|
|
99
|
+
{error ? <PlayErrorOverlay error={error} onRestart={onRestart} /> : null}
|
|
83
100
|
</div>
|
|
84
101
|
);
|
|
85
102
|
}
|
|
86
|
-
function startPlayerLoop(canvas, ctx, runtime, onFirstFrame) {
|
|
103
|
+
function startPlayerLoop(canvas, ctx, runtime, onFirstFrame, onError) {
|
|
87
104
|
let raf = 0;
|
|
88
105
|
let previousTime = performance.now();
|
|
89
106
|
let firstFrameSignaled = false;
|
|
90
107
|
const tick = (now) => {
|
|
91
108
|
const dt = Math.min(0.033, (now - previousTime) / 1000);
|
|
92
109
|
previousTime = now;
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
110
|
+
// A throw in a behavior's update/draw must not abort the loop silently:
|
|
111
|
+
// catch it, surface it via onError, and stop scheduling frames (so it
|
|
112
|
+
// doesn't rethrow 60x/second). Restart remounts a fresh loop.
|
|
113
|
+
try {
|
|
114
|
+
runtime.update(dt);
|
|
115
|
+
configureSceneCanvas(canvas, ctx);
|
|
116
|
+
runtime.draw(ctx, { useCamera: true });
|
|
117
|
+
} catch (err) {
|
|
118
|
+
onError?.(err);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
96
121
|
if (!firstFrameSignaled) {
|
|
97
122
|
firstFrameSignaled = true;
|
|
98
123
|
// Wait one frame so the draw composites before reveal, avoiding a blank flash.
|
|
@@ -103,3 +128,69 @@ function startPlayerLoop(canvas, ctx, runtime, onFirstFrame) {
|
|
|
103
128
|
raf = requestAnimationFrame(tick);
|
|
104
129
|
return () => cancelAnimationFrame(raf);
|
|
105
130
|
}
|
|
131
|
+
// Redbox overlay shown when the play loop throws. Covers the canvas with the
|
|
132
|
+
// error name/message and stack so a broken behavior is legible instead of a
|
|
133
|
+
// black screen. Restart remounts the loop; fixing the code and restarting the
|
|
134
|
+
// serve reloads the whole preview fresh.
|
|
135
|
+
function PlayErrorOverlay({ error, onRestart }) {
|
|
136
|
+
const name = error?.name ?? 'Error';
|
|
137
|
+
const message = error?.message ?? String(error);
|
|
138
|
+
const stack = typeof error?.stack === 'string' ? error.stack : '';
|
|
139
|
+
return (
|
|
140
|
+
<div
|
|
141
|
+
style={{
|
|
142
|
+
position: 'absolute',
|
|
143
|
+
inset: 0,
|
|
144
|
+
zIndex: 2147483000,
|
|
145
|
+
display: 'flex',
|
|
146
|
+
flexDirection: 'column',
|
|
147
|
+
gap: 12,
|
|
148
|
+
padding: '20px 22px',
|
|
149
|
+
background: 'rgba(20, 8, 8, 0.94)',
|
|
150
|
+
color: '#ffd9dc',
|
|
151
|
+
font: "13px/1.5 'SFMono-Regular', Consolas, 'Liberation Mono', monospace",
|
|
152
|
+
overflow: 'auto',
|
|
153
|
+
boxSizing: 'border-box',
|
|
154
|
+
}}
|
|
155
|
+
>
|
|
156
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
|
157
|
+
<span style={{ fontSize: 15, fontWeight: 700, color: '#ff6b74' }}>Play error</span>
|
|
158
|
+
<button
|
|
159
|
+
type="button"
|
|
160
|
+
onClick={onRestart}
|
|
161
|
+
style={{
|
|
162
|
+
marginLeft: 'auto',
|
|
163
|
+
padding: '4px 12px',
|
|
164
|
+
border: '1px solid #a02828',
|
|
165
|
+
borderRadius: 4,
|
|
166
|
+
background: '#a02828',
|
|
167
|
+
color: '#fff',
|
|
168
|
+
font: 'inherit',
|
|
169
|
+
cursor: 'pointer',
|
|
170
|
+
}}
|
|
171
|
+
>
|
|
172
|
+
Restart
|
|
173
|
+
</button>
|
|
174
|
+
</div>
|
|
175
|
+
<div style={{ color: '#fff', fontWeight: 600, wordBreak: 'break-word' }}>
|
|
176
|
+
{name}: {message}
|
|
177
|
+
</div>
|
|
178
|
+
{stack ? (
|
|
179
|
+
<pre
|
|
180
|
+
style={{
|
|
181
|
+
margin: 0,
|
|
182
|
+
whiteSpace: 'pre-wrap',
|
|
183
|
+
wordBreak: 'break-word',
|
|
184
|
+
color: '#e79aa0',
|
|
185
|
+
fontSize: 12,
|
|
186
|
+
}}
|
|
187
|
+
>
|
|
188
|
+
{stack}
|
|
189
|
+
</pre>
|
|
190
|
+
) : null}
|
|
191
|
+
<div style={{ color: '#c98a8f', fontSize: 12 }}>
|
|
192
|
+
The preview stopped. Fix the code and restart the serve to reload, or press Restart to retry.
|
|
193
|
+
</div>
|
|
194
|
+
</div>
|
|
195
|
+
);
|
|
196
|
+
}
|
|
@@ -1,6 +1,18 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import { CheckboxField, ColorField, NumberField, Panel, TextField, isHexColor } from './ui';
|
|
3
|
-
|
|
3
|
+
// Build the override-indicator field props (purple tint + "Default: X [Reset]")
|
|
4
|
+
// for a single property from a behavior panel's override context. Returns an
|
|
5
|
+
// empty object when there's no context (blueprint template editing) or the
|
|
6
|
+
// property isn't overridden on this instance, so fields render normally.
|
|
7
|
+
export function overrideProps(override, prop) {
|
|
8
|
+
if (!override || !override.isOverridden(prop)) return {};
|
|
9
|
+
return {
|
|
10
|
+
overridden: true,
|
|
11
|
+
defaultValue: override.baseline(prop),
|
|
12
|
+
onReset: () => override.reset(prop),
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
export function AutoFields({ defaultProps, component, setComponent, only, exclude, override }) {
|
|
4
16
|
const keys = Object.keys(defaultProps).filter((key) => {
|
|
5
17
|
if (only) return only.includes(key);
|
|
6
18
|
if (exclude) return !exclude.includes(key);
|
|
@@ -14,14 +26,15 @@ export function AutoFields({ defaultProps, component, setComponent, only, exclud
|
|
|
14
26
|
const set = (value) => setComponent({ [key]: value });
|
|
15
27
|
const label = humanizeKey(key);
|
|
16
28
|
const sample = fallback ?? current;
|
|
29
|
+
const ov = overrideProps(override, key);
|
|
17
30
|
if (typeof sample === 'number') {
|
|
18
|
-
return <NumberField key={key} label={label} value={current} onChange={set} />;
|
|
31
|
+
return <NumberField key={key} label={label} value={current} onChange={set} {...ov} />;
|
|
19
32
|
}
|
|
20
33
|
if (typeof sample === 'boolean') {
|
|
21
|
-
return <CheckboxField key={key} label={label} checked={current} onChange={set} />;
|
|
34
|
+
return <CheckboxField key={key} label={label} checked={current} onChange={set} {...ov} />;
|
|
22
35
|
}
|
|
23
36
|
if (isHexColor(sample)) {
|
|
24
|
-
return <ColorField key={key} label={label} value={current} onChange={set} />;
|
|
37
|
+
return <ColorField key={key} label={label} value={current} onChange={set} {...ov} />;
|
|
25
38
|
}
|
|
26
39
|
return (
|
|
27
40
|
<TextField
|
|
@@ -29,16 +42,22 @@ export function AutoFields({ defaultProps, component, setComponent, only, exclud
|
|
|
29
42
|
label={label}
|
|
30
43
|
value={current == null ? '' : String(current)}
|
|
31
44
|
onChange={set}
|
|
45
|
+
{...ov}
|
|
32
46
|
/>
|
|
33
47
|
);
|
|
34
48
|
})}
|
|
35
49
|
</>
|
|
36
50
|
);
|
|
37
51
|
}
|
|
38
|
-
export function AutoInspector({ behaviorName, defaultProps, component, setComponent }) {
|
|
52
|
+
export function AutoInspector({ behaviorName, defaultProps, component, setComponent, override }) {
|
|
39
53
|
return (
|
|
40
|
-
<Panel title={humanizeKey(behaviorName)}>
|
|
41
|
-
<AutoFields
|
|
54
|
+
<Panel title={humanizeKey(behaviorName)} overridden={override?.anyOverridden()}>
|
|
55
|
+
<AutoFields
|
|
56
|
+
defaultProps={defaultProps}
|
|
57
|
+
component={component}
|
|
58
|
+
setComponent={setComponent}
|
|
59
|
+
override={override}
|
|
60
|
+
/>
|
|
42
61
|
</Panel>
|
|
43
62
|
);
|
|
44
63
|
}
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// editors (which also mint/fork/migrate/cascade-delete blueprint files).
|
|
9
9
|
import { formatJson } from './files';
|
|
10
10
|
import { DEFAULT_RESOLUTION, TRANSPARENT, serializeCompact } from './pxart';
|
|
11
|
-
import { cardSize,
|
|
11
|
+
import { cardSize, dedupeActorId } from './scene';
|
|
12
12
|
|
|
13
13
|
export const BLUEPRINTS_DIR = 'blueprints';
|
|
14
14
|
export const DRAWINGS_DIR = 'drawings';
|
|
@@ -146,6 +146,16 @@ function slugifyActorId(id) {
|
|
|
146
146
|
.replace(/^-+|-+$/g, '');
|
|
147
147
|
}
|
|
148
148
|
|
|
149
|
+
// Mint a readable, unique actor id from a display name (a blueprint's `name`):
|
|
150
|
+
// slugified and slug-suffix-deduped against `existing` (`cauldron`,
|
|
151
|
+
// `cauldron-2`, ...). This is the editor's placement/creation default, so a
|
|
152
|
+
// freshly placed actor reads as `cauldron` rather than an opaque hex id.
|
|
153
|
+
// `mintActorId` (hex) still backs runtime spawns and the dedup fallback.
|
|
154
|
+
export function mintActorIdFromName(existing, name) {
|
|
155
|
+
const base = slugifyActorId(typeof name === 'string' ? name : '');
|
|
156
|
+
return dedupeActorId(existing, base);
|
|
157
|
+
}
|
|
158
|
+
|
|
149
159
|
// Derive a migration blueprint's { name, path } from the orphan actor's own
|
|
150
160
|
// `id` -- the one meaningful signal available at migration time (vs. the
|
|
151
161
|
// anonymous "Blueprint N" counter), so e.g. an actor `id: "logo"` mints
|
|
@@ -323,9 +333,23 @@ function mintDrawingPath(files) {
|
|
|
323
333
|
return `${DRAWINGS_DIR}/drawing-${max + 1}.pxart`;
|
|
324
334
|
}
|
|
325
335
|
|
|
336
|
+
// The z that draws on top of every current actor: max effective z + 1 (0 for
|
|
337
|
+
// an empty scene). An actor's effective z resolves its own Layout override
|
|
338
|
+
// first, then its blueprint template, then 0 -- so this beats instances that
|
|
339
|
+
// only inherit z from their blueprint.
|
|
340
|
+
function topZ(actors, files) {
|
|
341
|
+
let max = -Infinity;
|
|
342
|
+
for (const actor of actors ?? []) {
|
|
343
|
+
const template = actor.blueprint ? getBlueprintTemplate(files, actor.blueprint) : null;
|
|
344
|
+
const z = actor.components?.Layout?.z ?? template?.components?.Layout?.z ?? 0;
|
|
345
|
+
if (z > max) max = z;
|
|
346
|
+
}
|
|
347
|
+
return max === -Infinity ? 0 : max + 1;
|
|
348
|
+
}
|
|
349
|
+
|
|
326
350
|
export function addActorWithBlueprint(sceneData, files) {
|
|
327
|
-
const width =
|
|
328
|
-
const height =
|
|
351
|
+
const width = 50;
|
|
352
|
+
const height = 50;
|
|
329
353
|
const drawingFile = { path: mintDrawingPath(files), text: blankPxArtText() };
|
|
330
354
|
const components = {
|
|
331
355
|
Layout: { width, height, z: 0 },
|
|
@@ -334,10 +358,12 @@ export function addActorWithBlueprint(sceneData, files) {
|
|
|
334
358
|
const blueprintFile = mintBlueprintFile(files, components);
|
|
335
359
|
const next = structuredClone(sceneData);
|
|
336
360
|
const existingIds = new Set(next.actors.map((actor) => actor.id));
|
|
337
|
-
const newId =
|
|
361
|
+
const newId = mintActorIdFromName(existingIds, blueprintFile.name);
|
|
338
362
|
const x = Math.round((cardSize.width - width) / 2);
|
|
339
363
|
const y = Math.round((cardSize.height - height) / 2);
|
|
340
|
-
|
|
364
|
+
// Guarantee the new actor renders on top of whatever's already there.
|
|
365
|
+
const z = topZ(next.actors, files);
|
|
366
|
+
next.actors.push({ id: newId, blueprint: blueprintFile.path, components: { Layout: { x, y, z } } });
|
|
341
367
|
return { sceneData: next, newId, blueprintFile, drawingFile };
|
|
342
368
|
}
|
|
343
369
|
|
|
@@ -347,8 +373,8 @@ export function addActorWithBlueprint(sceneData, files) {
|
|
|
347
373
|
export function blueprintDropXY(files, blueprintPath, position, snap) {
|
|
348
374
|
const template = getBlueprintTemplate(files, blueprintPath);
|
|
349
375
|
const layout = template?.components?.Layout ?? {};
|
|
350
|
-
const width = layout.width ??
|
|
351
|
-
const height = layout.height ??
|
|
376
|
+
const width = layout.width ?? 50;
|
|
377
|
+
const height = layout.height ?? 50;
|
|
352
378
|
const dropX = position?.x ?? cardSize.width / 2;
|
|
353
379
|
const dropY = position?.y ?? cardSize.height / 2;
|
|
354
380
|
return {
|
|
@@ -364,7 +390,8 @@ export function blueprintDropXY(files, blueprintPath, position, snap) {
|
|
|
364
390
|
export function placeBlueprintInstance(sceneData, files, blueprintPath, position, snap) {
|
|
365
391
|
const next = structuredClone(sceneData);
|
|
366
392
|
const existingIds = new Set(next.actors.map((actor) => actor.id));
|
|
367
|
-
const
|
|
393
|
+
const template = getBlueprintTemplate(files, blueprintPath);
|
|
394
|
+
const newId = mintActorIdFromName(existingIds, template?.name);
|
|
368
395
|
const { x, y } = blueprintDropXY(files, blueprintPath, position, snap);
|
|
369
396
|
next.actors.push({ id: newId, blueprint: blueprintPath, components: { Layout: { x, y } } });
|
|
370
397
|
return { sceneData: next, newId };
|