castle-web-cli 0.4.85 → 0.4.87

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 (35) hide show
  1. package/dist/shell/assets/{index-BJLaUTJE.js → index-BFCG4tLs.js} +3 -3
  2. package/dist/shell/assets/{index-DonnH--m.css → index-DSIr52Kl.css} +1 -1
  3. package/dist/shell/index.html +2 -2
  4. package/kits/basic-2d/CLAUDE.md +3 -3
  5. package/kits/basic-2d/behaviors/Collider.jsx +172 -26
  6. package/kits/basic-2d/behaviors/Layout.jsx +24 -0
  7. package/kits/basic-2d/editors/PlayOnly.jsx +11 -9
  8. package/kits/basic-2d/editors/PxArtEditor.jsx +155 -17
  9. package/kits/basic-2d/editors/SceneEditor.jsx +103 -19
  10. package/kits/basic-2d/editors/SelectionOverlay.jsx +21 -11
  11. package/kits/basic-2d/editors/behaviorRegistry.js +9 -3
  12. package/kits/basic-2d/editors/useArtboardFit.js +4 -1
  13. package/kits/basic-2d/engine/ScenePlayer.jsx +14 -1
  14. package/kits/basic-2d/engine/behaviorExtensions.js +28 -0
  15. package/kits/basic-2d/engine/collider.js +60 -6
  16. package/kits/basic-2d/engine/scene.js +28 -2
  17. package/kits/basic-2d/engine/systemRegistry.js +12 -0
  18. package/kits/basic-2d/engine/ui.jsx +9 -0
  19. package/kits/basic-2d/main.jsx +3 -2
  20. package/kits/physics-2d/behaviors/Collider.jsx +20 -6
  21. package/kits/physics-2d/editors/PxArtEditor.jsx +155 -17
  22. package/kits/physics-2d/editors/SceneEditor.jsx +111 -45
  23. package/kits/physics-2d/editors/useArtboardFit.js +4 -1
  24. package/kits/physics-2d/engine/ScenePlayer.jsx +14 -1
  25. package/kits/physics-2d/engine/behaviorExtensions.js +28 -0
  26. package/kits/physics-2d/engine/collider.js +6 -2
  27. package/kits/physics-2d/engine/scene.js +12 -13
  28. package/kits/physics-2d/engine/systemRegistry.js +12 -0
  29. package/kits/physics-2d/engine/ui.jsx +7 -0
  30. package/kits/physics-2d/physics/behaviors/AnalogStick.jsx +4 -2
  31. package/kits/physics-2d/physics/behaviors/Slingshot.jsx +8 -2
  32. package/kits/physics-2d/physics/controls.js +14 -0
  33. package/kits/physics-2d/physics/extensions/collider.js +15 -0
  34. package/kits/physics-2d/systems/physics.js +8 -0
  35. package/package.json +1 -1
@@ -1,8 +1,8 @@
1
- import { useEffect, useRef, useState } from 'react';
1
+ import { useCallback, useEffect, useRef, useState } from 'react';
2
2
  import { frameCount, renderSpriteFrame, TRANSPARENT } from '../engine/pxart';
3
3
  import { renderSmoothSpriteFrame } from '../engine/pxartSmooth';
4
4
  import { basename } from '../engine/files';
5
- import { EditorBody, styles } from '../engine/ui';
5
+ import { EditorBody, IconButton, styles } from '../engine/ui';
6
6
  import { eventToCell } from './pixelCanvas';
7
7
  import { forEachDab } from './pixelGeometry';
8
8
  import { PixelArtboard, PixelEditorHeader, usePixelEditorShell } from './pixelEditorChrome';
@@ -74,6 +74,24 @@ import tl from './pxArtTimeline.module.css';
74
74
  const ONION_WARM = '#ff7a3c';
75
75
  const ONION_COOL = '#3ca0ff';
76
76
 
77
+ // Artboard zoom bounds and wheel sensitivity. zoom 1 = fit (the artboard fills
78
+ // its region); zooming in scrolls the surrounding viewport to pan.
79
+ const MIN_ART_ZOOM = 1;
80
+ const MAX_ART_ZOOM = 12;
81
+ const ART_ZOOM_WHEEL_SPEED = 0.01;
82
+ // Floating zoom/pan control cluster, tucked into the true bottom-right corner of
83
+ // the artboard region (over the palette column's empty lower area — the palette
84
+ // keeps its swatches top-aligned).
85
+ const PXART_ZOOM_CONTROLS_STYLE = {
86
+ position: 'absolute',
87
+ right: 8,
88
+ bottom: 8,
89
+ display: 'flex',
90
+ flexDirection: 'column',
91
+ gap: 4,
92
+ zIndex: 5,
93
+ };
94
+
77
95
  // Native CSS cursors over the artboard, keyed by the active tool. The transient
78
96
  // eyedropper (picker mode) overrides any of these with 'crosshair'. Tools not
79
97
  // listed fall back to the default cursor.
@@ -147,11 +165,109 @@ export function PxArtEditor({ path, text, onChange, ...chrome }) {
147
165
  eraseSize,
148
166
  activeKey,
149
167
  });
150
- const { canvasStyle } = useArtboardFit({
168
+ const { canvasStyle, displaySize } = useArtboardFit({
151
169
  wrapRef: canvasWrapRef,
152
170
  sizeBarRef: canvasSizeBarRef,
153
171
  resolution: sprite?.resolution ?? { width: 16, height: 16 },
154
172
  });
173
+ // Artboard zoom + pan. `zoom` scales the canvas above its fit size; the
174
+ // surrounding viewport (overflow: auto) scrolls to pan. Middle-drag pans and
175
+ // the wheel zooms at the cursor (wired below); the button cluster drives the
176
+ // same via `applyZoom`.
177
+ const viewportRef = useRef(null);
178
+ const panRef = useRef(null);
179
+ const [zoom, setZoom] = useState(1);
180
+ const zoomRef = useRef(zoom);
181
+ zoomRef.current = zoom;
182
+ // Set zoom to `nextRaw` (clamped) while pinning the point under (anchorX,
183
+ // anchorY) client coords — defaulting to the viewport center. Scroll is fixed
184
+ // up on the next frame, once the resized canvas has laid out.
185
+ const applyZoom = useCallback((nextRaw, anchorX, anchorY) => {
186
+ const vp = viewportRef.current;
187
+ const z0 = zoomRef.current;
188
+ const z1 = Math.max(MIN_ART_ZOOM, Math.min(MAX_ART_ZOOM, nextRaw));
189
+ if (!vp || z1 === z0) return;
190
+ const rect = vp.getBoundingClientRect();
191
+ const ax = (Number.isFinite(anchorX) ? anchorX : rect.left + rect.width / 2) - rect.left;
192
+ const ay = (Number.isFinite(anchorY) ? anchorY : rect.top + rect.height / 2) - rect.top;
193
+ const contentX = vp.scrollLeft + ax;
194
+ const contentY = vp.scrollTop + ay;
195
+ const ratio = z1 / z0;
196
+ setZoom(z1);
197
+ requestAnimationFrame(() => {
198
+ vp.scrollLeft = contentX * ratio - ax;
199
+ vp.scrollTop = contentY * ratio - ay;
200
+ });
201
+ }, []);
202
+ const resetZoom = useCallback(() => {
203
+ setZoom(1);
204
+ const vp = viewportRef.current;
205
+ if (vp) requestAnimationFrame(() => {
206
+ vp.scrollLeft = 0;
207
+ vp.scrollTop = 0;
208
+ });
209
+ }, []);
210
+ // Wheel zoom as a NATIVE non-passive listener so preventDefault stops the
211
+ // page/browser zoom underneath. Delta is clamped so a chunky mouse notch is a
212
+ // sane step while trackpad deltas stay smooth.
213
+ useEffect(() => {
214
+ const vp = viewportRef.current;
215
+ if (!vp) return undefined;
216
+ const onWheel = (event) => {
217
+ event.preventDefault();
218
+ const clamped = Math.max(-50, Math.min(50, event.deltaY));
219
+ applyZoom(zoomRef.current * Math.exp(-clamped * ART_ZOOM_WHEEL_SPEED), event.clientX, event.clientY);
220
+ };
221
+ vp.addEventListener('wheel', onWheel, { passive: false });
222
+ return () => vp.removeEventListener('wheel', onWheel, { passive: false });
223
+ }, [applyZoom]);
224
+ // Middle-button drag pans by scrolling the viewport. Intercept in the capture
225
+ // phase so the press never reaches the canvas's draw handler (startTool).
226
+ const onViewportPointerDownCapture = (event) => {
227
+ // Pan on a middle-button drag or a ⌘+left-drag (metaKey). Both are caught in
228
+ // the capture phase so the press never reaches the canvas's draw handler.
229
+ const startsPan = event.button === 1 || (event.button === 0 && event.metaKey);
230
+ if (!startsPan) return;
231
+ event.preventDefault();
232
+ event.stopPropagation();
233
+ const vp = viewportRef.current;
234
+ vp.setPointerCapture(event.pointerId);
235
+ panRef.current = { id: event.pointerId, x: event.clientX, y: event.clientY };
236
+ };
237
+ const onViewportPointerMove = (event) => {
238
+ const pan = panRef.current;
239
+ if (!pan || pan.id !== event.pointerId) return;
240
+ const vp = viewportRef.current;
241
+ vp.scrollLeft -= event.clientX - pan.x;
242
+ vp.scrollTop -= event.clientY - pan.y;
243
+ pan.x = event.clientX;
244
+ pan.y = event.clientY;
245
+ };
246
+ const onViewportPointerUp = (event) => {
247
+ const pan = panRef.current;
248
+ if (!pan || pan.id !== event.pointerId) return;
249
+ try {
250
+ viewportRef.current?.releasePointerCapture(event.pointerId);
251
+ } catch {
252
+ // pointer capture may already be gone
253
+ }
254
+ panRef.current = null;
255
+ };
256
+ // Viewport is sized to the fit baseline; the canvas inside scales by zoom, so
257
+ // zoom > 1 overflows and the viewport scrolls (= pan).
258
+ const artboardViewportStyle = displaySize
259
+ ? {
260
+ width: displaySize.width,
261
+ height: displaySize.height,
262
+ maxWidth: '100%',
263
+ maxHeight: '100%',
264
+ overflow: 'auto',
265
+ touchAction: 'none',
266
+ }
267
+ : { overflow: 'auto' };
268
+ const artboardCanvasStyle = displaySize
269
+ ? { width: displaySize.width * zoom, height: displaySize.height * zoom }
270
+ : canvasStyle;
155
271
  // `canvasStyle`'s width is the artboard's CURRENT display size (recomputed
156
272
  // by useArtboardFit's own ResizeObserver whenever the wrap resizes — e.g. a
157
273
  // dockview panel drag). Feeding it in here is what keeps the supersampled
@@ -437,7 +553,7 @@ export function PxArtEditor({ path, text, onChange, ...chrome }) {
437
553
  <PxArtShell path={path} subtitle={pxArtSubtitle(sprite)} shell={headerShell} chrome={chrome} editorBodyRef={editorBodyRef}>
438
554
  <div className={styles.drawingEditor}>
439
555
  <div className={tl.editorLeft}>
440
- <div className={tl.artboardRegion}>
556
+ <div className={tl.artboardRegion} style={{ position: 'relative' }}>
441
557
  <div className={styles.artboardToolColumn}>
442
558
  <PixelToolStrip
443
559
  tools={PIXEL_TOOLS}
@@ -464,19 +580,28 @@ export function PxArtEditor({ path, text, onChange, ...chrome }) {
464
580
  />
465
581
  </div>
466
582
  </div>
467
- <PixelArtboard
468
- canvasRef={canvasRef}
469
- smoothRef={smoothRef}
470
- overlayRef={overlayRef}
471
- style={{ ...canvasStyle, cursor }}
472
- handlers={{
473
- onPointerDown: startTool,
474
- onPointerMove: handlePointerMove,
475
- onPointerUp: finishTool,
476
- onPointerCancel: finishTool,
477
- onPointerLeave: clearHover,
478
- }}
479
- />
583
+ <div
584
+ ref={viewportRef}
585
+ style={artboardViewportStyle}
586
+ onPointerDownCapture={onViewportPointerDownCapture}
587
+ onPointerMove={onViewportPointerMove}
588
+ onPointerUp={onViewportPointerUp}
589
+ onPointerCancel={onViewportPointerUp}
590
+ >
591
+ <PixelArtboard
592
+ canvasRef={canvasRef}
593
+ smoothRef={smoothRef}
594
+ overlayRef={overlayRef}
595
+ style={{ ...artboardCanvasStyle, cursor }}
596
+ handlers={{
597
+ onPointerDown: startTool,
598
+ onPointerMove: handlePointerMove,
599
+ onPointerUp: finishTool,
600
+ onPointerCancel: finishTool,
601
+ onPointerLeave: clearHover,
602
+ }}
603
+ />
604
+ </div>
480
605
  </div>
481
606
  </div>
482
607
  <div className={styles.paintColumn}>
@@ -494,6 +619,19 @@ export function PxArtEditor({ path, text, onChange, ...chrome }) {
494
619
  selectActions={selectActions}
495
620
  />
496
621
  </div>
622
+ <div style={PXART_ZOOM_CONTROLS_STYLE}>
623
+ <IconButton
624
+ icon="zoom-in"
625
+ label="Zoom in"
626
+ onClick={() => applyZoom(zoomRef.current * 1.25)}
627
+ />
628
+ <IconButton
629
+ icon="zoom-out"
630
+ label="Zoom out"
631
+ onClick={() => applyZoom(zoomRef.current * 0.8)}
632
+ />
633
+ <IconButton icon="fit" label="Fit" onClick={resetZoom} />
634
+ </div>
497
635
  </div>
498
636
  <PxArtTimeline
499
637
  sprite={sprite}
@@ -58,6 +58,17 @@ const BLUEPRINT_TEMPLATE_ACTOR_ID = '__template__';
58
58
  const LONG_PRESS_MS = 500;
59
59
  const DRAG_THRESHOLD = 4;
60
60
  const DEFAULT_GRID_SIZE = 25;
61
+ // Floating zoom/pan control cluster, tucked into the bottom-right corner of the
62
+ // stage workspace (matches the art editor's placement).
63
+ const ZOOM_CONTROLS_STYLE = {
64
+ position: 'absolute',
65
+ right: 8,
66
+ bottom: 8,
67
+ display: 'flex',
68
+ flexDirection: 'column',
69
+ gap: 4,
70
+ zIndex: 5,
71
+ };
61
72
  const EDIT_VIEWPORT = {
62
73
  width: cardSize.width * 3,
63
74
  height: cardSize.height * 3,
@@ -321,7 +332,9 @@ export function SceneEditor({
321
332
  const Behavior = findBehaviorClass(behaviorName);
322
333
  if (!Behavior) return;
323
334
  updateBlueprintTemplate((components) => {
324
- components[behaviorName] = { ...Behavior.defaultProps };
335
+ // The template's own components (Layout/Sprite) are the actor context an
336
+ // initialProps hook (e.g. Collider auto-fit) reads from.
337
+ components[behaviorName] = initialBehaviorProps(Behavior, { components }, sprites);
325
338
  });
326
339
  },
327
340
  removeBehavior: (behaviorName) =>
@@ -347,6 +360,8 @@ export function SceneEditor({
347
360
  selectedActorIds,
348
361
  onSelectActorIds,
349
362
  isBlueprintFile,
363
+ sprites,
364
+ resolvedActors: previewSceneData?.actors,
350
365
  });
351
366
  const snapSettings = getSnapSettings(sceneData);
352
367
  const stageWrapStyle = {
@@ -464,35 +479,22 @@ export function SceneEditor({
464
479
  <div className={styles.sceneWorkspace}>
465
480
  <div className={styles.sceneTools}>
466
481
  {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
- </>
482
+ <BlueprintLibrary
483
+ files={files}
484
+ sprites={sprites}
485
+ canvasRef={canvasRef}
486
+ editCameraRef={editCameraRef}
487
+ isPlaying={isPlaying}
488
+ selectedBlueprintPath={selectedBlueprintPath}
489
+ instanceBlueprintPath={rawSelectedActor?.blueprint ?? null}
490
+ onSelectBlueprint={onSelectBlueprint}
491
+ onAddActor={onAddActor}
492
+ dragPlace={dragPlace}
493
+ onDeleteBlueprint={onDeleteBlueprint}
494
+ />
493
495
  )}
494
496
  </div>
495
- <div className={styles.stageWrap} style={stageWrapStyle}>
497
+ <div className={styles.stageWrap} style={{ ...stageWrapStyle, position: 'relative' }}>
496
498
  <div className={styles.stageCard} data-castle-card>
497
499
  <div className={cx(styles.stageCardClip, !isPlaying && styles.stageCardEditSurface)}>
498
500
  <canvas
@@ -507,7 +509,12 @@ export function SceneEditor({
507
509
  onPointerDown={(event) => {
508
510
  event.currentTarget.focus({ preventScroll: true });
509
511
  if (isPlaying) return playPointer.onPointerDown(event);
510
- if (panGesture.isSpacePanning()) return panGesture.onPointerDown(event);
512
+ if (
513
+ panGesture.isSpacePanning() ||
514
+ event.button === 1 ||
515
+ (event.button === 0 && event.metaKey)
516
+ )
517
+ return panGesture.onPointerDown(event);
511
518
  return gesture.onPointerDown(event);
512
519
  }}
513
520
  onPointerMove={(event) =>
@@ -551,6 +558,25 @@ export function SceneEditor({
551
558
  />
552
559
  )}
553
560
  </div>
561
+ {!isPlaying && (
562
+ <div style={ZOOM_CONTROLS_STYLE}>
563
+ <IconButton
564
+ icon="zoom-in"
565
+ label="Zoom in"
566
+ onClick={() => panGesture.zoomAtCenter(1.25)}
567
+ />
568
+ <IconButton
569
+ icon="zoom-out"
570
+ label="Zoom out"
571
+ onClick={() => panGesture.zoomAtCenter(0.8)}
572
+ />
573
+ <IconButton
574
+ icon="fit"
575
+ label="Reset view"
576
+ onClick={() => panGesture.resetView()}
577
+ />
578
+ </div>
579
+ )}
554
580
  </div>
555
581
  <aside {...inspectorSheet.rootProps}>
556
582
  <div {...inspectorSheet.grabProps}>
@@ -655,7 +681,17 @@ function HeaderPlaybackButtons({ isPlaying, setIsPlaying, history }) {
655
681
  // remove-behavior stay whole-component operations straight against the
656
682
  // instance's own sparse `components` -- there's no per-property baseline to
657
683
  // diff when a component doesn't exist on one side at all.
658
- function makeSceneActions({ sceneData, files, serialize, commit, selectedActorIds, onSelectActorIds, isBlueprintFile }) {
684
+ // Props to seed a behavior with when it's freshly added to `actor`. A behavior
685
+ // may define `static initialProps(actor, { sprites })` to derive props from the
686
+ // actor it lands on (e.g. Collider snaps to the sprite's opaque-pixel bounds);
687
+ // otherwise it starts from `defaultProps`. `actor` is the resolved actor
688
+ // (Layout/Sprite merged) or, for a blueprint template, `{ components }`.
689
+ function initialBehaviorProps(Behavior, actor, sprites) {
690
+ if (Behavior.initialProps && actor) return Behavior.initialProps(actor, { sprites });
691
+ return { ...Behavior.defaultProps };
692
+ }
693
+
694
+ function makeSceneActions({ sceneData, files, serialize, commit, selectedActorIds, onSelectActorIds, isBlueprintFile, sprites, resolvedActors }) {
659
695
  function commitScene(next, options) {
660
696
  commit(serialize(next), options);
661
697
  }
@@ -667,8 +703,9 @@ function makeSceneActions({ sceneData, files, serialize, commit, selectedActorId
667
703
  addBehavior: (actorId, behaviorName) => {
668
704
  const Behavior = findBehaviorClass(behaviorName);
669
705
  if (!Behavior) return;
706
+ const resolved = resolvedActors?.find((actor) => actor.id === actorId);
670
707
  commitScene(
671
- setActorComponent(sceneData, actorId, behaviorName, { ...Behavior.defaultProps })
708
+ setActorComponent(sceneData, actorId, behaviorName, initialBehaviorProps(Behavior, resolved, sprites))
672
709
  );
673
710
  },
674
711
  removeBehavior: (actorId, behaviorName) =>
@@ -1021,18 +1058,19 @@ function usePanGesture({ canvasRef, editCameraRef, isPlaying }) {
1021
1058
  const onWheel = (event) => {
1022
1059
  if (isPlayingRef.current) return;
1023
1060
  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
- }
1061
+ // Safari fires ctrl+wheel alongside its own gesture* pinch events; skip the
1062
+ // wheel while a pinch is mid-flight so zoom isn't applied twice.
1063
+ if (gestureActive) return;
1064
+ // The wheel zooms at the cursor (standard editor affordance). Trackpad
1065
+ // pinch and Ctrl+wheel arrive here too (Chromium sets ctrlKey) and map to
1066
+ // the same zoom. Clamp per-event delta so a chunky mouse-wheel notch
1067
+ // (deltaY ~100) is a sane step (≤1.65x) while trackpad deltas stay smooth.
1068
+ const clampedDeltaY = Math.max(-50, Math.min(50, event.deltaY));
1069
+ zoomByFactorAtPointer(
1070
+ event.clientX,
1071
+ event.clientY,
1072
+ Math.exp(-clampedDeltaY * ZOOM_WHEEL_SPEED)
1073
+ );
1036
1074
  };
1037
1075
  const onGestureStart = (event) => {
1038
1076
  if (isPlayingRef.current) return;
@@ -1073,7 +1111,13 @@ function usePanGesture({ canvasRef, editCameraRef, isPlaying }) {
1073
1111
  };
1074
1112
  }, [canvasRef, panByScreenDelta, zoomByFactorAtPointer]);
1075
1113
  const onPointerDown = useCallback((event) => {
1076
- if (isPlaying || !spaceDownRef.current) return;
1114
+ if (isPlaying) return;
1115
+ // Pan when Space is held (existing modifier), on a middle-button drag, or on
1116
+ // a ⌘+left-drag (metaKey) — matching the art editor. preventDefault also
1117
+ // suppresses the browser's middle-click autoscroll.
1118
+ const middle = event.button === 1;
1119
+ const cmdDrag = event.button === 0 && event.metaKey;
1120
+ if (!spaceDownRef.current && !middle && !cmdDrag) return;
1077
1121
  event.preventDefault();
1078
1122
  event.currentTarget.setPointerCapture(event.pointerId);
1079
1123
  dragRef.current = { id: event.pointerId, lastX: event.clientX, lastY: event.clientY };
@@ -1102,7 +1146,29 @@ function usePanGesture({ canvasRef, editCameraRef, isPlaying }) {
1102
1146
  }, []);
1103
1147
  const isSpacePanning = useCallback(() => spaceDownRef.current, []);
1104
1148
  const isActive = useCallback(() => Boolean(dragRef.current), []);
1105
- return { onPointerDown, onPointerMove, onPointerUp, isSpacePanning, isActive };
1149
+ // Button-driven zoom about the canvas center, and a reset-to-fit that restores
1150
+ // the initial camera (zoom 1 = the card exactly fills the viewport).
1151
+ const zoomAtCenter = useCallback(
1152
+ (factor) => {
1153
+ const canvas = canvasRef.current;
1154
+ if (!canvas) return;
1155
+ const rect = canvas.getBoundingClientRect();
1156
+ zoomByFactorAtPointer(rect.left + rect.width / 2, rect.top + rect.height / 2, factor);
1157
+ },
1158
+ [canvasRef, zoomByFactorAtPointer]
1159
+ );
1160
+ const resetView = useCallback(() => {
1161
+ editCameraRef.current = { x: 0, y: 0, zoom: 1 };
1162
+ }, [editCameraRef]);
1163
+ return {
1164
+ onPointerDown,
1165
+ onPointerMove,
1166
+ onPointerUp,
1167
+ isSpacePanning,
1168
+ isActive,
1169
+ zoomAtCenter,
1170
+ resetView,
1171
+ };
1106
1172
  }
1107
1173
  function handleShiftPointerDown(drag, actor, current) {
1108
1174
  if (actor) {
@@ -98,5 +98,8 @@ export function useArtboardFit({ wrapRef, sizeBarRef, resolution }) {
98
98
  ? { width: `${displaySize.width}px`, height: `${displaySize.height}px` }
99
99
  : undefined;
100
100
 
101
- return { canvasStyle, refit: fit };
101
+ // `displaySize` (numeric px, or null) is the fit baseline the zoom/pan layer
102
+ // multiplies against; `canvasStyle` is the same value pre-formatted for the
103
+ // unzoomed render path.
104
+ return { canvasStyle, displaySize, refit: fit };
102
105
  }
@@ -93,7 +93,20 @@ export function ScenePlayer({ sceneData, sprites, files, behaviorClasses, onFirs
93
93
  <canvas
94
94
  ref={canvasRef}
95
95
  tabIndex={0}
96
- style={{ width: '100%', height: '100%', display: 'block', outline: 'none' }}
96
+ style={{
97
+ width: '100%',
98
+ height: '100%',
99
+ display: 'block',
100
+ outline: 'none',
101
+ // Claim touches for the game: without this, a mobile WebView
102
+ // (castle-client) treats a drag as a scroll/pan gesture and fires
103
+ // `pointercancel` mid-drag, so a held Draggable/Slingshot loses its
104
+ // grip. Also suppress long-press text selection / callout.
105
+ touchAction: 'none',
106
+ userSelect: 'none',
107
+ WebkitUserSelect: 'none',
108
+ WebkitTouchCallout: 'none',
109
+ }}
97
110
  />
98
111
  <SceneUI getRuntime={getRuntime} />
99
112
  {error ? <PlayErrorOverlay error={error} onRestart={onRestart} /> : null}
@@ -0,0 +1,28 @@
1
+ // Behavior field extensions. A self-contained kit module (like `physics/`) can
2
+ // add fields to a behavior defined in the shared core WITHOUT editing that
3
+ // behavior's file -- so the core behavior stays byte-identical across kits and
4
+ // the module owns its own additions. Each extension module under any
5
+ // `<module>/extensions/*.js` exports:
6
+ //
7
+ // export const behaviorExtension = {
8
+ // behaviorName: 'Collider',
9
+ // defaultProps: { bounciness: 0, friction: 0.1 },
10
+ // };
11
+ //
12
+ // A behavior folds its registered extensions into its own `defaultProps` (see
13
+ // behaviors/Collider.jsx `...extensionDefaultProps('Collider')`); the inspector
14
+ // already renders leftover defaultProps generically, so registered fields show
15
+ // up with no inspector change. Empty in a kit with no `*/extensions/` dir (e.g.
16
+ // basic-2d). Symmetric with engine/systemRegistry.js and editors/behaviorRegistry.js.
17
+ const modules = import.meta.glob('../*/extensions/*.js', { eager: true });
18
+ const extensions = Object.values(modules)
19
+ .map((mod) => mod.behaviorExtension)
20
+ .filter(Boolean);
21
+
22
+ // Merged defaultProps that registered extensions contribute to `behaviorName`
23
+ // (empty object when none). Later extensions win on a key collision.
24
+ export function extensionDefaultProps(behaviorName) {
25
+ return extensions
26
+ .filter((ext) => ext.behaviorName === behaviorName)
27
+ .reduce((acc, ext) => ({ ...acc, ...ext.defaultProps }), {});
28
+ }
@@ -174,9 +174,13 @@ export function getColliderShape(actor, sprites) {
174
174
  // dynamically. Returns null when there's nothing to fit to (no Sprite, tile
175
175
  // mode, or fully transparent art). Powers the inspector's "Auto-fit to sprite".
176
176
  export function computeAutoFit(actor, sprites) {
177
- const layout = actor?.components?.Layout;
177
+ const rawLayout = actor?.components?.Layout;
178
178
  const spriteProps = actor?.components?.Sprite;
179
- if (!layout || !spriteProps) return null;
179
+ if (!rawLayout || !spriteProps) return null;
180
+ // Blueprint templates omit x/y (position is instance-local). x/y cancel out of
181
+ // the offset delta below, so default them to 0 -- otherwise an undefined x/y
182
+ // (auto-fitting a template, e.g. on add) turns the offsets into NaN.
183
+ const layout = { ...rawLayout, x: rawLayout.x ?? 0, y: rawLayout.y ?? 0 };
180
184
  const rect = autoRect(layout, spriteProps, sprites?.[spriteProps.file]);
181
185
  if (!rect || rect.width <= 0 || rect.height <= 0) return null;
182
186
  // manualRect centers a width x height box in the Layout box; the offset is the
@@ -1,7 +1,7 @@
1
1
  import { initialFiles, parseJsonFile } from './files';
2
2
  import { getBlueprintTemplate, mergeComponents } from './blueprint';
3
- import { installPhysics } from '../physics';
4
3
  import { getColliderRect, intersects, spriteIsEmpty } from './collider';
4
+ import { systemInstallers } from './systemRegistry';
5
5
 
6
6
  const CARD_WIDTH = 500;
7
7
  const CARD_HEIGHT = 700;
@@ -45,14 +45,13 @@ export class SceneRuntime {
45
45
  this.status = undefined;
46
46
  // Registered systems run once per frame after all behavior `update`s (see
47
47
  // `update`). A system is a plain object with an optional
48
- // `afterBehaviors(scene, dt)` hook; this is the seam the physics module
49
- // plugs into so it stays decoupled from the core runtime and portable to
50
- // other kits.
48
+ // `afterBehaviors(scene, dt)` hook; kits register systems from `systems/*.js`
49
+ // (see makeScene), so the engine core stays decoupled from any specific one.
51
50
  this.systems = [];
52
51
  this.load(sceneData);
53
52
  }
54
53
 
55
- // Register a per-frame system (e.g. the physics simulation). Systems step in
54
+ // Register a per-frame system (e.g. a physics simulation). Systems step in
56
55
  // registration order, after behaviors, every `update`. Returns the system.
57
56
  registerSystem(system) {
58
57
  this.systems.push(system);
@@ -102,8 +101,8 @@ export class SceneRuntime {
102
101
  }
103
102
 
104
103
  clone() {
105
- // Route through makeScene so the clone gets the same registered systems
106
- // (physics) as a freshly-made runtime.
104
+ // Route through makeScene so the clone gets the same registered systems as a
105
+ // freshly-made runtime.
107
106
  return makeScene(this.serialize(), [...this.behaviors.values()], this.sprites, this.files);
108
107
  }
109
108
 
@@ -206,9 +205,9 @@ export class SceneRuntime {
206
205
  for (const actor of this.getActors()) {
207
206
  this.forEachBehavior(actor, (instance) => instance.update?.(actor, this, dt));
208
207
  }
209
- // Behaviors have expressed their intent (velocities, forces) for this
210
- // frame; now let registered systems advance (physics integrates, writes
211
- // results back to Layout, and dispatches collision callbacks).
208
+ // Behaviors have expressed their intent (velocities, forces) for this frame;
209
+ // now let registered systems advance (e.g. physics integrates, writes results
210
+ // back to Layout, and dispatches collision callbacks).
212
211
  for (const system of this.systems) {
213
212
  system.afterBehaviors?.(this, dt);
214
213
  }
@@ -322,10 +321,10 @@ export class SceneRuntime {
322
321
 
323
322
  export function makeScene(sceneData, behaviors, sprites, files) {
324
323
  const runtime = new SceneRuntime(sceneData, behaviors, sprites, files);
325
- // Physics is a self-contained module registered as a runtime system. The
326
- // matter engine is created lazily on the first simulation step, so draw-only
324
+ // Install any runtime systems the kit provides (systems/*.js) -- no-op in a kit
325
+ // with none. A system's engine is created lazily on first use, so draw-only
327
326
  // editor previews (which never call update) stay free.
328
- installPhysics(runtime);
327
+ for (const install of systemInstallers) install(runtime);
329
328
  return runtime;
330
329
  }
331
330
 
@@ -0,0 +1,12 @@
1
+ // Discover per-frame runtime systems from `systems/*.js`. Each such module
2
+ // exports `installSystem(runtime)`, which is called once for every created
3
+ // SceneRuntime (see engine/scene.js `makeScene`) to register itself via
4
+ // `runtime.registerSystem(...)`. A system is a plain object with an optional
5
+ // `afterBehaviors(scene, dt)` hook, run once per frame after all behavior
6
+ // updates. Empty in a kit with no `systems/` dir (e.g. basic-2d); a kit adds a
7
+ // system by dropping a file here -- no edits to the engine required. Symmetric
8
+ // with editors/behaviorRegistry.js.
9
+ const modules = import.meta.glob('../systems/*.js', { eager: true });
10
+ export const systemInstallers = Object.values(modules)
11
+ .map((mod) => mod.installSystem)
12
+ .filter((fn) => typeof fn === 'function');
@@ -13,6 +13,7 @@ import {
13
13
  faCode,
14
14
  faCodeBranch,
15
15
  faEraser,
16
+ faExpand,
16
17
  faExternalLinkAlt,
17
18
  faEyeDropper,
18
19
  faFile,
@@ -27,6 +28,8 @@ import {
27
28
  faPlay,
28
29
  faPlus,
29
30
  faRedo,
31
+ faSearchMinus,
32
+ faSearchPlus,
30
33
  faShapes,
31
34
  faSlash,
32
35
  faVectorSquare,
@@ -180,6 +183,7 @@ const icons = {
180
183
  // FA5 has no faCodeFork (that's the FA6 name); faCodeBranch is the fork glyph.
181
184
  'code-fork': faCodeBranch,
182
185
  eraser: faEraser,
186
+ expand: faExpand,
183
187
  external: faExternalLinkAlt,
184
188
  eyedropper: faEyeDropper,
185
189
  fill: faFillDrip,
@@ -199,6 +203,9 @@ const icons = {
199
203
  plus: faPlus,
200
204
  redo: faRedo,
201
205
  rotate: faSyncAlt,
206
+ 'zoom-in': faSearchPlus,
207
+ 'zoom-out': faSearchMinus,
208
+ fit: faExpand,
202
209
  shapes: faShapes,
203
210
  slash: faSlash,
204
211
  square: faSquare,
@@ -1,7 +1,7 @@
1
1
  import React from 'react';
2
2
  import { Panel, SelectField } from '../../engine/ui';
3
3
  import { AutoFields, overrideProps } from '../../engine/autoInspector';
4
- import { analogVelocity, inActorWorldSpace, stickVector } from '../controls';
4
+ import { analogVelocity, inActorWorldSpace, pressOnDraggable, stickVector } from '../controls';
5
5
 
6
6
  // AnalogStick: an on-screen virtual joystick that drives the actor it's on --
7
7
  // the look and feel of castle-client's Analog Stick. Press anywhere to place
@@ -29,7 +29,9 @@ export class AnalogStick {
29
29
  const rt = actor.runtime;
30
30
  const pressed = p.down && !rt._stickWasDown;
31
31
  rt._stickWasDown = p.down;
32
- if (pressed) rt._stickOrigin = { x: p.x, y: p.y };
32
+ // A press on a draggable is consumed by the drag; every other press drives
33
+ // the stick.
34
+ if (pressed && !pressOnDraggable(scene)) rt._stickOrigin = { x: p.x, y: p.y };
33
35
 
34
36
  // Only drive velocity while the stick is actually held.
35
37
  if (p.down && rt._stickOrigin) {