castle-web-cli 0.4.84 → 0.4.85
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/ide.js +35 -13
- package/dist/shell/assets/{index-BOgm5T3W.js → index-BJLaUTJE.js} +21 -21
- package/dist/shell/index.html +1 -1
- package/kits/physics-2d/.prettierrc +8 -0
- package/kits/physics-2d/CLAUDE.md +329 -0
- package/kits/physics-2d/behaviors/Camera.jsx +43 -0
- package/kits/physics-2d/behaviors/Collider.jsx +199 -0
- package/kits/physics-2d/behaviors/Goal.jsx +29 -0
- package/kits/physics-2d/behaviors/Layout.jsx +53 -0
- package/kits/physics-2d/behaviors/Sprite.jsx +352 -0
- package/kits/physics-2d/behaviors/tint.js +47 -0
- package/kits/physics-2d/blueprints/ball.scene +14 -0
- package/kits/physics-2d/blueprints/block.scene +12 -0
- package/kits/physics-2d/blueprints/cauldron.scene +18 -0
- package/kits/physics-2d/blueprints/crate.scene +14 -0
- package/kits/physics-2d/blueprints/goal.scene +12 -0
- package/kits/physics-2d/castle.json +13 -0
- package/kits/physics-2d/docs/pxart-format.md +377 -0
- package/kits/physics-2d/drawings/block.pxart +25 -0
- package/kits/physics-2d/drawings/cauldron.pxart +113 -0
- package/kits/physics-2d/editors/BlueprintLibrary.jsx +247 -0
- package/kits/physics-2d/editors/ErrorBoundary.jsx +59 -0
- package/kits/physics-2d/editors/PlayOnly.jsx +31 -0
- package/kits/physics-2d/editors/PxArtEditor.jsx +954 -0
- package/kits/physics-2d/editors/SceneEditor.jsx +1681 -0
- package/kits/physics-2d/editors/SelectionOverlay.jsx +909 -0
- package/kits/physics-2d/editors/SingleEditor.jsx +122 -0
- package/kits/physics-2d/editors/behaviorRegistry.js +30 -0
- package/kits/physics-2d/editors/editorHistory.js +157 -0
- package/kits/physics-2d/editors/inspectorSheet.js +13 -0
- package/kits/physics-2d/editors/pixelCanvas.js +11 -0
- package/kits/physics-2d/editors/pixelEditorChrome.jsx +74 -0
- package/kits/physics-2d/editors/pixelGeometry.js +140 -0
- package/kits/physics-2d/editors/pixelInspector.jsx +633 -0
- package/kits/physics-2d/editors/pxArtEditorModel.js +732 -0
- package/kits/physics-2d/editors/pxArtPlayback.js +92 -0
- package/kits/physics-2d/editors/pxArtTimeline.jsx +752 -0
- package/kits/physics-2d/editors/pxArtTimeline.module.css +506 -0
- package/kits/physics-2d/editors/pxArtTools.js +232 -0
- package/kits/physics-2d/editors/useArtboardFit.js +102 -0
- package/kits/physics-2d/engine/ScenePlayer.jsx +196 -0
- package/kits/physics-2d/engine/SceneUI.jsx +59 -0
- package/kits/physics-2d/engine/assets.js +15 -0
- package/kits/physics-2d/engine/autoInspector.jsx +70 -0
- package/kits/physics-2d/engine/blueprint.js +521 -0
- package/kits/physics-2d/engine/collider.js +196 -0
- package/kits/physics-2d/engine/files.js +117 -0
- package/kits/physics-2d/engine/liveReload.js +88 -0
- package/kits/physics-2d/engine/pxart.js +1032 -0
- package/kits/physics-2d/engine/pxartSmooth.js +222 -0
- package/kits/physics-2d/engine/scene.js +686 -0
- package/kits/physics-2d/engine/spriteGeometry.js +32 -0
- package/kits/physics-2d/engine/ui.jsx +688 -0
- package/kits/physics-2d/engine/ui.module.css +2287 -0
- package/kits/physics-2d/eslint.config.js +71 -0
- package/kits/physics-2d/index.html +24 -0
- package/kits/physics-2d/main.jsx +24 -0
- package/kits/physics-2d/package-lock.json +2706 -0
- package/kits/physics-2d/package.json +42 -0
- package/kits/physics-2d/physics/PhysicsSystem.js +290 -0
- package/kits/physics-2d/physics/behaviors/AnalogStick.jsx +101 -0
- package/kits/physics-2d/physics/behaviors/Draggable.jsx +79 -0
- package/kits/physics-2d/physics/behaviors/RigidBody.jsx +55 -0
- package/kits/physics-2d/physics/behaviors/Slingshot.jsx +118 -0
- package/kits/physics-2d/physics/controls.js +79 -0
- package/kits/physics-2d/physics/index.js +26 -0
- package/kits/physics-2d/physics/matterBridge.js +126 -0
- package/kits/physics-2d/pnpm-lock.yaml +1761 -0
- package/kits/physics-2d/scenes/main.scene +12 -0
- package/kits/physics-2d/scenes/sandbox.scene +13 -0
- package/kits/physics-2d/scripts/draw.mjs +121 -0
- package/kits/physics-2d/vite.config.js +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,1681 @@
|
|
|
1
|
+
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { onSaveReloadState, takeReloadState, writeFile } from 'castle-web-sdk';
|
|
3
|
+
import { basename, formatJson, parseJsonFile } from '../engine/files';
|
|
4
|
+
import {
|
|
5
|
+
arrangeActors,
|
|
6
|
+
cardSize,
|
|
7
|
+
configureSceneCanvas,
|
|
8
|
+
duplicateActors,
|
|
9
|
+
makeScene,
|
|
10
|
+
mintActorId,
|
|
11
|
+
removeActorComponent,
|
|
12
|
+
removeActors,
|
|
13
|
+
screenToCard,
|
|
14
|
+
setActorComponent,
|
|
15
|
+
} from '../engine/scene';
|
|
16
|
+
import {
|
|
17
|
+
addActorWithBlueprint,
|
|
18
|
+
blueprintDropXY,
|
|
19
|
+
cascadeDeleteBlueprint,
|
|
20
|
+
countBlueprintInstances,
|
|
21
|
+
forkActorToBlueprint,
|
|
22
|
+
getBlueprintTemplate,
|
|
23
|
+
isBlueprintPath,
|
|
24
|
+
isInheritedProp,
|
|
25
|
+
migrateOrphanActors,
|
|
26
|
+
placeBlueprintInstance,
|
|
27
|
+
setBlueprintName,
|
|
28
|
+
setBlueprintTemplateComponents,
|
|
29
|
+
setInstanceComponent,
|
|
30
|
+
} from '../engine/blueprint';
|
|
31
|
+
import {
|
|
32
|
+
cx,
|
|
33
|
+
CheckboxField,
|
|
34
|
+
ColorField,
|
|
35
|
+
ConfirmDialog,
|
|
36
|
+
EditorBody,
|
|
37
|
+
EditorHeader,
|
|
38
|
+
Icon,
|
|
39
|
+
IconButton,
|
|
40
|
+
NumberField,
|
|
41
|
+
Panel,
|
|
42
|
+
SheetGrabHandle,
|
|
43
|
+
styles,
|
|
44
|
+
TextField,
|
|
45
|
+
useMobileSheet,
|
|
46
|
+
} from '../engine/ui';
|
|
47
|
+
import { AutoInspector } from '../engine/autoInspector';
|
|
48
|
+
import { SceneUI } from '../engine/SceneUI';
|
|
49
|
+
import { SelectionOverlay } from './SelectionOverlay';
|
|
50
|
+
import { BlueprintLibrary, BlueprintThumbnail } from './BlueprintLibrary';
|
|
51
|
+
import { behaviorClasses, findBehaviorClass } from './behaviorRegistry';
|
|
52
|
+
import { useEditHistory, useUndoRedoShortcuts } from './editorHistory';
|
|
53
|
+
// A blueprint file's `actors[0]` has no `id` (it's the template, not an
|
|
54
|
+
// instance) -- injected/stripped at the read/write boundary below so the rest
|
|
55
|
+
// of this editor (selection, drag, inspector) can keep treating it like any
|
|
56
|
+
// other single-actor scene.
|
|
57
|
+
const BLUEPRINT_TEMPLATE_ACTOR_ID = '__template__';
|
|
58
|
+
const LONG_PRESS_MS = 500;
|
|
59
|
+
const DRAG_THRESHOLD = 4;
|
|
60
|
+
const DEFAULT_GRID_SIZE = 25;
|
|
61
|
+
const EDIT_VIEWPORT = {
|
|
62
|
+
width: cardSize.width * 3,
|
|
63
|
+
height: cardSize.height * 3,
|
|
64
|
+
originX: cardSize.width,
|
|
65
|
+
originY: cardSize.height,
|
|
66
|
+
};
|
|
67
|
+
// Edit-camera zoom bounds. zoom > 1 magnifies (the visible viewport shrinks);
|
|
68
|
+
// zoom < 1 pulls back to show more around the card.
|
|
69
|
+
const MIN_EDIT_ZOOM = 0.4;
|
|
70
|
+
const MAX_EDIT_ZOOM = 6;
|
|
71
|
+
// Wheel-delta -> zoom-factor sensitivity for trackpad pinch (ctrl+wheel).
|
|
72
|
+
const ZOOM_WHEEL_SPEED = 0.01;
|
|
73
|
+
const clampZoom = (zoom) =>
|
|
74
|
+
Math.min(MAX_EDIT_ZOOM, Math.max(MIN_EDIT_ZOOM, Number(zoom) || 1));
|
|
75
|
+
// Derive the effective edit viewport for a zoom level. Both extent and origin
|
|
76
|
+
// scale by 1/zoom, which keeps the canvas draw path, `screenToCard`, and the
|
|
77
|
+
// SelectionOverlay's `sx = box/card` projection mutually consistent (the overlay
|
|
78
|
+
// just multiplies its scale by the same zoom).
|
|
79
|
+
function editViewportForZoom(zoom) {
|
|
80
|
+
const z = clampZoom(zoom);
|
|
81
|
+
return {
|
|
82
|
+
width: EDIT_VIEWPORT.width / z,
|
|
83
|
+
height: EDIT_VIEWPORT.height / z,
|
|
84
|
+
originX: EDIT_VIEWPORT.originX / z,
|
|
85
|
+
originY: EDIT_VIEWPORT.originY / z,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
// A blueprint file on disk has no `id` on its template actor -- inject a
|
|
89
|
+
// synthetic one so every other piece of editor code that expects `actor.id`
|
|
90
|
+
// (selection, drag, inspector lookups) keeps working unmodified. Stripped
|
|
91
|
+
// again by `stripTemplateId` right before a write (see `serialize` below).
|
|
92
|
+
function withTemplateId(sceneData, isBlueprintFile) {
|
|
93
|
+
if (!sceneData || !isBlueprintFile) return sceneData;
|
|
94
|
+
const next = structuredClone(sceneData);
|
|
95
|
+
next.actors = next.actors ?? [];
|
|
96
|
+
if (next.actors[0]) next.actors[0].id = BLUEPRINT_TEMPLATE_ACTOR_ID;
|
|
97
|
+
return next;
|
|
98
|
+
}
|
|
99
|
+
function stripTemplateId(sceneData) {
|
|
100
|
+
const next = structuredClone(sceneData);
|
|
101
|
+
if (next.actors?.[0]) delete next.actors[0].id;
|
|
102
|
+
return next;
|
|
103
|
+
}
|
|
104
|
+
// On opening a scene with orphan actors (no `blueprint` field -- the
|
|
105
|
+
// pre-blueprint shape), mint one blueprint per orphan and rewrite the scene
|
|
106
|
+
// in place. Runs once per distinct file text; the blueprint-file writes are
|
|
107
|
+
// fire-and-forget (they're brand new files, nothing else references them
|
|
108
|
+
// yet) while the scene rewrite goes through this file's own `onChange` so it
|
|
109
|
+
// picks up the normal history/live-reload path.
|
|
110
|
+
function useMigrateOrphanActors({ isBlueprintFile, sceneData, files, text, onChange }) {
|
|
111
|
+
useEffect(() => {
|
|
112
|
+
if (isBlueprintFile || !sceneData) return;
|
|
113
|
+
const hasOrphans = (sceneData.actors ?? []).some((actor) => !actor.blueprint);
|
|
114
|
+
if (!hasOrphans) return;
|
|
115
|
+
const { sceneData: migrated, newBlueprintFiles } = migrateOrphanActors(files, behaviorClasses, sceneData);
|
|
116
|
+
for (const blueprintFile of newBlueprintFiles) void writeFile(blueprintFile.path, blueprintFile.text);
|
|
117
|
+
onChange(formatJson(migrated));
|
|
118
|
+
// Re-run only when this file's own text changes (e.g. after the migration
|
|
119
|
+
// write lands, or the user opens a different orphaned file) -- `files`
|
|
120
|
+
// changes on every keystroke elsewhere and would otherwise loop.
|
|
121
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
122
|
+
}, [text, isBlueprintFile]);
|
|
123
|
+
}
|
|
124
|
+
export function SceneEditor({
|
|
125
|
+
path,
|
|
126
|
+
text,
|
|
127
|
+
files,
|
|
128
|
+
sprites,
|
|
129
|
+
onChange,
|
|
130
|
+
onChangeFile,
|
|
131
|
+
onToggleFiles,
|
|
132
|
+
filesOpen,
|
|
133
|
+
headerHost,
|
|
134
|
+
selectedActorIds,
|
|
135
|
+
onSelectActorIds: onSelectActorIdsRaw,
|
|
136
|
+
multiSelectMode,
|
|
137
|
+
onSetMultiSelectMode,
|
|
138
|
+
}) {
|
|
139
|
+
const canvasRef = useRef(null);
|
|
140
|
+
const runtimeRef = useRef(null);
|
|
141
|
+
const marqueeRef = useRef(null);
|
|
142
|
+
// Ephemeral editor state (camera, play mode, inspected blueprint) restored
|
|
143
|
+
// across a reload: an agent's CODE edit triggers requestReload, which would
|
|
144
|
+
// otherwise reset the viewport/play state on every change. Data edits fold in
|
|
145
|
+
// live without a reload, so they never need this. `takeReloadState` is
|
|
146
|
+
// one-shot (clears on read), so read it once here. Selection is stashed
|
|
147
|
+
// separately by SingleEditor.
|
|
148
|
+
const reloadStashKey = `scene-editor:${path}`;
|
|
149
|
+
const [reloadStash] = useState(() => takeReloadState(reloadStashKey));
|
|
150
|
+
const editCameraRef = useRef(reloadStash?.editCamera ?? { x: 0, y: 0, zoom: 1 });
|
|
151
|
+
const selectedActorIdsRef = useRef(selectedActorIds);
|
|
152
|
+
selectedActorIdsRef.current = selectedActorIds;
|
|
153
|
+
const [isPlaying, setIsPlaying] = useState(reloadStash?.isPlaying ?? false);
|
|
154
|
+
// Hotbar blueprint selection (tap a slot -> inspect/edit the blueprint in
|
|
155
|
+
// the sidebar; drag remains the placement gesture). Mutually exclusive with
|
|
156
|
+
// actor selection: ANY actor-selection change -- including clearing it via
|
|
157
|
+
// empty-canvas click or Escape -- also drops the blueprint selection, which
|
|
158
|
+
// is why every internal caller goes through the wrapper below.
|
|
159
|
+
const [selectedBlueprintPath, setSelectedBlueprintPath] = useState(
|
|
160
|
+
reloadStash?.selectedBlueprintPath ?? null
|
|
161
|
+
);
|
|
162
|
+
// Read live in the render loop (a ref so changing it doesn't restart the
|
|
163
|
+
// loop): while a blueprint is selected, its instances stay lit and every
|
|
164
|
+
// other actor dims. Assigned below once `hotbarBlueprint` resolves, so a
|
|
165
|
+
// stale/deleted selection (file vanished) doesn't dim the scene with nothing
|
|
166
|
+
// to highlight.
|
|
167
|
+
const selectedBlueprintPathRef = useRef(selectedBlueprintPath);
|
|
168
|
+
const onSelectActorIds = useCallback(
|
|
169
|
+
(ids) => {
|
|
170
|
+
setSelectedBlueprintPath(null);
|
|
171
|
+
onSelectActorIdsRaw(ids);
|
|
172
|
+
},
|
|
173
|
+
[onSelectActorIdsRaw]
|
|
174
|
+
);
|
|
175
|
+
const onSelectBlueprint = useCallback(
|
|
176
|
+
(blueprintPath) => {
|
|
177
|
+
onSelectActorIdsRaw([]);
|
|
178
|
+
onSetMultiSelectMode(false);
|
|
179
|
+
setSelectedBlueprintPath(blueprintPath);
|
|
180
|
+
},
|
|
181
|
+
[onSelectActorIdsRaw, onSetMultiSelectMode]
|
|
182
|
+
);
|
|
183
|
+
const isBlueprintFile = isBlueprintPath(path);
|
|
184
|
+
// Working state for an in-progress hotbar drag-placement (see `dragPlace`
|
|
185
|
+
// below). A ref, not state: pointer moves can outpace re-renders.
|
|
186
|
+
const dragPlaceRef = useRef(null);
|
|
187
|
+
const history = useEditHistory(text, onChange, path);
|
|
188
|
+
// Disabled during play: the header undo/redo buttons are already disabled
|
|
189
|
+
// then, but without this the keyboard shortcut could still rewrite the
|
|
190
|
+
// scene file out from under the running play-mode runtime.
|
|
191
|
+
useUndoRedoShortcuts(history, !isPlaying);
|
|
192
|
+
// Stash ephemeral editor state right before a reload so it survives an agent
|
|
193
|
+
// code edit. `editCamera` is read live from the ref at save time (a reload is
|
|
194
|
+
// imminent, so the latest value is what matters); isPlaying / blueprint
|
|
195
|
+
// selection re-register whenever they change.
|
|
196
|
+
useEffect(
|
|
197
|
+
() =>
|
|
198
|
+
onSaveReloadState(reloadStashKey, () => ({
|
|
199
|
+
isPlaying,
|
|
200
|
+
selectedBlueprintPath,
|
|
201
|
+
editCamera: editCameraRef.current,
|
|
202
|
+
})),
|
|
203
|
+
[reloadStashKey, isPlaying, selectedBlueprintPath]
|
|
204
|
+
);
|
|
205
|
+
const showMulti = !isBlueprintFile && (selectedActorIds.length > 1 || multiSelectMode);
|
|
206
|
+
const inspectorSheet = useSelectionInspectorSheet(true);
|
|
207
|
+
const { value: parsedSceneData, error } = parseJsonFile(path, text);
|
|
208
|
+
const sceneData = withTemplateId(parsedSceneData, isBlueprintFile);
|
|
209
|
+
// Sparse per-instance overrides merged with each actor's blueprint template
|
|
210
|
+
// -- what hit-testing, drag-start snapshots, and the inspector should read.
|
|
211
|
+
// Never the basis for a write (see engine/blueprint.js).
|
|
212
|
+
const previewSceneData = sceneData ? makeScene(sceneData, behaviorClasses, sprites, files).data : null;
|
|
213
|
+
const serialize = useCallback(
|
|
214
|
+
(next) => formatJson(isBlueprintFile ? stripTemplateId(next) : next),
|
|
215
|
+
[isBlueprintFile]
|
|
216
|
+
);
|
|
217
|
+
const getRuntime = useCallback(() => runtimeRef.current, []);
|
|
218
|
+
useMigrateOrphanActors({ isBlueprintFile, sceneData, files, text, onChange });
|
|
219
|
+
useScenePlayLoop({
|
|
220
|
+
sceneData,
|
|
221
|
+
sprites,
|
|
222
|
+
files,
|
|
223
|
+
isPlaying,
|
|
224
|
+
text,
|
|
225
|
+
canvasRef,
|
|
226
|
+
runtimeRef,
|
|
227
|
+
editCameraRef,
|
|
228
|
+
marqueeRef,
|
|
229
|
+
selectedActorIdsRef,
|
|
230
|
+
selectedBlueprintPathRef,
|
|
231
|
+
});
|
|
232
|
+
const panGesture = usePanGesture({ canvasRef, editCameraRef, isPlaying });
|
|
233
|
+
useScenePlayKeys(runtimeRef);
|
|
234
|
+
const gesture = useSelectionGesture({
|
|
235
|
+
canvasRef,
|
|
236
|
+
sceneData,
|
|
237
|
+
sprites,
|
|
238
|
+
files,
|
|
239
|
+
isPlaying,
|
|
240
|
+
selectedActorIds,
|
|
241
|
+
onSelectActorIds,
|
|
242
|
+
multiSelectMode: showMulti && multiSelectMode,
|
|
243
|
+
onSetMultiSelectMode,
|
|
244
|
+
marqueeRef,
|
|
245
|
+
editCameraRef,
|
|
246
|
+
applyScene: (next) => onChange(serialize(next)),
|
|
247
|
+
recordSceneSnapshot: history.recordSnapshot,
|
|
248
|
+
});
|
|
249
|
+
const playPointer = usePlayPointerGesture({ canvasRef, runtimeRef });
|
|
250
|
+
useSelectionKeyboard({
|
|
251
|
+
sceneData,
|
|
252
|
+
selectedActorIds,
|
|
253
|
+
selectedBlueprintPath,
|
|
254
|
+
onSelectActorIds,
|
|
255
|
+
multiSelectMode,
|
|
256
|
+
onSetMultiSelectMode,
|
|
257
|
+
isPlaying,
|
|
258
|
+
isBlueprintFile,
|
|
259
|
+
commitScene: (next) => history.commit(serialize(next)),
|
|
260
|
+
});
|
|
261
|
+
if (!sceneData) {
|
|
262
|
+
return (
|
|
263
|
+
<>
|
|
264
|
+
<EditorHeader
|
|
265
|
+
title={basename(path)}
|
|
266
|
+
onToggleFiles={onToggleFiles}
|
|
267
|
+
filesOpen={filesOpen}
|
|
268
|
+
headerHost={headerHost}
|
|
269
|
+
/>
|
|
270
|
+
<EditorBody>
|
|
271
|
+
<div className={styles.inspector}>{error}</div>
|
|
272
|
+
</EditorBody>
|
|
273
|
+
</>
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
const rawSelectedActor =
|
|
277
|
+
selectedActorIds.length === 1
|
|
278
|
+
? sceneData.actors.find((actor) => actor.id === selectedActorIds[0])
|
|
279
|
+
: undefined;
|
|
280
|
+
const selectedActor =
|
|
281
|
+
selectedActorIds.length === 1
|
|
282
|
+
? previewSceneData.actors.find((actor) => actor.id === selectedActorIds[0])
|
|
283
|
+
: undefined;
|
|
284
|
+
const selectedBlueprintTemplate = rawSelectedActor?.blueprint
|
|
285
|
+
? getBlueprintTemplate(files, rawSelectedActor.blueprint)
|
|
286
|
+
: undefined;
|
|
287
|
+
const selectedBlueprintName = rawSelectedActor?.blueprint
|
|
288
|
+
? selectedBlueprintTemplate?.name ?? basename(rawSelectedActor.blueprint)
|
|
289
|
+
: undefined;
|
|
290
|
+
// Hotbar-selected blueprint, resolved fresh from live files each render. A
|
|
291
|
+
// selection whose file vanished (cascade delete, external edit) degrades to
|
|
292
|
+
// "nothing selected" rather than a broken inspector.
|
|
293
|
+
const hotbarBlueprint =
|
|
294
|
+
!isBlueprintFile && selectedBlueprintPath
|
|
295
|
+
? getBlueprintTemplate(files, selectedBlueprintPath)
|
|
296
|
+
: null;
|
|
297
|
+
// Only dim for a blueprint that actually resolves to an inspectable template
|
|
298
|
+
// (mirrors the sidebar): a vanished/stale selection highlights nothing.
|
|
299
|
+
selectedBlueprintPathRef.current = hotbarBlueprint ? selectedBlueprintPath : null;
|
|
300
|
+
// Template edits from the sidebar write the BLUEPRINT file, not this scene:
|
|
301
|
+
// optimistic fold into live files state (instant merged-instance updates
|
|
302
|
+
// here) + debounced writeFile via SingleEditor's saver, which also makes
|
|
303
|
+
// `shouldSkipPath` ignore the fs echo of our own write. Same plain
|
|
304
|
+
// per-property merge semantics as editing the blueprint's own file. Outside
|
|
305
|
+
// this scene file's undo history (accepted v1 cross-file gap, like fork).
|
|
306
|
+
const updateBlueprintTemplate = (mutate) => {
|
|
307
|
+
if (!selectedBlueprintPath || !hotbarBlueprint) return;
|
|
308
|
+
const components = structuredClone(hotbarBlueprint.components);
|
|
309
|
+
mutate(components);
|
|
310
|
+
const nextText = setBlueprintTemplateComponents(files, selectedBlueprintPath, components);
|
|
311
|
+
if (nextText == null) return;
|
|
312
|
+
if (onChangeFile) onChangeFile(selectedBlueprintPath, nextText);
|
|
313
|
+
else void writeFile(selectedBlueprintPath, nextText);
|
|
314
|
+
};
|
|
315
|
+
const blueprintActions = {
|
|
316
|
+
setComponent: (behaviorName, nextProps) =>
|
|
317
|
+
updateBlueprintTemplate((components) => {
|
|
318
|
+
components[behaviorName] = { ...(components[behaviorName] ?? {}), ...nextProps };
|
|
319
|
+
}),
|
|
320
|
+
addBehavior: (behaviorName) => {
|
|
321
|
+
const Behavior = findBehaviorClass(behaviorName);
|
|
322
|
+
if (!Behavior) return;
|
|
323
|
+
updateBlueprintTemplate((components) => {
|
|
324
|
+
components[behaviorName] = { ...Behavior.defaultProps };
|
|
325
|
+
});
|
|
326
|
+
},
|
|
327
|
+
removeBehavior: (behaviorName) =>
|
|
328
|
+
updateBlueprintTemplate((components) => {
|
|
329
|
+
delete components[behaviorName];
|
|
330
|
+
}),
|
|
331
|
+
// Renaming writes only the blueprint file's top-level `name` (not this
|
|
332
|
+
// scene), so it flows through the same optimistic files update + debounced
|
|
333
|
+
// writeFile path as template edits above.
|
|
334
|
+
rename: (name) => {
|
|
335
|
+
if (!selectedBlueprintPath) return;
|
|
336
|
+
const nextText = setBlueprintName(files, selectedBlueprintPath, name);
|
|
337
|
+
if (nextText == null) return;
|
|
338
|
+
if (onChangeFile) onChangeFile(selectedBlueprintPath, nextText);
|
|
339
|
+
else void writeFile(selectedBlueprintPath, nextText);
|
|
340
|
+
},
|
|
341
|
+
};
|
|
342
|
+
const actions = makeSceneActions({
|
|
343
|
+
sceneData,
|
|
344
|
+
files,
|
|
345
|
+
serialize,
|
|
346
|
+
commit: history.commit,
|
|
347
|
+
selectedActorIds,
|
|
348
|
+
onSelectActorIds,
|
|
349
|
+
isBlueprintFile,
|
|
350
|
+
});
|
|
351
|
+
const snapSettings = getSnapSettings(sceneData);
|
|
352
|
+
const stageWrapStyle = {
|
|
353
|
+
backgroundColor: darkenSceneBackground(sceneData.background),
|
|
354
|
+
};
|
|
355
|
+
const updateSceneSettings = (nextScene) => {
|
|
356
|
+
history.commit(serialize(setSceneSettings(sceneData, nextScene)));
|
|
357
|
+
};
|
|
358
|
+
const updateSceneEditorSettings = (nextEditor) => {
|
|
359
|
+
history.commit(serialize(setSceneEditorSettings(sceneData, nextEditor)));
|
|
360
|
+
};
|
|
361
|
+
const onAddActor = () => {
|
|
362
|
+
const { sceneData: next, newId, blueprintFile, drawingFile } = addActorWithBlueprint(
|
|
363
|
+
sceneData,
|
|
364
|
+
files
|
|
365
|
+
);
|
|
366
|
+
void writeFile(drawingFile.path, drawingFile.text);
|
|
367
|
+
void writeFile(blueprintFile.path, blueprintFile.text);
|
|
368
|
+
history.commit(serialize(next));
|
|
369
|
+
onSelectActorIds([newId]);
|
|
370
|
+
};
|
|
371
|
+
// Drag-out from the hotbar places a REAL instance the moment the pointer
|
|
372
|
+
// enters the stage, so the rest of the drag is a normal grid-snapped actor
|
|
373
|
+
// move and the drop is just "let go" (castle-client belt behavior). One
|
|
374
|
+
// undo entry per gesture: a snapshot at entry, then plain onChange while
|
|
375
|
+
// the drag is live. Leaving the stage removes the actor again (back to the
|
|
376
|
+
// floating preview), which lands the text back at the snapshot -- the
|
|
377
|
+
// history's no-op trimming keeps undo clean. `dragPlaceRef` carries the
|
|
378
|
+
// working scene between pointer events so a move never applies to a parse
|
|
379
|
+
// that predates the placement.
|
|
380
|
+
const dragPlace = {
|
|
381
|
+
enter: (blueprintPath, position) => {
|
|
382
|
+
history.recordSnapshot();
|
|
383
|
+
const { sceneData: next, newId } = placeBlueprintInstance(sceneData, files, blueprintPath, position, snapSettings);
|
|
384
|
+
const layout = next.actors.find((actor) => actor.id === newId)?.components?.Layout ?? {};
|
|
385
|
+
dragPlaceRef.current = { actorId: newId, blueprintPath, sceneData: next, x: layout.x, y: layout.y };
|
|
386
|
+
onChange(serialize(next));
|
|
387
|
+
onSelectActorIds([newId]);
|
|
388
|
+
},
|
|
389
|
+
move: (position) => {
|
|
390
|
+
const drag = dragPlaceRef.current;
|
|
391
|
+
if (!drag) return;
|
|
392
|
+
const { x, y } = blueprintDropXY(files, drag.blueprintPath, position, snapSettings);
|
|
393
|
+
if (x === drag.x && y === drag.y) return;
|
|
394
|
+
const next = structuredClone(drag.sceneData);
|
|
395
|
+
const actor = next.actors.find((candidate) => candidate.id === drag.actorId);
|
|
396
|
+
if (!actor) return;
|
|
397
|
+
actor.components.Layout = { ...actor.components.Layout, x, y };
|
|
398
|
+
Object.assign(drag, { sceneData: next, x, y });
|
|
399
|
+
onChange(serialize(next));
|
|
400
|
+
},
|
|
401
|
+
leave: () => {
|
|
402
|
+
const drag = dragPlaceRef.current;
|
|
403
|
+
if (!drag) return;
|
|
404
|
+
dragPlaceRef.current = null;
|
|
405
|
+
onChange(serialize(removeActors(drag.sceneData, [drag.actorId])));
|
|
406
|
+
onSelectActorIds([]);
|
|
407
|
+
},
|
|
408
|
+
drop: () => {
|
|
409
|
+
const drag = dragPlaceRef.current;
|
|
410
|
+
if (!drag) return;
|
|
411
|
+
dragPlaceRef.current = null;
|
|
412
|
+
onSelectActorIds([drag.actorId]);
|
|
413
|
+
},
|
|
414
|
+
};
|
|
415
|
+
const onDeleteBlueprint = (blueprintPath) => {
|
|
416
|
+
// Every write here targets a plain `scenes/*.scene` (cascaded instance
|
|
417
|
+
// removal) or the blueprint file itself (tombstoned) -- never a
|
|
418
|
+
// blueprint's own template id, so no `serialize`/strip step is needed.
|
|
419
|
+
for (const write of cascadeDeleteBlueprint(files, blueprintPath)) {
|
|
420
|
+
if (write.path === path) history.commit(write.text);
|
|
421
|
+
else void writeFile(write.path, write.text);
|
|
422
|
+
}
|
|
423
|
+
onSelectActorIds([]);
|
|
424
|
+
};
|
|
425
|
+
const onForkSelection = () => {
|
|
426
|
+
if (isBlueprintFile || !rawSelectedActor?.blueprint) return;
|
|
427
|
+
const result = forkActorToBlueprint(files, behaviorClasses, sceneData, rawSelectedActor.id);
|
|
428
|
+
if (!result) return;
|
|
429
|
+
void writeFile(result.blueprintFile.path, result.blueprintFile.text);
|
|
430
|
+
history.commit(serialize(result.sceneData));
|
|
431
|
+
};
|
|
432
|
+
return (
|
|
433
|
+
<>
|
|
434
|
+
<EditorHeader
|
|
435
|
+
title={
|
|
436
|
+
isBlueprintFile ? (
|
|
437
|
+
<span className={styles.editorHeaderTitleRow}>
|
|
438
|
+
<span className={styles.blueprintBadge}>Blueprint</span>
|
|
439
|
+
{sceneData.name ?? basename(path)}
|
|
440
|
+
</span>
|
|
441
|
+
) : (
|
|
442
|
+
sceneData.name ?? basename(path)
|
|
443
|
+
)
|
|
444
|
+
}
|
|
445
|
+
subtitle={
|
|
446
|
+
isPlaying
|
|
447
|
+
? 'WASD / arrows'
|
|
448
|
+
: isBlueprintFile
|
|
449
|
+
? 'editing template -- changes apply to every placed instance'
|
|
450
|
+
: `${sceneData.actors.length} actors`
|
|
451
|
+
}
|
|
452
|
+
right={
|
|
453
|
+
<HeaderPlaybackButtons
|
|
454
|
+
isPlaying={isPlaying}
|
|
455
|
+
setIsPlaying={setIsPlaying}
|
|
456
|
+
history={history}
|
|
457
|
+
/>
|
|
458
|
+
}
|
|
459
|
+
onToggleFiles={onToggleFiles}
|
|
460
|
+
filesOpen={filesOpen}
|
|
461
|
+
headerHost={headerHost}
|
|
462
|
+
/>
|
|
463
|
+
<EditorBody>
|
|
464
|
+
<div className={styles.sceneWorkspace}>
|
|
465
|
+
<div className={styles.sceneTools}>
|
|
466
|
+
{isBlueprintFile ? null : (
|
|
467
|
+
<>
|
|
468
|
+
{/* Open (or focus) a Play panel in the shell for this scene.
|
|
469
|
+
Lives here in the visible toolbar because the scene editor's
|
|
470
|
+
header only renders in the combined editor, not the shell. */}
|
|
471
|
+
<IconButton
|
|
472
|
+
icon="external"
|
|
473
|
+
label="Open play preview"
|
|
474
|
+
onClick={(event) => {
|
|
475
|
+
event.currentTarget.blur();
|
|
476
|
+
window.parent.postMessage({ type: 'castle-open-playtest', scene: path }, '*');
|
|
477
|
+
}}
|
|
478
|
+
/>
|
|
479
|
+
<BlueprintLibrary
|
|
480
|
+
files={files}
|
|
481
|
+
sprites={sprites}
|
|
482
|
+
canvasRef={canvasRef}
|
|
483
|
+
editCameraRef={editCameraRef}
|
|
484
|
+
isPlaying={isPlaying}
|
|
485
|
+
selectedBlueprintPath={selectedBlueprintPath}
|
|
486
|
+
instanceBlueprintPath={rawSelectedActor?.blueprint ?? null}
|
|
487
|
+
onSelectBlueprint={onSelectBlueprint}
|
|
488
|
+
onAddActor={onAddActor}
|
|
489
|
+
dragPlace={dragPlace}
|
|
490
|
+
onDeleteBlueprint={onDeleteBlueprint}
|
|
491
|
+
/>
|
|
492
|
+
</>
|
|
493
|
+
)}
|
|
494
|
+
</div>
|
|
495
|
+
<div className={styles.stageWrap} style={stageWrapStyle}>
|
|
496
|
+
<div className={styles.stageCard} data-castle-card>
|
|
497
|
+
<div className={cx(styles.stageCardClip, !isPlaying && styles.stageCardEditSurface)}>
|
|
498
|
+
<canvas
|
|
499
|
+
ref={canvasRef}
|
|
500
|
+
className={cx(styles.stageCanvas, !isPlaying && styles.stageCanvasEdit)}
|
|
501
|
+
// Focusable so pointer-down pulls keyboard focus into this
|
|
502
|
+
// editor iframe and Cmd+Z reaches our undo handler instead of
|
|
503
|
+
// the browser's (see PxArtEditor startTool for the full
|
|
504
|
+
// rationale -- Safari won't keep focus on a clicked
|
|
505
|
+
// non-focusable canvas).
|
|
506
|
+
tabIndex={-1}
|
|
507
|
+
onPointerDown={(event) => {
|
|
508
|
+
event.currentTarget.focus({ preventScroll: true });
|
|
509
|
+
if (isPlaying) return playPointer.onPointerDown(event);
|
|
510
|
+
if (panGesture.isSpacePanning()) return panGesture.onPointerDown(event);
|
|
511
|
+
return gesture.onPointerDown(event);
|
|
512
|
+
}}
|
|
513
|
+
onPointerMove={(event) =>
|
|
514
|
+
isPlaying
|
|
515
|
+
? playPointer.onPointerMove(event)
|
|
516
|
+
: panGesture.isActive()
|
|
517
|
+
? panGesture.onPointerMove(event)
|
|
518
|
+
: gesture.onPointerMove(event)
|
|
519
|
+
}
|
|
520
|
+
onPointerUp={(event) =>
|
|
521
|
+
isPlaying
|
|
522
|
+
? playPointer.onPointerUp(event)
|
|
523
|
+
: panGesture.isActive()
|
|
524
|
+
? panGesture.onPointerUp(event)
|
|
525
|
+
: gesture.onPointerUp(event)
|
|
526
|
+
}
|
|
527
|
+
onPointerCancel={(event) =>
|
|
528
|
+
isPlaying
|
|
529
|
+
? playPointer.onPointerUp(event)
|
|
530
|
+
: panGesture.isActive()
|
|
531
|
+
? panGesture.onPointerUp(event)
|
|
532
|
+
: gesture.onPointerUp(event)
|
|
533
|
+
}
|
|
534
|
+
/>
|
|
535
|
+
{isPlaying && <SceneUI getRuntime={getRuntime} />}
|
|
536
|
+
</div>
|
|
537
|
+
{!isPlaying && selectedActorIds.length >= 1 && (
|
|
538
|
+
<SelectionOverlay
|
|
539
|
+
canvasRef={canvasRef}
|
|
540
|
+
editCameraRef={editCameraRef}
|
|
541
|
+
sceneData={sceneData}
|
|
542
|
+
previewSceneData={previewSceneData}
|
|
543
|
+
sprites={sprites}
|
|
544
|
+
selectedActorIds={selectedActorIds}
|
|
545
|
+
snap={snapSettings}
|
|
546
|
+
onArrange={isBlueprintFile ? undefined : actions.arrangeSelection}
|
|
547
|
+
onClone={isBlueprintFile ? undefined : actions.duplicateSelection}
|
|
548
|
+
onDelete={isBlueprintFile ? undefined : actions.deleteSelection}
|
|
549
|
+
applyScene={(next) => onChange(serialize(next))}
|
|
550
|
+
recordSnapshot={history.recordSnapshot}
|
|
551
|
+
/>
|
|
552
|
+
)}
|
|
553
|
+
</div>
|
|
554
|
+
</div>
|
|
555
|
+
<aside {...inspectorSheet.rootProps}>
|
|
556
|
+
<div {...inspectorSheet.grabProps}>
|
|
557
|
+
<SheetGrabHandle />
|
|
558
|
+
</div>
|
|
559
|
+
<div className={cx(styles.sheetBody, styles.inspectorBody)}>
|
|
560
|
+
{hotbarBlueprint ? (
|
|
561
|
+
<BlueprintInspector
|
|
562
|
+
name={hotbarBlueprint.name}
|
|
563
|
+
template={hotbarBlueprint}
|
|
564
|
+
sprites={sprites}
|
|
565
|
+
blueprintPath={selectedBlueprintPath}
|
|
566
|
+
files={files}
|
|
567
|
+
onRename={blueprintActions.rename}
|
|
568
|
+
onSetComponent={blueprintActions.setComponent}
|
|
569
|
+
onAddBehavior={blueprintActions.addBehavior}
|
|
570
|
+
onRemoveBehavior={blueprintActions.removeBehavior}
|
|
571
|
+
/>
|
|
572
|
+
) : showMulti ? (
|
|
573
|
+
<MultiSelectInspector
|
|
574
|
+
count={selectedActorIds.length}
|
|
575
|
+
onDeselectAll={() => {
|
|
576
|
+
onSelectActorIds([]);
|
|
577
|
+
onSetMultiSelectMode(false);
|
|
578
|
+
}}
|
|
579
|
+
/>
|
|
580
|
+
) : selectedActor ? (
|
|
581
|
+
<ActorInspector
|
|
582
|
+
key={selectedActor.id}
|
|
583
|
+
selectedActor={selectedActor}
|
|
584
|
+
rawActor={rawSelectedActor}
|
|
585
|
+
blueprintName={selectedBlueprintName}
|
|
586
|
+
blueprintTemplate={selectedBlueprintTemplate}
|
|
587
|
+
sprites={sprites}
|
|
588
|
+
files={files}
|
|
589
|
+
showAddBehavior={isBlueprintFile}
|
|
590
|
+
onSetComponent={actions.updateComponent}
|
|
591
|
+
onAddBehavior={actions.addBehavior}
|
|
592
|
+
onRemoveBehavior={actions.removeBehavior}
|
|
593
|
+
onFork={isBlueprintFile ? undefined : onForkSelection}
|
|
594
|
+
onEditBlueprint={
|
|
595
|
+
isBlueprintFile || !rawSelectedActor?.blueprint
|
|
596
|
+
? undefined
|
|
597
|
+
: () => onSelectBlueprint(rawSelectedActor.blueprint)
|
|
598
|
+
}
|
|
599
|
+
/>
|
|
600
|
+
) : (
|
|
601
|
+
<SceneInspector
|
|
602
|
+
sceneData={sceneData}
|
|
603
|
+
snapSettings={snapSettings}
|
|
604
|
+
onChangeScene={updateSceneSettings}
|
|
605
|
+
onChangeEditor={updateSceneEditorSettings}
|
|
606
|
+
/>
|
|
607
|
+
)}
|
|
608
|
+
</div>
|
|
609
|
+
</aside>
|
|
610
|
+
</div>
|
|
611
|
+
</EditorBody>
|
|
612
|
+
</>
|
|
613
|
+
);
|
|
614
|
+
}
|
|
615
|
+
// Header-right playback controls, in the mockup's order: Undo, Redo, Play.
|
|
616
|
+
// Play stays a play/stop toggle. Duplicate/remove live on the on-canvas toolbar.
|
|
617
|
+
// "Open preview" asks the shell (parent window) to open/focus a Play panel for
|
|
618
|
+
// THIS scene -- a separate, persistent preview alongside the editor (vs the
|
|
619
|
+
// in-panel play toggle). Defaults to an already-open preview for this scene,
|
|
620
|
+
// else opens a new one (handled shell-side, keyed by scene).
|
|
621
|
+
function HeaderPlaybackButtons({ isPlaying, setIsPlaying, history }) {
|
|
622
|
+
return (
|
|
623
|
+
<>
|
|
624
|
+
<IconButton
|
|
625
|
+
icon="undo"
|
|
626
|
+
label="Undo"
|
|
627
|
+
onClick={history.undo}
|
|
628
|
+
disabled={!history.canUndo || isPlaying}
|
|
629
|
+
/>
|
|
630
|
+
<IconButton
|
|
631
|
+
icon="redo"
|
|
632
|
+
label="Redo"
|
|
633
|
+
onClick={history.redo}
|
|
634
|
+
disabled={!history.canRedo || isPlaying}
|
|
635
|
+
/>
|
|
636
|
+
<IconButton
|
|
637
|
+
icon={isPlaying ? 'stop' : 'play'}
|
|
638
|
+
label={isPlaying ? 'Stop' : 'Play'}
|
|
639
|
+
active={isPlaying}
|
|
640
|
+
onClick={(event) => {
|
|
641
|
+
// Drop focus so a subsequent Space (a game input / pan modifier)
|
|
642
|
+
// doesn't re-activate this button and toggle play.
|
|
643
|
+
event.currentTarget.blur();
|
|
644
|
+
setIsPlaying(!isPlaying);
|
|
645
|
+
}}
|
|
646
|
+
/>
|
|
647
|
+
</>
|
|
648
|
+
);
|
|
649
|
+
}
|
|
650
|
+
// `updateComponent` goes through `setInstanceComponent`, which already
|
|
651
|
+
// branches on whether the target actor has a `blueprint` ref: for a
|
|
652
|
+
// blueprint's own template actor (no ref) it's a plain merge; for a normal
|
|
653
|
+
// instance it diffs each patched prop against the blueprint and records only
|
|
654
|
+
// what actually differs, dropping an override that becomes redundant. Add/
|
|
655
|
+
// remove-behavior stay whole-component operations straight against the
|
|
656
|
+
// instance's own sparse `components` -- there's no per-property baseline to
|
|
657
|
+
// diff when a component doesn't exist on one side at all.
|
|
658
|
+
function makeSceneActions({ sceneData, files, serialize, commit, selectedActorIds, onSelectActorIds, isBlueprintFile }) {
|
|
659
|
+
function commitScene(next, options) {
|
|
660
|
+
commit(serialize(next), options);
|
|
661
|
+
}
|
|
662
|
+
return {
|
|
663
|
+
updateComponent: (actorId, behaviorName, nextProps) =>
|
|
664
|
+
commitScene(setInstanceComponent(files, behaviorClasses, sceneData, actorId, behaviorName, nextProps), {
|
|
665
|
+
coalesceKey: componentCoalesceKey(actorId, behaviorName, nextProps),
|
|
666
|
+
}),
|
|
667
|
+
addBehavior: (actorId, behaviorName) => {
|
|
668
|
+
const Behavior = findBehaviorClass(behaviorName);
|
|
669
|
+
if (!Behavior) return;
|
|
670
|
+
commitScene(
|
|
671
|
+
setActorComponent(sceneData, actorId, behaviorName, { ...Behavior.defaultProps })
|
|
672
|
+
);
|
|
673
|
+
},
|
|
674
|
+
removeBehavior: (actorId, behaviorName) =>
|
|
675
|
+
commitScene(removeActorComponent(sceneData, actorId, behaviorName)),
|
|
676
|
+
deleteSelection: () => {
|
|
677
|
+
if (isBlueprintFile || selectedActorIds.length === 0) return;
|
|
678
|
+
commitScene(removeActors(sceneData, selectedActorIds));
|
|
679
|
+
onSelectActorIds([]);
|
|
680
|
+
},
|
|
681
|
+
duplicateSelection: () => {
|
|
682
|
+
if (isBlueprintFile || selectedActorIds.length === 0) return;
|
|
683
|
+
const { sceneData: next, newIds } = duplicateActors(sceneData, selectedActorIds);
|
|
684
|
+
commitScene(next);
|
|
685
|
+
if (newIds.length) onSelectActorIds(newIds);
|
|
686
|
+
},
|
|
687
|
+
arrangeSelection: (action) => {
|
|
688
|
+
if (isBlueprintFile || selectedActorIds.length === 0) return;
|
|
689
|
+
const next = arrangeActors(sceneData, selectedActorIds, action);
|
|
690
|
+
if (next !== sceneData) commitScene(next);
|
|
691
|
+
},
|
|
692
|
+
};
|
|
693
|
+
}
|
|
694
|
+
// setActorComponent merges nextProps into the actor's existing component, so
|
|
695
|
+
// its keys ARE the fields this call actually changed.
|
|
696
|
+
function componentCoalesceKey(actorId, behaviorName, nextProps) {
|
|
697
|
+
return `${actorId}:${behaviorName}:${Object.keys(nextProps).sort().join(',')}`;
|
|
698
|
+
}
|
|
699
|
+
function setSceneSettings(sceneData, nextScene) {
|
|
700
|
+
return {
|
|
701
|
+
...sceneData,
|
|
702
|
+
...nextScene,
|
|
703
|
+
};
|
|
704
|
+
}
|
|
705
|
+
function setSceneEditorSettings(sceneData, nextEditor) {
|
|
706
|
+
return {
|
|
707
|
+
...sceneData,
|
|
708
|
+
editor: {
|
|
709
|
+
...(sceneData.editor ?? {}),
|
|
710
|
+
...nextEditor,
|
|
711
|
+
},
|
|
712
|
+
};
|
|
713
|
+
}
|
|
714
|
+
function darkenSceneBackground(background) {
|
|
715
|
+
const parsed = parseHexColor(background ?? '#121213');
|
|
716
|
+
if (!parsed) return 'var(--castle-stage-workspace-bg)';
|
|
717
|
+
const amount = 0.42;
|
|
718
|
+
const darken = (channel) => Math.max(0, Math.round(channel * (1 - amount)));
|
|
719
|
+
return `rgb(${darken(parsed.r)}, ${darken(parsed.g)}, ${darken(parsed.b)})`;
|
|
720
|
+
}
|
|
721
|
+
function parseHexColor(value) {
|
|
722
|
+
if (typeof value !== 'string') return null;
|
|
723
|
+
const hex = value.trim().replace(/^#/, '');
|
|
724
|
+
const expand = (text) =>
|
|
725
|
+
text
|
|
726
|
+
.split('')
|
|
727
|
+
.map((char) => `${char}${char}`)
|
|
728
|
+
.join('');
|
|
729
|
+
const normalized =
|
|
730
|
+
hex.length === 3 || hex.length === 4
|
|
731
|
+
? expand(hex.slice(0, 3))
|
|
732
|
+
: hex.length === 6 || hex.length === 8
|
|
733
|
+
? hex.slice(0, 6)
|
|
734
|
+
: null;
|
|
735
|
+
if (!normalized || !/^[0-9a-f]{6}$/i.test(normalized)) return null;
|
|
736
|
+
return {
|
|
737
|
+
r: parseInt(normalized.slice(0, 2), 16),
|
|
738
|
+
g: parseInt(normalized.slice(2, 4), 16),
|
|
739
|
+
b: parseInt(normalized.slice(4, 6), 16),
|
|
740
|
+
};
|
|
741
|
+
}
|
|
742
|
+
function useSelectionInspectorSheet(open) {
|
|
743
|
+
// Inspector visibility is driven by selection / multi-mode; the sheet hook
|
|
744
|
+
// owns the drag-resize height and remembers it across selections while
|
|
745
|
+
// mounted (and restores it when the sheet reopens), plus persists it across
|
|
746
|
+
// reloads via `storageKey`.
|
|
747
|
+
return useMobileSheet({ open, baseClassName: styles.inspector, storageKey: 'scene-inspector' });
|
|
748
|
+
}
|
|
749
|
+
function useSelectionGesture(args) {
|
|
750
|
+
const dragRef = useRef(null);
|
|
751
|
+
// Pin args in a ref so the timer callback reads the latest values without
|
|
752
|
+
// re-creating the handlers each render.
|
|
753
|
+
const argsRef = useRef(args);
|
|
754
|
+
argsRef.current = args;
|
|
755
|
+
const onPointerDown = useCallback((event) => {
|
|
756
|
+
const current = argsRef.current;
|
|
757
|
+
if (current.isPlaying || !current.canvasRef.current || !current.sceneData) return;
|
|
758
|
+
const raw = screenToCard(current.canvasRef.current, event.clientX, event.clientY);
|
|
759
|
+
const cam = current.editCameraRef.current;
|
|
760
|
+
const point = { x: raw.x + cam.x, y: raw.y + cam.y };
|
|
761
|
+
current.canvasRef.current.setPointerCapture(event.pointerId);
|
|
762
|
+
const scene = makeScene(current.sceneData, behaviorClasses, current.sprites, current.files);
|
|
763
|
+
const actor = scene.actorAt(point.x, point.y);
|
|
764
|
+
const drag = {
|
|
765
|
+
pointerId: event.pointerId,
|
|
766
|
+
startPoint: point,
|
|
767
|
+
lastPoint: point,
|
|
768
|
+
startedOnActorId: actor?.id ?? null,
|
|
769
|
+
modeAtStart: current.multiSelectMode,
|
|
770
|
+
movedFar: false,
|
|
771
|
+
longPressTimer: null,
|
|
772
|
+
longPressFired: false,
|
|
773
|
+
kind: 'idle',
|
|
774
|
+
pendingMarquee: false,
|
|
775
|
+
movingActorIds: [],
|
|
776
|
+
moveStarts: {},
|
|
777
|
+
recordedMoveSnapshot: false,
|
|
778
|
+
lastMoveKey: '0,0',
|
|
779
|
+
selectionBeforeMarquee: [...current.selectedActorIds],
|
|
780
|
+
};
|
|
781
|
+
if (event.shiftKey) {
|
|
782
|
+
handleShiftPointerDown(drag, actor, current);
|
|
783
|
+
} else if (current.multiSelectMode) {
|
|
784
|
+
handleModePointerDown(drag, actor, current);
|
|
785
|
+
} else {
|
|
786
|
+
handleDefaultPointerDown(drag, actor, point, current);
|
|
787
|
+
}
|
|
788
|
+
drag.moveStarts = collectMoveStarts(current.sceneData, drag.movingActorIds);
|
|
789
|
+
dragRef.current = drag;
|
|
790
|
+
}, []);
|
|
791
|
+
const onPointerMove = useCallback((event) => {
|
|
792
|
+
const drag = dragRef.current;
|
|
793
|
+
const current = argsRef.current;
|
|
794
|
+
if (!drag || drag.pointerId !== event.pointerId) return;
|
|
795
|
+
if (!current.canvasRef.current || !current.sceneData) return;
|
|
796
|
+
const raw = screenToCard(current.canvasRef.current, event.clientX, event.clientY);
|
|
797
|
+
const cam = current.editCameraRef.current;
|
|
798
|
+
const point = { x: raw.x + cam.x, y: raw.y + cam.y };
|
|
799
|
+
const dxTotal = point.x - drag.startPoint.x;
|
|
800
|
+
const dyTotal = point.y - drag.startPoint.y;
|
|
801
|
+
if (!drag.movedFar && Math.hypot(dxTotal, dyTotal) > DRAG_THRESHOLD) {
|
|
802
|
+
drag.movedFar = true;
|
|
803
|
+
if (drag.longPressTimer !== null) {
|
|
804
|
+
window.clearTimeout(drag.longPressTimer);
|
|
805
|
+
drag.longPressTimer = null;
|
|
806
|
+
}
|
|
807
|
+
if (drag.pendingMarquee && drag.kind === 'idle') drag.kind = 'marquee';
|
|
808
|
+
}
|
|
809
|
+
if (drag.kind === 'move' && drag.movedFar) {
|
|
810
|
+
const snap = getSnapSettings(current.sceneData);
|
|
811
|
+
const dx = snapDelta(dxTotal, snap.gridSize, snap.enabled);
|
|
812
|
+
const dy = snapDelta(dyTotal, snap.gridSize, snap.enabled);
|
|
813
|
+
drag.lastPoint = point;
|
|
814
|
+
// Key the commit on the snapped delta, not on `nextScene === sceneData`.
|
|
815
|
+
// `moveActorsFromStarts` returns the pointer-down scene unchanged when the
|
|
816
|
+
// delta lands back on the start, and an identity guard would skip applying
|
|
817
|
+
// it -- stranding the actors on the last grid step instead of letting them
|
|
818
|
+
// return to origin. Re-apply whenever the snapped delta changes; the
|
|
819
|
+
// identity check now only gates recording the undo snapshot.
|
|
820
|
+
const moveKey = `${dx},${dy}`;
|
|
821
|
+
if (moveKey === drag.lastMoveKey) return;
|
|
822
|
+
drag.lastMoveKey = moveKey;
|
|
823
|
+
const nextScene = moveActorsFromStarts(
|
|
824
|
+
current.sceneData,
|
|
825
|
+
drag.movingActorIds,
|
|
826
|
+
drag.moveStarts,
|
|
827
|
+
dx,
|
|
828
|
+
dy
|
|
829
|
+
);
|
|
830
|
+
if (nextScene !== current.sceneData && !drag.recordedMoveSnapshot) {
|
|
831
|
+
current.recordSceneSnapshot();
|
|
832
|
+
drag.recordedMoveSnapshot = true;
|
|
833
|
+
}
|
|
834
|
+
current.applyScene(nextScene);
|
|
835
|
+
} else if (drag.kind === 'marquee') {
|
|
836
|
+
drag.lastPoint = point;
|
|
837
|
+
current.marqueeRef.current = {
|
|
838
|
+
x: Math.min(drag.startPoint.x, point.x),
|
|
839
|
+
y: Math.min(drag.startPoint.y, point.y),
|
|
840
|
+
width: Math.abs(point.x - drag.startPoint.x),
|
|
841
|
+
height: Math.abs(point.y - drag.startPoint.y),
|
|
842
|
+
};
|
|
843
|
+
}
|
|
844
|
+
}, []);
|
|
845
|
+
const onPointerUp = useCallback((event) => {
|
|
846
|
+
const drag = dragRef.current;
|
|
847
|
+
const current = argsRef.current;
|
|
848
|
+
if (!drag || drag.pointerId !== event.pointerId) return;
|
|
849
|
+
if (drag.longPressTimer !== null) {
|
|
850
|
+
window.clearTimeout(drag.longPressTimer);
|
|
851
|
+
drag.longPressTimer = null;
|
|
852
|
+
}
|
|
853
|
+
if (drag.kind === 'marquee') {
|
|
854
|
+
finalizeMarquee(drag, current);
|
|
855
|
+
} else if (drag.kind === 'idle' && !drag.movedFar && !drag.longPressFired) {
|
|
856
|
+
handleTap(drag, current);
|
|
857
|
+
}
|
|
858
|
+
current.marqueeRef.current = null;
|
|
859
|
+
dragRef.current = null;
|
|
860
|
+
}, []);
|
|
861
|
+
return { onPointerDown, onPointerMove, onPointerUp };
|
|
862
|
+
}
|
|
863
|
+
function getSnapSettings(sceneData) {
|
|
864
|
+
const rawSize = sceneData.editor?.gridSize;
|
|
865
|
+
return {
|
|
866
|
+
enabled: sceneData.editor?.snapToGrid ?? true,
|
|
867
|
+
gridSize: Number.isFinite(rawSize) && rawSize && rawSize > 0 ? rawSize : DEFAULT_GRID_SIZE,
|
|
868
|
+
};
|
|
869
|
+
}
|
|
870
|
+
function snapDelta(delta, gridSize, enabled) {
|
|
871
|
+
if (!enabled) return delta;
|
|
872
|
+
return Math.round(delta / gridSize) * gridSize;
|
|
873
|
+
}
|
|
874
|
+
function collectMoveStarts(sceneData, actorIds) {
|
|
875
|
+
const starts = {};
|
|
876
|
+
const movingIds = new Set(actorIds);
|
|
877
|
+
for (const actor of sceneData.actors) {
|
|
878
|
+
if (!movingIds.has(actor.id)) continue;
|
|
879
|
+
const layout = actor.components.Layout;
|
|
880
|
+
if (!layout) continue;
|
|
881
|
+
starts[actor.id] = { x: layout.x, y: layout.y };
|
|
882
|
+
}
|
|
883
|
+
return starts;
|
|
884
|
+
}
|
|
885
|
+
function moveActorsFromStarts(sceneData, actorIds, starts, dx, dy) {
|
|
886
|
+
if (actorIds.length === 0) return sceneData;
|
|
887
|
+
const movingIds = new Set(actorIds);
|
|
888
|
+
const next = structuredClone(sceneData);
|
|
889
|
+
let changed = false;
|
|
890
|
+
for (const actor of next.actors) {
|
|
891
|
+
if (!movingIds.has(actor.id)) continue;
|
|
892
|
+
const start = starts[actor.id];
|
|
893
|
+
const layout = actor.components.Layout;
|
|
894
|
+
if (!start || !layout) continue;
|
|
895
|
+
const nextX = Math.round(start.x + dx);
|
|
896
|
+
const nextY = Math.round(start.y + dy);
|
|
897
|
+
if (layout.x === nextX && layout.y === nextY) continue;
|
|
898
|
+
actor.components.Layout = {
|
|
899
|
+
...layout,
|
|
900
|
+
x: nextX,
|
|
901
|
+
y: nextY,
|
|
902
|
+
};
|
|
903
|
+
changed = true;
|
|
904
|
+
}
|
|
905
|
+
return changed ? next : sceneData;
|
|
906
|
+
}
|
|
907
|
+
function usePlayPointerGesture({ canvasRef, runtimeRef }) {
|
|
908
|
+
const setPointer = useCallback(
|
|
909
|
+
(event, down) => {
|
|
910
|
+
const canvas = canvasRef.current;
|
|
911
|
+
const runtime = runtimeRef.current;
|
|
912
|
+
if (!canvas || !runtime) return;
|
|
913
|
+
runtime.setPointerFromScreen(canvas, event.clientX, event.clientY, down);
|
|
914
|
+
},
|
|
915
|
+
[canvasRef, runtimeRef]
|
|
916
|
+
);
|
|
917
|
+
const onPointerDown = useCallback(
|
|
918
|
+
(event) => {
|
|
919
|
+
event.currentTarget.setPointerCapture(event.pointerId);
|
|
920
|
+
setPointer(event, true);
|
|
921
|
+
},
|
|
922
|
+
[setPointer]
|
|
923
|
+
);
|
|
924
|
+
const onPointerMove = useCallback(
|
|
925
|
+
(event) => {
|
|
926
|
+
setPointer(event);
|
|
927
|
+
},
|
|
928
|
+
[setPointer]
|
|
929
|
+
);
|
|
930
|
+
const onPointerUp = useCallback(
|
|
931
|
+
(event) => {
|
|
932
|
+
setPointer(event, false);
|
|
933
|
+
try {
|
|
934
|
+
event.currentTarget.releasePointerCapture(event.pointerId);
|
|
935
|
+
} catch {
|
|
936
|
+
// Pointer capture may already be gone after cancel/blur.
|
|
937
|
+
}
|
|
938
|
+
},
|
|
939
|
+
[setPointer]
|
|
940
|
+
);
|
|
941
|
+
return { onPointerDown, onPointerMove, onPointerUp };
|
|
942
|
+
}
|
|
943
|
+
function usePanGesture({ canvasRef, editCameraRef, isPlaying }) {
|
|
944
|
+
const dragRef = useRef(null);
|
|
945
|
+
const spaceDownRef = useRef(false);
|
|
946
|
+
// Native wheel/gesture listeners (below) read play state through a ref so they
|
|
947
|
+
// never need to detach/reattach when play toggles.
|
|
948
|
+
const isPlayingRef = useRef(isPlaying);
|
|
949
|
+
isPlayingRef.current = isPlaying;
|
|
950
|
+
useEffect(() => {
|
|
951
|
+
function onKeyDown(event) {
|
|
952
|
+
if (event.key !== ' ' || isEditableTarget(event.target)) return;
|
|
953
|
+
// Swallow Space's default: it would scroll the panel and re-activate a
|
|
954
|
+
// focused button (e.g. the Play/Stop toggle that keeps focus after a
|
|
955
|
+
// click), so Space only ever arms the pan modifier here.
|
|
956
|
+
event.preventDefault();
|
|
957
|
+
spaceDownRef.current = true;
|
|
958
|
+
}
|
|
959
|
+
function onKeyUp(event) {
|
|
960
|
+
if (event.key === ' ') spaceDownRef.current = false;
|
|
961
|
+
}
|
|
962
|
+
window.addEventListener('keydown', onKeyDown);
|
|
963
|
+
window.addEventListener('keyup', onKeyUp);
|
|
964
|
+
return () => {
|
|
965
|
+
window.removeEventListener('keydown', onKeyDown);
|
|
966
|
+
window.removeEventListener('keyup', onKeyUp);
|
|
967
|
+
};
|
|
968
|
+
}, []);
|
|
969
|
+
const panByScreenDelta = useCallback(
|
|
970
|
+
(dx, dy) => {
|
|
971
|
+
const canvas = canvasRef.current;
|
|
972
|
+
if (!canvas) return;
|
|
973
|
+
const rect = canvas.getBoundingClientRect();
|
|
974
|
+
const viewportWidth = Number(canvas.dataset.viewportWidth) || cardSize.width;
|
|
975
|
+
const viewportHeight = Number(canvas.dataset.viewportHeight) || cardSize.height;
|
|
976
|
+
editCameraRef.current = {
|
|
977
|
+
...editCameraRef.current,
|
|
978
|
+
x: editCameraRef.current.x + (dx * viewportWidth) / rect.width,
|
|
979
|
+
y: editCameraRef.current.y + (dy * viewportHeight) / rect.height,
|
|
980
|
+
};
|
|
981
|
+
},
|
|
982
|
+
[canvasRef, editCameraRef]
|
|
983
|
+
);
|
|
984
|
+
// Zoom about the pointer by a multiplicative factor: keep the world point
|
|
985
|
+
// under the cursor pinned while the zoom level changes. Because the effective
|
|
986
|
+
// origin scales as EDIT_VIEWPORT.origin / zoom, the camera shift to re-pin the
|
|
987
|
+
// cursor is (1/z0 - 1/z1) * (frac * EDIT_VIEWPORT.extent - EDIT_VIEWPORT.origin).
|
|
988
|
+
const zoomByFactorAtPointer = useCallback(
|
|
989
|
+
(clientX, clientY, factor) => {
|
|
990
|
+
const canvas = canvasRef.current;
|
|
991
|
+
if (!canvas || !Number.isFinite(factor) || factor <= 0) return;
|
|
992
|
+
const cam = editCameraRef.current;
|
|
993
|
+
const z0 = cam.zoom ?? 1;
|
|
994
|
+
const z1 = clampZoom(z0 * factor);
|
|
995
|
+
if (z1 === z0) return;
|
|
996
|
+
const rect = canvas.getBoundingClientRect();
|
|
997
|
+
const fracX = (clientX - rect.left) / rect.width;
|
|
998
|
+
const fracY = (clientY - rect.top) / rect.height;
|
|
999
|
+
const k = 1 / z0 - 1 / z1;
|
|
1000
|
+
editCameraRef.current = {
|
|
1001
|
+
x: cam.x + k * (fracX * EDIT_VIEWPORT.width - EDIT_VIEWPORT.originX),
|
|
1002
|
+
y: cam.y + k * (fracY * EDIT_VIEWPORT.height - EDIT_VIEWPORT.originY),
|
|
1003
|
+
zoom: z1,
|
|
1004
|
+
};
|
|
1005
|
+
},
|
|
1006
|
+
[canvasRef, editCameraRef]
|
|
1007
|
+
);
|
|
1008
|
+
// Wheel + pinch are bound as NATIVE, non-passive listeners (not React props):
|
|
1009
|
+
// React attaches `wheel` passively at its root, so a React onWheel can't call
|
|
1010
|
+
// preventDefault and the browser's own page-zoom wins. We also need Safari's
|
|
1011
|
+
// proprietary `gesture*` events, which is how it reports trackpad pinch (it
|
|
1012
|
+
// does not emit ctrl+wheel like Chromium). Both paths preventDefault to stop
|
|
1013
|
+
// the browser from zooming the page underneath the editor.
|
|
1014
|
+
useEffect(() => {
|
|
1015
|
+
const canvas = canvasRef.current;
|
|
1016
|
+
if (!canvas) return undefined;
|
|
1017
|
+
let gestureScale = 1;
|
|
1018
|
+
// Set while a Safari pinch gesture is in flight, so we don't double-apply
|
|
1019
|
+
// zoom if Safari also emits ctrl+wheel for the same pinch.
|
|
1020
|
+
let gestureActive = false;
|
|
1021
|
+
const onWheel = (event) => {
|
|
1022
|
+
if (isPlayingRef.current) return;
|
|
1023
|
+
event.preventDefault();
|
|
1024
|
+
// Trackpad pinch (and Ctrl+wheel) surface as wheel events with ctrlKey set
|
|
1025
|
+
// in Chromium; route those to zoom and leave plain scroll as pan.
|
|
1026
|
+
if (event.ctrlKey) {
|
|
1027
|
+
if (gestureActive) return;
|
|
1028
|
+
zoomByFactorAtPointer(
|
|
1029
|
+
event.clientX,
|
|
1030
|
+
event.clientY,
|
|
1031
|
+
Math.exp(-event.deltaY * ZOOM_WHEEL_SPEED)
|
|
1032
|
+
);
|
|
1033
|
+
} else {
|
|
1034
|
+
panByScreenDelta(event.deltaX, event.deltaY);
|
|
1035
|
+
}
|
|
1036
|
+
};
|
|
1037
|
+
const onGestureStart = (event) => {
|
|
1038
|
+
if (isPlayingRef.current) return;
|
|
1039
|
+
event.preventDefault();
|
|
1040
|
+
gestureActive = true;
|
|
1041
|
+
gestureScale = event.scale || 1;
|
|
1042
|
+
};
|
|
1043
|
+
const onGestureChange = (event) => {
|
|
1044
|
+
if (isPlayingRef.current) return;
|
|
1045
|
+
event.preventDefault();
|
|
1046
|
+
// Safari reports `scale` cumulatively from gesturestart (1.0 at start), so
|
|
1047
|
+
// zoom by the ratio against the last reading.
|
|
1048
|
+
const prev = gestureScale || 1;
|
|
1049
|
+
const next = event.scale || prev;
|
|
1050
|
+
gestureScale = next;
|
|
1051
|
+
// GestureEvent carries clientX/clientY (the pinch centroid); fall back to
|
|
1052
|
+
// the canvas center if a browser omits them.
|
|
1053
|
+
const rect = canvas.getBoundingClientRect();
|
|
1054
|
+
const px = Number.isFinite(event.clientX) ? event.clientX : rect.left + rect.width / 2;
|
|
1055
|
+
const py = Number.isFinite(event.clientY) ? event.clientY : rect.top + rect.height / 2;
|
|
1056
|
+
zoomByFactorAtPointer(px, py, next / prev);
|
|
1057
|
+
};
|
|
1058
|
+
const onGestureEnd = (event) => {
|
|
1059
|
+
if (isPlayingRef.current) return;
|
|
1060
|
+
event.preventDefault();
|
|
1061
|
+
gestureActive = false;
|
|
1062
|
+
};
|
|
1063
|
+
const opts = { passive: false };
|
|
1064
|
+
canvas.addEventListener('wheel', onWheel, opts);
|
|
1065
|
+
canvas.addEventListener('gesturestart', onGestureStart, opts);
|
|
1066
|
+
canvas.addEventListener('gesturechange', onGestureChange, opts);
|
|
1067
|
+
canvas.addEventListener('gestureend', onGestureEnd, opts);
|
|
1068
|
+
return () => {
|
|
1069
|
+
canvas.removeEventListener('wheel', onWheel, opts);
|
|
1070
|
+
canvas.removeEventListener('gesturestart', onGestureStart, opts);
|
|
1071
|
+
canvas.removeEventListener('gesturechange', onGestureChange, opts);
|
|
1072
|
+
canvas.removeEventListener('gestureend', onGestureEnd, opts);
|
|
1073
|
+
};
|
|
1074
|
+
}, [canvasRef, panByScreenDelta, zoomByFactorAtPointer]);
|
|
1075
|
+
const onPointerDown = useCallback((event) => {
|
|
1076
|
+
if (isPlaying || !spaceDownRef.current) return;
|
|
1077
|
+
event.preventDefault();
|
|
1078
|
+
event.currentTarget.setPointerCapture(event.pointerId);
|
|
1079
|
+
dragRef.current = { id: event.pointerId, lastX: event.clientX, lastY: event.clientY };
|
|
1080
|
+
}, [isPlaying]);
|
|
1081
|
+
const onPointerMove = useCallback(
|
|
1082
|
+
(event) => {
|
|
1083
|
+
const drag = dragRef.current;
|
|
1084
|
+
if (!drag || drag.id !== event.pointerId) return;
|
|
1085
|
+
const dx = event.clientX - drag.lastX;
|
|
1086
|
+
const dy = event.clientY - drag.lastY;
|
|
1087
|
+
drag.lastX = event.clientX;
|
|
1088
|
+
drag.lastY = event.clientY;
|
|
1089
|
+
panByScreenDelta(-dx, -dy);
|
|
1090
|
+
},
|
|
1091
|
+
[panByScreenDelta]
|
|
1092
|
+
);
|
|
1093
|
+
const onPointerUp = useCallback((event) => {
|
|
1094
|
+
const drag = dragRef.current;
|
|
1095
|
+
if (!drag || drag.id !== event.pointerId) return;
|
|
1096
|
+
try {
|
|
1097
|
+
event.currentTarget.releasePointerCapture(event.pointerId);
|
|
1098
|
+
} catch {
|
|
1099
|
+
// pointer capture may already be gone
|
|
1100
|
+
}
|
|
1101
|
+
dragRef.current = null;
|
|
1102
|
+
}, []);
|
|
1103
|
+
const isSpacePanning = useCallback(() => spaceDownRef.current, []);
|
|
1104
|
+
const isActive = useCallback(() => Boolean(dragRef.current), []);
|
|
1105
|
+
return { onPointerDown, onPointerMove, onPointerUp, isSpacePanning, isActive };
|
|
1106
|
+
}
|
|
1107
|
+
function handleShiftPointerDown(drag, actor, current) {
|
|
1108
|
+
if (actor) {
|
|
1109
|
+
const wasSelected = current.selectedActorIds.includes(actor.id);
|
|
1110
|
+
const nextIds = wasSelected
|
|
1111
|
+
? current.selectedActorIds.filter((id) => id !== actor.id)
|
|
1112
|
+
: [...current.selectedActorIds, actor.id];
|
|
1113
|
+
current.onSelectActorIds(nextIds);
|
|
1114
|
+
if (!wasSelected) {
|
|
1115
|
+
drag.kind = 'move';
|
|
1116
|
+
drag.movingActorIds = nextIds;
|
|
1117
|
+
}
|
|
1118
|
+
} else {
|
|
1119
|
+
drag.pendingMarquee = true;
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
function handleModePointerDown(drag, actor, current) {
|
|
1123
|
+
if (actor) {
|
|
1124
|
+
if (current.selectedActorIds.includes(actor.id)) {
|
|
1125
|
+
drag.kind = 'move';
|
|
1126
|
+
drag.movingActorIds = [...current.selectedActorIds];
|
|
1127
|
+
}
|
|
1128
|
+
// else: leave kind='idle' so pointerup triggers the tap-toggle path.
|
|
1129
|
+
} else {
|
|
1130
|
+
// Defer marquee until movement, so a stationary press/release reaches
|
|
1131
|
+
// the tap handler (which exits multi mode on empty tap).
|
|
1132
|
+
drag.pendingMarquee = true;
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
function handleDefaultPointerDown(drag, actor, point, current) {
|
|
1136
|
+
if (actor) {
|
|
1137
|
+
if (!current.selectedActorIds.includes(actor.id)) {
|
|
1138
|
+
current.onSelectActorIds([actor.id]);
|
|
1139
|
+
drag.movingActorIds = [actor.id];
|
|
1140
|
+
} else {
|
|
1141
|
+
drag.movingActorIds = [...current.selectedActorIds];
|
|
1142
|
+
}
|
|
1143
|
+
drag.kind = 'move';
|
|
1144
|
+
drag.longPressTimer = window.setTimeout(() => {
|
|
1145
|
+
if (drag.movedFar) return;
|
|
1146
|
+
drag.longPressFired = true;
|
|
1147
|
+
current.onSetMultiSelectMode(true);
|
|
1148
|
+
}, LONG_PRESS_MS);
|
|
1149
|
+
} else {
|
|
1150
|
+
current.onSelectActorIds([]);
|
|
1151
|
+
drag.selectionBeforeMarquee = [];
|
|
1152
|
+
drag.pendingMarquee = true;
|
|
1153
|
+
drag.longPressTimer = window.setTimeout(() => {
|
|
1154
|
+
if (drag.movedFar) return;
|
|
1155
|
+
drag.longPressFired = true;
|
|
1156
|
+
current.onSetMultiSelectMode(true);
|
|
1157
|
+
drag.kind = 'marquee';
|
|
1158
|
+
current.marqueeRef.current = { x: point.x, y: point.y, width: 0, height: 0 };
|
|
1159
|
+
}, LONG_PRESS_MS);
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
function finalizeMarquee(drag, current) {
|
|
1163
|
+
const m = current.marqueeRef.current;
|
|
1164
|
+
if (!m || !current.sceneData) return;
|
|
1165
|
+
if (m.width === 0 && m.height === 0) return;
|
|
1166
|
+
const scene = makeScene(current.sceneData, behaviorClasses, current.sprites, current.files);
|
|
1167
|
+
const hits = scene.actorIdsInRect(m);
|
|
1168
|
+
const merged = new Set(drag.selectionBeforeMarquee);
|
|
1169
|
+
for (const id of hits) merged.add(id);
|
|
1170
|
+
current.onSelectActorIds([...merged]);
|
|
1171
|
+
}
|
|
1172
|
+
function handleTap(drag, current) {
|
|
1173
|
+
if (!drag.modeAtStart) return;
|
|
1174
|
+
if (drag.startedOnActorId !== null) {
|
|
1175
|
+
const id = drag.startedOnActorId;
|
|
1176
|
+
const wasSelected = current.selectedActorIds.includes(id);
|
|
1177
|
+
current.onSelectActorIds(
|
|
1178
|
+
wasSelected
|
|
1179
|
+
? current.selectedActorIds.filter((x) => x !== id)
|
|
1180
|
+
: [...current.selectedActorIds, id]
|
|
1181
|
+
);
|
|
1182
|
+
} else {
|
|
1183
|
+
current.onSetMultiSelectMode(false);
|
|
1184
|
+
current.onSelectActorIds([]);
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
function useSelectionKeyboard(args) {
|
|
1188
|
+
const ref = useRef(args);
|
|
1189
|
+
ref.current = args;
|
|
1190
|
+
useEffect(() => {
|
|
1191
|
+
function onKeyDown(event) {
|
|
1192
|
+
const current = ref.current;
|
|
1193
|
+
if (current.isPlaying || isEditableTarget(event.target)) return;
|
|
1194
|
+
if (event.key === 'Escape') {
|
|
1195
|
+
if (current.selectedActorIds.length || current.multiSelectMode || current.selectedBlueprintPath) {
|
|
1196
|
+
event.preventDefault();
|
|
1197
|
+
// The wrapped setter also clears any hotbar blueprint selection.
|
|
1198
|
+
current.onSelectActorIds([]);
|
|
1199
|
+
current.onSetMultiSelectMode(false);
|
|
1200
|
+
}
|
|
1201
|
+
return;
|
|
1202
|
+
}
|
|
1203
|
+
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'a') {
|
|
1204
|
+
if (!current.sceneData || current.isBlueprintFile) return;
|
|
1205
|
+
event.preventDefault();
|
|
1206
|
+
current.onSelectActorIds(current.sceneData.actors.map((actor) => actor.id));
|
|
1207
|
+
return;
|
|
1208
|
+
}
|
|
1209
|
+
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'd') {
|
|
1210
|
+
if (!current.sceneData || current.isBlueprintFile || current.selectedActorIds.length === 0) return;
|
|
1211
|
+
event.preventDefault();
|
|
1212
|
+
const { sceneData: next, newIds } = duplicateActors(
|
|
1213
|
+
current.sceneData,
|
|
1214
|
+
current.selectedActorIds
|
|
1215
|
+
);
|
|
1216
|
+
current.commitScene(next);
|
|
1217
|
+
if (newIds.length) current.onSelectActorIds(newIds);
|
|
1218
|
+
return;
|
|
1219
|
+
}
|
|
1220
|
+
if (event.key === 'Delete' || event.key === 'Backspace') {
|
|
1221
|
+
if (!current.sceneData || current.isBlueprintFile || current.selectedActorIds.length === 0) return;
|
|
1222
|
+
event.preventDefault();
|
|
1223
|
+
current.commitScene(removeActors(current.sceneData, current.selectedActorIds));
|
|
1224
|
+
current.onSelectActorIds([]);
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
window.addEventListener('keydown', onKeyDown);
|
|
1228
|
+
return () => window.removeEventListener('keydown', onKeyDown);
|
|
1229
|
+
}, []);
|
|
1230
|
+
}
|
|
1231
|
+
function useScenePlayLoop({
|
|
1232
|
+
sceneData,
|
|
1233
|
+
sprites,
|
|
1234
|
+
files,
|
|
1235
|
+
isPlaying,
|
|
1236
|
+
text,
|
|
1237
|
+
canvasRef,
|
|
1238
|
+
runtimeRef,
|
|
1239
|
+
editCameraRef,
|
|
1240
|
+
marqueeRef,
|
|
1241
|
+
selectedActorIdsRef,
|
|
1242
|
+
selectedBlueprintPathRef,
|
|
1243
|
+
}) {
|
|
1244
|
+
// Spin up / tear down the play-mode runtime as the user toggles play. `text`
|
|
1245
|
+
// is the stable identity for `sceneData` (which is re-parsed every render);
|
|
1246
|
+
// depending on `sceneData` here would reset the runtime — and any
|
|
1247
|
+
// behavior-mutated state like Layout.rotation — on every parent re-render.
|
|
1248
|
+
useEffect(() => {
|
|
1249
|
+
if (!isPlaying) {
|
|
1250
|
+
runtimeRef.current = null;
|
|
1251
|
+
return;
|
|
1252
|
+
}
|
|
1253
|
+
if (!sceneData) return;
|
|
1254
|
+
runtimeRef.current = makeScene(sceneData, behaviorClasses, sprites, files).clone();
|
|
1255
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1256
|
+
}, [sprites, isPlaying, text, runtimeRef]);
|
|
1257
|
+
// Animation loop -- draws edit-mode previews and ticks the play-mode runtime.
|
|
1258
|
+
useEffect(() => {
|
|
1259
|
+
if (!sceneData || !canvasRef.current) return undefined;
|
|
1260
|
+
const canvas = canvasRef.current;
|
|
1261
|
+
const ctx = canvas.getContext('2d');
|
|
1262
|
+
if (!ctx) return undefined;
|
|
1263
|
+
const viewport = isPlaying
|
|
1264
|
+
? undefined
|
|
1265
|
+
: editViewportForZoom(editCameraRef.current.zoom);
|
|
1266
|
+
configureSceneCanvas(canvas, ctx, viewport);
|
|
1267
|
+
let raf = 0;
|
|
1268
|
+
let last = performance.now();
|
|
1269
|
+
const frame = (now) => {
|
|
1270
|
+
const dt = Math.min(0.033, (now - last) / 1000);
|
|
1271
|
+
last = now;
|
|
1272
|
+
// Recompute each frame so the edit zoom (mutated on the camera ref by the
|
|
1273
|
+
// pinch gesture) takes effect without restarting the loop.
|
|
1274
|
+
const frameViewport = isPlaying
|
|
1275
|
+
? undefined
|
|
1276
|
+
: editViewportForZoom(editCameraRef.current.zoom);
|
|
1277
|
+
const scene = isPlaying
|
|
1278
|
+
? runtimeRef.current
|
|
1279
|
+
: makeScene(sceneData, behaviorClasses, sprites, files);
|
|
1280
|
+
if (scene) {
|
|
1281
|
+
const snap = getSnapSettings(sceneData);
|
|
1282
|
+
if (isPlaying) scene.update(dt);
|
|
1283
|
+
if (!isPlaying) scene.camera = { ...editCameraRef.current };
|
|
1284
|
+
configureSceneCanvas(canvas, ctx, frameViewport);
|
|
1285
|
+
// Read selection from the ref each frame so the grid/box gating tracks
|
|
1286
|
+
// the current selection without resetting this loop.
|
|
1287
|
+
const editSelectedActorIds = isPlaying ? [] : selectedActorIdsRef.current ?? [];
|
|
1288
|
+
scene.draw(ctx, {
|
|
1289
|
+
selectedActorIds: [],
|
|
1290
|
+
marquee: isPlaying ? null : marqueeRef.current,
|
|
1291
|
+
showGrid: !isPlaying && snap.enabled && editSelectedActorIds.length > 0,
|
|
1292
|
+
gridSize: snap.gridSize,
|
|
1293
|
+
showDebugColliders: false,
|
|
1294
|
+
showCropOutline: !isPlaying,
|
|
1295
|
+
viewport: frameViewport,
|
|
1296
|
+
useCamera: true,
|
|
1297
|
+
editPlaceholders: !isPlaying,
|
|
1298
|
+
dimBlueprintPath: isPlaying ? null : selectedBlueprintPathRef.current,
|
|
1299
|
+
});
|
|
1300
|
+
}
|
|
1301
|
+
raf = requestAnimationFrame(frame);
|
|
1302
|
+
};
|
|
1303
|
+
raf = requestAnimationFrame(frame);
|
|
1304
|
+
return () => cancelAnimationFrame(raf);
|
|
1305
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- `text` proxies `sceneData` identity; resetting on every parse churns the loop and wipes runtime state.
|
|
1306
|
+
}, [
|
|
1307
|
+
sprites,
|
|
1308
|
+
files,
|
|
1309
|
+
isPlaying,
|
|
1310
|
+
text,
|
|
1311
|
+
canvasRef,
|
|
1312
|
+
runtimeRef,
|
|
1313
|
+
marqueeRef,
|
|
1314
|
+
selectedActorIdsRef,
|
|
1315
|
+
selectedBlueprintPathRef,
|
|
1316
|
+
]);
|
|
1317
|
+
}
|
|
1318
|
+
function useScenePlayKeys(runtimeRef) {
|
|
1319
|
+
useEffect(() => {
|
|
1320
|
+
const down = (event) => {
|
|
1321
|
+
if (!runtimeRef.current) return;
|
|
1322
|
+
// Space is a game input while playing — stop the browser from scrolling
|
|
1323
|
+
// the panel or re-triggering a focused button (the Play/Stop toggle keeps
|
|
1324
|
+
// focus after a click) instead of reaching gameplay.
|
|
1325
|
+
if (event.key === ' ') event.preventDefault();
|
|
1326
|
+
runtimeRef.current.keys.add(event.key);
|
|
1327
|
+
};
|
|
1328
|
+
const up = (event) => {
|
|
1329
|
+
if (!runtimeRef.current) return;
|
|
1330
|
+
runtimeRef.current.keys.delete(event.key);
|
|
1331
|
+
};
|
|
1332
|
+
window.addEventListener('keydown', down);
|
|
1333
|
+
window.addEventListener('keyup', up);
|
|
1334
|
+
return () => {
|
|
1335
|
+
window.removeEventListener('keydown', down);
|
|
1336
|
+
window.removeEventListener('keyup', up);
|
|
1337
|
+
};
|
|
1338
|
+
}, [runtimeRef]);
|
|
1339
|
+
}
|
|
1340
|
+
function isEditableTarget(target) {
|
|
1341
|
+
return (
|
|
1342
|
+
target instanceof Element &&
|
|
1343
|
+
!!target.closest('input, textarea, select, [contenteditable="true"]')
|
|
1344
|
+
);
|
|
1345
|
+
}
|
|
1346
|
+
// Clone/Delete moved to the on-canvas SelectionOverlay toolbar; this inspector
|
|
1347
|
+
// now only carries the multi-select status text and "Deselect all".
|
|
1348
|
+
function MultiSelectInspector({ count, onDeselectAll }) {
|
|
1349
|
+
return (
|
|
1350
|
+
<div style={{ padding: '14px 16px', display: 'flex', flexDirection: 'column', gap: 12 }}>
|
|
1351
|
+
<div style={{ fontSize: 13, opacity: 0.8 }}>
|
|
1352
|
+
{count === 0
|
|
1353
|
+
? 'Multi-select mode -- tap actors to add'
|
|
1354
|
+
: `${count} actor${count === 1 ? '' : 's'} selected`}
|
|
1355
|
+
</div>
|
|
1356
|
+
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
|
1357
|
+
<button type="button" onClick={onDeselectAll} style={inspectorActionStyle(false)}>
|
|
1358
|
+
<Icon name="times" /> Deselect all
|
|
1359
|
+
</button>
|
|
1360
|
+
</div>
|
|
1361
|
+
</div>
|
|
1362
|
+
);
|
|
1363
|
+
}
|
|
1364
|
+
function inspectorActionStyle(disabled) {
|
|
1365
|
+
return {
|
|
1366
|
+
display: 'inline-flex',
|
|
1367
|
+
alignItems: 'center',
|
|
1368
|
+
gap: 6,
|
|
1369
|
+
padding: '6px 10px',
|
|
1370
|
+
background: 'transparent',
|
|
1371
|
+
color: 'inherit',
|
|
1372
|
+
border: '1px solid var(--castle-inspector-divider)',
|
|
1373
|
+
borderRadius: 6,
|
|
1374
|
+
cursor: disabled ? 'default' : 'pointer',
|
|
1375
|
+
opacity: disabled ? 0.5 : 1,
|
|
1376
|
+
fontSize: 13,
|
|
1377
|
+
};
|
|
1378
|
+
}
|
|
1379
|
+
function SceneInspector({ sceneData, snapSettings, onChangeScene, onChangeEditor }) {
|
|
1380
|
+
return (
|
|
1381
|
+
<Panel title="Scene">
|
|
1382
|
+
<TextField
|
|
1383
|
+
label="Name"
|
|
1384
|
+
value={sceneData.name ?? ''}
|
|
1385
|
+
onChange={(name) => onChangeScene({ name })}
|
|
1386
|
+
/>
|
|
1387
|
+
<ColorField
|
|
1388
|
+
label="Background"
|
|
1389
|
+
value={sceneData.background ?? '#121213'}
|
|
1390
|
+
onChange={(background) => onChangeScene({ background })}
|
|
1391
|
+
/>
|
|
1392
|
+
<CheckboxField
|
|
1393
|
+
label="Snap to grid"
|
|
1394
|
+
checked={snapSettings.enabled}
|
|
1395
|
+
onChange={(snapToGrid) => onChangeEditor({ snapToGrid })}
|
|
1396
|
+
/>
|
|
1397
|
+
<NumberField
|
|
1398
|
+
label="Grid size"
|
|
1399
|
+
value={snapSettings.gridSize}
|
|
1400
|
+
min={1}
|
|
1401
|
+
step={1}
|
|
1402
|
+
onChange={(gridSize) => onChangeEditor({ gridSize: Math.round(gridSize) })}
|
|
1403
|
+
/>
|
|
1404
|
+
</Panel>
|
|
1405
|
+
);
|
|
1406
|
+
}
|
|
1407
|
+
// Sidebar inspector for a hotbar-selected blueprint: badge + name header, then
|
|
1408
|
+
// the template's components through the same ActorInspector panels used for
|
|
1409
|
+
// actors -- the template is presented as a synthetic actor with no `blueprint`
|
|
1410
|
+
// ref, so ActorInspector treats it exactly like a blueprint file's template
|
|
1411
|
+
// (plain edits, no instance hint, Layout not removable). The action callbacks
|
|
1412
|
+
// drop the synthetic actor id and write the blueprint FILE (see
|
|
1413
|
+
// `updateBlueprintTemplate` in SceneEditor).
|
|
1414
|
+
function BlueprintInspector({
|
|
1415
|
+
name,
|
|
1416
|
+
template,
|
|
1417
|
+
sprites,
|
|
1418
|
+
blueprintPath,
|
|
1419
|
+
files,
|
|
1420
|
+
onRename,
|
|
1421
|
+
onSetComponent,
|
|
1422
|
+
onAddBehavior,
|
|
1423
|
+
onRemoveBehavior,
|
|
1424
|
+
}) {
|
|
1425
|
+
const templateActor = { id: '__blueprint__', components: template.components };
|
|
1426
|
+
const instanceCount = countBlueprintInstances(files, blueprintPath);
|
|
1427
|
+
return (
|
|
1428
|
+
<>
|
|
1429
|
+
<div className={styles.instanceHeader}>
|
|
1430
|
+
<div className={styles.instanceHeaderName}>
|
|
1431
|
+
<BlueprintThumbnail blueprint={template} sprites={sprites} />
|
|
1432
|
+
<div className={styles.instanceHeaderNameCol}>
|
|
1433
|
+
<input
|
|
1434
|
+
className={styles.blueprintNameInput}
|
|
1435
|
+
value={name}
|
|
1436
|
+
onChange={(event) => onRename?.(event.target.value)}
|
|
1437
|
+
aria-label="Blueprint name"
|
|
1438
|
+
spellCheck={false}
|
|
1439
|
+
/>
|
|
1440
|
+
<span className={styles.instanceHeaderId}>
|
|
1441
|
+
{instanceCount} {instanceCount === 1 ? 'actor' : 'actors'}
|
|
1442
|
+
</span>
|
|
1443
|
+
</div>
|
|
1444
|
+
</div>
|
|
1445
|
+
</div>
|
|
1446
|
+
<ActorInspector
|
|
1447
|
+
selectedActor={templateActor}
|
|
1448
|
+
rawActor={templateActor}
|
|
1449
|
+
files={files}
|
|
1450
|
+
onSetComponent={(actorId, behaviorName, nextProps) => onSetComponent(behaviorName, nextProps)}
|
|
1451
|
+
onAddBehavior={(actorId, behaviorName) => onAddBehavior(behaviorName)}
|
|
1452
|
+
onRemoveBehavior={(actorId, behaviorName) => onRemoveBehavior(behaviorName)}
|
|
1453
|
+
/>
|
|
1454
|
+
</>
|
|
1455
|
+
);
|
|
1456
|
+
}
|
|
1457
|
+
// Per-behavior helper the inspector fields use to render instance overrides.
|
|
1458
|
+
// `isOverridden` is true only for inherited props the instance has its own
|
|
1459
|
+
// value for (position/rotation are `inherit: false` and always instance-local,
|
|
1460
|
+
// so they never read as "overriding" a blueprint default). `baseline` is what
|
|
1461
|
+
// the prop falls back to when reset -- the blueprint template value if present,
|
|
1462
|
+
// else the behavior's default. `reset` writes that baseline; for inherited
|
|
1463
|
+
// props `applyComponentPatch` then drops the now-redundant override.
|
|
1464
|
+
function makeOverrideContext({ Behavior, rawComponent, templateProps, setComponent }) {
|
|
1465
|
+
const baselineOf = (prop) =>
|
|
1466
|
+
templateProps && prop in templateProps ? templateProps[prop] : Behavior?.defaultProps?.[prop];
|
|
1467
|
+
return {
|
|
1468
|
+
isOverridden: (prop) =>
|
|
1469
|
+
isInheritedProp(Behavior, prop) && !!rawComponent && prop in rawComponent,
|
|
1470
|
+
// True when the behavior overrides at least one inherited prop -- i.e. it
|
|
1471
|
+
// has a field that renders with the purple override tint. Drives the
|
|
1472
|
+
// matching tint on the panel's title. Position/rotation are inherit:false,
|
|
1473
|
+
// so a position-only instance override doesn't count (nor does it tint any
|
|
1474
|
+
// field), matching isOverridden's semantics.
|
|
1475
|
+
anyOverridden: () =>
|
|
1476
|
+
!!rawComponent && Object.keys(rawComponent).some((prop) => isInheritedProp(Behavior, prop)),
|
|
1477
|
+
baseline: baselineOf,
|
|
1478
|
+
reset: (prop) => setComponent({ [prop]: baselineOf(prop) }),
|
|
1479
|
+
};
|
|
1480
|
+
}
|
|
1481
|
+
function ActorInspector({
|
|
1482
|
+
selectedActor,
|
|
1483
|
+
rawActor,
|
|
1484
|
+
blueprintName,
|
|
1485
|
+
blueprintTemplate,
|
|
1486
|
+
sprites,
|
|
1487
|
+
showAddBehavior = true,
|
|
1488
|
+
files,
|
|
1489
|
+
onSetComponent,
|
|
1490
|
+
onAddBehavior,
|
|
1491
|
+
onRemoveBehavior,
|
|
1492
|
+
onFork,
|
|
1493
|
+
onEditBlueprint,
|
|
1494
|
+
}) {
|
|
1495
|
+
const isInstance = Boolean(rawActor?.blueprint);
|
|
1496
|
+
const presentNames = new Set(
|
|
1497
|
+
Object.entries(selectedActor.components)
|
|
1498
|
+
.filter(([, component]) => !!component)
|
|
1499
|
+
.map(([behaviorName]) => behaviorName)
|
|
1500
|
+
);
|
|
1501
|
+
const availableBehaviors = behaviorClasses.filter(
|
|
1502
|
+
(candidate) => !presentNames.has(candidate.behaviorName)
|
|
1503
|
+
);
|
|
1504
|
+
const behaviorEntries = Object.entries(selectedActor.components).filter((entry) => !!entry[1]);
|
|
1505
|
+
// For an instance, behaviors it doesn't already override are collapsed behind
|
|
1506
|
+
// a reveal button; Layout and any behavior with an instance override stay
|
|
1507
|
+
// visible below the header. Revealing is a pure view toggle -- it never
|
|
1508
|
+
// touches the actor, it just exposes the inherited behaviors' fields so they
|
|
1509
|
+
// can be overridden. Keyed by actor id at the call site, so the collapsed
|
|
1510
|
+
// state resets when a different actor is selected.
|
|
1511
|
+
const [showAllBehaviors, setShowAllBehaviors] = useState(false);
|
|
1512
|
+
const isPinnedBehavior = (behaviorName) =>
|
|
1513
|
+
behaviorName === 'Layout' || Boolean(rawActor?.components?.[behaviorName]);
|
|
1514
|
+
const collapsed = isInstance && !showAllBehaviors;
|
|
1515
|
+
const visibleEntries = collapsed
|
|
1516
|
+
? behaviorEntries.filter(([behaviorName]) => isPinnedBehavior(behaviorName))
|
|
1517
|
+
: behaviorEntries;
|
|
1518
|
+
const showReveal = collapsed && visibleEntries.length < behaviorEntries.length;
|
|
1519
|
+
return (
|
|
1520
|
+
<>
|
|
1521
|
+
{isInstance ? (
|
|
1522
|
+
<div className={styles.instanceHeader}>
|
|
1523
|
+
<div className={styles.instanceHeaderName}>
|
|
1524
|
+
<BlueprintThumbnail
|
|
1525
|
+
blueprint={blueprintTemplate ?? { components: selectedActor.components }}
|
|
1526
|
+
sprites={sprites}
|
|
1527
|
+
/>
|
|
1528
|
+
<div className={styles.instanceHeaderNameCol}>
|
|
1529
|
+
<span className={styles.instanceHeaderNameText}>
|
|
1530
|
+
<strong className={styles.instanceHeaderNameStrong}>{blueprintName}</strong> Actor
|
|
1531
|
+
</span>
|
|
1532
|
+
<span className={styles.instanceHeaderId}>id: {selectedActor.id}</span>
|
|
1533
|
+
</div>
|
|
1534
|
+
</div>
|
|
1535
|
+
{onEditBlueprint ? (
|
|
1536
|
+
<button
|
|
1537
|
+
type="button"
|
|
1538
|
+
className={styles.instanceForkButton}
|
|
1539
|
+
onClick={onEditBlueprint}
|
|
1540
|
+
title="Select this blueprint in the hotbar">
|
|
1541
|
+
<Icon name="pencil" />
|
|
1542
|
+
<span>Edit blueprint</span>
|
|
1543
|
+
</button>
|
|
1544
|
+
) : null}
|
|
1545
|
+
{onFork ? (
|
|
1546
|
+
<button
|
|
1547
|
+
type="button"
|
|
1548
|
+
className={styles.instanceForkButton}
|
|
1549
|
+
onClick={onFork}
|
|
1550
|
+
title="New blueprint from this actor">
|
|
1551
|
+
<Icon name="code-fork" />
|
|
1552
|
+
<span>Fork new blueprint</span>
|
|
1553
|
+
</button>
|
|
1554
|
+
) : null}
|
|
1555
|
+
</div>
|
|
1556
|
+
) : null}
|
|
1557
|
+
{showAddBehavior ? (
|
|
1558
|
+
<AddBehaviorPicker
|
|
1559
|
+
available={availableBehaviors}
|
|
1560
|
+
onAdd={(behaviorName) => onAddBehavior(selectedActor.id, behaviorName)}
|
|
1561
|
+
/>
|
|
1562
|
+
) : null}
|
|
1563
|
+
{visibleEntries.map(([behaviorName, component], index) => {
|
|
1564
|
+
const Behavior = behaviorClasses.find(
|
|
1565
|
+
(candidate) => candidate.behaviorName === behaviorName
|
|
1566
|
+
);
|
|
1567
|
+
const Inspector = Behavior?.Inspector;
|
|
1568
|
+
const setComponent = (nextProps) =>
|
|
1569
|
+
onSetComponent(selectedActor.id, behaviorName, nextProps);
|
|
1570
|
+
// Instance overrides get a purple tint + "Default: X [Reset]" sub-line.
|
|
1571
|
+
// Null when editing a blueprint template (nothing to override against).
|
|
1572
|
+
const override = isInstance
|
|
1573
|
+
? makeOverrideContext({
|
|
1574
|
+
Behavior,
|
|
1575
|
+
rawComponent: rawActor?.components?.[behaviorName],
|
|
1576
|
+
templateProps: blueprintTemplate?.components?.[behaviorName],
|
|
1577
|
+
setComponent,
|
|
1578
|
+
})
|
|
1579
|
+
: null;
|
|
1580
|
+
const body = Inspector ? (
|
|
1581
|
+
<Inspector
|
|
1582
|
+
actor={selectedActor}
|
|
1583
|
+
component={component}
|
|
1584
|
+
files={files}
|
|
1585
|
+
sprites={sprites}
|
|
1586
|
+
setComponent={setComponent}
|
|
1587
|
+
override={override}
|
|
1588
|
+
/>
|
|
1589
|
+
) : (
|
|
1590
|
+
<AutoInspector
|
|
1591
|
+
behaviorName={behaviorName}
|
|
1592
|
+
defaultProps={Behavior?.defaultProps ?? component}
|
|
1593
|
+
component={component}
|
|
1594
|
+
setComponent={setComponent}
|
|
1595
|
+
override={override}
|
|
1596
|
+
/>
|
|
1597
|
+
);
|
|
1598
|
+
// Layout is core to every actor -- it cannot be removed. A component
|
|
1599
|
+
// an instance only has via its blueprint (no override of its own yet)
|
|
1600
|
+
// has nothing to remove either -- v1 has no per-instance "hide this
|
|
1601
|
+
// inherited component" concept (see propertyMeta punt list).
|
|
1602
|
+
const removable =
|
|
1603
|
+
behaviorName !== 'Layout' && (!isInstance || Boolean(rawActor.components?.[behaviorName]));
|
|
1604
|
+
// When the reveal button follows, keep the last visible panel's divider
|
|
1605
|
+
// so it doesn't merge into the button row.
|
|
1606
|
+
const isLast = index === visibleEntries.length - 1 && !showReveal;
|
|
1607
|
+
// Trash renders before the panel so the panel stays the wrapper's
|
|
1608
|
+
// last child -- the panel's own `:last-child` border zeroes out, and
|
|
1609
|
+
// this wrapper owns the divider between panels deterministically.
|
|
1610
|
+
return (
|
|
1611
|
+
<div
|
|
1612
|
+
key={behaviorName}
|
|
1613
|
+
style={{
|
|
1614
|
+
position: 'relative',
|
|
1615
|
+
borderBottom: isLast ? undefined : '1px solid var(--castle-inspector-divider)',
|
|
1616
|
+
}}>
|
|
1617
|
+
{removable ? (
|
|
1618
|
+
<button
|
|
1619
|
+
type="button"
|
|
1620
|
+
aria-label={`Remove ${behaviorName}`}
|
|
1621
|
+
title={`Remove ${behaviorName}`}
|
|
1622
|
+
onClick={() => onRemoveBehavior(selectedActor.id, behaviorName)}
|
|
1623
|
+
style={{
|
|
1624
|
+
position: 'absolute',
|
|
1625
|
+
top: 14,
|
|
1626
|
+
right: 16,
|
|
1627
|
+
border: 'none',
|
|
1628
|
+
background: 'transparent',
|
|
1629
|
+
padding: 0,
|
|
1630
|
+
margin: 0,
|
|
1631
|
+
cursor: 'pointer',
|
|
1632
|
+
color: 'inherit',
|
|
1633
|
+
fontSize: 16,
|
|
1634
|
+
lineHeight: 1,
|
|
1635
|
+
}}>
|
|
1636
|
+
<Icon name="trash" />
|
|
1637
|
+
</button>
|
|
1638
|
+
) : null}
|
|
1639
|
+
{body}
|
|
1640
|
+
</div>
|
|
1641
|
+
);
|
|
1642
|
+
})}
|
|
1643
|
+
{showReveal ? (
|
|
1644
|
+
<button
|
|
1645
|
+
type="button"
|
|
1646
|
+
className={styles.revealOverridesButton}
|
|
1647
|
+
onClick={() => setShowAllBehaviors(true)}>
|
|
1648
|
+
<Icon name="chevron-down" />
|
|
1649
|
+
<span>Override behavior properties</span>
|
|
1650
|
+
</button>
|
|
1651
|
+
) : null}
|
|
1652
|
+
</>
|
|
1653
|
+
);
|
|
1654
|
+
}
|
|
1655
|
+
function AddBehaviorPicker({ available, onAdd }) {
|
|
1656
|
+
if (!available.length) return null;
|
|
1657
|
+
return (
|
|
1658
|
+
<div
|
|
1659
|
+
style={{
|
|
1660
|
+
height: 'var(--castle-hotbar-row-full)',
|
|
1661
|
+
padding: '0 16px',
|
|
1662
|
+
display: 'flex',
|
|
1663
|
+
alignItems: 'center',
|
|
1664
|
+
borderBottom: '1px solid var(--castle-inspector-divider)',
|
|
1665
|
+
}}>
|
|
1666
|
+
<select
|
|
1667
|
+
className={styles.select}
|
|
1668
|
+
value=""
|
|
1669
|
+
onChange={(event) => {
|
|
1670
|
+
if (event.target.value) onAdd(event.target.value);
|
|
1671
|
+
}}>
|
|
1672
|
+
<option value="">+ Add behavior</option>
|
|
1673
|
+
{available.map((behavior) => (
|
|
1674
|
+
<option key={behavior.behaviorName} value={behavior.behaviorName}>
|
|
1675
|
+
{behavior.behaviorName}
|
|
1676
|
+
</option>
|
|
1677
|
+
))}
|
|
1678
|
+
</select>
|
|
1679
|
+
</div>
|
|
1680
|
+
);
|
|
1681
|
+
}
|