castle-web-cli 0.4.71 → 0.4.73
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.js +22 -3
- package/dist/agent.js +731 -313
- package/dist/init.js +1 -1
- package/dist/shell/assets/index-Dfn29Bkt.js +108 -0
- package/dist/shell/assets/{index-CVEnWuGV.css → index-WNbOHPBj.css} +1 -1
- package/dist/shell/index.html +2 -2
- package/dist/vitePlugins.js +3 -2
- package/kits/basic-2d/CLAUDE.md +29 -8
- package/kits/basic-2d/behaviors/Collider.jsx +6 -4
- package/kits/basic-2d/behaviors/Layout.jsx +2 -2
- package/kits/basic-2d/behaviors/Sprite.jsx +210 -0
- package/kits/basic-2d/behaviors/tint.js +47 -0
- package/kits/basic-2d/docs/pxart-format.md +298 -0
- package/kits/basic-2d/drawings/pig.pxart +59 -0
- package/kits/basic-2d/editors/App.jsx +125 -76
- package/kits/basic-2d/editors/CodeEditor.jsx +9 -45
- package/kits/basic-2d/editors/FileBrowser.jsx +234 -47
- package/kits/basic-2d/editors/PlayOnly.jsx +9 -7
- package/kits/basic-2d/editors/PxArtEditor.jsx +662 -0
- package/kits/basic-2d/editors/SceneEditor.jsx +587 -221
- package/kits/basic-2d/editors/SelectionOverlay.jsx +808 -0
- package/kits/basic-2d/editors/SingleEditor.jsx +38 -20
- package/kits/basic-2d/editors/codeTheme.js +135 -0
- package/kits/basic-2d/editors/editorHistory.js +44 -17
- package/kits/basic-2d/editors/inspectorSheet.js +23 -0
- package/kits/basic-2d/editors/pixelCanvas.js +11 -0
- package/kits/basic-2d/editors/pixelEditorChrome.jsx +55 -0
- package/kits/basic-2d/editors/pixelGeometry.js +45 -0
- package/kits/basic-2d/editors/pixelInspector.jsx +416 -0
- package/kits/basic-2d/editors/pxArtEditorModel.js +718 -0
- package/kits/basic-2d/editors/pxArtPlayback.js +92 -0
- package/kits/basic-2d/editors/pxArtTimeline.jsx +752 -0
- package/kits/basic-2d/editors/pxArtTimeline.module.css +506 -0
- package/kits/basic-2d/editors/pxArtTools.js +124 -0
- package/kits/basic-2d/editors/useArtboardFit.js +102 -0
- package/kits/basic-2d/engine/ScenePlayer.jsx +10 -4
- package/kits/basic-2d/engine/SceneUI.jsx +3 -11
- package/kits/basic-2d/engine/assets.js +15 -0
- package/kits/basic-2d/engine/files.js +57 -2
- package/kits/basic-2d/engine/pxart.js +985 -0
- package/kits/basic-2d/engine/scene.js +222 -41
- package/kits/basic-2d/engine/ui.jsx +155 -26
- package/kits/basic-2d/engine/ui.module.css +1280 -344
- package/kits/basic-2d/eslint.config.js +21 -0
- package/kits/basic-2d/index.html +13 -0
- package/kits/basic-2d/package.json +1 -0
- package/kits/basic-2d/pnpm-lock.yaml +5 -5
- package/kits/basic-2d/scenes/main.scene +19 -26
- package/kits/basic-2d/scripts/draw.mjs +121 -0
- package/kits/basic-3d/editors/PlayOnly.jsx +9 -1
- package/kits/basic-3d/engine/ScenePlayer.jsx +7 -1
- package/package.json +1 -1
- package/dist/shell/assets/index-BY21Og40.js +0 -106
- package/kits/basic-2d/behaviors/Drawing.jsx +0 -142
- package/kits/basic-2d/drawings/block.drawing +0 -70
- package/kits/basic-2d/drawings/default.drawing +0 -70
- 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,24 +31,31 @@ 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
|
+
};
|
|
36
46
|
export function SceneEditor({
|
|
37
47
|
path,
|
|
38
48
|
text,
|
|
39
49
|
files,
|
|
40
|
-
|
|
50
|
+
sprites,
|
|
41
51
|
onChange,
|
|
42
52
|
onToggleFiles,
|
|
43
53
|
filesOpen,
|
|
54
|
+
headerHost,
|
|
44
55
|
selectedActorIds,
|
|
45
56
|
onSelectActorIds,
|
|
46
57
|
multiSelectMode,
|
|
47
58
|
onSetMultiSelectMode,
|
|
48
|
-
bare = false,
|
|
49
59
|
}) {
|
|
50
60
|
const canvasRef = useRef(null);
|
|
51
61
|
const runtimeRef = useRef(null);
|
|
@@ -54,8 +64,8 @@ export function SceneEditor({
|
|
|
54
64
|
const selectedActorIdsRef = useRef(selectedActorIds);
|
|
55
65
|
selectedActorIdsRef.current = selectedActorIds;
|
|
56
66
|
const [isPlaying, setIsPlaying] = useState(false);
|
|
57
|
-
const [panMode, setPanMode] = useState(false);
|
|
58
67
|
const history = useEditHistory(text, onChange);
|
|
68
|
+
useUndoRedoShortcuts(history);
|
|
59
69
|
const showMulti = selectedActorIds.length > 1 || multiSelectMode;
|
|
60
70
|
const inspectorSheet = useSelectionInspectorSheet(true);
|
|
61
71
|
const { value: sceneData, error } = parseJsonFile(path, text);
|
|
@@ -63,21 +73,21 @@ export function SceneEditor({
|
|
|
63
73
|
const getRuntime = useCallback(() => runtimeRef.current, []);
|
|
64
74
|
useScenePlayLoop({
|
|
65
75
|
sceneData,
|
|
66
|
-
|
|
76
|
+
sprites,
|
|
67
77
|
isPlaying,
|
|
68
78
|
text,
|
|
69
79
|
canvasRef,
|
|
70
80
|
runtimeRef,
|
|
71
81
|
editCameraRef,
|
|
72
|
-
selectedActorIds,
|
|
73
82
|
marqueeRef,
|
|
83
|
+
selectedActorIdsRef,
|
|
74
84
|
});
|
|
75
|
-
const panGesture = usePanGesture({ canvasRef, editCameraRef,
|
|
76
|
-
useScenePlayKeys(runtimeRef
|
|
85
|
+
const panGesture = usePanGesture({ canvasRef, editCameraRef, isPlaying });
|
|
86
|
+
useScenePlayKeys(runtimeRef);
|
|
77
87
|
const gesture = useSelectionGesture({
|
|
78
88
|
canvasRef,
|
|
79
89
|
sceneData,
|
|
80
|
-
|
|
90
|
+
sprites,
|
|
81
91
|
isPlaying,
|
|
82
92
|
selectedActorIds,
|
|
83
93
|
onSelectActorIds,
|
|
@@ -101,9 +111,12 @@ export function SceneEditor({
|
|
|
101
111
|
if (!sceneData) {
|
|
102
112
|
return (
|
|
103
113
|
<>
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
114
|
+
<EditorHeader
|
|
115
|
+
title={basename(path)}
|
|
116
|
+
onToggleFiles={onToggleFiles}
|
|
117
|
+
filesOpen={filesOpen}
|
|
118
|
+
headerHost={headerHost}
|
|
119
|
+
/>
|
|
107
120
|
<EditorBody>
|
|
108
121
|
<div className={styles.inspector}>{error}</div>
|
|
109
122
|
</EditorBody>
|
|
@@ -121,6 +134,9 @@ export function SceneEditor({
|
|
|
121
134
|
onSelectActorIds,
|
|
122
135
|
});
|
|
123
136
|
const snapSettings = getSnapSettings(sceneData);
|
|
137
|
+
const stageWrapStyle = {
|
|
138
|
+
backgroundColor: darkenSceneBackground(sceneData.background),
|
|
139
|
+
};
|
|
124
140
|
const updateSceneSettings = (nextScene) => {
|
|
125
141
|
history.commit(formatJson(setSceneSettings(sceneData, nextScene)));
|
|
126
142
|
};
|
|
@@ -133,85 +149,90 @@ export function SceneEditor({
|
|
|
133
149
|
: selectedActor
|
|
134
150
|
? `actor ${selectedActor.id}`
|
|
135
151
|
: '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
152
|
return (
|
|
158
153
|
<>
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
154
|
+
<EditorHeader
|
|
155
|
+
title={sceneData.name ?? basename(path)}
|
|
156
|
+
subtitle={isPlaying ? 'WASD / arrows' : `${sceneData.actors.length} actors`}
|
|
157
|
+
right={
|
|
158
|
+
<HeaderPlaybackButtons
|
|
159
|
+
isPlaying={isPlaying}
|
|
160
|
+
setIsPlaying={setIsPlaying}
|
|
161
|
+
history={history}
|
|
162
|
+
/>
|
|
163
|
+
}
|
|
164
|
+
onToggleFiles={onToggleFiles}
|
|
165
|
+
filesOpen={filesOpen}
|
|
166
|
+
headerHost={headerHost}
|
|
167
|
+
/>
|
|
173
168
|
<EditorBody>
|
|
174
169
|
<div className={styles.sceneWorkspace}>
|
|
175
170
|
<div className={styles.sceneTools}>
|
|
176
|
-
<
|
|
177
|
-
|
|
178
|
-
|
|
171
|
+
<BlueprintHotbar
|
|
172
|
+
sceneData={sceneData}
|
|
173
|
+
files={files}
|
|
174
|
+
sprites={sprites}
|
|
175
|
+
canvasRef={canvasRef}
|
|
176
|
+
editCameraRef={editCameraRef}
|
|
177
|
+
selectedActorIds={selectedActorIds}
|
|
178
|
+
onSelectActorIds={onSelectActorIds}
|
|
179
|
+
isPlaying={isPlaying}
|
|
180
|
+
onChange={onChange}
|
|
181
|
+
history={history}
|
|
182
|
+
/>
|
|
179
183
|
</div>
|
|
180
|
-
<div className={styles.stageWrap}>
|
|
184
|
+
<div className={styles.stageWrap} style={stageWrapStyle}>
|
|
181
185
|
<div className={styles.stageCard} data-castle-card>
|
|
182
|
-
<
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
186
|
+
<div className={cx(styles.stageCardClip, !isPlaying && styles.stageCardEditSurface)}>
|
|
187
|
+
<canvas
|
|
188
|
+
ref={canvasRef}
|
|
189
|
+
className={cx(styles.stageCanvas, !isPlaying && styles.stageCanvasEdit)}
|
|
190
|
+
onPointerDown={(event) =>
|
|
191
|
+
isPlaying
|
|
192
|
+
? playPointer.onPointerDown(event)
|
|
193
|
+
: panGesture.isSpacePanning()
|
|
194
|
+
? panGesture.onPointerDown(event)
|
|
195
|
+
: gesture.onPointerDown(event)
|
|
196
|
+
}
|
|
197
|
+
onPointerMove={(event) =>
|
|
198
|
+
isPlaying
|
|
199
|
+
? playPointer.onPointerMove(event)
|
|
200
|
+
: panGesture.isActive()
|
|
201
|
+
? panGesture.onPointerMove(event)
|
|
202
|
+
: gesture.onPointerMove(event)
|
|
203
|
+
}
|
|
204
|
+
onPointerUp={(event) =>
|
|
205
|
+
isPlaying
|
|
206
|
+
? playPointer.onPointerUp(event)
|
|
207
|
+
: panGesture.isActive()
|
|
208
|
+
? panGesture.onPointerUp(event)
|
|
209
|
+
: gesture.onPointerUp(event)
|
|
210
|
+
}
|
|
211
|
+
onPointerCancel={(event) =>
|
|
212
|
+
isPlaying
|
|
213
|
+
? playPointer.onPointerUp(event)
|
|
214
|
+
: panGesture.isActive()
|
|
215
|
+
? panGesture.onPointerUp(event)
|
|
216
|
+
: gesture.onPointerUp(event)
|
|
217
|
+
}
|
|
218
|
+
onWheel={panGesture.onWheel}
|
|
219
|
+
/>
|
|
220
|
+
{isPlaying && <SceneUI getRuntime={getRuntime} />}
|
|
221
|
+
</div>
|
|
222
|
+
{!isPlaying && selectedActorIds.length >= 1 && (
|
|
223
|
+
<SelectionOverlay
|
|
224
|
+
canvasRef={canvasRef}
|
|
225
|
+
editCameraRef={editCameraRef}
|
|
226
|
+
sceneData={sceneData}
|
|
227
|
+
selectedActorIds={selectedActorIds}
|
|
228
|
+
snap={snapSettings}
|
|
229
|
+
onArrange={actions.arrangeSelection}
|
|
230
|
+
onClone={actions.duplicateSelection}
|
|
231
|
+
onDelete={actions.deleteSelection}
|
|
232
|
+
applyScene={(next) => onChange(formatJson(next))}
|
|
233
|
+
recordSnapshot={history.recordSnapshot}
|
|
234
|
+
/>
|
|
235
|
+
)}
|
|
215
236
|
</div>
|
|
216
237
|
</div>
|
|
217
238
|
<aside {...inspectorSheet.rootProps}>
|
|
@@ -222,8 +243,6 @@ export function SceneEditor({
|
|
|
222
243
|
{showMulti ? (
|
|
223
244
|
<MultiSelectInspector
|
|
224
245
|
count={selectedActorIds.length}
|
|
225
|
-
onDelete={actions.deleteSelection}
|
|
226
|
-
onDuplicate={actions.duplicateSelection}
|
|
227
246
|
onDeselectAll={() => {
|
|
228
247
|
onSelectActorIds([]);
|
|
229
248
|
onSetMultiSelectMode(false);
|
|
@@ -253,16 +272,11 @@ export function SceneEditor({
|
|
|
253
272
|
</>
|
|
254
273
|
);
|
|
255
274
|
}
|
|
256
|
-
|
|
275
|
+
// Header-right playback controls, in the mockup's order: Undo, Redo, Play.
|
|
276
|
+
// Play stays a play/stop toggle. Duplicate/remove live on the on-canvas toolbar.
|
|
277
|
+
function HeaderPlaybackButtons({ isPlaying, setIsPlaying, history }) {
|
|
257
278
|
return (
|
|
258
279
|
<>
|
|
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
280
|
<IconButton
|
|
267
281
|
icon="undo"
|
|
268
282
|
label="Undo"
|
|
@@ -275,66 +289,359 @@ function PlaybackButtons({ isPlaying, setIsPlaying, history, panMode, setPanMode
|
|
|
275
289
|
onClick={history.redo}
|
|
276
290
|
disabled={!history.canRedo || isPlaying}
|
|
277
291
|
/>
|
|
278
|
-
<span style={{ width: 16 }} aria-hidden="true" />
|
|
279
292
|
<IconButton
|
|
280
|
-
icon=
|
|
281
|
-
label=
|
|
282
|
-
active={
|
|
283
|
-
onClick={() =>
|
|
284
|
-
|
|
293
|
+
icon={isPlaying ? 'stop' : 'play'}
|
|
294
|
+
label={isPlaying ? 'Stop' : 'Play'}
|
|
295
|
+
active={isPlaying}
|
|
296
|
+
onClick={(event) => {
|
|
297
|
+
// Drop focus so a subsequent Space (a game input / pan modifier)
|
|
298
|
+
// doesn't re-activate this button and toggle play.
|
|
299
|
+
event.currentTarget.blur();
|
|
300
|
+
setIsPlaying(!isPlaying);
|
|
301
|
+
}}
|
|
285
302
|
/>
|
|
286
303
|
</>
|
|
287
304
|
);
|
|
288
305
|
}
|
|
289
|
-
|
|
306
|
+
// Blueprint hotbar -- lightweight actor stamps saved in scene.editor.blueprints.
|
|
307
|
+
function BlueprintHotbar({
|
|
290
308
|
sceneData,
|
|
291
309
|
files,
|
|
292
|
-
|
|
310
|
+
sprites,
|
|
311
|
+
canvasRef,
|
|
312
|
+
editCameraRef,
|
|
293
313
|
selectedActorIds,
|
|
294
314
|
onSelectActorIds,
|
|
295
315
|
isPlaying,
|
|
296
316
|
onChange,
|
|
297
317
|
history,
|
|
298
318
|
}) {
|
|
299
|
-
const
|
|
319
|
+
const blueprints = sceneData.editor?.blueprints ?? [];
|
|
320
|
+
const dragRef = useRef(null);
|
|
321
|
+
const suppressClickRef = useRef(false);
|
|
322
|
+
const [dragPreview, setDragPreview] = useState(null);
|
|
323
|
+
// Right-click context menu anchored at the cursor: { blueprint, x, y }.
|
|
324
|
+
const [contextMenu, setContextMenu] = useState(null);
|
|
325
|
+
const closeContextMenu = useCallback(() => setContextMenu(null), []);
|
|
300
326
|
const onAdd = () => {
|
|
301
|
-
const { sceneData: next, newId } = addActor(sceneData, files,
|
|
327
|
+
const { sceneData: next, newId } = addActor(sceneData, files, sprites);
|
|
302
328
|
history.recordSnapshot();
|
|
303
|
-
|
|
329
|
+
onChange(formatJson(next));
|
|
304
330
|
onSelectActorIds([newId]);
|
|
305
331
|
};
|
|
306
|
-
const
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
history.
|
|
310
|
-
applyScene(next);
|
|
311
|
-
if (newIds.length) onSelectActorIds(newIds);
|
|
332
|
+
const onSaveSelection = () => {
|
|
333
|
+
const next = addSelectedActorBlueprint(sceneData, selectedActorIds);
|
|
334
|
+
if (next === sceneData) return;
|
|
335
|
+
history.commit(formatJson(next));
|
|
312
336
|
};
|
|
313
|
-
const
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
onSelectActorIds([]);
|
|
337
|
+
const onStamp = (blueprint, position) => {
|
|
338
|
+
const { sceneData: next, newId } = stampActorBlueprint(sceneData, blueprint, position);
|
|
339
|
+
if (next === sceneData) return;
|
|
340
|
+
history.commit(formatJson(next));
|
|
341
|
+
onSelectActorIds([newId]);
|
|
342
|
+
};
|
|
343
|
+
const onRemoveBlueprint = (blueprintId) => {
|
|
344
|
+
const next = removeActorBlueprint(sceneData, blueprintId);
|
|
345
|
+
if (next === sceneData) return;
|
|
346
|
+
history.commit(formatJson(next));
|
|
347
|
+
};
|
|
348
|
+
const onBlueprintContextMenu = (event, blueprint) => {
|
|
349
|
+
if (isPlaying) return;
|
|
350
|
+
event.preventDefault();
|
|
351
|
+
event.stopPropagation();
|
|
352
|
+
setContextMenu({ blueprint, x: event.clientX, y: event.clientY });
|
|
353
|
+
};
|
|
354
|
+
const onBlueprintPointerDown = (event, blueprint) => {
|
|
355
|
+
if (isPlaying || event.button !== 0) return;
|
|
356
|
+
event.preventDefault();
|
|
357
|
+
const drag = {
|
|
358
|
+
blueprint,
|
|
359
|
+
pointerId: event.pointerId,
|
|
360
|
+
startX: event.clientX,
|
|
361
|
+
startY: event.clientY,
|
|
362
|
+
moved: false,
|
|
363
|
+
};
|
|
364
|
+
dragRef.current = drag;
|
|
365
|
+
try {
|
|
366
|
+
event.currentTarget.setPointerCapture(event.pointerId);
|
|
367
|
+
} catch {
|
|
368
|
+
// Window listeners still finish the drag if capture is unavailable.
|
|
369
|
+
}
|
|
370
|
+
const onMove = (moveEvent) => {
|
|
371
|
+
if (moveEvent.pointerId !== drag.pointerId) return;
|
|
372
|
+
if (!drag.moved && Math.hypot(moveEvent.clientX - drag.startX, moveEvent.clientY - drag.startY) > DRAG_THRESHOLD) {
|
|
373
|
+
drag.moved = true;
|
|
374
|
+
}
|
|
375
|
+
if (drag.moved) {
|
|
376
|
+
setDragPreview({
|
|
377
|
+
blueprint,
|
|
378
|
+
x: moveEvent.clientX,
|
|
379
|
+
y: moveEvent.clientY,
|
|
380
|
+
overStage: Boolean(eventToStagePoint(moveEvent, canvasRef, editCameraRef)),
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
};
|
|
384
|
+
const onUp = (upEvent) => {
|
|
385
|
+
if (upEvent.pointerId !== drag.pointerId) return;
|
|
386
|
+
window.removeEventListener('pointermove', onMove);
|
|
387
|
+
window.removeEventListener('pointerup', onUp);
|
|
388
|
+
window.removeEventListener('pointercancel', onUp);
|
|
389
|
+
dragRef.current = null;
|
|
390
|
+
setDragPreview(null);
|
|
391
|
+
if (!drag.moved) return;
|
|
392
|
+
suppressClickRef.current = true;
|
|
393
|
+
const position = eventToStagePoint(upEvent, canvasRef, editCameraRef);
|
|
394
|
+
if (position) onStamp(blueprint, position);
|
|
395
|
+
};
|
|
396
|
+
window.addEventListener('pointermove', onMove);
|
|
397
|
+
window.addEventListener('pointerup', onUp);
|
|
398
|
+
window.addEventListener('pointercancel', onUp);
|
|
318
399
|
};
|
|
319
|
-
const hasSelection = selectedActorIds.length > 0;
|
|
320
400
|
return (
|
|
321
|
-
|
|
322
|
-
<
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
label="
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
401
|
+
<div className={styles.bpHotbar} aria-label="Blueprints">
|
|
402
|
+
<button
|
|
403
|
+
type="button"
|
|
404
|
+
className={styles.bpActionSlot}
|
|
405
|
+
aria-label="Save selected actor as blueprint"
|
|
406
|
+
title="Save selected actor as blueprint"
|
|
407
|
+
onClick={onSaveSelection}
|
|
408
|
+
disabled={isPlaying || selectedActorIds.length !== 1}>
|
|
409
|
+
<Icon name="stamp" />
|
|
410
|
+
</button>
|
|
411
|
+
<button
|
|
412
|
+
type="button"
|
|
413
|
+
className={styles.bpActionSlot}
|
|
414
|
+
aria-label="Add actor"
|
|
415
|
+
title="Add actor"
|
|
416
|
+
onClick={onAdd}
|
|
417
|
+
disabled={isPlaying}>
|
|
418
|
+
<span className={styles.dashedSquareIcon} aria-hidden="true" />
|
|
419
|
+
</button>
|
|
420
|
+
{blueprints.map((blueprint) => (
|
|
421
|
+
<button
|
|
422
|
+
key={blueprint.id}
|
|
423
|
+
type="button"
|
|
424
|
+
className={styles.bpSlot}
|
|
425
|
+
title={`Add ${blueprint.name}`}
|
|
426
|
+
disabled={isPlaying}
|
|
427
|
+
onPointerDown={(event) => onBlueprintPointerDown(event, blueprint)}
|
|
428
|
+
onContextMenu={(event) => onBlueprintContextMenu(event, blueprint)}
|
|
429
|
+
onClick={() => {
|
|
430
|
+
if (suppressClickRef.current) {
|
|
431
|
+
suppressClickRef.current = false;
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
onStamp(blueprint);
|
|
435
|
+
}}>
|
|
436
|
+
<BlueprintThumbnail blueprint={blueprint} sprites={sprites} />
|
|
437
|
+
</button>
|
|
438
|
+
))}
|
|
439
|
+
{dragPreview ? (
|
|
440
|
+
<BlueprintDragPreview preview={dragPreview} sprites={sprites} />
|
|
441
|
+
) : null}
|
|
442
|
+
{contextMenu ? (
|
|
443
|
+
<BlueprintContextMenu
|
|
444
|
+
x={contextMenu.x}
|
|
445
|
+
y={contextMenu.y}
|
|
446
|
+
onRemove={() => onRemoveBlueprint(contextMenu.blueprint.id)}
|
|
447
|
+
onClose={closeContextMenu}
|
|
448
|
+
/>
|
|
449
|
+
) : null}
|
|
450
|
+
</div>
|
|
451
|
+
);
|
|
452
|
+
}
|
|
453
|
+
// Cursor-anchored right-click menu for a hotbar blueprint. Mirrors the
|
|
454
|
+
// pxArtTimeline ContextMenu: reuses the shared `selArrangeMenu`/`selArrangeItem`
|
|
455
|
+
// chrome, fixed-positions at the click point with viewport clamping, and closes
|
|
456
|
+
// on outside pointerdown or Escape.
|
|
457
|
+
function BlueprintContextMenu({ x, y, onRemove, onClose }) {
|
|
458
|
+
const ref = useRef(null);
|
|
459
|
+
const [pos, setPos] = useState({ left: x, top: y });
|
|
460
|
+
useLayoutEffect(() => {
|
|
461
|
+
const el = ref.current;
|
|
462
|
+
if (!el) return;
|
|
463
|
+
const rect = el.getBoundingClientRect();
|
|
464
|
+
const margin = 8;
|
|
465
|
+
const maxLeft = Math.max(margin, window.innerWidth - rect.width - margin);
|
|
466
|
+
const maxTop = Math.max(margin, window.innerHeight - rect.height - margin);
|
|
467
|
+
setPos({ left: Math.min(x, maxLeft), top: Math.min(y, maxTop) });
|
|
468
|
+
}, [x, y]);
|
|
469
|
+
useEffect(() => {
|
|
470
|
+
const onPointerDown = (event) => {
|
|
471
|
+
if (!ref.current?.contains(event.target)) onClose();
|
|
472
|
+
};
|
|
473
|
+
const onKeyDown = (event) => {
|
|
474
|
+
if (event.key === 'Escape') onClose();
|
|
475
|
+
};
|
|
476
|
+
window.addEventListener('pointerdown', onPointerDown);
|
|
477
|
+
window.addEventListener('keydown', onKeyDown);
|
|
478
|
+
return () => {
|
|
479
|
+
window.removeEventListener('pointerdown', onPointerDown);
|
|
480
|
+
window.removeEventListener('keydown', onKeyDown);
|
|
481
|
+
};
|
|
482
|
+
}, [onClose]);
|
|
483
|
+
return (
|
|
484
|
+
<div
|
|
485
|
+
ref={ref}
|
|
486
|
+
className={styles.selArrangeMenu}
|
|
487
|
+
role="menu"
|
|
488
|
+
aria-label="Blueprint"
|
|
489
|
+
style={{ position: 'fixed', left: pos.left, top: pos.top, transform: 'none' }}
|
|
490
|
+
onPointerDown={(event) => event.stopPropagation()}
|
|
491
|
+
onContextMenu={(event) => event.preventDefault()}>
|
|
492
|
+
<button
|
|
493
|
+
type="button"
|
|
494
|
+
role="menuitem"
|
|
495
|
+
className={styles.selArrangeItem}
|
|
496
|
+
onClick={() => {
|
|
497
|
+
onRemove();
|
|
498
|
+
onClose();
|
|
499
|
+
}}>
|
|
500
|
+
Remove blueprint
|
|
501
|
+
</button>
|
|
502
|
+
</div>
|
|
336
503
|
);
|
|
337
504
|
}
|
|
505
|
+
function BlueprintDragPreview({ preview, sprites }) {
|
|
506
|
+
const layout = preview.blueprint.actor?.components?.Layout;
|
|
507
|
+
const width = Math.max(24, Math.min(80, layout?.width ?? 48));
|
|
508
|
+
const height = Math.max(24, Math.min(80, layout?.height ?? 48));
|
|
509
|
+
return (
|
|
510
|
+
<div
|
|
511
|
+
className={cx(styles.bpDragPreview, preview.overStage && styles.bpDragPreviewOverStage)}
|
|
512
|
+
style={{
|
|
513
|
+
left: preview.x,
|
|
514
|
+
top: preview.y,
|
|
515
|
+
width,
|
|
516
|
+
height,
|
|
517
|
+
transform: `translate(-50%, -50%) rotate(${layout?.rotation ?? 0}deg)`,
|
|
518
|
+
}}>
|
|
519
|
+
<BlueprintThumbnail blueprint={preview.blueprint} sprites={sprites} preview />
|
|
520
|
+
</div>
|
|
521
|
+
);
|
|
522
|
+
}
|
|
523
|
+
function BlueprintThumbnail({ blueprint, sprites, preview = false }) {
|
|
524
|
+
const canvasRef = useRef(null);
|
|
525
|
+
const spritePath = blueprint.actor?.components?.Sprite?.file;
|
|
526
|
+
const sprite = spritePath ? sprites?.[spritePath] : null;
|
|
527
|
+
useEffect(() => {
|
|
528
|
+
const canvas = canvasRef.current;
|
|
529
|
+
if (!canvas || !sprite) return;
|
|
530
|
+
// A .pxart blueprint renders its first frame via the SDK.
|
|
531
|
+
renderSpriteFrame(sprite, 0, canvas);
|
|
532
|
+
}, [sprite]);
|
|
533
|
+
if (!sprite) {
|
|
534
|
+
return (
|
|
535
|
+
<span className={styles.bpSlotFallback}>
|
|
536
|
+
<Icon name="clone" />
|
|
537
|
+
</span>
|
|
538
|
+
);
|
|
539
|
+
}
|
|
540
|
+
return (
|
|
541
|
+
<canvas
|
|
542
|
+
ref={canvasRef}
|
|
543
|
+
className={cx(styles.bpSlotThumb, preview && styles.bpPreviewThumb)}
|
|
544
|
+
aria-hidden="true"
|
|
545
|
+
/>
|
|
546
|
+
);
|
|
547
|
+
}
|
|
548
|
+
function addSelectedActorBlueprint(sceneData, selectedActorIds) {
|
|
549
|
+
if (selectedActorIds.length !== 1) return sceneData;
|
|
550
|
+
const actor = sceneData.actors.find((candidate) => candidate.id === selectedActorIds[0]);
|
|
551
|
+
if (!actor) return sceneData;
|
|
552
|
+
const next = structuredClone(sceneData);
|
|
553
|
+
const copy = structuredClone(actor);
|
|
554
|
+
delete copy.id;
|
|
555
|
+
if (copy.components?.Layout) {
|
|
556
|
+
copy.components.Layout = {
|
|
557
|
+
...copy.components.Layout,
|
|
558
|
+
x: 0,
|
|
559
|
+
y: 0,
|
|
560
|
+
};
|
|
561
|
+
}
|
|
562
|
+
const blueprints = [...(next.editor?.blueprints ?? [])];
|
|
563
|
+
const name = makeBlueprintName(actor, blueprints.length);
|
|
564
|
+
blueprints.push({
|
|
565
|
+
id: makeBlueprintId(blueprints),
|
|
566
|
+
name,
|
|
567
|
+
actor: copy,
|
|
568
|
+
});
|
|
569
|
+
next.editor = {
|
|
570
|
+
...(next.editor ?? {}),
|
|
571
|
+
blueprints,
|
|
572
|
+
};
|
|
573
|
+
return next;
|
|
574
|
+
}
|
|
575
|
+
function stampActorBlueprint(sceneData, blueprint, position) {
|
|
576
|
+
if (!blueprint?.actor) return { sceneData, newId: null };
|
|
577
|
+
const next = structuredClone(sceneData);
|
|
578
|
+
const existingIds = new Set(next.actors.map((actor) => actor.id));
|
|
579
|
+
const actor = structuredClone(blueprint.actor);
|
|
580
|
+
const newId = makeActorId(existingIds);
|
|
581
|
+
actor.id = newId;
|
|
582
|
+
actor.components = actor.components ?? {};
|
|
583
|
+
const layout = actor.components.Layout ?? { width: 64, height: 64, z: 0, rotation: 0 };
|
|
584
|
+
actor.components.Layout = {
|
|
585
|
+
...layout,
|
|
586
|
+
x: Math.round((position?.x ?? cardSize.width / 2) - layout.width / 2),
|
|
587
|
+
y: Math.round((position?.y ?? cardSize.height / 2) - layout.height / 2),
|
|
588
|
+
};
|
|
589
|
+
next.actors.push(actor);
|
|
590
|
+
return { sceneData: next, newId };
|
|
591
|
+
}
|
|
592
|
+
function removeActorBlueprint(sceneData, blueprintId) {
|
|
593
|
+
const blueprints = sceneData.editor?.blueprints ?? [];
|
|
594
|
+
if (!blueprints.some((blueprint) => blueprint.id === blueprintId)) return sceneData;
|
|
595
|
+
const next = structuredClone(sceneData);
|
|
596
|
+
next.editor = {
|
|
597
|
+
...(next.editor ?? {}),
|
|
598
|
+
blueprints: (next.editor?.blueprints ?? []).filter(
|
|
599
|
+
(blueprint) => blueprint.id !== blueprintId
|
|
600
|
+
),
|
|
601
|
+
};
|
|
602
|
+
return next;
|
|
603
|
+
}
|
|
604
|
+
function eventToStagePoint(event, canvasRef, editCameraRef) {
|
|
605
|
+
const canvas = canvasRef.current;
|
|
606
|
+
if (!canvas) return null;
|
|
607
|
+
const rect = canvas.getBoundingClientRect();
|
|
608
|
+
if (
|
|
609
|
+
event.clientX < rect.left ||
|
|
610
|
+
event.clientX > rect.right ||
|
|
611
|
+
event.clientY < rect.top ||
|
|
612
|
+
event.clientY > rect.bottom
|
|
613
|
+
) {
|
|
614
|
+
return null;
|
|
615
|
+
}
|
|
616
|
+
const raw = screenToCard(canvas, event.clientX, event.clientY);
|
|
617
|
+
const camera = editCameraRef.current ?? { x: 0, y: 0 };
|
|
618
|
+
return {
|
|
619
|
+
x: raw.x + camera.x,
|
|
620
|
+
y: raw.y + camera.y,
|
|
621
|
+
};
|
|
622
|
+
}
|
|
623
|
+
function makeBlueprintName(actor, index) {
|
|
624
|
+
const spriteFile = actor.components?.Sprite?.file;
|
|
625
|
+
if (spriteFile) return basename(spriteFile).replace(/\.pxart$/i, '');
|
|
626
|
+
return `Actor ${index + 1}`;
|
|
627
|
+
}
|
|
628
|
+
function makeBlueprintId(blueprints) {
|
|
629
|
+
const existing = new Set(blueprints.map((blueprint) => blueprint.id));
|
|
630
|
+
for (let index = 1; index < 1000; index++) {
|
|
631
|
+
const candidate = `blueprint-${index}`;
|
|
632
|
+
if (!existing.has(candidate)) return candidate;
|
|
633
|
+
}
|
|
634
|
+
return `blueprint-${Date.now()}`;
|
|
635
|
+
}
|
|
636
|
+
function makeActorId(existingIds) {
|
|
637
|
+
for (let attempt = 0; attempt < 64; attempt++) {
|
|
638
|
+
const candidate = Math.floor(Math.random() * 0xffffffff)
|
|
639
|
+
.toString(16)
|
|
640
|
+
.padStart(8, '0');
|
|
641
|
+
if (!existingIds.has(candidate)) return candidate;
|
|
642
|
+
}
|
|
643
|
+
return `actor-${Date.now()}`;
|
|
644
|
+
}
|
|
338
645
|
function makeSceneActions({ sceneData, commit, selectedActorIds, onSelectActorIds }) {
|
|
339
646
|
function commitScene(next) {
|
|
340
647
|
commit(formatJson(next));
|
|
@@ -362,6 +669,11 @@ function makeSceneActions({ sceneData, commit, selectedActorIds, onSelectActorId
|
|
|
362
669
|
commitScene(next);
|
|
363
670
|
if (newIds.length) onSelectActorIds(newIds);
|
|
364
671
|
},
|
|
672
|
+
arrangeSelection: (action) => {
|
|
673
|
+
if (selectedActorIds.length === 0) return;
|
|
674
|
+
const next = arrangeActors(sceneData, selectedActorIds, action);
|
|
675
|
+
if (next !== sceneData) commitScene(next);
|
|
676
|
+
},
|
|
365
677
|
};
|
|
366
678
|
}
|
|
367
679
|
function setSceneSettings(sceneData, nextScene) {
|
|
@@ -379,6 +691,34 @@ function setSceneEditorSettings(sceneData, nextEditor) {
|
|
|
379
691
|
},
|
|
380
692
|
};
|
|
381
693
|
}
|
|
694
|
+
function darkenSceneBackground(background) {
|
|
695
|
+
const parsed = parseHexColor(background ?? '#121213');
|
|
696
|
+
if (!parsed) return 'var(--castle-stage-workspace-bg)';
|
|
697
|
+
const amount = 0.42;
|
|
698
|
+
const darken = (channel) => Math.max(0, Math.round(channel * (1 - amount)));
|
|
699
|
+
return `rgb(${darken(parsed.r)}, ${darken(parsed.g)}, ${darken(parsed.b)})`;
|
|
700
|
+
}
|
|
701
|
+
function parseHexColor(value) {
|
|
702
|
+
if (typeof value !== 'string') return null;
|
|
703
|
+
const hex = value.trim().replace(/^#/, '');
|
|
704
|
+
const expand = (text) =>
|
|
705
|
+
text
|
|
706
|
+
.split('')
|
|
707
|
+
.map((char) => `${char}${char}`)
|
|
708
|
+
.join('');
|
|
709
|
+
const normalized =
|
|
710
|
+
hex.length === 3 || hex.length === 4
|
|
711
|
+
? expand(hex.slice(0, 3))
|
|
712
|
+
: hex.length === 6 || hex.length === 8
|
|
713
|
+
? hex.slice(0, 6)
|
|
714
|
+
: null;
|
|
715
|
+
if (!normalized || !/^[0-9a-f]{6}$/i.test(normalized)) return null;
|
|
716
|
+
return {
|
|
717
|
+
r: parseInt(normalized.slice(0, 2), 16),
|
|
718
|
+
g: parseInt(normalized.slice(2, 4), 16),
|
|
719
|
+
b: parseInt(normalized.slice(4, 6), 16),
|
|
720
|
+
};
|
|
721
|
+
}
|
|
382
722
|
function useSelectionInspectorSheet(open) {
|
|
383
723
|
const [snap, setSnap] = useState('high');
|
|
384
724
|
// Inspector visibility is driven by selection / multi-mode. Snap state
|
|
@@ -408,7 +748,7 @@ function useSelectionGesture(args) {
|
|
|
408
748
|
const cam = current.editCameraRef.current;
|
|
409
749
|
const point = { x: raw.x + cam.x, y: raw.y + cam.y };
|
|
410
750
|
current.canvasRef.current.setPointerCapture(event.pointerId);
|
|
411
|
-
const scene = makeScene(current.sceneData, behaviorClasses, current.
|
|
751
|
+
const scene = makeScene(current.sceneData, behaviorClasses, current.sprites);
|
|
412
752
|
const actor = scene.actorAt(point.x, point.y);
|
|
413
753
|
const drag = {
|
|
414
754
|
pointerId: event.pointerId,
|
|
@@ -580,35 +920,59 @@ function usePlayPointerGesture({ canvasRef, runtimeRef }) {
|
|
|
580
920
|
);
|
|
581
921
|
return { onPointerDown, onPointerMove, onPointerUp };
|
|
582
922
|
}
|
|
583
|
-
function usePanGesture({ canvasRef, editCameraRef,
|
|
923
|
+
function usePanGesture({ canvasRef, editCameraRef, isPlaying }) {
|
|
584
924
|
const dragRef = useRef(null);
|
|
585
|
-
const
|
|
586
|
-
|
|
587
|
-
|
|
925
|
+
const spaceDownRef = useRef(false);
|
|
926
|
+
useEffect(() => {
|
|
927
|
+
function onKeyDown(event) {
|
|
928
|
+
if (event.key !== ' ' || isEditableTarget(event.target)) return;
|
|
929
|
+
// Swallow Space's default: it would scroll the panel and re-activate a
|
|
930
|
+
// focused button (e.g. the Play/Stop toggle that keeps focus after a
|
|
931
|
+
// click), so Space only ever arms the pan modifier here.
|
|
932
|
+
event.preventDefault();
|
|
933
|
+
spaceDownRef.current = true;
|
|
934
|
+
}
|
|
935
|
+
function onKeyUp(event) {
|
|
936
|
+
if (event.key === ' ') spaceDownRef.current = false;
|
|
937
|
+
}
|
|
938
|
+
window.addEventListener('keydown', onKeyDown);
|
|
939
|
+
window.addEventListener('keyup', onKeyUp);
|
|
940
|
+
return () => {
|
|
941
|
+
window.removeEventListener('keydown', onKeyDown);
|
|
942
|
+
window.removeEventListener('keyup', onKeyUp);
|
|
943
|
+
};
|
|
944
|
+
}, []);
|
|
945
|
+
const panByScreenDelta = useCallback(
|
|
946
|
+
(dx, dy) => {
|
|
588
947
|
const canvas = canvasRef.current;
|
|
589
948
|
if (!canvas) return;
|
|
590
|
-
const
|
|
591
|
-
|
|
592
|
-
|
|
949
|
+
const rect = canvas.getBoundingClientRect();
|
|
950
|
+
const viewportWidth = Number(canvas.dataset.viewportWidth) || cardSize.width;
|
|
951
|
+
const viewportHeight = Number(canvas.dataset.viewportHeight) || cardSize.height;
|
|
952
|
+
editCameraRef.current = {
|
|
953
|
+
x: editCameraRef.current.x + (dx * viewportWidth) / rect.width,
|
|
954
|
+
y: editCameraRef.current.y + (dy * viewportHeight) / rect.height,
|
|
955
|
+
};
|
|
593
956
|
},
|
|
594
|
-
[canvasRef,
|
|
957
|
+
[canvasRef, editCameraRef]
|
|
595
958
|
);
|
|
959
|
+
const onPointerDown = useCallback((event) => {
|
|
960
|
+
if (isPlaying || !spaceDownRef.current) return;
|
|
961
|
+
event.preventDefault();
|
|
962
|
+
event.currentTarget.setPointerCapture(event.pointerId);
|
|
963
|
+
dragRef.current = { id: event.pointerId, lastX: event.clientX, lastY: event.clientY };
|
|
964
|
+
}, [isPlaying]);
|
|
596
965
|
const onPointerMove = useCallback(
|
|
597
966
|
(event) => {
|
|
598
967
|
const drag = dragRef.current;
|
|
599
968
|
if (!drag || drag.id !== event.pointerId) return;
|
|
600
|
-
const
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
drag.last = point;
|
|
606
|
-
editCameraRef.current = {
|
|
607
|
-
x: editCameraRef.current.x - dx,
|
|
608
|
-
y: editCameraRef.current.y - dy,
|
|
609
|
-
};
|
|
969
|
+
const dx = event.clientX - drag.lastX;
|
|
970
|
+
const dy = event.clientY - drag.lastY;
|
|
971
|
+
drag.lastX = event.clientX;
|
|
972
|
+
drag.lastY = event.clientY;
|
|
973
|
+
panByScreenDelta(-dx, -dy);
|
|
610
974
|
},
|
|
611
|
-
[
|
|
975
|
+
[panByScreenDelta]
|
|
612
976
|
);
|
|
613
977
|
const onPointerUp = useCallback((event) => {
|
|
614
978
|
const drag = dragRef.current;
|
|
@@ -620,7 +984,17 @@ function usePanGesture({ canvasRef, editCameraRef, enabled }) {
|
|
|
620
984
|
}
|
|
621
985
|
dragRef.current = null;
|
|
622
986
|
}, []);
|
|
623
|
-
|
|
987
|
+
const onWheel = useCallback(
|
|
988
|
+
(event) => {
|
|
989
|
+
if (isPlaying) return;
|
|
990
|
+
event.preventDefault();
|
|
991
|
+
panByScreenDelta(event.deltaX, event.deltaY);
|
|
992
|
+
},
|
|
993
|
+
[isPlaying, panByScreenDelta]
|
|
994
|
+
);
|
|
995
|
+
const isSpacePanning = useCallback(() => spaceDownRef.current, []);
|
|
996
|
+
const isActive = useCallback(() => Boolean(dragRef.current), []);
|
|
997
|
+
return { onPointerDown, onPointerMove, onPointerUp, onWheel, isSpacePanning, isActive };
|
|
624
998
|
}
|
|
625
999
|
function handleShiftPointerDown(drag, actor, current) {
|
|
626
1000
|
if (actor) {
|
|
@@ -666,6 +1040,8 @@ function handleDefaultPointerDown(drag, actor, point, current) {
|
|
|
666
1040
|
}, LONG_PRESS_MS);
|
|
667
1041
|
} else {
|
|
668
1042
|
current.onSelectActorIds([]);
|
|
1043
|
+
drag.selectionBeforeMarquee = [];
|
|
1044
|
+
drag.pendingMarquee = true;
|
|
669
1045
|
drag.longPressTimer = window.setTimeout(() => {
|
|
670
1046
|
if (drag.movedFar) return;
|
|
671
1047
|
drag.longPressFired = true;
|
|
@@ -679,7 +1055,7 @@ function finalizeMarquee(drag, current) {
|
|
|
679
1055
|
const m = current.marqueeRef.current;
|
|
680
1056
|
if (!m || !current.sceneData) return;
|
|
681
1057
|
if (m.width === 0 && m.height === 0) return;
|
|
682
|
-
const scene = makeScene(current.sceneData, behaviorClasses, current.
|
|
1058
|
+
const scene = makeScene(current.sceneData, behaviorClasses, current.sprites);
|
|
683
1059
|
const hits = scene.actorIdsInRect(m);
|
|
684
1060
|
const merged = new Set(drag.selectionBeforeMarquee);
|
|
685
1061
|
for (const id of hits) merged.add(id);
|
|
@@ -721,6 +1097,17 @@ function useSelectionKeyboard(args) {
|
|
|
721
1097
|
current.onSelectActorIds(current.sceneData.actors.map((actor) => actor.id));
|
|
722
1098
|
return;
|
|
723
1099
|
}
|
|
1100
|
+
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'd') {
|
|
1101
|
+
if (!current.sceneData || current.selectedActorIds.length === 0) return;
|
|
1102
|
+
event.preventDefault();
|
|
1103
|
+
const { sceneData: next, newIds } = duplicateActors(
|
|
1104
|
+
current.sceneData,
|
|
1105
|
+
current.selectedActorIds
|
|
1106
|
+
);
|
|
1107
|
+
current.commitScene(next);
|
|
1108
|
+
if (newIds.length) current.onSelectActorIds(newIds);
|
|
1109
|
+
return;
|
|
1110
|
+
}
|
|
724
1111
|
if (event.key === 'Delete' || event.key === 'Backspace') {
|
|
725
1112
|
if (!current.sceneData || current.selectedActorIds.length === 0) return;
|
|
726
1113
|
event.preventDefault();
|
|
@@ -734,14 +1121,14 @@ function useSelectionKeyboard(args) {
|
|
|
734
1121
|
}
|
|
735
1122
|
function useScenePlayLoop({
|
|
736
1123
|
sceneData,
|
|
737
|
-
|
|
1124
|
+
sprites,
|
|
738
1125
|
isPlaying,
|
|
739
1126
|
text,
|
|
740
1127
|
canvasRef,
|
|
741
1128
|
runtimeRef,
|
|
742
1129
|
editCameraRef,
|
|
743
|
-
selectedActorIds,
|
|
744
1130
|
marqueeRef,
|
|
1131
|
+
selectedActorIdsRef,
|
|
745
1132
|
}) {
|
|
746
1133
|
// Spin up / tear down the play-mode runtime as the user toggles play. `text`
|
|
747
1134
|
// is the stable identity for `sceneData` (which is re-parsed every render);
|
|
@@ -753,16 +1140,17 @@ function useScenePlayLoop({
|
|
|
753
1140
|
return;
|
|
754
1141
|
}
|
|
755
1142
|
if (!sceneData) return;
|
|
756
|
-
runtimeRef.current = makeScene(sceneData, behaviorClasses,
|
|
1143
|
+
runtimeRef.current = makeScene(sceneData, behaviorClasses, sprites).clone();
|
|
757
1144
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
758
|
-
}, [
|
|
1145
|
+
}, [sprites, isPlaying, text, runtimeRef]);
|
|
759
1146
|
// Animation loop -- draws edit-mode previews and ticks the play-mode runtime.
|
|
760
1147
|
useEffect(() => {
|
|
761
1148
|
if (!sceneData || !canvasRef.current) return undefined;
|
|
762
1149
|
const canvas = canvasRef.current;
|
|
763
1150
|
const ctx = canvas.getContext('2d');
|
|
764
1151
|
if (!ctx) return undefined;
|
|
765
|
-
|
|
1152
|
+
const viewport = isPlaying ? undefined : EDIT_VIEWPORT;
|
|
1153
|
+
configureSceneCanvas(canvas, ctx, viewport);
|
|
766
1154
|
let raf = 0;
|
|
767
1155
|
let last = performance.now();
|
|
768
1156
|
const frame = (now) => {
|
|
@@ -770,18 +1158,26 @@ function useScenePlayLoop({
|
|
|
770
1158
|
last = now;
|
|
771
1159
|
const scene = isPlaying
|
|
772
1160
|
? runtimeRef.current
|
|
773
|
-
: makeScene(sceneData, behaviorClasses,
|
|
1161
|
+
: makeScene(sceneData, behaviorClasses, sprites);
|
|
774
1162
|
if (scene) {
|
|
1163
|
+
const snap = getSnapSettings(sceneData);
|
|
775
1164
|
if (isPlaying) scene.update(dt);
|
|
776
1165
|
if (!isPlaying) scene.camera = { ...editCameraRef.current };
|
|
777
|
-
configureSceneCanvas(canvas, ctx);
|
|
1166
|
+
configureSceneCanvas(canvas, ctx, viewport);
|
|
1167
|
+
// Read selection from the ref each frame so the grid/box gating tracks
|
|
1168
|
+
// the current selection without resetting this loop.
|
|
1169
|
+
const editSelectedActorIds = isPlaying ? [] : selectedActorIdsRef.current ?? [];
|
|
778
1170
|
scene.draw(ctx, {
|
|
779
|
-
selectedActorIds:
|
|
1171
|
+
selectedActorIds: [],
|
|
780
1172
|
marquee: isPlaying ? null : marqueeRef.current,
|
|
781
|
-
showGrid:
|
|
1173
|
+
showGrid: !isPlaying && snap.enabled && editSelectedActorIds.length > 0,
|
|
1174
|
+
gridSize: snap.gridSize,
|
|
782
1175
|
showDebugColliders: false,
|
|
1176
|
+
showCropOutline: !isPlaying,
|
|
1177
|
+
viewport,
|
|
783
1178
|
useCamera: true,
|
|
784
1179
|
editPlaceholders: !isPlaying,
|
|
1180
|
+
editSelectedActorIds,
|
|
785
1181
|
});
|
|
786
1182
|
}
|
|
787
1183
|
raf = requestAnimationFrame(frame);
|
|
@@ -789,17 +1185,16 @@ function useScenePlayLoop({
|
|
|
789
1185
|
raf = requestAnimationFrame(frame);
|
|
790
1186
|
return () => cancelAnimationFrame(raf);
|
|
791
1187
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- `text` proxies `sceneData` identity; resetting on every parse churns the loop and wipes runtime state.
|
|
792
|
-
}, [
|
|
1188
|
+
}, [sprites, isPlaying, text, canvasRef, runtimeRef, marqueeRef, selectedActorIdsRef]);
|
|
793
1189
|
}
|
|
794
|
-
function useScenePlayKeys(runtimeRef
|
|
1190
|
+
function useScenePlayKeys(runtimeRef) {
|
|
795
1191
|
useEffect(() => {
|
|
796
1192
|
const down = (event) => {
|
|
797
|
-
if (event.key === ' ' && !isEditableTarget(event.target)) {
|
|
798
|
-
event.preventDefault();
|
|
799
|
-
setIsPlaying((current) => !current);
|
|
800
|
-
return;
|
|
801
|
-
}
|
|
802
1193
|
if (!runtimeRef.current) return;
|
|
1194
|
+
// Space is a game input while playing — stop the browser from scrolling
|
|
1195
|
+
// the panel or re-triggering a focused button (the Play/Stop toggle keeps
|
|
1196
|
+
// focus after a click) instead of reaching gameplay.
|
|
1197
|
+
if (event.key === ' ') event.preventDefault();
|
|
803
1198
|
runtimeRef.current.keys.add(event.key);
|
|
804
1199
|
};
|
|
805
1200
|
const up = (event) => {
|
|
@@ -812,7 +1207,7 @@ function useScenePlayKeys(runtimeRef, setIsPlaying) {
|
|
|
812
1207
|
window.removeEventListener('keydown', down);
|
|
813
1208
|
window.removeEventListener('keyup', up);
|
|
814
1209
|
};
|
|
815
|
-
}, [runtimeRef
|
|
1210
|
+
}, [runtimeRef]);
|
|
816
1211
|
}
|
|
817
1212
|
function isEditableTarget(target) {
|
|
818
1213
|
return (
|
|
@@ -820,7 +1215,9 @@ function isEditableTarget(target) {
|
|
|
820
1215
|
!!target.closest('input, textarea, select, [contenteditable="true"]')
|
|
821
1216
|
);
|
|
822
1217
|
}
|
|
823
|
-
|
|
1218
|
+
// Clone/Delete moved to the on-canvas SelectionOverlay toolbar; this inspector
|
|
1219
|
+
// now only carries the multi-select status text and "Deselect all".
|
|
1220
|
+
function MultiSelectInspector({ count, onDeselectAll }) {
|
|
824
1221
|
return (
|
|
825
1222
|
<div style={{ padding: '14px 16px', display: 'flex', flexDirection: 'column', gap: 12 }}>
|
|
826
1223
|
<div style={{ fontSize: 13, opacity: 0.8 }}>
|
|
@@ -829,20 +1226,6 @@ function MultiSelectInspector({ count, onDelete, onDuplicate, onDeselectAll }) {
|
|
|
829
1226
|
: `${count} actor${count === 1 ? '' : 's'} selected`}
|
|
830
1227
|
</div>
|
|
831
1228
|
<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
1229
|
<button type="button" onClick={onDeselectAll} style={inspectorActionStyle(false)}>
|
|
847
1230
|
<Icon name="times" /> Deselect all
|
|
848
1231
|
</button>
|
|
@@ -976,42 +1359,25 @@ function AddBehaviorPicker({ available, onAdd }) {
|
|
|
976
1359
|
return (
|
|
977
1360
|
<div
|
|
978
1361
|
style={{
|
|
979
|
-
|
|
1362
|
+
height: 'var(--castle-hotbar-row-full)',
|
|
1363
|
+
padding: '0 16px',
|
|
1364
|
+
display: 'flex',
|
|
1365
|
+
alignItems: 'center',
|
|
980
1366
|
borderBottom: '1px solid var(--castle-inspector-divider)',
|
|
981
1367
|
}}>
|
|
982
|
-
<
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
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>
|
|
1368
|
+
<select
|
|
1369
|
+
className={styles.select}
|
|
1370
|
+
value=""
|
|
1371
|
+
onChange={(event) => {
|
|
1372
|
+
if (event.target.value) onAdd(event.target.value);
|
|
1373
|
+
}}>
|
|
1374
|
+
<option value="">+ Add behavior</option>
|
|
1375
|
+
{available.map((behavior) => (
|
|
1376
|
+
<option key={behavior.behaviorName} value={behavior.behaviorName}>
|
|
1377
|
+
{behavior.behaviorName}
|
|
1378
|
+
</option>
|
|
1379
|
+
))}
|
|
1380
|
+
</select>
|
|
1015
1381
|
</div>
|
|
1016
1382
|
);
|
|
1017
1383
|
}
|