castle-web-cli 0.4.71 → 0.4.73

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