castle-web-cli 0.4.2 → 0.4.4

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 (96) hide show
  1. package/dist/index.js +8 -2
  2. package/dist/init.js +9 -6
  3. package/package.json +1 -1
  4. package/kits/basic-2d-frozen/.prettierrc +0 -8
  5. package/kits/basic-2d-frozen/CLAUDE.md +0 -131
  6. package/kits/basic-2d-frozen/behaviors/Camera.jsx +0 -43
  7. package/kits/basic-2d-frozen/behaviors/Collider.jsx +0 -71
  8. package/kits/basic-2d-frozen/behaviors/Drawing.jsx +0 -139
  9. package/kits/basic-2d-frozen/behaviors/Layout.jsx +0 -16
  10. package/kits/basic-2d-frozen/drawings/floor.drawing +0 -70
  11. package/kits/basic-2d-frozen/editors/App.jsx +0 -152
  12. package/kits/basic-2d-frozen/editors/CodeEditor.jsx +0 -112
  13. package/kits/basic-2d-frozen/editors/DrawingEditor.jsx +0 -222
  14. package/kits/basic-2d-frozen/editors/FileBrowser.jsx +0 -143
  15. package/kits/basic-2d-frozen/editors/PlayOnly.jsx +0 -21
  16. package/kits/basic-2d-frozen/editors/SceneEditor.jsx +0 -1012
  17. package/kits/basic-2d-frozen/editors/behaviorRegistry.js +0 -24
  18. package/kits/basic-2d-frozen/editors/editorHistory.js +0 -52
  19. package/kits/basic-2d-frozen/engine/ScenePlayer.jsx +0 -83
  20. package/kits/basic-2d-frozen/engine/SceneUI.jsx +0 -67
  21. package/kits/basic-2d-frozen/engine/TouchControls.jsx +0 -136
  22. package/kits/basic-2d-frozen/engine/autoInspector.jsx +0 -51
  23. package/kits/basic-2d-frozen/engine/files.js +0 -62
  24. package/kits/basic-2d-frozen/engine/scene.js +0 -420
  25. package/kits/basic-2d-frozen/engine/ui.jsx +0 -344
  26. package/kits/basic-2d-frozen/engine/ui.module.css +0 -928
  27. package/kits/basic-2d-frozen/eslint.config.js +0 -50
  28. package/kits/basic-2d-frozen/index.html +0 -11
  29. package/kits/basic-2d-frozen/main.jsx +0 -10
  30. package/kits/basic-2d-frozen/package-lock.json +0 -2706
  31. package/kits/basic-2d-frozen/package.json +0 -41
  32. package/kits/basic-2d-frozen/scenes/main.scene +0 -108
  33. package/kits/basic-2d-frozen/vite.config.js +0 -1
  34. package/kits/rpg-2d/.prettierrc +0 -8
  35. package/kits/rpg-2d/behaviors/Camera.tsx +0 -52
  36. package/kits/rpg-2d/behaviors/Collider.tsx +0 -98
  37. package/kits/rpg-2d/behaviors/Dialog.tsx +0 -184
  38. package/kits/rpg-2d/behaviors/Drawing.tsx +0 -161
  39. package/kits/rpg-2d/behaviors/Friend.tsx +0 -45
  40. package/kits/rpg-2d/behaviors/Layout.tsx +0 -29
  41. package/kits/rpg-2d/behaviors/PlayerController.tsx +0 -255
  42. package/kits/rpg-2d/behaviors/Portal.tsx +0 -60
  43. package/kits/rpg-2d/behaviors/QuestLog.tsx +0 -90
  44. package/kits/rpg-2d/behaviors/SaveMenu.tsx +0 -123
  45. package/kits/rpg-2d/behaviors/Tilemap.tsx +0 -90
  46. package/kits/rpg-2d/drawings/bld-home.drawing +0 -8136
  47. package/kits/rpg-2d/drawings/env-crate.drawing +0 -509
  48. package/kits/rpg-2d/drawings/env-fence.drawing +0 -536
  49. package/kits/rpg-2d/drawings/env-flower-bed.drawing +0 -607
  50. package/kits/rpg-2d/drawings/env-fountain.drawing +0 -2622
  51. package/kits/rpg-2d/drawings/env-hedge.drawing +0 -601
  52. package/kits/rpg-2d/drawings/env-house-blue.drawing +0 -1
  53. package/kits/rpg-2d/drawings/env-house-green.drawing +0 -1
  54. package/kits/rpg-2d/drawings/env-tree-oak.drawing +0 -1540
  55. package/kits/rpg-2d/drawings/env-tree-pine.drawing +0 -1315
  56. package/kits/rpg-2d/drawings/floor.drawing +0 -70
  57. package/kits/rpg-2d/drawings/fx-sparkle.drawing +0 -926
  58. package/kits/rpg-2d/drawings/npc-juno-idle-down.drawing +0 -1099
  59. package/kits/rpg-2d/drawings/npc-juno-walk-down.drawing +0 -4177
  60. package/kits/rpg-2d/drawings/npc-opal-idle-down.drawing +0 -1099
  61. package/kits/rpg-2d/drawings/npc-opal-walk-down.drawing +0 -4177
  62. package/kits/rpg-2d/drawings/player-idle-down.drawing +0 -1070
  63. package/kits/rpg-2d/drawings/player-idle-left.drawing +0 -1070
  64. package/kits/rpg-2d/drawings/player-idle-right.drawing +0 -1070
  65. package/kits/rpg-2d/drawings/player-idle-up.drawing +0 -1070
  66. package/kits/rpg-2d/drawings/player-walk-down.drawing +0 -4148
  67. package/kits/rpg-2d/drawings/player-walk-left.drawing +0 -4148
  68. package/kits/rpg-2d/drawings/player-walk-right.drawing +0 -4148
  69. package/kits/rpg-2d/drawings/player-walk-up.drawing +0 -4148
  70. package/kits/rpg-2d/editors/App.tsx +0 -163
  71. package/kits/rpg-2d/editors/CodeEditor.tsx +0 -120
  72. package/kits/rpg-2d/editors/DrawingEditor.tsx +0 -278
  73. package/kits/rpg-2d/editors/FileBrowser.tsx +0 -191
  74. package/kits/rpg-2d/editors/PlayOnly.tsx +0 -26
  75. package/kits/rpg-2d/editors/SceneEditor.tsx +0 -1093
  76. package/kits/rpg-2d/editors/behaviorRegistry.ts +0 -33
  77. package/kits/rpg-2d/editors/editorHistory.ts +0 -75
  78. package/kits/rpg-2d/editors/editorProps.ts +0 -10
  79. package/kits/rpg-2d/engine/ScenePlayer.tsx +0 -130
  80. package/kits/rpg-2d/engine/SceneUI.tsx +0 -74
  81. package/kits/rpg-2d/engine/TouchControls.tsx +0 -157
  82. package/kits/rpg-2d/engine/autoInspector.tsx +0 -111
  83. package/kits/rpg-2d/engine/drawing.ts +0 -81
  84. package/kits/rpg-2d/engine/files.ts +0 -215
  85. package/kits/rpg-2d/engine/scene.ts +0 -484
  86. package/kits/rpg-2d/engine/ui.module.css +0 -928
  87. package/kits/rpg-2d/engine/ui.tsx +0 -483
  88. package/kits/rpg-2d/eslint.config.js +0 -46
  89. package/kits/rpg-2d/index.html +0 -11
  90. package/kits/rpg-2d/main.tsx +0 -14
  91. package/kits/rpg-2d/package-lock.json +0 -3149
  92. package/kits/rpg-2d/package.json +0 -46
  93. package/kits/rpg-2d/scenes/main.scene +0 -203
  94. package/kits/rpg-2d/tsconfig.json +0 -17
  95. package/kits/rpg-2d/vite-env.d.ts +0 -7
  96. package/kits/rpg-2d/vite.config.js +0 -1
@@ -1,1093 +0,0 @@
1
- import React, { useCallback, useEffect, useRef, useState } from 'react';
2
- import type { PointerEvent } from 'react';
3
- import { basename, formatJson, parseJsonFile } from '../engine/files';
4
- import { TouchControls } from '../engine/TouchControls';
5
- import type { DrawingData, FileMap } from '../engine/files';
6
- import {
7
- addActor,
8
- configureSceneCanvas,
9
- duplicateActors,
10
- makeScene,
11
- moveActors,
12
- removeActorComponent,
13
- removeActors,
14
- screenToCard,
15
- setActorComponent,
16
- SceneRuntime,
17
- } from '../engine/scene';
18
- import type { SelectionRect } from '../engine/scene';
19
- import {
20
- cx,
21
- EditorBody,
22
- EditorHeader,
23
- Icon,
24
- IconButton,
25
- SheetGrabHandle,
26
- styles,
27
- useMobileSheet,
28
- } from '../engine/ui';
29
- import type { SheetSnap } from '../engine/ui';
30
- import { AutoInspector } from '../engine/autoInspector';
31
- import { SceneUI } from '../engine/SceneUI';
32
- import type { ActorData, BehaviorClass, ComponentProps, SceneData } from '../engine/scene';
33
- import { behaviorClasses, findBehaviorClass } from './behaviorRegistry';
34
- import type { FileEditorProps } from './editorProps';
35
- import { useEditHistory } from './editorHistory';
36
-
37
- type InspectorComponent = (props: {
38
- actor: ActorData;
39
- component: ComponentProps;
40
- files: FileMap;
41
- setComponent: (nextProps: Partial<ComponentProps>) => void;
42
- }) => React.ReactNode;
43
-
44
- interface SceneEditorProps extends FileEditorProps {
45
- files: FileMap;
46
- drawings: Record<string, DrawingData>;
47
- selectedActorIds: string[];
48
- onSelectActorIds: (ids: string[]) => void;
49
- multiSelectMode: boolean;
50
- onSetMultiSelectMode: (active: boolean) => void;
51
- }
52
-
53
- const LONG_PRESS_MS = 500;
54
- const DRAG_THRESHOLD = 4;
55
-
56
- type DragKind = 'idle' | 'move' | 'marquee';
57
-
58
- interface DragState {
59
- pointerId: number;
60
- startPoint: { x: number; y: number };
61
- lastPoint: { x: number; y: number };
62
- startedOnActorId: string | null;
63
- modeAtStart: boolean;
64
- movedFar: boolean;
65
- longPressTimer: number | null;
66
- longPressFired: boolean;
67
- kind: DragKind;
68
- // When true, a pointer-move past the drag threshold promotes kind to
69
- // 'marquee'. Keeps a stationary press/release reaching the tap handler
70
- // instead of being eaten by finalizeMarquee as a zero-area release.
71
- pendingMarquee: boolean;
72
- movingActorIds: string[];
73
- recordedMoveSnapshot: boolean;
74
- selectionBeforeMarquee: string[];
75
- }
76
-
77
- // eslint-disable-next-line max-lines-per-function
78
- export function SceneEditor({
79
- path,
80
- text,
81
- files,
82
- drawings,
83
- onChange,
84
- onToggleFiles,
85
- filesOpen,
86
- selectedActorIds,
87
- onSelectActorIds,
88
- multiSelectMode,
89
- onSetMultiSelectMode,
90
- }: SceneEditorProps) {
91
- const canvasRef = useRef<HTMLCanvasElement | null>(null);
92
- const runtimeRef = useRef<SceneRuntime | null>(null);
93
- const marqueeRef = useRef<SelectionRect | null>(null);
94
- const editCameraRef = useRef<{ x: number; y: number }>({ x: 0, y: 0 });
95
- const selectedActorIdsRef = useRef<string[]>(selectedActorIds);
96
- selectedActorIdsRef.current = selectedActorIds;
97
- const [isPlaying, setIsPlaying] = useState(false);
98
- const [panMode, setPanMode] = useState(false);
99
- const history = useEditHistory(text, onChange);
100
- const showMulti = selectedActorIds.length > 1 || multiSelectMode;
101
- const inspectorSheet = useSelectionInspectorSheet(selectedActorIds.length > 0 || multiSelectMode);
102
- const { value: sceneData, error } = parseJsonFile<SceneData>(path, text);
103
- const getRuntimeKeys = useCallback(() => runtimeRef.current?.keys ?? null, []);
104
- const getRuntime = useCallback(() => runtimeRef.current, []);
105
-
106
- useScenePlayLoop({
107
- sceneData,
108
- drawings,
109
- isPlaying,
110
- text,
111
- canvasRef,
112
- runtimeRef,
113
- editCameraRef,
114
- selectedActorIds,
115
- marqueeRef,
116
- });
117
- useScenePlayKeys(runtimeRef, setIsPlaying);
118
-
119
- const gesture = useSelectionGesture({
120
- canvasRef,
121
- sceneData,
122
- drawings,
123
- isPlaying,
124
- selectedActorIds,
125
- onSelectActorIds,
126
- multiSelectMode,
127
- onSetMultiSelectMode,
128
- marqueeRef,
129
- editCameraRef,
130
- applyScene: (next) => onChange(formatJson(next)),
131
- recordSceneSnapshot: history.recordSnapshot,
132
- });
133
- const playPointer = usePlayPointerGesture({ canvasRef, runtimeRef });
134
- const panGesture = usePanGesture({ canvasRef, editCameraRef, enabled: panMode && !isPlaying });
135
-
136
- useSelectionKeyboard({
137
- sceneData,
138
- selectedActorIds,
139
- onSelectActorIds,
140
- multiSelectMode,
141
- onSetMultiSelectMode,
142
- isPlaying,
143
- commitScene: (next) => history.commit(formatJson(next)),
144
- });
145
-
146
- if (!sceneData) {
147
- return (
148
- <>
149
- <EditorHeader title={basename(path)} onToggleFiles={onToggleFiles} filesOpen={filesOpen} />
150
- <EditorBody>
151
- <div className={styles.inspector}>{error}</div>
152
- </EditorBody>
153
- </>
154
- );
155
- }
156
- // Re-bind under a non-nullable type so the handlers below close over the
157
- // narrowed value -- TS won't propagate the early-return narrowing into
158
- // closures captured by inner functions.
159
- const data: SceneData = sceneData;
160
-
161
- const selectedActor =
162
- data.actors.find((actor) => actor.id === selectedActorIds[0]) ?? data.actors[0];
163
-
164
- const actions = makeSceneActions({
165
- sceneData: data,
166
- commit: history.commit,
167
- selectedActorIds,
168
- onSelectActorIds,
169
- });
170
-
171
- function applyScene(next: SceneData): void {
172
- onChange(formatJson(next));
173
- }
174
-
175
- function handleAddActor(): void {
176
- const { sceneData: next, newId } = addActor(data, files, drawings);
177
- history.recordSnapshot();
178
- applyScene(next);
179
- onSelectActorIds([newId]);
180
- }
181
-
182
- function handleDuplicateSelection(): void {
183
- if (selectedActorIds.length === 0) return;
184
- const { sceneData: next, newIds } = duplicateActors(data, selectedActorIds);
185
- history.recordSnapshot();
186
- applyScene(next);
187
- if (newIds.length) onSelectActorIds(newIds);
188
- }
189
-
190
- function handleRemoveSelection(): void {
191
- if (selectedActorIds.length === 0) return;
192
- history.recordSnapshot();
193
- applyScene(removeActors(data, selectedActorIds));
194
- onSelectActorIds([]);
195
- }
196
-
197
- const hasSelection = selectedActorIds.length > 0;
198
- // Playback group: centered. Actor-tool group: right-aligned. Empty span
199
- // on the left of the 3-column grid keeps the playback group visually
200
- // centered. Same layout as basic-2d.
201
- const playbackButtons = (
202
- <>
203
- <IconButton
204
- icon={isPlaying ? 'stop' : 'play'}
205
- label={isPlaying ? 'Stop' : 'Play'}
206
- active={isPlaying}
207
- variant={isPlaying ? 'primary' : ''}
208
- onClick={() => setIsPlaying(!isPlaying)}
209
- />
210
- <IconButton
211
- icon="undo"
212
- label="Undo"
213
- onClick={history.undo}
214
- disabled={!history.canUndo || isPlaying}
215
- />
216
- <IconButton
217
- icon="redo"
218
- label="Redo"
219
- onClick={history.redo}
220
- disabled={!history.canRedo || isPlaying}
221
- />
222
- <span style={{ width: 16 }} aria-hidden="true" />
223
- <IconButton
224
- icon="camera"
225
- label="Pan camera"
226
- active={panMode}
227
- onClick={() => setPanMode((value) => !value)}
228
- disabled={isPlaying}
229
- />
230
- </>
231
- );
232
- const actorButtons = (
233
- <>
234
- <IconButton icon="plus" label="Add actor" onClick={handleAddActor} disabled={isPlaying} />
235
- <IconButton
236
- icon="clone"
237
- label="Duplicate"
238
- onClick={handleDuplicateSelection}
239
- disabled={!hasSelection || isPlaying}
240
- />
241
- <IconButton
242
- icon="trash"
243
- label="Remove"
244
- onClick={handleRemoveSelection}
245
- disabled={!hasSelection || isPlaying}
246
- />
247
- </>
248
- );
249
-
250
- return (
251
- <>
252
- <EditorHeader
253
- title={sceneData.name ?? basename(path)}
254
- subtitle={isPlaying ? 'WASD / arrows' : `${sceneData.actors.length} actors`}
255
- right={
256
- <span className={styles.mobileOnly}>
257
- {playbackButtons}
258
- {actorButtons}
259
- </span>
260
- }
261
- onToggleFiles={onToggleFiles}
262
- filesOpen={filesOpen}
263
- />
264
- <EditorBody>
265
- <div className={styles.sceneWorkspace}>
266
- <div className={styles.sceneTools}>
267
- <span aria-hidden="true" />
268
- <div className={styles.sceneToolsGroup}>{playbackButtons}</div>
269
- <div className={styles.sceneToolsGroup}>{actorButtons}</div>
270
- </div>
271
- <div className={styles.stageWrap}>
272
- <div className={styles.stageCard}>
273
- <canvas
274
- ref={canvasRef}
275
- className={styles.stageCanvas}
276
- onPointerDown={
277
- isPlaying
278
- ? playPointer.onPointerDown
279
- : panMode
280
- ? panGesture.onPointerDown
281
- : gesture.onPointerDown
282
- }
283
- onPointerMove={
284
- isPlaying
285
- ? playPointer.onPointerMove
286
- : panMode
287
- ? panGesture.onPointerMove
288
- : gesture.onPointerMove
289
- }
290
- onPointerUp={
291
- isPlaying
292
- ? playPointer.onPointerUp
293
- : panMode
294
- ? panGesture.onPointerUp
295
- : gesture.onPointerUp
296
- }
297
- onPointerCancel={
298
- isPlaying
299
- ? playPointer.onPointerUp
300
- : panMode
301
- ? panGesture.onPointerUp
302
- : gesture.onPointerUp
303
- }
304
- />
305
- {isPlaying && <SceneUI getRuntime={getRuntime} />}
306
- </div>
307
- </div>
308
- <aside {...inspectorSheet.rootProps}>
309
- <div {...inspectorSheet.grabProps}>
310
- <SheetGrabHandle
311
- label={showMulti ? 'Multi-select' : 'Inspector'}
312
- hint={
313
- showMulti
314
- ? `${selectedActorIds.length} actor${selectedActorIds.length === 1 ? '' : 's'} selected`
315
- : selectedActor
316
- ? `actor ${selectedActor.id}`
317
- : 'tap an actor'
318
- }
319
- />
320
- </div>
321
- <div className={cx(styles.sheetBody, styles.inspectorBody)}>
322
- {showMulti ? (
323
- <MultiSelectInspector
324
- count={selectedActorIds.length}
325
- onDelete={actions.deleteSelection}
326
- onDuplicate={actions.duplicateSelection}
327
- onDeselectAll={() => {
328
- onSelectActorIds([]);
329
- onSetMultiSelectMode(false);
330
- }}
331
- />
332
- ) : (
333
- <SceneInspector
334
- selectedActor={selectedActor}
335
- files={files}
336
- onSetComponent={actions.updateComponent}
337
- onAddBehavior={actions.addBehavior}
338
- onRemoveBehavior={actions.removeBehavior}
339
- />
340
- )}
341
- </div>
342
- </aside>
343
- </div>
344
- </EditorBody>
345
- <TouchControls getKeys={getRuntimeKeys} visible={isPlaying} />
346
- </>
347
- );
348
- }
349
-
350
- interface SceneActions {
351
- updateComponent: (
352
- actorId: string,
353
- behaviorName: string,
354
- nextProps: Partial<ComponentProps>
355
- ) => void;
356
- addBehavior: (actorId: string, behaviorName: string) => void;
357
- removeBehavior: (actorId: string, behaviorName: string) => void;
358
- deleteSelection: () => void;
359
- duplicateSelection: () => void;
360
- }
361
-
362
- function makeSceneActions({
363
- sceneData,
364
- commit,
365
- selectedActorIds,
366
- onSelectActorIds,
367
- }: {
368
- sceneData: SceneData;
369
- commit: (nextText: string) => void;
370
- selectedActorIds: string[];
371
- onSelectActorIds: (ids: string[]) => void;
372
- }): SceneActions {
373
- function commitScene(next: SceneData): void {
374
- commit(formatJson(next));
375
- }
376
- return {
377
- updateComponent: (actorId, behaviorName, nextProps) =>
378
- commitScene(setActorComponent(sceneData, actorId, behaviorName, nextProps)),
379
- addBehavior: (actorId, behaviorName) => {
380
- const Behavior = findBehaviorClass(behaviorName);
381
- if (!Behavior) return;
382
- commitScene(
383
- setActorComponent(sceneData, actorId, behaviorName, { ...Behavior.defaultProps })
384
- );
385
- },
386
- removeBehavior: (actorId, behaviorName) =>
387
- commitScene(removeActorComponent(sceneData, actorId, behaviorName)),
388
- deleteSelection: () => {
389
- if (selectedActorIds.length === 0) return;
390
- commitScene(removeActors(sceneData, selectedActorIds));
391
- onSelectActorIds([]);
392
- },
393
- duplicateSelection: () => {
394
- if (selectedActorIds.length === 0) return;
395
- const { sceneData: next, newIds } = duplicateActors(sceneData, selectedActorIds);
396
- commitScene(next);
397
- if (newIds.length) onSelectActorIds(newIds);
398
- },
399
- };
400
- }
401
-
402
- function useSelectionInspectorSheet(open: boolean): ReturnType<typeof useMobileSheet> {
403
- const [snap, setSnap] = useState<'high' | 'low'>('high');
404
- // Inspector visibility is driven by selection / multi-mode. Snap state
405
- // persists across selections so switching keeps the user's chosen snap.
406
- const effectiveSnap: SheetSnap = open ? snap : 'hidden';
407
- return useMobileSheet({
408
- snap: effectiveSnap,
409
- baseClassName: styles.inspector,
410
- onTransition: (direction) => {
411
- if (!open) return;
412
- if (direction === 'tap') setSnap((prev) => (prev === 'high' ? 'low' : 'high'));
413
- else if (direction === 'down') setSnap('low');
414
- else if (direction === 'up') setSnap('high');
415
- },
416
- });
417
- }
418
-
419
- interface SelectionGestureArgs {
420
- canvasRef: React.RefObject<HTMLCanvasElement | null>;
421
- sceneData: SceneData | null;
422
- drawings: Record<string, DrawingData>;
423
- isPlaying: boolean;
424
- selectedActorIds: string[];
425
- onSelectActorIds: (ids: string[]) => void;
426
- multiSelectMode: boolean;
427
- onSetMultiSelectMode: (active: boolean) => void;
428
- marqueeRef: React.RefObject<SelectionRect | null>;
429
- editCameraRef: React.RefObject<{ x: number; y: number }>;
430
- applyScene: (next: SceneData) => void;
431
- recordSceneSnapshot: () => void;
432
- }
433
-
434
- function useSelectionGesture(args: SelectionGestureArgs): {
435
- onPointerDown: (event: PointerEvent<HTMLCanvasElement>) => void;
436
- onPointerMove: (event: PointerEvent<HTMLCanvasElement>) => void;
437
- onPointerUp: (event: PointerEvent<HTMLCanvasElement>) => void;
438
- } {
439
- const dragRef = useRef<DragState | null>(null);
440
- // Pin args in a ref so the timer callback reads the latest values without
441
- // re-creating the handlers each render.
442
- const argsRef = useRef(args);
443
- argsRef.current = args;
444
-
445
- const onPointerDown = useCallback((event: PointerEvent<HTMLCanvasElement>) => {
446
- const current = argsRef.current;
447
- if (current.isPlaying || !current.canvasRef.current || !current.sceneData) return;
448
- const raw = screenToCard(current.canvasRef.current, event.clientX, event.clientY);
449
- const cam = current.editCameraRef.current;
450
- const point = { x: raw.x + cam.x, y: raw.y + cam.y };
451
- current.canvasRef.current.setPointerCapture(event.pointerId);
452
- const scene = makeScene(current.sceneData, behaviorClasses, current.drawings);
453
- const actor = scene.actorAt(point.x, point.y);
454
- const drag: DragState = {
455
- pointerId: event.pointerId,
456
- startPoint: point,
457
- lastPoint: point,
458
- startedOnActorId: actor?.id ?? null,
459
- modeAtStart: current.multiSelectMode,
460
- movedFar: false,
461
- longPressTimer: null,
462
- longPressFired: false,
463
- kind: 'idle',
464
- pendingMarquee: false,
465
- movingActorIds: [],
466
- recordedMoveSnapshot: false,
467
- selectionBeforeMarquee: [...current.selectedActorIds],
468
- };
469
- if (event.shiftKey) {
470
- handleShiftPointerDown(drag, actor, current);
471
- } else if (current.multiSelectMode) {
472
- handleModePointerDown(drag, actor, current);
473
- } else {
474
- handleDefaultPointerDown(drag, actor, point, current);
475
- }
476
- dragRef.current = drag;
477
- }, []);
478
-
479
- const onPointerMove = useCallback((event: PointerEvent<HTMLCanvasElement>) => {
480
- const drag = dragRef.current;
481
- const current = argsRef.current;
482
- if (!drag || drag.pointerId !== event.pointerId) return;
483
- if (!current.canvasRef.current || !current.sceneData) return;
484
- const raw = screenToCard(current.canvasRef.current, event.clientX, event.clientY);
485
- const cam = current.editCameraRef.current;
486
- const point = { x: raw.x + cam.x, y: raw.y + cam.y };
487
- const dxTotal = point.x - drag.startPoint.x;
488
- const dyTotal = point.y - drag.startPoint.y;
489
- if (!drag.movedFar && Math.hypot(dxTotal, dyTotal) > DRAG_THRESHOLD) {
490
- drag.movedFar = true;
491
- if (drag.longPressTimer !== null) {
492
- window.clearTimeout(drag.longPressTimer);
493
- drag.longPressTimer = null;
494
- }
495
- if (drag.pendingMarquee && drag.kind === 'idle') drag.kind = 'marquee';
496
- }
497
- if (drag.kind === 'move' && drag.movedFar) {
498
- const dx = point.x - drag.lastPoint.x;
499
- const dy = point.y - drag.lastPoint.y;
500
- drag.lastPoint = point;
501
- if (!drag.recordedMoveSnapshot) {
502
- current.recordSceneSnapshot();
503
- drag.recordedMoveSnapshot = true;
504
- }
505
- current.applyScene(moveActors(current.sceneData, drag.movingActorIds, dx, dy));
506
- } else if (drag.kind === 'marquee') {
507
- drag.lastPoint = point;
508
- current.marqueeRef.current = {
509
- x: Math.min(drag.startPoint.x, point.x),
510
- y: Math.min(drag.startPoint.y, point.y),
511
- width: Math.abs(point.x - drag.startPoint.x),
512
- height: Math.abs(point.y - drag.startPoint.y),
513
- };
514
- }
515
- }, []);
516
-
517
- const onPointerUp = useCallback((event: PointerEvent<HTMLCanvasElement>) => {
518
- const drag = dragRef.current;
519
- const current = argsRef.current;
520
- if (!drag || drag.pointerId !== event.pointerId) return;
521
- if (drag.longPressTimer !== null) {
522
- window.clearTimeout(drag.longPressTimer);
523
- drag.longPressTimer = null;
524
- }
525
- if (drag.kind === 'marquee') {
526
- finalizeMarquee(drag, current);
527
- } else if (drag.kind === 'idle' && !drag.movedFar && !drag.longPressFired) {
528
- handleTap(drag, current);
529
- }
530
- current.marqueeRef.current = null;
531
- dragRef.current = null;
532
- }, []);
533
-
534
- return { onPointerDown, onPointerMove, onPointerUp };
535
- }
536
-
537
- function usePlayPointerGesture({
538
- canvasRef,
539
- runtimeRef,
540
- }: {
541
- canvasRef: React.RefObject<HTMLCanvasElement | null>;
542
- runtimeRef: React.RefObject<SceneRuntime | null>;
543
- }): {
544
- onPointerDown: (event: PointerEvent<HTMLCanvasElement>) => void;
545
- onPointerMove: (event: PointerEvent<HTMLCanvasElement>) => void;
546
- onPointerUp: (event: PointerEvent<HTMLCanvasElement>) => void;
547
- } {
548
- const setPointer = useCallback(
549
- (event: PointerEvent<HTMLCanvasElement>, down?: boolean): void => {
550
- const canvas = canvasRef.current;
551
- const runtime = runtimeRef.current;
552
- if (!canvas || !runtime) return;
553
- runtime.setPointerFromScreen(canvas, event.clientX, event.clientY, down);
554
- },
555
- [canvasRef, runtimeRef]
556
- );
557
-
558
- const onPointerDown = useCallback(
559
- (event: PointerEvent<HTMLCanvasElement>): void => {
560
- event.currentTarget.setPointerCapture(event.pointerId);
561
- setPointer(event, true);
562
- },
563
- [setPointer]
564
- );
565
-
566
- const onPointerMove = useCallback(
567
- (event: PointerEvent<HTMLCanvasElement>): void => {
568
- setPointer(event);
569
- },
570
- [setPointer]
571
- );
572
-
573
- const onPointerUp = useCallback(
574
- (event: PointerEvent<HTMLCanvasElement>): void => {
575
- setPointer(event, false);
576
- try {
577
- event.currentTarget.releasePointerCapture(event.pointerId);
578
- } catch {
579
- // Pointer capture may already be gone after cancel/blur.
580
- }
581
- },
582
- [setPointer]
583
- );
584
-
585
- return { onPointerDown, onPointerMove, onPointerUp };
586
- }
587
-
588
- function usePanGesture({
589
- canvasRef,
590
- editCameraRef,
591
- enabled,
592
- }: {
593
- canvasRef: React.RefObject<HTMLCanvasElement | null>;
594
- editCameraRef: React.RefObject<{ x: number; y: number }>;
595
- enabled: boolean;
596
- }): {
597
- onPointerDown: (event: PointerEvent<HTMLCanvasElement>) => void;
598
- onPointerMove: (event: PointerEvent<HTMLCanvasElement>) => void;
599
- onPointerUp: (event: PointerEvent<HTMLCanvasElement>) => void;
600
- } {
601
- const dragRef = useRef<{ id: number; last: { x: number; y: number } } | null>(null);
602
-
603
- const onPointerDown = useCallback(
604
- (event: PointerEvent<HTMLCanvasElement>): void => {
605
- if (!enabled) return;
606
- const canvas = canvasRef.current;
607
- if (!canvas) return;
608
- const point = screenToCard(canvas, event.clientX, event.clientY);
609
- event.currentTarget.setPointerCapture(event.pointerId);
610
- dragRef.current = { id: event.pointerId, last: point };
611
- },
612
- [canvasRef, enabled]
613
- );
614
-
615
- const onPointerMove = useCallback(
616
- (event: PointerEvent<HTMLCanvasElement>): void => {
617
- const drag = dragRef.current;
618
- if (!drag || drag.id !== event.pointerId) return;
619
- const canvas = canvasRef.current;
620
- if (!canvas) return;
621
- const point = screenToCard(canvas, event.clientX, event.clientY);
622
- const dx = point.x - drag.last.x;
623
- const dy = point.y - drag.last.y;
624
- drag.last = point;
625
- editCameraRef.current = {
626
- x: editCameraRef.current.x - dx,
627
- y: editCameraRef.current.y - dy,
628
- };
629
- },
630
- [canvasRef, editCameraRef]
631
- );
632
-
633
- const onPointerUp = useCallback(
634
- (event: PointerEvent<HTMLCanvasElement>): void => {
635
- const drag = dragRef.current;
636
- if (!drag || drag.id !== event.pointerId) return;
637
- try {
638
- event.currentTarget.releasePointerCapture(event.pointerId);
639
- } catch {
640
- // pointer capture may already be gone
641
- }
642
- dragRef.current = null;
643
- },
644
- []
645
- );
646
-
647
- return { onPointerDown, onPointerMove, onPointerUp };
648
- }
649
-
650
- function handleShiftPointerDown(
651
- drag: DragState,
652
- actor: ActorData | null,
653
- current: SelectionGestureArgs
654
- ): void {
655
- if (actor) {
656
- const wasSelected = current.selectedActorIds.includes(actor.id);
657
- const nextIds = wasSelected
658
- ? current.selectedActorIds.filter((id) => id !== actor.id)
659
- : [...current.selectedActorIds, actor.id];
660
- current.onSelectActorIds(nextIds);
661
- if (!wasSelected) {
662
- drag.kind = 'move';
663
- drag.movingActorIds = nextIds;
664
- }
665
- } else {
666
- drag.pendingMarquee = true;
667
- }
668
- }
669
-
670
- function handleModePointerDown(
671
- drag: DragState,
672
- actor: ActorData | null,
673
- current: SelectionGestureArgs
674
- ): void {
675
- if (actor) {
676
- if (current.selectedActorIds.includes(actor.id)) {
677
- drag.kind = 'move';
678
- drag.movingActorIds = [...current.selectedActorIds];
679
- }
680
- // else: leave kind='idle' so pointerup triggers the tap-toggle path.
681
- } else {
682
- // Defer marquee until movement, so a stationary press/release reaches
683
- // the tap handler (which exits multi mode on empty tap).
684
- drag.pendingMarquee = true;
685
- }
686
- }
687
-
688
- function handleDefaultPointerDown(
689
- drag: DragState,
690
- actor: ActorData | null,
691
- point: { x: number; y: number },
692
- current: SelectionGestureArgs
693
- ): void {
694
- if (actor) {
695
- if (!current.selectedActorIds.includes(actor.id)) {
696
- current.onSelectActorIds([actor.id]);
697
- drag.movingActorIds = [actor.id];
698
- } else {
699
- drag.movingActorIds = [...current.selectedActorIds];
700
- }
701
- drag.kind = 'move';
702
- drag.longPressTimer = window.setTimeout(() => {
703
- if (drag.movedFar) return;
704
- drag.longPressFired = true;
705
- current.onSetMultiSelectMode(true);
706
- }, LONG_PRESS_MS);
707
- } else {
708
- current.onSelectActorIds([]);
709
- drag.longPressTimer = window.setTimeout(() => {
710
- if (drag.movedFar) return;
711
- drag.longPressFired = true;
712
- current.onSetMultiSelectMode(true);
713
- drag.kind = 'marquee';
714
- current.marqueeRef.current = { x: point.x, y: point.y, width: 0, height: 0 };
715
- }, LONG_PRESS_MS);
716
- }
717
- }
718
-
719
- function finalizeMarquee(drag: DragState, current: SelectionGestureArgs): void {
720
- const m = current.marqueeRef.current;
721
- if (!m || !current.sceneData) return;
722
- if (m.width === 0 && m.height === 0) return;
723
- const scene = makeScene(current.sceneData, behaviorClasses, current.drawings);
724
- const hits = scene.actorIdsInRect(m);
725
- const merged = new Set(drag.selectionBeforeMarquee);
726
- for (const id of hits) merged.add(id);
727
- current.onSelectActorIds([...merged]);
728
- }
729
-
730
- function handleTap(drag: DragState, current: SelectionGestureArgs): void {
731
- if (!drag.modeAtStart) return;
732
- if (drag.startedOnActorId !== null) {
733
- const id = drag.startedOnActorId;
734
- const wasSelected = current.selectedActorIds.includes(id);
735
- current.onSelectActorIds(
736
- wasSelected
737
- ? current.selectedActorIds.filter((x) => x !== id)
738
- : [...current.selectedActorIds, id]
739
- );
740
- } else {
741
- current.onSetMultiSelectMode(false);
742
- current.onSelectActorIds([]);
743
- }
744
- }
745
-
746
- interface SelectionKeyboardArgs {
747
- sceneData: SceneData | null;
748
- selectedActorIds: string[];
749
- onSelectActorIds: (ids: string[]) => void;
750
- multiSelectMode: boolean;
751
- onSetMultiSelectMode: (active: boolean) => void;
752
- isPlaying: boolean;
753
- commitScene: (next: SceneData) => void;
754
- }
755
-
756
- function useSelectionKeyboard(args: SelectionKeyboardArgs): void {
757
- const ref = useRef(args);
758
- ref.current = args;
759
- useEffect(() => {
760
- function onKeyDown(event: KeyboardEvent): void {
761
- const current = ref.current;
762
- if (current.isPlaying || isEditableTarget(event.target)) return;
763
- if (event.key === 'Escape') {
764
- if (current.selectedActorIds.length || current.multiSelectMode) {
765
- event.preventDefault();
766
- current.onSelectActorIds([]);
767
- current.onSetMultiSelectMode(false);
768
- }
769
- return;
770
- }
771
- if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'a') {
772
- if (!current.sceneData) return;
773
- event.preventDefault();
774
- current.onSelectActorIds(current.sceneData.actors.map((actor) => actor.id));
775
- return;
776
- }
777
- if (event.key === 'Delete' || event.key === 'Backspace') {
778
- if (!current.sceneData || current.selectedActorIds.length === 0) return;
779
- event.preventDefault();
780
- current.commitScene(removeActors(current.sceneData, current.selectedActorIds));
781
- current.onSelectActorIds([]);
782
- }
783
- }
784
- window.addEventListener('keydown', onKeyDown);
785
- return () => window.removeEventListener('keydown', onKeyDown);
786
- }, []);
787
- }
788
-
789
- function useScenePlayLoop({
790
- sceneData,
791
- drawings,
792
- isPlaying,
793
- text,
794
- canvasRef,
795
- runtimeRef,
796
- editCameraRef,
797
- selectedActorIds,
798
- marqueeRef,
799
- }: {
800
- sceneData: SceneData | null;
801
- drawings: Record<string, DrawingData>;
802
- isPlaying: boolean;
803
- text: string;
804
- canvasRef: React.RefObject<HTMLCanvasElement | null>;
805
- runtimeRef: React.RefObject<SceneRuntime | null>;
806
- editCameraRef: React.RefObject<{ x: number; y: number }>;
807
- selectedActorIds: string[];
808
- marqueeRef: React.RefObject<SelectionRect | null>;
809
- }): void {
810
- // Spin up / tear down the play-mode runtime as the user toggles play.
811
- useEffect(() => {
812
- if (!sceneData) return;
813
- if (!isPlaying) {
814
- runtimeRef.current = null;
815
- return;
816
- }
817
- runtimeRef.current = makeScene(sceneData, behaviorClasses, drawings).clone();
818
- }, [drawings, isPlaying, sceneData, text, runtimeRef]);
819
-
820
- // Animation loop -- draws edit-mode previews and ticks the play-mode runtime.
821
- useEffect(() => {
822
- if (!sceneData || !canvasRef.current) return undefined;
823
- const canvas = canvasRef.current;
824
- const ctx = canvas.getContext('2d');
825
- if (!ctx) return undefined;
826
- configureSceneCanvas(canvas, ctx);
827
- let raf = 0;
828
- let last = performance.now();
829
- const frame = (now: number): void => {
830
- const dt = Math.min(0.033, (now - last) / 1000);
831
- last = now;
832
- const scene = isPlaying
833
- ? runtimeRef.current
834
- : makeScene(sceneData, behaviorClasses, drawings);
835
- if (scene) {
836
- if (isPlaying) scene.update(dt);
837
- if (!isPlaying) scene.camera = { ...editCameraRef.current };
838
- configureSceneCanvas(canvas, ctx);
839
- scene.draw(ctx, {
840
- selectedActorIds: isPlaying ? [] : selectedActorIds,
841
- marquee: isPlaying ? null : marqueeRef.current,
842
- showGrid: false,
843
- showDebugColliders: false,
844
- useCamera: true,
845
- editPlaceholders: !isPlaying,
846
- });
847
- }
848
- raf = requestAnimationFrame(frame);
849
- };
850
- raf = requestAnimationFrame(frame);
851
- return () => cancelAnimationFrame(raf);
852
- }, [drawings, isPlaying, sceneData, selectedActorIds, text, canvasRef, runtimeRef, marqueeRef]);
853
- }
854
-
855
- function useScenePlayKeys(
856
- runtimeRef: React.RefObject<SceneRuntime | null>,
857
- setIsPlaying: React.Dispatch<React.SetStateAction<boolean>>
858
- ): void {
859
- useEffect(() => {
860
- const down = (event: KeyboardEvent): void => {
861
- if (event.key === ' ' && !isEditableTarget(event.target)) {
862
- event.preventDefault();
863
- setIsPlaying((current) => !current);
864
- return;
865
- }
866
- if (!runtimeRef.current) return;
867
- runtimeRef.current.keys.add(event.key);
868
- };
869
- const up = (event: KeyboardEvent): void => {
870
- if (!runtimeRef.current) return;
871
- runtimeRef.current.keys.delete(event.key);
872
- };
873
- window.addEventListener('keydown', down);
874
- window.addEventListener('keyup', up);
875
- return () => {
876
- window.removeEventListener('keydown', down);
877
- window.removeEventListener('keyup', up);
878
- };
879
- }, [runtimeRef, setIsPlaying]);
880
- }
881
-
882
- function isEditableTarget(target: EventTarget | null): boolean {
883
- return (
884
- target instanceof Element &&
885
- !!target.closest('input, textarea, select, [contenteditable="true"]')
886
- );
887
- }
888
-
889
- function MultiSelectInspector({
890
- count,
891
- onDelete,
892
- onDuplicate,
893
- onDeselectAll,
894
- }: {
895
- count: number;
896
- onDelete: () => void;
897
- onDuplicate: () => void;
898
- onDeselectAll: () => void;
899
- }) {
900
- return (
901
- <div style={{ padding: '14px 16px', display: 'flex', flexDirection: 'column', gap: 12 }}>
902
- <div style={{ fontSize: 13, opacity: 0.8 }}>
903
- {count === 0
904
- ? 'Multi-select mode -- tap actors to add'
905
- : `${count} actor${count === 1 ? '' : 's'} selected`}
906
- </div>
907
- <div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
908
- <button
909
- type="button"
910
- onClick={onDuplicate}
911
- disabled={count === 0}
912
- style={inspectorActionStyle(count === 0)}>
913
- <Icon name="clone" /> Duplicate
914
- </button>
915
- <button
916
- type="button"
917
- onClick={onDelete}
918
- disabled={count === 0}
919
- style={inspectorActionStyle(count === 0)}>
920
- <Icon name="trash" /> Delete
921
- </button>
922
- <button type="button" onClick={onDeselectAll} style={inspectorActionStyle(false)}>
923
- <Icon name="times" /> Deselect all
924
- </button>
925
- </div>
926
- </div>
927
- );
928
- }
929
-
930
- function inspectorActionStyle(disabled: boolean): React.CSSProperties {
931
- return {
932
- display: 'inline-flex',
933
- alignItems: 'center',
934
- gap: 6,
935
- padding: '6px 10px',
936
- background: 'transparent',
937
- color: 'inherit',
938
- border: '1px solid var(--castle-inspector-divider)',
939
- borderRadius: 6,
940
- cursor: disabled ? 'default' : 'pointer',
941
- opacity: disabled ? 0.5 : 1,
942
- fontSize: 13,
943
- };
944
- }
945
-
946
- function SceneInspector({
947
- selectedActor,
948
- files,
949
- onSetComponent,
950
- onAddBehavior,
951
- onRemoveBehavior,
952
- }: {
953
- selectedActor: ActorData | undefined;
954
- files: FileMap;
955
- onSetComponent: (
956
- actorId: string,
957
- behaviorName: string,
958
- nextProps: Partial<ComponentProps>
959
- ) => void;
960
- onAddBehavior: (actorId: string, behaviorName: string) => void;
961
- onRemoveBehavior: (actorId: string, behaviorName: string) => void;
962
- }) {
963
- if (!selectedActor) return null;
964
- const presentNames = new Set(
965
- Object.entries(selectedActor.components)
966
- .filter(([, component]) => !!component)
967
- .map(([behaviorName]) => behaviorName)
968
- );
969
- const availableBehaviors = behaviorClasses.filter(
970
- (candidate) => !presentNames.has(candidate.behaviorName)
971
- );
972
- const behaviorEntries = Object.entries(selectedActor.components).filter(
973
- (entry): entry is [string, ComponentProps] => !!entry[1]
974
- );
975
- return (
976
- <>
977
- <AddBehaviorPicker
978
- available={availableBehaviors}
979
- onAdd={(behaviorName) => onAddBehavior(selectedActor.id, behaviorName)}
980
- />
981
- {behaviorEntries.map(([behaviorName, component], index) => {
982
- const Behavior = behaviorClasses.find(
983
- (candidate) => candidate.behaviorName === behaviorName
984
- );
985
- const Inspector = Behavior?.Inspector as InspectorComponent | undefined;
986
- const body = Inspector ? (
987
- <Inspector
988
- actor={selectedActor}
989
- component={component}
990
- files={files}
991
- setComponent={(nextProps) => onSetComponent(selectedActor.id, behaviorName, nextProps)}
992
- />
993
- ) : (
994
- <AutoInspector
995
- behaviorName={behaviorName}
996
- defaultProps={Behavior?.defaultProps ?? component}
997
- component={component}
998
- setComponent={(nextProps) => onSetComponent(selectedActor.id, behaviorName, nextProps)}
999
- />
1000
- );
1001
- // Layout is core to every actor -- it cannot be removed.
1002
- const removable = behaviorName !== 'Layout';
1003
- const isLast = index === behaviorEntries.length - 1;
1004
- // Trash renders before the panel so the panel stays the wrapper's
1005
- // last child -- the panel's own `:last-child` border zeroes out, and
1006
- // this wrapper owns the divider between panels deterministically.
1007
- return (
1008
- <div
1009
- key={behaviorName}
1010
- style={{
1011
- position: 'relative',
1012
- borderBottom: isLast ? undefined : '1px solid var(--castle-inspector-divider)',
1013
- }}>
1014
- {removable ? (
1015
- <button
1016
- type="button"
1017
- aria-label={`Remove ${behaviorName}`}
1018
- title={`Remove ${behaviorName}`}
1019
- onClick={() => onRemoveBehavior(selectedActor.id, behaviorName)}
1020
- style={{
1021
- position: 'absolute',
1022
- top: 14,
1023
- right: 16,
1024
- border: 'none',
1025
- background: 'transparent',
1026
- padding: 0,
1027
- margin: 0,
1028
- cursor: 'pointer',
1029
- color: 'inherit',
1030
- fontSize: 16,
1031
- lineHeight: 1,
1032
- }}>
1033
- <Icon name="trash" />
1034
- </button>
1035
- ) : null}
1036
- {body}
1037
- </div>
1038
- );
1039
- })}
1040
- </>
1041
- );
1042
- }
1043
-
1044
- function AddBehaviorPicker({
1045
- available,
1046
- onAdd,
1047
- }: {
1048
- available: BehaviorClass[];
1049
- onAdd: (behaviorName: string) => void;
1050
- }) {
1051
- if (!available.length) return null;
1052
- return (
1053
- <div
1054
- style={{
1055
- padding: '12px 16px',
1056
- borderBottom: '1px solid var(--castle-inspector-divider)',
1057
- }}>
1058
- <div style={{ position: 'relative' }}>
1059
- <select
1060
- className={styles.select}
1061
- value=""
1062
- onChange={(event) => {
1063
- if (event.target.value) onAdd(event.target.value);
1064
- }}
1065
- style={{
1066
- appearance: 'none',
1067
- WebkitAppearance: 'none',
1068
- MozAppearance: 'none',
1069
- paddingRight: 30,
1070
- }}>
1071
- <option value="">+ Add behavior</option>
1072
- {available.map((behavior) => (
1073
- <option key={behavior.behaviorName} value={behavior.behaviorName}>
1074
- {behavior.behaviorName}
1075
- </option>
1076
- ))}
1077
- </select>
1078
- <span
1079
- style={{
1080
- position: 'absolute',
1081
- right: 11,
1082
- top: '50%',
1083
- transform: 'translateY(-50%)',
1084
- pointerEvents: 'none',
1085
- fontSize: 12,
1086
- opacity: 0.6,
1087
- }}>
1088
- <Icon name="chevron-down" />
1089
- </span>
1090
- </div>
1091
- </div>
1092
- );
1093
- }