castle-web-cli 0.4.94 → 0.4.95
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/init.js +1 -1
- package/kits/physics-2d/CLAUDE.md +116 -6
- package/kits/physics-2d/behaviors/Collider.jsx +76 -10
- package/kits/physics-2d/behaviors/Sprite.jsx +6 -1
- package/kits/physics-2d/drawings/joint-rope.pxart +26 -0
- package/kits/physics-2d/editors/SceneEditor.jsx +58 -0
- package/kits/physics-2d/editors/SelectionOverlay.jsx +48 -19
- package/kits/physics-2d/editors/behaviorRegistry.js +8 -2
- package/kits/physics-2d/engine/blueprint.js +39 -3
- package/kits/physics-2d/engine/collider.js +13 -3
- package/kits/physics-2d/engine/files.js +26 -5
- package/kits/physics-2d/engine/scene.js +9 -1
- package/kits/physics-2d/engine/systemRegistry.js +5 -1
- package/kits/physics-2d/package-lock.json +10 -3
- package/kits/physics-2d/physics/PhysicsSystem.js +112 -0
- package/kits/physics-2d/physics/behaviors/Joints.jsx +291 -0
- package/kits/physics-2d/physics/jointArt.js +57 -0
- package/kits/physics-2d/physics/joints.js +136 -0
- package/kits/physics-2d/physics/matterBridge.js +22 -2
- package/package.json +1 -1
package/dist/init.js
CHANGED
|
@@ -36,7 +36,7 @@ const DEFAULT_KIT = "basic-2d";
|
|
|
36
36
|
// Registry version of castle-web-sdk to inject when scaffolding from a
|
|
37
37
|
// globally-installed castle-web (not from inside the workspace). Bumped
|
|
38
38
|
// alongside cli/sdk version bumps.
|
|
39
|
-
const PUBLISHED_SDK_VERSION = "0.4.
|
|
39
|
+
const PUBLISHED_SDK_VERSION = "0.4.11";
|
|
40
40
|
// Never copied into a fresh deck: build/dependency junk. castle.json IS copied
|
|
41
41
|
// (the kit ships a config-only one with the editor layout / file filters), but
|
|
42
42
|
// `scaffoldFromKit` strips any identity fields off it first -- a fresh deck has
|
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
This is the `basic-2d` actor/behavior/scene framework plus a built-in 2D
|
|
4
4
|
**physics** system (matter-js). Everything in `basic-2d` works the same; physics
|
|
5
5
|
adds `RigidBody`, physics fields on `Collider`, a world-gravity scene setting,
|
|
6
|
-
collision callbacks,
|
|
7
|
-
`Slingshot`, `AnalogStick`).
|
|
6
|
+
collision callbacks, `Joints` that link actors (spring/rod/pin/weld/rope, several
|
|
7
|
+
per actor), and ready-made touch-first controls (`Draggable`, `Slingshot`, `AnalogStick`).
|
|
8
|
+
See `## Physics` below.
|
|
8
9
|
|
|
9
10
|
## Welcome message
|
|
10
11
|
|
|
@@ -27,6 +28,25 @@ Do you already know what you want to make, or do you want to figure it out toget
|
|
|
27
28
|
- Do not read `engine/`, `editors/`, or built-in behaviors (`Layout.jsx`, `Sprite.jsx`, `Collider.jsx`, `Camera.jsx`) to build a game. Their public API is documented below.
|
|
28
29
|
- Details below: `## Behavior shape`, `## Scene file`, `## Blueprints`, `## Built-in behaviors`, `## Creating pixel art`, `## SceneRuntime API`, and `## Input shortcuts`.
|
|
29
30
|
|
|
31
|
+
## Files and imports
|
|
32
|
+
|
|
33
|
+
One rule wherever a file is named -- JS imports, `"blueprint"` refs, `Sprite.file`,
|
|
34
|
+
a joint's `sprite`, anything a behavior invents:
|
|
35
|
+
|
|
36
|
+
- `drawings/ship.pxart` -- a file of the deck the reference is WRITTEN IN. In this
|
|
37
|
+
deck's own files that means this deck; in a file belonging to an import, that
|
|
38
|
+
import. So a kit's blueprint saying `drawings/cauldron.pxart` keeps meaning the
|
|
39
|
+
kit's drawing once the kit is imported by someone else.
|
|
40
|
+
- `@imports/<alias>/drawings/ship.pxart` -- a file of the deck imported under
|
|
41
|
+
`<alias>`. This is the only way to name another deck's file, so cross-deck
|
|
42
|
+
references are visible as such, and it means the same thing from any file at
|
|
43
|
+
any depth.
|
|
44
|
+
|
|
45
|
+
Imports are read-only: their files belong to the deck they came from. Use them,
|
|
46
|
+
don't edit them. `castle-web add-import <deckId>` adds one, `update-import`
|
|
47
|
+
re-fetches it. `resolveDeckFile` from `castle-web-sdk` is the rule itself, if a
|
|
48
|
+
behavior needs to resolve a path it was handed.
|
|
49
|
+
|
|
30
50
|
## Scope
|
|
31
51
|
|
|
32
52
|
Write the smallest game that satisfies what the user asked for. No sound, particles, menus, multi-level progression, or visual polish unless they specifically asked for it. A typical behavior is 30–80 lines — if yours is hitting 200, you're over-engineering: cut feel-good extras, fewer fields on props, fewer edge cases, fewer comments. Ship the core loop first; the user can ask for more.
|
|
@@ -152,6 +172,10 @@ make it move.**
|
|
|
152
172
|
(`mode`, `width`/`height`, `offsetX`/`offsetY`, `debug`) it has:
|
|
153
173
|
- `shape: 'box' | 'circle'` — the physics shape (default `box`). A circle uses
|
|
154
174
|
`radius`, or half the smaller collider dimension if `radius` is 0.
|
|
175
|
+
- A collider can hold **multiple shapes** (a compound body) — edit them in the
|
|
176
|
+
inspector's shape list. A **box shape takes an `angle`** (degrees), so one
|
|
177
|
+
actor can have an angled collider (e.g. a skateboard: a flat deck box plus an
|
|
178
|
+
upturned nose and tail box) instead of gluing separate actors together.
|
|
155
179
|
- `isTrigger` — a **sensor**: detects overlaps (fires collision callbacks) but
|
|
156
180
|
does **not** block. Use for pickups, goals, zones.
|
|
157
181
|
- `bounciness` — restitution, 0 (dead) to ~1 (very bouncy), can exceed 1.
|
|
@@ -228,6 +252,83 @@ on-screen control -- never be the only way to play.
|
|
|
228
252
|
`onCollisionEnter` that scores when `other` is the ball.
|
|
229
253
|
- Scene: set `"physics": { "gravity": 1 }`, place one ball, walls, a goal.
|
|
230
254
|
|
|
255
|
+
### Joints (link actors together)
|
|
256
|
+
|
|
257
|
+
Add a **`Joints`** behavior to connect an actor to one or more `target` actors
|
|
258
|
+
with physics links. The component holds a **list**, so one actor can carry
|
|
259
|
+
several links at once (a ragdoll pelvis pinned to both thighs, a ball slung
|
|
260
|
+
between two anchors, a body both sprung and roped). Both actors of a link need a
|
|
261
|
+
`Collider` (to have a body); the owner is usually a dynamic `RigidBody` and each
|
|
262
|
+
target either dynamic or a static anchor (a lone `Collider`). Each list entry:
|
|
263
|
+
|
|
264
|
+
- `type` — the link's feel:
|
|
265
|
+
- `spring` — soft elastic tether (`springiness` 0..1 + `damping`).
|
|
266
|
+
- `rod` — rigid fixed-distance stick; the ends still rotate freely. This is also
|
|
267
|
+
your **hinge**: a `rod` to a static anchor is a pendulum / swinging door.
|
|
268
|
+
- `weld` — fused rigid: a pivot link plus frozen rotation, so the two bodies
|
|
269
|
+
can't move or turn relative to each other. Place them slightly apart.
|
|
270
|
+
- `rope` — slack, taut only at `length` (max); the bob free-falls then catches.
|
|
271
|
+
- (`pin`, a free-rotating coincident-pivot hinge, was removed — a length-0
|
|
272
|
+
revolute between two dynamic bodies is matter's most unstable case. Use `rod`.)
|
|
273
|
+
- `target` — the other actor's id. **Author it per instance** (pick it on the
|
|
274
|
+
canvas — see below), not on a blueprint, unless every instance really should
|
|
275
|
+
link to the same actor (e.g. many things roped to one anchor).
|
|
276
|
+
- `length` — rest length (spring/rod) or max length (rope) in px. In the
|
|
277
|
+
inspector, turn **Auto length** on to lock it to the actors' distance when play
|
|
278
|
+
starts (stored as `-1`). Not used by `pin`/`weld`.
|
|
279
|
+
- `springiness` (0..1) — for `spring`, how bouncy the tether is; for `rope`, how
|
|
280
|
+
it behaves *when taut*: `0` = a dead rope (holds firm at max length), `1` = a
|
|
281
|
+
bungee (stretches well past and springs back).
|
|
282
|
+
- `damping` (0..1, spring only) — how fast the spring's oscillation settles.
|
|
283
|
+
- `anchorX/anchorY` (+ `targetAnchorX/targetAnchorY` for spring/rod/rope) —
|
|
284
|
+
attach-point offsets in px, under the inspector's **Anchor offsets** toggle. For
|
|
285
|
+
`pin`/`weld` the owner offset moves the shared pivot.
|
|
286
|
+
|
|
287
|
+
**Author a joint:** add the `Joints` behavior to the owner, then in the inspector
|
|
288
|
+
click **Pick target** and click the other actor on the canvas (Esc / empty click
|
|
289
|
+
cancels). **+ Add joint** appends another link; each has its own Remove. Links
|
|
290
|
+
draw as colored overlays (coil = spring, dashed = rope, double line = weld, ring =
|
|
291
|
+
pin, plain line = rod) so you can see what's connected. In the scene file it's a
|
|
292
|
+
list: `"Joints": { "list": [ { "type": "rod", "target": "anchor" }, … ] }`.
|
|
293
|
+
|
|
294
|
+
`target` is instance-local (a blueprint-level target points every instance at the
|
|
295
|
+
same actor — only what you want for a shared anchor). You rarely need multiple
|
|
296
|
+
entries: a chain A–B–C just puts one link on B→A and one on C→B. Reach for a
|
|
297
|
+
multi-entry list when an actor genuinely connects to two+ others at once.
|
|
298
|
+
|
|
299
|
+
Recipe — pendulum: a static `anchor` actor (Collider, no RigidBody) at top, a
|
|
300
|
+
dynamic ball below with `Joints { list: [{ type: 'rod', target: 'anchor' }] }`.
|
|
301
|
+
The ball swings at a fixed radius. Swap `rod`→`rope` for a slack tether,
|
|
302
|
+
`rod`→`spring` for a bouncy one; add a second entry to sling it between two
|
|
303
|
+
anchors.
|
|
304
|
+
|
|
305
|
+
**How a joint looks in play** — each entry has a `render`:
|
|
306
|
+
|
|
307
|
+
- `line` (default) — the schematic overlay (coil/dashed/etc.). Good for
|
|
308
|
+
prototyping; you see your joints working.
|
|
309
|
+
- `hidden` — nothing drawn in play (the joint is pure mechanics). It still shows
|
|
310
|
+
as a faint line in the *editor* so you don't lose track of it.
|
|
311
|
+
- `sprite` — draw art along the joint. Set `sprite` to a `.pxart`
|
|
312
|
+
(defaults to a built-in rope), `thickness` (px), and `fit`: `tile` (repeat a
|
|
313
|
+
segment — rope, chain) or `stretch` (one image end-to-end — stick, beam). The
|
|
314
|
+
art rotates to the joint's angle and scales to its live length, so a rope
|
|
315
|
+
visibly stretches. The editor previews it WYSIWYG.
|
|
316
|
+
|
|
317
|
+
**Custom joint visuals (draw your own):** for anything beyond the built-in art,
|
|
318
|
+
set the joint's `render: 'hidden'` and draw it yourself from a behavior's
|
|
319
|
+
`draw()` using `scene.physics.getJoints()`. It returns one entry per link with
|
|
320
|
+
world-space endpoints so your art lines up with the sprites:
|
|
321
|
+
|
|
322
|
+
```jsx
|
|
323
|
+
draw(actor, scene, ctx) {
|
|
324
|
+
for (const j of scene.physics.getJoints()) {
|
|
325
|
+
// j = { ownerId, targetId, type, a:{x,y}, b:{x,y}, length, angle }
|
|
326
|
+
ctx.strokeStyle = '#0cf1ff';
|
|
327
|
+
ctx.beginPath(); ctx.moveTo(j.a.x, j.a.y); ctx.lineTo(j.b.x, j.b.y); ctx.stroke();
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
```
|
|
331
|
+
|
|
231
332
|
### Gotchas
|
|
232
333
|
|
|
233
334
|
- **Fast bodies + thin walls tunnel.** A small body moving faster than a static
|
|
@@ -241,6 +342,12 @@ on-screen control -- never be the only way to play.
|
|
|
241
342
|
directly is only for `static`/`kinematic` bodies.
|
|
242
343
|
- **Editor vs play.** Physics only steps during play; in the editor actors sit
|
|
243
344
|
where you place them. `RigidBody.velocityX/Y` apply once when play starts.
|
|
345
|
+
- **A joint binds only when both actors have a body.** If a `Joints` entry's
|
|
346
|
+
owner or `target` has no `Collider` (or the target id is wrong/missing), that
|
|
347
|
+
link is silently skipped until both bodies exist. `weld`/`pin` want the two
|
|
348
|
+
actors placed a little apart, not overlapping.
|
|
349
|
+
- **A global max-speed clamp (`MAX_SPEED` in matterBridge)** backstops any joint
|
|
350
|
+
blow-up so a body can't be flung off screen — normal motion never reaches it.
|
|
244
351
|
|
|
245
352
|
## Adding physics to another kit
|
|
246
353
|
|
|
@@ -250,10 +357,13 @@ different kit:
|
|
|
250
357
|
1. Copy the `physics/` folder into the kit.
|
|
251
358
|
2. Add `matter-js` to the kit's `package.json` dependencies.
|
|
252
359
|
3. In the kit's `engine/scene.js`: add a systems registry to `SceneRuntime`
|
|
253
|
-
(`this.systems = []`, a `registerSystem(system)` method,
|
|
254
|
-
`update(dt)
|
|
255
|
-
|
|
256
|
-
|
|
360
|
+
(`this.systems = []`, a `registerSystem(system)` method, at the end of
|
|
361
|
+
`update(dt)` a `for (const s of this.systems) s.afterBehaviors?.(this, dt);`,
|
|
362
|
+
and at the START of `load(sceneData)` a
|
|
363
|
+
`for (const s of this.systems) s.reset?.(this);` so a reload/restart/scene
|
|
364
|
+
transition resets the simulation to the authored layout instead of carrying
|
|
365
|
+
over body positions + joints), then call `installPhysics(runtime)` from
|
|
366
|
+
`makeScene` (and route `clone()` through `makeScene` so clones get it too).
|
|
257
367
|
4. In the kit's `editors/behaviorRegistry`, also glob
|
|
258
368
|
`../physics/behaviors/*.jsx` so the physics behaviors auto-register.
|
|
259
369
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import React, { useState } from 'react';
|
|
1
|
+
import React, { useEffect, useState } from 'react';
|
|
2
2
|
import { NumberField, Panel, SelectField } from '../engine/ui';
|
|
3
3
|
import { AutoFields } from '../engine/autoInspector';
|
|
4
4
|
import { computeAutoFit, getColliderRect, getColliderShapes, intersects } from '../engine/collider';
|
|
@@ -61,6 +61,13 @@ function drawShape(ctx, s) {
|
|
|
61
61
|
for (let k = 1; k < s.points.length; k++) ctx.lineTo(s.points[k].x, s.points[k].y);
|
|
62
62
|
ctx.closePath();
|
|
63
63
|
ctx.stroke();
|
|
64
|
+
} else if (s.angle) {
|
|
65
|
+
// Rotate the outline about the box center to match the (angled) matter part.
|
|
66
|
+
ctx.save();
|
|
67
|
+
ctx.translate(s.cx, s.cy);
|
|
68
|
+
ctx.rotate((s.angle * Math.PI) / 180);
|
|
69
|
+
ctx.strokeRect(-s.width / 2 + 1, -s.height / 2 + 1, s.width - 2, s.height - 2);
|
|
70
|
+
ctx.restore();
|
|
64
71
|
} else {
|
|
65
72
|
ctx.strokeRect(s.x + 1, s.y + 1, s.width - 2, s.height - 2);
|
|
66
73
|
}
|
|
@@ -113,14 +120,21 @@ export class Collider {
|
|
|
113
120
|
const shapes = getColliderShapes(actor);
|
|
114
121
|
if (!shapes) return;
|
|
115
122
|
const isSensor = Boolean(this.props.isTrigger) || this.props.kind === 'pickup';
|
|
123
|
+
// When editing this blueprint (hotbar), highlight the shape the inspector has
|
|
124
|
+
// selected so you can tell which chip is which on its lit instances -- the
|
|
125
|
+
// selected-instance path does this via the SelectionOverlay instead.
|
|
126
|
+
const highlight = blueprintSelected ? options.colliderShapeSel ?? -1 : -1;
|
|
116
127
|
ctx.save();
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
128
|
+
shapes.forEach((s, k) => {
|
|
129
|
+
const on = k === highlight;
|
|
130
|
+
ctx.strokeStyle = on ? '#ffd24a' : isSensor ? '#ffe17a' : '#8db7ff';
|
|
131
|
+
ctx.lineWidth = on ? 3.5 : 2;
|
|
132
|
+
drawShape(ctx, s);
|
|
133
|
+
});
|
|
120
134
|
ctx.restore();
|
|
121
135
|
}
|
|
122
136
|
|
|
123
|
-
static Inspector({ actor, component, sprites, setComponent, override }) {
|
|
137
|
+
static Inspector({ actor, component, sprites, setComponent, override, onSelectShape }) {
|
|
124
138
|
const [sel, setSel] = useState(0);
|
|
125
139
|
const [addOpen, setAddOpen] = useState(false);
|
|
126
140
|
const layout = actor?.components?.Layout ?? {};
|
|
@@ -129,6 +143,9 @@ export class Collider {
|
|
|
129
143
|
|
|
130
144
|
const shapes = currentShapes(component);
|
|
131
145
|
const i = Math.min(sel, shapes.length - 1);
|
|
146
|
+
// Report the selected shape index up so the on-canvas overlay can highlight
|
|
147
|
+
// it (fires on mount with 0, and on every chip click / add / remove).
|
|
148
|
+
useEffect(() => onSelectShape?.(i), [i, onSelectShape]);
|
|
132
149
|
const shape = shapes[i];
|
|
133
150
|
const isCircle = shape.type === 'circle';
|
|
134
151
|
const isPoly = shape.type === 'triangle' || shape.type === 'polygon';
|
|
@@ -160,6 +177,22 @@ export class Collider {
|
|
|
160
177
|
const offXPx = Math.round(((shape.x ?? 0.5) - 0.5) * bw);
|
|
161
178
|
const offYPx = Math.round(((shape.y ?? 0.5) - 0.5) * bh);
|
|
162
179
|
|
|
180
|
+
// Per-subfield override state vs the blueprint's version of THIS shape. The
|
|
181
|
+
// shape fields don't go through AutoFields, so without this an instance that
|
|
182
|
+
// changed e.g. box 3's offset X wouldn't show the tint / Default / Reset.
|
|
183
|
+
// Null override (editing the blueprint template itself) shows nothing.
|
|
184
|
+
const bpShapes = override?.baseline?.('shapes');
|
|
185
|
+
const bShape = Array.isArray(bpShapes) ? bpShapes[i] : null;
|
|
186
|
+
const shapeOv = (key, dflt, toPx) => {
|
|
187
|
+
if (!bShape || bShape.type !== shape.type) return {};
|
|
188
|
+
const bv = bShape[key] ?? dflt;
|
|
189
|
+
return {
|
|
190
|
+
overridden: Math.abs((shape[key] ?? dflt) - bv) > 1e-4,
|
|
191
|
+
defaultValue: toPx(bv),
|
|
192
|
+
onReset: () => patchSel({ [key]: bv }),
|
|
193
|
+
};
|
|
194
|
+
};
|
|
195
|
+
|
|
163
196
|
const density = component.density > 0 ? component.density : 0.001;
|
|
164
197
|
|
|
165
198
|
// Auto-fit (box/circle) for the selected shape.
|
|
@@ -205,17 +238,50 @@ export class Collider {
|
|
|
205
238
|
<>
|
|
206
239
|
<SelectField label="Shape" value={shape.type} onChange={setType} options={['box', 'circle']} />
|
|
207
240
|
{isCircle ? (
|
|
208
|
-
<NumberField
|
|
241
|
+
<NumberField
|
|
242
|
+
label="Radius"
|
|
243
|
+
value={radiusPx}
|
|
244
|
+
onChange={(v) => patchSel({ r: v / Math.min(bw, bh) })}
|
|
245
|
+
{...shapeOv('r', 0.5, (f) => Math.round(f * Math.min(bw, bh)))}
|
|
246
|
+
/>
|
|
209
247
|
) : (
|
|
210
248
|
<>
|
|
211
|
-
<NumberField
|
|
212
|
-
|
|
249
|
+
<NumberField
|
|
250
|
+
label="Width"
|
|
251
|
+
value={widthPx}
|
|
252
|
+
onChange={(v) => patchSel({ w: v / bw })}
|
|
253
|
+
{...shapeOv('w', 1, (f) => Math.round(f * bw))}
|
|
254
|
+
/>
|
|
255
|
+
<NumberField
|
|
256
|
+
label="Height"
|
|
257
|
+
value={heightPx}
|
|
258
|
+
onChange={(v) => patchSel({ h: v / bh })}
|
|
259
|
+
{...shapeOv('h', 1, (f) => Math.round(f * bh))}
|
|
260
|
+
/>
|
|
261
|
+
<NumberField
|
|
262
|
+
label="Angle"
|
|
263
|
+
value={Math.round(shape.angle ?? 0)}
|
|
264
|
+
min={-180}
|
|
265
|
+
max={180}
|
|
266
|
+
onChange={(v) => patchSel({ angle: v })}
|
|
267
|
+
{...shapeOv('angle', 0, (f) => Math.round(f))}
|
|
268
|
+
/>
|
|
213
269
|
</>
|
|
214
270
|
)}
|
|
215
271
|
</>
|
|
216
272
|
)}
|
|
217
|
-
<NumberField
|
|
218
|
-
|
|
273
|
+
<NumberField
|
|
274
|
+
label="Offset X"
|
|
275
|
+
value={offXPx}
|
|
276
|
+
onChange={(v) => patchSel({ x: 0.5 + v / bw })}
|
|
277
|
+
{...shapeOv('x', 0.5, (f) => Math.round((f - 0.5) * bw))}
|
|
278
|
+
/>
|
|
279
|
+
<NumberField
|
|
280
|
+
label="Offset Y"
|
|
281
|
+
value={offYPx}
|
|
282
|
+
onChange={(v) => patchSel({ y: 0.5 + v / bh })}
|
|
283
|
+
{...shapeOv('y', 0.5, (f) => Math.round((f - 0.5) * bh))}
|
|
284
|
+
/>
|
|
219
285
|
<div style={actionRow}>
|
|
220
286
|
{autoFit && !fitted ? (
|
|
221
287
|
<button type="button" onClick={() => writeShapes(shapes.map((s, k) => (k === i ? autoFit : s)))} style={linkBtn}>
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { resolveDeckFile } from 'castle-web-sdk';
|
|
1
2
|
import React from 'react';
|
|
2
3
|
import { frameCount, renderSpriteFrame } from '../engine/pxart';
|
|
3
4
|
import { renderSmoothSpriteFrame } from '../engine/pxartSmooth';
|
|
@@ -37,7 +38,11 @@ export class Sprite {
|
|
|
37
38
|
}
|
|
38
39
|
|
|
39
40
|
resolveSprite(scene) {
|
|
40
|
-
|
|
41
|
+
// `drawings/x.pxart` is this deck's; `@imports/<alias>/drawings/x.pxart` is
|
|
42
|
+
// an import's. (A value inherited from an imported blueprint arrives already
|
|
43
|
+
// resolved against that blueprint's deck -- see engine/blueprint.js.)
|
|
44
|
+
const file = resolveDeckFile(this.props.file);
|
|
45
|
+
return scene.sprites?.[file] ?? scene.sprites?.[FALLBACK_FILE] ?? null;
|
|
41
46
|
}
|
|
42
47
|
|
|
43
48
|
update(actor, scene, dt) {
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"format": "compact",
|
|
3
|
+
"palette": {
|
|
4
|
+
"a": "#8a4836",
|
|
5
|
+
"b": "#bf6f4a",
|
|
6
|
+
"c": "#e69c69"
|
|
7
|
+
},
|
|
8
|
+
"grid": [
|
|
9
|
+
"................",
|
|
10
|
+
"................",
|
|
11
|
+
"................",
|
|
12
|
+
"..cc........cc..",
|
|
13
|
+
".b..b......b..b.",
|
|
14
|
+
"a....a....a....a",
|
|
15
|
+
"......a..a......",
|
|
16
|
+
".......aa.......",
|
|
17
|
+
".......aa.......",
|
|
18
|
+
"......a..a......",
|
|
19
|
+
"a....a....a....a",
|
|
20
|
+
".b..b......b..b.",
|
|
21
|
+
"..cc........cc..",
|
|
22
|
+
"................",
|
|
23
|
+
"................",
|
|
24
|
+
"................"
|
|
25
|
+
]
|
|
26
|
+
}
|
|
@@ -161,6 +161,27 @@ export function SceneEditor({
|
|
|
161
161
|
const editCameraRef = useRef(reloadStash?.editCamera ?? { x: 0, y: 0, zoom: 1 });
|
|
162
162
|
const selectedActorIdsRef = useRef(selectedActorIds);
|
|
163
163
|
selectedActorIdsRef.current = selectedActorIds;
|
|
164
|
+
// Transient "click an actor to pick it" mode, triggered by an inspector field
|
|
165
|
+
// (e.g. Joint's target picker). Holds `{ onPick }`; the next edit-canvas click
|
|
166
|
+
// resolves it with the clicked actor's id instead of selecting. A ref mirror
|
|
167
|
+
// lets the pointer gesture read it live without re-creating handlers.
|
|
168
|
+
const [pickRequest, setPickRequest] = useState(null);
|
|
169
|
+
const pickRequestRef = useRef(null);
|
|
170
|
+
pickRequestRef.current = pickRequest;
|
|
171
|
+
const beginPick = useCallback((onPick) => setPickRequest(onPick ? { onPick } : null), []);
|
|
172
|
+
useEffect(() => {
|
|
173
|
+
if (!pickRequest) return undefined;
|
|
174
|
+
const onKey = (e) => e.key === 'Escape' && setPickRequest(null);
|
|
175
|
+
window.addEventListener('keydown', onKey);
|
|
176
|
+
return () => window.removeEventListener('keydown', onKey);
|
|
177
|
+
}, [pickRequest]);
|
|
178
|
+
// Which Collider shape the inspector has selected, so the on-canvas overlay can
|
|
179
|
+
// highlight it. The Collider inspector reports its index up via onSelectShape.
|
|
180
|
+
// A ref mirror feeds the draw loop (blueprint-lit instances highlight via the
|
|
181
|
+
// canvas draw); the SelectionOverlay reads the state directly.
|
|
182
|
+
const [colliderShapeSel, setColliderShapeSel] = useState(0);
|
|
183
|
+
const colliderShapeSelRef = useRef(colliderShapeSel);
|
|
184
|
+
colliderShapeSelRef.current = colliderShapeSel;
|
|
164
185
|
const [isPlaying, setIsPlaying] = useState(reloadStash?.isPlaying ?? false);
|
|
165
186
|
// Hotbar blueprint selection (tap a slot -> inspect/edit the blueprint in
|
|
166
187
|
// the sidebar; drag remains the placement gesture). Mutually exclusive with
|
|
@@ -239,9 +260,19 @@ export function SceneEditor({
|
|
|
239
260
|
marqueeRef,
|
|
240
261
|
selectedActorIdsRef,
|
|
241
262
|
selectedBlueprintPathRef,
|
|
263
|
+
colliderShapeSelRef,
|
|
242
264
|
});
|
|
243
265
|
const panGesture = usePanGesture({ canvasRef, editCameraRef, isPlaying });
|
|
244
266
|
useScenePlayKeys(runtimeRef);
|
|
267
|
+
// Resolve an in-progress target pick: a click on the owner itself is ignored
|
|
268
|
+
// (a self-joint is meaningless); a click on empty space cancels.
|
|
269
|
+
const onPickTarget = (actorId) => {
|
|
270
|
+
const req = pickRequestRef.current;
|
|
271
|
+
if (!req) return;
|
|
272
|
+
if (actorId && actorId === selectedActorIdsRef.current[0]) return;
|
|
273
|
+
setPickRequest(null);
|
|
274
|
+
if (actorId) req.onPick(actorId);
|
|
275
|
+
};
|
|
245
276
|
const gesture = useSelectionGesture({
|
|
246
277
|
canvasRef,
|
|
247
278
|
sceneData,
|
|
@@ -254,6 +285,8 @@ export function SceneEditor({
|
|
|
254
285
|
onSetMultiSelectMode,
|
|
255
286
|
marqueeRef,
|
|
256
287
|
editCameraRef,
|
|
288
|
+
pickRequestRef,
|
|
289
|
+
onPickTarget,
|
|
257
290
|
applyScene: (next) => onChange(serialize(next)),
|
|
258
291
|
recordSceneSnapshot: history.recordSnapshot,
|
|
259
292
|
});
|
|
@@ -549,6 +582,7 @@ export function SceneEditor({
|
|
|
549
582
|
previewSceneData={previewSceneData}
|
|
550
583
|
sprites={sprites}
|
|
551
584
|
selectedActorIds={selectedActorIds}
|
|
585
|
+
selectedColliderShape={colliderShapeSel}
|
|
552
586
|
snap={snapSettings}
|
|
553
587
|
onArrange={isBlueprintFile ? undefined : actions.arrangeSelection}
|
|
554
588
|
onClone={isBlueprintFile ? undefined : actions.duplicateSelection}
|
|
@@ -594,6 +628,7 @@ export function SceneEditor({
|
|
|
594
628
|
onSetComponent={blueprintActions.setComponent}
|
|
595
629
|
onAddBehavior={blueprintActions.addBehavior}
|
|
596
630
|
onRemoveBehavior={blueprintActions.removeBehavior}
|
|
631
|
+
onSelectShape={setColliderShapeSel}
|
|
597
632
|
/>
|
|
598
633
|
) : showMulti ? (
|
|
599
634
|
<MultiSelectInspector
|
|
@@ -622,6 +657,9 @@ export function SceneEditor({
|
|
|
622
657
|
? undefined
|
|
623
658
|
: () => onSelectBlueprint(rawSelectedActor.blueprint)
|
|
624
659
|
}
|
|
660
|
+
onBeginPick={beginPick}
|
|
661
|
+
pickActive={Boolean(pickRequest)}
|
|
662
|
+
onSelectShape={setColliderShapeSel}
|
|
625
663
|
/>
|
|
626
664
|
) : (
|
|
627
665
|
<SceneInspector
|
|
@@ -800,6 +838,13 @@ function useSelectionGesture(args) {
|
|
|
800
838
|
const stack = scene.actorsAt(point.x, point.y);
|
|
801
839
|
const stackIds = stack.map((a) => a.id);
|
|
802
840
|
const actor = stack[0] ?? null;
|
|
841
|
+
// Target-pick mode swallows this click: resolve the pick with the clicked
|
|
842
|
+
// actor (or cancel on empty space) instead of selecting/dragging.
|
|
843
|
+
if (current.pickRequestRef?.current) {
|
|
844
|
+
current.canvasRef.current.releasePointerCapture?.(event.pointerId);
|
|
845
|
+
current.onPickTarget(actor?.id ?? null);
|
|
846
|
+
return;
|
|
847
|
+
}
|
|
803
848
|
const drag = {
|
|
804
849
|
pointerId: event.pointerId,
|
|
805
850
|
startPoint: point,
|
|
@@ -1339,6 +1384,7 @@ function useScenePlayLoop({
|
|
|
1339
1384
|
marqueeRef,
|
|
1340
1385
|
selectedActorIdsRef,
|
|
1341
1386
|
selectedBlueprintPathRef,
|
|
1387
|
+
colliderShapeSelRef,
|
|
1342
1388
|
}) {
|
|
1343
1389
|
// Spin up / tear down the play-mode runtime as the user toggles play. `text`
|
|
1344
1390
|
// is the stable identity for `sceneData` (which is re-parsed every render);
|
|
@@ -1398,6 +1444,9 @@ function useScenePlayLoop({
|
|
|
1398
1444
|
useCamera: true,
|
|
1399
1445
|
editPlaceholders: !isPlaying,
|
|
1400
1446
|
dimBlueprintPath: isPlaying ? null : selectedBlueprintPathRef.current,
|
|
1447
|
+
// Highlight the collider shape the blueprint inspector has selected on
|
|
1448
|
+
// its lit instances.
|
|
1449
|
+
colliderShapeSel: isPlaying ? -1 : colliderShapeSelRef?.current ?? -1,
|
|
1401
1450
|
});
|
|
1402
1451
|
}
|
|
1403
1452
|
raf = requestAnimationFrame(frame);
|
|
@@ -1415,6 +1464,7 @@ function useScenePlayLoop({
|
|
|
1415
1464
|
marqueeRef,
|
|
1416
1465
|
selectedActorIdsRef,
|
|
1417
1466
|
selectedBlueprintPathRef,
|
|
1467
|
+
colliderShapeSelRef,
|
|
1418
1468
|
]);
|
|
1419
1469
|
}
|
|
1420
1470
|
function useScenePlayKeys(runtimeRef) {
|
|
@@ -1523,6 +1573,7 @@ function BlueprintInspector({
|
|
|
1523
1573
|
onSetComponent,
|
|
1524
1574
|
onAddBehavior,
|
|
1525
1575
|
onRemoveBehavior,
|
|
1576
|
+
onSelectShape,
|
|
1526
1577
|
}) {
|
|
1527
1578
|
const templateActor = { id: '__blueprint__', components: template.components };
|
|
1528
1579
|
const instanceCount = countBlueprintInstances(files, blueprintPath);
|
|
@@ -1552,6 +1603,7 @@ function BlueprintInspector({
|
|
|
1552
1603
|
onSetComponent={(actorId, behaviorName, nextProps) => onSetComponent(behaviorName, nextProps)}
|
|
1553
1604
|
onAddBehavior={(actorId, behaviorName) => onAddBehavior(behaviorName)}
|
|
1554
1605
|
onRemoveBehavior={(actorId, behaviorName) => onRemoveBehavior(behaviorName)}
|
|
1606
|
+
onSelectShape={onSelectShape}
|
|
1555
1607
|
/>
|
|
1556
1608
|
</>
|
|
1557
1609
|
);
|
|
@@ -1593,6 +1645,9 @@ function ActorInspector({
|
|
|
1593
1645
|
onRemoveBehavior,
|
|
1594
1646
|
onFork,
|
|
1595
1647
|
onEditBlueprint,
|
|
1648
|
+
onBeginPick,
|
|
1649
|
+
pickActive,
|
|
1650
|
+
onSelectShape,
|
|
1596
1651
|
}) {
|
|
1597
1652
|
const isInstance = Boolean(rawActor?.blueprint);
|
|
1598
1653
|
const presentNames = new Set(
|
|
@@ -1687,6 +1742,9 @@ function ActorInspector({
|
|
|
1687
1742
|
sprites={sprites}
|
|
1688
1743
|
setComponent={setComponent}
|
|
1689
1744
|
override={override}
|
|
1745
|
+
beginPick={onBeginPick}
|
|
1746
|
+
pickActive={pickActive}
|
|
1747
|
+
onSelectShape={onSelectShape}
|
|
1690
1748
|
/>
|
|
1691
1749
|
) : (
|
|
1692
1750
|
<AutoInspector
|
|
@@ -68,6 +68,7 @@ export function SelectionOverlay({
|
|
|
68
68
|
previewSceneData,
|
|
69
69
|
sprites,
|
|
70
70
|
selectedActorIds,
|
|
71
|
+
selectedColliderShape,
|
|
71
72
|
snap,
|
|
72
73
|
onArrange,
|
|
73
74
|
onClone,
|
|
@@ -198,6 +199,9 @@ export function SelectionOverlay({
|
|
|
198
199
|
|
|
199
200
|
const geometry = getOverlayGeometry(frame, box, camera);
|
|
200
201
|
const colliderFrames = getSelectedColliderFrames(previewSceneData, selectedActorIds, sprites);
|
|
202
|
+
// The collider inspector edits one shape of a single-selected actor; highlight
|
|
203
|
+
// that shape's box so you can see which one you're editing at a glance.
|
|
204
|
+
const editedColliderActor = selectedActorIds.length === 1 ? selectedActorIds[0] : null;
|
|
201
205
|
|
|
202
206
|
return (
|
|
203
207
|
<div ref={rootRef} className={styles.selOverlayRoot}>
|
|
@@ -211,24 +215,34 @@ export function SelectionOverlay({
|
|
|
211
215
|
<div
|
|
212
216
|
className={styles.selChromeWorld}
|
|
213
217
|
style={{ transform: `translate(${-geometry.camX}px, ${-geometry.camY}px)` }}>
|
|
214
|
-
{colliderFrames.map((collider, i) =>
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
collider.
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
218
|
+
{colliderFrames.map((collider, i) => {
|
|
219
|
+
const highlighted =
|
|
220
|
+
collider.actorId === editedColliderActor && collider.shapeIndex === selectedColliderShape;
|
|
221
|
+
return (
|
|
222
|
+
<div
|
|
223
|
+
key={`${collider.actorId}:${i}`}
|
|
224
|
+
className={cx(
|
|
225
|
+
styles.selColliderBox,
|
|
226
|
+
collider.isTrigger && styles.selColliderPickup
|
|
227
|
+
)}
|
|
228
|
+
style={{
|
|
229
|
+
left: collider.x,
|
|
230
|
+
top: collider.y,
|
|
231
|
+
width: collider.width,
|
|
232
|
+
height: collider.height,
|
|
233
|
+
borderRadius: collider.shape === 'circle' ? '50%' : undefined,
|
|
234
|
+
transformOrigin: '0 0',
|
|
235
|
+
transform: colliderTransform(collider),
|
|
236
|
+
// The shape being edited: a bright border + tint, lifted above
|
|
237
|
+
// the others so an overlapping (e.g. skateboard) shape reads clearly.
|
|
238
|
+
borderColor: highlighted ? '#ffd24a' : undefined,
|
|
239
|
+
borderWidth: highlighted ? 3 : undefined,
|
|
240
|
+
background: highlighted ? 'rgba(255, 210, 74, 0.14)' : undefined,
|
|
241
|
+
zIndex: highlighted ? 1 : 0,
|
|
242
|
+
}}
|
|
243
|
+
/>
|
|
244
|
+
);
|
|
245
|
+
})}
|
|
232
246
|
</div>
|
|
233
247
|
</div>
|
|
234
248
|
|
|
@@ -456,6 +470,19 @@ function getActionAnchor({ centerX, centerY, halfH, rotateAnchor }) {
|
|
|
456
470
|
};
|
|
457
471
|
}
|
|
458
472
|
|
|
473
|
+
// Compose the collider box's on-screen transform (origin = box top-left): rotate
|
|
474
|
+
// by the shape's own `angle` about its center, then by the actor's Layout.rotation
|
|
475
|
+
// about the actor center. Both pivots explicit since they differ.
|
|
476
|
+
function colliderTransform(c) {
|
|
477
|
+
const ax = c.originX;
|
|
478
|
+
const ay = c.originY;
|
|
479
|
+
const layout = `translate(${ax}px, ${ay}px) rotate(${c.rotation}deg) translate(${-ax}px, ${-ay}px)`;
|
|
480
|
+
if (!c.shapeAngle) return layout;
|
|
481
|
+
const cx = c.width / 2;
|
|
482
|
+
const cy = c.height / 2;
|
|
483
|
+
return `${layout} translate(${cx}px, ${cy}px) rotate(${c.shapeAngle}deg) translate(${-cx}px, ${-cy}px)`;
|
|
484
|
+
}
|
|
485
|
+
|
|
459
486
|
function getSelectedColliderFrames(sceneData, actorIds, sprites) {
|
|
460
487
|
if (!sceneData || !actorIds || actorIds.length === 0) return [];
|
|
461
488
|
const wanted = new Set(actorIds);
|
|
@@ -470,7 +497,7 @@ function getSelectedColliderFrames(sceneData, actorIds, sprites) {
|
|
|
470
497
|
// One preview box per collider shape, from the shared geometry so the
|
|
471
498
|
// preview matches the physics body. A circle renders as a radius-sized
|
|
472
499
|
// square with border-radius; a triangle/polygon as its AABB (v1).
|
|
473
|
-
return shapes.map((s) => {
|
|
500
|
+
return shapes.map((s, shapeIndex) => {
|
|
474
501
|
let kind = 'box';
|
|
475
502
|
let x;
|
|
476
503
|
let y;
|
|
@@ -505,6 +532,7 @@ function getSelectedColliderFrames(sceneData, actorIds, sprites) {
|
|
|
505
532
|
}
|
|
506
533
|
return {
|
|
507
534
|
actorId: actor.id,
|
|
535
|
+
shapeIndex,
|
|
508
536
|
shape: kind,
|
|
509
537
|
isTrigger,
|
|
510
538
|
x,
|
|
@@ -512,6 +540,7 @@ function getSelectedColliderFrames(sceneData, actorIds, sprites) {
|
|
|
512
540
|
width,
|
|
513
541
|
height,
|
|
514
542
|
rotation: layout.rotation ?? 0,
|
|
543
|
+
shapeAngle: s.type === 'box' ? s.angle ?? 0 : 0,
|
|
515
544
|
originX: layout.x + layout.width / 2 - x,
|
|
516
545
|
originY: layout.y + layout.height / 2 - y,
|
|
517
546
|
};
|
|
@@ -3,9 +3,15 @@
|
|
|
3
3
|
// picked up on the next reload/restart. The physics glob is what lets the
|
|
4
4
|
// self-contained physics module ship its behaviors without polluting the core
|
|
5
5
|
// `behaviors/` folder — a kit adopts physics by copying `physics/` in.
|
|
6
|
+
// Root-anchored so the kit finds the DECK's behaviors even when the kit is
|
|
7
|
+
// itself an import (see engine/files.js). Imports are swept first and the deck's
|
|
8
|
+
// own behaviors last: collectBehaviors keys by behaviorName, so a behavior the
|
|
9
|
+
// deck defines wins over one of the same name from a dependency.
|
|
6
10
|
const modules = {
|
|
7
|
-
...import.meta.glob('
|
|
8
|
-
...import.meta.glob('
|
|
11
|
+
...import.meta.glob('/imports/*/behaviors/*.jsx', { eager: true }),
|
|
12
|
+
...import.meta.glob('/imports/*/physics/behaviors/*.jsx', { eager: true }),
|
|
13
|
+
...import.meta.glob('/behaviors/*.jsx', { eager: true }),
|
|
14
|
+
...import.meta.glob('/physics/behaviors/*.jsx', { eager: true }),
|
|
9
15
|
};
|
|
10
16
|
function isBehaviorClass(value) {
|
|
11
17
|
return typeof value === 'function' && typeof value.behaviorName === 'string';
|