castle-web-cli 0.4.94 → 0.4.95

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,291 @@
1
+ import React, { useState } from 'react';
2
+ import { Panel, SelectField, NumberField, CheckboxField, Button } from '../../engine/ui';
3
+ import { centerOf, inActorWorldSpace } from '../controls';
4
+ import { JOINT_TYPES } from '../joints';
5
+ import { drawJointArt } from '../jointArt';
6
+
7
+ const RENDER_MODES = ['line', 'hidden', 'sprite'];
8
+ // Bound anchor offsets (px, local frame). A large offset lengthens the pivot's
9
+ // lever arm, which is what destabilizes a weld -- so the UI caps it well
10
+ // inside the safe range rather than letting a broken value be typed.
11
+ const ANCHOR_MAX = 120;
12
+
13
+ // Joints: connect this actor to one or more `target` actors with physics links.
14
+ // The component holds a LIST so an actor can carry several links at once (a
15
+ // a body both sprung and roped, a truss node). Each entry picks a TYPE -- spring
16
+ // (elastic), rod (rigid stick, ends rotate; also the hinge -- to a static anchor),
17
+ // weld (fused, no relative rotation), rope (slack, taut at max length). The
18
+ // simulation builds the matter constraints
19
+ // (see physics/joints.js); this behavior owns the authoring UI + editor overlay.
20
+ //
21
+ // Both actors of a link need a Collider (to have a body); the owner is usually a
22
+ // dynamic RigidBody and each target either dynamic or a static anchor. `target`
23
+ // is instance-local -- pick it per instance, since a blueprint-level target would
24
+ // point every instance at the same actor.
25
+ export class Joints {
26
+ static behaviorName = 'Joints';
27
+
28
+ static defaultProps = { list: [] };
29
+
30
+ // A freshly-added Joints behavior starts with one blank link to fill in.
31
+ static initialProps() {
32
+ return { list: [defaultJoint()] };
33
+ }
34
+
35
+ constructor(props) {
36
+ this.props = props;
37
+ }
38
+
39
+ // Draw every link. In the EDITOR (options.editPlaceholders) it's an authoring
40
+ // overlay -- always shown so connections are visible, sprite-art previewed
41
+ // WYSIWYG, and even `hidden` joints drawn as a faint line so you don't lose
42
+ // them. In PLAY each joint's `render` decides: `hidden` (nothing), `line` (the
43
+ // schematic) or `sprite` (art along the joint). Drawn in world space so the
44
+ // link doesn't spin with the actor's rotation (see inActorWorldSpace).
45
+ draw(actor, scene, ctx, options) {
46
+ const list = Array.isArray(this.props.list) ? this.props.list : [];
47
+ if (!list.length) return;
48
+ const editing = Boolean(options.editPlaceholders);
49
+ const emphasized =
50
+ options.colliderOverlayIds?.includes(actor.id) ||
51
+ (Boolean(options.dimBlueprintPath) && actor.blueprint === options.dimBlueprintPath);
52
+ const a = centerOf(actor.components.Layout);
53
+ inActorWorldSpace(ctx, actor.components.Layout, (g) => {
54
+ for (const joint of list) {
55
+ const target = joint?.target && scene.getActor(joint.target);
56
+ if (!target || target.id === actor.id) continue;
57
+ const b = centerOf(target.components.Layout);
58
+ // hidden shows as a faint line in the editor, nothing in play.
59
+ let render = joint.render ?? 'line';
60
+ if (editing && render === 'hidden') render = 'line';
61
+ if (render === 'hidden') continue;
62
+ if (render === 'sprite' && drawJointArt(g, scene, joint, a, b)) continue;
63
+ g.globalAlpha = editing ? (emphasized ? 1 : 0.45) : 0.7;
64
+ g.strokeStyle = '#c9a9ff';
65
+ g.fillStyle = '#c9a9ff';
66
+ g.lineWidth = 2;
67
+ drawLink(g, a, b, joint.type ?? 'spring');
68
+ g.globalAlpha = 1;
69
+ }
70
+ });
71
+ }
72
+
73
+ static Inspector({ component, setComponent, override, beginPick, pickActive, files }) {
74
+ const list = Array.isArray(component.list) ? component.list : [];
75
+ const spriteFiles = Object.keys(files ?? {}).filter((f) => f.endsWith('.pxart'));
76
+ // Which entry's target-pick is live. Combined with the editor's global
77
+ // `pickActive` so a cancel (Esc/empty click) clears every entry's state.
78
+ const [pickingIndex, setPickingIndex] = useState(null);
79
+ const update = (next) => setComponent({ list: next });
80
+ const patchAt = (i, patch) => update(list.map((j, k) => (k === i ? { ...j, ...patch } : j)));
81
+ const onPickToggle = (i) => {
82
+ if (pickActive && pickingIndex === i) return beginPick?.(null);
83
+ setPickingIndex(i);
84
+ return beginPick?.((id) => patchAt(i, { target: id }));
85
+ };
86
+ return (
87
+ <Panel title="Joints" overridden={override?.anyOverridden()}>
88
+ {list.map((joint, i) => (
89
+ <JointEntry
90
+ key={i}
91
+ index={i}
92
+ joint={joint}
93
+ spriteFiles={spriteFiles}
94
+ active={pickActive && pickingIndex === i}
95
+ onPickToggle={() => onPickToggle(i)}
96
+ onPatch={(patch) => patchAt(i, patch)}
97
+ onRemove={() => update(list.filter((_, k) => k !== i))}
98
+ />
99
+ ))}
100
+ <Button onClick={() => update([...list, defaultJoint()])}>+ Add joint</Button>
101
+ </Panel>
102
+ );
103
+ }
104
+ }
105
+
106
+ function defaultJoint() {
107
+ return { type: 'spring', target: '', length: -1, springiness: 0.4, damping: 0.1, render: 'line' };
108
+ }
109
+
110
+ // One editable link in the list: type, target picker, type-specific fields, and
111
+ // how it renders in play (line / hidden / sprite art along the joint).
112
+ function JointEntry({ index, joint, spriteFiles, active, onPickToggle, onPatch, onRemove }) {
113
+ const [showAnchors, setShowAnchors] = useState(false);
114
+ const type = joint.type ?? 'spring';
115
+ const isPivot = type === 'weld';
116
+ const soft = type === 'spring' || type === 'rope';
117
+ const target = joint.target || '';
118
+ const render = joint.render ?? 'line';
119
+ const lengthAuto = (joint.length ?? -1) < 0;
120
+ const lengthLabel = type === 'rope' ? 'Max length (px)' : 'Length (px)';
121
+ return (
122
+ <div style={entryBox}>
123
+ <div style={entryHead}>
124
+ <strong style={entryTitle}>Joint {index + 1}</strong>
125
+ <button type="button" style={removeLink} onClick={onRemove}>
126
+ Remove
127
+ </button>
128
+ </div>
129
+ <SelectField label="Type" value={type} onChange={(t) => onPatch({ type: t })} options={JOINT_TYPES} />
130
+ <div style={targetRow}>
131
+ <span style={targetText}>
132
+ Target: <strong>{target || '(none)'}</strong>
133
+ </span>
134
+ <div style={{ display: 'flex', gap: 6 }}>
135
+ <Button active={active} onClick={onPickToggle}>
136
+ {active ? 'Click an actor…' : target ? 'Re-pick' : 'Pick target'}
137
+ </Button>
138
+ {target ? <Button onClick={() => onPatch({ target: '' })}>Clear</Button> : null}
139
+ </div>
140
+ </div>
141
+ {!isPivot ? (
142
+ <>
143
+ {/* Auto = -1 sentinel, but the UI expresses it as a checkbox so you
144
+ can't type a broken negative length. */}
145
+ <CheckboxField label="Auto length" checked={lengthAuto} onChange={(on) => onPatch({ length: on ? -1 : 100 })} />
146
+ {!lengthAuto ? (
147
+ <NumberField label={lengthLabel} value={joint.length} min={0} onChange={(v) => onPatch({ length: v })} />
148
+ ) : null}
149
+ </>
150
+ ) : null}
151
+ {soft ? (
152
+ <NumberField
153
+ label={type === 'rope' ? 'Springiness (0=dead → 1=bungee)' : 'Springiness'}
154
+ value={joint.springiness ?? 0.4}
155
+ min={0}
156
+ max={1}
157
+ step={0.05}
158
+ onChange={(v) => onPatch({ springiness: v })}
159
+ />
160
+ ) : null}
161
+ {type === 'spring' ? (
162
+ <NumberField
163
+ label="Damping"
164
+ value={joint.damping ?? 0.1}
165
+ min={0}
166
+ max={1}
167
+ step={0.05}
168
+ onChange={(v) => onPatch({ damping: v })}
169
+ />
170
+ ) : null}
171
+ <button type="button" style={anchorToggle} onClick={() => setShowAnchors((s) => !s)}>
172
+ {showAnchors ? '▾' : '▸'} Anchor offsets
173
+ </button>
174
+ {showAnchors ? <AnchorFields joint={joint} isPivot={isPivot} onPatch={onPatch} /> : null}
175
+ <SelectField label="Show in play" value={render} onChange={(v) => onPatch({ render: v })} options={RENDER_MODES} />
176
+ {render === 'sprite' ? (
177
+ <>
178
+ <SelectField
179
+ label="Art"
180
+ value={joint.sprite || ''}
181
+ onChange={(v) => onPatch({ sprite: v })}
182
+ options={['', ...spriteFiles]}
183
+ />
184
+ <NumberField label="Thickness" value={joint.thickness ?? 12} min={1} onChange={(v) => onPatch({ thickness: v })} />
185
+ <SelectField
186
+ label="Fit"
187
+ value={joint.fit ?? 'tile'}
188
+ onChange={(v) => onPatch({ fit: v })}
189
+ options={['tile', 'stretch']}
190
+ />
191
+ </>
192
+ ) : null}
193
+ </div>
194
+ );
195
+ }
196
+
197
+ // Attach-point offsets (advanced, tucked behind a toggle). For weld the owner
198
+ // offset moves the shared pivot; for spring/rod/rope the owner + target offsets
199
+ // move each end's attach point. All bounded to ±ANCHOR_MAX.
200
+ function AnchorFields({ joint, isPivot, onPatch }) {
201
+ const field = (label, key) => (
202
+ <NumberField
203
+ label={label}
204
+ value={joint[key] ?? 0}
205
+ min={-ANCHOR_MAX}
206
+ max={ANCHOR_MAX}
207
+ onChange={(v) => onPatch({ [key]: v })}
208
+ />
209
+ );
210
+ return isPivot ? (
211
+ <>
212
+ {field('Pivot offset X', 'anchorX')}
213
+ {field('Pivot offset Y', 'anchorY')}
214
+ </>
215
+ ) : (
216
+ <>
217
+ {field('Anchor X', 'anchorX')}
218
+ {field('Anchor Y', 'anchorY')}
219
+ {field('Target anchor X', 'targetAnchorX')}
220
+ {field('Target anchor Y', 'targetAnchorY')}
221
+ </>
222
+ );
223
+ }
224
+
225
+ const entryBox = { borderTop: '1px solid var(--castle-inspector-divider)', paddingTop: 8, marginBottom: 6 };
226
+ const anchorToggle = {
227
+ background: 'none',
228
+ border: 'none',
229
+ padding: '2px 0',
230
+ margin: '2px 0 8px',
231
+ color: '#4aa3ff',
232
+ fontSize: 12,
233
+ cursor: 'pointer',
234
+ };
235
+ const entryHead = { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6 };
236
+ const entryTitle = { color: '#c9a9ff', fontSize: 13 };
237
+ const removeLink = { background: 'none', border: 'none', padding: 0, color: '#ff7a7a', fontSize: 12, cursor: 'pointer' };
238
+ const targetRow = { display: 'flex', flexDirection: 'column', gap: 6, margin: '4px 0 10px' };
239
+ const targetText = { fontSize: 13, color: 'var(--castle-inspector-text)' };
240
+
241
+ // Draw the link between two world points, styled by joint type: spring coils,
242
+ // rope dashes, weld doubles the line, rod is a plain line.
243
+ function drawLink(ctx, a, b, type) {
244
+ if (type === 'spring') return drawCoil(ctx, a, b);
245
+ if (type === 'rope') {
246
+ ctx.setLineDash([6, 5]);
247
+ line(ctx, a, b);
248
+ ctx.setLineDash([]);
249
+ return;
250
+ }
251
+ if (type === 'weld') return drawDouble(ctx, a, b);
252
+ line(ctx, a, b); // rod (and any fallthrough)
253
+ }
254
+
255
+ function line(ctx, a, b) {
256
+ ctx.beginPath();
257
+ ctx.moveTo(a.x, a.y);
258
+ ctx.lineTo(b.x, b.y);
259
+ ctx.stroke();
260
+ }
261
+
262
+ // Two parallel lines offset along the perpendicular -- the "fused" read.
263
+ function drawDouble(ctx, a, b) {
264
+ const dx = b.x - a.x;
265
+ const dy = b.y - a.y;
266
+ const len = Math.hypot(dx, dy) || 1;
267
+ const nx = (-dy / len) * 3;
268
+ const ny = (dx / len) * 3;
269
+ line(ctx, { x: a.x + nx, y: a.y + ny }, { x: b.x + nx, y: b.y + ny });
270
+ line(ctx, { x: a.x - nx, y: a.y - ny }, { x: b.x - nx, y: b.y - ny });
271
+ }
272
+
273
+ // A zig-zag coil between the endpoints, straight stubs at each end.
274
+ function drawCoil(ctx, a, b) {
275
+ const dx = b.x - a.x;
276
+ const dy = b.y - a.y;
277
+ const len = Math.hypot(dx, dy) || 1;
278
+ const px = -dy / len;
279
+ const py = dx / len;
280
+ const coils = Math.max(3, Math.min(10, Math.round(len / 22)));
281
+ const amp = 6;
282
+ ctx.beginPath();
283
+ ctx.moveTo(a.x, a.y);
284
+ for (let i = 1; i < coils; i++) {
285
+ const t = i / coils;
286
+ const side = i % 2 === 0 ? 1 : -1;
287
+ ctx.lineTo(a.x + dx * t + px * amp * side, a.y + dy * t + py * amp * side);
288
+ }
289
+ ctx.lineTo(b.x, b.y);
290
+ ctx.stroke();
291
+ }
@@ -0,0 +1,57 @@
1
+ // Draws a joint's `sprite` art along the segment between its two endpoints,
2
+ // rotated to the joint's angle and scaled to its current length -- so a rope
3
+ // visibly stretches as a spring extends. Two fits:
4
+ // tile repeat a segment along the length (rope, chain)
5
+ // stretch one image end-to-end (stick, beam, plank)
6
+ // Used by Joints.draw() for `render: 'sprite'`. Reuses the kit's pxart renderer;
7
+ // a small WeakMap caches the native-resolution frame canvas per sprite object.
8
+
9
+ /* global document */
10
+ import { resolveDeckFile } from 'castle-web-sdk';
11
+ import { renderSpriteFrame } from '../engine/pxart';
12
+
13
+ export const DEFAULT_JOINT_SPRITE = 'drawings/joint-rope.pxart';
14
+
15
+ const canvasCache = new WeakMap();
16
+ function frameCanvas(sprite) {
17
+ let canvas = canvasCache.get(sprite);
18
+ if (!canvas) {
19
+ canvas = document.createElement('canvas');
20
+ renderSpriteFrame(sprite, 0, canvas);
21
+ canvasCache.set(sprite, canvas);
22
+ }
23
+ return canvas;
24
+ }
25
+
26
+ // Draw the art from world point `a` to `b`. Returns false if the sprite can't be
27
+ // resolved (so the caller can fall back to the schematic line).
28
+ export function drawJointArt(ctx, scene, joint, a, b) {
29
+ // `drawings/x.pxart` is this deck's; `@imports/<alias>/drawings/x.pxart` is an
30
+ // import's -- same rule the Sprite behavior follows.
31
+ const path = resolveDeckFile(joint.sprite || DEFAULT_JOINT_SPRITE);
32
+ const sprite = scene.sprites?.[path] ?? scene.sprites?.[DEFAULT_JOINT_SPRITE];
33
+ if (!sprite) return false;
34
+ const canvas = frameCanvas(sprite);
35
+ if (!canvas.width || !canvas.height) return false;
36
+ const dx = b.x - a.x;
37
+ const dy = b.y - a.y;
38
+ const len = Math.hypot(dx, dy);
39
+ if (len < 1) return true;
40
+ const thickness = joint.thickness > 0 ? joint.thickness : 12;
41
+ ctx.save();
42
+ ctx.translate(a.x, a.y);
43
+ ctx.rotate(Math.atan2(dy, dx));
44
+ ctx.imageSmoothingEnabled = sprite.cornerRadius > 0;
45
+ const top = -thickness / 2;
46
+ if ((joint.fit ?? 'tile') === 'stretch') {
47
+ ctx.drawImage(canvas, 0, top, len, thickness);
48
+ } else {
49
+ const tileW = Math.max(2, thickness * (canvas.width / canvas.height));
50
+ ctx.beginPath();
51
+ ctx.rect(0, top, len, thickness);
52
+ ctx.clip();
53
+ for (let x = 0; x < len; x += tileW) ctx.drawImage(canvas, x, top, tileW, thickness);
54
+ }
55
+ ctx.restore();
56
+ return true;
57
+ }
@@ -0,0 +1,136 @@
1
+ // Joints connect two physics actors with matter-js constraints. matter has one
2
+ // primitive -- Matter.Constraint (a point-to-point distance link with stiffness
3
+ // / damping / length) -- so every joint TYPE here is composed from one or two of
4
+ // them:
5
+ //
6
+ // spring soft elastic tether 1 constraint, low stiffness, at rest length
7
+ // rod rigid fixed-distance link 1 constraint, high stiffness, free rotation
8
+ // weld fused, no relative rotation 1 zero-length pivot + frozen rotation
9
+ // rope slack, taut only at max length 1 constraint, stiffness toggled per step
10
+ //
11
+ // The joint lives on the OWNER actor (bodyA) and points at a `target` actor
12
+ // (bodyB). Anchors are px offsets in each body's local (unrotated) frame; default
13
+ // (0,0) attaches at the body center. Geometry is resolved against the live matter
14
+ // bodies, so a joint tracks whatever the collider/rigidbody reconcile produced.
15
+
16
+ import Matter from 'matter-js';
17
+
18
+ // No `pin` (free-rotating coincident-pivot hinge): a length-0 revolute between two
19
+ // dynamic bodies is matter's most unstable case and no tuning made it robust. Use
20
+ // `rod` (to a static anchor) for a hinge / pendulum instead.
21
+ export const JOINT_TYPES = ['spring', 'rod', 'weld', 'rope'];
22
+
23
+ const sub = (a, b) => ({ x: a.x - b.x, y: a.y - b.y });
24
+
25
+ // A body-local point (unrotated frame) -> world, and the inverse. matter rotates
26
+ // a constraint's local point by the body angle when solving, so we store points
27
+ // in that same local frame.
28
+ function toWorld(body, local) {
29
+ return Matter.Vector.add(body.position, Matter.Vector.rotate(local, body.angle));
30
+ }
31
+ function toLocal(body, world) {
32
+ return Matter.Vector.rotate(sub(world, body.position), -body.angle);
33
+ }
34
+
35
+ // springiness (0..1) -> matter stiffness. Kept low so springs read as springs;
36
+ // rigid types use a fixed high stiffness instead.
37
+ function springStiffness(springiness) {
38
+ const s = Math.min(1, Math.max(0, springiness ?? 0.4));
39
+ return 0.002 + s * 0.05;
40
+ }
41
+
42
+ // Rope stiffness WHEN TAUT, from `springiness`: 0 = a dead rope (stiff 0.9, holds
43
+ // firm at max length), 1 = a bungee (very soft ~0.002, stretches well past and
44
+ // springs back). Geometric interpolation -- a light body barely stretches a stiff
45
+ // constraint, so the useful range is tiny stiffnesses and a linear map would waste
46
+ // most of the slider in the "firm" zone.
47
+ function ropeStiffness(springiness) {
48
+ const s = Math.min(1, Math.max(0, springiness ?? 0));
49
+ return 0.9 * Math.pow(0.0025, s);
50
+ }
51
+
52
+ function ownerAnchorLocal(joint) {
53
+ return { x: joint.anchorX ?? 0, y: joint.anchorY ?? 0 };
54
+ }
55
+ function targetAnchorLocal(joint) {
56
+ return { x: joint.targetAnchorX ?? 0, y: joint.targetAnchorY ?? 0 };
57
+ }
58
+
59
+ // Structural signature: a change here rebuilds the constraint set; everything
60
+ // else (length, springiness, damping) is live-patched in place.
61
+ export function jointSignature(joint) {
62
+ const a = ownerAnchorLocal(joint);
63
+ const b = targetAnchorLocal(joint);
64
+ return `${joint.type ?? 'spring'}|${joint.target ?? ''}|${a.x},${a.y}|${b.x},${b.y}`;
65
+ }
66
+
67
+ // Distance between the two resolved anchor world points right now.
68
+ function anchorGap(bodyA, bodyB, joint) {
69
+ const wa = toWorld(bodyA, ownerAnchorLocal(joint));
70
+ const wb = toWorld(bodyB, targetAnchorLocal(joint));
71
+ return Matter.Vector.magnitude(sub(wa, wb));
72
+ }
73
+
74
+ // The rest/target length for distance-style joints: authored `length`, or the
75
+ // current gap when length is auto (-1).
76
+ function restLength(bodyA, bodyB, joint) {
77
+ const len = joint.length ?? -1;
78
+ return len >= 0 ? len : anchorGap(bodyA, bodyB, joint);
79
+ }
80
+
81
+ // Build the matter constraint(s) for a joint. `weld` is a single zero-length
82
+ // pivot link; PhysicsSystem additionally freezes both bodies' rotation so they
83
+ // can't turn relative to each other (a rigid fuse). A single stable pivot avoids
84
+ // the numerical blow-up that two fighting stiff length-0 links cause under
85
+ // collision, especially with a diagonal offset.
86
+ export function buildJointConstraints(bodyA, bodyB, joint) {
87
+ const type = joint.type ?? 'spring';
88
+ if (type === 'weld') return [pivotConstraint(bodyA, bodyB, joint)];
89
+ const pointA = ownerAnchorLocal(joint);
90
+ const pointB = targetAnchorLocal(joint);
91
+ const length = restLength(bodyA, bodyB, joint);
92
+ const stiffness =
93
+ type === 'spring' ? springStiffness(joint.springiness) : type === 'rope' ? ropeStiffness(joint.springiness) : 0.9;
94
+ // Rope carries no damping so a bungee actually bounces; spring/rod use theirs.
95
+ const damping = type === 'rope' ? 0 : joint.damping ?? 0.1;
96
+ return [Matter.Constraint.create({ bodyA, bodyB, pointA, pointB, length, stiffness, damping })];
97
+ }
98
+
99
+ // Zero-length link at the midpoint of the two centers, shifted by the owner
100
+ // anchor -- the position half of a weld (rotation is frozen separately). 0.7 is
101
+ // firm but below matter's length-0 instability threshold; the velocity clamp in
102
+ // PhysicsSystem backstops any residual runaway.
103
+ function pivotConstraint(bodyA, bodyB, joint) {
104
+ const a = ownerAnchorLocal(joint);
105
+ const pivotWorld = {
106
+ x: (bodyA.position.x + bodyB.position.x) / 2 + a.x,
107
+ y: (bodyA.position.y + bodyB.position.y) / 2 + a.y,
108
+ };
109
+ return Matter.Constraint.create({
110
+ bodyA,
111
+ bodyB,
112
+ pointA: toLocal(bodyA, pivotWorld),
113
+ pointB: toLocal(bodyB, pivotWorld),
114
+ length: 0,
115
+ stiffness: 0.7,
116
+ damping: 0.1,
117
+ });
118
+ }
119
+
120
+ // Live-patch the mutable feel of an existing constraint set (no rebuild). Rope is
121
+ // one-sided: it pulls only when stretched past its length (stiffness from
122
+ // `springiness` -- dead rope to bungee), and goes fully slack (zero stiffness)
123
+ // when closer -- matter has no native max-only constraint.
124
+ export function patchJoint(constraints, bodyA, bodyB, joint) {
125
+ const type = joint.type ?? 'spring';
126
+ if (type === 'weld') return;
127
+ const c = constraints[0];
128
+ if (!c) return;
129
+ if ((joint.length ?? -1) >= 0) c.length = joint.length;
130
+ if (type === 'spring') {
131
+ c.stiffness = springStiffness(joint.springiness);
132
+ c.damping = joint.damping ?? 0.1;
133
+ } else if (type === 'rope') {
134
+ c.stiffness = anchorGap(bodyA, bodyB, joint) > c.length ? ropeStiffness(joint.springiness) : 0;
135
+ }
136
+ }
@@ -27,6 +27,13 @@ export const FIXED_STEP_MS = 1000 / 60;
27
27
  export const FIXED_STEP_S = FIXED_STEP_MS / 1000;
28
28
  export const MAX_STEPS_PER_FRAME = 5;
29
29
 
30
+ // Hard cap on a body's speed (px per fixed step) -- a safety net so a constraint
31
+ // blow-up (a stiff joint at a bad angle can inject huge velocity in one step)
32
+ // can't fling a body off screen. Well above any intended motion (a slingshot
33
+ // launch is ~20), so normal gameplay never touches it; it only ever engages to
34
+ // bound a runaway.
35
+ export const MAX_SPEED = 60;
36
+
30
37
  // World gravity in matter's own units. `scale` matches matter's internal
31
38
  // default; `y` is what a scene tunes (1 ~= a gentle arcade fall).
32
39
  export const DEFAULT_GRAVITY = { x: 0, y: 1, scale: 0.001 };
@@ -73,7 +80,10 @@ function shapeToPart(s) {
73
80
  if (part) return part;
74
81
  return Matter.Bodies.rectangle((x0 + x1) / 2, (y0 + y1) / 2, Math.max(1, x1 - x0), Math.max(1, y1 - y0));
75
82
  }
76
- return Matter.Bodies.rectangle(s.cx, s.cy, Math.max(1, s.width), Math.max(1, s.height));
83
+ // `s.angle` (deg) rotates the box part about its center at creation; the
84
+ // compound body's Layout.rotation composes on top.
85
+ const opts = s.angle ? { angle: s.angle * DEG_TO_RAD } : undefined;
86
+ return Matter.Bodies.rectangle(s.cx, s.cy, Math.max(1, s.width), Math.max(1, s.height), opts);
77
87
  }
78
88
 
79
89
  // Structural signature: when this changes we rebuild the body (shape set / sizes
@@ -87,7 +97,7 @@ export function bodySignature(actor) {
87
97
  s.type === 'circle'
88
98
  ? `c${Math.round(s.radius)}`
89
99
  : s.type === 'box'
90
- ? `b${Math.round(s.width)}x${Math.round(s.height)}`
100
+ ? `b${Math.round(s.width)}x${Math.round(s.height)}@${Math.round(s.angle ?? 0)}`
91
101
  : `${s.type[0]}${(s.points ?? []).length}`
92
102
  )
93
103
  .join(',');
@@ -173,3 +183,13 @@ export function applyAngularDrag(bodies) {
173
183
  if (drag > 0 && !body.isStatic) body.angularVelocity *= Math.max(0, 1 - drag * FIXED_STEP_S);
174
184
  }
175
185
  }
186
+
187
+ // Safety clamp: bound each body's speed to MAX_SPEED so a constraint blow-up
188
+ // can't fling it off screen. Run right after each Engine.update.
189
+ export function clampSpeeds(bodies) {
190
+ for (const body of bodies) {
191
+ if (body.isStatic) continue;
192
+ const s = Math.hypot(body.velocity.x, body.velocity.y);
193
+ if (s > MAX_SPEED) Matter.Body.setVelocity(body, { x: (body.velocity.x / s) * MAX_SPEED, y: (body.velocity.y / s) * MAX_SPEED });
194
+ }
195
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "castle-web-cli",
3
- "version": "0.4.94",
3
+ "version": "0.4.95",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "castle-web": "./dist/index.js"