castle-web-cli 0.4.77 → 0.4.79
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent-prompts.d.ts +4 -1
- package/dist/agent-prompts.js +28 -7
- package/dist/agent.d.ts +7 -2
- package/dist/agent.js +655 -51
- package/dist/ide.js +2 -0
- package/dist/native/loop.d.ts +2 -0
- package/dist/native/loop.js +698 -0
- package/dist/native/openrouter.d.ts +55 -0
- package/dist/native/openrouter.js +354 -0
- package/dist/native/playtest-browser.d.ts +34 -0
- package/dist/native/playtest-browser.js +354 -0
- package/dist/native/playtest-executor.d.ts +3 -0
- package/dist/native/playtest-executor.js +156 -0
- package/dist/native/playtest.d.ts +131 -0
- package/dist/native/playtest.js +314 -0
- package/dist/native/tools.d.ts +38 -0
- package/dist/native/tools.js +630 -0
- package/dist/native/types.d.ts +40 -0
- package/dist/native/types.js +41 -0
- package/dist/serve.js +12 -0
- package/dist/shell/assets/{index-CvHiGhAV.js → index-CNT3KxJb.js} +37 -37
- package/dist/shell/assets/{index-QteLRDnK.css → index-RZrw5gQ2.css} +1 -1
- package/dist/shell/index.html +2 -2
- package/kits/basic-2d/CLAUDE.md +29 -3
- package/kits/basic-2d/behaviors/Layout.jsx +10 -0
- package/kits/basic-2d/behaviors/Sprite.jsx +16 -4
- package/kits/basic-2d/blueprints/cauldron.scene +22 -0
- package/kits/basic-2d/castle.json +5 -7
- package/kits/basic-2d/docs/pxart-format.md +83 -4
- package/kits/basic-2d/drawings/cauldron.pxart +113 -0
- package/kits/basic-2d/editors/BlueprintLibrary.jsx +247 -0
- package/kits/basic-2d/editors/PlayOnly.jsx +1 -0
- package/kits/basic-2d/editors/PxArtEditor.jsx +68 -9
- package/kits/basic-2d/editors/SceneEditor.jsx +424 -418
- package/kits/basic-2d/editors/SelectionOverlay.jsx +125 -63
- package/kits/basic-2d/editors/SingleEditor.jsx +11 -2
- package/kits/basic-2d/editors/editorHistory.js +95 -17
- package/kits/basic-2d/editors/inspectorSheet.js +5 -19
- package/kits/basic-2d/editors/pixelEditorChrome.jsx +21 -8
- package/kits/basic-2d/editors/pixelInspector.jsx +39 -0
- package/kits/basic-2d/editors/pxArtEditorModel.js +15 -1
- package/kits/basic-2d/engine/ScenePlayer.jsx +2 -2
- package/kits/basic-2d/engine/blueprint.js +423 -0
- package/kits/basic-2d/engine/files.js +1 -1
- package/kits/basic-2d/engine/pxart.js +49 -2
- package/kits/basic-2d/engine/pxartSmooth.js +222 -0
- package/kits/basic-2d/engine/scene.js +29 -29
- package/kits/basic-2d/engine/ui.jsx +160 -21
- package/kits/basic-2d/engine/ui.module.css +263 -27
- package/kits/basic-2d/pnpm-workspace.yaml +3 -0
- package/kits/basic-2d/scenes/main.scene +3 -13
- package/package.json +2 -1
- package/kits/basic-2d/drawings/pig.pxart +0 -26
|
@@ -1,22 +1,36 @@
|
|
|
1
|
-
import React, { useCallback, useEffect,
|
|
2
|
-
import {
|
|
1
|
+
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { writeFile } from 'castle-web-sdk';
|
|
3
3
|
import { basename, formatJson, parseJsonFile } from '../engine/files';
|
|
4
4
|
import {
|
|
5
|
-
addActor,
|
|
6
5
|
arrangeActors,
|
|
7
6
|
cardSize,
|
|
8
7
|
configureSceneCanvas,
|
|
9
8
|
duplicateActors,
|
|
10
9
|
makeScene,
|
|
10
|
+
mintActorId,
|
|
11
11
|
removeActorComponent,
|
|
12
12
|
removeActors,
|
|
13
13
|
screenToCard,
|
|
14
14
|
setActorComponent,
|
|
15
15
|
} from '../engine/scene';
|
|
16
|
+
import {
|
|
17
|
+
addActorWithBlueprint,
|
|
18
|
+
blueprintDropXY,
|
|
19
|
+
cascadeDeleteBlueprint,
|
|
20
|
+
forkActorToBlueprint,
|
|
21
|
+
getBlueprintTemplate,
|
|
22
|
+
isBlueprintPath,
|
|
23
|
+
migrateOrphanActors,
|
|
24
|
+
placeBlueprintInstance,
|
|
25
|
+
setBlueprintName,
|
|
26
|
+
setBlueprintTemplateComponents,
|
|
27
|
+
setInstanceComponent,
|
|
28
|
+
} from '../engine/blueprint';
|
|
16
29
|
import {
|
|
17
30
|
cx,
|
|
18
31
|
CheckboxField,
|
|
19
32
|
ColorField,
|
|
33
|
+
ConfirmDialog,
|
|
20
34
|
EditorBody,
|
|
21
35
|
EditorHeader,
|
|
22
36
|
Icon,
|
|
@@ -31,8 +45,14 @@ import {
|
|
|
31
45
|
import { AutoInspector } from '../engine/autoInspector';
|
|
32
46
|
import { SceneUI } from '../engine/SceneUI';
|
|
33
47
|
import { SelectionOverlay } from './SelectionOverlay';
|
|
48
|
+
import { BlueprintLibrary } from './BlueprintLibrary';
|
|
34
49
|
import { behaviorClasses, findBehaviorClass } from './behaviorRegistry';
|
|
35
50
|
import { useEditHistory, useUndoRedoShortcuts } from './editorHistory';
|
|
51
|
+
// A blueprint file's `actors[0]` has no `id` (it's the template, not an
|
|
52
|
+
// instance) -- injected/stripped at the read/write boundary below so the rest
|
|
53
|
+
// of this editor (selection, drag, inspector) can keep treating it like any
|
|
54
|
+
// other single-actor scene.
|
|
55
|
+
const BLUEPRINT_TEMPLATE_ACTOR_ID = '__template__';
|
|
36
56
|
const LONG_PRESS_MS = 500;
|
|
37
57
|
const DRAG_THRESHOLD = 4;
|
|
38
58
|
const DEFAULT_GRID_SIZE = 25;
|
|
@@ -63,17 +83,54 @@ function editViewportForZoom(zoom) {
|
|
|
63
83
|
originY: EDIT_VIEWPORT.originY / z,
|
|
64
84
|
};
|
|
65
85
|
}
|
|
86
|
+
// A blueprint file on disk has no `id` on its template actor -- inject a
|
|
87
|
+
// synthetic one so every other piece of editor code that expects `actor.id`
|
|
88
|
+
// (selection, drag, inspector lookups) keeps working unmodified. Stripped
|
|
89
|
+
// again by `stripTemplateId` right before a write (see `serialize` below).
|
|
90
|
+
function withTemplateId(sceneData, isBlueprintFile) {
|
|
91
|
+
if (!sceneData || !isBlueprintFile) return sceneData;
|
|
92
|
+
const next = structuredClone(sceneData);
|
|
93
|
+
next.actors = next.actors ?? [];
|
|
94
|
+
if (next.actors[0]) next.actors[0].id = BLUEPRINT_TEMPLATE_ACTOR_ID;
|
|
95
|
+
return next;
|
|
96
|
+
}
|
|
97
|
+
function stripTemplateId(sceneData) {
|
|
98
|
+
const next = structuredClone(sceneData);
|
|
99
|
+
if (next.actors?.[0]) delete next.actors[0].id;
|
|
100
|
+
return next;
|
|
101
|
+
}
|
|
102
|
+
// On opening a scene with orphan actors (no `blueprint` field -- the
|
|
103
|
+
// pre-blueprint shape), mint one blueprint per orphan and rewrite the scene
|
|
104
|
+
// in place. Runs once per distinct file text; the blueprint-file writes are
|
|
105
|
+
// fire-and-forget (they're brand new files, nothing else references them
|
|
106
|
+
// yet) while the scene rewrite goes through this file's own `onChange` so it
|
|
107
|
+
// picks up the normal history/live-reload path.
|
|
108
|
+
function useMigrateOrphanActors({ isBlueprintFile, sceneData, files, text, onChange }) {
|
|
109
|
+
useEffect(() => {
|
|
110
|
+
if (isBlueprintFile || !sceneData) return;
|
|
111
|
+
const hasOrphans = (sceneData.actors ?? []).some((actor) => !actor.blueprint);
|
|
112
|
+
if (!hasOrphans) return;
|
|
113
|
+
const { sceneData: migrated, newBlueprintFiles } = migrateOrphanActors(files, behaviorClasses, sceneData);
|
|
114
|
+
for (const blueprintFile of newBlueprintFiles) void writeFile(blueprintFile.path, blueprintFile.text);
|
|
115
|
+
onChange(formatJson(migrated));
|
|
116
|
+
// Re-run only when this file's own text changes (e.g. after the migration
|
|
117
|
+
// write lands, or the user opens a different orphaned file) -- `files`
|
|
118
|
+
// changes on every keystroke elsewhere and would otherwise loop.
|
|
119
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
120
|
+
}, [text, isBlueprintFile]);
|
|
121
|
+
}
|
|
66
122
|
export function SceneEditor({
|
|
67
123
|
path,
|
|
68
124
|
text,
|
|
69
125
|
files,
|
|
70
126
|
sprites,
|
|
71
127
|
onChange,
|
|
128
|
+
onChangeFile,
|
|
72
129
|
onToggleFiles,
|
|
73
130
|
filesOpen,
|
|
74
131
|
headerHost,
|
|
75
132
|
selectedActorIds,
|
|
76
|
-
onSelectActorIds,
|
|
133
|
+
onSelectActorIds: onSelectActorIdsRaw,
|
|
77
134
|
multiSelectMode,
|
|
78
135
|
onSetMultiSelectMode,
|
|
79
136
|
}) {
|
|
@@ -84,15 +141,54 @@ export function SceneEditor({
|
|
|
84
141
|
const selectedActorIdsRef = useRef(selectedActorIds);
|
|
85
142
|
selectedActorIdsRef.current = selectedActorIds;
|
|
86
143
|
const [isPlaying, setIsPlaying] = useState(false);
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
144
|
+
// Hotbar blueprint selection (tap a slot -> inspect/edit the blueprint in
|
|
145
|
+
// the sidebar; drag remains the placement gesture). Mutually exclusive with
|
|
146
|
+
// actor selection: ANY actor-selection change -- including clearing it via
|
|
147
|
+
// empty-canvas click or Escape -- also drops the blueprint selection, which
|
|
148
|
+
// is why every internal caller goes through the wrapper below.
|
|
149
|
+
const [selectedBlueprintPath, setSelectedBlueprintPath] = useState(null);
|
|
150
|
+
const onSelectActorIds = useCallback(
|
|
151
|
+
(ids) => {
|
|
152
|
+
setSelectedBlueprintPath(null);
|
|
153
|
+
onSelectActorIdsRaw(ids);
|
|
154
|
+
},
|
|
155
|
+
[onSelectActorIdsRaw]
|
|
156
|
+
);
|
|
157
|
+
const onSelectBlueprint = useCallback(
|
|
158
|
+
(blueprintPath) => {
|
|
159
|
+
onSelectActorIdsRaw([]);
|
|
160
|
+
onSetMultiSelectMode(false);
|
|
161
|
+
setSelectedBlueprintPath(blueprintPath);
|
|
162
|
+
},
|
|
163
|
+
[onSelectActorIdsRaw, onSetMultiSelectMode]
|
|
164
|
+
);
|
|
165
|
+
const isBlueprintFile = isBlueprintPath(path);
|
|
166
|
+
// Working state for an in-progress hotbar drag-placement (see `dragPlace`
|
|
167
|
+
// below). A ref, not state: pointer moves can outpace re-renders.
|
|
168
|
+
const dragPlaceRef = useRef(null);
|
|
169
|
+
const history = useEditHistory(text, onChange, path);
|
|
170
|
+
// Disabled during play: the header undo/redo buttons are already disabled
|
|
171
|
+
// then, but without this the keyboard shortcut could still rewrite the
|
|
172
|
+
// scene file out from under the running play-mode runtime.
|
|
173
|
+
useUndoRedoShortcuts(history, !isPlaying);
|
|
174
|
+
const showMulti = !isBlueprintFile && (selectedActorIds.length > 1 || multiSelectMode);
|
|
90
175
|
const inspectorSheet = useSelectionInspectorSheet(true);
|
|
91
|
-
const { value:
|
|
176
|
+
const { value: parsedSceneData, error } = parseJsonFile(path, text);
|
|
177
|
+
const sceneData = withTemplateId(parsedSceneData, isBlueprintFile);
|
|
178
|
+
// Sparse per-instance overrides merged with each actor's blueprint template
|
|
179
|
+
// -- what hit-testing, drag-start snapshots, and the inspector should read.
|
|
180
|
+
// Never the basis for a write (see engine/blueprint.js).
|
|
181
|
+
const previewSceneData = sceneData ? makeScene(sceneData, behaviorClasses, sprites, files).data : null;
|
|
182
|
+
const serialize = useCallback(
|
|
183
|
+
(next) => formatJson(isBlueprintFile ? stripTemplateId(next) : next),
|
|
184
|
+
[isBlueprintFile]
|
|
185
|
+
);
|
|
92
186
|
const getRuntime = useCallback(() => runtimeRef.current, []);
|
|
187
|
+
useMigrateOrphanActors({ isBlueprintFile, sceneData, files, text, onChange });
|
|
93
188
|
useScenePlayLoop({
|
|
94
189
|
sceneData,
|
|
95
190
|
sprites,
|
|
191
|
+
files,
|
|
96
192
|
isPlaying,
|
|
97
193
|
text,
|
|
98
194
|
canvasRef,
|
|
@@ -107,25 +203,28 @@ export function SceneEditor({
|
|
|
107
203
|
canvasRef,
|
|
108
204
|
sceneData,
|
|
109
205
|
sprites,
|
|
206
|
+
files,
|
|
110
207
|
isPlaying,
|
|
111
208
|
selectedActorIds,
|
|
112
209
|
onSelectActorIds,
|
|
113
|
-
multiSelectMode,
|
|
210
|
+
multiSelectMode: showMulti && multiSelectMode,
|
|
114
211
|
onSetMultiSelectMode,
|
|
115
212
|
marqueeRef,
|
|
116
213
|
editCameraRef,
|
|
117
|
-
applyScene: (next) => onChange(
|
|
214
|
+
applyScene: (next) => onChange(serialize(next)),
|
|
118
215
|
recordSceneSnapshot: history.recordSnapshot,
|
|
119
216
|
});
|
|
120
217
|
const playPointer = usePlayPointerGesture({ canvasRef, runtimeRef });
|
|
121
218
|
useSelectionKeyboard({
|
|
122
219
|
sceneData,
|
|
123
220
|
selectedActorIds,
|
|
221
|
+
selectedBlueprintPath,
|
|
124
222
|
onSelectActorIds,
|
|
125
223
|
multiSelectMode,
|
|
126
224
|
onSetMultiSelectMode,
|
|
127
225
|
isPlaying,
|
|
128
|
-
|
|
226
|
+
isBlueprintFile,
|
|
227
|
+
commitScene: (next) => history.commit(serialize(next)),
|
|
129
228
|
});
|
|
130
229
|
if (!sceneData) {
|
|
131
230
|
return (
|
|
@@ -142,37 +241,194 @@ export function SceneEditor({
|
|
|
142
241
|
</>
|
|
143
242
|
);
|
|
144
243
|
}
|
|
145
|
-
const
|
|
244
|
+
const rawSelectedActor =
|
|
146
245
|
selectedActorIds.length === 1
|
|
147
246
|
? sceneData.actors.find((actor) => actor.id === selectedActorIds[0])
|
|
148
247
|
: undefined;
|
|
248
|
+
const selectedActor =
|
|
249
|
+
selectedActorIds.length === 1
|
|
250
|
+
? previewSceneData.actors.find((actor) => actor.id === selectedActorIds[0])
|
|
251
|
+
: undefined;
|
|
252
|
+
const selectedBlueprintName = rawSelectedActor?.blueprint
|
|
253
|
+
? getBlueprintTemplate(files, rawSelectedActor.blueprint)?.name ?? basename(rawSelectedActor.blueprint)
|
|
254
|
+
: undefined;
|
|
255
|
+
// Hotbar-selected blueprint, resolved fresh from live files each render. A
|
|
256
|
+
// selection whose file vanished (cascade delete, external edit) degrades to
|
|
257
|
+
// "nothing selected" rather than a broken inspector.
|
|
258
|
+
const hotbarBlueprint =
|
|
259
|
+
!isBlueprintFile && selectedBlueprintPath
|
|
260
|
+
? getBlueprintTemplate(files, selectedBlueprintPath)
|
|
261
|
+
: null;
|
|
262
|
+
// Template edits from the sidebar write the BLUEPRINT file, not this scene:
|
|
263
|
+
// optimistic fold into live files state (instant merged-instance updates
|
|
264
|
+
// here) + debounced writeFile via SingleEditor's saver, which also makes
|
|
265
|
+
// `shouldSkipPath` ignore the fs echo of our own write. Same plain
|
|
266
|
+
// per-property merge semantics as editing the blueprint's own file. Outside
|
|
267
|
+
// this scene file's undo history (accepted v1 cross-file gap, like fork).
|
|
268
|
+
const updateBlueprintTemplate = (mutate) => {
|
|
269
|
+
if (!selectedBlueprintPath || !hotbarBlueprint) return;
|
|
270
|
+
const components = structuredClone(hotbarBlueprint.components);
|
|
271
|
+
mutate(components);
|
|
272
|
+
const nextText = setBlueprintTemplateComponents(files, selectedBlueprintPath, components);
|
|
273
|
+
if (nextText == null) return;
|
|
274
|
+
if (onChangeFile) onChangeFile(selectedBlueprintPath, nextText);
|
|
275
|
+
else void writeFile(selectedBlueprintPath, nextText);
|
|
276
|
+
};
|
|
277
|
+
const blueprintActions = {
|
|
278
|
+
setComponent: (behaviorName, nextProps) =>
|
|
279
|
+
updateBlueprintTemplate((components) => {
|
|
280
|
+
components[behaviorName] = { ...(components[behaviorName] ?? {}), ...nextProps };
|
|
281
|
+
}),
|
|
282
|
+
addBehavior: (behaviorName) => {
|
|
283
|
+
const Behavior = findBehaviorClass(behaviorName);
|
|
284
|
+
if (!Behavior) return;
|
|
285
|
+
updateBlueprintTemplate((components) => {
|
|
286
|
+
components[behaviorName] = { ...Behavior.defaultProps };
|
|
287
|
+
});
|
|
288
|
+
},
|
|
289
|
+
removeBehavior: (behaviorName) =>
|
|
290
|
+
updateBlueprintTemplate((components) => {
|
|
291
|
+
delete components[behaviorName];
|
|
292
|
+
}),
|
|
293
|
+
// Renaming writes only the blueprint file's top-level `name` (not this
|
|
294
|
+
// scene), so it flows through the same optimistic files update + debounced
|
|
295
|
+
// writeFile path as template edits above.
|
|
296
|
+
rename: (name) => {
|
|
297
|
+
if (!selectedBlueprintPath) return;
|
|
298
|
+
const nextText = setBlueprintName(files, selectedBlueprintPath, name);
|
|
299
|
+
if (nextText == null) return;
|
|
300
|
+
if (onChangeFile) onChangeFile(selectedBlueprintPath, nextText);
|
|
301
|
+
else void writeFile(selectedBlueprintPath, nextText);
|
|
302
|
+
},
|
|
303
|
+
};
|
|
149
304
|
const actions = makeSceneActions({
|
|
150
305
|
sceneData,
|
|
306
|
+
files,
|
|
307
|
+
serialize,
|
|
151
308
|
commit: history.commit,
|
|
152
309
|
selectedActorIds,
|
|
153
310
|
onSelectActorIds,
|
|
311
|
+
isBlueprintFile,
|
|
154
312
|
});
|
|
155
313
|
const snapSettings = getSnapSettings(sceneData);
|
|
156
314
|
const stageWrapStyle = {
|
|
157
315
|
backgroundColor: darkenSceneBackground(sceneData.background),
|
|
158
316
|
};
|
|
159
317
|
const updateSceneSettings = (nextScene) => {
|
|
160
|
-
history.commit(
|
|
318
|
+
history.commit(serialize(setSceneSettings(sceneData, nextScene)));
|
|
161
319
|
};
|
|
162
320
|
const updateSceneEditorSettings = (nextEditor) => {
|
|
163
|
-
history.commit(
|
|
321
|
+
history.commit(serialize(setSceneEditorSettings(sceneData, nextEditor)));
|
|
164
322
|
};
|
|
165
|
-
const
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
323
|
+
const onAddActor = () => {
|
|
324
|
+
const { sceneData: next, newId, blueprintFile, drawingFile } = addActorWithBlueprint(
|
|
325
|
+
sceneData,
|
|
326
|
+
files
|
|
327
|
+
);
|
|
328
|
+
void writeFile(drawingFile.path, drawingFile.text);
|
|
329
|
+
void writeFile(blueprintFile.path, blueprintFile.text);
|
|
330
|
+
history.commit(serialize(next));
|
|
331
|
+
onSelectActorIds([newId]);
|
|
332
|
+
};
|
|
333
|
+
// Drag-out from the hotbar places a REAL instance the moment the pointer
|
|
334
|
+
// enters the stage, so the rest of the drag is a normal grid-snapped actor
|
|
335
|
+
// move and the drop is just "let go" (castle-client belt behavior). One
|
|
336
|
+
// undo entry per gesture: a snapshot at entry, then plain onChange while
|
|
337
|
+
// the drag is live. Leaving the stage removes the actor again (back to the
|
|
338
|
+
// floating preview), which lands the text back at the snapshot -- the
|
|
339
|
+
// history's no-op trimming keeps undo clean. `dragPlaceRef` carries the
|
|
340
|
+
// working scene between pointer events so a move never applies to a parse
|
|
341
|
+
// that predates the placement.
|
|
342
|
+
const dragPlace = {
|
|
343
|
+
enter: (blueprintPath, position) => {
|
|
344
|
+
history.recordSnapshot();
|
|
345
|
+
const { sceneData: next, newId } = placeBlueprintInstance(sceneData, files, blueprintPath, position, snapSettings);
|
|
346
|
+
const layout = next.actors.find((actor) => actor.id === newId)?.components?.Layout ?? {};
|
|
347
|
+
dragPlaceRef.current = { actorId: newId, blueprintPath, sceneData: next, x: layout.x, y: layout.y };
|
|
348
|
+
onChange(serialize(next));
|
|
349
|
+
onSelectActorIds([newId]);
|
|
350
|
+
},
|
|
351
|
+
move: (position) => {
|
|
352
|
+
const drag = dragPlaceRef.current;
|
|
353
|
+
if (!drag) return;
|
|
354
|
+
const { x, y } = blueprintDropXY(files, drag.blueprintPath, position, snapSettings);
|
|
355
|
+
if (x === drag.x && y === drag.y) return;
|
|
356
|
+
const next = structuredClone(drag.sceneData);
|
|
357
|
+
const actor = next.actors.find((candidate) => candidate.id === drag.actorId);
|
|
358
|
+
if (!actor) return;
|
|
359
|
+
actor.components.Layout = { ...actor.components.Layout, x, y };
|
|
360
|
+
Object.assign(drag, { sceneData: next, x, y });
|
|
361
|
+
onChange(serialize(next));
|
|
362
|
+
},
|
|
363
|
+
leave: () => {
|
|
364
|
+
const drag = dragPlaceRef.current;
|
|
365
|
+
if (!drag) return;
|
|
366
|
+
dragPlaceRef.current = null;
|
|
367
|
+
onChange(serialize(removeActors(drag.sceneData, [drag.actorId])));
|
|
368
|
+
onSelectActorIds([]);
|
|
369
|
+
},
|
|
370
|
+
drop: () => {
|
|
371
|
+
const drag = dragPlaceRef.current;
|
|
372
|
+
if (!drag) return;
|
|
373
|
+
dragPlaceRef.current = null;
|
|
374
|
+
onSelectActorIds([drag.actorId]);
|
|
375
|
+
},
|
|
376
|
+
};
|
|
377
|
+
const onDeleteBlueprint = (blueprintPath) => {
|
|
378
|
+
// Every write here targets a plain `scenes/*.scene` (cascaded instance
|
|
379
|
+
// removal) or the blueprint file itself (tombstoned) -- never a
|
|
380
|
+
// blueprint's own template id, so no `serialize`/strip step is needed.
|
|
381
|
+
for (const write of cascadeDeleteBlueprint(files, blueprintPath)) {
|
|
382
|
+
if (write.path === path) history.commit(write.text);
|
|
383
|
+
else void writeFile(write.path, write.text);
|
|
384
|
+
}
|
|
385
|
+
onSelectActorIds([]);
|
|
386
|
+
};
|
|
387
|
+
const onForkSelection = () => {
|
|
388
|
+
if (isBlueprintFile || !rawSelectedActor?.blueprint) return;
|
|
389
|
+
const result = forkActorToBlueprint(files, behaviorClasses, sceneData, rawSelectedActor.id);
|
|
390
|
+
if (!result) return;
|
|
391
|
+
void writeFile(result.blueprintFile.path, result.blueprintFile.text);
|
|
392
|
+
history.commit(serialize(result.sceneData));
|
|
393
|
+
};
|
|
394
|
+
const inspectorLabel = isBlueprintFile
|
|
395
|
+
? 'Blueprint'
|
|
396
|
+
: hotbarBlueprint
|
|
397
|
+
? 'Blueprint'
|
|
398
|
+
: showMulti
|
|
399
|
+
? 'Multi-select'
|
|
400
|
+
: selectedActor
|
|
401
|
+
? 'Inspector'
|
|
402
|
+
: 'Scene';
|
|
403
|
+
const inspectorHint = isBlueprintFile
|
|
404
|
+
? sceneData.name ?? basename(path)
|
|
405
|
+
: hotbarBlueprint
|
|
406
|
+
? hotbarBlueprint.name
|
|
407
|
+
: showMulti
|
|
408
|
+
? `${selectedActorIds.length} actor${selectedActorIds.length === 1 ? '' : 's'} selected`
|
|
409
|
+
: selectedActor
|
|
410
|
+
? `actor ${selectedActor.id}`
|
|
411
|
+
: 'scene settings';
|
|
171
412
|
return (
|
|
172
413
|
<>
|
|
173
414
|
<EditorHeader
|
|
174
|
-
title={
|
|
175
|
-
|
|
415
|
+
title={
|
|
416
|
+
isBlueprintFile ? (
|
|
417
|
+
<span className={styles.editorHeaderTitleRow}>
|
|
418
|
+
<span className={styles.blueprintBadge}>Blueprint</span>
|
|
419
|
+
{sceneData.name ?? basename(path)}
|
|
420
|
+
</span>
|
|
421
|
+
) : (
|
|
422
|
+
sceneData.name ?? basename(path)
|
|
423
|
+
)
|
|
424
|
+
}
|
|
425
|
+
subtitle={
|
|
426
|
+
isPlaying
|
|
427
|
+
? 'WASD / arrows'
|
|
428
|
+
: isBlueprintFile
|
|
429
|
+
? 'editing template -- changes apply to every placed instance'
|
|
430
|
+
: `${sceneData.actors.length} actors`
|
|
431
|
+
}
|
|
176
432
|
right={
|
|
177
433
|
<HeaderPlaybackButtons
|
|
178
434
|
isPlaying={isPlaying}
|
|
@@ -187,18 +443,20 @@ export function SceneEditor({
|
|
|
187
443
|
<EditorBody>
|
|
188
444
|
<div className={styles.sceneWorkspace}>
|
|
189
445
|
<div className={styles.sceneTools}>
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
446
|
+
{isBlueprintFile ? null : (
|
|
447
|
+
<BlueprintLibrary
|
|
448
|
+
files={files}
|
|
449
|
+
sprites={sprites}
|
|
450
|
+
canvasRef={canvasRef}
|
|
451
|
+
editCameraRef={editCameraRef}
|
|
452
|
+
isPlaying={isPlaying}
|
|
453
|
+
selectedBlueprintPath={selectedBlueprintPath}
|
|
454
|
+
onSelectBlueprint={onSelectBlueprint}
|
|
455
|
+
onAddActor={onAddActor}
|
|
456
|
+
dragPlace={dragPlace}
|
|
457
|
+
onDeleteBlueprint={onDeleteBlueprint}
|
|
458
|
+
/>
|
|
459
|
+
)}
|
|
202
460
|
</div>
|
|
203
461
|
<div className={styles.stageWrap} style={stageWrapStyle}>
|
|
204
462
|
<div className={styles.stageCard} data-castle-card>
|
|
@@ -206,13 +464,18 @@ export function SceneEditor({
|
|
|
206
464
|
<canvas
|
|
207
465
|
ref={canvasRef}
|
|
208
466
|
className={cx(styles.stageCanvas, !isPlaying && styles.stageCanvasEdit)}
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
467
|
+
// Focusable so pointer-down pulls keyboard focus into this
|
|
468
|
+
// editor iframe and Cmd+Z reaches our undo handler instead of
|
|
469
|
+
// the browser's (see PxArtEditor startTool for the full
|
|
470
|
+
// rationale -- Safari won't keep focus on a clicked
|
|
471
|
+
// non-focusable canvas).
|
|
472
|
+
tabIndex={-1}
|
|
473
|
+
onPointerDown={(event) => {
|
|
474
|
+
event.currentTarget.focus({ preventScroll: true });
|
|
475
|
+
if (isPlaying) return playPointer.onPointerDown(event);
|
|
476
|
+
if (panGesture.isSpacePanning()) return panGesture.onPointerDown(event);
|
|
477
|
+
return gesture.onPointerDown(event);
|
|
478
|
+
}}
|
|
216
479
|
onPointerMove={(event) =>
|
|
217
480
|
isPlaying
|
|
218
481
|
? playPointer.onPointerMove(event)
|
|
@@ -242,12 +505,13 @@ export function SceneEditor({
|
|
|
242
505
|
canvasRef={canvasRef}
|
|
243
506
|
editCameraRef={editCameraRef}
|
|
244
507
|
sceneData={sceneData}
|
|
508
|
+
previewSceneData={previewSceneData}
|
|
245
509
|
selectedActorIds={selectedActorIds}
|
|
246
510
|
snap={snapSettings}
|
|
247
|
-
onArrange={actions.arrangeSelection}
|
|
248
|
-
onClone={actions.duplicateSelection}
|
|
249
|
-
onDelete={actions.deleteSelection}
|
|
250
|
-
applyScene={(next) => onChange(
|
|
511
|
+
onArrange={isBlueprintFile ? undefined : actions.arrangeSelection}
|
|
512
|
+
onClone={isBlueprintFile ? undefined : actions.duplicateSelection}
|
|
513
|
+
onDelete={isBlueprintFile ? undefined : actions.deleteSelection}
|
|
514
|
+
applyScene={(next) => onChange(serialize(next))}
|
|
251
515
|
recordSnapshot={history.recordSnapshot}
|
|
252
516
|
/>
|
|
253
517
|
)}
|
|
@@ -258,7 +522,17 @@ export function SceneEditor({
|
|
|
258
522
|
<SheetGrabHandle label={inspectorLabel} hint={inspectorHint} />
|
|
259
523
|
</div>
|
|
260
524
|
<div className={cx(styles.sheetBody, styles.inspectorBody)}>
|
|
261
|
-
{
|
|
525
|
+
{hotbarBlueprint ? (
|
|
526
|
+
<BlueprintInspector
|
|
527
|
+
name={hotbarBlueprint.name}
|
|
528
|
+
template={hotbarBlueprint}
|
|
529
|
+
files={files}
|
|
530
|
+
onRename={blueprintActions.rename}
|
|
531
|
+
onSetComponent={blueprintActions.setComponent}
|
|
532
|
+
onAddBehavior={blueprintActions.addBehavior}
|
|
533
|
+
onRemoveBehavior={blueprintActions.removeBehavior}
|
|
534
|
+
/>
|
|
535
|
+
) : showMulti ? (
|
|
262
536
|
<MultiSelectInspector
|
|
263
537
|
count={selectedActorIds.length}
|
|
264
538
|
onDeselectAll={() => {
|
|
@@ -269,10 +543,13 @@ export function SceneEditor({
|
|
|
269
543
|
) : selectedActor ? (
|
|
270
544
|
<ActorInspector
|
|
271
545
|
selectedActor={selectedActor}
|
|
546
|
+
rawActor={rawSelectedActor}
|
|
547
|
+
blueprintName={selectedBlueprintName}
|
|
272
548
|
files={files}
|
|
273
549
|
onSetComponent={actions.updateComponent}
|
|
274
550
|
onAddBehavior={actions.addBehavior}
|
|
275
551
|
onRemoveBehavior={actions.removeBehavior}
|
|
552
|
+
onFork={isBlueprintFile ? undefined : onForkSelection}
|
|
276
553
|
/>
|
|
277
554
|
) : (
|
|
278
555
|
<SceneInspector
|
|
@@ -320,352 +597,23 @@ function HeaderPlaybackButtons({ isPlaying, setIsPlaying, history }) {
|
|
|
320
597
|
</>
|
|
321
598
|
);
|
|
322
599
|
}
|
|
323
|
-
//
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
history,
|
|
335
|
-
}) {
|
|
336
|
-
const blueprints = sceneData.editor?.blueprints ?? [];
|
|
337
|
-
const dragRef = useRef(null);
|
|
338
|
-
const suppressClickRef = useRef(false);
|
|
339
|
-
const [dragPreview, setDragPreview] = useState(null);
|
|
340
|
-
// Right-click context menu anchored at the cursor: { blueprint, x, y }.
|
|
341
|
-
const [contextMenu, setContextMenu] = useState(null);
|
|
342
|
-
const closeContextMenu = useCallback(() => setContextMenu(null), []);
|
|
343
|
-
const onAdd = () => {
|
|
344
|
-
const { sceneData: next, newId } = addActor(sceneData, files, sprites);
|
|
345
|
-
history.recordSnapshot();
|
|
346
|
-
onChange(formatJson(next));
|
|
347
|
-
onSelectActorIds([newId]);
|
|
348
|
-
};
|
|
349
|
-
const onSaveSelection = () => {
|
|
350
|
-
const next = addSelectedActorBlueprint(sceneData, selectedActorIds);
|
|
351
|
-
if (next === sceneData) return;
|
|
352
|
-
history.commit(formatJson(next));
|
|
353
|
-
};
|
|
354
|
-
const onStamp = (blueprint, position) => {
|
|
355
|
-
const { sceneData: next, newId } = stampActorBlueprint(sceneData, blueprint, position);
|
|
356
|
-
if (next === sceneData) return;
|
|
357
|
-
history.commit(formatJson(next));
|
|
358
|
-
onSelectActorIds([newId]);
|
|
359
|
-
};
|
|
360
|
-
const onRemoveBlueprint = (blueprintId) => {
|
|
361
|
-
const next = removeActorBlueprint(sceneData, blueprintId);
|
|
362
|
-
if (next === sceneData) return;
|
|
363
|
-
history.commit(formatJson(next));
|
|
364
|
-
};
|
|
365
|
-
const onBlueprintContextMenu = (event, blueprint) => {
|
|
366
|
-
if (isPlaying) return;
|
|
367
|
-
event.preventDefault();
|
|
368
|
-
event.stopPropagation();
|
|
369
|
-
setContextMenu({ blueprint, x: event.clientX, y: event.clientY });
|
|
370
|
-
};
|
|
371
|
-
const onBlueprintPointerDown = (event, blueprint) => {
|
|
372
|
-
if (isPlaying || event.button !== 0) return;
|
|
373
|
-
event.preventDefault();
|
|
374
|
-
const drag = {
|
|
375
|
-
blueprint,
|
|
376
|
-
pointerId: event.pointerId,
|
|
377
|
-
startX: event.clientX,
|
|
378
|
-
startY: event.clientY,
|
|
379
|
-
moved: false,
|
|
380
|
-
};
|
|
381
|
-
dragRef.current = drag;
|
|
382
|
-
try {
|
|
383
|
-
event.currentTarget.setPointerCapture(event.pointerId);
|
|
384
|
-
} catch {
|
|
385
|
-
// Window listeners still finish the drag if capture is unavailable.
|
|
386
|
-
}
|
|
387
|
-
const onMove = (moveEvent) => {
|
|
388
|
-
if (moveEvent.pointerId !== drag.pointerId) return;
|
|
389
|
-
if (!drag.moved && Math.hypot(moveEvent.clientX - drag.startX, moveEvent.clientY - drag.startY) > DRAG_THRESHOLD) {
|
|
390
|
-
drag.moved = true;
|
|
391
|
-
}
|
|
392
|
-
if (drag.moved) {
|
|
393
|
-
setDragPreview({
|
|
394
|
-
blueprint,
|
|
395
|
-
x: moveEvent.clientX,
|
|
396
|
-
y: moveEvent.clientY,
|
|
397
|
-
overStage: Boolean(eventToStagePoint(moveEvent, canvasRef, editCameraRef)),
|
|
398
|
-
});
|
|
399
|
-
}
|
|
400
|
-
};
|
|
401
|
-
const onUp = (upEvent) => {
|
|
402
|
-
if (upEvent.pointerId !== drag.pointerId) return;
|
|
403
|
-
window.removeEventListener('pointermove', onMove);
|
|
404
|
-
window.removeEventListener('pointerup', onUp);
|
|
405
|
-
window.removeEventListener('pointercancel', onUp);
|
|
406
|
-
dragRef.current = null;
|
|
407
|
-
setDragPreview(null);
|
|
408
|
-
if (!drag.moved) return;
|
|
409
|
-
suppressClickRef.current = true;
|
|
410
|
-
const position = eventToStagePoint(upEvent, canvasRef, editCameraRef);
|
|
411
|
-
if (position) onStamp(blueprint, position);
|
|
412
|
-
};
|
|
413
|
-
window.addEventListener('pointermove', onMove);
|
|
414
|
-
window.addEventListener('pointerup', onUp);
|
|
415
|
-
window.addEventListener('pointercancel', onUp);
|
|
416
|
-
};
|
|
417
|
-
return (
|
|
418
|
-
<div className={styles.bpHotbar} aria-label="Blueprints">
|
|
419
|
-
<button
|
|
420
|
-
type="button"
|
|
421
|
-
className={styles.bpActionSlot}
|
|
422
|
-
aria-label="Save selected actor as blueprint"
|
|
423
|
-
title="Save selected actor as blueprint"
|
|
424
|
-
onClick={onSaveSelection}
|
|
425
|
-
disabled={isPlaying || selectedActorIds.length !== 1}>
|
|
426
|
-
<Icon name="stamp" />
|
|
427
|
-
</button>
|
|
428
|
-
<button
|
|
429
|
-
type="button"
|
|
430
|
-
className={styles.bpActionSlot}
|
|
431
|
-
aria-label="Add actor"
|
|
432
|
-
title="Add actor"
|
|
433
|
-
onClick={onAdd}
|
|
434
|
-
disabled={isPlaying}>
|
|
435
|
-
<span className={styles.dashedSquareIcon} aria-hidden="true" />
|
|
436
|
-
</button>
|
|
437
|
-
{blueprints.map((blueprint) => (
|
|
438
|
-
<button
|
|
439
|
-
key={blueprint.id}
|
|
440
|
-
type="button"
|
|
441
|
-
className={styles.bpSlot}
|
|
442
|
-
title={`Add ${blueprint.name}`}
|
|
443
|
-
disabled={isPlaying}
|
|
444
|
-
onPointerDown={(event) => onBlueprintPointerDown(event, blueprint)}
|
|
445
|
-
onContextMenu={(event) => onBlueprintContextMenu(event, blueprint)}
|
|
446
|
-
onClick={() => {
|
|
447
|
-
if (suppressClickRef.current) {
|
|
448
|
-
suppressClickRef.current = false;
|
|
449
|
-
return;
|
|
450
|
-
}
|
|
451
|
-
onStamp(blueprint);
|
|
452
|
-
}}>
|
|
453
|
-
<BlueprintThumbnail blueprint={blueprint} sprites={sprites} />
|
|
454
|
-
</button>
|
|
455
|
-
))}
|
|
456
|
-
{dragPreview ? (
|
|
457
|
-
<BlueprintDragPreview preview={dragPreview} sprites={sprites} />
|
|
458
|
-
) : null}
|
|
459
|
-
{contextMenu ? (
|
|
460
|
-
<BlueprintContextMenu
|
|
461
|
-
x={contextMenu.x}
|
|
462
|
-
y={contextMenu.y}
|
|
463
|
-
onRemove={() => onRemoveBlueprint(contextMenu.blueprint.id)}
|
|
464
|
-
onClose={closeContextMenu}
|
|
465
|
-
/>
|
|
466
|
-
) : null}
|
|
467
|
-
</div>
|
|
468
|
-
);
|
|
469
|
-
}
|
|
470
|
-
// Cursor-anchored right-click menu for a hotbar blueprint. Mirrors the
|
|
471
|
-
// pxArtTimeline ContextMenu: reuses the shared `selArrangeMenu`/`selArrangeItem`
|
|
472
|
-
// chrome, fixed-positions at the click point with viewport clamping, and closes
|
|
473
|
-
// on outside pointerdown or Escape.
|
|
474
|
-
function BlueprintContextMenu({ x, y, onRemove, onClose }) {
|
|
475
|
-
const ref = useRef(null);
|
|
476
|
-
const [pos, setPos] = useState({ left: x, top: y });
|
|
477
|
-
useLayoutEffect(() => {
|
|
478
|
-
const el = ref.current;
|
|
479
|
-
if (!el) return;
|
|
480
|
-
const rect = el.getBoundingClientRect();
|
|
481
|
-
const margin = 8;
|
|
482
|
-
const maxLeft = Math.max(margin, window.innerWidth - rect.width - margin);
|
|
483
|
-
const maxTop = Math.max(margin, window.innerHeight - rect.height - margin);
|
|
484
|
-
setPos({ left: Math.min(x, maxLeft), top: Math.min(y, maxTop) });
|
|
485
|
-
}, [x, y]);
|
|
486
|
-
useEffect(() => {
|
|
487
|
-
const onPointerDown = (event) => {
|
|
488
|
-
if (!ref.current?.contains(event.target)) onClose();
|
|
489
|
-
};
|
|
490
|
-
const onKeyDown = (event) => {
|
|
491
|
-
if (event.key === 'Escape') onClose();
|
|
492
|
-
};
|
|
493
|
-
window.addEventListener('pointerdown', onPointerDown);
|
|
494
|
-
window.addEventListener('keydown', onKeyDown);
|
|
495
|
-
return () => {
|
|
496
|
-
window.removeEventListener('pointerdown', onPointerDown);
|
|
497
|
-
window.removeEventListener('keydown', onKeyDown);
|
|
498
|
-
};
|
|
499
|
-
}, [onClose]);
|
|
500
|
-
return (
|
|
501
|
-
<div
|
|
502
|
-
ref={ref}
|
|
503
|
-
className={styles.selArrangeMenu}
|
|
504
|
-
role="menu"
|
|
505
|
-
aria-label="Blueprint"
|
|
506
|
-
style={{ position: 'fixed', left: pos.left, top: pos.top, transform: 'none' }}
|
|
507
|
-
onPointerDown={(event) => event.stopPropagation()}
|
|
508
|
-
onContextMenu={(event) => event.preventDefault()}>
|
|
509
|
-
<button
|
|
510
|
-
type="button"
|
|
511
|
-
role="menuitem"
|
|
512
|
-
className={styles.selArrangeItem}
|
|
513
|
-
onClick={() => {
|
|
514
|
-
onRemove();
|
|
515
|
-
onClose();
|
|
516
|
-
}}>
|
|
517
|
-
Remove blueprint
|
|
518
|
-
</button>
|
|
519
|
-
</div>
|
|
520
|
-
);
|
|
521
|
-
}
|
|
522
|
-
function BlueprintDragPreview({ preview, sprites }) {
|
|
523
|
-
const layout = preview.blueprint.actor?.components?.Layout;
|
|
524
|
-
const width = Math.max(24, Math.min(80, layout?.width ?? 48));
|
|
525
|
-
const height = Math.max(24, Math.min(80, layout?.height ?? 48));
|
|
526
|
-
return (
|
|
527
|
-
<div
|
|
528
|
-
className={cx(styles.bpDragPreview, preview.overStage && styles.bpDragPreviewOverStage)}
|
|
529
|
-
style={{
|
|
530
|
-
left: preview.x,
|
|
531
|
-
top: preview.y,
|
|
532
|
-
width,
|
|
533
|
-
height,
|
|
534
|
-
transform: `translate(-50%, -50%) rotate(${layout?.rotation ?? 0}deg)`,
|
|
535
|
-
}}>
|
|
536
|
-
<BlueprintThumbnail blueprint={preview.blueprint} sprites={sprites} preview />
|
|
537
|
-
</div>
|
|
538
|
-
);
|
|
539
|
-
}
|
|
540
|
-
function BlueprintThumbnail({ blueprint, sprites, preview = false }) {
|
|
541
|
-
const canvasRef = useRef(null);
|
|
542
|
-
const spritePath = blueprint.actor?.components?.Sprite?.file;
|
|
543
|
-
const sprite = spritePath ? sprites?.[spritePath] : null;
|
|
544
|
-
useEffect(() => {
|
|
545
|
-
const canvas = canvasRef.current;
|
|
546
|
-
if (!canvas || !sprite) return;
|
|
547
|
-
// A .pxart blueprint renders its first frame via the SDK.
|
|
548
|
-
renderSpriteFrame(sprite, 0, canvas);
|
|
549
|
-
}, [sprite]);
|
|
550
|
-
if (!sprite) {
|
|
551
|
-
return (
|
|
552
|
-
<span className={styles.bpSlotFallback}>
|
|
553
|
-
<Icon name="clone" />
|
|
554
|
-
</span>
|
|
555
|
-
);
|
|
556
|
-
}
|
|
557
|
-
return (
|
|
558
|
-
<canvas
|
|
559
|
-
ref={canvasRef}
|
|
560
|
-
className={cx(styles.bpSlotThumb, preview && styles.bpPreviewThumb)}
|
|
561
|
-
aria-hidden="true"
|
|
562
|
-
/>
|
|
563
|
-
);
|
|
564
|
-
}
|
|
565
|
-
function addSelectedActorBlueprint(sceneData, selectedActorIds) {
|
|
566
|
-
if (selectedActorIds.length !== 1) return sceneData;
|
|
567
|
-
const actor = sceneData.actors.find((candidate) => candidate.id === selectedActorIds[0]);
|
|
568
|
-
if (!actor) return sceneData;
|
|
569
|
-
const next = structuredClone(sceneData);
|
|
570
|
-
const copy = structuredClone(actor);
|
|
571
|
-
delete copy.id;
|
|
572
|
-
if (copy.components?.Layout) {
|
|
573
|
-
copy.components.Layout = {
|
|
574
|
-
...copy.components.Layout,
|
|
575
|
-
x: 0,
|
|
576
|
-
y: 0,
|
|
577
|
-
};
|
|
578
|
-
}
|
|
579
|
-
const blueprints = [...(next.editor?.blueprints ?? [])];
|
|
580
|
-
const name = makeBlueprintName(actor, blueprints.length);
|
|
581
|
-
blueprints.push({
|
|
582
|
-
id: makeBlueprintId(blueprints),
|
|
583
|
-
name,
|
|
584
|
-
actor: copy,
|
|
585
|
-
});
|
|
586
|
-
next.editor = {
|
|
587
|
-
...(next.editor ?? {}),
|
|
588
|
-
blueprints,
|
|
589
|
-
};
|
|
590
|
-
return next;
|
|
591
|
-
}
|
|
592
|
-
function stampActorBlueprint(sceneData, blueprint, position) {
|
|
593
|
-
if (!blueprint?.actor) return { sceneData, newId: null };
|
|
594
|
-
const next = structuredClone(sceneData);
|
|
595
|
-
const existingIds = new Set(next.actors.map((actor) => actor.id));
|
|
596
|
-
const actor = structuredClone(blueprint.actor);
|
|
597
|
-
const newId = makeActorId(existingIds);
|
|
598
|
-
actor.id = newId;
|
|
599
|
-
actor.components = actor.components ?? {};
|
|
600
|
-
const layout = actor.components.Layout ?? { width: 64, height: 64, z: 0, rotation: 0 };
|
|
601
|
-
actor.components.Layout = {
|
|
602
|
-
...layout,
|
|
603
|
-
x: Math.round((position?.x ?? cardSize.width / 2) - layout.width / 2),
|
|
604
|
-
y: Math.round((position?.y ?? cardSize.height / 2) - layout.height / 2),
|
|
605
|
-
};
|
|
606
|
-
next.actors.push(actor);
|
|
607
|
-
return { sceneData: next, newId };
|
|
608
|
-
}
|
|
609
|
-
function removeActorBlueprint(sceneData, blueprintId) {
|
|
610
|
-
const blueprints = sceneData.editor?.blueprints ?? [];
|
|
611
|
-
if (!blueprints.some((blueprint) => blueprint.id === blueprintId)) return sceneData;
|
|
612
|
-
const next = structuredClone(sceneData);
|
|
613
|
-
next.editor = {
|
|
614
|
-
...(next.editor ?? {}),
|
|
615
|
-
blueprints: (next.editor?.blueprints ?? []).filter(
|
|
616
|
-
(blueprint) => blueprint.id !== blueprintId
|
|
617
|
-
),
|
|
618
|
-
};
|
|
619
|
-
return next;
|
|
620
|
-
}
|
|
621
|
-
function eventToStagePoint(event, canvasRef, editCameraRef) {
|
|
622
|
-
const canvas = canvasRef.current;
|
|
623
|
-
if (!canvas) return null;
|
|
624
|
-
const rect = canvas.getBoundingClientRect();
|
|
625
|
-
if (
|
|
626
|
-
event.clientX < rect.left ||
|
|
627
|
-
event.clientX > rect.right ||
|
|
628
|
-
event.clientY < rect.top ||
|
|
629
|
-
event.clientY > rect.bottom
|
|
630
|
-
) {
|
|
631
|
-
return null;
|
|
632
|
-
}
|
|
633
|
-
const raw = screenToCard(canvas, event.clientX, event.clientY);
|
|
634
|
-
const camera = editCameraRef.current ?? { x: 0, y: 0 };
|
|
635
|
-
return {
|
|
636
|
-
x: raw.x + camera.x,
|
|
637
|
-
y: raw.y + camera.y,
|
|
638
|
-
};
|
|
639
|
-
}
|
|
640
|
-
function makeBlueprintName(actor, index) {
|
|
641
|
-
const spriteFile = actor.components?.Sprite?.file;
|
|
642
|
-
if (spriteFile) return basename(spriteFile).replace(/\.pxart$/i, '');
|
|
643
|
-
return `Actor ${index + 1}`;
|
|
644
|
-
}
|
|
645
|
-
function makeBlueprintId(blueprints) {
|
|
646
|
-
const existing = new Set(blueprints.map((blueprint) => blueprint.id));
|
|
647
|
-
for (let index = 1; index < 1000; index++) {
|
|
648
|
-
const candidate = `blueprint-${index}`;
|
|
649
|
-
if (!existing.has(candidate)) return candidate;
|
|
650
|
-
}
|
|
651
|
-
return `blueprint-${Date.now()}`;
|
|
652
|
-
}
|
|
653
|
-
function makeActorId(existingIds) {
|
|
654
|
-
for (let attempt = 0; attempt < 64; attempt++) {
|
|
655
|
-
const candidate = Math.floor(Math.random() * 0xffffffff)
|
|
656
|
-
.toString(16)
|
|
657
|
-
.padStart(8, '0');
|
|
658
|
-
if (!existingIds.has(candidate)) return candidate;
|
|
659
|
-
}
|
|
660
|
-
return `actor-${Date.now()}`;
|
|
661
|
-
}
|
|
662
|
-
function makeSceneActions({ sceneData, commit, selectedActorIds, onSelectActorIds }) {
|
|
663
|
-
function commitScene(next) {
|
|
664
|
-
commit(formatJson(next));
|
|
600
|
+
// `updateComponent` goes through `setInstanceComponent`, which already
|
|
601
|
+
// branches on whether the target actor has a `blueprint` ref: for a
|
|
602
|
+
// blueprint's own template actor (no ref) it's a plain merge; for a normal
|
|
603
|
+
// instance it diffs each patched prop against the blueprint and records only
|
|
604
|
+
// what actually differs, dropping an override that becomes redundant. Add/
|
|
605
|
+
// remove-behavior stay whole-component operations straight against the
|
|
606
|
+
// instance's own sparse `components` -- there's no per-property baseline to
|
|
607
|
+
// diff when a component doesn't exist on one side at all.
|
|
608
|
+
function makeSceneActions({ sceneData, files, serialize, commit, selectedActorIds, onSelectActorIds, isBlueprintFile }) {
|
|
609
|
+
function commitScene(next, options) {
|
|
610
|
+
commit(serialize(next), options);
|
|
665
611
|
}
|
|
666
612
|
return {
|
|
667
613
|
updateComponent: (actorId, behaviorName, nextProps) =>
|
|
668
|
-
commitScene(
|
|
614
|
+
commitScene(setInstanceComponent(files, behaviorClasses, sceneData, actorId, behaviorName, nextProps), {
|
|
615
|
+
coalesceKey: componentCoalesceKey(actorId, behaviorName, nextProps),
|
|
616
|
+
}),
|
|
669
617
|
addBehavior: (actorId, behaviorName) => {
|
|
670
618
|
const Behavior = findBehaviorClass(behaviorName);
|
|
671
619
|
if (!Behavior) return;
|
|
@@ -676,23 +624,28 @@ function makeSceneActions({ sceneData, commit, selectedActorIds, onSelectActorId
|
|
|
676
624
|
removeBehavior: (actorId, behaviorName) =>
|
|
677
625
|
commitScene(removeActorComponent(sceneData, actorId, behaviorName)),
|
|
678
626
|
deleteSelection: () => {
|
|
679
|
-
if (selectedActorIds.length === 0) return;
|
|
627
|
+
if (isBlueprintFile || selectedActorIds.length === 0) return;
|
|
680
628
|
commitScene(removeActors(sceneData, selectedActorIds));
|
|
681
629
|
onSelectActorIds([]);
|
|
682
630
|
},
|
|
683
631
|
duplicateSelection: () => {
|
|
684
|
-
if (selectedActorIds.length === 0) return;
|
|
632
|
+
if (isBlueprintFile || selectedActorIds.length === 0) return;
|
|
685
633
|
const { sceneData: next, newIds } = duplicateActors(sceneData, selectedActorIds);
|
|
686
634
|
commitScene(next);
|
|
687
635
|
if (newIds.length) onSelectActorIds(newIds);
|
|
688
636
|
},
|
|
689
637
|
arrangeSelection: (action) => {
|
|
690
|
-
if (selectedActorIds.length === 0) return;
|
|
638
|
+
if (isBlueprintFile || selectedActorIds.length === 0) return;
|
|
691
639
|
const next = arrangeActors(sceneData, selectedActorIds, action);
|
|
692
640
|
if (next !== sceneData) commitScene(next);
|
|
693
641
|
},
|
|
694
642
|
};
|
|
695
643
|
}
|
|
644
|
+
// setActorComponent merges nextProps into the actor's existing component, so
|
|
645
|
+
// its keys ARE the fields this call actually changed.
|
|
646
|
+
function componentCoalesceKey(actorId, behaviorName, nextProps) {
|
|
647
|
+
return `${actorId}:${behaviorName}:${Object.keys(nextProps).sort().join(',')}`;
|
|
648
|
+
}
|
|
696
649
|
function setSceneSettings(sceneData, nextScene) {
|
|
697
650
|
return {
|
|
698
651
|
...sceneData,
|
|
@@ -737,20 +690,10 @@ function parseHexColor(value) {
|
|
|
737
690
|
};
|
|
738
691
|
}
|
|
739
692
|
function useSelectionInspectorSheet(open) {
|
|
740
|
-
|
|
741
|
-
//
|
|
742
|
-
//
|
|
743
|
-
|
|
744
|
-
return useMobileSheet({
|
|
745
|
-
snap: effectiveSnap,
|
|
746
|
-
baseClassName: styles.inspector,
|
|
747
|
-
onTransition: (direction) => {
|
|
748
|
-
if (!open) return;
|
|
749
|
-
if (direction === 'tap') setSnap((prev) => (prev === 'high' ? 'low' : 'high'));
|
|
750
|
-
else if (direction === 'down') setSnap('low');
|
|
751
|
-
else if (direction === 'up') setSnap('high');
|
|
752
|
-
},
|
|
753
|
-
});
|
|
693
|
+
// Inspector visibility is driven by selection / multi-mode; the sheet hook
|
|
694
|
+
// owns the drag-resize height and remembers it across selections while
|
|
695
|
+
// mounted (and restores it when the sheet reopens).
|
|
696
|
+
return useMobileSheet({ open, baseClassName: styles.inspector });
|
|
754
697
|
}
|
|
755
698
|
function useSelectionGesture(args) {
|
|
756
699
|
const dragRef = useRef(null);
|
|
@@ -765,7 +708,7 @@ function useSelectionGesture(args) {
|
|
|
765
708
|
const cam = current.editCameraRef.current;
|
|
766
709
|
const point = { x: raw.x + cam.x, y: raw.y + cam.y };
|
|
767
710
|
current.canvasRef.current.setPointerCapture(event.pointerId);
|
|
768
|
-
const scene = makeScene(current.sceneData, behaviorClasses, current.sprites);
|
|
711
|
+
const scene = makeScene(current.sceneData, behaviorClasses, current.sprites, current.files);
|
|
769
712
|
const actor = scene.actorAt(point.x, point.y);
|
|
770
713
|
const drag = {
|
|
771
714
|
pointerId: event.pointerId,
|
|
@@ -1160,7 +1103,7 @@ function finalizeMarquee(drag, current) {
|
|
|
1160
1103
|
const m = current.marqueeRef.current;
|
|
1161
1104
|
if (!m || !current.sceneData) return;
|
|
1162
1105
|
if (m.width === 0 && m.height === 0) return;
|
|
1163
|
-
const scene = makeScene(current.sceneData, behaviorClasses, current.sprites);
|
|
1106
|
+
const scene = makeScene(current.sceneData, behaviorClasses, current.sprites, current.files);
|
|
1164
1107
|
const hits = scene.actorIdsInRect(m);
|
|
1165
1108
|
const merged = new Set(drag.selectionBeforeMarquee);
|
|
1166
1109
|
for (const id of hits) merged.add(id);
|
|
@@ -1189,21 +1132,22 @@ function useSelectionKeyboard(args) {
|
|
|
1189
1132
|
const current = ref.current;
|
|
1190
1133
|
if (current.isPlaying || isEditableTarget(event.target)) return;
|
|
1191
1134
|
if (event.key === 'Escape') {
|
|
1192
|
-
if (current.selectedActorIds.length || current.multiSelectMode) {
|
|
1135
|
+
if (current.selectedActorIds.length || current.multiSelectMode || current.selectedBlueprintPath) {
|
|
1193
1136
|
event.preventDefault();
|
|
1137
|
+
// The wrapped setter also clears any hotbar blueprint selection.
|
|
1194
1138
|
current.onSelectActorIds([]);
|
|
1195
1139
|
current.onSetMultiSelectMode(false);
|
|
1196
1140
|
}
|
|
1197
1141
|
return;
|
|
1198
1142
|
}
|
|
1199
1143
|
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'a') {
|
|
1200
|
-
if (!current.sceneData) return;
|
|
1144
|
+
if (!current.sceneData || current.isBlueprintFile) return;
|
|
1201
1145
|
event.preventDefault();
|
|
1202
1146
|
current.onSelectActorIds(current.sceneData.actors.map((actor) => actor.id));
|
|
1203
1147
|
return;
|
|
1204
1148
|
}
|
|
1205
1149
|
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'd') {
|
|
1206
|
-
if (!current.sceneData || current.selectedActorIds.length === 0) return;
|
|
1150
|
+
if (!current.sceneData || current.isBlueprintFile || current.selectedActorIds.length === 0) return;
|
|
1207
1151
|
event.preventDefault();
|
|
1208
1152
|
const { sceneData: next, newIds } = duplicateActors(
|
|
1209
1153
|
current.sceneData,
|
|
@@ -1214,7 +1158,7 @@ function useSelectionKeyboard(args) {
|
|
|
1214
1158
|
return;
|
|
1215
1159
|
}
|
|
1216
1160
|
if (event.key === 'Delete' || event.key === 'Backspace') {
|
|
1217
|
-
if (!current.sceneData || current.selectedActorIds.length === 0) return;
|
|
1161
|
+
if (!current.sceneData || current.isBlueprintFile || current.selectedActorIds.length === 0) return;
|
|
1218
1162
|
event.preventDefault();
|
|
1219
1163
|
current.commitScene(removeActors(current.sceneData, current.selectedActorIds));
|
|
1220
1164
|
current.onSelectActorIds([]);
|
|
@@ -1227,6 +1171,7 @@ function useSelectionKeyboard(args) {
|
|
|
1227
1171
|
function useScenePlayLoop({
|
|
1228
1172
|
sceneData,
|
|
1229
1173
|
sprites,
|
|
1174
|
+
files,
|
|
1230
1175
|
isPlaying,
|
|
1231
1176
|
text,
|
|
1232
1177
|
canvasRef,
|
|
@@ -1245,7 +1190,7 @@ function useScenePlayLoop({
|
|
|
1245
1190
|
return;
|
|
1246
1191
|
}
|
|
1247
1192
|
if (!sceneData) return;
|
|
1248
|
-
runtimeRef.current = makeScene(sceneData, behaviorClasses, sprites).clone();
|
|
1193
|
+
runtimeRef.current = makeScene(sceneData, behaviorClasses, sprites, files).clone();
|
|
1249
1194
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1250
1195
|
}, [sprites, isPlaying, text, runtimeRef]);
|
|
1251
1196
|
// Animation loop -- draws edit-mode previews and ticks the play-mode runtime.
|
|
@@ -1270,7 +1215,7 @@ function useScenePlayLoop({
|
|
|
1270
1215
|
: editViewportForZoom(editCameraRef.current.zoom);
|
|
1271
1216
|
const scene = isPlaying
|
|
1272
1217
|
? runtimeRef.current
|
|
1273
|
-
: makeScene(sceneData, behaviorClasses, sprites);
|
|
1218
|
+
: makeScene(sceneData, behaviorClasses, sprites, files);
|
|
1274
1219
|
if (scene) {
|
|
1275
1220
|
const snap = getSnapSettings(sceneData);
|
|
1276
1221
|
if (isPlaying) scene.update(dt);
|
|
@@ -1297,7 +1242,7 @@ function useScenePlayLoop({
|
|
|
1297
1242
|
raf = requestAnimationFrame(frame);
|
|
1298
1243
|
return () => cancelAnimationFrame(raf);
|
|
1299
1244
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- `text` proxies `sceneData` identity; resetting on every parse churns the loop and wipes runtime state.
|
|
1300
|
-
}, [sprites, isPlaying, text, canvasRef, runtimeRef, marqueeRef, selectedActorIdsRef]);
|
|
1245
|
+
}, [sprites, files, isPlaying, text, canvasRef, runtimeRef, marqueeRef, selectedActorIdsRef]);
|
|
1301
1246
|
}
|
|
1302
1247
|
function useScenePlayKeys(runtimeRef) {
|
|
1303
1248
|
useEffect(() => {
|
|
@@ -1388,7 +1333,56 @@ function SceneInspector({ sceneData, snapSettings, onChangeScene, onChangeEditor
|
|
|
1388
1333
|
</Panel>
|
|
1389
1334
|
);
|
|
1390
1335
|
}
|
|
1391
|
-
|
|
1336
|
+
// Sidebar inspector for a hotbar-selected blueprint: badge + name header, then
|
|
1337
|
+
// the template's components through the same ActorInspector panels used for
|
|
1338
|
+
// actors -- the template is presented as a synthetic actor with no `blueprint`
|
|
1339
|
+
// ref, so ActorInspector treats it exactly like a blueprint file's template
|
|
1340
|
+
// (plain edits, no instance hint, Layout not removable). The action callbacks
|
|
1341
|
+
// drop the synthetic actor id and write the blueprint FILE (see
|
|
1342
|
+
// `updateBlueprintTemplate` in SceneEditor).
|
|
1343
|
+
function BlueprintInspector({
|
|
1344
|
+
name,
|
|
1345
|
+
template,
|
|
1346
|
+
files,
|
|
1347
|
+
onRename,
|
|
1348
|
+
onSetComponent,
|
|
1349
|
+
onAddBehavior,
|
|
1350
|
+
onRemoveBehavior,
|
|
1351
|
+
}) {
|
|
1352
|
+
const templateActor = { id: '__blueprint__', components: template.components };
|
|
1353
|
+
return (
|
|
1354
|
+
<>
|
|
1355
|
+
<div className={styles.instanceHint}>
|
|
1356
|
+
<input
|
|
1357
|
+
className={styles.blueprintNameInput}
|
|
1358
|
+
value={name}
|
|
1359
|
+
onChange={(event) => onRename?.(event.target.value)}
|
|
1360
|
+
aria-label="Blueprint name"
|
|
1361
|
+
spellCheck={false}
|
|
1362
|
+
/>
|
|
1363
|
+
</div>
|
|
1364
|
+
<ActorInspector
|
|
1365
|
+
selectedActor={templateActor}
|
|
1366
|
+
rawActor={templateActor}
|
|
1367
|
+
files={files}
|
|
1368
|
+
onSetComponent={(actorId, behaviorName, nextProps) => onSetComponent(behaviorName, nextProps)}
|
|
1369
|
+
onAddBehavior={(actorId, behaviorName) => onAddBehavior(behaviorName)}
|
|
1370
|
+
onRemoveBehavior={(actorId, behaviorName) => onRemoveBehavior(behaviorName)}
|
|
1371
|
+
/>
|
|
1372
|
+
</>
|
|
1373
|
+
);
|
|
1374
|
+
}
|
|
1375
|
+
function ActorInspector({
|
|
1376
|
+
selectedActor,
|
|
1377
|
+
rawActor,
|
|
1378
|
+
blueprintName,
|
|
1379
|
+
files,
|
|
1380
|
+
onSetComponent,
|
|
1381
|
+
onAddBehavior,
|
|
1382
|
+
onRemoveBehavior,
|
|
1383
|
+
onFork,
|
|
1384
|
+
}) {
|
|
1385
|
+
const isInstance = Boolean(rawActor?.blueprint);
|
|
1392
1386
|
const presentNames = new Set(
|
|
1393
1387
|
Object.entries(selectedActor.components)
|
|
1394
1388
|
.filter(([, component]) => !!component)
|
|
@@ -1400,6 +1394,14 @@ function ActorInspector({ selectedActor, files, onSetComponent, onAddBehavior, o
|
|
|
1400
1394
|
const behaviorEntries = Object.entries(selectedActor.components).filter((entry) => !!entry[1]);
|
|
1401
1395
|
return (
|
|
1402
1396
|
<>
|
|
1397
|
+
{isInstance ? (
|
|
1398
|
+
<div className={styles.instanceHint}>
|
|
1399
|
+
<span className={styles.instanceHintLabel}>Instance of {blueprintName}</span>
|
|
1400
|
+
{onFork ? (
|
|
1401
|
+
<IconButton icon="code-fork" label="New blueprint from this actor" onClick={onFork} />
|
|
1402
|
+
) : null}
|
|
1403
|
+
</div>
|
|
1404
|
+
) : null}
|
|
1403
1405
|
<AddBehaviorPicker
|
|
1404
1406
|
available={availableBehaviors}
|
|
1405
1407
|
onAdd={(behaviorName) => onAddBehavior(selectedActor.id, behaviorName)}
|
|
@@ -1424,8 +1426,12 @@ function ActorInspector({ selectedActor, files, onSetComponent, onAddBehavior, o
|
|
|
1424
1426
|
setComponent={(nextProps) => onSetComponent(selectedActor.id, behaviorName, nextProps)}
|
|
1425
1427
|
/>
|
|
1426
1428
|
);
|
|
1427
|
-
// Layout is core to every actor -- it cannot be removed.
|
|
1428
|
-
|
|
1429
|
+
// Layout is core to every actor -- it cannot be removed. A component
|
|
1430
|
+
// an instance only has via its blueprint (no override of its own yet)
|
|
1431
|
+
// has nothing to remove either -- v1 has no per-instance "hide this
|
|
1432
|
+
// inherited component" concept (see propertyMeta punt list).
|
|
1433
|
+
const removable =
|
|
1434
|
+
behaviorName !== 'Layout' && (!isInstance || Boolean(rawActor.components?.[behaviorName]));
|
|
1429
1435
|
const isLast = index === behaviorEntries.length - 1;
|
|
1430
1436
|
// Trash renders before the panel so the panel stays the wrapper's
|
|
1431
1437
|
// last child -- the panel's own `:last-child` border zeroes out, and
|