castle-web-cli 0.4.85 → 0.4.86

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.
@@ -99,7 +99,7 @@ A **blueprint** is a `.scene` file under `blueprints/` whose single actor (`acto
99
99
  "components": {
100
100
  "Layout": { "width": 50, "height": 25 },
101
101
  "Sprite": { "file": "drawings/brick.pxart" },
102
- "Collider": { "kind": "solid" },
102
+ "Collider": {},
103
103
  "Brick": {}
104
104
  }
105
105
  }
@@ -120,7 +120,7 @@ Template `components` keys are behavior names (the `static behaviorName`); add a
120
120
  - **Sprite** — `{ file: "drawings/foo.pxart", tint?: "#rrggbbaa", playing?: true, tag?: "", mode?: 'cover'|'fit'|'stretch'|'tile', tileSize?: 50 }`. Renders a `.pxart` pixel-art sprite into the Layout box: scaled up preserving the art's aspect ratio to fill the whole box, cropping whatever overflows a mismatched box (`mode: 'cover'`, the default -- CSS object-fit: cover), scaled preserving the art's aspect ratio and centered so the whole sprite stays visible, letterboxing a mismatched box instead of cropping (`mode: 'fit'` -- CSS object-fit: contain), scaled to fill the box exactly and distorting the art when the aspect ratios don't match (`mode: 'stretch'`), or repeated (`mode: 'tile'`). `tint` multiplies; use white (`#ffffffff`) or omit for the original colors. Animated sprites play automatically; set `playing: false` to hold the first frame, or `tag` to play a named animation tag. Tile mode repeats the art at a fixed cell size (`tileSize` card units tall, width scaled by the art's aspect) instead of stretching it across the Layout box.
121
121
  - **Missing sprites fall back to a placeholder.** If `file` names a sprite that doesn't exist yet, it renders the fallback `drawings/cauldron.pxart` — so you can give an actor its REAL intended sprite name (`drawings/paddle.pxart`) right away and it shows the placeholder until that file is created. Reference real names from the start; don't wait for the art.
122
122
  - **Fill the box, don't distort.** The default `mode: 'cover'` fills the Layout box with the art undistorted, cropping whatever overflows when the box aspect doesn't match the art's — so treat the Layout box like an image frame in a design tool: size it to frame what matters and keep the sprite's important content toward the center, since the edges may be cropped. Keeping an actor's Layout aspect ratio close to its sprite's native aspect ratio minimizes how much gets cropped. When the whole sprite must stay visible (nothing is safe to crop), use `mode: 'fit'`, which preserves the art and letterboxes the mismatched box instead. Avoid `mode: 'stretch'` for pixel art — filling a long/tall box by distorting wrecks the pixels (a brick sprite stretched into a wall ruins the bricks). For long surfaces (walls, floors, platforms), use `mode: 'tile'` on one actor to repeat the art at a fixed cell size instead of stretching or cropping a single sprite.
123
- - **Collider** — `{ kind: 'solid'|'pickup', mode?: 'auto'|'manual', width, height, offsetX?, offsetY?, debug? }`. Just a rect; `offsetX`/`offsetY` nudge the collider from its default position (so `0,0` is the default). `mode: 'auto'` (the default) fits the rect to the actor's Sprite's opaque pixels, mapped through the Sprite's draw mode (`fit`'s letterboxed dest rect, `stretch`'s full Layout box, `cover`'s filled box clipped to the Layout box; `tile` mode and a missing/unresolved Sprite use the whole Layout box instead). `width`/`height` only apply in `mode: 'manual'` (a rect of that size, centered in the Layout box); they're ignored in `auto`. The framework does NOT auto-resolve collisions for you. Use it as data:
123
+ - **Collider** — `{ shape?: 'box'|'circle', width, height, radius, offsetX?, offsetY?, isTrigger?, debug? }`. A box (default) or circle. **Size tracks the Layout box** unless you set an explicit `width`/`height` (box) or `radius` (circle); `offsetX`/`offsetY` nudge it from center (so `0,0` is centered). In the editor, the Collider panel's **"Auto-fit to sprite"** action snaps the dimensions/offset to the sprite's opaque-pixel bounds (only shown when it isn't already fitted) a one-time computed value, not a live mode. `isTrigger: true` marks a **sensor** (draws yellow, reads as a pass-through zone for pickup/goal logic); a plain collider reads as a solid wall. Either way the framework does NOT auto-resolve collisions the collider is data you act on. (Legacy decks carrying `mode: 'auto'` keep their old live sprite-fit for back-compat.) Use it:
124
124
 
125
125
  ```jsx
126
126
  for (const other of scene.getActors()) {
@@ -189,7 +189,7 @@ For HUD text use a behavior's `ui` hook (returns React); for in-world text or sh
189
189
 
190
190
  - `Paddle` behavior: read keys, clamp x to `[0, 500 - layout.width]`.
191
191
  - `Ball` behavior: store `vx, vy` on `actor.runtime`; integrate; bounce off wall edges (`x<0`, `x+w>500`, `y<0`); on `scene.overlaps(actor, paddle)`, flip `vy`; for each `brick` in `scene.actorsWith('Brick')` check `scene.overlaps(actor, brick)` → flip `vy` and `scene.despawnActor(brick.id)`; if `y > 700` lose a life.
192
- - `Brick` behavior: typically just a marker — `kind: 'solid'` collider is enough. State (hit count) goes on `actor.runtime` or the brick's own props.
192
+ - `Brick` behavior: typically just a marker — a plain `Collider` is enough. State (hit count) goes on `actor.runtime` or the brick's own props.
193
193
  - `GameController` (no Layout needed if you don't draw it): tracks score / lives / status; expose HUD via `ui()`.
194
194
 
195
195
  ## Don't
@@ -1,58 +1,204 @@
1
- import React from 'react';
2
- import { Panel, SelectField } from '../engine/ui';
1
+ import React, { useState } from 'react';
2
+ import { Icon, NumberField, Panel, SelectField } from '../engine/ui';
3
3
  import { AutoFields, overrideProps } from '../engine/autoInspector';
4
- import { getColliderRect, intersects } from '../engine/collider';
4
+ import { computeAutoFit, getColliderRect, getColliderShape, intersects } from '../engine/collider';
5
+ import { extensionDefaultProps } from '../engine/behaviorExtensions';
6
+
7
+ // Collapsible "Dimensions" section. The header row's label lines up exactly with
8
+ // the field labels above/below (both start at the panel body's 16px left pad);
9
+ // the open/closed caret floats in the left gutter without shifting the label.
10
+ // `margin-bottom: 12px` matches a field row's `padding-bottom`, so the gap to
11
+ // the next entry is the same whether the section is open or closed.
12
+ const dimHeaderRowStyle = {
13
+ position: 'relative',
14
+ display: 'flex',
15
+ alignItems: 'center',
16
+ gap: 10,
17
+ minHeight: 28,
18
+ margin: '0 0 12px',
19
+ };
20
+ const dimToggleStyle = {
21
+ display: 'inline-flex',
22
+ alignItems: 'center',
23
+ background: 'none',
24
+ border: 'none',
25
+ padding: 0,
26
+ margin: 0,
27
+ color: 'var(--castle-inspector-text)',
28
+ fontFamily: 'inherit',
29
+ fontSize: 14,
30
+ cursor: 'pointer',
31
+ };
32
+ // The caret sits in the panel's left gutter (label column starts at x=16).
33
+ const dimCaretStyle = {
34
+ position: 'absolute',
35
+ left: -14,
36
+ top: '50%',
37
+ transform: 'translateY(-50%)',
38
+ display: 'inline-flex',
39
+ alignItems: 'center',
40
+ fontSize: 11,
41
+ opacity: 0.65,
42
+ };
43
+ const autoFitLinkStyle = {
44
+ background: 'none',
45
+ border: 'none',
46
+ padding: 0,
47
+ color: '#4aa3ff',
48
+ fontFamily: 'inherit',
49
+ fontSize: 13,
50
+ cursor: 'pointer',
51
+ };
52
+ const dimBodyStyle = { paddingLeft: 14 };
5
53
 
6
54
  export class Collider {
7
55
  static behaviorName = 'Collider';
8
56
 
9
57
  static defaultProps = {
10
- kind: 'solid',
11
- mode: 'auto',
12
- width: 50,
13
- height: 50,
58
+ shape: 'box',
59
+ // width/height/radius are intentionally NOT defaulted: an unset size means
60
+ // "match the actor's Layout box" (see engine/collider.js manualRect and the
61
+ // effective values in the inspector). Defaulting them to a fixed number made
62
+ // every collider shrink to that number instead of tracking the actor.
63
+ radius: 0,
14
64
  offsetX: 0,
15
65
  offsetY: 0,
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.
73
+ isTrigger: false,
16
74
  debug: false,
75
+ ...extensionDefaultProps('Collider'),
17
76
  };
18
77
 
19
78
  constructor(props) {
20
79
  this.props = props;
21
80
  }
22
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
+
23
93
  draw(actor, scene, ctx, options) {
24
94
  if (!options.showDebugColliders && !this.props.debug) return;
25
- const rect = getColliderRect(actor, scene.sprites);
26
- if (!rect) return;
95
+ const geom = getColliderShape(actor, scene.sprites);
96
+ if (!geom) return;
97
+ // A sensor (isTrigger) reads as a pass-through zone; a solid as a wall.
98
+ const isSensor = Boolean(this.props.isTrigger) || this.props.kind === 'pickup';
27
99
  ctx.save();
28
- ctx.strokeStyle = this.props.kind === 'pickup' ? '#ffe17a' : '#8db7ff';
100
+ ctx.strokeStyle = isSensor ? '#ffe17a' : '#8db7ff';
29
101
  ctx.lineWidth = 2;
30
- ctx.strokeRect(rect.x + 1, rect.y + 1, rect.width - 2, rect.height - 2);
102
+ if (geom.shape === 'circle') {
103
+ ctx.beginPath();
104
+ ctx.arc(geom.cx, geom.cy, geom.radius, 0, Math.PI * 2);
105
+ ctx.stroke();
106
+ } else {
107
+ ctx.strokeRect(geom.x + 1, geom.y + 1, geom.width - 2, geom.height - 2);
108
+ }
31
109
  ctx.restore();
32
110
  }
33
111
 
34
- static Inspector({ component, setComponent, override }) {
112
+ static Inspector({ actor, component, sprites, setComponent, override }) {
113
+ const [dimOpen, setDimOpen] = useState(true);
114
+ const layout = actor?.components?.Layout ?? {};
115
+ const shape = component.shape ?? Collider.defaultProps.shape;
116
+ // Unset width/height/radius track the actor's Layout box; surface that
117
+ // effective value so a field never reads blank while the collider is sized.
118
+ const width = component.width ?? layout.width ?? 50;
119
+ const height = component.height ?? layout.height ?? 50;
120
+ const effectiveRadius = component.radius > 0 ? component.radius : Math.min(width, height) / 2;
121
+
122
+ // "Auto-fit to sprite": the explicit dims/offset that match the sprite's
123
+ // opaque-pixel bounds. Only offered when the collider isn't already fitted.
124
+ const autoFit = computeAutoFit(actor, sprites);
125
+ let autoFitPatch = null;
126
+ if (autoFit) {
127
+ autoFitPatch =
128
+ shape === 'circle'
129
+ ? { radius: Math.round(Math.min(autoFit.width, autoFit.height) / 2), offsetX: autoFit.offsetX, offsetY: autoFit.offsetY }
130
+ : { width: autoFit.width, height: autoFit.height, offsetX: autoFit.offsetX, offsetY: autoFit.offsetY };
131
+ }
132
+ const currentValue = (key) => {
133
+ if (key === 'radius') return effectiveRadius;
134
+ if (key === 'width') return width;
135
+ if (key === 'height') return height;
136
+ return component[key] ?? 0;
137
+ };
138
+ const alreadyFitted =
139
+ autoFitPatch && Object.entries(autoFitPatch).every(([key, value]) => Math.abs(currentValue(key) - value) < 0.6);
140
+
35
141
  return (
36
142
  <Panel title="Collider" overridden={override?.anyOverridden()}>
37
143
  <SelectField
38
- label="Kind"
39
- value={component.kind}
40
- onChange={(kind) => setComponent({ kind: kind })}
41
- options={['solid', 'pickup']}
42
- {...overrideProps(override, 'kind')}
43
- />
44
- <SelectField
45
- label="Mode"
46
- value={component.mode}
47
- onChange={(mode) => setComponent({ mode: mode })}
48
- options={['auto', 'manual']}
49
- {...overrideProps(override, 'mode')}
144
+ label="Shape"
145
+ value={component.shape}
146
+ onChange={(value) => setComponent({ shape: value })}
147
+ options={['box', 'circle']}
148
+ {...overrideProps(override, 'shape')}
50
149
  />
150
+ <div style={dimHeaderRowStyle}>
151
+ <span style={dimCaretStyle}>
152
+ <Icon name={dimOpen ? 'chevron-down' : 'chevron-right'} />
153
+ </span>
154
+ <button type="button" onClick={() => setDimOpen((open) => !open)} style={dimToggleStyle}>
155
+ Dimensions
156
+ </button>
157
+ {autoFitPatch && !alreadyFitted ? (
158
+ <button type="button" onClick={() => setComponent(autoFitPatch)} style={autoFitLinkStyle}>
159
+ Auto-fit to sprite
160
+ </button>
161
+ ) : null}
162
+ </div>
163
+ {dimOpen ? (
164
+ <div style={dimBodyStyle}>
165
+ {shape === 'circle' ? (
166
+ <NumberField
167
+ label="Radius"
168
+ value={effectiveRadius}
169
+ onChange={(value) => setComponent({ radius: value })}
170
+ {...overrideProps(override, 'radius')}
171
+ />
172
+ ) : (
173
+ <>
174
+ <NumberField
175
+ label="Width"
176
+ value={width}
177
+ onChange={(value) => setComponent({ width: value })}
178
+ {...overrideProps(override, 'width')}
179
+ />
180
+ <NumberField
181
+ label="Height"
182
+ value={height}
183
+ onChange={(value) => setComponent({ height: value })}
184
+ {...overrideProps(override, 'height')}
185
+ />
186
+ </>
187
+ )}
188
+ <AutoFields
189
+ defaultProps={Collider.defaultProps}
190
+ component={component}
191
+ setComponent={setComponent}
192
+ only={['offsetX', 'offsetY']}
193
+ override={override}
194
+ />
195
+ </div>
196
+ ) : null}
51
197
  <AutoFields
52
198
  defaultProps={Collider.defaultProps}
53
199
  component={component}
54
200
  setComponent={setComponent}
55
- exclude={['kind', 'mode']}
201
+ exclude={['shape', 'width', 'height', 'radius', 'offsetX', 'offsetY']}
56
202
  override={override}
57
203
  />
58
204
  </Panel>
@@ -64,4 +210,4 @@ export class Collider {
64
210
  // scene (e.g. editors/SelectionOverlay.jsx, which passes the merged preview
65
211
  // actors plus the sprites map). See engine/collider.js for the single
66
212
  // implementation.
67
- export { getColliderRect, intersects };
213
+ export { getColliderRect, getColliderShape, intersects };
@@ -8,6 +8,9 @@ export class Layout {
8
8
  rotation: 0,
9
9
  width: 50,
10
10
  height: 50,
11
+ // Show the Layout box (the invisible frame the sprite fills and the collider
12
+ // derives from) as a dashed outline, in editor and play. Per-actor toggle.
13
+ debug: false,
11
14
  };
12
15
 
13
16
  // Position/rotation/z never inherit from a blueprint: they're always written
@@ -16,6 +19,13 @@ export class Layout {
16
19
  // affects newly placed instances, never moves ones already placed. Because
17
20
  // they're always instance-local, they're also never surfaced as blueprint
18
21
  // "overrides" in the inspector. width/height default to inherited (absent).
22
+ //
23
+ // Size is width/height in card units -- the same space you compose in. A
24
+ // user-facing scaleX/scaleY (relative to the sprite's native pixels) was
25
+ // tried and reverted: it only pays off under pixel-perfect (integer scales),
26
+ // and in today's card-units model it just adds a conversion + couples Layout
27
+ // to the Sprite. Revisit scale fields when pixel-perfect mode lands. See
28
+ // ~/castle/cauldron-rendering-scale-model.md.
19
29
  static propertyMeta = {
20
30
  x: { inherit: false },
21
31
  y: { inherit: false },
@@ -26,4 +36,18 @@ export class Layout {
26
36
  constructor(props) {
27
37
  this.props = props;
28
38
  }
39
+
40
+ // Outline the Layout box when `debug` is on. Drawn in the actor's own rotated
41
+ // frame (so it rotates with the actor), with a distinct dashed lavender style
42
+ // so it reads separately from the solid collider debug and the selection box.
43
+ draw(actor, scene, ctx) {
44
+ if (!this.props.debug) return;
45
+ const { x, y, width, height } = this.props;
46
+ ctx.save();
47
+ ctx.strokeStyle = 'rgba(190, 178, 255, 0.75)';
48
+ ctx.setLineDash([4, 3]);
49
+ ctx.lineWidth = 1.5;
50
+ ctx.strokeRect(x + 0.5, y + 0.5, width - 1, height - 1);
51
+ ctx.restore();
52
+ }
29
53
  }
@@ -5,20 +5,22 @@ import { useLiveDeckFiles } from '../engine/liveReload';
5
5
  import { collectAssets } from '../engine/assets';
6
6
  import { ScenePlayer } from '../engine/ScenePlayer';
7
7
  import { behaviorClasses } from './behaviorRegistry';
8
- // Play-mode entry point. Intentionally thin: it locates the start scene and
9
- // hands it to the engine's `ScenePlayer`, which owns the runtime and input.
10
- // Deck/game logic belongs in `scenes/` and `behaviors/`, not here.
11
- // Files are live: scene/drawing edits re-key the player against fresh data;
12
- // code changes reload this context (see engine/liveReload.js).
13
- export function PlayOnly() {
8
+ // Play-mode entry point. Plays the scene named by `?scene=<path>` (the shell's
9
+ // Play panel sets this), defaulting to `scenes/main.scene`. It renders ONLY the
10
+ // game the scene picker lives in the Play panel chrome (the shell), not here,
11
+ // so nothing floats over the game surface and steals input. Files are live:
12
+ // scene/drawing edits re-key the player against fresh data.
13
+ const DEFAULT_SCENE = 'scenes/main.scene';
14
+
15
+ export function PlayOnly({ initialScene } = {}) {
14
16
  const { files, dataVersion } = useLiveDeckFiles();
15
- const sceneText = files['scenes/main.scene'] ?? '';
16
- const { value: sceneData } = parseJsonFile('scenes/main.scene', sceneText);
17
+ const scenePath = initialScene && files[initialScene] !== undefined ? initialScene : DEFAULT_SCENE;
18
+ const { value: sceneData } = parseJsonFile(scenePath, files[scenePath] ?? '');
17
19
  if (!sceneData) return null;
18
20
  const { sprites } = collectAssets(files);
19
21
  return (
20
22
  <ScenePlayer
21
- key={dataVersion}
23
+ key={`${scenePath}:${dataVersion}`}
22
24
  sceneData={sceneData}
23
25
  sprites={sprites}
24
26
  files={files}
@@ -321,7 +321,9 @@ export function SceneEditor({
321
321
  const Behavior = findBehaviorClass(behaviorName);
322
322
  if (!Behavior) return;
323
323
  updateBlueprintTemplate((components) => {
324
- components[behaviorName] = { ...Behavior.defaultProps };
324
+ // The template's own components (Layout/Sprite) are the actor context an
325
+ // initialProps hook (e.g. Collider auto-fit) reads from.
326
+ components[behaviorName] = initialBehaviorProps(Behavior, { components }, sprites);
325
327
  });
326
328
  },
327
329
  removeBehavior: (behaviorName) =>
@@ -347,6 +349,8 @@ export function SceneEditor({
347
349
  selectedActorIds,
348
350
  onSelectActorIds,
349
351
  isBlueprintFile,
352
+ sprites,
353
+ resolvedActors: previewSceneData?.actors,
350
354
  });
351
355
  const snapSettings = getSnapSettings(sceneData);
352
356
  const stageWrapStyle = {
@@ -464,19 +468,32 @@ export function SceneEditor({
464
468
  <div className={styles.sceneWorkspace}>
465
469
  <div className={styles.sceneTools}>
466
470
  {isBlueprintFile ? null : (
467
- <BlueprintLibrary
468
- files={files}
469
- sprites={sprites}
470
- canvasRef={canvasRef}
471
- editCameraRef={editCameraRef}
472
- isPlaying={isPlaying}
473
- selectedBlueprintPath={selectedBlueprintPath}
474
- instanceBlueprintPath={rawSelectedActor?.blueprint ?? null}
475
- onSelectBlueprint={onSelectBlueprint}
476
- onAddActor={onAddActor}
477
- dragPlace={dragPlace}
478
- onDeleteBlueprint={onDeleteBlueprint}
479
- />
471
+ <>
472
+ {/* Open (or focus) a Play panel in the shell for this scene.
473
+ Lives here in the visible toolbar because the scene editor's
474
+ header only renders in the combined editor, not the shell. */}
475
+ <IconButton
476
+ icon="external"
477
+ label="Open play preview"
478
+ onClick={(event) => {
479
+ event.currentTarget.blur();
480
+ window.parent.postMessage({ type: 'castle-open-playtest', scene: path }, '*');
481
+ }}
482
+ />
483
+ <BlueprintLibrary
484
+ files={files}
485
+ sprites={sprites}
486
+ canvasRef={canvasRef}
487
+ editCameraRef={editCameraRef}
488
+ isPlaying={isPlaying}
489
+ selectedBlueprintPath={selectedBlueprintPath}
490
+ instanceBlueprintPath={rawSelectedActor?.blueprint ?? null}
491
+ onSelectBlueprint={onSelectBlueprint}
492
+ onAddActor={onAddActor}
493
+ dragPlace={dragPlace}
494
+ onDeleteBlueprint={onDeleteBlueprint}
495
+ />
496
+ </>
480
497
  )}
481
498
  </div>
482
499
  <div className={styles.stageWrap} style={stageWrapStyle}>
@@ -601,6 +618,10 @@ export function SceneEditor({
601
618
  }
602
619
  // Header-right playback controls, in the mockup's order: Undo, Redo, Play.
603
620
  // Play stays a play/stop toggle. Duplicate/remove live on the on-canvas toolbar.
621
+ // "Open preview" asks the shell (parent window) to open/focus a Play panel for
622
+ // THIS scene -- a separate, persistent preview alongside the editor (vs the
623
+ // in-panel play toggle). Defaults to an already-open preview for this scene,
624
+ // else opens a new one (handled shell-side, keyed by scene).
604
625
  function HeaderPlaybackButtons({ isPlaying, setIsPlaying, history }) {
605
626
  return (
606
627
  <>
@@ -638,7 +659,17 @@ function HeaderPlaybackButtons({ isPlaying, setIsPlaying, history }) {
638
659
  // remove-behavior stay whole-component operations straight against the
639
660
  // instance's own sparse `components` -- there's no per-property baseline to
640
661
  // diff when a component doesn't exist on one side at all.
641
- function makeSceneActions({ sceneData, files, serialize, commit, selectedActorIds, onSelectActorIds, isBlueprintFile }) {
662
+ // Props to seed a behavior with when it's freshly added to `actor`. A behavior
663
+ // may define `static initialProps(actor, { sprites })` to derive props from the
664
+ // actor it lands on (e.g. Collider snaps to the sprite's opaque-pixel bounds);
665
+ // otherwise it starts from `defaultProps`. `actor` is the resolved actor
666
+ // (Layout/Sprite merged) or, for a blueprint template, `{ components }`.
667
+ function initialBehaviorProps(Behavior, actor, sprites) {
668
+ if (Behavior.initialProps && actor) return Behavior.initialProps(actor, { sprites });
669
+ return { ...Behavior.defaultProps };
670
+ }
671
+
672
+ function makeSceneActions({ sceneData, files, serialize, commit, selectedActorIds, onSelectActorIds, isBlueprintFile, sprites, resolvedActors }) {
642
673
  function commitScene(next, options) {
643
674
  commit(serialize(next), options);
644
675
  }
@@ -650,8 +681,9 @@ function makeSceneActions({ sceneData, files, serialize, commit, selectedActorId
650
681
  addBehavior: (actorId, behaviorName) => {
651
682
  const Behavior = findBehaviorClass(behaviorName);
652
683
  if (!Behavior) return;
684
+ const resolved = resolvedActors?.find((actor) => actor.id === actorId);
653
685
  commitScene(
654
- setActorComponent(sceneData, actorId, behaviorName, { ...Behavior.defaultProps })
686
+ setActorComponent(sceneData, actorId, behaviorName, initialBehaviorProps(Behavior, resolved, sprites))
655
687
  );
656
688
  },
657
689
  removeBehavior: (actorId, behaviorName) =>
@@ -1565,6 +1597,7 @@ function ActorInspector({
1565
1597
  actor={selectedActor}
1566
1598
  component={component}
1567
1599
  files={files}
1600
+ sprites={sprites}
1568
1601
  setComponent={setComponent}
1569
1602
  override={override}
1570
1603
  />
@@ -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
  }
@@ -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,7 @@ import {
13
13
  faCode,
14
14
  faCodeBranch,
15
15
  faEraser,
16
+ faExternalLinkAlt,
16
17
  faEyeDropper,
17
18
  faFile,
18
19
  faFilm,
@@ -179,6 +180,7 @@ const icons = {
179
180
  // FA5 has no faCodeFork (that's the FA6 name); faCodeBranch is the fork glyph.
180
181
  'code-fork': faCodeBranch,
181
182
  eraser: faEraser,
183
+ external: faExternalLinkAlt,
182
184
  eyedropper: faEyeDropper,
183
185
  fill: faFillDrip,
184
186
  'flip-h': faArrowsAltH,
@@ -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);
@@ -321,7 +321,9 @@ export function SceneEditor({
321
321
  const Behavior = findBehaviorClass(behaviorName);
322
322
  if (!Behavior) return;
323
323
  updateBlueprintTemplate((components) => {
324
- components[behaviorName] = { ...Behavior.defaultProps };
324
+ // The template's own components (Layout/Sprite) are the actor context an
325
+ // initialProps hook (e.g. Collider auto-fit) reads from.
326
+ components[behaviorName] = initialBehaviorProps(Behavior, { components }, sprites);
325
327
  });
326
328
  },
327
329
  removeBehavior: (behaviorName) =>
@@ -347,6 +349,8 @@ export function SceneEditor({
347
349
  selectedActorIds,
348
350
  onSelectActorIds,
349
351
  isBlueprintFile,
352
+ sprites,
353
+ resolvedActors: previewSceneData?.actors,
350
354
  });
351
355
  const snapSettings = getSnapSettings(sceneData);
352
356
  const stageWrapStyle = {
@@ -655,7 +659,17 @@ function HeaderPlaybackButtons({ isPlaying, setIsPlaying, history }) {
655
659
  // remove-behavior stay whole-component operations straight against the
656
660
  // instance's own sparse `components` -- there's no per-property baseline to
657
661
  // diff when a component doesn't exist on one side at all.
658
- function makeSceneActions({ sceneData, files, serialize, commit, selectedActorIds, onSelectActorIds, isBlueprintFile }) {
662
+ // Props to seed a behavior with when it's freshly added to `actor`. A behavior
663
+ // may define `static initialProps(actor, { sprites })` to derive props from the
664
+ // actor it lands on (e.g. Collider snaps to the sprite's opaque-pixel bounds);
665
+ // otherwise it starts from `defaultProps`. `actor` is the resolved actor
666
+ // (Layout/Sprite merged) or, for a blueprint template, `{ components }`.
667
+ function initialBehaviorProps(Behavior, actor, sprites) {
668
+ if (Behavior.initialProps && actor) return Behavior.initialProps(actor, { sprites });
669
+ return { ...Behavior.defaultProps };
670
+ }
671
+
672
+ function makeSceneActions({ sceneData, files, serialize, commit, selectedActorIds, onSelectActorIds, isBlueprintFile, sprites, resolvedActors }) {
659
673
  function commitScene(next, options) {
660
674
  commit(serialize(next), options);
661
675
  }
@@ -667,8 +681,9 @@ function makeSceneActions({ sceneData, files, serialize, commit, selectedActorId
667
681
  addBehavior: (actorId, behaviorName) => {
668
682
  const Behavior = findBehaviorClass(behaviorName);
669
683
  if (!Behavior) return;
684
+ const resolved = resolvedActors?.find((actor) => actor.id === actorId);
670
685
  commitScene(
671
- setActorComponent(sceneData, actorId, behaviorName, { ...Behavior.defaultProps })
686
+ setActorComponent(sceneData, actorId, behaviorName, initialBehaviorProps(Behavior, resolved, sprites))
672
687
  );
673
688
  },
674
689
  removeBehavior: (actorId, behaviorName) =>
@@ -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');
@@ -0,0 +1,15 @@
1
+ // Physics contributes material fields to the shared Collider behavior WITHOUT
2
+ // editing behaviors/Collider.jsx -- that file stays identical to basic-2d's, so
3
+ // the drift check treats it as a converged shared file. These fields are read
4
+ // by the physics simulation (see physics/matterBridge.js); a kit without the
5
+ // physics module never registers them, so its Collider has no material fields.
6
+ // See engine/behaviorExtensions.js for how this is discovered.
7
+ export const behaviorExtension = {
8
+ behaviorName: 'Collider',
9
+ defaultProps: {
10
+ // `bounciness` = restitution: 0 (dead) to ~1 (very bouncy); can exceed 1.
11
+ bounciness: 0,
12
+ // `friction` = surface friction.
13
+ friction: 0.1,
14
+ },
15
+ };
@@ -0,0 +1,8 @@
1
+ // Registers the physics simulation as a runtime system. Auto-discovered by
2
+ // engine/systemRegistry.js (`systems/*.js`) and invoked by makeScene, so the
3
+ // physics module plugs in without the shared engine ever referencing it.
4
+ import { installPhysics } from '../physics';
5
+
6
+ export function installSystem(runtime) {
7
+ installPhysics(runtime);
8
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "castle-web-cli",
3
- "version": "0.4.85",
3
+ "version": "0.4.86",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "castle-web": "./dist/index.js"