castle-web-cli 0.4.78 → 0.4.80
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/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 +690 -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-yGdKhgfZ.js → index-D3unT7do.js} +37 -37
- package/dist/shell/assets/{index-WE24qX3d.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 +1 -1
- 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 +4 -3
- 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/SceneEditor.jsx +399 -411
- 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 +8 -2
- package/kits/basic-2d/editors/inspectorSheet.js +5 -19
- 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/scene.js +29 -29
- package/kits/basic-2d/engine/ui.jsx +160 -21
- package/kits/basic-2d/engine/ui.module.css +155 -13
- 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,18 +141,54 @@ export function SceneEditor({
|
|
|
84
141
|
const selectedActorIdsRef = useRef(selectedActorIds);
|
|
85
142
|
selectedActorIdsRef.current = selectedActorIds;
|
|
86
143
|
const [isPlaying, setIsPlaying] = useState(false);
|
|
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);
|
|
87
169
|
const history = useEditHistory(text, onChange, path);
|
|
88
170
|
// Disabled during play: the header undo/redo buttons are already disabled
|
|
89
171
|
// then, but without this the keyboard shortcut could still rewrite the
|
|
90
172
|
// scene file out from under the running play-mode runtime.
|
|
91
173
|
useUndoRedoShortcuts(history, !isPlaying);
|
|
92
|
-
const showMulti = selectedActorIds.length > 1 || multiSelectMode;
|
|
174
|
+
const showMulti = !isBlueprintFile && (selectedActorIds.length > 1 || multiSelectMode);
|
|
93
175
|
const inspectorSheet = useSelectionInspectorSheet(true);
|
|
94
|
-
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
|
+
);
|
|
95
186
|
const getRuntime = useCallback(() => runtimeRef.current, []);
|
|
187
|
+
useMigrateOrphanActors({ isBlueprintFile, sceneData, files, text, onChange });
|
|
96
188
|
useScenePlayLoop({
|
|
97
189
|
sceneData,
|
|
98
190
|
sprites,
|
|
191
|
+
files,
|
|
99
192
|
isPlaying,
|
|
100
193
|
text,
|
|
101
194
|
canvasRef,
|
|
@@ -110,25 +203,28 @@ export function SceneEditor({
|
|
|
110
203
|
canvasRef,
|
|
111
204
|
sceneData,
|
|
112
205
|
sprites,
|
|
206
|
+
files,
|
|
113
207
|
isPlaying,
|
|
114
208
|
selectedActorIds,
|
|
115
209
|
onSelectActorIds,
|
|
116
|
-
multiSelectMode,
|
|
210
|
+
multiSelectMode: showMulti && multiSelectMode,
|
|
117
211
|
onSetMultiSelectMode,
|
|
118
212
|
marqueeRef,
|
|
119
213
|
editCameraRef,
|
|
120
|
-
applyScene: (next) => onChange(
|
|
214
|
+
applyScene: (next) => onChange(serialize(next)),
|
|
121
215
|
recordSceneSnapshot: history.recordSnapshot,
|
|
122
216
|
});
|
|
123
217
|
const playPointer = usePlayPointerGesture({ canvasRef, runtimeRef });
|
|
124
218
|
useSelectionKeyboard({
|
|
125
219
|
sceneData,
|
|
126
220
|
selectedActorIds,
|
|
221
|
+
selectedBlueprintPath,
|
|
127
222
|
onSelectActorIds,
|
|
128
223
|
multiSelectMode,
|
|
129
224
|
onSetMultiSelectMode,
|
|
130
225
|
isPlaying,
|
|
131
|
-
|
|
226
|
+
isBlueprintFile,
|
|
227
|
+
commitScene: (next) => history.commit(serialize(next)),
|
|
132
228
|
});
|
|
133
229
|
if (!sceneData) {
|
|
134
230
|
return (
|
|
@@ -145,37 +241,194 @@ export function SceneEditor({
|
|
|
145
241
|
</>
|
|
146
242
|
);
|
|
147
243
|
}
|
|
148
|
-
const
|
|
244
|
+
const rawSelectedActor =
|
|
149
245
|
selectedActorIds.length === 1
|
|
150
246
|
? sceneData.actors.find((actor) => actor.id === selectedActorIds[0])
|
|
151
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
|
+
};
|
|
152
304
|
const actions = makeSceneActions({
|
|
153
305
|
sceneData,
|
|
306
|
+
files,
|
|
307
|
+
serialize,
|
|
154
308
|
commit: history.commit,
|
|
155
309
|
selectedActorIds,
|
|
156
310
|
onSelectActorIds,
|
|
311
|
+
isBlueprintFile,
|
|
157
312
|
});
|
|
158
313
|
const snapSettings = getSnapSettings(sceneData);
|
|
159
314
|
const stageWrapStyle = {
|
|
160
315
|
backgroundColor: darkenSceneBackground(sceneData.background),
|
|
161
316
|
};
|
|
162
317
|
const updateSceneSettings = (nextScene) => {
|
|
163
|
-
history.commit(
|
|
318
|
+
history.commit(serialize(setSceneSettings(sceneData, nextScene)));
|
|
164
319
|
};
|
|
165
320
|
const updateSceneEditorSettings = (nextEditor) => {
|
|
166
|
-
history.commit(
|
|
321
|
+
history.commit(serialize(setSceneEditorSettings(sceneData, nextEditor)));
|
|
322
|
+
};
|
|
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([]);
|
|
167
386
|
};
|
|
168
|
-
const
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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';
|
|
174
412
|
return (
|
|
175
413
|
<>
|
|
176
414
|
<EditorHeader
|
|
177
|
-
title={
|
|
178
|
-
|
|
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
|
+
}
|
|
179
432
|
right={
|
|
180
433
|
<HeaderPlaybackButtons
|
|
181
434
|
isPlaying={isPlaying}
|
|
@@ -190,18 +443,20 @@ export function SceneEditor({
|
|
|
190
443
|
<EditorBody>
|
|
191
444
|
<div className={styles.sceneWorkspace}>
|
|
192
445
|
<div className={styles.sceneTools}>
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
+
)}
|
|
205
460
|
</div>
|
|
206
461
|
<div className={styles.stageWrap} style={stageWrapStyle}>
|
|
207
462
|
<div className={styles.stageCard} data-castle-card>
|
|
@@ -250,12 +505,13 @@ export function SceneEditor({
|
|
|
250
505
|
canvasRef={canvasRef}
|
|
251
506
|
editCameraRef={editCameraRef}
|
|
252
507
|
sceneData={sceneData}
|
|
508
|
+
previewSceneData={previewSceneData}
|
|
253
509
|
selectedActorIds={selectedActorIds}
|
|
254
510
|
snap={snapSettings}
|
|
255
|
-
onArrange={actions.arrangeSelection}
|
|
256
|
-
onClone={actions.duplicateSelection}
|
|
257
|
-
onDelete={actions.deleteSelection}
|
|
258
|
-
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))}
|
|
259
515
|
recordSnapshot={history.recordSnapshot}
|
|
260
516
|
/>
|
|
261
517
|
)}
|
|
@@ -266,7 +522,17 @@ export function SceneEditor({
|
|
|
266
522
|
<SheetGrabHandle label={inspectorLabel} hint={inspectorHint} />
|
|
267
523
|
</div>
|
|
268
524
|
<div className={cx(styles.sheetBody, styles.inspectorBody)}>
|
|
269
|
-
{
|
|
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 ? (
|
|
270
536
|
<MultiSelectInspector
|
|
271
537
|
count={selectedActorIds.length}
|
|
272
538
|
onDeselectAll={() => {
|
|
@@ -277,10 +543,13 @@ export function SceneEditor({
|
|
|
277
543
|
) : selectedActor ? (
|
|
278
544
|
<ActorInspector
|
|
279
545
|
selectedActor={selectedActor}
|
|
546
|
+
rawActor={rawSelectedActor}
|
|
547
|
+
blueprintName={selectedBlueprintName}
|
|
280
548
|
files={files}
|
|
281
549
|
onSetComponent={actions.updateComponent}
|
|
282
550
|
onAddBehavior={actions.addBehavior}
|
|
283
551
|
onRemoveBehavior={actions.removeBehavior}
|
|
552
|
+
onFork={isBlueprintFile ? undefined : onForkSelection}
|
|
284
553
|
/>
|
|
285
554
|
) : (
|
|
286
555
|
<SceneInspector
|
|
@@ -328,355 +597,21 @@ function HeaderPlaybackButtons({ isPlaying, setIsPlaying, history }) {
|
|
|
328
597
|
</>
|
|
329
598
|
);
|
|
330
599
|
}
|
|
331
|
-
//
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
isPlaying,
|
|
341
|
-
onChange,
|
|
342
|
-
history,
|
|
343
|
-
}) {
|
|
344
|
-
const blueprints = sceneData.editor?.blueprints ?? [];
|
|
345
|
-
const dragRef = useRef(null);
|
|
346
|
-
const suppressClickRef = useRef(false);
|
|
347
|
-
const [dragPreview, setDragPreview] = useState(null);
|
|
348
|
-
// Right-click context menu anchored at the cursor: { blueprint, x, y }.
|
|
349
|
-
const [contextMenu, setContextMenu] = useState(null);
|
|
350
|
-
const closeContextMenu = useCallback(() => setContextMenu(null), []);
|
|
351
|
-
const onAdd = () => {
|
|
352
|
-
const { sceneData: next, newId } = addActor(sceneData, files, sprites);
|
|
353
|
-
history.recordSnapshot();
|
|
354
|
-
onChange(formatJson(next));
|
|
355
|
-
onSelectActorIds([newId]);
|
|
356
|
-
};
|
|
357
|
-
const onSaveSelection = () => {
|
|
358
|
-
const next = addSelectedActorBlueprint(sceneData, selectedActorIds);
|
|
359
|
-
if (next === sceneData) return;
|
|
360
|
-
history.commit(formatJson(next));
|
|
361
|
-
};
|
|
362
|
-
const onStamp = (blueprint, position) => {
|
|
363
|
-
const { sceneData: next, newId } = stampActorBlueprint(sceneData, blueprint, position);
|
|
364
|
-
if (next === sceneData) return;
|
|
365
|
-
history.commit(formatJson(next));
|
|
366
|
-
onSelectActorIds([newId]);
|
|
367
|
-
};
|
|
368
|
-
const onRemoveBlueprint = (blueprintId) => {
|
|
369
|
-
const next = removeActorBlueprint(sceneData, blueprintId);
|
|
370
|
-
if (next === sceneData) return;
|
|
371
|
-
history.commit(formatJson(next));
|
|
372
|
-
};
|
|
373
|
-
const onBlueprintContextMenu = (event, blueprint) => {
|
|
374
|
-
if (isPlaying) return;
|
|
375
|
-
event.preventDefault();
|
|
376
|
-
event.stopPropagation();
|
|
377
|
-
setContextMenu({ blueprint, x: event.clientX, y: event.clientY });
|
|
378
|
-
};
|
|
379
|
-
const onBlueprintPointerDown = (event, blueprint) => {
|
|
380
|
-
if (isPlaying || event.button !== 0) return;
|
|
381
|
-
event.preventDefault();
|
|
382
|
-
const drag = {
|
|
383
|
-
blueprint,
|
|
384
|
-
pointerId: event.pointerId,
|
|
385
|
-
startX: event.clientX,
|
|
386
|
-
startY: event.clientY,
|
|
387
|
-
moved: false,
|
|
388
|
-
};
|
|
389
|
-
dragRef.current = drag;
|
|
390
|
-
try {
|
|
391
|
-
event.currentTarget.setPointerCapture(event.pointerId);
|
|
392
|
-
} catch {
|
|
393
|
-
// Window listeners still finish the drag if capture is unavailable.
|
|
394
|
-
}
|
|
395
|
-
const onMove = (moveEvent) => {
|
|
396
|
-
if (moveEvent.pointerId !== drag.pointerId) return;
|
|
397
|
-
if (!drag.moved && Math.hypot(moveEvent.clientX - drag.startX, moveEvent.clientY - drag.startY) > DRAG_THRESHOLD) {
|
|
398
|
-
drag.moved = true;
|
|
399
|
-
}
|
|
400
|
-
if (drag.moved) {
|
|
401
|
-
setDragPreview({
|
|
402
|
-
blueprint,
|
|
403
|
-
x: moveEvent.clientX,
|
|
404
|
-
y: moveEvent.clientY,
|
|
405
|
-
overStage: Boolean(eventToStagePoint(moveEvent, canvasRef, editCameraRef)),
|
|
406
|
-
});
|
|
407
|
-
}
|
|
408
|
-
};
|
|
409
|
-
const onUp = (upEvent) => {
|
|
410
|
-
if (upEvent.pointerId !== drag.pointerId) return;
|
|
411
|
-
window.removeEventListener('pointermove', onMove);
|
|
412
|
-
window.removeEventListener('pointerup', onUp);
|
|
413
|
-
window.removeEventListener('pointercancel', onUp);
|
|
414
|
-
dragRef.current = null;
|
|
415
|
-
setDragPreview(null);
|
|
416
|
-
if (!drag.moved) return;
|
|
417
|
-
suppressClickRef.current = true;
|
|
418
|
-
const position = eventToStagePoint(upEvent, canvasRef, editCameraRef);
|
|
419
|
-
if (position) onStamp(blueprint, position);
|
|
420
|
-
};
|
|
421
|
-
window.addEventListener('pointermove', onMove);
|
|
422
|
-
window.addEventListener('pointerup', onUp);
|
|
423
|
-
window.addEventListener('pointercancel', onUp);
|
|
424
|
-
};
|
|
425
|
-
return (
|
|
426
|
-
<div className={styles.bpHotbar} aria-label="Blueprints">
|
|
427
|
-
<button
|
|
428
|
-
type="button"
|
|
429
|
-
className={styles.bpActionSlot}
|
|
430
|
-
aria-label="Save selected actor as blueprint"
|
|
431
|
-
title="Save selected actor as blueprint"
|
|
432
|
-
onClick={onSaveSelection}
|
|
433
|
-
disabled={isPlaying || selectedActorIds.length !== 1}>
|
|
434
|
-
<Icon name="stamp" />
|
|
435
|
-
</button>
|
|
436
|
-
<button
|
|
437
|
-
type="button"
|
|
438
|
-
className={styles.bpActionSlot}
|
|
439
|
-
aria-label="Add actor"
|
|
440
|
-
title="Add actor"
|
|
441
|
-
onClick={onAdd}
|
|
442
|
-
disabled={isPlaying}>
|
|
443
|
-
<span className={styles.dashedSquareIcon} aria-hidden="true" />
|
|
444
|
-
</button>
|
|
445
|
-
{blueprints.map((blueprint) => (
|
|
446
|
-
<button
|
|
447
|
-
key={blueprint.id}
|
|
448
|
-
type="button"
|
|
449
|
-
className={styles.bpSlot}
|
|
450
|
-
title={`Add ${blueprint.name}`}
|
|
451
|
-
disabled={isPlaying}
|
|
452
|
-
onPointerDown={(event) => onBlueprintPointerDown(event, blueprint)}
|
|
453
|
-
onContextMenu={(event) => onBlueprintContextMenu(event, blueprint)}
|
|
454
|
-
onClick={() => {
|
|
455
|
-
if (suppressClickRef.current) {
|
|
456
|
-
suppressClickRef.current = false;
|
|
457
|
-
return;
|
|
458
|
-
}
|
|
459
|
-
onStamp(blueprint);
|
|
460
|
-
}}>
|
|
461
|
-
<BlueprintThumbnail blueprint={blueprint} sprites={sprites} />
|
|
462
|
-
</button>
|
|
463
|
-
))}
|
|
464
|
-
{dragPreview ? (
|
|
465
|
-
<BlueprintDragPreview preview={dragPreview} sprites={sprites} />
|
|
466
|
-
) : null}
|
|
467
|
-
{contextMenu ? (
|
|
468
|
-
<BlueprintContextMenu
|
|
469
|
-
x={contextMenu.x}
|
|
470
|
-
y={contextMenu.y}
|
|
471
|
-
onRemove={() => onRemoveBlueprint(contextMenu.blueprint.id)}
|
|
472
|
-
onClose={closeContextMenu}
|
|
473
|
-
/>
|
|
474
|
-
) : null}
|
|
475
|
-
</div>
|
|
476
|
-
);
|
|
477
|
-
}
|
|
478
|
-
// Cursor-anchored right-click menu for a hotbar blueprint. Mirrors the
|
|
479
|
-
// pxArtTimeline ContextMenu: reuses the shared `selArrangeMenu`/`selArrangeItem`
|
|
480
|
-
// chrome, fixed-positions at the click point with viewport clamping, and closes
|
|
481
|
-
// on outside pointerdown or Escape.
|
|
482
|
-
function BlueprintContextMenu({ x, y, onRemove, onClose }) {
|
|
483
|
-
const ref = useRef(null);
|
|
484
|
-
const [pos, setPos] = useState({ left: x, top: y });
|
|
485
|
-
useLayoutEffect(() => {
|
|
486
|
-
const el = ref.current;
|
|
487
|
-
if (!el) return;
|
|
488
|
-
const rect = el.getBoundingClientRect();
|
|
489
|
-
const margin = 8;
|
|
490
|
-
const maxLeft = Math.max(margin, window.innerWidth - rect.width - margin);
|
|
491
|
-
const maxTop = Math.max(margin, window.innerHeight - rect.height - margin);
|
|
492
|
-
setPos({ left: Math.min(x, maxLeft), top: Math.min(y, maxTop) });
|
|
493
|
-
}, [x, y]);
|
|
494
|
-
useEffect(() => {
|
|
495
|
-
const onPointerDown = (event) => {
|
|
496
|
-
if (!ref.current?.contains(event.target)) onClose();
|
|
497
|
-
};
|
|
498
|
-
const onKeyDown = (event) => {
|
|
499
|
-
if (event.key === 'Escape') onClose();
|
|
500
|
-
};
|
|
501
|
-
window.addEventListener('pointerdown', onPointerDown);
|
|
502
|
-
window.addEventListener('keydown', onKeyDown);
|
|
503
|
-
return () => {
|
|
504
|
-
window.removeEventListener('pointerdown', onPointerDown);
|
|
505
|
-
window.removeEventListener('keydown', onKeyDown);
|
|
506
|
-
};
|
|
507
|
-
}, [onClose]);
|
|
508
|
-
return (
|
|
509
|
-
<div
|
|
510
|
-
ref={ref}
|
|
511
|
-
className={styles.selArrangeMenu}
|
|
512
|
-
role="menu"
|
|
513
|
-
aria-label="Blueprint"
|
|
514
|
-
style={{ position: 'fixed', left: pos.left, top: pos.top, transform: 'none' }}
|
|
515
|
-
onPointerDown={(event) => event.stopPropagation()}
|
|
516
|
-
onContextMenu={(event) => event.preventDefault()}>
|
|
517
|
-
<button
|
|
518
|
-
type="button"
|
|
519
|
-
role="menuitem"
|
|
520
|
-
className={styles.selArrangeItem}
|
|
521
|
-
onClick={() => {
|
|
522
|
-
onRemove();
|
|
523
|
-
onClose();
|
|
524
|
-
}}>
|
|
525
|
-
Remove blueprint
|
|
526
|
-
</button>
|
|
527
|
-
</div>
|
|
528
|
-
);
|
|
529
|
-
}
|
|
530
|
-
function BlueprintDragPreview({ preview, sprites }) {
|
|
531
|
-
const layout = preview.blueprint.actor?.components?.Layout;
|
|
532
|
-
const width = Math.max(24, Math.min(80, layout?.width ?? 48));
|
|
533
|
-
const height = Math.max(24, Math.min(80, layout?.height ?? 48));
|
|
534
|
-
return (
|
|
535
|
-
<div
|
|
536
|
-
className={cx(styles.bpDragPreview, preview.overStage && styles.bpDragPreviewOverStage)}
|
|
537
|
-
style={{
|
|
538
|
-
left: preview.x,
|
|
539
|
-
top: preview.y,
|
|
540
|
-
width,
|
|
541
|
-
height,
|
|
542
|
-
transform: `translate(-50%, -50%) rotate(${layout?.rotation ?? 0}deg)`,
|
|
543
|
-
}}>
|
|
544
|
-
<BlueprintThumbnail blueprint={preview.blueprint} sprites={sprites} preview />
|
|
545
|
-
</div>
|
|
546
|
-
);
|
|
547
|
-
}
|
|
548
|
-
function BlueprintThumbnail({ blueprint, sprites, preview = false }) {
|
|
549
|
-
const canvasRef = useRef(null);
|
|
550
|
-
const spritePath = blueprint.actor?.components?.Sprite?.file;
|
|
551
|
-
const sprite = spritePath ? sprites?.[spritePath] : null;
|
|
552
|
-
useEffect(() => {
|
|
553
|
-
const canvas = canvasRef.current;
|
|
554
|
-
if (!canvas || !sprite) return;
|
|
555
|
-
// A .pxart blueprint renders its first frame via the SDK.
|
|
556
|
-
renderSpriteFrame(sprite, 0, canvas);
|
|
557
|
-
}, [sprite]);
|
|
558
|
-
if (!sprite) {
|
|
559
|
-
return (
|
|
560
|
-
<span className={styles.bpSlotFallback}>
|
|
561
|
-
<Icon name="clone" />
|
|
562
|
-
</span>
|
|
563
|
-
);
|
|
564
|
-
}
|
|
565
|
-
return (
|
|
566
|
-
<canvas
|
|
567
|
-
ref={canvasRef}
|
|
568
|
-
className={cx(styles.bpSlotThumb, preview && styles.bpPreviewThumb)}
|
|
569
|
-
aria-hidden="true"
|
|
570
|
-
/>
|
|
571
|
-
);
|
|
572
|
-
}
|
|
573
|
-
function addSelectedActorBlueprint(sceneData, selectedActorIds) {
|
|
574
|
-
if (selectedActorIds.length !== 1) return sceneData;
|
|
575
|
-
const actor = sceneData.actors.find((candidate) => candidate.id === selectedActorIds[0]);
|
|
576
|
-
if (!actor) return sceneData;
|
|
577
|
-
const next = structuredClone(sceneData);
|
|
578
|
-
const copy = structuredClone(actor);
|
|
579
|
-
delete copy.id;
|
|
580
|
-
if (copy.components?.Layout) {
|
|
581
|
-
copy.components.Layout = {
|
|
582
|
-
...copy.components.Layout,
|
|
583
|
-
x: 0,
|
|
584
|
-
y: 0,
|
|
585
|
-
};
|
|
586
|
-
}
|
|
587
|
-
const blueprints = [...(next.editor?.blueprints ?? [])];
|
|
588
|
-
const name = makeBlueprintName(actor, blueprints.length);
|
|
589
|
-
blueprints.push({
|
|
590
|
-
id: makeBlueprintId(blueprints),
|
|
591
|
-
name,
|
|
592
|
-
actor: copy,
|
|
593
|
-
});
|
|
594
|
-
next.editor = {
|
|
595
|
-
...(next.editor ?? {}),
|
|
596
|
-
blueprints,
|
|
597
|
-
};
|
|
598
|
-
return next;
|
|
599
|
-
}
|
|
600
|
-
function stampActorBlueprint(sceneData, blueprint, position) {
|
|
601
|
-
if (!blueprint?.actor) return { sceneData, newId: null };
|
|
602
|
-
const next = structuredClone(sceneData);
|
|
603
|
-
const existingIds = new Set(next.actors.map((actor) => actor.id));
|
|
604
|
-
const actor = structuredClone(blueprint.actor);
|
|
605
|
-
const newId = makeActorId(existingIds);
|
|
606
|
-
actor.id = newId;
|
|
607
|
-
actor.components = actor.components ?? {};
|
|
608
|
-
const layout = actor.components.Layout ?? { width: 64, height: 64, z: 0, rotation: 0 };
|
|
609
|
-
actor.components.Layout = {
|
|
610
|
-
...layout,
|
|
611
|
-
x: Math.round((position?.x ?? cardSize.width / 2) - layout.width / 2),
|
|
612
|
-
y: Math.round((position?.y ?? cardSize.height / 2) - layout.height / 2),
|
|
613
|
-
};
|
|
614
|
-
next.actors.push(actor);
|
|
615
|
-
return { sceneData: next, newId };
|
|
616
|
-
}
|
|
617
|
-
function removeActorBlueprint(sceneData, blueprintId) {
|
|
618
|
-
const blueprints = sceneData.editor?.blueprints ?? [];
|
|
619
|
-
if (!blueprints.some((blueprint) => blueprint.id === blueprintId)) return sceneData;
|
|
620
|
-
const next = structuredClone(sceneData);
|
|
621
|
-
next.editor = {
|
|
622
|
-
...(next.editor ?? {}),
|
|
623
|
-
blueprints: (next.editor?.blueprints ?? []).filter(
|
|
624
|
-
(blueprint) => blueprint.id !== blueprintId
|
|
625
|
-
),
|
|
626
|
-
};
|
|
627
|
-
return next;
|
|
628
|
-
}
|
|
629
|
-
function eventToStagePoint(event, canvasRef, editCameraRef) {
|
|
630
|
-
const canvas = canvasRef.current;
|
|
631
|
-
if (!canvas) return null;
|
|
632
|
-
const rect = canvas.getBoundingClientRect();
|
|
633
|
-
if (
|
|
634
|
-
event.clientX < rect.left ||
|
|
635
|
-
event.clientX > rect.right ||
|
|
636
|
-
event.clientY < rect.top ||
|
|
637
|
-
event.clientY > rect.bottom
|
|
638
|
-
) {
|
|
639
|
-
return null;
|
|
640
|
-
}
|
|
641
|
-
const raw = screenToCard(canvas, event.clientX, event.clientY);
|
|
642
|
-
const camera = editCameraRef.current ?? { x: 0, y: 0 };
|
|
643
|
-
return {
|
|
644
|
-
x: raw.x + camera.x,
|
|
645
|
-
y: raw.y + camera.y,
|
|
646
|
-
};
|
|
647
|
-
}
|
|
648
|
-
function makeBlueprintName(actor, index) {
|
|
649
|
-
const spriteFile = actor.components?.Sprite?.file;
|
|
650
|
-
if (spriteFile) return basename(spriteFile).replace(/\.pxart$/i, '');
|
|
651
|
-
return `Actor ${index + 1}`;
|
|
652
|
-
}
|
|
653
|
-
function makeBlueprintId(blueprints) {
|
|
654
|
-
const existing = new Set(blueprints.map((blueprint) => blueprint.id));
|
|
655
|
-
for (let index = 1; index < 1000; index++) {
|
|
656
|
-
const candidate = `blueprint-${index}`;
|
|
657
|
-
if (!existing.has(candidate)) return candidate;
|
|
658
|
-
}
|
|
659
|
-
return `blueprint-${Date.now()}`;
|
|
660
|
-
}
|
|
661
|
-
function makeActorId(existingIds) {
|
|
662
|
-
for (let attempt = 0; attempt < 64; attempt++) {
|
|
663
|
-
const candidate = Math.floor(Math.random() * 0xffffffff)
|
|
664
|
-
.toString(16)
|
|
665
|
-
.padStart(8, '0');
|
|
666
|
-
if (!existingIds.has(candidate)) return candidate;
|
|
667
|
-
}
|
|
668
|
-
return `actor-${Date.now()}`;
|
|
669
|
-
}
|
|
670
|
-
function makeSceneActions({ sceneData, commit, selectedActorIds, onSelectActorIds }) {
|
|
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 }) {
|
|
671
609
|
function commitScene(next, options) {
|
|
672
|
-
commit(
|
|
610
|
+
commit(serialize(next), options);
|
|
673
611
|
}
|
|
674
612
|
return {
|
|
675
|
-
// Keyed by the changed field so consecutive edits to the SAME field (e.g.
|
|
676
|
-
// dragging a ColorField or clicking a NumberField stepper) coalesce into
|
|
677
|
-
// one undo entry, while edits to a different field stay separate steps.
|
|
678
613
|
updateComponent: (actorId, behaviorName, nextProps) =>
|
|
679
|
-
commitScene(
|
|
614
|
+
commitScene(setInstanceComponent(files, behaviorClasses, sceneData, actorId, behaviorName, nextProps), {
|
|
680
615
|
coalesceKey: componentCoalesceKey(actorId, behaviorName, nextProps),
|
|
681
616
|
}),
|
|
682
617
|
addBehavior: (actorId, behaviorName) => {
|
|
@@ -689,18 +624,18 @@ function makeSceneActions({ sceneData, commit, selectedActorIds, onSelectActorId
|
|
|
689
624
|
removeBehavior: (actorId, behaviorName) =>
|
|
690
625
|
commitScene(removeActorComponent(sceneData, actorId, behaviorName)),
|
|
691
626
|
deleteSelection: () => {
|
|
692
|
-
if (selectedActorIds.length === 0) return;
|
|
627
|
+
if (isBlueprintFile || selectedActorIds.length === 0) return;
|
|
693
628
|
commitScene(removeActors(sceneData, selectedActorIds));
|
|
694
629
|
onSelectActorIds([]);
|
|
695
630
|
},
|
|
696
631
|
duplicateSelection: () => {
|
|
697
|
-
if (selectedActorIds.length === 0) return;
|
|
632
|
+
if (isBlueprintFile || selectedActorIds.length === 0) return;
|
|
698
633
|
const { sceneData: next, newIds } = duplicateActors(sceneData, selectedActorIds);
|
|
699
634
|
commitScene(next);
|
|
700
635
|
if (newIds.length) onSelectActorIds(newIds);
|
|
701
636
|
},
|
|
702
637
|
arrangeSelection: (action) => {
|
|
703
|
-
if (selectedActorIds.length === 0) return;
|
|
638
|
+
if (isBlueprintFile || selectedActorIds.length === 0) return;
|
|
704
639
|
const next = arrangeActors(sceneData, selectedActorIds, action);
|
|
705
640
|
if (next !== sceneData) commitScene(next);
|
|
706
641
|
},
|
|
@@ -755,20 +690,10 @@ function parseHexColor(value) {
|
|
|
755
690
|
};
|
|
756
691
|
}
|
|
757
692
|
function useSelectionInspectorSheet(open) {
|
|
758
|
-
|
|
759
|
-
//
|
|
760
|
-
//
|
|
761
|
-
|
|
762
|
-
return useMobileSheet({
|
|
763
|
-
snap: effectiveSnap,
|
|
764
|
-
baseClassName: styles.inspector,
|
|
765
|
-
onTransition: (direction) => {
|
|
766
|
-
if (!open) return;
|
|
767
|
-
if (direction === 'tap') setSnap((prev) => (prev === 'high' ? 'low' : 'high'));
|
|
768
|
-
else if (direction === 'down') setSnap('low');
|
|
769
|
-
else if (direction === 'up') setSnap('high');
|
|
770
|
-
},
|
|
771
|
-
});
|
|
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 });
|
|
772
697
|
}
|
|
773
698
|
function useSelectionGesture(args) {
|
|
774
699
|
const dragRef = useRef(null);
|
|
@@ -783,7 +708,7 @@ function useSelectionGesture(args) {
|
|
|
783
708
|
const cam = current.editCameraRef.current;
|
|
784
709
|
const point = { x: raw.x + cam.x, y: raw.y + cam.y };
|
|
785
710
|
current.canvasRef.current.setPointerCapture(event.pointerId);
|
|
786
|
-
const scene = makeScene(current.sceneData, behaviorClasses, current.sprites);
|
|
711
|
+
const scene = makeScene(current.sceneData, behaviorClasses, current.sprites, current.files);
|
|
787
712
|
const actor = scene.actorAt(point.x, point.y);
|
|
788
713
|
const drag = {
|
|
789
714
|
pointerId: event.pointerId,
|
|
@@ -1178,7 +1103,7 @@ function finalizeMarquee(drag, current) {
|
|
|
1178
1103
|
const m = current.marqueeRef.current;
|
|
1179
1104
|
if (!m || !current.sceneData) return;
|
|
1180
1105
|
if (m.width === 0 && m.height === 0) return;
|
|
1181
|
-
const scene = makeScene(current.sceneData, behaviorClasses, current.sprites);
|
|
1106
|
+
const scene = makeScene(current.sceneData, behaviorClasses, current.sprites, current.files);
|
|
1182
1107
|
const hits = scene.actorIdsInRect(m);
|
|
1183
1108
|
const merged = new Set(drag.selectionBeforeMarquee);
|
|
1184
1109
|
for (const id of hits) merged.add(id);
|
|
@@ -1207,21 +1132,22 @@ function useSelectionKeyboard(args) {
|
|
|
1207
1132
|
const current = ref.current;
|
|
1208
1133
|
if (current.isPlaying || isEditableTarget(event.target)) return;
|
|
1209
1134
|
if (event.key === 'Escape') {
|
|
1210
|
-
if (current.selectedActorIds.length || current.multiSelectMode) {
|
|
1135
|
+
if (current.selectedActorIds.length || current.multiSelectMode || current.selectedBlueprintPath) {
|
|
1211
1136
|
event.preventDefault();
|
|
1137
|
+
// The wrapped setter also clears any hotbar blueprint selection.
|
|
1212
1138
|
current.onSelectActorIds([]);
|
|
1213
1139
|
current.onSetMultiSelectMode(false);
|
|
1214
1140
|
}
|
|
1215
1141
|
return;
|
|
1216
1142
|
}
|
|
1217
1143
|
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'a') {
|
|
1218
|
-
if (!current.sceneData) return;
|
|
1144
|
+
if (!current.sceneData || current.isBlueprintFile) return;
|
|
1219
1145
|
event.preventDefault();
|
|
1220
1146
|
current.onSelectActorIds(current.sceneData.actors.map((actor) => actor.id));
|
|
1221
1147
|
return;
|
|
1222
1148
|
}
|
|
1223
1149
|
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'd') {
|
|
1224
|
-
if (!current.sceneData || current.selectedActorIds.length === 0) return;
|
|
1150
|
+
if (!current.sceneData || current.isBlueprintFile || current.selectedActorIds.length === 0) return;
|
|
1225
1151
|
event.preventDefault();
|
|
1226
1152
|
const { sceneData: next, newIds } = duplicateActors(
|
|
1227
1153
|
current.sceneData,
|
|
@@ -1232,7 +1158,7 @@ function useSelectionKeyboard(args) {
|
|
|
1232
1158
|
return;
|
|
1233
1159
|
}
|
|
1234
1160
|
if (event.key === 'Delete' || event.key === 'Backspace') {
|
|
1235
|
-
if (!current.sceneData || current.selectedActorIds.length === 0) return;
|
|
1161
|
+
if (!current.sceneData || current.isBlueprintFile || current.selectedActorIds.length === 0) return;
|
|
1236
1162
|
event.preventDefault();
|
|
1237
1163
|
current.commitScene(removeActors(current.sceneData, current.selectedActorIds));
|
|
1238
1164
|
current.onSelectActorIds([]);
|
|
@@ -1245,6 +1171,7 @@ function useSelectionKeyboard(args) {
|
|
|
1245
1171
|
function useScenePlayLoop({
|
|
1246
1172
|
sceneData,
|
|
1247
1173
|
sprites,
|
|
1174
|
+
files,
|
|
1248
1175
|
isPlaying,
|
|
1249
1176
|
text,
|
|
1250
1177
|
canvasRef,
|
|
@@ -1263,7 +1190,7 @@ function useScenePlayLoop({
|
|
|
1263
1190
|
return;
|
|
1264
1191
|
}
|
|
1265
1192
|
if (!sceneData) return;
|
|
1266
|
-
runtimeRef.current = makeScene(sceneData, behaviorClasses, sprites).clone();
|
|
1193
|
+
runtimeRef.current = makeScene(sceneData, behaviorClasses, sprites, files).clone();
|
|
1267
1194
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1268
1195
|
}, [sprites, isPlaying, text, runtimeRef]);
|
|
1269
1196
|
// Animation loop -- draws edit-mode previews and ticks the play-mode runtime.
|
|
@@ -1288,7 +1215,7 @@ function useScenePlayLoop({
|
|
|
1288
1215
|
: editViewportForZoom(editCameraRef.current.zoom);
|
|
1289
1216
|
const scene = isPlaying
|
|
1290
1217
|
? runtimeRef.current
|
|
1291
|
-
: makeScene(sceneData, behaviorClasses, sprites);
|
|
1218
|
+
: makeScene(sceneData, behaviorClasses, sprites, files);
|
|
1292
1219
|
if (scene) {
|
|
1293
1220
|
const snap = getSnapSettings(sceneData);
|
|
1294
1221
|
if (isPlaying) scene.update(dt);
|
|
@@ -1315,7 +1242,7 @@ function useScenePlayLoop({
|
|
|
1315
1242
|
raf = requestAnimationFrame(frame);
|
|
1316
1243
|
return () => cancelAnimationFrame(raf);
|
|
1317
1244
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- `text` proxies `sceneData` identity; resetting on every parse churns the loop and wipes runtime state.
|
|
1318
|
-
}, [sprites, isPlaying, text, canvasRef, runtimeRef, marqueeRef, selectedActorIdsRef]);
|
|
1245
|
+
}, [sprites, files, isPlaying, text, canvasRef, runtimeRef, marqueeRef, selectedActorIdsRef]);
|
|
1319
1246
|
}
|
|
1320
1247
|
function useScenePlayKeys(runtimeRef) {
|
|
1321
1248
|
useEffect(() => {
|
|
@@ -1406,7 +1333,56 @@ function SceneInspector({ sceneData, snapSettings, onChangeScene, onChangeEditor
|
|
|
1406
1333
|
</Panel>
|
|
1407
1334
|
);
|
|
1408
1335
|
}
|
|
1409
|
-
|
|
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);
|
|
1410
1386
|
const presentNames = new Set(
|
|
1411
1387
|
Object.entries(selectedActor.components)
|
|
1412
1388
|
.filter(([, component]) => !!component)
|
|
@@ -1418,6 +1394,14 @@ function ActorInspector({ selectedActor, files, onSetComponent, onAddBehavior, o
|
|
|
1418
1394
|
const behaviorEntries = Object.entries(selectedActor.components).filter((entry) => !!entry[1]);
|
|
1419
1395
|
return (
|
|
1420
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}
|
|
1421
1405
|
<AddBehaviorPicker
|
|
1422
1406
|
available={availableBehaviors}
|
|
1423
1407
|
onAdd={(behaviorName) => onAddBehavior(selectedActor.id, behaviorName)}
|
|
@@ -1442,8 +1426,12 @@ function ActorInspector({ selectedActor, files, onSetComponent, onAddBehavior, o
|
|
|
1442
1426
|
setComponent={(nextProps) => onSetComponent(selectedActor.id, behaviorName, nextProps)}
|
|
1443
1427
|
/>
|
|
1444
1428
|
);
|
|
1445
|
-
// Layout is core to every actor -- it cannot be removed.
|
|
1446
|
-
|
|
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]));
|
|
1447
1435
|
const isLast = index === behaviorEntries.length - 1;
|
|
1448
1436
|
// Trash renders before the panel so the panel stays the wrapper's
|
|
1449
1437
|
// last child -- the panel's own `:last-child` border zeroes out, and
|