castle-web-cli 0.4.81 → 0.4.83
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-prompts.d.ts +0 -3
- package/dist/agent-prompts.js +3 -8
- package/dist/agent.d.ts +1 -2
- package/dist/agent.js +327 -157
- package/dist/castle-host/host.js +28 -0
- package/dist/ide.js +150 -1
- package/dist/init.js +1 -1
- package/dist/native/loop.js +15 -29
- package/dist/native/openrouter.d.ts +5 -1
- package/dist/native/openrouter.js +20 -1
- package/dist/native/tools.d.ts +0 -1
- package/dist/native/tools.js +3 -79
- package/dist/native/types.d.ts +4 -1
- package/dist/native/types.js +3 -3
- package/dist/shell/assets/index-BMkQt27u.css +1 -0
- package/dist/shell/assets/index-C9Zhmien.js +142 -0
- package/dist/shell/index.html +2 -2
- package/dist/shell/operator.png +0 -0
- package/kits/basic-2d/CLAUDE.md +27 -22
- 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 +102 -7
- package/kits/basic-2d/engine/autoInspector.jsx +26 -7
- package/kits/basic-2d/engine/blueprint.js +109 -11
- 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-D3unT7do.js +0 -141
- package/dist/shell/assets/index-RZrw5gQ2.css +0 -1
- package/kits/basic-2d/pnpm-workspace.yaml +0 -3
|
@@ -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;
|
|
@@ -54,8 +60,18 @@ export function ScenePlayer({ sceneData, sprites, files, behaviorClasses, onFirs
|
|
|
54
60
|
canvas.addEventListener('pointermove', onPointerMove);
|
|
55
61
|
canvas.addEventListener('pointerup', onPointerUp);
|
|
56
62
|
canvas.addEventListener('pointercancel', onPointerUp);
|
|
57
|
-
|
|
58
|
-
|
|
63
|
+
// Don't steal focus from the embedding shell (e.g. the chat composer) on
|
|
64
|
+
// reload/remount. A real standalone/play, or an iframe that's
|
|
65
|
+
// already focused, still auto-focuses; a click into the game always
|
|
66
|
+
// focuses via onPointerDown above.
|
|
67
|
+
if (document.hasFocus() || window.parent === window) canvas.focus();
|
|
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);
|
|
59
75
|
return () => {
|
|
60
76
|
stopLoop();
|
|
61
77
|
window.removeEventListener('keydown', onKeyDown);
|
|
@@ -67,6 +83,10 @@ export function ScenePlayer({ sceneData, sprites, files, behaviorClasses, onFirs
|
|
|
67
83
|
runtimeRef.current = null;
|
|
68
84
|
};
|
|
69
85
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
86
|
+
}, [runId]);
|
|
87
|
+
const onRestart = useCallback(() => {
|
|
88
|
+
setError(null);
|
|
89
|
+
setRunId((n) => n + 1);
|
|
70
90
|
}, []);
|
|
71
91
|
return (
|
|
72
92
|
<div style={{ position: 'fixed', inset: 0, background: '#000' }}>
|
|
@@ -76,19 +96,28 @@ export function ScenePlayer({ sceneData, sprites, files, behaviorClasses, onFirs
|
|
|
76
96
|
style={{ width: '100%', height: '100%', display: 'block', outline: 'none' }}
|
|
77
97
|
/>
|
|
78
98
|
<SceneUI getRuntime={getRuntime} />
|
|
99
|
+
{error ? <PlayErrorOverlay error={error} onRestart={onRestart} /> : null}
|
|
79
100
|
</div>
|
|
80
101
|
);
|
|
81
102
|
}
|
|
82
|
-
function startPlayerLoop(canvas, ctx, runtime, onFirstFrame) {
|
|
103
|
+
function startPlayerLoop(canvas, ctx, runtime, onFirstFrame, onError) {
|
|
83
104
|
let raf = 0;
|
|
84
105
|
let previousTime = performance.now();
|
|
85
106
|
let firstFrameSignaled = false;
|
|
86
107
|
const tick = (now) => {
|
|
87
108
|
const dt = Math.min(0.033, (now - previousTime) / 1000);
|
|
88
109
|
previousTime = now;
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
+
}
|
|
92
121
|
if (!firstFrameSignaled) {
|
|
93
122
|
firstFrameSignaled = true;
|
|
94
123
|
// Wait one frame so the draw composites before reveal, avoiding a blank flash.
|
|
@@ -99,3 +128,69 @@ function startPlayerLoop(canvas, ctx, runtime, onFirstFrame) {
|
|
|
99
128
|
raf = requestAnimationFrame(tick);
|
|
100
129
|
return () => cancelAnimationFrame(raf);
|
|
101
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';
|
|
@@ -126,6 +126,79 @@ function mintBlueprintPath(existingPaths) {
|
|
|
126
126
|
return `${BLUEPRINTS_DIR}/blueprint-${max + 1}.scene`;
|
|
127
127
|
}
|
|
128
128
|
|
|
129
|
+
// "auto-tetris" -> "Auto Tetris". Splits on non-alphanumeric runs (covers the
|
|
130
|
+
// hyphen/underscore ids actors typically use) and title-cases each word.
|
|
131
|
+
function humanizeActorId(id) {
|
|
132
|
+
return id
|
|
133
|
+
.split(/[^a-zA-Z0-9]+/)
|
|
134
|
+
.filter(Boolean)
|
|
135
|
+
.map((word) => word[0].toUpperCase() + word.slice(1))
|
|
136
|
+
.join(' ');
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// "Auto Tetris" -> "auto-tetris" for the filename slug. Kept independent of
|
|
140
|
+
// the actor id's own formatting (an id could already be any casing/shape).
|
|
141
|
+
function slugifyActorId(id) {
|
|
142
|
+
return id
|
|
143
|
+
.trim()
|
|
144
|
+
.toLowerCase()
|
|
145
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
146
|
+
.replace(/^-+|-+$/g, '');
|
|
147
|
+
}
|
|
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
|
+
|
|
159
|
+
// Derive a migration blueprint's { name, path } from the orphan actor's own
|
|
160
|
+
// `id` -- the one meaningful signal available at migration time (vs. the
|
|
161
|
+
// anonymous "Blueprint N" counter), so e.g. an actor `id: "logo"` mints
|
|
162
|
+
// `blueprints/logo.scene` named "Logo" instead of "Blueprint 1". Returns null
|
|
163
|
+
// when the id doesn't yield a usable name/slug, so the caller falls back to
|
|
164
|
+
// the counter-based mint functions.
|
|
165
|
+
//
|
|
166
|
+
// Collision safety is load-bearing: `knownBlueprints` includes every
|
|
167
|
+
// already-migrated blueprint in this same run (see the caller's loop), so two
|
|
168
|
+
// orphan actors that humanize to the same name, or that collide with a
|
|
169
|
+
// pre-existing blueprint, must never mint the same path or duplicate name.
|
|
170
|
+
// Deterministically disambiguate with a numeric suffix instead.
|
|
171
|
+
function deriveBlueprintName(actorId, knownBlueprints) {
|
|
172
|
+
if (typeof actorId !== 'string') return null;
|
|
173
|
+
const baseSlug = slugifyActorId(actorId);
|
|
174
|
+
const baseName = humanizeActorId(actorId.trim());
|
|
175
|
+
if (!baseSlug || !baseName) return null;
|
|
176
|
+
const existingNames = new Set(knownBlueprints.map((blueprint) => blueprint.name));
|
|
177
|
+
const existingPaths = new Set(knownBlueprints.map((blueprint) => blueprint.path));
|
|
178
|
+
const MAX_ATTEMPTS = 1000;
|
|
179
|
+
for (let suffix = 1; suffix <= MAX_ATTEMPTS; suffix += 1) {
|
|
180
|
+
const name = suffix === 1 ? baseName : `${baseName} ${suffix}`;
|
|
181
|
+
const path = suffix === 1 ? `${BLUEPRINTS_DIR}/${baseSlug}.scene` : `${BLUEPRINTS_DIR}/${baseSlug}-${suffix}.scene`;
|
|
182
|
+
if (!existingNames.has(name) && !existingPaths.has(path)) return { name, path };
|
|
183
|
+
}
|
|
184
|
+
// Pathological: 1000 colliding suffixes. Let the caller fall back to the
|
|
185
|
+
// counter-based mint functions rather than looping forever.
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// { name, path } for a freshly minted migration blueprint: the actor id's
|
|
190
|
+
// derived name/slug when usable and collision-free, else the anonymous
|
|
191
|
+
// counter (see `deriveBlueprintName`). Factored out so the branching here
|
|
192
|
+
// doesn't add to `migrateOrphanActors`'s own complexity.
|
|
193
|
+
function mintMigrationBlueprintName(actorId, knownBlueprints) {
|
|
194
|
+
const derived = deriveBlueprintName(actorId, knownBlueprints);
|
|
195
|
+
if (derived) return derived;
|
|
196
|
+
return {
|
|
197
|
+
name: mintBlueprintName(knownBlueprints.map((blueprint) => blueprint.name)),
|
|
198
|
+
path: mintBlueprintPath(knownBlueprints.map((blueprint) => blueprint.path)),
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
129
202
|
export function formatBlueprintFileText(name, components) {
|
|
130
203
|
return formatJson({ name, actors: [{ components }] });
|
|
131
204
|
}
|
|
@@ -260,9 +333,23 @@ function mintDrawingPath(files) {
|
|
|
260
333
|
return `${DRAWINGS_DIR}/drawing-${max + 1}.pxart`;
|
|
261
334
|
}
|
|
262
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
|
+
|
|
263
350
|
export function addActorWithBlueprint(sceneData, files) {
|
|
264
|
-
const width =
|
|
265
|
-
const height =
|
|
351
|
+
const width = 50;
|
|
352
|
+
const height = 50;
|
|
266
353
|
const drawingFile = { path: mintDrawingPath(files), text: blankPxArtText() };
|
|
267
354
|
const components = {
|
|
268
355
|
Layout: { width, height, z: 0 },
|
|
@@ -271,10 +358,12 @@ export function addActorWithBlueprint(sceneData, files) {
|
|
|
271
358
|
const blueprintFile = mintBlueprintFile(files, components);
|
|
272
359
|
const next = structuredClone(sceneData);
|
|
273
360
|
const existingIds = new Set(next.actors.map((actor) => actor.id));
|
|
274
|
-
const newId =
|
|
361
|
+
const newId = mintActorIdFromName(existingIds, blueprintFile.name);
|
|
275
362
|
const x = Math.round((cardSize.width - width) / 2);
|
|
276
363
|
const y = Math.round((cardSize.height - height) / 2);
|
|
277
|
-
|
|
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 } } });
|
|
278
367
|
return { sceneData: next, newId, blueprintFile, drawingFile };
|
|
279
368
|
}
|
|
280
369
|
|
|
@@ -284,8 +373,8 @@ export function addActorWithBlueprint(sceneData, files) {
|
|
|
284
373
|
export function blueprintDropXY(files, blueprintPath, position, snap) {
|
|
285
374
|
const template = getBlueprintTemplate(files, blueprintPath);
|
|
286
375
|
const layout = template?.components?.Layout ?? {};
|
|
287
|
-
const width = layout.width ??
|
|
288
|
-
const height = layout.height ??
|
|
376
|
+
const width = layout.width ?? 50;
|
|
377
|
+
const height = layout.height ?? 50;
|
|
289
378
|
const dropX = position?.x ?? cardSize.width / 2;
|
|
290
379
|
const dropY = position?.y ?? cardSize.height / 2;
|
|
291
380
|
return {
|
|
@@ -301,7 +390,8 @@ export function blueprintDropXY(files, blueprintPath, position, snap) {
|
|
|
301
390
|
export function placeBlueprintInstance(sceneData, files, blueprintPath, position, snap) {
|
|
302
391
|
const next = structuredClone(sceneData);
|
|
303
392
|
const existingIds = new Set(next.actors.map((actor) => actor.id));
|
|
304
|
-
const
|
|
393
|
+
const template = getBlueprintTemplate(files, blueprintPath);
|
|
394
|
+
const newId = mintActorIdFromName(existingIds, template?.name);
|
|
305
395
|
const { x, y } = blueprintDropXY(files, blueprintPath, position, snap);
|
|
306
396
|
next.actors.push({ id: newId, blueprint: blueprintPath, components: { Layout: { x, y } } });
|
|
307
397
|
return { sceneData: next, newId };
|
|
@@ -408,11 +498,19 @@ export function migrateOrphanActors(files, behaviors, sceneData) {
|
|
|
408
498
|
if (!props) continue;
|
|
409
499
|
const Behavior = findBehavior(behaviors, behaviorName);
|
|
410
500
|
const { inherited, overridden } = splitByInherit(Behavior, props);
|
|
411
|
-
|
|
501
|
+
// A behavior attached with no props at all (e.g. `AutoTetris: {}`) has
|
|
502
|
+
// both `inherited` and `overridden` empty. It must still land somewhere,
|
|
503
|
+
// or the attachment itself -- not just some prop of it -- silently
|
|
504
|
+
// vanishes from the resolved actor. Fall back to the template side (as
|
|
505
|
+
// `{}`) so the instance keeps referencing the behavior exactly once.
|
|
506
|
+
if (Object.keys(inherited).length > 0 || Object.keys(overridden).length === 0) {
|
|
507
|
+
templateComponents[behaviorName] = inherited;
|
|
508
|
+
}
|
|
412
509
|
if (Object.keys(overridden).length > 0) instanceOverrides[behaviorName] = overridden;
|
|
413
510
|
}
|
|
414
|
-
|
|
415
|
-
|
|
511
|
+
// The actor's own id is the one meaningful signal available here (vs. an
|
|
512
|
+
// anonymous "Blueprint N") -- see `mintMigrationBlueprintName`.
|
|
513
|
+
const { name, path } = mintMigrationBlueprintName(actor.id, knownBlueprints);
|
|
416
514
|
const text = formatBlueprintFileText(name, templateComponents);
|
|
417
515
|
newBlueprintFiles.push({ path, text });
|
|
418
516
|
knownBlueprints = [...knownBlueprints, { path, name, components: templateComponents }];
|