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.
- package/kits/basic-2d/CLAUDE.md +3 -3
- package/kits/basic-2d/behaviors/Collider.jsx +172 -26
- package/kits/basic-2d/behaviors/Layout.jsx +24 -0
- package/kits/basic-2d/editors/PlayOnly.jsx +11 -9
- package/kits/basic-2d/editors/SceneEditor.jsx +49 -16
- package/kits/basic-2d/editors/SelectionOverlay.jsx +21 -11
- package/kits/basic-2d/editors/behaviorRegistry.js +9 -3
- package/kits/basic-2d/engine/behaviorExtensions.js +28 -0
- package/kits/basic-2d/engine/collider.js +60 -6
- package/kits/basic-2d/engine/scene.js +28 -2
- package/kits/basic-2d/engine/systemRegistry.js +12 -0
- package/kits/basic-2d/engine/ui.jsx +2 -0
- package/kits/basic-2d/main.jsx +3 -2
- package/kits/physics-2d/behaviors/Collider.jsx +20 -6
- package/kits/physics-2d/editors/SceneEditor.jsx +18 -3
- package/kits/physics-2d/engine/behaviorExtensions.js +28 -0
- package/kits/physics-2d/engine/collider.js +6 -2
- package/kits/physics-2d/engine/scene.js +12 -13
- package/kits/physics-2d/engine/systemRegistry.js +12 -0
- package/kits/physics-2d/physics/extensions/collider.js +15 -0
- package/kits/physics-2d/systems/physics.js +8 -0
- package/package.json +1 -1
package/kits/basic-2d/CLAUDE.md
CHANGED
|
@@ -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": {
|
|
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** — `{
|
|
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 —
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
|
26
|
-
if (!
|
|
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 =
|
|
100
|
+
ctx.strokeStyle = isSensor ? '#ffe17a' : '#8db7ff';
|
|
29
101
|
ctx.lineWidth = 2;
|
|
30
|
-
|
|
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="
|
|
39
|
-
value={component.
|
|
40
|
-
onChange={(
|
|
41
|
-
options={['
|
|
42
|
-
{...overrideProps(override, '
|
|
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={['
|
|
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.
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
|
|
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
|
|
16
|
-
const { value: sceneData } = parseJsonFile('
|
|
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
|
|
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
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
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
|
-
|
|
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,
|
|
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 {
|
|
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.
|
|
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
|
|
467
|
-
if (!layout || !collider || !
|
|
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
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
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 -
|
|
477
|
-
originY: layout.y + layout.height / 2 -
|
|
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/*.
|
|
2
|
-
//
|
|
3
|
-
|
|
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
|
|
117
|
-
//
|
|
118
|
-
//
|
|
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 (
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
package/kits/basic-2d/main.jsx
CHANGED
|
@@ -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
|
-
|
|
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
|
-
//
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
//
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
|
177
|
+
const rawLayout = actor?.components?.Layout;
|
|
178
178
|
const spriteProps = actor?.components?.Sprite;
|
|
179
|
-
if (!
|
|
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;
|
|
49
|
-
//
|
|
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.
|
|
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
|
-
//
|
|
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
|
-
//
|
|
211
|
-
//
|
|
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
|
-
//
|
|
326
|
-
//
|
|
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
|
-
|
|
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
|
+
}
|