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
@@ -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 = {
@@ -479,7 +494,7 @@ export function SceneEditor({
479
494
  />
480
495
  )}
481
496
  </div>
482
- <div className={styles.stageWrap} style={stageWrapStyle}>
497
+ <div className={styles.stageWrap} style={{ ...stageWrapStyle, position: 'relative' }}>
483
498
  <div className={styles.stageCard} data-castle-card>
484
499
  <div className={cx(styles.stageCardClip, !isPlaying && styles.stageCardEditSurface)}>
485
500
  <canvas
@@ -494,7 +509,12 @@ export function SceneEditor({
494
509
  onPointerDown={(event) => {
495
510
  event.currentTarget.focus({ preventScroll: true });
496
511
  if (isPlaying) return playPointer.onPointerDown(event);
497
- 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);
498
518
  return gesture.onPointerDown(event);
499
519
  }}
500
520
  onPointerMove={(event) =>
@@ -538,6 +558,25 @@ export function SceneEditor({
538
558
  />
539
559
  )}
540
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
+ )}
541
580
  </div>
542
581
  <aside {...inspectorSheet.rootProps}>
543
582
  <div {...inspectorSheet.grabProps}>
@@ -601,6 +640,10 @@ export function SceneEditor({
601
640
  }
602
641
  // Header-right playback controls, in the mockup's order: Undo, Redo, Play.
603
642
  // Play stays a play/stop toggle. Duplicate/remove live on the on-canvas toolbar.
643
+ // "Open preview" asks the shell (parent window) to open/focus a Play panel for
644
+ // THIS scene -- a separate, persistent preview alongside the editor (vs the
645
+ // in-panel play toggle). Defaults to an already-open preview for this scene,
646
+ // else opens a new one (handled shell-side, keyed by scene).
604
647
  function HeaderPlaybackButtons({ isPlaying, setIsPlaying, history }) {
605
648
  return (
606
649
  <>
@@ -638,7 +681,17 @@ function HeaderPlaybackButtons({ isPlaying, setIsPlaying, history }) {
638
681
  // remove-behavior stay whole-component operations straight against the
639
682
  // instance's own sparse `components` -- there's no per-property baseline to
640
683
  // diff when a component doesn't exist on one side at all.
641
- 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 }) {
642
695
  function commitScene(next, options) {
643
696
  commit(serialize(next), options);
644
697
  }
@@ -650,8 +703,9 @@ function makeSceneActions({ sceneData, files, serialize, commit, selectedActorId
650
703
  addBehavior: (actorId, behaviorName) => {
651
704
  const Behavior = findBehaviorClass(behaviorName);
652
705
  if (!Behavior) return;
706
+ const resolved = resolvedActors?.find((actor) => actor.id === actorId);
653
707
  commitScene(
654
- setActorComponent(sceneData, actorId, behaviorName, { ...Behavior.defaultProps })
708
+ setActorComponent(sceneData, actorId, behaviorName, initialBehaviorProps(Behavior, resolved, sprites))
655
709
  );
656
710
  },
657
711
  removeBehavior: (actorId, behaviorName) =>
@@ -1004,18 +1058,19 @@ function usePanGesture({ canvasRef, editCameraRef, isPlaying }) {
1004
1058
  const onWheel = (event) => {
1005
1059
  if (isPlayingRef.current) return;
1006
1060
  event.preventDefault();
1007
- // Trackpad pinch (and Ctrl+wheel) surface as wheel events with ctrlKey set
1008
- // in Chromium; route those to zoom and leave plain scroll as pan.
1009
- if (event.ctrlKey) {
1010
- if (gestureActive) return;
1011
- zoomByFactorAtPointer(
1012
- event.clientX,
1013
- event.clientY,
1014
- Math.exp(-event.deltaY * ZOOM_WHEEL_SPEED)
1015
- );
1016
- } else {
1017
- panByScreenDelta(event.deltaX, event.deltaY);
1018
- }
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
+ );
1019
1074
  };
1020
1075
  const onGestureStart = (event) => {
1021
1076
  if (isPlayingRef.current) return;
@@ -1056,7 +1111,13 @@ function usePanGesture({ canvasRef, editCameraRef, isPlaying }) {
1056
1111
  };
1057
1112
  }, [canvasRef, panByScreenDelta, zoomByFactorAtPointer]);
1058
1113
  const onPointerDown = useCallback((event) => {
1059
- 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;
1060
1121
  event.preventDefault();
1061
1122
  event.currentTarget.setPointerCapture(event.pointerId);
1062
1123
  dragRef.current = { id: event.pointerId, lastX: event.clientX, lastY: event.clientY };
@@ -1085,7 +1146,29 @@ function usePanGesture({ canvasRef, editCameraRef, isPlaying }) {
1085
1146
  }, []);
1086
1147
  const isSpacePanning = useCallback(() => spaceDownRef.current, []);
1087
1148
  const isActive = useCallback(() => Boolean(dragRef.current), []);
1088
- 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
+ };
1089
1172
  }
1090
1173
  function handleShiftPointerDown(drag, actor, current) {
1091
1174
  if (actor) {
@@ -1565,6 +1648,7 @@ function ActorInspector({
1565
1648
  actor={selectedActor}
1566
1649
  component={component}
1567
1650
  files={files}
1651
+ sprites={sprites}
1568
1652
  setComponent={setComponent}
1569
1653
  override={override}
1570
1654
  />
@@ -1,5 +1,5 @@
1
1
  import React, { useCallback, useEffect, useRef, useState } from 'react';
2
- import { getColliderRect } from '../behaviors/Collider';
2
+ import { getColliderShape } from '../behaviors/Collider';
3
3
  import { cardSize, screenToCard } from '../engine/scene';
4
4
  import { cx, Icon, styles, useElementSize } from '../engine/ui';
5
5
 
@@ -216,13 +216,14 @@ export function SelectionOverlay({
216
216
  key={collider.actorId}
217
217
  className={cx(
218
218
  styles.selColliderBox,
219
- collider.kind === 'pickup' && styles.selColliderPickup
219
+ collider.isTrigger && styles.selColliderPickup
220
220
  )}
221
221
  style={{
222
222
  left: collider.x,
223
223
  top: collider.y,
224
224
  width: collider.width,
225
225
  height: collider.height,
226
+ borderRadius: collider.shape === 'circle' ? '50%' : undefined,
226
227
  transformOrigin: `${collider.originX}px ${collider.originY}px`,
227
228
  transform: `rotate(${collider.rotation}deg)`,
228
229
  }}
@@ -463,18 +464,27 @@ function getSelectedColliderFrames(sceneData, actorIds, sprites) {
463
464
  .map((actor) => {
464
465
  const layout = actor.components.Layout;
465
466
  const collider = actor.components.Collider;
466
- const rect = getColliderRect(actor, sprites);
467
- if (!layout || !collider || !rect) return null;
467
+ const geom = getColliderShape(actor, sprites);
468
+ if (!layout || !collider || !geom) return null;
469
+ // For a circle we render a radius-sized square with border-radius; for a
470
+ // box, the rect itself. Both come from the shared geometry so the preview
471
+ // matches the physics body exactly (shape, radius, offset, auto/manual).
472
+ const isCircle = geom.shape === 'circle';
473
+ const x = isCircle ? geom.cx - geom.radius : geom.x;
474
+ const y = isCircle ? geom.cy - geom.radius : geom.y;
475
+ const width = isCircle ? geom.radius * 2 : geom.width;
476
+ const height = isCircle ? geom.radius * 2 : geom.height;
468
477
  return {
469
478
  actorId: actor.id,
470
- kind: collider.kind,
471
- x: rect.x,
472
- y: rect.y,
473
- width: rect.width,
474
- height: rect.height,
479
+ shape: geom.shape,
480
+ isTrigger: Boolean(collider.isTrigger) || collider.kind === 'pickup',
481
+ x,
482
+ y,
483
+ width,
484
+ height,
475
485
  rotation: layout.rotation ?? 0,
476
- originX: layout.x + layout.width / 2 - rect.x,
477
- originY: layout.y + layout.height / 2 - rect.y,
486
+ originX: layout.x + layout.width / 2 - x,
487
+ originY: layout.y + layout.height / 2 - y,
478
488
  };
479
489
  })
480
490
  .filter(Boolean);
@@ -1,6 +1,12 @@
1
- // Discover every behavior class from `behaviors/*.tsx`. Vite HMR is off, so a
2
- // newly-added behavior file is picked up on the next reload/restart.
3
- const modules = import.meta.glob('../behaviors/*.jsx', { eager: true });
1
+ // Discover every behavior class from `behaviors/*.jsx` and the physics module's
2
+ // `physics/behaviors/*.jsx`. Vite HMR is off, so a newly-added behavior file is
3
+ // picked up on the next reload/restart. The physics glob is what lets the
4
+ // self-contained physics module ship its behaviors without polluting the core
5
+ // `behaviors/` folder — a kit adopts physics by copying `physics/` in.
6
+ const modules = {
7
+ ...import.meta.glob('../behaviors/*.jsx', { eager: true }),
8
+ ...import.meta.glob('../physics/behaviors/*.jsx', { eager: true }),
9
+ };
4
10
  function isBehaviorClass(value) {
5
11
  return typeof value === 'function' && typeof value.behaviorName === 'string';
6
12
  }
@@ -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
+ }
@@ -113,13 +113,16 @@ function manualRect(layout, collider) {
113
113
 
114
114
  const fullLayoutRect = (layout) => ({ x: layout.x, y: layout.y, width: layout.width, height: layout.height });
115
115
 
116
- // The un-offset rect for whichever mode is active: `manualRect` for
117
- // `mode: 'manual'`, otherwise the auto sprite-bbox rect (or its Layout-box
118
- // fallback).
116
+ // The un-offset rect. Colliders are sized by their explicit `width`/`height`
117
+ // (the "auto vs manual" mode is gone -- use the inspector's "Auto-fit to sprite"
118
+ // action to snap those to the sprite). Legacy decks that still carry
119
+ // `mode: 'auto'` keep their dynamic sprite fit for back-compat.
119
120
  function modeRect(layout, collider, spriteProps, sprites) {
120
- if ((collider.mode ?? 'auto') === 'manual') return manualRect(layout, collider);
121
- const sprite = spriteProps ? sprites?.[spriteProps.file] : null;
122
- return autoRect(layout, spriteProps, sprite) ?? fullLayoutRect(layout);
121
+ if (collider.mode === 'auto') {
122
+ const sprite = spriteProps ? sprites?.[spriteProps.file] : null;
123
+ return autoRect(layout, spriteProps, sprite) ?? fullLayoutRect(layout);
124
+ }
125
+ return manualRect(layout, collider);
123
126
  }
124
127
 
125
128
  // The Collider rect for an actor, from its Layout + Collider (+ Sprite, for
@@ -141,6 +144,57 @@ export function getColliderRect(actor, sprites) {
141
144
  };
142
145
  }
143
146
 
147
+ // Full collider geometry: the AABB rect (`x/y/width/height`, offset-applied)
148
+ // PLUS the shape and, for circles, the center + radius. This is the SINGLE
149
+ // SOURCE OF TRUTH for collider shape -- the physics body (matterBridge), the
150
+ // play-mode debug draw (Collider.draw), and the editor selection overlay all
151
+ // derive their geometry from here, so the visual preview always matches the
152
+ // simulated collider. `radius` of 0 means "derive from the rect" (min side / 2),
153
+ // so a circle shows a real size even before you set an explicit radius.
154
+ export function getColliderShape(actor, sprites) {
155
+ const rect = getColliderRect(actor, sprites);
156
+ if (!rect) return null;
157
+ const collider = actor.components.Collider;
158
+ const shape = collider.shape === 'circle' ? 'circle' : 'box';
159
+ const radius = collider.radius > 0 ? collider.radius : Math.min(rect.width, rect.height) / 2;
160
+ return {
161
+ shape,
162
+ x: rect.x,
163
+ y: rect.y,
164
+ width: rect.width,
165
+ height: rect.height,
166
+ cx: rect.x + rect.width / 2,
167
+ cy: rect.y + rect.height / 2,
168
+ radius,
169
+ };
170
+ }
171
+
172
+ // The explicit `width`/`height`/`offsetX`/`offsetY` a collider would need to
173
+ // exactly match its sprite's opaque-pixel fit -- what "auto" used to compute
174
+ // dynamically. Returns null when there's nothing to fit to (no Sprite, tile
175
+ // mode, or fully transparent art). Powers the inspector's "Auto-fit to sprite".
176
+ export function computeAutoFit(actor, sprites) {
177
+ const rawLayout = actor?.components?.Layout;
178
+ const spriteProps = actor?.components?.Sprite;
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 };
184
+ const rect = autoRect(layout, spriteProps, sprites?.[spriteProps.file]);
185
+ if (!rect || rect.width <= 0 || rect.height <= 0) return null;
186
+ // manualRect centers a width x height box in the Layout box; the offset is the
187
+ // delta from that centered position to the sprite-fit rect.
188
+ const centeredX = layout.x + (layout.width - rect.width) / 2;
189
+ const centeredY = layout.y + (layout.height - rect.height) / 2;
190
+ return {
191
+ width: Math.round(rect.width),
192
+ height: Math.round(rect.height),
193
+ offsetX: Math.round(rect.x - centeredX),
194
+ offsetY: Math.round(rect.y - centeredY),
195
+ };
196
+ }
197
+
144
198
  export function intersects(a, b) {
145
199
  return a.x < b.x + b.width && a.x + a.width > b.x && a.y < b.y + b.height && a.y + a.height > b.y;
146
200
  }
@@ -1,6 +1,7 @@
1
1
  import { initialFiles, parseJsonFile } from './files';
2
2
  import { getBlueprintTemplate, mergeComponents } from './blueprint';
3
3
  import { getColliderRect, intersects, spriteIsEmpty } from './collider';
4
+ import { systemInstallers } from './systemRegistry';
4
5
 
5
6
  const CARD_WIDTH = 500;
6
7
  const CARD_HEIGHT = 700;
@@ -42,9 +43,21 @@ export class SceneRuntime {
42
43
  this.actors = new Map();
43
44
  this.camera = undefined;
44
45
  this.status = undefined;
46
+ // Registered systems run once per frame after all behavior `update`s (see
47
+ // `update`). A system is a plain object with an optional
48
+ // `afterBehaviors(scene, dt)` hook; kits register systems from `systems/*.js`
49
+ // (see makeScene), so the engine core stays decoupled from any specific one.
50
+ this.systems = [];
45
51
  this.load(sceneData);
46
52
  }
47
53
 
54
+ // Register a per-frame system (e.g. a physics simulation). Systems step in
55
+ // registration order, after behaviors, every `update`. Returns the system.
56
+ registerSystem(system) {
57
+ this.systems.push(system);
58
+ return system;
59
+ }
60
+
48
61
  load(sceneData) {
49
62
  this.data = structuredClone(sceneData);
50
63
  this.actors = new Map();
@@ -88,7 +101,9 @@ export class SceneRuntime {
88
101
  }
89
102
 
90
103
  clone() {
91
- return new SceneRuntime(this.serialize(), [...this.behaviors.values()], this.sprites, this.files);
104
+ // Route through makeScene so the clone gets the same registered systems as a
105
+ // freshly-made runtime.
106
+ return makeScene(this.serialize(), [...this.behaviors.values()], this.sprites, this.files);
92
107
  }
93
108
 
94
109
  serialize() {
@@ -190,6 +205,12 @@ export class SceneRuntime {
190
205
  for (const actor of this.getActors()) {
191
206
  this.forEachBehavior(actor, (instance) => instance.update?.(actor, this, dt));
192
207
  }
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).
211
+ for (const system of this.systems) {
212
+ system.afterBehaviors?.(this, dt);
213
+ }
193
214
  }
194
215
 
195
216
  forEachBehavior(actor, callback) {
@@ -299,7 +320,12 @@ export class SceneRuntime {
299
320
  }
300
321
 
301
322
  export function makeScene(sceneData, behaviors, sprites, files) {
302
- return new SceneRuntime(sceneData, behaviors, sprites, files);
323
+ const runtime = new SceneRuntime(sceneData, behaviors, sprites, files);
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
326
+ // editor previews (which never call update) stay free.
327
+ for (const install of systemInstallers) install(runtime);
328
+ return runtime;
303
329
  }
304
330
 
305
331
  export function setActorComponent(sceneData, actorId, behaviorName, nextProps) {
@@ -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,8 @@ import {
13
13
  faCode,
14
14
  faCodeBranch,
15
15
  faEraser,
16
+ faExpand,
17
+ faExternalLinkAlt,
16
18
  faEyeDropper,
17
19
  faFile,
18
20
  faFilm,
@@ -26,6 +28,8 @@ import {
26
28
  faPlay,
27
29
  faPlus,
28
30
  faRedo,
31
+ faSearchMinus,
32
+ faSearchPlus,
29
33
  faShapes,
30
34
  faSlash,
31
35
  faVectorSquare,
@@ -179,6 +183,8 @@ const icons = {
179
183
  // FA5 has no faCodeFork (that's the FA6 name); faCodeBranch is the fork glyph.
180
184
  'code-fork': faCodeBranch,
181
185
  eraser: faEraser,
186
+ expand: faExpand,
187
+ external: faExternalLinkAlt,
182
188
  eyedropper: faEyeDropper,
183
189
  fill: faFillDrip,
184
190
  'flip-h': faArrowsAltH,
@@ -197,6 +203,9 @@ const icons = {
197
203
  plus: faPlus,
198
204
  redo: faRedo,
199
205
  rotate: faSyncAlt,
206
+ 'zoom-in': faSearchPlus,
207
+ 'zoom-out': faSearchMinus,
208
+ fit: faExpand,
200
209
  shapes: faShapes,
201
210
  slash: faSlash,
202
211
  square: faSquare,
@@ -15,9 +15,10 @@ if (!root) throw new Error('Missing root element');
15
15
  // ?file=<path>[&editor=<id>] -> a single rich editor for that file
16
16
  const params = new URLSearchParams(window.location.search);
17
17
  function pick() {
18
- if (!isEdit()) return <PlayOnly />;
18
+ const initialScene = params.get('scene') ?? undefined;
19
+ if (!isEdit()) return <PlayOnly initialScene={initialScene} />;
19
20
  const file = params.get('file');
20
21
  if (file) return <SingleEditor path={file} editor={params.get('editor') ?? undefined} />;
21
- return <PlayOnly />;
22
+ return <PlayOnly initialScene={initialScene} />;
22
23
  }
23
24
  createRoot(root).render(<ErrorBoundary>{pick()}</ErrorBoundary>);
@@ -2,6 +2,7 @@ import React, { useState } from 'react';
2
2
  import { Icon, NumberField, Panel, SelectField } from '../engine/ui';
3
3
  import { AutoFields, overrideProps } from '../engine/autoInspector';
4
4
  import { computeAutoFit, getColliderRect, getColliderShape, intersects } from '../engine/collider';
5
+ import { extensionDefaultProps } from '../engine/behaviorExtensions';
5
6
 
6
7
  // Collapsible "Dimensions" section. The header row's label lines up exactly with
7
8
  // the field labels above/below (both start at the panel body's 16px left pad);
@@ -62,20 +63,33 @@ export class Collider {
62
63
  radius: 0,
63
64
  offsetX: 0,
64
65
  offsetY: 0,
65
- // Physics material (used by the physics simulation; ignored by plain
66
- // scene.overlaps checks). `isTrigger` = a sensor: detects overlaps but
67
- // doesn't block. `bounciness` = restitution (0..1+). `friction` = surface
68
- // friction.
66
+ // `isTrigger` marks a sensor: it still reports overlaps (draws yellow, reads
67
+ // as a pass-through zone for pickup/goal logic) but does not block on its
68
+ // own -- collisions are data you act on. Kit modules can register MORE
69
+ // Collider fields from outside this file (the physics module adds
70
+ // bounciness/friction material); see engine/behaviorExtensions.js. That's
71
+ // why this file is byte-identical across kits -- the physics-only fields
72
+ // live in physics/extensions/, not here.
69
73
  isTrigger: false,
70
- bounciness: 0,
71
- friction: 0.1,
72
74
  debug: false,
75
+ ...extensionDefaultProps('Collider'),
73
76
  };
74
77
 
75
78
  constructor(props) {
76
79
  this.props = props;
77
80
  }
78
81
 
82
+ // Seed props when the collider is first added to an actor: snap it to the
83
+ // sprite's opaque-pixel bounds (what "Auto-fit to sprite" computes) so a new
84
+ // collider frames the art rather than the whole Layout box. Falls back to the
85
+ // unset (Layout-box) size when there's nothing to fit to -- no sprite, tile
86
+ // mode, or transparent art. The editor's addBehavior calls this (see
87
+ // editors/SceneEditor.jsx initialBehaviorProps).
88
+ static initialProps(actor, ctx) {
89
+ const fit = computeAutoFit(actor, ctx?.sprites);
90
+ return fit ? { ...Collider.defaultProps, ...fit } : { ...Collider.defaultProps };
91
+ }
92
+
79
93
  draw(actor, scene, ctx, options) {
80
94
  if (!options.showDebugColliders && !this.props.debug) return;
81
95
  const geom = getColliderShape(actor, scene.sprites);