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.
- package/kits/basic-2d/editors/SceneEditor.jsx +39 -6
- package/kits/basic-2d/engine/scene.js +10 -2
- package/kits/physics-2d/behaviors/Collider.jsx +190 -153
- package/kits/physics-2d/editors/SceneEditor.jsx +42 -6
- package/kits/physics-2d/editors/SelectionOverlay.jsx +57 -28
- package/kits/physics-2d/engine/collider.js +123 -105
- package/kits/physics-2d/engine/scene.js +10 -2
- package/kits/physics-2d/physics/behaviors/RigidBody.jsx +3 -0
- package/kits/physics-2d/physics/matterBridge.js +73 -24
- package/package.json +1 -1
- package/kits/physics-2d/engine/behaviorExtensions.js +0 -28
- package/kits/physics-2d/physics/extensions/collider.js +0 -15
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
302
|
+
hits.push(actor);
|
|
295
303
|
}
|
|
296
304
|
}
|
|
297
|
-
return
|
|
305
|
+
return hits;
|
|
298
306
|
}
|
|
299
307
|
|
|
300
308
|
actorIdsInRect(rect) {
|
|
@@ -1,46 +1,23 @@
|
|
|
1
1
|
import React, { useState } from 'react';
|
|
2
|
-
import {
|
|
3
|
-
import { AutoFields
|
|
4
|
-
import { computeAutoFit, getColliderRect,
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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:
|
|
15
|
+
fontSize: 12,
|
|
30
16
|
cursor: 'pointer',
|
|
31
|
-
};
|
|
32
|
-
|
|
33
|
-
const
|
|
34
|
-
|
|
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
|
|
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
|
-
|
|
59
|
-
//
|
|
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
|
-
|
|
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
|
|
83
|
-
//
|
|
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,
|
|
100
|
+
return fit ? { ...Collider.defaultProps, shapes: [fit] } : { ...Collider.defaultProps };
|
|
91
101
|
}
|
|
92
102
|
|
|
93
103
|
draw(actor, scene, ctx, options) {
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
if (
|
|
97
|
-
//
|
|
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
|
-
|
|
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 [
|
|
124
|
+
const [sel, setSel] = useState(0);
|
|
125
|
+
const [addOpen, setAddOpen] = useState(false);
|
|
114
126
|
const layout = actor?.components?.Layout ?? {};
|
|
115
|
-
const
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
const
|
|
119
|
-
const
|
|
120
|
-
const
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
const
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
|
139
|
-
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
)
|
|
182
|
+
))}
|
|
183
|
+
<button type="button" onClick={() => setAddOpen((o) => !o)} style={addBtn} title="Add a shape">
|
|
184
|
+
+
|
|
185
|
+
</button>
|
|
162
186
|
</div>
|
|
163
|
-
{
|
|
164
|
-
<div style={
|
|
165
|
-
{
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
|
|
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
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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={['
|
|
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
|
|
210
|
-
// scene (e.g. editors/SelectionOverlay.jsx
|
|
211
|
-
|
|
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
|
|
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
|
-
|
|
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 {
|
|
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
|
-
.
|
|
464
|
+
.flatMap((actor) => {
|
|
465
465
|
const layout = actor.components.Layout;
|
|
466
466
|
const collider = actor.components.Collider;
|
|
467
|
-
const
|
|
468
|
-
if (!layout || !collider || !
|
|
469
|
-
|
|
470
|
-
//
|
|
471
|
-
// matches the physics body
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
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
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
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
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
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
|
|
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
|
|
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
|
-
//
|
|
102
|
-
//
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
129
|
-
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
//
|
|
173
|
-
//
|
|
174
|
-
//
|
|
175
|
-
|
|
176
|
-
|
|
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
|
-
|
|
187
|
-
|
|
188
|
-
const
|
|
189
|
-
const
|
|
190
|
-
return {
|
|
191
|
-
|
|
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
|
-
|
|
302
|
+
hits.push(actor);
|
|
295
303
|
}
|
|
296
304
|
}
|
|
297
|
-
return
|
|
305
|
+
return hits;
|
|
298
306
|
}
|
|
299
307
|
|
|
300
308
|
actorIdsInRect(rect) {
|
|
@@ -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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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/
|
|
58
|
-
// rather than live-patching material props in place.
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
|
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
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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'
|
|
81
|
-
|
|
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,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
|
-
};
|