castle-web-cli 0.4.84 → 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/dist/ide.js +35 -13
- package/dist/shell/assets/{index-BOgm5T3W.js → index-BJLaUTJE.js} +21 -21
- package/dist/shell/index.html +1 -1
- 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/.prettierrc +8 -0
- package/kits/physics-2d/CLAUDE.md +329 -0
- package/kits/physics-2d/behaviors/Camera.jsx +43 -0
- package/kits/physics-2d/behaviors/Collider.jsx +213 -0
- package/kits/physics-2d/behaviors/Goal.jsx +29 -0
- package/kits/physics-2d/behaviors/Layout.jsx +53 -0
- package/kits/physics-2d/behaviors/Sprite.jsx +352 -0
- package/kits/physics-2d/behaviors/tint.js +47 -0
- package/kits/physics-2d/blueprints/ball.scene +14 -0
- package/kits/physics-2d/blueprints/block.scene +12 -0
- package/kits/physics-2d/blueprints/cauldron.scene +18 -0
- package/kits/physics-2d/blueprints/crate.scene +14 -0
- package/kits/physics-2d/blueprints/goal.scene +12 -0
- package/kits/physics-2d/castle.json +13 -0
- package/kits/physics-2d/docs/pxart-format.md +377 -0
- package/kits/physics-2d/drawings/block.pxart +25 -0
- package/kits/physics-2d/drawings/cauldron.pxart +113 -0
- package/kits/physics-2d/editors/BlueprintLibrary.jsx +247 -0
- package/kits/physics-2d/editors/ErrorBoundary.jsx +59 -0
- package/kits/physics-2d/editors/PlayOnly.jsx +31 -0
- package/kits/physics-2d/editors/PxArtEditor.jsx +954 -0
- package/kits/physics-2d/editors/SceneEditor.jsx +1696 -0
- package/kits/physics-2d/editors/SelectionOverlay.jsx +909 -0
- package/kits/physics-2d/editors/SingleEditor.jsx +122 -0
- package/kits/physics-2d/editors/behaviorRegistry.js +30 -0
- package/kits/physics-2d/editors/editorHistory.js +157 -0
- package/kits/physics-2d/editors/inspectorSheet.js +13 -0
- package/kits/physics-2d/editors/pixelCanvas.js +11 -0
- package/kits/physics-2d/editors/pixelEditorChrome.jsx +74 -0
- package/kits/physics-2d/editors/pixelGeometry.js +140 -0
- package/kits/physics-2d/editors/pixelInspector.jsx +633 -0
- package/kits/physics-2d/editors/pxArtEditorModel.js +732 -0
- package/kits/physics-2d/editors/pxArtPlayback.js +92 -0
- package/kits/physics-2d/editors/pxArtTimeline.jsx +752 -0
- package/kits/physics-2d/editors/pxArtTimeline.module.css +506 -0
- package/kits/physics-2d/editors/pxArtTools.js +232 -0
- package/kits/physics-2d/editors/useArtboardFit.js +102 -0
- package/kits/physics-2d/engine/ScenePlayer.jsx +196 -0
- package/kits/physics-2d/engine/SceneUI.jsx +59 -0
- package/kits/physics-2d/engine/assets.js +15 -0
- package/kits/physics-2d/engine/autoInspector.jsx +70 -0
- package/kits/physics-2d/engine/behaviorExtensions.js +28 -0
- package/kits/physics-2d/engine/blueprint.js +521 -0
- package/kits/physics-2d/engine/collider.js +200 -0
- package/kits/physics-2d/engine/files.js +117 -0
- package/kits/physics-2d/engine/liveReload.js +88 -0
- package/kits/physics-2d/engine/pxart.js +1032 -0
- package/kits/physics-2d/engine/pxartSmooth.js +222 -0
- package/kits/physics-2d/engine/scene.js +685 -0
- package/kits/physics-2d/engine/spriteGeometry.js +32 -0
- package/kits/physics-2d/engine/systemRegistry.js +12 -0
- package/kits/physics-2d/engine/ui.jsx +688 -0
- package/kits/physics-2d/engine/ui.module.css +2287 -0
- package/kits/physics-2d/eslint.config.js +71 -0
- package/kits/physics-2d/index.html +24 -0
- package/kits/physics-2d/main.jsx +24 -0
- package/kits/physics-2d/package-lock.json +2706 -0
- package/kits/physics-2d/package.json +42 -0
- package/kits/physics-2d/physics/PhysicsSystem.js +290 -0
- package/kits/physics-2d/physics/behaviors/AnalogStick.jsx +101 -0
- package/kits/physics-2d/physics/behaviors/Draggable.jsx +79 -0
- package/kits/physics-2d/physics/behaviors/RigidBody.jsx +55 -0
- package/kits/physics-2d/physics/behaviors/Slingshot.jsx +118 -0
- package/kits/physics-2d/physics/controls.js +79 -0
- package/kits/physics-2d/physics/extensions/collider.js +15 -0
- package/kits/physics-2d/physics/index.js +26 -0
- package/kits/physics-2d/physics/matterBridge.js +126 -0
- package/kits/physics-2d/pnpm-lock.yaml +1761 -0
- package/kits/physics-2d/scenes/main.scene +12 -0
- package/kits/physics-2d/scenes/sandbox.scene +13 -0
- package/kits/physics-2d/scripts/draw.mjs +121 -0
- package/kits/physics-2d/systems/physics.js +8 -0
- package/kits/physics-2d/vite.config.js +1 -0
- package/package.json +1 -1
package/dist/shell/index.html
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
<meta charset="utf-8" />
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
6
|
<title>Castle Editor</title>
|
|
7
|
-
<script type="module" crossorigin src="/__castle/ide/assets/index-
|
|
7
|
+
<script type="module" crossorigin src="/__castle/ide/assets/index-BJLaUTJE.js"></script>
|
|
8
8
|
<link rel="stylesheet" crossorigin href="/__castle/ide/assets/index-DonnH--m.css">
|
|
9
9
|
</head>
|
|
10
10
|
<body>
|
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
|
+
}
|