castle-web-cli 0.4.72 → 0.4.74

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.
Files changed (58) hide show
  1. package/dist/agent-prompts.js +22 -3
  2. package/dist/agent.js +731 -313
  3. package/dist/init.js +1 -1
  4. package/dist/shell/assets/index-Dfn29Bkt.js +108 -0
  5. package/dist/shell/assets/{index-CVEnWuGV.css → index-WNbOHPBj.css} +1 -1
  6. package/dist/shell/index.html +2 -2
  7. package/dist/vitePlugins.js +3 -2
  8. package/kits/basic-2d/CLAUDE.md +29 -8
  9. package/kits/basic-2d/behaviors/Collider.jsx +6 -4
  10. package/kits/basic-2d/behaviors/Layout.jsx +2 -2
  11. package/kits/basic-2d/behaviors/Sprite.jsx +210 -0
  12. package/kits/basic-2d/behaviors/tint.js +47 -0
  13. package/kits/basic-2d/docs/pxart-format.md +298 -0
  14. package/kits/basic-2d/drawings/pig.pxart +59 -0
  15. package/kits/basic-2d/editors/App.jsx +125 -76
  16. package/kits/basic-2d/editors/CodeEditor.jsx +9 -45
  17. package/kits/basic-2d/editors/FileBrowser.jsx +234 -47
  18. package/kits/basic-2d/editors/PlayOnly.jsx +9 -7
  19. package/kits/basic-2d/editors/PxArtEditor.jsx +662 -0
  20. package/kits/basic-2d/editors/SceneEditor.jsx +703 -222
  21. package/kits/basic-2d/editors/SelectionOverlay.jsx +818 -0
  22. package/kits/basic-2d/editors/SingleEditor.jsx +38 -20
  23. package/kits/basic-2d/editors/codeTheme.js +135 -0
  24. package/kits/basic-2d/editors/editorHistory.js +44 -17
  25. package/kits/basic-2d/editors/inspectorSheet.js +23 -0
  26. package/kits/basic-2d/editors/pixelCanvas.js +11 -0
  27. package/kits/basic-2d/editors/pixelEditorChrome.jsx +55 -0
  28. package/kits/basic-2d/editors/pixelGeometry.js +45 -0
  29. package/kits/basic-2d/editors/pixelInspector.jsx +416 -0
  30. package/kits/basic-2d/editors/pxArtEditorModel.js +718 -0
  31. package/kits/basic-2d/editors/pxArtPlayback.js +92 -0
  32. package/kits/basic-2d/editors/pxArtTimeline.jsx +752 -0
  33. package/kits/basic-2d/editors/pxArtTimeline.module.css +506 -0
  34. package/kits/basic-2d/editors/pxArtTools.js +124 -0
  35. package/kits/basic-2d/editors/useArtboardFit.js +102 -0
  36. package/kits/basic-2d/engine/ScenePlayer.jsx +169 -13
  37. package/kits/basic-2d/engine/SceneUI.jsx +3 -11
  38. package/kits/basic-2d/engine/assets.js +15 -0
  39. package/kits/basic-2d/engine/files.js +57 -2
  40. package/kits/basic-2d/engine/playConsole.js +66 -0
  41. package/kits/basic-2d/engine/pxart.js +985 -0
  42. package/kits/basic-2d/engine/scene.js +222 -41
  43. package/kits/basic-2d/engine/ui.jsx +155 -26
  44. package/kits/basic-2d/engine/ui.module.css +1385 -332
  45. package/kits/basic-2d/eslint.config.js +21 -0
  46. package/kits/basic-2d/index.html +13 -0
  47. package/kits/basic-2d/package.json +1 -0
  48. package/kits/basic-2d/pnpm-lock.yaml +5 -5
  49. package/kits/basic-2d/scenes/main.scene +19 -26
  50. package/kits/basic-2d/scripts/draw.mjs +121 -0
  51. package/kits/basic-3d/editors/PlayOnly.jsx +9 -1
  52. package/kits/basic-3d/engine/ScenePlayer.jsx +7 -1
  53. package/package.json +1 -1
  54. package/dist/shell/assets/index-vmdKwUE1.js +0 -106
  55. package/kits/basic-2d/behaviors/Drawing.jsx +0 -142
  56. package/kits/basic-2d/drawings/block.drawing +0 -70
  57. package/kits/basic-2d/drawings/default.drawing +0 -70
  58. package/kits/basic-2d/editors/DrawingEditor.jsx +0 -224
@@ -1,8 +1,11 @@
1
- import React, { useCallback, useEffect, useRef, useState } from 'react';
1
+ import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
2
+ import { renderSpriteFrame } from '../engine/pxart';
2
3
  import { basename, formatJson, parseJsonFile } from '../engine/files';
3
4
  import { TouchControls } from '../engine/TouchControls';
4
5
  import {
5
6
  addActor,
7
+ arrangeActors,
8
+ cardSize,
6
9
  configureSceneCanvas,
7
10
  duplicateActors,
8
11
  makeScene,
@@ -28,34 +31,62 @@ import {
28
31
  } from '../engine/ui';
29
32
  import { AutoInspector } from '../engine/autoInspector';
30
33
  import { SceneUI } from '../engine/SceneUI';
34
+ import { SelectionOverlay } from './SelectionOverlay';
31
35
  import { behaviorClasses, findBehaviorClass } from './behaviorRegistry';
32
- import { useEditHistory } from './editorHistory';
36
+ import { useEditHistory, useUndoRedoShortcuts } from './editorHistory';
33
37
  const LONG_PRESS_MS = 500;
34
38
  const DRAG_THRESHOLD = 4;
35
39
  const DEFAULT_GRID_SIZE = 25;
40
+ const EDIT_VIEWPORT = {
41
+ width: cardSize.width * 3,
42
+ height: cardSize.height * 3,
43
+ originX: cardSize.width,
44
+ originY: cardSize.height,
45
+ };
46
+ // Edit-camera zoom bounds. zoom > 1 magnifies (the visible viewport shrinks);
47
+ // zoom < 1 pulls back to show more around the card.
48
+ const MIN_EDIT_ZOOM = 0.4;
49
+ const MAX_EDIT_ZOOM = 6;
50
+ // Wheel-delta -> zoom-factor sensitivity for trackpad pinch (ctrl+wheel).
51
+ const ZOOM_WHEEL_SPEED = 0.01;
52
+ const clampZoom = (zoom) =>
53
+ Math.min(MAX_EDIT_ZOOM, Math.max(MIN_EDIT_ZOOM, Number(zoom) || 1));
54
+ // Derive the effective edit viewport for a zoom level. Both extent and origin
55
+ // scale by 1/zoom, which keeps the canvas draw path, `screenToCard`, and the
56
+ // SelectionOverlay's `sx = box/card` projection mutually consistent (the overlay
57
+ // just multiplies its scale by the same zoom).
58
+ function editViewportForZoom(zoom) {
59
+ const z = clampZoom(zoom);
60
+ return {
61
+ width: EDIT_VIEWPORT.width / z,
62
+ height: EDIT_VIEWPORT.height / z,
63
+ originX: EDIT_VIEWPORT.originX / z,
64
+ originY: EDIT_VIEWPORT.originY / z,
65
+ };
66
+ }
36
67
  export function SceneEditor({
37
68
  path,
38
69
  text,
39
70
  files,
40
- drawings,
71
+ sprites,
41
72
  onChange,
42
73
  onToggleFiles,
43
74
  filesOpen,
75
+ headerHost,
44
76
  selectedActorIds,
45
77
  onSelectActorIds,
46
78
  multiSelectMode,
47
79
  onSetMultiSelectMode,
48
- bare = false,
49
80
  }) {
50
81
  const canvasRef = useRef(null);
51
82
  const runtimeRef = useRef(null);
52
83
  const marqueeRef = useRef(null);
53
- const editCameraRef = useRef({ x: 0, y: 0 });
84
+ const editCameraRef = useRef({ x: 0, y: 0, zoom: 1 });
54
85
  const selectedActorIdsRef = useRef(selectedActorIds);
55
86
  selectedActorIdsRef.current = selectedActorIds;
56
87
  const [isPlaying, setIsPlaying] = useState(false);
57
- const [panMode, setPanMode] = useState(false);
58
88
  const history = useEditHistory(text, onChange);
89
+ useUndoRedoShortcuts(history);
59
90
  const showMulti = selectedActorIds.length > 1 || multiSelectMode;
60
91
  const inspectorSheet = useSelectionInspectorSheet(true);
61
92
  const { value: sceneData, error } = parseJsonFile(path, text);
@@ -63,21 +94,21 @@ export function SceneEditor({
63
94
  const getRuntime = useCallback(() => runtimeRef.current, []);
64
95
  useScenePlayLoop({
65
96
  sceneData,
66
- drawings,
97
+ sprites,
67
98
  isPlaying,
68
99
  text,
69
100
  canvasRef,
70
101
  runtimeRef,
71
102
  editCameraRef,
72
- selectedActorIds,
73
103
  marqueeRef,
104
+ selectedActorIdsRef,
74
105
  });
75
- const panGesture = usePanGesture({ canvasRef, editCameraRef, enabled: panMode && !isPlaying });
76
- useScenePlayKeys(runtimeRef, setIsPlaying);
106
+ const panGesture = usePanGesture({ canvasRef, editCameraRef, isPlaying });
107
+ useScenePlayKeys(runtimeRef);
77
108
  const gesture = useSelectionGesture({
78
109
  canvasRef,
79
110
  sceneData,
80
- drawings,
111
+ sprites,
81
112
  isPlaying,
82
113
  selectedActorIds,
83
114
  onSelectActorIds,
@@ -101,9 +132,12 @@ export function SceneEditor({
101
132
  if (!sceneData) {
102
133
  return (
103
134
  <>
104
- {bare ? null : (
105
- <EditorHeader title={basename(path)} onToggleFiles={onToggleFiles} filesOpen={filesOpen} />
106
- )}
135
+ <EditorHeader
136
+ title={basename(path)}
137
+ onToggleFiles={onToggleFiles}
138
+ filesOpen={filesOpen}
139
+ headerHost={headerHost}
140
+ />
107
141
  <EditorBody>
108
142
  <div className={styles.inspector}>{error}</div>
109
143
  </EditorBody>
@@ -121,6 +155,9 @@ export function SceneEditor({
121
155
  onSelectActorIds,
122
156
  });
123
157
  const snapSettings = getSnapSettings(sceneData);
158
+ const stageWrapStyle = {
159
+ backgroundColor: darkenSceneBackground(sceneData.background),
160
+ };
124
161
  const updateSceneSettings = (nextScene) => {
125
162
  history.commit(formatJson(setSceneSettings(sceneData, nextScene)));
126
163
  };
@@ -133,85 +170,89 @@ export function SceneEditor({
133
170
  : selectedActor
134
171
  ? `actor ${selectedActor.id}`
135
172
  : 'scene settings';
136
- const playbackButtons = (
137
- <PlaybackButtons
138
- isPlaying={isPlaying}
139
- setIsPlaying={setIsPlaying}
140
- history={history}
141
- panMode={panMode}
142
- setPanMode={setPanMode}
143
- />
144
- );
145
- const actorButtons = (
146
- <ActorToolButtons
147
- sceneData={sceneData}
148
- files={files}
149
- drawings={drawings}
150
- selectedActorIds={selectedActorIds}
151
- onSelectActorIds={onSelectActorIds}
152
- isPlaying={isPlaying}
153
- onChange={onChange}
154
- history={history}
155
- />
156
- );
157
173
  return (
158
174
  <>
159
- {bare ? null : (
160
- <EditorHeader
161
- title={sceneData.name ?? basename(path)}
162
- subtitle={isPlaying ? 'WASD / arrows' : `${sceneData.actors.length} actors`}
163
- right={
164
- <span className={styles.mobileOnly}>
165
- {playbackButtons}
166
- {actorButtons}
167
- </span>
168
- }
169
- onToggleFiles={onToggleFiles}
170
- filesOpen={filesOpen}
171
- />
172
- )}
175
+ <EditorHeader
176
+ title={sceneData.name ?? basename(path)}
177
+ subtitle={isPlaying ? 'WASD / arrows' : `${sceneData.actors.length} actors`}
178
+ right={
179
+ <HeaderPlaybackButtons
180
+ isPlaying={isPlaying}
181
+ setIsPlaying={setIsPlaying}
182
+ history={history}
183
+ />
184
+ }
185
+ onToggleFiles={onToggleFiles}
186
+ filesOpen={filesOpen}
187
+ headerHost={headerHost}
188
+ />
173
189
  <EditorBody>
174
190
  <div className={styles.sceneWorkspace}>
175
191
  <div className={styles.sceneTools}>
176
- <span aria-hidden="true" />
177
- <div className={styles.sceneToolsGroup}>{playbackButtons}</div>
178
- <div className={styles.sceneToolsGroup}>{actorButtons}</div>
192
+ <BlueprintHotbar
193
+ sceneData={sceneData}
194
+ files={files}
195
+ sprites={sprites}
196
+ canvasRef={canvasRef}
197
+ editCameraRef={editCameraRef}
198
+ selectedActorIds={selectedActorIds}
199
+ onSelectActorIds={onSelectActorIds}
200
+ isPlaying={isPlaying}
201
+ onChange={onChange}
202
+ history={history}
203
+ />
179
204
  </div>
180
- <div className={styles.stageWrap}>
205
+ <div className={styles.stageWrap} style={stageWrapStyle}>
181
206
  <div className={styles.stageCard} data-castle-card>
182
- <canvas
183
- ref={canvasRef}
184
- className={styles.stageCanvas}
185
- onPointerDown={
186
- isPlaying
187
- ? playPointer.onPointerDown
188
- : panMode
189
- ? panGesture.onPointerDown
190
- : gesture.onPointerDown
191
- }
192
- onPointerMove={
193
- isPlaying
194
- ? playPointer.onPointerMove
195
- : panMode
196
- ? panGesture.onPointerMove
197
- : gesture.onPointerMove
198
- }
199
- onPointerUp={
200
- isPlaying
201
- ? playPointer.onPointerUp
202
- : panMode
203
- ? panGesture.onPointerUp
204
- : gesture.onPointerUp
205
- }
206
- onPointerCancel={
207
- isPlaying
208
- ? playPointer.onPointerUp
209
- : panMode
210
- ? panGesture.onPointerUp
211
- : gesture.onPointerUp
212
- }
213
- />
214
- {isPlaying && <SceneUI getRuntime={getRuntime} />}
207
+ <div className={cx(styles.stageCardClip, !isPlaying && styles.stageCardEditSurface)}>
208
+ <canvas
209
+ ref={canvasRef}
210
+ className={cx(styles.stageCanvas, !isPlaying && styles.stageCanvasEdit)}
211
+ onPointerDown={(event) =>
212
+ isPlaying
213
+ ? playPointer.onPointerDown(event)
214
+ : panGesture.isSpacePanning()
215
+ ? panGesture.onPointerDown(event)
216
+ : gesture.onPointerDown(event)
217
+ }
218
+ onPointerMove={(event) =>
219
+ isPlaying
220
+ ? playPointer.onPointerMove(event)
221
+ : panGesture.isActive()
222
+ ? panGesture.onPointerMove(event)
223
+ : gesture.onPointerMove(event)
224
+ }
225
+ onPointerUp={(event) =>
226
+ isPlaying
227
+ ? playPointer.onPointerUp(event)
228
+ : panGesture.isActive()
229
+ ? panGesture.onPointerUp(event)
230
+ : gesture.onPointerUp(event)
231
+ }
232
+ onPointerCancel={(event) =>
233
+ isPlaying
234
+ ? playPointer.onPointerUp(event)
235
+ : panGesture.isActive()
236
+ ? panGesture.onPointerUp(event)
237
+ : gesture.onPointerUp(event)
238
+ }
239
+ />
240
+ {isPlaying && <SceneUI getRuntime={getRuntime} />}
241
+ </div>
242
+ {!isPlaying && selectedActorIds.length >= 1 && (
243
+ <SelectionOverlay
244
+ canvasRef={canvasRef}
245
+ editCameraRef={editCameraRef}
246
+ sceneData={sceneData}
247
+ selectedActorIds={selectedActorIds}
248
+ snap={snapSettings}
249
+ onArrange={actions.arrangeSelection}
250
+ onClone={actions.duplicateSelection}
251
+ onDelete={actions.deleteSelection}
252
+ applyScene={(next) => onChange(formatJson(next))}
253
+ recordSnapshot={history.recordSnapshot}
254
+ />
255
+ )}
215
256
  </div>
216
257
  </div>
217
258
  <aside {...inspectorSheet.rootProps}>
@@ -222,8 +263,6 @@ export function SceneEditor({
222
263
  {showMulti ? (
223
264
  <MultiSelectInspector
224
265
  count={selectedActorIds.length}
225
- onDelete={actions.deleteSelection}
226
- onDuplicate={actions.duplicateSelection}
227
266
  onDeselectAll={() => {
228
267
  onSelectActorIds([]);
229
268
  onSetMultiSelectMode(false);
@@ -253,16 +292,11 @@ export function SceneEditor({
253
292
  </>
254
293
  );
255
294
  }
256
- function PlaybackButtons({ isPlaying, setIsPlaying, history, panMode, setPanMode }) {
295
+ // Header-right playback controls, in the mockup's order: Undo, Redo, Play.
296
+ // Play stays a play/stop toggle. Duplicate/remove live on the on-canvas toolbar.
297
+ function HeaderPlaybackButtons({ isPlaying, setIsPlaying, history }) {
257
298
  return (
258
299
  <>
259
- <IconButton
260
- icon={isPlaying ? 'stop' : 'play'}
261
- label={isPlaying ? 'Stop' : 'Play'}
262
- active={isPlaying}
263
- variant={isPlaying ? 'primary' : ''}
264
- onClick={() => setIsPlaying(!isPlaying)}
265
- />
266
300
  <IconButton
267
301
  icon="undo"
268
302
  label="Undo"
@@ -275,66 +309,359 @@ function PlaybackButtons({ isPlaying, setIsPlaying, history, panMode, setPanMode
275
309
  onClick={history.redo}
276
310
  disabled={!history.canRedo || isPlaying}
277
311
  />
278
- <span style={{ width: 16 }} aria-hidden="true" />
279
312
  <IconButton
280
- icon="camera"
281
- label="Pan camera"
282
- active={panMode}
283
- onClick={() => setPanMode((value) => !value)}
284
- disabled={isPlaying}
313
+ icon={isPlaying ? 'stop' : 'play'}
314
+ label={isPlaying ? 'Stop' : 'Play'}
315
+ active={isPlaying}
316
+ onClick={(event) => {
317
+ // Drop focus so a subsequent Space (a game input / pan modifier)
318
+ // doesn't re-activate this button and toggle play.
319
+ event.currentTarget.blur();
320
+ setIsPlaying(!isPlaying);
321
+ }}
285
322
  />
286
323
  </>
287
324
  );
288
325
  }
289
- function ActorToolButtons({
326
+ // Blueprint hotbar -- lightweight actor stamps saved in scene.editor.blueprints.
327
+ function BlueprintHotbar({
290
328
  sceneData,
291
329
  files,
292
- drawings,
330
+ sprites,
331
+ canvasRef,
332
+ editCameraRef,
293
333
  selectedActorIds,
294
334
  onSelectActorIds,
295
335
  isPlaying,
296
336
  onChange,
297
337
  history,
298
338
  }) {
299
- const applyScene = (next) => onChange(formatJson(next));
339
+ const blueprints = sceneData.editor?.blueprints ?? [];
340
+ const dragRef = useRef(null);
341
+ const suppressClickRef = useRef(false);
342
+ const [dragPreview, setDragPreview] = useState(null);
343
+ // Right-click context menu anchored at the cursor: { blueprint, x, y }.
344
+ const [contextMenu, setContextMenu] = useState(null);
345
+ const closeContextMenu = useCallback(() => setContextMenu(null), []);
300
346
  const onAdd = () => {
301
- const { sceneData: next, newId } = addActor(sceneData, files, drawings);
347
+ const { sceneData: next, newId } = addActor(sceneData, files, sprites);
302
348
  history.recordSnapshot();
303
- applyScene(next);
349
+ onChange(formatJson(next));
304
350
  onSelectActorIds([newId]);
305
351
  };
306
- const onDuplicate = () => {
307
- if (selectedActorIds.length === 0) return;
308
- const { sceneData: next, newIds } = duplicateActors(sceneData, selectedActorIds);
309
- history.recordSnapshot();
310
- applyScene(next);
311
- if (newIds.length) onSelectActorIds(newIds);
352
+ const onSaveSelection = () => {
353
+ const next = addSelectedActorBlueprint(sceneData, selectedActorIds);
354
+ if (next === sceneData) return;
355
+ history.commit(formatJson(next));
312
356
  };
313
- const onRemove = () => {
314
- if (selectedActorIds.length === 0) return;
315
- history.recordSnapshot();
316
- applyScene(removeActors(sceneData, selectedActorIds));
317
- onSelectActorIds([]);
357
+ const onStamp = (blueprint, position) => {
358
+ const { sceneData: next, newId } = stampActorBlueprint(sceneData, blueprint, position);
359
+ if (next === sceneData) return;
360
+ history.commit(formatJson(next));
361
+ onSelectActorIds([newId]);
362
+ };
363
+ const onRemoveBlueprint = (blueprintId) => {
364
+ const next = removeActorBlueprint(sceneData, blueprintId);
365
+ if (next === sceneData) return;
366
+ history.commit(formatJson(next));
367
+ };
368
+ const onBlueprintContextMenu = (event, blueprint) => {
369
+ if (isPlaying) return;
370
+ event.preventDefault();
371
+ event.stopPropagation();
372
+ setContextMenu({ blueprint, x: event.clientX, y: event.clientY });
373
+ };
374
+ const onBlueprintPointerDown = (event, blueprint) => {
375
+ if (isPlaying || event.button !== 0) return;
376
+ event.preventDefault();
377
+ const drag = {
378
+ blueprint,
379
+ pointerId: event.pointerId,
380
+ startX: event.clientX,
381
+ startY: event.clientY,
382
+ moved: false,
383
+ };
384
+ dragRef.current = drag;
385
+ try {
386
+ event.currentTarget.setPointerCapture(event.pointerId);
387
+ } catch {
388
+ // Window listeners still finish the drag if capture is unavailable.
389
+ }
390
+ const onMove = (moveEvent) => {
391
+ if (moveEvent.pointerId !== drag.pointerId) return;
392
+ if (!drag.moved && Math.hypot(moveEvent.clientX - drag.startX, moveEvent.clientY - drag.startY) > DRAG_THRESHOLD) {
393
+ drag.moved = true;
394
+ }
395
+ if (drag.moved) {
396
+ setDragPreview({
397
+ blueprint,
398
+ x: moveEvent.clientX,
399
+ y: moveEvent.clientY,
400
+ overStage: Boolean(eventToStagePoint(moveEvent, canvasRef, editCameraRef)),
401
+ });
402
+ }
403
+ };
404
+ const onUp = (upEvent) => {
405
+ if (upEvent.pointerId !== drag.pointerId) return;
406
+ window.removeEventListener('pointermove', onMove);
407
+ window.removeEventListener('pointerup', onUp);
408
+ window.removeEventListener('pointercancel', onUp);
409
+ dragRef.current = null;
410
+ setDragPreview(null);
411
+ if (!drag.moved) return;
412
+ suppressClickRef.current = true;
413
+ const position = eventToStagePoint(upEvent, canvasRef, editCameraRef);
414
+ if (position) onStamp(blueprint, position);
415
+ };
416
+ window.addEventListener('pointermove', onMove);
417
+ window.addEventListener('pointerup', onUp);
418
+ window.addEventListener('pointercancel', onUp);
318
419
  };
319
- const hasSelection = selectedActorIds.length > 0;
320
420
  return (
321
- <>
322
- <IconButton icon="plus" label="Add actor" onClick={onAdd} disabled={isPlaying} />
323
- <IconButton
324
- icon="clone"
325
- label="Duplicate"
326
- onClick={onDuplicate}
327
- disabled={!hasSelection || isPlaying}
328
- />
329
- <IconButton
330
- icon="trash"
331
- label="Remove"
332
- onClick={onRemove}
333
- disabled={!hasSelection || isPlaying}
334
- />
335
- </>
421
+ <div className={styles.bpHotbar} aria-label="Blueprints">
422
+ <button
423
+ type="button"
424
+ className={styles.bpActionSlot}
425
+ aria-label="Save selected actor as blueprint"
426
+ title="Save selected actor as blueprint"
427
+ onClick={onSaveSelection}
428
+ disabled={isPlaying || selectedActorIds.length !== 1}>
429
+ <Icon name="stamp" />
430
+ </button>
431
+ <button
432
+ type="button"
433
+ className={styles.bpActionSlot}
434
+ aria-label="Add actor"
435
+ title="Add actor"
436
+ onClick={onAdd}
437
+ disabled={isPlaying}>
438
+ <span className={styles.dashedSquareIcon} aria-hidden="true" />
439
+ </button>
440
+ {blueprints.map((blueprint) => (
441
+ <button
442
+ key={blueprint.id}
443
+ type="button"
444
+ className={styles.bpSlot}
445
+ title={`Add ${blueprint.name}`}
446
+ disabled={isPlaying}
447
+ onPointerDown={(event) => onBlueprintPointerDown(event, blueprint)}
448
+ onContextMenu={(event) => onBlueprintContextMenu(event, blueprint)}
449
+ onClick={() => {
450
+ if (suppressClickRef.current) {
451
+ suppressClickRef.current = false;
452
+ return;
453
+ }
454
+ onStamp(blueprint);
455
+ }}>
456
+ <BlueprintThumbnail blueprint={blueprint} sprites={sprites} />
457
+ </button>
458
+ ))}
459
+ {dragPreview ? (
460
+ <BlueprintDragPreview preview={dragPreview} sprites={sprites} />
461
+ ) : null}
462
+ {contextMenu ? (
463
+ <BlueprintContextMenu
464
+ x={contextMenu.x}
465
+ y={contextMenu.y}
466
+ onRemove={() => onRemoveBlueprint(contextMenu.blueprint.id)}
467
+ onClose={closeContextMenu}
468
+ />
469
+ ) : null}
470
+ </div>
336
471
  );
337
472
  }
473
+ // Cursor-anchored right-click menu for a hotbar blueprint. Mirrors the
474
+ // pxArtTimeline ContextMenu: reuses the shared `selArrangeMenu`/`selArrangeItem`
475
+ // chrome, fixed-positions at the click point with viewport clamping, and closes
476
+ // on outside pointerdown or Escape.
477
+ function BlueprintContextMenu({ x, y, onRemove, onClose }) {
478
+ const ref = useRef(null);
479
+ const [pos, setPos] = useState({ left: x, top: y });
480
+ useLayoutEffect(() => {
481
+ const el = ref.current;
482
+ if (!el) return;
483
+ const rect = el.getBoundingClientRect();
484
+ const margin = 8;
485
+ const maxLeft = Math.max(margin, window.innerWidth - rect.width - margin);
486
+ const maxTop = Math.max(margin, window.innerHeight - rect.height - margin);
487
+ setPos({ left: Math.min(x, maxLeft), top: Math.min(y, maxTop) });
488
+ }, [x, y]);
489
+ useEffect(() => {
490
+ const onPointerDown = (event) => {
491
+ if (!ref.current?.contains(event.target)) onClose();
492
+ };
493
+ const onKeyDown = (event) => {
494
+ if (event.key === 'Escape') onClose();
495
+ };
496
+ window.addEventListener('pointerdown', onPointerDown);
497
+ window.addEventListener('keydown', onKeyDown);
498
+ return () => {
499
+ window.removeEventListener('pointerdown', onPointerDown);
500
+ window.removeEventListener('keydown', onKeyDown);
501
+ };
502
+ }, [onClose]);
503
+ return (
504
+ <div
505
+ ref={ref}
506
+ className={styles.selArrangeMenu}
507
+ role="menu"
508
+ aria-label="Blueprint"
509
+ style={{ position: 'fixed', left: pos.left, top: pos.top, transform: 'none' }}
510
+ onPointerDown={(event) => event.stopPropagation()}
511
+ onContextMenu={(event) => event.preventDefault()}>
512
+ <button
513
+ type="button"
514
+ role="menuitem"
515
+ className={styles.selArrangeItem}
516
+ onClick={() => {
517
+ onRemove();
518
+ onClose();
519
+ }}>
520
+ Remove blueprint
521
+ </button>
522
+ </div>
523
+ );
524
+ }
525
+ function BlueprintDragPreview({ preview, sprites }) {
526
+ const layout = preview.blueprint.actor?.components?.Layout;
527
+ const width = Math.max(24, Math.min(80, layout?.width ?? 48));
528
+ const height = Math.max(24, Math.min(80, layout?.height ?? 48));
529
+ return (
530
+ <div
531
+ className={cx(styles.bpDragPreview, preview.overStage && styles.bpDragPreviewOverStage)}
532
+ style={{
533
+ left: preview.x,
534
+ top: preview.y,
535
+ width,
536
+ height,
537
+ transform: `translate(-50%, -50%) rotate(${layout?.rotation ?? 0}deg)`,
538
+ }}>
539
+ <BlueprintThumbnail blueprint={preview.blueprint} sprites={sprites} preview />
540
+ </div>
541
+ );
542
+ }
543
+ function BlueprintThumbnail({ blueprint, sprites, preview = false }) {
544
+ const canvasRef = useRef(null);
545
+ const spritePath = blueprint.actor?.components?.Sprite?.file;
546
+ const sprite = spritePath ? sprites?.[spritePath] : null;
547
+ useEffect(() => {
548
+ const canvas = canvasRef.current;
549
+ if (!canvas || !sprite) return;
550
+ // A .pxart blueprint renders its first frame via the SDK.
551
+ renderSpriteFrame(sprite, 0, canvas);
552
+ }, [sprite]);
553
+ if (!sprite) {
554
+ return (
555
+ <span className={styles.bpSlotFallback}>
556
+ <Icon name="clone" />
557
+ </span>
558
+ );
559
+ }
560
+ return (
561
+ <canvas
562
+ ref={canvasRef}
563
+ className={cx(styles.bpSlotThumb, preview && styles.bpPreviewThumb)}
564
+ aria-hidden="true"
565
+ />
566
+ );
567
+ }
568
+ function addSelectedActorBlueprint(sceneData, selectedActorIds) {
569
+ if (selectedActorIds.length !== 1) return sceneData;
570
+ const actor = sceneData.actors.find((candidate) => candidate.id === selectedActorIds[0]);
571
+ if (!actor) return sceneData;
572
+ const next = structuredClone(sceneData);
573
+ const copy = structuredClone(actor);
574
+ delete copy.id;
575
+ if (copy.components?.Layout) {
576
+ copy.components.Layout = {
577
+ ...copy.components.Layout,
578
+ x: 0,
579
+ y: 0,
580
+ };
581
+ }
582
+ const blueprints = [...(next.editor?.blueprints ?? [])];
583
+ const name = makeBlueprintName(actor, blueprints.length);
584
+ blueprints.push({
585
+ id: makeBlueprintId(blueprints),
586
+ name,
587
+ actor: copy,
588
+ });
589
+ next.editor = {
590
+ ...(next.editor ?? {}),
591
+ blueprints,
592
+ };
593
+ return next;
594
+ }
595
+ function stampActorBlueprint(sceneData, blueprint, position) {
596
+ if (!blueprint?.actor) return { sceneData, newId: null };
597
+ const next = structuredClone(sceneData);
598
+ const existingIds = new Set(next.actors.map((actor) => actor.id));
599
+ const actor = structuredClone(blueprint.actor);
600
+ const newId = makeActorId(existingIds);
601
+ actor.id = newId;
602
+ actor.components = actor.components ?? {};
603
+ const layout = actor.components.Layout ?? { width: 64, height: 64, z: 0, rotation: 0 };
604
+ actor.components.Layout = {
605
+ ...layout,
606
+ x: Math.round((position?.x ?? cardSize.width / 2) - layout.width / 2),
607
+ y: Math.round((position?.y ?? cardSize.height / 2) - layout.height / 2),
608
+ };
609
+ next.actors.push(actor);
610
+ return { sceneData: next, newId };
611
+ }
612
+ function removeActorBlueprint(sceneData, blueprintId) {
613
+ const blueprints = sceneData.editor?.blueprints ?? [];
614
+ if (!blueprints.some((blueprint) => blueprint.id === blueprintId)) return sceneData;
615
+ const next = structuredClone(sceneData);
616
+ next.editor = {
617
+ ...(next.editor ?? {}),
618
+ blueprints: (next.editor?.blueprints ?? []).filter(
619
+ (blueprint) => blueprint.id !== blueprintId
620
+ ),
621
+ };
622
+ return next;
623
+ }
624
+ function eventToStagePoint(event, canvasRef, editCameraRef) {
625
+ const canvas = canvasRef.current;
626
+ if (!canvas) return null;
627
+ const rect = canvas.getBoundingClientRect();
628
+ if (
629
+ event.clientX < rect.left ||
630
+ event.clientX > rect.right ||
631
+ event.clientY < rect.top ||
632
+ event.clientY > rect.bottom
633
+ ) {
634
+ return null;
635
+ }
636
+ const raw = screenToCard(canvas, event.clientX, event.clientY);
637
+ const camera = editCameraRef.current ?? { x: 0, y: 0 };
638
+ return {
639
+ x: raw.x + camera.x,
640
+ y: raw.y + camera.y,
641
+ };
642
+ }
643
+ function makeBlueprintName(actor, index) {
644
+ const spriteFile = actor.components?.Sprite?.file;
645
+ if (spriteFile) return basename(spriteFile).replace(/\.pxart$/i, '');
646
+ return `Actor ${index + 1}`;
647
+ }
648
+ function makeBlueprintId(blueprints) {
649
+ const existing = new Set(blueprints.map((blueprint) => blueprint.id));
650
+ for (let index = 1; index < 1000; index++) {
651
+ const candidate = `blueprint-${index}`;
652
+ if (!existing.has(candidate)) return candidate;
653
+ }
654
+ return `blueprint-${Date.now()}`;
655
+ }
656
+ function makeActorId(existingIds) {
657
+ for (let attempt = 0; attempt < 64; attempt++) {
658
+ const candidate = Math.floor(Math.random() * 0xffffffff)
659
+ .toString(16)
660
+ .padStart(8, '0');
661
+ if (!existingIds.has(candidate)) return candidate;
662
+ }
663
+ return `actor-${Date.now()}`;
664
+ }
338
665
  function makeSceneActions({ sceneData, commit, selectedActorIds, onSelectActorIds }) {
339
666
  function commitScene(next) {
340
667
  commit(formatJson(next));
@@ -362,6 +689,11 @@ function makeSceneActions({ sceneData, commit, selectedActorIds, onSelectActorId
362
689
  commitScene(next);
363
690
  if (newIds.length) onSelectActorIds(newIds);
364
691
  },
692
+ arrangeSelection: (action) => {
693
+ if (selectedActorIds.length === 0) return;
694
+ const next = arrangeActors(sceneData, selectedActorIds, action);
695
+ if (next !== sceneData) commitScene(next);
696
+ },
365
697
  };
366
698
  }
367
699
  function setSceneSettings(sceneData, nextScene) {
@@ -379,6 +711,34 @@ function setSceneEditorSettings(sceneData, nextEditor) {
379
711
  },
380
712
  };
381
713
  }
714
+ function darkenSceneBackground(background) {
715
+ const parsed = parseHexColor(background ?? '#121213');
716
+ if (!parsed) return 'var(--castle-stage-workspace-bg)';
717
+ const amount = 0.42;
718
+ const darken = (channel) => Math.max(0, Math.round(channel * (1 - amount)));
719
+ return `rgb(${darken(parsed.r)}, ${darken(parsed.g)}, ${darken(parsed.b)})`;
720
+ }
721
+ function parseHexColor(value) {
722
+ if (typeof value !== 'string') return null;
723
+ const hex = value.trim().replace(/^#/, '');
724
+ const expand = (text) =>
725
+ text
726
+ .split('')
727
+ .map((char) => `${char}${char}`)
728
+ .join('');
729
+ const normalized =
730
+ hex.length === 3 || hex.length === 4
731
+ ? expand(hex.slice(0, 3))
732
+ : hex.length === 6 || hex.length === 8
733
+ ? hex.slice(0, 6)
734
+ : null;
735
+ if (!normalized || !/^[0-9a-f]{6}$/i.test(normalized)) return null;
736
+ return {
737
+ r: parseInt(normalized.slice(0, 2), 16),
738
+ g: parseInt(normalized.slice(2, 4), 16),
739
+ b: parseInt(normalized.slice(4, 6), 16),
740
+ };
741
+ }
382
742
  function useSelectionInspectorSheet(open) {
383
743
  const [snap, setSnap] = useState('high');
384
744
  // Inspector visibility is driven by selection / multi-mode. Snap state
@@ -408,7 +768,7 @@ function useSelectionGesture(args) {
408
768
  const cam = current.editCameraRef.current;
409
769
  const point = { x: raw.x + cam.x, y: raw.y + cam.y };
410
770
  current.canvasRef.current.setPointerCapture(event.pointerId);
411
- const scene = makeScene(current.sceneData, behaviorClasses, current.drawings);
771
+ const scene = makeScene(current.sceneData, behaviorClasses, current.sprites);
412
772
  const actor = scene.actorAt(point.x, point.y);
413
773
  const drag = {
414
774
  pointerId: event.pointerId,
@@ -580,36 +940,156 @@ function usePlayPointerGesture({ canvasRef, runtimeRef }) {
580
940
  );
581
941
  return { onPointerDown, onPointerMove, onPointerUp };
582
942
  }
583
- function usePanGesture({ canvasRef, editCameraRef, enabled }) {
943
+ function usePanGesture({ canvasRef, editCameraRef, isPlaying }) {
584
944
  const dragRef = useRef(null);
585
- const onPointerDown = useCallback(
586
- (event) => {
587
- if (!enabled) return;
945
+ const spaceDownRef = useRef(false);
946
+ // Native wheel/gesture listeners (below) read play state through a ref so they
947
+ // never need to detach/reattach when play toggles.
948
+ const isPlayingRef = useRef(isPlaying);
949
+ isPlayingRef.current = isPlaying;
950
+ useEffect(() => {
951
+ function onKeyDown(event) {
952
+ if (event.key !== ' ' || isEditableTarget(event.target)) return;
953
+ // Swallow Space's default: it would scroll the panel and re-activate a
954
+ // focused button (e.g. the Play/Stop toggle that keeps focus after a
955
+ // click), so Space only ever arms the pan modifier here.
956
+ event.preventDefault();
957
+ spaceDownRef.current = true;
958
+ }
959
+ function onKeyUp(event) {
960
+ if (event.key === ' ') spaceDownRef.current = false;
961
+ }
962
+ window.addEventListener('keydown', onKeyDown);
963
+ window.addEventListener('keyup', onKeyUp);
964
+ return () => {
965
+ window.removeEventListener('keydown', onKeyDown);
966
+ window.removeEventListener('keyup', onKeyUp);
967
+ };
968
+ }, []);
969
+ const panByScreenDelta = useCallback(
970
+ (dx, dy) => {
588
971
  const canvas = canvasRef.current;
589
972
  if (!canvas) return;
590
- const point = screenToCard(canvas, event.clientX, event.clientY);
591
- event.currentTarget.setPointerCapture(event.pointerId);
592
- dragRef.current = { id: event.pointerId, last: point };
973
+ const rect = canvas.getBoundingClientRect();
974
+ const viewportWidth = Number(canvas.dataset.viewportWidth) || cardSize.width;
975
+ const viewportHeight = Number(canvas.dataset.viewportHeight) || cardSize.height;
976
+ editCameraRef.current = {
977
+ ...editCameraRef.current,
978
+ x: editCameraRef.current.x + (dx * viewportWidth) / rect.width,
979
+ y: editCameraRef.current.y + (dy * viewportHeight) / rect.height,
980
+ };
593
981
  },
594
- [canvasRef, enabled]
982
+ [canvasRef, editCameraRef]
595
983
  );
596
- const onPointerMove = useCallback(
597
- (event) => {
598
- const drag = dragRef.current;
599
- if (!drag || drag.id !== event.pointerId) return;
984
+ // Zoom about the pointer by a multiplicative factor: keep the world point
985
+ // under the cursor pinned while the zoom level changes. Because the effective
986
+ // origin scales as EDIT_VIEWPORT.origin / zoom, the camera shift to re-pin the
987
+ // cursor is (1/z0 - 1/z1) * (frac * EDIT_VIEWPORT.extent - EDIT_VIEWPORT.origin).
988
+ const zoomByFactorAtPointer = useCallback(
989
+ (clientX, clientY, factor) => {
600
990
  const canvas = canvasRef.current;
601
- if (!canvas) return;
602
- const point = screenToCard(canvas, event.clientX, event.clientY);
603
- const dx = point.x - drag.last.x;
604
- const dy = point.y - drag.last.y;
605
- drag.last = point;
991
+ if (!canvas || !Number.isFinite(factor) || factor <= 0) return;
992
+ const cam = editCameraRef.current;
993
+ const z0 = cam.zoom ?? 1;
994
+ const z1 = clampZoom(z0 * factor);
995
+ if (z1 === z0) return;
996
+ const rect = canvas.getBoundingClientRect();
997
+ const fracX = (clientX - rect.left) / rect.width;
998
+ const fracY = (clientY - rect.top) / rect.height;
999
+ const k = 1 / z0 - 1 / z1;
606
1000
  editCameraRef.current = {
607
- x: editCameraRef.current.x - dx,
608
- y: editCameraRef.current.y - dy,
1001
+ x: cam.x + k * (fracX * EDIT_VIEWPORT.width - EDIT_VIEWPORT.originX),
1002
+ y: cam.y + k * (fracY * EDIT_VIEWPORT.height - EDIT_VIEWPORT.originY),
1003
+ zoom: z1,
609
1004
  };
610
1005
  },
611
1006
  [canvasRef, editCameraRef]
612
1007
  );
1008
+ // Wheel + pinch are bound as NATIVE, non-passive listeners (not React props):
1009
+ // React attaches `wheel` passively at its root, so a React onWheel can't call
1010
+ // preventDefault and the browser's own page-zoom wins. We also need Safari's
1011
+ // proprietary `gesture*` events, which is how it reports trackpad pinch (it
1012
+ // does not emit ctrl+wheel like Chromium). Both paths preventDefault to stop
1013
+ // the browser from zooming the page underneath the editor.
1014
+ useEffect(() => {
1015
+ const canvas = canvasRef.current;
1016
+ if (!canvas) return undefined;
1017
+ let gestureScale = 1;
1018
+ // Set while a Safari pinch gesture is in flight, so we don't double-apply
1019
+ // zoom if Safari also emits ctrl+wheel for the same pinch.
1020
+ let gestureActive = false;
1021
+ const onWheel = (event) => {
1022
+ if (isPlayingRef.current) return;
1023
+ event.preventDefault();
1024
+ // Trackpad pinch (and Ctrl+wheel) surface as wheel events with ctrlKey set
1025
+ // in Chromium; route those to zoom and leave plain scroll as pan.
1026
+ if (event.ctrlKey) {
1027
+ if (gestureActive) return;
1028
+ zoomByFactorAtPointer(
1029
+ event.clientX,
1030
+ event.clientY,
1031
+ Math.exp(-event.deltaY * ZOOM_WHEEL_SPEED)
1032
+ );
1033
+ } else {
1034
+ panByScreenDelta(event.deltaX, event.deltaY);
1035
+ }
1036
+ };
1037
+ const onGestureStart = (event) => {
1038
+ if (isPlayingRef.current) return;
1039
+ event.preventDefault();
1040
+ gestureActive = true;
1041
+ gestureScale = event.scale || 1;
1042
+ };
1043
+ const onGestureChange = (event) => {
1044
+ if (isPlayingRef.current) return;
1045
+ event.preventDefault();
1046
+ // Safari reports `scale` cumulatively from gesturestart (1.0 at start), so
1047
+ // zoom by the ratio against the last reading.
1048
+ const prev = gestureScale || 1;
1049
+ const next = event.scale || prev;
1050
+ gestureScale = next;
1051
+ // GestureEvent carries clientX/clientY (the pinch centroid); fall back to
1052
+ // the canvas center if a browser omits them.
1053
+ const rect = canvas.getBoundingClientRect();
1054
+ const px = Number.isFinite(event.clientX) ? event.clientX : rect.left + rect.width / 2;
1055
+ const py = Number.isFinite(event.clientY) ? event.clientY : rect.top + rect.height / 2;
1056
+ zoomByFactorAtPointer(px, py, next / prev);
1057
+ };
1058
+ const onGestureEnd = (event) => {
1059
+ if (isPlayingRef.current) return;
1060
+ event.preventDefault();
1061
+ gestureActive = false;
1062
+ };
1063
+ const opts = { passive: false };
1064
+ canvas.addEventListener('wheel', onWheel, opts);
1065
+ canvas.addEventListener('gesturestart', onGestureStart, opts);
1066
+ canvas.addEventListener('gesturechange', onGestureChange, opts);
1067
+ canvas.addEventListener('gestureend', onGestureEnd, opts);
1068
+ return () => {
1069
+ canvas.removeEventListener('wheel', onWheel, opts);
1070
+ canvas.removeEventListener('gesturestart', onGestureStart, opts);
1071
+ canvas.removeEventListener('gesturechange', onGestureChange, opts);
1072
+ canvas.removeEventListener('gestureend', onGestureEnd, opts);
1073
+ };
1074
+ }, [canvasRef, panByScreenDelta, zoomByFactorAtPointer]);
1075
+ const onPointerDown = useCallback((event) => {
1076
+ if (isPlaying || !spaceDownRef.current) return;
1077
+ event.preventDefault();
1078
+ event.currentTarget.setPointerCapture(event.pointerId);
1079
+ dragRef.current = { id: event.pointerId, lastX: event.clientX, lastY: event.clientY };
1080
+ }, [isPlaying]);
1081
+ const onPointerMove = useCallback(
1082
+ (event) => {
1083
+ const drag = dragRef.current;
1084
+ if (!drag || drag.id !== event.pointerId) return;
1085
+ const dx = event.clientX - drag.lastX;
1086
+ const dy = event.clientY - drag.lastY;
1087
+ drag.lastX = event.clientX;
1088
+ drag.lastY = event.clientY;
1089
+ panByScreenDelta(-dx, -dy);
1090
+ },
1091
+ [panByScreenDelta]
1092
+ );
613
1093
  const onPointerUp = useCallback((event) => {
614
1094
  const drag = dragRef.current;
615
1095
  if (!drag || drag.id !== event.pointerId) return;
@@ -620,7 +1100,9 @@ function usePanGesture({ canvasRef, editCameraRef, enabled }) {
620
1100
  }
621
1101
  dragRef.current = null;
622
1102
  }, []);
623
- return { onPointerDown, onPointerMove, onPointerUp };
1103
+ const isSpacePanning = useCallback(() => spaceDownRef.current, []);
1104
+ const isActive = useCallback(() => Boolean(dragRef.current), []);
1105
+ return { onPointerDown, onPointerMove, onPointerUp, isSpacePanning, isActive };
624
1106
  }
625
1107
  function handleShiftPointerDown(drag, actor, current) {
626
1108
  if (actor) {
@@ -666,6 +1148,8 @@ function handleDefaultPointerDown(drag, actor, point, current) {
666
1148
  }, LONG_PRESS_MS);
667
1149
  } else {
668
1150
  current.onSelectActorIds([]);
1151
+ drag.selectionBeforeMarquee = [];
1152
+ drag.pendingMarquee = true;
669
1153
  drag.longPressTimer = window.setTimeout(() => {
670
1154
  if (drag.movedFar) return;
671
1155
  drag.longPressFired = true;
@@ -679,7 +1163,7 @@ function finalizeMarquee(drag, current) {
679
1163
  const m = current.marqueeRef.current;
680
1164
  if (!m || !current.sceneData) return;
681
1165
  if (m.width === 0 && m.height === 0) return;
682
- const scene = makeScene(current.sceneData, behaviorClasses, current.drawings);
1166
+ const scene = makeScene(current.sceneData, behaviorClasses, current.sprites);
683
1167
  const hits = scene.actorIdsInRect(m);
684
1168
  const merged = new Set(drag.selectionBeforeMarquee);
685
1169
  for (const id of hits) merged.add(id);
@@ -721,6 +1205,17 @@ function useSelectionKeyboard(args) {
721
1205
  current.onSelectActorIds(current.sceneData.actors.map((actor) => actor.id));
722
1206
  return;
723
1207
  }
1208
+ if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'd') {
1209
+ if (!current.sceneData || current.selectedActorIds.length === 0) return;
1210
+ event.preventDefault();
1211
+ const { sceneData: next, newIds } = duplicateActors(
1212
+ current.sceneData,
1213
+ current.selectedActorIds
1214
+ );
1215
+ current.commitScene(next);
1216
+ if (newIds.length) current.onSelectActorIds(newIds);
1217
+ return;
1218
+ }
724
1219
  if (event.key === 'Delete' || event.key === 'Backspace') {
725
1220
  if (!current.sceneData || current.selectedActorIds.length === 0) return;
726
1221
  event.preventDefault();
@@ -734,14 +1229,14 @@ function useSelectionKeyboard(args) {
734
1229
  }
735
1230
  function useScenePlayLoop({
736
1231
  sceneData,
737
- drawings,
1232
+ sprites,
738
1233
  isPlaying,
739
1234
  text,
740
1235
  canvasRef,
741
1236
  runtimeRef,
742
1237
  editCameraRef,
743
- selectedActorIds,
744
1238
  marqueeRef,
1239
+ selectedActorIdsRef,
745
1240
  }) {
746
1241
  // Spin up / tear down the play-mode runtime as the user toggles play. `text`
747
1242
  // is the stable identity for `sceneData` (which is re-parsed every render);
@@ -753,35 +1248,51 @@ function useScenePlayLoop({
753
1248
  return;
754
1249
  }
755
1250
  if (!sceneData) return;
756
- runtimeRef.current = makeScene(sceneData, behaviorClasses, drawings).clone();
1251
+ runtimeRef.current = makeScene(sceneData, behaviorClasses, sprites).clone();
757
1252
  // eslint-disable-next-line react-hooks/exhaustive-deps
758
- }, [drawings, isPlaying, text, runtimeRef]);
1253
+ }, [sprites, isPlaying, text, runtimeRef]);
759
1254
  // Animation loop -- draws edit-mode previews and ticks the play-mode runtime.
760
1255
  useEffect(() => {
761
1256
  if (!sceneData || !canvasRef.current) return undefined;
762
1257
  const canvas = canvasRef.current;
763
1258
  const ctx = canvas.getContext('2d');
764
1259
  if (!ctx) return undefined;
765
- configureSceneCanvas(canvas, ctx);
1260
+ const viewport = isPlaying
1261
+ ? undefined
1262
+ : editViewportForZoom(editCameraRef.current.zoom);
1263
+ configureSceneCanvas(canvas, ctx, viewport);
766
1264
  let raf = 0;
767
1265
  let last = performance.now();
768
1266
  const frame = (now) => {
769
1267
  const dt = Math.min(0.033, (now - last) / 1000);
770
1268
  last = now;
1269
+ // Recompute each frame so the edit zoom (mutated on the camera ref by the
1270
+ // pinch gesture) takes effect without restarting the loop.
1271
+ const frameViewport = isPlaying
1272
+ ? undefined
1273
+ : editViewportForZoom(editCameraRef.current.zoom);
771
1274
  const scene = isPlaying
772
1275
  ? runtimeRef.current
773
- : makeScene(sceneData, behaviorClasses, drawings);
1276
+ : makeScene(sceneData, behaviorClasses, sprites);
774
1277
  if (scene) {
1278
+ const snap = getSnapSettings(sceneData);
775
1279
  if (isPlaying) scene.update(dt);
776
1280
  if (!isPlaying) scene.camera = { ...editCameraRef.current };
777
- configureSceneCanvas(canvas, ctx);
1281
+ configureSceneCanvas(canvas, ctx, frameViewport);
1282
+ // Read selection from the ref each frame so the grid/box gating tracks
1283
+ // the current selection without resetting this loop.
1284
+ const editSelectedActorIds = isPlaying ? [] : selectedActorIdsRef.current ?? [];
778
1285
  scene.draw(ctx, {
779
- selectedActorIds: isPlaying ? [] : selectedActorIds,
1286
+ selectedActorIds: [],
780
1287
  marquee: isPlaying ? null : marqueeRef.current,
781
- showGrid: false,
1288
+ showGrid: !isPlaying && snap.enabled && editSelectedActorIds.length > 0,
1289
+ gridSize: snap.gridSize,
782
1290
  showDebugColliders: false,
1291
+ showCropOutline: !isPlaying,
1292
+ viewport: frameViewport,
783
1293
  useCamera: true,
784
1294
  editPlaceholders: !isPlaying,
1295
+ editSelectedActorIds,
785
1296
  });
786
1297
  }
787
1298
  raf = requestAnimationFrame(frame);
@@ -789,17 +1300,16 @@ function useScenePlayLoop({
789
1300
  raf = requestAnimationFrame(frame);
790
1301
  return () => cancelAnimationFrame(raf);
791
1302
  // eslint-disable-next-line react-hooks/exhaustive-deps -- `text` proxies `sceneData` identity; resetting on every parse churns the loop and wipes runtime state.
792
- }, [drawings, isPlaying, selectedActorIds, text, canvasRef, runtimeRef, marqueeRef]);
1303
+ }, [sprites, isPlaying, text, canvasRef, runtimeRef, marqueeRef, selectedActorIdsRef]);
793
1304
  }
794
- function useScenePlayKeys(runtimeRef, setIsPlaying) {
1305
+ function useScenePlayKeys(runtimeRef) {
795
1306
  useEffect(() => {
796
1307
  const down = (event) => {
797
- if (event.key === ' ' && !isEditableTarget(event.target)) {
798
- event.preventDefault();
799
- setIsPlaying((current) => !current);
800
- return;
801
- }
802
1308
  if (!runtimeRef.current) return;
1309
+ // Space is a game input while playing — stop the browser from scrolling
1310
+ // the panel or re-triggering a focused button (the Play/Stop toggle keeps
1311
+ // focus after a click) instead of reaching gameplay.
1312
+ if (event.key === ' ') event.preventDefault();
803
1313
  runtimeRef.current.keys.add(event.key);
804
1314
  };
805
1315
  const up = (event) => {
@@ -812,7 +1322,7 @@ function useScenePlayKeys(runtimeRef, setIsPlaying) {
812
1322
  window.removeEventListener('keydown', down);
813
1323
  window.removeEventListener('keyup', up);
814
1324
  };
815
- }, [runtimeRef, setIsPlaying]);
1325
+ }, [runtimeRef]);
816
1326
  }
817
1327
  function isEditableTarget(target) {
818
1328
  return (
@@ -820,7 +1330,9 @@ function isEditableTarget(target) {
820
1330
  !!target.closest('input, textarea, select, [contenteditable="true"]')
821
1331
  );
822
1332
  }
823
- function MultiSelectInspector({ count, onDelete, onDuplicate, onDeselectAll }) {
1333
+ // Clone/Delete moved to the on-canvas SelectionOverlay toolbar; this inspector
1334
+ // now only carries the multi-select status text and "Deselect all".
1335
+ function MultiSelectInspector({ count, onDeselectAll }) {
824
1336
  return (
825
1337
  <div style={{ padding: '14px 16px', display: 'flex', flexDirection: 'column', gap: 12 }}>
826
1338
  <div style={{ fontSize: 13, opacity: 0.8 }}>
@@ -829,20 +1341,6 @@ function MultiSelectInspector({ count, onDelete, onDuplicate, onDeselectAll }) {
829
1341
  : `${count} actor${count === 1 ? '' : 's'} selected`}
830
1342
  </div>
831
1343
  <div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
832
- <button
833
- type="button"
834
- onClick={onDuplicate}
835
- disabled={count === 0}
836
- style={inspectorActionStyle(count === 0)}>
837
- <Icon name="clone" /> Duplicate
838
- </button>
839
- <button
840
- type="button"
841
- onClick={onDelete}
842
- disabled={count === 0}
843
- style={inspectorActionStyle(count === 0)}>
844
- <Icon name="trash" /> Delete
845
- </button>
846
1344
  <button type="button" onClick={onDeselectAll} style={inspectorActionStyle(false)}>
847
1345
  <Icon name="times" /> Deselect all
848
1346
  </button>
@@ -976,42 +1474,25 @@ function AddBehaviorPicker({ available, onAdd }) {
976
1474
  return (
977
1475
  <div
978
1476
  style={{
979
- padding: '12px 16px',
1477
+ height: 'var(--castle-hotbar-row-full)',
1478
+ padding: '0 16px',
1479
+ display: 'flex',
1480
+ alignItems: 'center',
980
1481
  borderBottom: '1px solid var(--castle-inspector-divider)',
981
1482
  }}>
982
- <div style={{ position: 'relative' }}>
983
- <select
984
- className={styles.select}
985
- value=""
986
- onChange={(event) => {
987
- if (event.target.value) onAdd(event.target.value);
988
- }}
989
- style={{
990
- appearance: 'none',
991
- WebkitAppearance: 'none',
992
- MozAppearance: 'none',
993
- paddingRight: 30,
994
- }}>
995
- <option value="">+ Add behavior</option>
996
- {available.map((behavior) => (
997
- <option key={behavior.behaviorName} value={behavior.behaviorName}>
998
- {behavior.behaviorName}
999
- </option>
1000
- ))}
1001
- </select>
1002
- <span
1003
- style={{
1004
- position: 'absolute',
1005
- right: 11,
1006
- top: '50%',
1007
- transform: 'translateY(-50%)',
1008
- pointerEvents: 'none',
1009
- fontSize: 12,
1010
- opacity: 0.6,
1011
- }}>
1012
- <Icon name="chevron-down" />
1013
- </span>
1014
- </div>
1483
+ <select
1484
+ className={styles.select}
1485
+ value=""
1486
+ onChange={(event) => {
1487
+ if (event.target.value) onAdd(event.target.value);
1488
+ }}>
1489
+ <option value="">+ Add behavior</option>
1490
+ {available.map((behavior) => (
1491
+ <option key={behavior.behaviorName} value={behavior.behaviorName}>
1492
+ {behavior.behaviorName}
1493
+ </option>
1494
+ ))}
1495
+ </select>
1015
1496
  </div>
1016
1497
  );
1017
1498
  }