castle-web-cli 0.4.91 → 0.4.92

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.
@@ -797,7 +797,9 @@ function useSelectionGesture(args) {
797
797
  const point = { x: raw.x + cam.x, y: raw.y + cam.y };
798
798
  current.canvasRef.current.setPointerCapture(event.pointerId);
799
799
  const scene = makeScene(current.sceneData, behaviorClasses, current.sprites, current.files);
800
- const actor = scene.actorAt(point.x, point.y);
800
+ const stack = scene.actorsAt(point.x, point.y);
801
+ const stackIds = stack.map((a) => a.id);
802
+ const actor = stack[0] ?? null;
801
803
  const drag = {
802
804
  pointerId: event.pointerId,
803
805
  startPoint: point,
@@ -820,7 +822,7 @@ function useSelectionGesture(args) {
820
822
  } else if (current.multiSelectMode) {
821
823
  handleModePointerDown(drag, actor, current);
822
824
  } else {
823
- handleDefaultPointerDown(drag, actor, point, current);
825
+ handleDefaultPointerDown(drag, actor, point, current, stackIds);
824
826
  }
825
827
  drag.moveStarts = collectMoveStarts(current.sceneData, drag.movingActorIds);
826
828
  dragRef.current = drag;
@@ -891,6 +893,13 @@ function useSelectionGesture(args) {
891
893
  finalizeMarquee(drag, current);
892
894
  } else if (drag.kind === 'idle' && !drag.movedFar && !drag.longPressFired) {
893
895
  handleTap(drag, current);
896
+ } else if (
897
+ drag.kind === 'move' &&
898
+ !drag.movedFar &&
899
+ !drag.longPressFired &&
900
+ drag.cycleStack
901
+ ) {
902
+ cycleSelection(drag, current);
894
903
  }
895
904
  current.marqueeRef.current = null;
896
905
  dragRef.current = null;
@@ -1198,13 +1207,23 @@ function handleModePointerDown(drag, actor, current) {
1198
1207
  drag.pendingMarquee = true;
1199
1208
  }
1200
1209
  }
1201
- function handleDefaultPointerDown(drag, actor, point, current) {
1210
+ function handleDefaultPointerDown(drag, actor, point, current, stackIds) {
1202
1211
  if (actor) {
1203
- if (!current.selectedActorIds.includes(actor.id)) {
1212
+ const sel = current.selectedActorIds;
1213
+ if (sel.length === 1 && stackIds.length > 1 && stackIds.includes(sel[0])) {
1214
+ // Overlapping pile with a single selected actor under the cursor: keep it
1215
+ // selected so it stays draggable, and arm click-to-cycle so a stationary
1216
+ // click descends to the next actor beneath it (see cycleSelection).
1217
+ drag.movingActorIds = [...sel];
1218
+ drag.cycleStack = stackIds;
1219
+ } else if (sel.includes(actor.id)) {
1220
+ // Pressed an actor that's part of the current selection: keep the
1221
+ // selection so the whole group stays draggable.
1222
+ drag.movingActorIds = [...sel];
1223
+ } else {
1224
+ // Fresh pick: select the topmost actor under the cursor.
1204
1225
  current.onSelectActorIds([actor.id]);
1205
1226
  drag.movingActorIds = [actor.id];
1206
- } else {
1207
- drag.movingActorIds = [...current.selectedActorIds];
1208
1227
  }
1209
1228
  drag.kind = 'move';
1210
1229
  drag.longPressTimer = window.setTimeout(() => {
@@ -1235,6 +1254,20 @@ function finalizeMarquee(drag, current) {
1235
1254
  for (const id of hits) merged.add(id);
1236
1255
  current.onSelectActorIds([...merged]);
1237
1256
  }
1257
+ // A stationary click on a pile of overlapping actors advances the selection to
1258
+ // the next actor below the currently selected one, wrapping around at the
1259
+ // bottom. This lets repeated clicks in the same spot reach an actor buried under
1260
+ // others that would otherwise always win the topmost hit-test.
1261
+ function cycleSelection(drag, current) {
1262
+ const stack = drag.cycleStack;
1263
+ if (!stack || stack.length < 2) return;
1264
+ const sel = current.selectedActorIds;
1265
+ if (sel.length !== 1) return;
1266
+ const idx = stack.indexOf(sel[0]);
1267
+ if (idx === -1) return;
1268
+ const nextId = stack[(idx + 1) % stack.length];
1269
+ if (nextId !== sel[0]) current.onSelectActorIds([nextId]);
1270
+ }
1238
1271
  function handleTap(drag, current) {
1239
1272
  if (!drag.modeAtStart) return;
1240
1273
  if (drag.startedOnActorId !== null) {
@@ -281,7 +281,15 @@ export class SceneRuntime {
281
281
  }
282
282
 
283
283
  actorAt(x, y) {
284
+ return this.actorsAt(x, y)[0] ?? null;
285
+ }
286
+
287
+ // All actors whose Layout box contains the point, ordered topmost-first (high
288
+ // z -> low z). Used by the editor's click-to-cycle so repeated clicks in the
289
+ // same spot can walk down through overlapping actors.
290
+ actorsAt(x, y) {
284
291
  const actors = this.getActors().slice().reverse();
292
+ const hits = [];
285
293
  for (const actor of actors) {
286
294
  const layout = getLayout(actor);
287
295
  if (!layout) continue;
@@ -291,10 +299,10 @@ export class SceneRuntime {
291
299
  y >= layout.y &&
292
300
  y <= layout.y + layout.height
293
301
  ) {
294
- return actor;
302
+ hits.push(actor);
295
303
  }
296
304
  }
297
- return null;
305
+ return hits;
298
306
  }
299
307
 
300
308
  actorIdsInRect(rect) {
@@ -1,46 +1,23 @@
1
1
  import React, { useState } from 'react';
2
- import { Icon, NumberField, Panel, SelectField } from '../engine/ui';
3
- import { AutoFields, overrideProps } from '../engine/autoInspector';
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,
2
+ import { NumberField, Panel, SelectField } from '../engine/ui';
3
+ import { AutoFields } from '../engine/autoInspector';
4
+ import { computeAutoFit, getColliderRect, getColliderShapes, intersects } from '../engine/collider';
5
+
6
+ // --- inspector styles ------------------------------------------------------
7
+ const rowGap = { display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 6, marginBottom: 10 };
8
+ const chip = (active) => ({
9
+ padding: '3px 9px',
10
+ borderRadius: 6,
11
+ border: `1px solid ${active ? '#4aa3ff' : 'var(--castle-inspector-divider)'}`,
12
+ background: active ? 'rgba(74,163,255,0.15)' : 'transparent',
27
13
  color: 'var(--castle-inspector-text)',
28
14
  fontFamily: 'inherit',
29
- fontSize: 14,
15
+ fontSize: 12,
30
16
  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 = {
17
+ });
18
+ const addBtn = { ...chip(false), fontWeight: 700, padding: '3px 8px' };
19
+ const addMenu = { display: 'flex', flexWrap: 'wrap', gap: 6, marginBottom: 10 };
20
+ const linkBtn = {
44
21
  background: 'none',
45
22
  border: 'none',
46
23
  padding: 0,
@@ -49,156 +26,218 @@ const autoFitLinkStyle = {
49
26
  fontSize: 13,
50
27
  cursor: 'pointer',
51
28
  };
52
- const dimBodyStyle = { paddingLeft: 14 };
29
+ const deleteBtn = { ...linkBtn, color: '#ff7a7a' };
30
+ const actionRow = { display: 'flex', justifyContent: 'space-between', alignItems: 'center', margin: '4px 0 12px' };
31
+ const noteStyle = { fontSize: 12, color: 'var(--castle-inspector-muted, #888)', margin: '2px 0 10px' };
32
+
33
+ // --- shape helpers ---------------------------------------------------------
34
+ const FULL_BOX = { type: 'box', x: 0.5, y: 0.5, w: 1, h: 1 };
35
+ // Sensible defaults (box-fractions) for each primitive the Add menu offers.
36
+ const SHAPE_DEFAULTS = {
37
+ box: { type: 'box', x: 0.5, y: 0.5, w: 0.6, h: 0.6 },
38
+ circle: { type: 'circle', x: 0.5, y: 0.5, r: 0.3 },
39
+ triangle: { type: 'triangle', points: [{ x: 0.5, y: 0.15 }, { x: 0.15, y: 0.85 }, { x: 0.85, y: 0.85 }] },
40
+ polygon: {
41
+ type: 'polygon',
42
+ points: [{ x: 0.5, y: 0.12 }, { x: 0.14, y: 0.42 }, { x: 0.3, y: 0.85 }, { x: 0.7, y: 0.85 }, { x: 0.86, y: 0.42 }],
43
+ },
44
+ };
45
+
46
+ // The collider's shapes, materialized (default = one full box).
47
+ function currentShapes(component) {
48
+ return Array.isArray(component.shapes) && component.shapes.length > 0 ? component.shapes : [FULL_BOX];
49
+ }
50
+
51
+ // Draw one resolved (world-space) collider shape as an outline.
52
+ function drawShape(ctx, s) {
53
+ if (s.type === 'circle') {
54
+ ctx.beginPath();
55
+ ctx.arc(s.cx, s.cy, s.radius, 0, Math.PI * 2);
56
+ ctx.stroke();
57
+ } else if (s.type === 'triangle' || s.type === 'polygon') {
58
+ if (!s.points || s.points.length < 2) return;
59
+ ctx.beginPath();
60
+ ctx.moveTo(s.points[0].x, s.points[0].y);
61
+ for (let k = 1; k < s.points.length; k++) ctx.lineTo(s.points[k].x, s.points[k].y);
62
+ ctx.closePath();
63
+ ctx.stroke();
64
+ } else {
65
+ ctx.strokeRect(s.x + 1, s.y + 1, s.width - 2, s.height - 2);
66
+ }
67
+ }
53
68
 
54
69
  export class Collider {
55
70
  static behaviorName = 'Collider';
56
71
 
72
+ // A Collider is a LIST of shapes (`shapes`), each a fraction of the Layout box
73
+ // -- so it keeps its relative size when the box is resized. Unset `shapes` = one
74
+ // full-box shape. Only material fields live here; geometry is authored into
75
+ // `shapes` through the inspector.
57
76
  static defaultProps = {
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,
64
- offsetX: 0,
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.
77
+ // `isTrigger` marks a sensor: reports overlaps (draws yellow, pass-through
78
+ // zone for pickup/goal logic) but doesn't block -- collisions are data.
73
79
  isTrigger: false,
74
80
  debug: false,
75
- ...extensionDefaultProps('Collider'),
81
+ // Physics material, read by physics/matterBridge.js. `bounciness` =
82
+ // restitution (0 dead .. ~1 very bouncy); `friction` = surface friction;
83
+ // `density` sets mass (= density x total shape area); matter's default 0.001.
84
+ bounciness: 0,
85
+ friction: 0.1,
86
+ // `frictionStatic` is stiction -- resistance to *starting* to slide (matter
87
+ // default 0.5). `density` sets mass (= density x total shape area).
88
+ frictionStatic: 0.5,
89
+ density: 0.001,
76
90
  };
77
91
 
78
92
  constructor(props) {
79
93
  this.props = props;
80
94
  }
81
95
 
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).
96
+ // Seed props when first added: fit a single box to the sprite's opaque bounds
97
+ // (or the full box when there's nothing to fit to).
88
98
  static initialProps(actor, ctx) {
89
99
  const fit = computeAutoFit(actor, ctx?.sprites);
90
- return fit ? { ...Collider.defaultProps, ...fit } : { ...Collider.defaultProps };
100
+ return fit ? { ...Collider.defaultProps, shapes: [fit] } : { ...Collider.defaultProps };
91
101
  }
92
102
 
93
103
  draw(actor, scene, ctx, options) {
94
- if (!options.showDebugColliders && !this.props.debug) return;
95
- const geom = getColliderShape(actor, scene.sprites);
96
- if (!geom) return;
97
- // A sensor (isTrigger) reads as a pass-through zone; a solid as a wall.
104
+ // A selected instance always shows its collider -- but the editor's selection
105
+ // overlay draws it, so skip here to avoid a double outline.
106
+ if (options.colliderOverlayIds?.includes(actor.id)) return;
107
+ // Otherwise draw when this actor's blueprint is selected in the hotbar (so
108
+ // all its instances light up), or when the per-collider debug flag is on
109
+ // (unselected actors in the editor, and during play).
110
+ const blueprintSelected =
111
+ Boolean(options.dimBlueprintPath) && actor.blueprint === options.dimBlueprintPath;
112
+ if (!blueprintSelected && !options.showDebugColliders && !this.props.debug) return;
113
+ const shapes = getColliderShapes(actor);
114
+ if (!shapes) return;
98
115
  const isSensor = Boolean(this.props.isTrigger) || this.props.kind === 'pickup';
99
116
  ctx.save();
100
117
  ctx.strokeStyle = isSensor ? '#ffe17a' : '#8db7ff';
101
118
  ctx.lineWidth = 2;
102
- if (geom.shape === 'circle') {
103
- ctx.beginPath();
104
- ctx.arc(geom.cx, geom.cy, geom.radius, 0, Math.PI * 2);
105
- ctx.stroke();
106
- } else {
107
- ctx.strokeRect(geom.x + 1, geom.y + 1, geom.width - 2, geom.height - 2);
108
- }
119
+ for (const s of shapes) drawShape(ctx, s);
109
120
  ctx.restore();
110
121
  }
111
122
 
112
123
  static Inspector({ actor, component, sprites, setComponent, override }) {
113
- const [dimOpen, setDimOpen] = useState(true);
124
+ const [sel, setSel] = useState(0);
125
+ const [addOpen, setAddOpen] = useState(false);
114
126
  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;
127
+ const bw = layout.width || 1;
128
+ const bh = layout.height || 1;
129
+
130
+ const shapes = currentShapes(component);
131
+ const i = Math.min(sel, shapes.length - 1);
132
+ const shape = shapes[i];
133
+ const isCircle = shape.type === 'circle';
134
+ const isPoly = shape.type === 'triangle' || shape.type === 'polygon';
135
+
136
+ const writeShapes = (next) => setComponent({ shapes: next });
137
+ const patchSel = (patch) => writeShapes(shapes.map((s, k) => (k === i ? { ...s, ...patch } : s)));
138
+ const addShape = (type) => {
139
+ writeShapes([...shapes, { ...SHAPE_DEFAULTS[type] }]);
140
+ setSel(shapes.length);
141
+ setAddOpen(false);
142
+ };
143
+ const removeSel = () => {
144
+ const next = shapes.filter((_, k) => k !== i);
145
+ writeShapes(next.length ? next : [FULL_BOX]);
146
+ setSel(Math.max(0, i - 1));
137
147
  };
138
- const alreadyFitted =
139
- autoFitPatch && Object.entries(autoFitPatch).every(([key, value]) => Math.abs(currentValue(key) - value) < 0.6);
148
+ const setType = (type) => {
149
+ if (type === 'circle') {
150
+ patchSel({ type: 'circle', x: shape.x ?? 0.5, y: shape.y ?? 0.5, r: Math.min(shape.w ?? 1, shape.h ?? 1) / 2, w: undefined, h: undefined });
151
+ } else {
152
+ const d = (shape.r ?? 0.5) * 2;
153
+ patchSel({ type: 'box', x: shape.x ?? 0.5, y: shape.y ?? 0.5, w: d, h: d, r: undefined });
154
+ }
155
+ };
156
+
157
+ const widthPx = Math.round((shape.w ?? 1) * bw);
158
+ const heightPx = Math.round((shape.h ?? 1) * bh);
159
+ const radiusPx = Math.round((shape.r ?? 0.5) * Math.min(bw, bh));
160
+ const offXPx = Math.round(((shape.x ?? 0.5) - 0.5) * bw);
161
+ const offYPx = Math.round(((shape.y ?? 0.5) - 0.5) * bh);
162
+
163
+ const density = component.density > 0 ? component.density : 0.001;
164
+
165
+ // Auto-fit (box/circle) for the selected shape.
166
+ const autoFit = isPoly ? null : computeAutoFit(actor, sprites, isCircle);
167
+ const near = (a, b) => Math.abs((a ?? 0) - (b ?? 0)) < 0.01;
168
+ const fitted =
169
+ autoFit &&
170
+ near(autoFit.x, shape.x) &&
171
+ near(autoFit.y, shape.y) &&
172
+ (isCircle ? near(autoFit.r, shape.r) : near(autoFit.w, shape.w) && near(autoFit.h, shape.h));
140
173
 
141
174
  return (
142
175
  <Panel title="Collider" overridden={override?.anyOverridden()}>
143
- <SelectField
144
- label="Shape"
145
- value={component.shape}
146
- onChange={(value) => setComponent({ shape: value })}
147
- options={['box', 'circle']}
148
- {...overrideProps(override, 'shape')}
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
176
+ {/* Shape list -- one chip per shape + an Add menu (compound is manual). */}
177
+ <div style={rowGap}>
178
+ {shapes.map((s, k) => (
179
+ <button key={k} type="button" onClick={() => setSel(k)} style={chip(k === i)}>
180
+ {s.type} {k + 1}
160
181
  </button>
161
- ) : null}
182
+ ))}
183
+ <button type="button" onClick={() => setAddOpen((o) => !o)} style={addBtn} title="Add a shape">
184
+ +
185
+ </button>
162
186
  </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
- />
187
+ {addOpen ? (
188
+ <div style={addMenu}>
189
+ {/* triangle/polygon are supported by the data model + runtime but not
190
+ offered in the UI yet (no on-canvas point editing). */}
191
+ {['box', 'circle'].map((t) => (
192
+ <button key={t} type="button" onClick={() => addShape(t)} style={chip(false)}>
193
+ {t}
194
+ </button>
195
+ ))}
196
+ </div>
197
+ ) : null}
198
+
199
+ {/* Selected shape */}
200
+ {isPoly ? (
201
+ <div style={noteStyle}>
202
+ {shape.type} · {shape.points?.length ?? 0} points (on-canvas point editing coming soon)
203
+ </div>
204
+ ) : (
205
+ <>
206
+ <SelectField label="Shape" value={shape.type} onChange={setType} options={['box', 'circle']} />
207
+ {isCircle ? (
208
+ <NumberField label="Radius" value={radiusPx} onChange={(v) => patchSel({ r: v / Math.min(bw, bh) })} />
172
209
  ) : (
173
210
  <>
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
- />
211
+ <NumberField label="Width" value={widthPx} onChange={(v) => patchSel({ w: v / bw })} />
212
+ <NumberField label="Height" value={heightPx} onChange={(v) => patchSel({ h: v / bh })} />
186
213
  </>
187
214
  )}
188
- <AutoFields
189
- defaultProps={Collider.defaultProps}
190
- component={component}
191
- setComponent={setComponent}
192
- only={['offsetX', 'offsetY']}
193
- override={override}
194
- />
195
- </div>
196
- ) : null}
215
+ </>
216
+ )}
217
+ <NumberField label="Offset X" value={offXPx} onChange={(v) => patchSel({ x: 0.5 + v / bw })} />
218
+ <NumberField label="Offset Y" value={offYPx} onChange={(v) => patchSel({ y: 0.5 + v / bh })} />
219
+ <div style={actionRow}>
220
+ {autoFit && !fitted ? (
221
+ <button type="button" onClick={() => writeShapes(shapes.map((s, k) => (k === i ? autoFit : s)))} style={linkBtn}>
222
+ Auto-fit to sprite
223
+ </button>
224
+ ) : (
225
+ <span />
226
+ )}
227
+ {shapes.length > 1 ? (
228
+ <button type="button" onClick={removeSel} style={deleteBtn}>
229
+ Delete shape
230
+ </button>
231
+ ) : null}
232
+ </div>
233
+
234
+ {/* Material (collider-wide) */}
235
+ <NumberField label="Density" value={density} onChange={(v) => setComponent({ density: v })} />
197
236
  <AutoFields
198
237
  defaultProps={Collider.defaultProps}
199
238
  component={component}
200
239
  setComponent={setComponent}
201
- exclude={['shape', 'width', 'height', 'radius', 'offsetX', 'offsetY']}
240
+ exclude={['density']}
202
241
  override={override}
203
242
  />
204
243
  </Panel>
@@ -206,8 +245,6 @@ export class Collider {
206
245
  }
207
246
  }
208
247
 
209
- // Re-exported for callers that need the rect / overlap test outside a running
210
- // scene (e.g. editors/SelectionOverlay.jsx, which passes the merged preview
211
- // actors plus the sprites map). See engine/collider.js for the single
212
- // implementation.
213
- export { getColliderRect, getColliderShape, intersects };
248
+ // Re-exported for callers that need the rect / shapes / overlap test outside a
249
+ // running scene (e.g. editors/SelectionOverlay.jsx). See engine/collider.js.
250
+ export { getColliderRect, getColliderShapes, intersects };
@@ -797,7 +797,9 @@ function useSelectionGesture(args) {
797
797
  const point = { x: raw.x + cam.x, y: raw.y + cam.y };
798
798
  current.canvasRef.current.setPointerCapture(event.pointerId);
799
799
  const scene = makeScene(current.sceneData, behaviorClasses, current.sprites, current.files);
800
- const actor = scene.actorAt(point.x, point.y);
800
+ const stack = scene.actorsAt(point.x, point.y);
801
+ const stackIds = stack.map((a) => a.id);
802
+ const actor = stack[0] ?? null;
801
803
  const drag = {
802
804
  pointerId: event.pointerId,
803
805
  startPoint: point,
@@ -820,7 +822,7 @@ function useSelectionGesture(args) {
820
822
  } else if (current.multiSelectMode) {
821
823
  handleModePointerDown(drag, actor, current);
822
824
  } else {
823
- handleDefaultPointerDown(drag, actor, point, current);
825
+ handleDefaultPointerDown(drag, actor, point, current, stackIds);
824
826
  }
825
827
  drag.moveStarts = collectMoveStarts(current.sceneData, drag.movingActorIds);
826
828
  dragRef.current = drag;
@@ -891,6 +893,13 @@ function useSelectionGesture(args) {
891
893
  finalizeMarquee(drag, current);
892
894
  } else if (drag.kind === 'idle' && !drag.movedFar && !drag.longPressFired) {
893
895
  handleTap(drag, current);
896
+ } else if (
897
+ drag.kind === 'move' &&
898
+ !drag.movedFar &&
899
+ !drag.longPressFired &&
900
+ drag.cycleStack
901
+ ) {
902
+ cycleSelection(drag, current);
894
903
  }
895
904
  current.marqueeRef.current = null;
896
905
  dragRef.current = null;
@@ -1198,13 +1207,23 @@ function handleModePointerDown(drag, actor, current) {
1198
1207
  drag.pendingMarquee = true;
1199
1208
  }
1200
1209
  }
1201
- function handleDefaultPointerDown(drag, actor, point, current) {
1210
+ function handleDefaultPointerDown(drag, actor, point, current, stackIds) {
1202
1211
  if (actor) {
1203
- if (!current.selectedActorIds.includes(actor.id)) {
1212
+ const sel = current.selectedActorIds;
1213
+ if (sel.length === 1 && stackIds.length > 1 && stackIds.includes(sel[0])) {
1214
+ // Overlapping pile with a single selected actor under the cursor: keep it
1215
+ // selected so it stays draggable, and arm click-to-cycle so a stationary
1216
+ // click descends to the next actor beneath it (see cycleSelection).
1217
+ drag.movingActorIds = [...sel];
1218
+ drag.cycleStack = stackIds;
1219
+ } else if (sel.includes(actor.id)) {
1220
+ // Pressed an actor that's part of the current selection: keep the
1221
+ // selection so the whole group stays draggable.
1222
+ drag.movingActorIds = [...sel];
1223
+ } else {
1224
+ // Fresh pick: select the topmost actor under the cursor.
1204
1225
  current.onSelectActorIds([actor.id]);
1205
1226
  drag.movingActorIds = [actor.id];
1206
- } else {
1207
- drag.movingActorIds = [...current.selectedActorIds];
1208
1227
  }
1209
1228
  drag.kind = 'move';
1210
1229
  drag.longPressTimer = window.setTimeout(() => {
@@ -1235,6 +1254,20 @@ function finalizeMarquee(drag, current) {
1235
1254
  for (const id of hits) merged.add(id);
1236
1255
  current.onSelectActorIds([...merged]);
1237
1256
  }
1257
+ // A stationary click on a pile of overlapping actors advances the selection to
1258
+ // the next actor below the currently selected one, wrapping around at the
1259
+ // bottom. This lets repeated clicks in the same spot reach an actor buried under
1260
+ // others that would otherwise always win the topmost hit-test.
1261
+ function cycleSelection(drag, current) {
1262
+ const stack = drag.cycleStack;
1263
+ if (!stack || stack.length < 2) return;
1264
+ const sel = current.selectedActorIds;
1265
+ if (sel.length !== 1) return;
1266
+ const idx = stack.indexOf(sel[0]);
1267
+ if (idx === -1) return;
1268
+ const nextId = stack[(idx + 1) % stack.length];
1269
+ if (nextId !== sel[0]) current.onSelectActorIds([nextId]);
1270
+ }
1238
1271
  function handleTap(drag, current) {
1239
1272
  if (!drag.modeAtStart) return;
1240
1273
  if (drag.startedOnActorId !== null) {
@@ -1357,6 +1390,9 @@ function useScenePlayLoop({
1357
1390
  showGrid: !isPlaying && snap.enabled && editSelectedActorIds.length > 0,
1358
1391
  gridSize: snap.gridSize,
1359
1392
  showDebugColliders: false,
1393
+ // Selected actors' colliders are drawn by the selection overlay; tell
1394
+ // Collider.draw to skip them so a selected+debug collider isn't doubled.
1395
+ colliderOverlayIds: editSelectedActorIds,
1360
1396
  showCropOutline: !isPlaying,
1361
1397
  viewport: frameViewport,
1362
1398
  useCamera: true,
@@ -1,5 +1,5 @@
1
1
  import React, { useCallback, useEffect, useRef, useState } from 'react';
2
- import { getColliderShape } from '../behaviors/Collider';
2
+ import { getColliderShapes } from '../behaviors/Collider';
3
3
  import { cardSize, screenToCard } from '../engine/scene';
4
4
  import { cx, Icon, styles, useElementSize } from '../engine/ui';
5
5
 
@@ -211,9 +211,9 @@ export function SelectionOverlay({
211
211
  <div
212
212
  className={styles.selChromeWorld}
213
213
  style={{ transform: `translate(${-geometry.camX}px, ${-geometry.camY}px)` }}>
214
- {colliderFrames.map((collider) => (
214
+ {colliderFrames.map((collider, i) => (
215
215
  <div
216
- key={collider.actorId}
216
+ key={`${collider.actorId}:${i}`}
217
217
  className={cx(
218
218
  styles.selColliderBox,
219
219
  collider.isTrigger && styles.selColliderPickup
@@ -461,33 +461,62 @@ function getSelectedColliderFrames(sceneData, actorIds, sprites) {
461
461
  const wanted = new Set(actorIds);
462
462
  return sceneData.actors
463
463
  .filter((actor) => wanted.has(actor.id))
464
- .map((actor) => {
464
+ .flatMap((actor) => {
465
465
  const layout = actor.components.Layout;
466
466
  const collider = actor.components.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;
477
- return {
478
- actorId: actor.id,
479
- shape: geom.shape,
480
- isTrigger: Boolean(collider.isTrigger) || collider.kind === 'pickup',
481
- x,
482
- y,
483
- width,
484
- height,
485
- rotation: layout.rotation ?? 0,
486
- originX: layout.x + layout.width / 2 - x,
487
- originY: layout.y + layout.height / 2 - y,
488
- };
489
- })
490
- .filter(Boolean);
467
+ const shapes = getColliderShapes(actor);
468
+ if (!layout || !collider || !shapes) return [];
469
+ const isTrigger = Boolean(collider.isTrigger) || collider.kind === 'pickup';
470
+ // One preview box per collider shape, from the shared geometry so the
471
+ // preview matches the physics body. A circle renders as a radius-sized
472
+ // square with border-radius; a triangle/polygon as its AABB (v1).
473
+ return shapes.map((s) => {
474
+ let kind = 'box';
475
+ let x;
476
+ let y;
477
+ let width;
478
+ let height;
479
+ if (s.type === 'circle') {
480
+ kind = 'circle';
481
+ x = s.cx - s.radius;
482
+ y = s.cy - s.radius;
483
+ width = s.radius * 2;
484
+ height = s.radius * 2;
485
+ } else if (s.type === 'triangle' || s.type === 'polygon') {
486
+ let x0 = Infinity;
487
+ let y0 = Infinity;
488
+ let x1 = -Infinity;
489
+ let y1 = -Infinity;
490
+ for (const p of s.points) {
491
+ if (p.x < x0) x0 = p.x;
492
+ if (p.y < y0) y0 = p.y;
493
+ if (p.x > x1) x1 = p.x;
494
+ if (p.y > y1) y1 = p.y;
495
+ }
496
+ x = x0;
497
+ y = y0;
498
+ width = x1 - x0;
499
+ height = y1 - y0;
500
+ } else {
501
+ x = s.x;
502
+ y = s.y;
503
+ width = s.width;
504
+ height = s.height;
505
+ }
506
+ return {
507
+ actorId: actor.id,
508
+ shape: kind,
509
+ isTrigger,
510
+ x,
511
+ y,
512
+ width,
513
+ height,
514
+ rotation: layout.rotation ?? 0,
515
+ originX: layout.x + layout.width / 2 - x,
516
+ originY: layout.y + layout.height / 2 - y,
517
+ };
518
+ });
519
+ });
491
520
  }
492
521
 
493
522
  function getSharedRotation(layouts) {
@@ -1,21 +1,30 @@
1
- // Single source of truth for the Collider rect. Lives in engine/ (not
2
- // behaviors/) so SceneRuntime can use it directly, with behaviors/Collider.jsx
3
- // (its `draw`, and its `getColliderRect`/`intersects` re-exports) delegating
4
- // here instead of keeping a second, drifting copy.
1
+ // Single source of truth for Collider geometry. Lives in engine/ so SceneRuntime
2
+ // can use it directly; behaviors/Collider.jsx delegates here (its draw + the
3
+ // getColliderRect/intersects re-exports).
4
+ //
5
+ // A Collider is a LIST of shapes (`Collider.shapes`), each stored as a FRACTION
6
+ // of the actor's Layout box -- so a collider keeps its relative size and position
7
+ // when the box is resized (blueprint default OR per-instance override). Shapes:
8
+ // box: { type:'box', x, y, w, h } center + size, fractions of box
9
+ // circle: { type:'circle', x, y, r } center frac; r = frac of min(box side)
10
+ // triangle: { type:'triangle', points:[{x,y}×3] } fractions of box
11
+ // polygon: { type:'polygon', points:[{x,y}×N] }
12
+ // (x,y) are fractions of the box where (0,0)=top-left, (1,1)=bottom-right, so the
13
+ // box center is (0.5, 0.5); a circle radius `r` is a fraction of the box's SHORTER
14
+ // side (keeps circles round under non-uniform box scaling). Legacy single-shape
15
+ // colliders (shape/width/height/radius/offset[/mode]) normalize to one box shape.
16
+ //
17
+ // Shapes are in the UNROTATED box frame; the draw pipeline (scene.js) and the
18
+ // matter body (matterBridge) apply Layout.rotation, so nothing here rotates.
5
19
  import { frameCount, renderSpriteFrame } from './pxart';
6
20
  import { spriteDestRect } from './spriteGeometry';
7
21
 
8
- // Union bounding box (fraction of native resolution, 0..1) of every opaque
9
- // (alpha > 0) pixel across ALL animation frames of a sprite, or null when the
10
- // art is fully transparent. Unioning across frames keeps an animated sprite's
11
- // collider from pulsing frame to frame. Cached in a WeakMap keyed by the
12
- // sprite object -- a sprite edit produces a fresh object (same invalidation
13
- // lifecycle as Sprite.jsx's rendered-canvas cache), so a stale entry never
14
- // outlives the art it was computed from.
22
+ // ---------------------------------------------------------------------------
23
+ // Sprite opaque-bounds -- only used now for the "auto-fit to sprite" action and
24
+ // the empty-actor placeholder. Colliders no longer live-track the sprite.
25
+ // ---------------------------------------------------------------------------
15
26
  const opaqueBoundsCache = new WeakMap();
16
27
 
17
- // Expand `acc` (in pixel units) to cover every opaque pixel of the canvas's
18
- // current contents.
19
28
  function accumulateOpaquePixels(acc, ctx, canvas) {
20
29
  if (!ctx || canvas.width === 0 || canvas.height === 0) return;
21
30
  const { data } = ctx.getImageData(0, 0, canvas.width, canvas.height);
@@ -32,7 +41,6 @@ function accumulateOpaquePixels(acc, ctx, canvas) {
32
41
 
33
42
  function opaqueBoundsFraction(sprite) {
34
43
  if (opaqueBoundsCache.has(sprite)) return opaqueBoundsCache.get(sprite);
35
-
36
44
  const { width, height } = sprite.resolution;
37
45
  const canvas = document.createElement('canvas');
38
46
  const ctx = canvas.getContext('2d');
@@ -42,7 +50,6 @@ function opaqueBoundsFraction(sprite) {
42
50
  renderSpriteFrame(sprite, frame, canvas);
43
51
  accumulateOpaquePixels(acc, ctx, canvas);
44
52
  }
45
-
46
53
  const bounds =
47
54
  acc.minX <= acc.maxX && acc.minY <= acc.maxY && width > 0 && height > 0
48
55
  ? {
@@ -56,21 +63,12 @@ function opaqueBoundsFraction(sprite) {
56
63
  return bounds;
57
64
  }
58
65
 
59
- // True when a resolved sprite has no opaque pixels across any frame -- fully
60
- // transparent art (e.g. a brand-new blank drawing). The editor draws an
61
- // empty-actor placeholder for these so a blank actor isn't invisible. Shares
62
- // `opaqueBoundsFraction`'s per-sprite cache (a sprite edit yields a fresh
63
- // object, which invalidates it).
66
+ // True when a resolved sprite has no opaque pixels across any frame.
64
67
  export function spriteIsEmpty(sprite) {
65
68
  return !sprite || opaqueBoundsFraction(sprite) === null;
66
69
  }
67
70
 
68
- // The `mode: 'auto'` rect (before offsets), in world units, or null when auto
69
- // falls back to the full Layout box: no Sprite, an unresolved sprite file,
70
- // `Sprite.mode === 'tile'` (tiled art fills its cell by definition, so a
71
- // per-cell bbox doesn't compose), or fully transparent art. Deliberately does
72
- // NOT follow Sprite.jsx's FALLBACK_FILE placeholder-art fallback -- an
73
- // unresolvable file means the Layout box, period.
71
+ // The sprite's opaque-pixel rect in world units through the draw mode, or null.
74
72
  function autoRect(layout, spriteProps, sprite) {
75
73
  if (!sprite || spriteProps.mode === 'tile') return null;
76
74
  const bounds = opaqueBoundsFraction(sprite);
@@ -82,14 +80,10 @@ function autoRect(layout, spriteProps, sprite) {
82
80
  width: bounds.width * dest.width,
83
81
  height: bounds.height * dest.height,
84
82
  };
85
- // `cover`'s dest overflows the Layout box and the draw clips to it, so the
86
- // opaque-bounds rect can spill outside the actor -- clamp to the visible box.
87
- // A no-op for `stretch`/`fit`, whose dest already sits inside the box.
88
83
  if (spriteProps.mode === 'cover') return intersectRect(rect, layout);
89
84
  return rect;
90
85
  }
91
86
 
92
- // Rect intersection in world units; zero-size (never negative) when disjoint.
93
87
  function intersectRect(rect, box) {
94
88
  const x0 = Math.max(rect.x, box.x);
95
89
  const y0 = Math.max(rect.y, box.y);
@@ -98,101 +92,125 @@ function intersectRect(rect, box) {
98
92
  return { x: x0, y: y0, width: Math.max(0, x1 - x0), height: Math.max(0, y1 - y0) };
99
93
  }
100
94
 
101
- // `mode: 'manual'` rect (before offsets): `width`/`height`, centered in the
102
- // Layout box.
103
- function manualRect(layout, collider) {
104
- const width = collider.width ?? layout.width;
105
- const height = collider.height ?? layout.height;
106
- return {
107
- x: layout.x + (layout.width - width) / 2,
108
- y: layout.y + (layout.height - height) / 2,
109
- width,
110
- height,
111
- };
95
+ // ---------------------------------------------------------------------------
96
+ // Shape model
97
+ // ---------------------------------------------------------------------------
98
+ export const FULL_BOX_SHAPE = { type: 'box', x: 0.5, y: 0.5, w: 1, h: 1 };
99
+
100
+ // A collider's shapes as box-fraction shapes: the stored `shapes` list, or a
101
+ // single shape migrated from the retired single-shape fields.
102
+ export function colliderShapeFractions(collider, layout) {
103
+ if (Array.isArray(collider.shapes) && collider.shapes.length > 0) return collider.shapes;
104
+ return [legacyShapeFraction(collider, layout)];
112
105
  }
113
106
 
114
- const fullLayoutRect = (layout) => ({ x: layout.x, y: layout.y, width: layout.width, height: layout.height });
107
+ // Migrate retired single-shape fields (shape/width/height/radius/offset) to one
108
+ // box-fraction shape. `mode: 'auto'` decks lose the live sprite-fit (a one-time
109
+ // snapshot isn't available here) and fall back to the full box.
110
+ function legacyShapeFraction(collider, layout) {
111
+ const bw = layout?.width || 1;
112
+ const bh = layout?.height || 1;
113
+ const cx = 0.5 + (collider.offsetX ?? 0) / bw;
114
+ const cy = 0.5 + (collider.offsetY ?? 0) / bh;
115
+ if (collider.shape === 'circle') {
116
+ const r =
117
+ collider.radius > 0
118
+ ? collider.radius / Math.min(bw, bh)
119
+ : Math.min((collider.width ?? bw) / bw, (collider.height ?? bh) / bh) / 2;
120
+ return { type: 'circle', x: cx, y: cy, r };
121
+ }
122
+ return { type: 'box', x: cx, y: cy, w: (collider.width ?? bw) / bw, h: (collider.height ?? bh) / bh };
123
+ }
115
124
 
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.
120
- function modeRect(layout, collider, spriteProps, sprites) {
121
- if (collider.mode === 'auto') {
122
- const sprite = spriteProps ? sprites?.[spriteProps.file] : null;
123
- return autoRect(layout, spriteProps, sprite) ?? fullLayoutRect(layout);
125
+ // Resolve one box-fraction shape to world coordinates (unrotated box frame).
126
+ function resolveShape(shape, layout) {
127
+ const bx = layout.x;
128
+ const by = layout.y;
129
+ const bw = layout.width;
130
+ const bh = layout.height;
131
+ const wx = (fx) => bx + fx * bw;
132
+ const wy = (fy) => by + fy * bh;
133
+ if (shape.type === 'circle') {
134
+ return { type: 'circle', cx: wx(shape.x ?? 0.5), cy: wy(shape.y ?? 0.5), radius: (shape.r ?? 0.5) * Math.min(bw, bh) };
124
135
  }
125
- return manualRect(layout, collider);
136
+ if (shape.type === 'triangle' || shape.type === 'polygon') {
137
+ return { type: shape.type, points: (shape.points ?? []).map((p) => ({ x: wx(p.x), y: wy(p.y) })) };
138
+ }
139
+ const w = (shape.w ?? 1) * bw;
140
+ const h = (shape.h ?? 1) * bh;
141
+ const cx = wx(shape.x ?? 0.5);
142
+ const cy = wy(shape.y ?? 0.5);
143
+ return { type: 'box', cx, cy, width: w, height: h, x: cx - w / 2, y: cy - h / 2 };
126
144
  }
127
145
 
128
- // The Collider rect for an actor, from its Layout + Collider (+ Sprite, for
129
- // `mode: 'auto'`), or null if it lacks a Layout or Collider. `sprites` is the
130
- // scene's sprite map (file path -> parsed sprite); omit it (or pass a map
131
- // missing the actor's file) to force the Layout-box fallback, e.g. from a
132
- // caller that only has raw scene data and no loaded sprites.
133
- export function getColliderRect(actor, sprites) {
146
+ // World-space shapes for an actor's collider (list), or null if no Layout/Collider.
147
+ export function getColliderShapes(actor) {
134
148
  const layout = actor?.components?.Layout;
135
149
  const collider = actor?.components?.Collider;
136
150
  if (!layout || !collider) return null;
151
+ return colliderShapeFractions(collider, layout).map((s) => resolveShape(s, layout));
152
+ }
137
153
 
138
- const rect = modeRect(layout, collider, actor.components.Sprite, sprites);
139
- return {
140
- x: rect.x + (collider.offsetX ?? 0),
141
- y: rect.y + (collider.offsetY ?? 0),
142
- width: rect.width,
143
- height: rect.height,
144
- };
154
+ // Back-compat single-shape accessor (the first shape).
155
+ export function getColliderShape(actor) {
156
+ const shapes = getColliderShapes(actor);
157
+ return shapes && shapes.length ? shapes[0] : null;
145
158
  }
146
159
 
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
- };
160
+ function shapeAabb(s) {
161
+ if (s.type === 'circle') return [s.cx - s.radius, s.cy - s.radius, s.cx + s.radius, s.cy + s.radius];
162
+ if (s.type === 'triangle' || s.type === 'polygon') {
163
+ let x0 = Infinity;
164
+ let y0 = Infinity;
165
+ let x1 = -Infinity;
166
+ let y1 = -Infinity;
167
+ for (const p of s.points) {
168
+ if (p.x < x0) x0 = p.x;
169
+ if (p.y < y0) y0 = p.y;
170
+ if (p.x > x1) x1 = p.x;
171
+ if (p.y > y1) y1 = p.y;
172
+ }
173
+ return [x0, y0, x1, y1];
174
+ }
175
+ return [s.x, s.y, s.x + s.width, s.y + s.height];
170
176
  }
171
177
 
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) {
178
+ // AABB of the whole collider (union of all shapes), world units -- for
179
+ // scene.overlaps / camera / coarse queries. Second arg accepted for call-site
180
+ // compat (colliders no longer read sprites at resolve time).
181
+ export function getColliderRect(actor) {
182
+ const shapes = getColliderShapes(actor);
183
+ if (!shapes || shapes.length === 0) return null;
184
+ let minX = Infinity;
185
+ let minY = Infinity;
186
+ let maxX = -Infinity;
187
+ let maxY = -Infinity;
188
+ for (const s of shapes) {
189
+ const [x0, y0, x1, y1] = shapeAabb(s);
190
+ if (x0 < minX) minX = x0;
191
+ if (y0 < minY) minY = y0;
192
+ if (x1 > maxX) maxX = x1;
193
+ if (y1 > maxY) maxY = y1;
194
+ }
195
+ return { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
196
+ }
197
+
198
+ // A single box-fraction shape matching the sprite's opaque bounds through the
199
+ // draw mode, or null when there's nothing to fit to. `asCircle` returns a circle
200
+ // that encloses those bounds. Powers the inspector's "Auto-fit to sprite".
201
+ export function computeAutoFit(actor, sprites, asCircle = false) {
177
202
  const rawLayout = actor?.components?.Layout;
178
203
  const spriteProps = actor?.components?.Sprite;
179
204
  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
205
  const layout = { ...rawLayout, x: rawLayout.x ?? 0, y: rawLayout.y ?? 0 };
184
206
  const rect = autoRect(layout, spriteProps, sprites?.[spriteProps.file]);
185
207
  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
- };
208
+ const x = (rect.x + rect.width / 2 - layout.x) / layout.width;
209
+ const y = (rect.y + rect.height / 2 - layout.y) / layout.height;
210
+ const w = rect.width / layout.width;
211
+ const h = rect.height / layout.height;
212
+ if (asCircle) return { type: 'circle', x, y, r: Math.max(w, h) / 2 };
213
+ return { type: 'box', x, y, w, h };
196
214
  }
197
215
 
198
216
  export function intersects(a, b) {
@@ -281,7 +281,15 @@ export class SceneRuntime {
281
281
  }
282
282
 
283
283
  actorAt(x, y) {
284
+ return this.actorsAt(x, y)[0] ?? null;
285
+ }
286
+
287
+ // All actors whose Layout box contains the point, ordered topmost-first (high
288
+ // z -> low z). Used by the editor's click-to-cycle so repeated clicks in the
289
+ // same spot can walk down through overlapping actors.
290
+ actorsAt(x, y) {
284
291
  const actors = this.getActors().slice().reverse();
292
+ const hits = [];
285
293
  for (const actor of actors) {
286
294
  const layout = getLayout(actor);
287
295
  if (!layout) continue;
@@ -291,10 +299,10 @@ export class SceneRuntime {
291
299
  y >= layout.y &&
292
300
  y <= layout.y + layout.height
293
301
  ) {
294
- return actor;
302
+ hits.push(actor);
295
303
  }
296
304
  }
297
- return null;
305
+ return hits;
298
306
  }
299
307
 
300
308
  actorIdsInRect(rect) {
@@ -26,6 +26,9 @@ export class RigidBody {
26
26
  freezeRotation: false,
27
27
  velocityX: 0,
28
28
  velocityY: 0,
29
+ // Initial spin, degrees per fixed step (like velocityX/Y are px per step),
30
+ // applied once when play starts.
31
+ angularVelocity: 0,
29
32
  };
30
33
 
31
34
  constructor(props) {
@@ -16,6 +16,7 @@
16
16
  // emulated here (applyWorldGravity, and kinematic == static-moved-from-Layout).
17
17
 
18
18
  import Matter from 'matter-js';
19
+ import { getColliderShapes } from '../engine/collider';
19
20
 
20
21
  export const DEG_TO_RAD = Math.PI / 180;
21
22
  export const RAD_TO_DEG = 180 / Math.PI;
@@ -46,39 +47,78 @@ export function rectCenter(rect) {
46
47
  return { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 };
47
48
  }
48
49
 
49
- function bodyRadius(actor, rect) {
50
- // `radius` of 0 (the default) means "derive from the collider size" -- note
51
- // `??` won't do here since 0 is a real number; a 0 radius would be a
52
- // degenerate, zero-mass body that NaNs the simulation.
53
- const r = actor.components.Collider.radius;
54
- return r && r > 0 ? r : Math.min(rect.width, rect.height) / 2;
50
+ // One matter part (a body) for a resolved, world-space collider shape. Triangles
51
+ // and convex polygons go through `fromVertices`; a concave polygon (which would
52
+ // need the poly-decomp lib we don't ship) or a degenerate shape falls back to its
53
+ // AABB box so it still collides.
54
+ function shapeToPart(s) {
55
+ if (!s) return null;
56
+ if (s.type === 'circle') return Matter.Bodies.circle(s.cx, s.cy, s.radius > 0 ? s.radius : 0.5);
57
+ if ((s.type === 'triangle' || s.type === 'polygon') && s.points && s.points.length >= 3) {
58
+ let cx = 0;
59
+ let cy = 0;
60
+ let x0 = Infinity;
61
+ let y0 = Infinity;
62
+ let x1 = -Infinity;
63
+ let y1 = -Infinity;
64
+ for (const p of s.points) {
65
+ cx += p.x;
66
+ cy += p.y;
67
+ if (p.x < x0) x0 = p.x;
68
+ if (p.y < y0) y0 = p.y;
69
+ if (p.x > x1) x1 = p.x;
70
+ if (p.y > y1) y1 = p.y;
71
+ }
72
+ const part = Matter.Bodies.fromVertices(cx / s.points.length, cy / s.points.length, [s.points]);
73
+ if (part) return part;
74
+ return Matter.Bodies.rectangle((x0 + x1) / 2, (y0 + y1) / 2, Math.max(1, x1 - x0), Math.max(1, y1 - y0));
75
+ }
76
+ return Matter.Bodies.rectangle(s.cx, s.cy, Math.max(1, s.width), Math.max(1, s.height));
55
77
  }
56
78
 
57
- // Structural signature: when this changes we rebuild the body (shape/size/type)
58
- // rather than live-patching material props in place.
59
- export function bodySignature(actor, rect) {
60
- const shape = actor.components.Collider.shape ?? 'box';
61
- const dims =
62
- shape === 'circle'
63
- ? `r${Math.round(bodyRadius(actor, rect))}`
64
- : `${Math.round(rect.width)}x${Math.round(rect.height)}`;
65
- return `${shape}|${dims}|${rigidBodyType(actor)}`;
79
+ // Structural signature: when this changes we rebuild the body (shape set / sizes
80
+ // / type) rather than live-patching material props in place. Resolved sizes are
81
+ // included, so resizing the Layout box (which rescales every fraction shape)
82
+ // triggers a rebuild.
83
+ export function bodySignature(actor) {
84
+ const shapes = getColliderShapes(actor) ?? [];
85
+ const sig = shapes
86
+ .map((s) =>
87
+ s.type === 'circle'
88
+ ? `c${Math.round(s.radius)}`
89
+ : s.type === 'box'
90
+ ? `b${Math.round(s.width)}x${Math.round(s.height)}`
91
+ : `${s.type[0]}${(s.points ?? []).length}`
92
+ )
93
+ .join(',');
94
+ return `${sig}|${rigidBodyType(actor)}`;
66
95
  }
67
96
 
68
- // Build a fresh matter body at the collider rect's center, oriented by Layout.
97
+ // Build a fresh matter body -- a single part, or a compound of all collider
98
+ // shapes -- anchored so `body.position` is the collider's AABB center (matching
99
+ // the center-offset PhysicsSystem caches), oriented by Layout.
69
100
  export function createBody(actor, rect) {
70
- const center = rectCenter(rect);
71
- const options = { angle: (actor.components.Layout.rotation ?? 0) * DEG_TO_RAD };
72
- const body =
73
- (actor.components.Collider.shape ?? 'box') === 'circle'
74
- ? Matter.Bodies.circle(center.x, center.y, bodyRadius(actor, rect), options)
75
- : Matter.Bodies.rectangle(center.x, center.y, rect.width, rect.height, options);
101
+ const parts = (getColliderShapes(actor) ?? []).map(shapeToPart).filter(Boolean);
102
+ let body;
103
+ if (parts.length === 0) {
104
+ const c = rectCenter(rect);
105
+ body = Matter.Bodies.rectangle(c.x, c.y, Math.max(1, rect.width), Math.max(1, rect.height));
106
+ } else if (parts.length === 1) {
107
+ body = parts[0];
108
+ } else {
109
+ body = Matter.Body.create({ parts });
110
+ }
111
+ Matter.Body.setPosition(body, rectCenter(rect));
112
+ Matter.Body.setAngle(body, (actor.components.Layout.rotation ?? 0) * DEG_TO_RAD);
76
113
  body.plugin.actorId = actor.id;
77
114
  Matter.Body.setStatic(body, rigidBodyType(actor) !== 'dynamic');
78
115
  patchBody(body, actor);
79
116
  const rb = actor.components.RigidBody;
80
- if (rb && rigidBodyType(actor) === 'dynamic' && (rb.velocityX || rb.velocityY)) {
81
- Matter.Body.setVelocity(body, { x: rb.velocityX ?? 0, y: rb.velocityY ?? 0 });
117
+ if (rb && rigidBodyType(actor) === 'dynamic') {
118
+ if (rb.velocityX || rb.velocityY) {
119
+ Matter.Body.setVelocity(body, { x: rb.velocityX ?? 0, y: rb.velocityY ?? 0 });
120
+ }
121
+ if (rb.angularVelocity) Matter.Body.setAngularVelocity(body, rb.angularVelocity * DEG_TO_RAD);
82
122
  }
83
123
  return body;
84
124
  }
@@ -89,12 +129,21 @@ export function patchBody(body, actor) {
89
129
  const rb = actor.components.RigidBody;
90
130
  body.restitution = collider.bounciness ?? 0;
91
131
  body.friction = collider.friction ?? 0.1;
132
+ body.frictionStatic = collider.frictionStatic ?? 0.5;
92
133
  // `isTrigger` is the source of truth for solid-vs-sensor; legacy decks that
93
134
  // used the retired `kind: 'pickup'` label are still honored as sensors.
94
135
  body.isSensor = Boolean(collider.isTrigger || collider.kind === 'pickup');
95
136
  body.frictionAir = rb?.drag ?? 0.01;
96
137
  body.plugin.gravityScale = rb?.gravityScale ?? 1;
97
138
  body.plugin.angularDrag = rb?.angularDrag ?? 0;
139
+ // Density sets mass (= density x area) -- DYNAMIC bodies only. On a static or
140
+ // kinematic body, setDensity would replace the infinite mass that setStatic
141
+ // gave it with a finite inverse mass, corrupting it as an immovable obstacle
142
+ // (things fall through it / the sim NaNs). It recomputes inertia, so it runs
143
+ // before the freezeRotation override below.
144
+ if (rigidBodyType(actor) === 'dynamic') {
145
+ Matter.Body.setDensity(body, collider.density > 0 ? collider.density : 0.001);
146
+ }
98
147
  if (rb?.freezeRotation) Matter.Body.setInertia(body, Infinity);
99
148
  }
100
149
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "castle-web-cli",
3
- "version": "0.4.91",
3
+ "version": "0.4.92",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "castle-web": "./dist/index.js"
@@ -1,28 +0,0 @@
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
- }
@@ -1,15 +0,0 @@
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
- };