castle-web-cli 0.4.78 → 0.4.79
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 +4 -1
- package/dist/agent-prompts.js +28 -7
- package/dist/agent.d.ts +7 -2
- package/dist/agent.js +655 -51
- package/dist/native/loop.d.ts +2 -0
- package/dist/native/loop.js +698 -0
- package/dist/native/openrouter.d.ts +55 -0
- package/dist/native/openrouter.js +354 -0
- package/dist/native/playtest-browser.d.ts +34 -0
- package/dist/native/playtest-browser.js +354 -0
- package/dist/native/playtest-executor.d.ts +3 -0
- package/dist/native/playtest-executor.js +156 -0
- package/dist/native/playtest.d.ts +131 -0
- package/dist/native/playtest.js +314 -0
- package/dist/native/tools.d.ts +38 -0
- package/dist/native/tools.js +630 -0
- package/dist/native/types.d.ts +40 -0
- package/dist/native/types.js +41 -0
- package/dist/serve.js +12 -0
- package/dist/shell/assets/{index-yGdKhgfZ.js → index-CNT3KxJb.js} +37 -37
- package/dist/shell/assets/{index-WE24qX3d.css → index-RZrw5gQ2.css} +1 -1
- package/dist/shell/index.html +2 -2
- package/kits/basic-2d/CLAUDE.md +29 -3
- package/kits/basic-2d/behaviors/Layout.jsx +10 -0
- package/kits/basic-2d/behaviors/Sprite.jsx +1 -1
- package/kits/basic-2d/blueprints/cauldron.scene +22 -0
- package/kits/basic-2d/castle.json +5 -7
- package/kits/basic-2d/docs/pxart-format.md +4 -3
- package/kits/basic-2d/drawings/cauldron.pxart +113 -0
- package/kits/basic-2d/editors/BlueprintLibrary.jsx +247 -0
- package/kits/basic-2d/editors/PlayOnly.jsx +1 -0
- package/kits/basic-2d/editors/SceneEditor.jsx +399 -411
- package/kits/basic-2d/editors/SelectionOverlay.jsx +125 -63
- package/kits/basic-2d/editors/SingleEditor.jsx +11 -2
- package/kits/basic-2d/editors/editorHistory.js +8 -2
- package/kits/basic-2d/editors/inspectorSheet.js +5 -19
- package/kits/basic-2d/engine/ScenePlayer.jsx +2 -2
- package/kits/basic-2d/engine/blueprint.js +423 -0
- package/kits/basic-2d/engine/files.js +1 -1
- package/kits/basic-2d/engine/scene.js +29 -29
- package/kits/basic-2d/engine/ui.jsx +160 -21
- package/kits/basic-2d/engine/ui.module.css +155 -13
- package/kits/basic-2d/pnpm-workspace.yaml +3 -0
- package/kits/basic-2d/scenes/main.scene +3 -13
- package/package.json +2 -1
- package/kits/basic-2d/drawings/pig.pxart +0 -26
|
@@ -48,10 +48,19 @@ const SCALE_HANDLES = [
|
|
|
48
48
|
{ id: 'w', x: -1, y: 0, cursor: 'ew-resize' },
|
|
49
49
|
];
|
|
50
50
|
|
|
51
|
+
// `sceneData` (raw, sparse) is the clone target for every write here --
|
|
52
|
+
// writes only ever add/replace the exact prop keys a gesture changed (x/y,
|
|
53
|
+
// rotation, width/height), preserving whatever sparse overrides already
|
|
54
|
+
// existed. `previewSceneData` (blueprint template merged with overrides) is
|
|
55
|
+
// the read side for every geometry computation (bounds, colliders, drag-start
|
|
56
|
+
// snapshots) -- an instance that inherits width/height from its blueprint has
|
|
57
|
+
// no `Layout.width` at all in `sceneData`, so reading raw here would produce
|
|
58
|
+
// NaN boxes. See engine/blueprint.js for the merge this mirrors.
|
|
51
59
|
export function SelectionOverlay({
|
|
52
60
|
canvasRef,
|
|
53
61
|
editCameraRef,
|
|
54
62
|
sceneData,
|
|
63
|
+
previewSceneData,
|
|
55
64
|
selectedActorIds,
|
|
56
65
|
snap,
|
|
57
66
|
onArrange,
|
|
@@ -91,20 +100,20 @@ export function SelectionOverlay({
|
|
|
91
100
|
setArrangeOpen(false);
|
|
92
101
|
}, [selectionKey]);
|
|
93
102
|
|
|
94
|
-
const frame = getSelectionFrame(
|
|
103
|
+
const frame = getSelectionFrame(previewSceneData, selectedActorIds, groupFrameRotation);
|
|
95
104
|
|
|
96
105
|
const onMoveDown = usePointerDragHandle((event) => {
|
|
97
106
|
const canvas = canvasRef.current;
|
|
98
107
|
if (!canvas || !sceneData || selectedActorIds.length === 0) return null;
|
|
99
108
|
const startPoint = screenToCard(canvas, event.clientX, event.clientY);
|
|
100
|
-
const starts = collectLayoutStarts(
|
|
109
|
+
const starts = collectLayoutStarts(previewSceneData, selectedActorIds);
|
|
101
110
|
const state = { recorded: false };
|
|
102
111
|
return {
|
|
103
112
|
onMove: (moveEvent) => {
|
|
104
113
|
const point = screenToCard(canvas, moveEvent.clientX, moveEvent.clientY);
|
|
105
114
|
const dx = snapDelta(point.x - startPoint.x, snap);
|
|
106
115
|
const dy = snapDelta(point.y - startPoint.y, snap);
|
|
107
|
-
const next = moveSelected(sceneData, selectedActorIds, starts, dx, dy);
|
|
116
|
+
const next = moveSelected(sceneData, previewSceneData, selectedActorIds, starts, dx, dy);
|
|
108
117
|
applyDragResult(next, sceneData, state, recordSnapshot, applyScene);
|
|
109
118
|
},
|
|
110
119
|
};
|
|
@@ -118,7 +127,7 @@ export function SelectionOverlay({
|
|
|
118
127
|
let lastAngle = startAngle;
|
|
119
128
|
let totalDelta = 0;
|
|
120
129
|
const startFrameRotation = frame.rotation;
|
|
121
|
-
const starts = collectLayoutStarts(
|
|
130
|
+
const starts = collectLayoutStarts(previewSceneData, selectedActorIds);
|
|
122
131
|
const state = { recorded: false, lastDelta: 0 };
|
|
123
132
|
return {
|
|
124
133
|
onMove: (moveEvent) => {
|
|
@@ -138,7 +147,7 @@ export function SelectionOverlay({
|
|
|
138
147
|
if (delta === state.lastDelta) return;
|
|
139
148
|
state.lastDelta = delta;
|
|
140
149
|
setGroupFrameRotation(normalizeAngle(startFrameRotation + delta));
|
|
141
|
-
const next = rotateSelected(sceneData, selectedActorIds, starts, center, delta);
|
|
150
|
+
const next = rotateSelected(sceneData, previewSceneData, selectedActorIds, starts, center, delta);
|
|
142
151
|
if (!state.recorded) {
|
|
143
152
|
recordSnapshot();
|
|
144
153
|
state.recorded = true;
|
|
@@ -155,7 +164,7 @@ export function SelectionOverlay({
|
|
|
155
164
|
if (!handle || !canvas || !sceneData || !frame || selectedActorIds.length === 0) return null;
|
|
156
165
|
const startTransform = makeBoundsTransform(frame, frame.rotation);
|
|
157
166
|
const startPointer = pointerLocal(canvas, editCameraRef, event, startTransform);
|
|
158
|
-
const starts = collectLayoutStarts(
|
|
167
|
+
const starts = collectLayoutStarts(previewSceneData, selectedActorIds);
|
|
159
168
|
const state = { recorded: false };
|
|
160
169
|
return {
|
|
161
170
|
onMove: (moveEvent) => {
|
|
@@ -169,7 +178,7 @@ export function SelectionOverlay({
|
|
|
169
178
|
dy,
|
|
170
179
|
moveEvent.shiftKey
|
|
171
180
|
);
|
|
172
|
-
const next = scaleSelected(sceneData, selectedActorIds, starts, startTransform, nextBounds);
|
|
181
|
+
const next = scaleSelected(sceneData, previewSceneData, selectedActorIds, starts, startTransform, nextBounds);
|
|
173
182
|
applyDragResult(next, sceneData, state, recordSnapshot, applyScene);
|
|
174
183
|
},
|
|
175
184
|
};
|
|
@@ -181,7 +190,7 @@ export function SelectionOverlay({
|
|
|
181
190
|
}
|
|
182
191
|
|
|
183
192
|
const geometry = getOverlayGeometry(frame, box, camera);
|
|
184
|
-
const colliderFrames = getSelectedColliderFrames(
|
|
193
|
+
const colliderFrames = getSelectedColliderFrames(previewSceneData, selectedActorIds);
|
|
185
194
|
|
|
186
195
|
return (
|
|
187
196
|
<div ref={rootRef} className={styles.selOverlayRoot}>
|
|
@@ -221,7 +230,7 @@ export function SelectionOverlay({
|
|
|
221
230
|
transformOrigin: `${geometry.centerCardX}px ${geometry.centerCardY}px`,
|
|
222
231
|
transform: `rotate(${geometry.rotation}deg)`,
|
|
223
232
|
}}>
|
|
224
|
-
<SelectionChrome frame={frame} stemLength={geometry.stemLength}
|
|
233
|
+
<SelectionChrome frame={frame} stemLength={geometry.stemLength} />
|
|
225
234
|
</div>
|
|
226
235
|
</div>
|
|
227
236
|
</div>
|
|
@@ -230,12 +239,56 @@ export function SelectionOverlay({
|
|
|
230
239
|
readable. The rotate handle orbits via its anchor; clone/delete flip
|
|
231
240
|
above/below to stay clear of it. */}
|
|
232
241
|
<div className={styles.selButtonLayer}>
|
|
242
|
+
{/* Dashed bounding box + center pivot render here (un-scaled px layer)
|
|
243
|
+
rather than the chrome layer, so their stroke width, dash pattern,
|
|
244
|
+
and pivot size stay constant with zoom. Their px dimensions are
|
|
245
|
+
projected from the box's screen extents, so the box still traces the
|
|
246
|
+
actual bounds. */}
|
|
247
|
+
<div
|
|
248
|
+
className={styles.selBox}
|
|
249
|
+
style={{
|
|
250
|
+
left: geometry.centerX,
|
|
251
|
+
top: geometry.centerY,
|
|
252
|
+
width: geometry.boxWidthPx,
|
|
253
|
+
height: geometry.boxHeightPx,
|
|
254
|
+
transform: `translate(-50%, -50%) rotate(${geometry.rotation}deg)`,
|
|
255
|
+
}}
|
|
256
|
+
/>
|
|
257
|
+
<div className={styles.selPivot} style={{ left: geometry.centerX, top: geometry.centerY }} />
|
|
258
|
+
{/* Scale handles live here (un-scaled px layer), not in the chrome layer:
|
|
259
|
+
projecting them to screen px keeps them crisp and a constant size at
|
|
260
|
+
any zoom, instead of being rasterized small then stretched by the
|
|
261
|
+
layer's `scale()`. Each is rotated so edge handles hug the box side. */}
|
|
262
|
+
{geometry.scaleHandles.map((handle) => (
|
|
263
|
+
<div
|
|
264
|
+
key={handle.id}
|
|
265
|
+
role="button"
|
|
266
|
+
aria-label={`Scale ${handle.id}`}
|
|
267
|
+
title={`Scale ${handle.id}`}
|
|
268
|
+
className={cx(
|
|
269
|
+
styles.selScaleHandle,
|
|
270
|
+
handle.corner ? styles.selScaleCorner : styles.selScaleEdge
|
|
271
|
+
)}
|
|
272
|
+
data-handle={handle.id}
|
|
273
|
+
style={{
|
|
274
|
+
left: handle.x,
|
|
275
|
+
top: handle.y,
|
|
276
|
+
cursor: handle.cursor,
|
|
277
|
+
transform: `translate(-50%, -50%) rotate(${geometry.rotation}deg)`,
|
|
278
|
+
}}
|
|
279
|
+
onPointerDown={(event) => {
|
|
280
|
+
event.stopPropagation();
|
|
281
|
+
onScaleDown(event);
|
|
282
|
+
}}
|
|
283
|
+
/>
|
|
284
|
+
))}
|
|
233
285
|
<Floating x={geometry.cloneAnchor.x} y={geometry.cloneAnchor.y}>
|
|
234
286
|
<div className={styles.selBtnGroup}>
|
|
235
287
|
<div className={styles.selArrangeWrap}>
|
|
236
288
|
<OverlayButton
|
|
237
289
|
label="Arrange"
|
|
238
290
|
icon="layer-group"
|
|
291
|
+
disabled={!onArrange}
|
|
239
292
|
onActivate={() => setArrangeOpen((open) => !open)}
|
|
240
293
|
/>
|
|
241
294
|
{arrangeOpen ? (
|
|
@@ -261,8 +314,8 @@ export function SelectionOverlay({
|
|
|
261
314
|
</div>
|
|
262
315
|
) : null}
|
|
263
316
|
</div>
|
|
264
|
-
<OverlayButton label="Clone" icon="clone" onActivate={onClone} />
|
|
265
|
-
<OverlayButton label="Delete" icon="trash" onActivate={onDelete} />
|
|
317
|
+
<OverlayButton label="Clone" icon="clone" disabled={!onClone} onActivate={onClone} />
|
|
318
|
+
<OverlayButton label="Delete" icon="trash" disabled={!onDelete} onActivate={onDelete} />
|
|
266
319
|
</div>
|
|
267
320
|
</Floating>
|
|
268
321
|
<Floating x={geometry.rotateAnchor.x} y={geometry.rotateAnchor.y}>
|
|
@@ -282,49 +335,21 @@ export function SelectionOverlay({
|
|
|
282
335
|
// Chrome in card units: dashed bounding box, scale handles, connector stem, and
|
|
283
336
|
// center pivot dot. The layer itself ignores pointer events; only the handles
|
|
284
337
|
// opt back in so the canvas still receives ordinary selection gestures.
|
|
285
|
-
function SelectionChrome({ frame, stemLength
|
|
338
|
+
function SelectionChrome({ frame, stemLength }) {
|
|
286
339
|
const left = frame.x;
|
|
287
340
|
const top = frame.y;
|
|
288
341
|
const width = frame.width;
|
|
289
342
|
const height = frame.height;
|
|
290
|
-
const centerX = left + width / 2;
|
|
291
343
|
const centerY = top + height / 2;
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
}
|
|
297
|
-
return (
|
|
298
|
-
<>
|
|
299
|
-
{/* Connector stem from the right-center edge out to the rotate handle.
|
|
300
|
-
Lives in the rotated chrome, so it swings with the actor. */}
|
|
301
|
-
{stemLength > 0 ? (
|
|
302
|
-
<div className={styles.selStem} style={{ left: left + width, top: centerY, width: stemLength }} />
|
|
303
|
-
) : null}
|
|
304
|
-
<div className={styles.selBox} style={{ left, top, width, height }} />
|
|
305
|
-
{SCALE_HANDLES.map((handle) => (
|
|
306
|
-
<div
|
|
307
|
-
key={handle.id}
|
|
308
|
-
role="button"
|
|
309
|
-
aria-label={`Scale ${handle.id}`}
|
|
310
|
-
title={`Scale ${handle.id}`}
|
|
311
|
-
className={cx(
|
|
312
|
-
styles.selScaleHandle,
|
|
313
|
-
handle.corner ? styles.selScaleCorner : styles.selScaleEdge
|
|
314
|
-
)}
|
|
315
|
-
data-handle={handle.id}
|
|
316
|
-
style={handlePosition(handle)}
|
|
317
|
-
onPointerDown={(event) => {
|
|
318
|
-
event.stopPropagation();
|
|
319
|
-
onScaleDown(event);
|
|
320
|
-
}}
|
|
321
|
-
/>
|
|
322
|
-
))}
|
|
323
|
-
<div className={styles.selPivot} style={{ left: centerX, top: centerY }} />
|
|
324
|
-
</>
|
|
325
|
-
);
|
|
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 }} />;
|
|
326
349
|
}
|
|
327
350
|
|
|
351
|
+
// `sceneData` here is always the MERGED preview scene (see the note on
|
|
352
|
+
// `SelectionOverlay` above) -- every Layout read in this file is.
|
|
328
353
|
function getSelectionFrame(sceneData, actorIds, preferredRotation = null) {
|
|
329
354
|
if (!sceneData || !actorIds || actorIds.length === 0) return null;
|
|
330
355
|
const wanted = new Set(actorIds);
|
|
@@ -370,9 +395,35 @@ function getOverlayGeometry(frame, box, camera) {
|
|
|
370
395
|
rotateAnchor,
|
|
371
396
|
cloneAnchor: getActionAnchor({ centerX, centerY, halfH, rotateAnchor }),
|
|
372
397
|
stemLength: HANDLE_OFFSET + GAP_PX / sx,
|
|
398
|
+
boxWidthPx: frame.width * sx,
|
|
399
|
+
boxHeightPx: frame.height * sy,
|
|
400
|
+
scaleHandles: getScaleHandleAnchors({
|
|
401
|
+
centerX,
|
|
402
|
+
centerY,
|
|
403
|
+
halfWpx: (frame.width / 2) * sx,
|
|
404
|
+
halfHpx: (frame.height / 2) * sy,
|
|
405
|
+
rotation,
|
|
406
|
+
}),
|
|
373
407
|
};
|
|
374
408
|
}
|
|
375
409
|
|
|
410
|
+
// Project the 8 scale handles from the box's rotated corners/edges into screen
|
|
411
|
+
// px so they can render in the un-scaled button layer at a constant size.
|
|
412
|
+
function getScaleHandleAnchors({ centerX, centerY, halfWpx, halfHpx, rotation }) {
|
|
413
|
+
const rad = (rotation * Math.PI) / 180;
|
|
414
|
+
const cosR = Math.cos(rad);
|
|
415
|
+
const sinR = Math.sin(rad);
|
|
416
|
+
return SCALE_HANDLES.map((handle) => {
|
|
417
|
+
const lx = handle.x * halfWpx;
|
|
418
|
+
const ly = handle.y * halfHpx;
|
|
419
|
+
return {
|
|
420
|
+
...handle,
|
|
421
|
+
x: centerX + lx * cosR - ly * sinR,
|
|
422
|
+
y: centerY + lx * sinR + ly * cosR,
|
|
423
|
+
};
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
|
|
376
427
|
function getRotateAnchor({ centerX, centerY, halfW, rotation }) {
|
|
377
428
|
const rad = (rotation * Math.PI) / 180;
|
|
378
429
|
const cosR = Math.cos(rad);
|
|
@@ -492,19 +543,22 @@ function Floating({ x, y, children }) {
|
|
|
492
543
|
);
|
|
493
544
|
}
|
|
494
545
|
|
|
495
|
-
function OverlayButton({ label, icon, onActivate, onPointerDown, extraClass }) {
|
|
546
|
+
function OverlayButton({ label, icon, onActivate, onPointerDown, extraClass, disabled = false }) {
|
|
496
547
|
return (
|
|
497
548
|
<button
|
|
498
549
|
type="button"
|
|
499
550
|
aria-label={label}
|
|
500
551
|
title={label}
|
|
552
|
+
disabled={disabled}
|
|
501
553
|
className={cx(styles.selBtn, extraClass)}
|
|
502
554
|
onPointerDown={(event) => {
|
|
503
555
|
event.stopPropagation();
|
|
556
|
+
if (disabled) return;
|
|
504
557
|
onPointerDown?.(event);
|
|
505
558
|
}}
|
|
506
559
|
onClick={(event) => {
|
|
507
560
|
event.stopPropagation();
|
|
561
|
+
if (disabled) return;
|
|
508
562
|
onActivate?.();
|
|
509
563
|
}}>
|
|
510
564
|
<Icon name={icon} />
|
|
@@ -577,29 +631,37 @@ function snapDelta(delta, snap) {
|
|
|
577
631
|
return Math.round(delta / snap.gridSize) * snap.gridSize;
|
|
578
632
|
}
|
|
579
633
|
|
|
580
|
-
// Clone the
|
|
581
|
-
//
|
|
582
|
-
//
|
|
583
|
-
//
|
|
584
|
-
|
|
634
|
+
// Clone the RAW `sceneData` (the sparse write target) and patch the Layout
|
|
635
|
+
// override of every selected actor, but read each actor's CURRENT layout
|
|
636
|
+
// from `previewSceneData` (blueprint-merged) so `patch` sees real width/
|
|
637
|
+
// height/rotation even when an instance inherits them and has no Layout
|
|
638
|
+
// override of its own yet. `patch(layout, start)` returns the props to merge
|
|
639
|
+
// into that actor's Layout OVERRIDE (not the merged layout), or null to leave
|
|
640
|
+
// it untouched -- this is exactly the sparse-override write path (drag =
|
|
641
|
+
// x/y, always non-inherited; scale additionally promotes width/height to
|
|
642
|
+
// instance overrides). Returns the original `sceneData` when nothing changed
|
|
643
|
+
// so callers can skip a no-op commit.
|
|
644
|
+
function updateSelectedLayouts(sceneData, previewSceneData, actorIds, starts, patch) {
|
|
585
645
|
const wanted = new Set(actorIds);
|
|
586
646
|
const next = structuredClone(sceneData);
|
|
587
647
|
let changed = false;
|
|
588
648
|
for (const actor of next.actors) {
|
|
589
649
|
if (!wanted.has(actor.id)) continue;
|
|
590
650
|
const start = starts[actor.id];
|
|
591
|
-
const
|
|
592
|
-
|
|
593
|
-
|
|
651
|
+
const previewLayout = previewSceneData.actors.find((candidate) => candidate.id === actor.id)
|
|
652
|
+
?.components?.Layout;
|
|
653
|
+
if (!start || !previewLayout) continue;
|
|
654
|
+
const props = patch(previewLayout, start);
|
|
594
655
|
if (!props) continue;
|
|
595
|
-
actor.components
|
|
656
|
+
actor.components ??= {};
|
|
657
|
+
actor.components.Layout = { ...(actor.components.Layout ?? {}), ...props };
|
|
596
658
|
changed = true;
|
|
597
659
|
}
|
|
598
660
|
return changed ? next : sceneData;
|
|
599
661
|
}
|
|
600
662
|
|
|
601
|
-
function moveSelected(sceneData, actorIds, starts, dx, dy) {
|
|
602
|
-
return updateSelectedLayouts(sceneData, actorIds, starts, (layout, start) => {
|
|
663
|
+
function moveSelected(sceneData, previewSceneData, actorIds, starts, dx, dy) {
|
|
664
|
+
return updateSelectedLayouts(sceneData, previewSceneData, actorIds, starts, (layout, start) => {
|
|
603
665
|
const x = Math.round(start.x + dx);
|
|
604
666
|
const y = Math.round(start.y + dy);
|
|
605
667
|
return layout.x === x && layout.y === y ? null : { x, y };
|
|
@@ -608,8 +670,8 @@ function moveSelected(sceneData, actorIds, starts, dx, dy) {
|
|
|
608
670
|
|
|
609
671
|
// Common-pivot rotation: actor centers orbit the selection pivot while each
|
|
610
672
|
// actor also spins by the same delta, matching a temporary group transform.
|
|
611
|
-
function rotateSelected(sceneData, actorIds, starts, pivot, deltaDeg) {
|
|
612
|
-
return updateSelectedLayouts(sceneData, actorIds, starts, (layout, start) => {
|
|
673
|
+
function rotateSelected(sceneData, previewSceneData, actorIds, starts, pivot, deltaDeg) {
|
|
674
|
+
return updateSelectedLayouts(sceneData, previewSceneData, actorIds, starts, (layout, start) => {
|
|
613
675
|
const center = {
|
|
614
676
|
x: start.x + start.width / 2,
|
|
615
677
|
y: start.y + start.height / 2,
|
|
@@ -623,11 +685,11 @@ function rotateSelected(sceneData, actorIds, starts, pivot, deltaDeg) {
|
|
|
623
685
|
});
|
|
624
686
|
}
|
|
625
687
|
|
|
626
|
-
function scaleSelected(sceneData, actorIds, starts, startTransform, nextLocalBounds) {
|
|
688
|
+
function scaleSelected(sceneData, previewSceneData, actorIds, starts, startTransform, nextLocalBounds) {
|
|
627
689
|
const startBounds = startTransform.localBounds;
|
|
628
690
|
const scaleX = startBounds.width === 0 ? 1 : nextLocalBounds.width / startBounds.width;
|
|
629
691
|
const scaleY = startBounds.height === 0 ? 1 : nextLocalBounds.height / startBounds.height;
|
|
630
|
-
return updateSelectedLayouts(sceneData, actorIds, starts, (layout, start) => {
|
|
692
|
+
return updateSelectedLayouts(sceneData, previewSceneData, actorIds, starts, (layout, start) => {
|
|
631
693
|
const startCenter = {
|
|
632
694
|
x: start.x + start.width / 2,
|
|
633
695
|
y: start.y + start.height / 2,
|
|
@@ -79,9 +79,17 @@ export function SingleEditor({ path, editor }) {
|
|
|
79
79
|
[stashKey, selectedActorIds, multiSelectMode]
|
|
80
80
|
);
|
|
81
81
|
const { sprites } = collectAssets(files);
|
|
82
|
+
// Optimistic cross-file edit: fold the new text into live files state NOW
|
|
83
|
+
// (so merged previews update this frame) and debounce the real write; the
|
|
84
|
+
// fs echo of our own write is skipped while pending (shouldSkipPath above).
|
|
85
|
+
// Used for this editor's own file AND for blueprint-file edits made from a
|
|
86
|
+
// scene panel's blueprint inspector.
|
|
87
|
+
function onChangeFile(targetPath, nextText) {
|
|
88
|
+
setFiles((current) => ({ ...current, [targetPath]: nextText }));
|
|
89
|
+
schedule(targetPath, nextText);
|
|
90
|
+
}
|
|
82
91
|
function onChange(nextText) {
|
|
83
|
-
|
|
84
|
-
schedule(path, nextText);
|
|
92
|
+
onChangeFile(path, nextText);
|
|
85
93
|
}
|
|
86
94
|
const kind = editor || getFileKind(path);
|
|
87
95
|
const text = files[path] ?? '';
|
|
@@ -94,6 +102,7 @@ export function SingleEditor({ path, editor }) {
|
|
|
94
102
|
files={files}
|
|
95
103
|
sprites={sprites}
|
|
96
104
|
onChange={onChange}
|
|
105
|
+
onChangeFile={onChangeFile}
|
|
97
106
|
selectedActorIds={selectedActorIds}
|
|
98
107
|
onSelectActorIds={setSelectedActorIds}
|
|
99
108
|
multiSelectMode={multiSelectMode}
|
|
@@ -138,8 +138,14 @@ export function useUndoRedoShortcuts(history, enabled = true) {
|
|
|
138
138
|
if (event.shiftKey) historyRef.current.redo();
|
|
139
139
|
else historyRef.current.undo();
|
|
140
140
|
}
|
|
141
|
-
|
|
142
|
-
|
|
141
|
+
// Capture phase, not bubble: this must win Cmd+Z/Cmd+Shift+Z before it can
|
|
142
|
+
// fall through to the browser's own undo, and before any other listener
|
|
143
|
+
// further down the tree (e.g. a widget that stops propagation on its own
|
|
144
|
+
// keydown) can swallow it first. This only matters once this iframe
|
|
145
|
+
// actually HAS keyboard focus (see EditorBody's pointer-down focus claim)
|
|
146
|
+
// -- capture order can't help if the event never reaches this window.
|
|
147
|
+
window.addEventListener('keydown', onKeyDown, true);
|
|
148
|
+
return () => window.removeEventListener('keydown', onKeyDown, true);
|
|
143
149
|
}, []);
|
|
144
150
|
}
|
|
145
151
|
|
|
@@ -1,23 +1,9 @@
|
|
|
1
|
-
import { useEffect, useState } from 'react';
|
|
2
1
|
import { styles, useMobileSheet } from '../engine/ui';
|
|
3
2
|
|
|
4
|
-
// Inspector panel as a bottom sheet on compact viewports, docked
|
|
5
|
-
// Used by the pixel-art (PxArt) editor. `inspectorOpen` drives
|
|
6
|
-
//
|
|
3
|
+
// Inspector panel as a drag-resizable bottom sheet on compact viewports, docked
|
|
4
|
+
// on desktop. Used by the pixel-art (PxArt) editor. `inspectorOpen` drives
|
|
5
|
+
// visibility; the sheet hook owns the resize height and the settle-on-release
|
|
6
|
+
// behavior (drag the grab handle to resize, tap it to toggle peek / expanded).
|
|
7
7
|
export function useInspectorSheet(inspectorOpen) {
|
|
8
|
-
|
|
9
|
-
useEffect(() => {
|
|
10
|
-
if (inspectorOpen) setSnap('high');
|
|
11
|
-
}, [inspectorOpen]);
|
|
12
|
-
const effectiveSnap = inspectorOpen ? snap : 'hidden';
|
|
13
|
-
return useMobileSheet({
|
|
14
|
-
snap: effectiveSnap,
|
|
15
|
-
baseClassName: styles.inspector,
|
|
16
|
-
onTransition: (direction) => {
|
|
17
|
-
if (!inspectorOpen) return;
|
|
18
|
-
if (direction === 'tap') setSnap((previous) => (previous === 'high' ? 'low' : 'high'));
|
|
19
|
-
else if (direction === 'down') setSnap('low');
|
|
20
|
-
else if (direction === 'up') setSnap('high');
|
|
21
|
-
},
|
|
22
|
-
});
|
|
8
|
+
return useMobileSheet({ open: inspectorOpen, baseClassName: styles.inspector });
|
|
23
9
|
}
|
|
@@ -6,7 +6,7 @@ import { SceneUI } from './SceneUI';
|
|
|
6
6
|
// behavior-driven UI overlay. No game logic lives here -- behaviors and
|
|
7
7
|
// scenes are the place for that. The dev logs drawer is a builtin shell panel
|
|
8
8
|
// now (cli/src/shell), not rendered here.
|
|
9
|
-
export function ScenePlayer({ sceneData, sprites, behaviorClasses, onFirstFrame }) {
|
|
9
|
+
export function ScenePlayer({ sceneData, sprites, files, behaviorClasses, onFirstFrame }) {
|
|
10
10
|
const canvasRef = useRef(null);
|
|
11
11
|
const runtimeRef = useRef(null);
|
|
12
12
|
const getRuntime = useCallback(() => runtimeRef.current, []);
|
|
@@ -16,7 +16,7 @@ export function ScenePlayer({ sceneData, sprites, behaviorClasses, onFirstFrame
|
|
|
16
16
|
const ctx = canvas.getContext('2d');
|
|
17
17
|
if (!ctx) return undefined;
|
|
18
18
|
configureSceneCanvas(canvas, ctx);
|
|
19
|
-
const runtime = makeScene(sceneData, behaviorClasses, sprites).clone();
|
|
19
|
+
const runtime = makeScene(sceneData, behaviorClasses, sprites, files).clone();
|
|
20
20
|
runtimeRef.current = runtime;
|
|
21
21
|
// Store BOTH the physical code ('KeyX', 'ArrowLeft', 'Space') and the
|
|
22
22
|
// logical key ('x', 'ArrowLeft', ' ') so behaviors can match either. Codes
|