castle-web-cli 0.4.93 → 0.4.95
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/init.js +1 -1
- package/dist/shell/assets/{index-CWNH9QiB.css → index-0Nmmod9f.css} +1 -1
- package/dist/shell/index.html +2 -2
- package/kits/physics-2d/CLAUDE.md +116 -6
- package/kits/physics-2d/behaviors/Collider.jsx +76 -10
- package/kits/physics-2d/behaviors/Sprite.jsx +6 -1
- package/kits/physics-2d/drawings/joint-rope.pxart +26 -0
- package/kits/physics-2d/editors/SceneEditor.jsx +58 -0
- package/kits/physics-2d/editors/SelectionOverlay.jsx +48 -19
- package/kits/physics-2d/editors/behaviorRegistry.js +8 -2
- package/kits/physics-2d/engine/blueprint.js +39 -3
- package/kits/physics-2d/engine/collider.js +13 -3
- package/kits/physics-2d/engine/files.js +26 -5
- package/kits/physics-2d/engine/scene.js +9 -1
- package/kits/physics-2d/engine/systemRegistry.js +5 -1
- package/kits/physics-2d/package-lock.json +10 -3
- package/kits/physics-2d/physics/PhysicsSystem.js +112 -0
- package/kits/physics-2d/physics/behaviors/Joints.jsx +291 -0
- package/kits/physics-2d/physics/jointArt.js +57 -0
- package/kits/physics-2d/physics/joints.js +136 -0
- package/kits/physics-2d/physics/matterBridge.js +22 -2
- package/package.json +1 -1
- /package/dist/shell/assets/{index-_C2BvstY.js → index-DG5ZD4p8.js} +0 -0
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
|
-
"name": "
|
|
2
|
+
"name": "physics-2d",
|
|
3
3
|
"lockfileVersion": 3,
|
|
4
4
|
"requires": true,
|
|
5
5
|
"packages": {
|
|
6
6
|
"": {
|
|
7
|
-
"name": "
|
|
7
|
+
"name": "physics-2d",
|
|
8
8
|
"dependencies": {
|
|
9
9
|
"@codemirror/commands": "^6.10.3",
|
|
10
10
|
"@codemirror/lang-javascript": "^6.2.5",
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
"@lezer/highlight": "^1.2.3",
|
|
16
16
|
"castle-web-sdk": "file:../../sdk",
|
|
17
17
|
"codemirror": "^6.0.2",
|
|
18
|
+
"matter-js": "^0.20.0",
|
|
18
19
|
"react": "^19.2.4",
|
|
19
20
|
"react-dom": "^19.2.4"
|
|
20
21
|
},
|
|
@@ -27,7 +28,7 @@
|
|
|
27
28
|
},
|
|
28
29
|
"../../sdk": {
|
|
29
30
|
"name": "castle-web-sdk",
|
|
30
|
-
"version": "0.4.
|
|
31
|
+
"version": "0.4.11",
|
|
31
32
|
"devDependencies": {
|
|
32
33
|
"eslint": "^9.0.0",
|
|
33
34
|
"jscpd": "^4.0.5",
|
|
@@ -1899,6 +1900,12 @@
|
|
|
1899
1900
|
"node": ">= 0.4"
|
|
1900
1901
|
}
|
|
1901
1902
|
},
|
|
1903
|
+
"node_modules/matter-js": {
|
|
1904
|
+
"version": "0.20.0",
|
|
1905
|
+
"resolved": "https://registry.npmjs.org/matter-js/-/matter-js-0.20.0.tgz",
|
|
1906
|
+
"integrity": "sha512-iC9fYR7zVT3HppNnsFsp9XOoQdQN2tUyfaKg4CHLH8bN+j6GT4Gw7IH2rP0tflAebrHFw730RR3DkVSZRX8hwA==",
|
|
1907
|
+
"license": "MIT"
|
|
1908
|
+
},
|
|
1902
1909
|
"node_modules/merge-stream": {
|
|
1903
1910
|
"version": "2.0.0",
|
|
1904
1911
|
"resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
import Matter from 'matter-js';
|
|
11
11
|
import { getColliderRect } from '../engine/collider';
|
|
12
|
+
import { buildJointConstraints, jointSignature, patchJoint } from './joints';
|
|
12
13
|
import {
|
|
13
14
|
DEFAULT_GRAVITY,
|
|
14
15
|
FIXED_STEP_MS,
|
|
@@ -18,6 +19,7 @@ import {
|
|
|
18
19
|
applyAngularDrag,
|
|
19
20
|
applyWorldGravity,
|
|
20
21
|
bodySignature,
|
|
22
|
+
clampSpeeds,
|
|
21
23
|
createBody,
|
|
22
24
|
isPhysicsActor,
|
|
23
25
|
patchBody,
|
|
@@ -29,6 +31,7 @@ export class PhysicsSystem {
|
|
|
29
31
|
constructor() {
|
|
30
32
|
this.engine = null;
|
|
31
33
|
this.bodies = new Map(); // actorId -> { body, sig, offset: {dx, dy} }
|
|
34
|
+
this.joints = new Map(); // `${ownerId}#${listIndex}` -> { constraints, bodyA, bodyB, sig }
|
|
32
35
|
this.intents = new Map(); // actorId -> { velocity?, impulse?, force? }
|
|
33
36
|
this.gravity = { ...DEFAULT_GRAVITY };
|
|
34
37
|
this.acc = 0;
|
|
@@ -44,6 +47,24 @@ export class PhysicsSystem {
|
|
|
44
47
|
runtime.physics = this.makeApi();
|
|
45
48
|
}
|
|
46
49
|
|
|
50
|
+
// Tear down all simulation state so the next step rebuilds from scratch. The
|
|
51
|
+
// runtime calls this from `scene.load()` -- a reload/restart/scene-transition
|
|
52
|
+
// must reset physics to the authored layout, otherwise bodies keep their last
|
|
53
|
+
// simulated positions and joints keep stale rest lengths (a reloaded scene
|
|
54
|
+
// would come back mid-fall / tangled instead of fresh). Dropping the engine
|
|
55
|
+
// lets `ensureEngine` build a clean one on the next step.
|
|
56
|
+
reset() {
|
|
57
|
+
this.engine = null;
|
|
58
|
+
this.bodies.clear();
|
|
59
|
+
this.joints.clear();
|
|
60
|
+
this.intents.clear();
|
|
61
|
+
this.active.clear();
|
|
62
|
+
this.ndActive.clear();
|
|
63
|
+
this.enterPairs.length = 0;
|
|
64
|
+
this.exitPairs.length = 0;
|
|
65
|
+
this.acc = 0;
|
|
66
|
+
}
|
|
67
|
+
|
|
47
68
|
ensureEngine() {
|
|
48
69
|
if (this.engine) return;
|
|
49
70
|
this.engine = Matter.Engine.create();
|
|
@@ -63,9 +84,11 @@ export class PhysicsSystem {
|
|
|
63
84
|
}
|
|
64
85
|
|
|
65
86
|
afterBehaviors(scene, dt) {
|
|
87
|
+
this.lastScene = scene; // for the author-facing getJoints() API
|
|
66
88
|
this.ensureEngine();
|
|
67
89
|
this.readGravity(scene);
|
|
68
90
|
this.reconcile(scene);
|
|
91
|
+
this.reconcileJoints(scene);
|
|
69
92
|
this.applyIntents();
|
|
70
93
|
this.syncStaticBodies(scene);
|
|
71
94
|
this.step(dt);
|
|
@@ -103,6 +126,57 @@ export class PhysicsSystem {
|
|
|
103
126
|
for (const id of [...this.bodies.keys()]) if (!seen.has(id)) this.removeBody(id);
|
|
104
127
|
}
|
|
105
128
|
|
|
129
|
+
// Create/rebuild/remove joint constraints to match actors carrying a Joints
|
|
130
|
+
// list. Runs after body reconcile so both endpoints' bodies exist. Each list
|
|
131
|
+
// entry is tracked under `ownerId#index`; a joint whose owner or target has no
|
|
132
|
+
// body (yet), or whose target is missing/self, is skipped -- it binds
|
|
133
|
+
// automatically once both bodies appear.
|
|
134
|
+
reconcileJoints(scene) {
|
|
135
|
+
const seen = new Set();
|
|
136
|
+
for (const actor of scene.getActors()) {
|
|
137
|
+
const list = actor.components.Joints?.list;
|
|
138
|
+
if (!Array.isArray(list) || list.length === 0) continue;
|
|
139
|
+
const bodyA = this.bodies.get(actor.id)?.body;
|
|
140
|
+
if (!bodyA) continue;
|
|
141
|
+
list.forEach((joint, i) => {
|
|
142
|
+
if (!joint?.target || joint.target === actor.id) return;
|
|
143
|
+
const bodyB = this.bodies.get(joint.target)?.body;
|
|
144
|
+
if (!bodyB) return;
|
|
145
|
+
const key = `${actor.id}#${i}`;
|
|
146
|
+
seen.add(key);
|
|
147
|
+
const sig = jointSignature(joint);
|
|
148
|
+
const entry = this.joints.get(key);
|
|
149
|
+
// Rebuild on structural change or when either endpoint's body was rebuilt
|
|
150
|
+
// (a collider edit swaps the body object, orphaning the old constraint).
|
|
151
|
+
if (!entry || entry.sig !== sig || entry.bodyA !== bodyA || entry.bodyB !== bodyB) {
|
|
152
|
+
this.removeJoint(key);
|
|
153
|
+
this.addJoint(key, bodyA, bodyB, joint, sig);
|
|
154
|
+
} else patchJoint(entry.constraints, bodyA, bodyB, joint);
|
|
155
|
+
// A weld is a pivot link PLUS frozen rotation on both bodies, so they
|
|
156
|
+
// can't turn relative to each other -- a rigid, stable fuse. Re-applied
|
|
157
|
+
// each frame because patchBody's setDensity resets inertia to finite.
|
|
158
|
+
if ((joint.type ?? 'spring') === 'weld') {
|
|
159
|
+
Matter.Body.setInertia(bodyA, Infinity);
|
|
160
|
+
Matter.Body.setInertia(bodyB, Infinity);
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
for (const key of [...this.joints.keys()]) if (!seen.has(key)) this.removeJoint(key);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
addJoint(id, bodyA, bodyB, joint, sig) {
|
|
168
|
+
const constraints = buildJointConstraints(bodyA, bodyB, joint);
|
|
169
|
+
for (const c of constraints) Matter.Composite.add(this.engine.world, c);
|
|
170
|
+
this.joints.set(id, { constraints, bodyA, bodyB, sig });
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
removeJoint(id) {
|
|
174
|
+
const entry = this.joints.get(id);
|
|
175
|
+
if (!entry) return;
|
|
176
|
+
for (const c of entry.constraints) Matter.Composite.remove(this.engine.world, c);
|
|
177
|
+
this.joints.delete(id);
|
|
178
|
+
}
|
|
179
|
+
|
|
106
180
|
addBody(actor, rect) {
|
|
107
181
|
const body = createBody(actor, rect);
|
|
108
182
|
const center = rectCenter(rect);
|
|
@@ -163,6 +237,7 @@ export class PhysicsSystem {
|
|
|
163
237
|
applyWorldGravity(dynamic, this.gravity);
|
|
164
238
|
applyAngularDrag(dynamic);
|
|
165
239
|
Matter.Engine.update(this.engine, FIXED_STEP_MS);
|
|
240
|
+
clampSpeeds(dynamic); // backstop: a joint blow-up can't fling a body off screen
|
|
166
241
|
this.acc -= FIXED_STEP_S;
|
|
167
242
|
steps += 1;
|
|
168
243
|
}
|
|
@@ -275,8 +350,45 @@ export class PhysicsSystem {
|
|
|
275
350
|
return this.active.has(pairKey(aId, bId));
|
|
276
351
|
},
|
|
277
352
|
bodyFor: (actor) => this.bodyOf(actor),
|
|
353
|
+
// Resolved joint geometry for custom rendering: each link's world-space
|
|
354
|
+
// endpoints (Layout centers, so art lines up with the sprites), type,
|
|
355
|
+
// length and angle. Draw your own art between `a` and `b` from a behavior's
|
|
356
|
+
// draw(); set the joint's `render: 'hidden'` so the built-in doesn't
|
|
357
|
+
// double-draw.
|
|
358
|
+
getJoints: () => this.collectJoints(),
|
|
278
359
|
};
|
|
279
360
|
}
|
|
361
|
+
|
|
362
|
+
collectJoints() {
|
|
363
|
+
const scene = this.lastScene;
|
|
364
|
+
if (!scene) return [];
|
|
365
|
+
const out = [];
|
|
366
|
+
for (const actor of scene.getActors()) {
|
|
367
|
+
const list = actor.components.Joints?.list;
|
|
368
|
+
if (!Array.isArray(list)) continue;
|
|
369
|
+
const a = centerOfLayout(actor.components.Layout);
|
|
370
|
+
for (const joint of list) {
|
|
371
|
+
const target = joint?.target && scene.getActor(joint.target);
|
|
372
|
+
if (!target || !a) continue;
|
|
373
|
+
const b = centerOfLayout(target.components.Layout);
|
|
374
|
+
if (!b) continue;
|
|
375
|
+
out.push({
|
|
376
|
+
ownerId: actor.id,
|
|
377
|
+
targetId: joint.target,
|
|
378
|
+
type: joint.type ?? 'spring',
|
|
379
|
+
a,
|
|
380
|
+
b,
|
|
381
|
+
length: Math.hypot(b.x - a.x, b.y - a.y),
|
|
382
|
+
angle: Math.atan2(b.y - a.y, b.x - a.x),
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
return out;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function centerOfLayout(L) {
|
|
391
|
+
return L ? { x: L.x + (L.width ?? 0) / 2, y: L.y + (L.height ?? 0) / 2 } : null;
|
|
280
392
|
}
|
|
281
393
|
|
|
282
394
|
function pairKey(a, b) {
|
|
@@ -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
|
+
}
|