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.
- 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 +703 -222
- package/kits/basic-2d/editors/SelectionOverlay.jsx +818 -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 +169 -13
- 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/playConsole.js +66 -0
- 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 +1385 -332
- 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-vmdKwUE1.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,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
|
-
|
|
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
|
-
|
|
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,
|
|
76
|
-
useScenePlayKeys(runtimeRef
|
|
106
|
+
const panGesture = usePanGesture({ canvasRef, editCameraRef, isPlaying });
|
|
107
|
+
useScenePlayKeys(runtimeRef);
|
|
77
108
|
const gesture = useSelectionGesture({
|
|
78
109
|
canvasRef,
|
|
79
110
|
sceneData,
|
|
80
|
-
|
|
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
|
-
|
|
105
|
-
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
<
|
|
177
|
-
|
|
178
|
-
|
|
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
|
-
<
|
|
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
|
-
|
|
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
|
-
|
|
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=
|
|
281
|
-
label=
|
|
282
|
-
active={
|
|
283
|
-
onClick={() =>
|
|
284
|
-
|
|
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
|
-
|
|
326
|
+
// Blueprint hotbar -- lightweight actor stamps saved in scene.editor.blueprints.
|
|
327
|
+
function BlueprintHotbar({
|
|
290
328
|
sceneData,
|
|
291
329
|
files,
|
|
292
|
-
|
|
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
|
|
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,
|
|
347
|
+
const { sceneData: next, newId } = addActor(sceneData, files, sprites);
|
|
302
348
|
history.recordSnapshot();
|
|
303
|
-
|
|
349
|
+
onChange(formatJson(next));
|
|
304
350
|
onSelectActorIds([newId]);
|
|
305
351
|
};
|
|
306
|
-
const
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
history.
|
|
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
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
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
|
-
<
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
label="
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
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.
|
|
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,
|
|
943
|
+
function usePanGesture({ canvasRef, editCameraRef, isPlaying }) {
|
|
584
944
|
const dragRef = useRef(null);
|
|
585
|
-
const
|
|
586
|
-
|
|
587
|
-
|
|
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
|
|
591
|
-
|
|
592
|
-
|
|
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,
|
|
982
|
+
[canvasRef, editCameraRef]
|
|
595
983
|
);
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
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
|
|
603
|
-
const
|
|
604
|
-
const
|
|
605
|
-
|
|
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:
|
|
608
|
-
y:
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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,
|
|
1251
|
+
runtimeRef.current = makeScene(sceneData, behaviorClasses, sprites).clone();
|
|
757
1252
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
758
|
-
}, [
|
|
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
|
-
|
|
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,
|
|
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:
|
|
1286
|
+
selectedActorIds: [],
|
|
780
1287
|
marquee: isPlaying ? null : marqueeRef.current,
|
|
781
|
-
showGrid:
|
|
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
|
-
}, [
|
|
1303
|
+
}, [sprites, isPlaying, text, canvasRef, runtimeRef, marqueeRef, selectedActorIdsRef]);
|
|
793
1304
|
}
|
|
794
|
-
function useScenePlayKeys(runtimeRef
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
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>
|
|
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
|
}
|