castle-web-cli 0.4.120 → 0.4.122
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/editorConfig.d.ts +6 -1
- package/dist/editorConfig.js +20 -1
- package/dist/ide.d.ts +1 -0
- package/dist/ide.js +47 -1
- package/dist/init.js +1 -1
- package/dist/shell/assets/{index-Cxmq9Sed.js → index-DXBpj3-y.js} +58 -58
- package/dist/shell/index.html +1 -1
- package/dist/vitePlugins.js +21 -8
- package/kits/physics-2d/CLAUDE.md +56 -5
- package/kits/physics-2d/behaviors/AnalogStick.jsx +40 -9
- package/kits/physics-2d/behaviors/Collider.jsx +12 -0
- package/kits/physics-2d/behaviors/Draggable.jsx +67 -23
- package/kits/physics-2d/behaviors/Goal.jsx +2 -0
- package/kits/physics-2d/behaviors/Joints.jsx +5 -0
- package/kits/physics-2d/behaviors/RigidBody.jsx +16 -0
- package/kits/physics-2d/behaviors/Slingshot.jsx +48 -16
- package/kits/physics-2d/behaviors/Sound.jsx +11 -0
- package/kits/physics-2d/behaviors/Sprite.jsx +7 -0
- package/kits/physics-2d/behaviors/Tone.jsx +12 -0
- package/kits/physics-2d/behaviors/Video.jsx +6 -0
- package/kits/physics-2d/castle.json +1 -1
- package/kits/physics-2d/editors/SceneEditor.jsx +42 -6
- package/kits/physics-2d/editors/SingleEditor.jsx +43 -2
- package/kits/physics-2d/editors/editorRegistry.jsx +34 -0
- package/kits/physics-2d/engine/ScenePlayer.jsx +9 -3
- package/kits/physics-2d/engine/autoInspector.jsx +28 -3
- package/kits/physics-2d/engine/physics/PhysicsSystem.js +117 -0
- package/kits/physics-2d/engine/physics/controls.js +43 -19
- package/kits/physics-2d/engine/propertyRanges.js +27 -0
- package/kits/physics-2d/engine/scene.js +86 -4
- package/kits/physics-2d/engine/ui.jsx +14 -1
- package/kits/physics-2d/main.jsx +11 -2
- package/package.json +2 -1
|
@@ -2,6 +2,7 @@ import React from 'react';
|
|
|
2
2
|
import { playTone, WAVEFORMS } from '../engine/tone';
|
|
3
3
|
import { Panel, SelectField } from '../engine/ui';
|
|
4
4
|
import { AutoFields, overrideProps } from '../engine/autoInspector';
|
|
5
|
+
import { PAN, POSITIVE, UNIT } from '../engine/propertyRanges';
|
|
5
6
|
|
|
6
7
|
// Plays a synthesized note -- no audio file involved. This is the cheapest sound
|
|
7
8
|
// a deck can make: a blip on a pickup, a thud on a miss, a rising scale as a
|
|
@@ -27,6 +28,16 @@ export class Tone {
|
|
|
27
28
|
playOnStart: false,
|
|
28
29
|
};
|
|
29
30
|
|
|
31
|
+
// `note` is MIDI, which is genuinely 0..127. `attack`/`release` are seconds,
|
|
32
|
+
// so they get a floor but no ceiling.
|
|
33
|
+
static propertyMeta = {
|
|
34
|
+
note: { min: 0, max: 127, step: 1 },
|
|
35
|
+
attack: POSITIVE,
|
|
36
|
+
release: POSITIVE,
|
|
37
|
+
volume: UNIT,
|
|
38
|
+
pan: PAN,
|
|
39
|
+
};
|
|
40
|
+
|
|
30
41
|
constructor(props) {
|
|
31
42
|
this.props = props;
|
|
32
43
|
}
|
|
@@ -54,6 +65,7 @@ export class Tone {
|
|
|
54
65
|
/>
|
|
55
66
|
<AutoFields
|
|
56
67
|
defaultProps={Tone.defaultProps}
|
|
68
|
+
meta={Tone.propertyMeta}
|
|
57
69
|
component={component}
|
|
58
70
|
setComponent={setComponent}
|
|
59
71
|
only={['note', 'attack', 'release', 'volume', 'pan', 'playOnStart']}
|
|
@@ -5,6 +5,7 @@ import { mediaFilesOfKind } from '../engine/files';
|
|
|
5
5
|
import { spriteDestRect } from '../engine/spriteGeometry';
|
|
6
6
|
import { Panel, SelectField } from '../engine/ui';
|
|
7
7
|
import { AutoFields, overrideProps } from '../engine/autoInspector';
|
|
8
|
+
import { UNIT } from '../engine/propertyRanges';
|
|
8
9
|
|
|
9
10
|
// Plays a video file (.mp4 / .webm / .mov / .m4v) inside the actor's Layout box.
|
|
10
11
|
//
|
|
@@ -29,6 +30,10 @@ export class Video {
|
|
|
29
30
|
volume: 1,
|
|
30
31
|
};
|
|
31
32
|
|
|
33
|
+
static propertyMeta = {
|
|
34
|
+
volume: UNIT,
|
|
35
|
+
};
|
|
36
|
+
|
|
32
37
|
constructor(props) {
|
|
33
38
|
this.props = props;
|
|
34
39
|
}
|
|
@@ -100,6 +105,7 @@ export class Video {
|
|
|
100
105
|
/>
|
|
101
106
|
<AutoFields
|
|
102
107
|
defaultProps={Video.defaultProps}
|
|
108
|
+
meta={Video.propertyMeta}
|
|
103
109
|
component={component}
|
|
104
110
|
setComponent={setComponent}
|
|
105
111
|
only={['playing', 'loop', 'muted', 'volume']}
|
|
@@ -787,6 +787,38 @@ function initialBehaviorProps(Behavior, actor, sprites) {
|
|
|
787
787
|
return { ...Behavior.defaultProps };
|
|
788
788
|
}
|
|
789
789
|
|
|
790
|
+
// A behavior that can't work alone names the components it needs BESIDE IT on the
|
|
791
|
+
// same actor: `static expectedSiblings = ['Collider', ...]`. Adding it brings any
|
|
792
|
+
// that are missing along with it. Physics is the case that matters -- a body only
|
|
793
|
+
// exists for an actor with a Collider (matterBridge's `isPhysicsActor`), so a lone
|
|
794
|
+
// Draggable or RigidBody is silently inert: it looks added and does nothing.
|
|
795
|
+
//
|
|
796
|
+
// "Sibling" is the scope, and it is deliberately narrow. It means another
|
|
797
|
+
// component on THIS actor, never another actor -- a Joints link also needs its
|
|
798
|
+
// `target` actor to have a Collider, and that is not this, because the target is
|
|
799
|
+
// the author's to pick.
|
|
800
|
+
//
|
|
801
|
+
// "Expected" is the strength, and also deliberate: this fires when the editor
|
|
802
|
+
// adds a behavior and nowhere else. A blueprint hand-written (or agent-written)
|
|
803
|
+
// without the siblings is not corrected, and removing a Collider later is not
|
|
804
|
+
// blocked. It closes the common path, not every path.
|
|
805
|
+
//
|
|
806
|
+
// Depth-first, so a sibling's own siblings land first: Draggable expects
|
|
807
|
+
// RigidBody, which expects Collider, so the order is Collider, RigidBody,
|
|
808
|
+
// Draggable. Anything the actor already has is left exactly as authored, and
|
|
809
|
+
// `seen` makes a cyclic declaration terminate rather than recurse forever.
|
|
810
|
+
function behaviorsToAdd(behaviorName, actor, seen = new Set()) {
|
|
811
|
+
if (seen.has(behaviorName)) return [];
|
|
812
|
+
seen.add(behaviorName);
|
|
813
|
+
const out = [];
|
|
814
|
+
for (const sibling of findBehaviorClass(behaviorName)?.expectedSiblings ?? []) {
|
|
815
|
+
if (actor?.components?.[sibling]) continue;
|
|
816
|
+
out.push(...behaviorsToAdd(sibling, actor, seen));
|
|
817
|
+
}
|
|
818
|
+
out.push(behaviorName);
|
|
819
|
+
return out;
|
|
820
|
+
}
|
|
821
|
+
|
|
790
822
|
function makeSceneActions({ sceneData, files, serialize, commit, selectedActorIds, onSelectActorIds, isBlueprintFile, sprites, resolvedActors }) {
|
|
791
823
|
function commitScene(next, options) {
|
|
792
824
|
commit(serialize(next), options);
|
|
@@ -797,12 +829,16 @@ function makeSceneActions({ sceneData, files, serialize, commit, selectedActorId
|
|
|
797
829
|
coalesceKey: componentCoalesceKey(actorId, behaviorName, nextProps),
|
|
798
830
|
}),
|
|
799
831
|
addBehavior: (actorId, behaviorName) => {
|
|
800
|
-
|
|
801
|
-
if (!Behavior) return;
|
|
832
|
+
if (!findBehaviorClass(behaviorName)) return;
|
|
802
833
|
const resolved = resolvedActors?.find((actor) => actor.id === actorId);
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
)
|
|
834
|
+
// One commit for the whole set, so adding Draggable is a single undo.
|
|
835
|
+
let next = sceneData;
|
|
836
|
+
for (const name of behaviorsToAdd(behaviorName, resolved)) {
|
|
837
|
+
const Behavior = findBehaviorClass(name);
|
|
838
|
+
if (!Behavior) continue;
|
|
839
|
+
next = setActorComponent(next, actorId, name, initialBehaviorProps(Behavior, resolved, sprites));
|
|
840
|
+
}
|
|
841
|
+
if (next !== sceneData) commitScene(next);
|
|
806
842
|
},
|
|
807
843
|
removeBehavior: (actorId, behaviorName) =>
|
|
808
844
|
commitScene(removeActorComponent(sceneData, actorId, behaviorName)),
|
|
@@ -1072,7 +1108,7 @@ function usePlayPointerGesture({ canvasRef, runtimeRef }) {
|
|
|
1072
1108
|
const canvas = canvasRef.current;
|
|
1073
1109
|
const runtime = runtimeRef.current;
|
|
1074
1110
|
if (!canvas || !runtime) return;
|
|
1075
|
-
runtime.setPointerFromScreen(canvas, event.clientX, event.clientY, down);
|
|
1111
|
+
runtime.setPointerFromScreen(canvas, event.clientX, event.clientY, down, event.pointerId);
|
|
1076
1112
|
},
|
|
1077
1113
|
[canvasRef, runtimeRef]
|
|
1078
1114
|
);
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import React, { useEffect, useRef, useState } from 'react';
|
|
8
8
|
import { onBeforeRestart, onSaveReloadState, takeReloadState, writeFile } from 'castle-web-sdk';
|
|
9
9
|
import { getFileKind } from '../engine/files';
|
|
10
|
+
import { hasEditorModule, loadEditorModule } from './editorRegistry';
|
|
10
11
|
import { useLiveDeckFiles } from '../engine/liveReload';
|
|
11
12
|
import { collectAssets } from '../engine/assets';
|
|
12
13
|
import { MainEditor, styles } from '../engine/ui';
|
|
@@ -65,7 +66,33 @@ function useFileSaver() {
|
|
|
65
66
|
return { schedule, hasPending };
|
|
66
67
|
}
|
|
67
68
|
|
|
68
|
-
|
|
69
|
+
// A file type whose declaring deck named an editor module: load that module and
|
|
70
|
+
// render it, rather than matching the path against this kit's own list. This is
|
|
71
|
+
// what lets a deck hold two kits -- each type reaches its own declarer's editor,
|
|
72
|
+
// and neither kit has to know the other exists.
|
|
73
|
+
function DeclaredEditor({ path, module: modulePath, text, onChange, files, onChangeFile }) {
|
|
74
|
+
const [Editor, setEditor] = useState(null);
|
|
75
|
+
const [error, setError] = useState(null);
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
let alive = true;
|
|
78
|
+
setEditor(null);
|
|
79
|
+
setError(null);
|
|
80
|
+
loadEditorModule(modulePath).then(
|
|
81
|
+
(loaded) => alive && setEditor(() => loaded),
|
|
82
|
+
(e) => alive && setError(e instanceof Error ? e.message : String(e)),
|
|
83
|
+
);
|
|
84
|
+
return () => {
|
|
85
|
+
alive = false;
|
|
86
|
+
};
|
|
87
|
+
}, [modulePath]);
|
|
88
|
+
if (error) return <div className={styles.editorBody}>{error}</div>;
|
|
89
|
+
if (!Editor) return <div className={styles.editorBody}>loading editor…</div>;
|
|
90
|
+
return (
|
|
91
|
+
<Editor path={path} text={text} onChange={onChange} files={files} onChangeFile={onChangeFile} />
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function SingleEditor({ path, editor, editorModule }) {
|
|
69
96
|
// Selection survives a code-change reload: stashed via the SDK's save-state
|
|
70
97
|
// hook right before the reload, picked back up here on boot.
|
|
71
98
|
const stashKey = `single-editor:${path}`;
|
|
@@ -96,7 +123,21 @@ export function SingleEditor({ path, editor }) {
|
|
|
96
123
|
const kind = editor || getFileKind(path);
|
|
97
124
|
const text = files[path] ?? '';
|
|
98
125
|
let body = null;
|
|
99
|
-
|
|
126
|
+
// A declared module wins over this kit's own extension matching -- including
|
|
127
|
+
// over its own types, so the kit's editors are reachable the same way anyone
|
|
128
|
+
// else's are once physics-2d names them in castle.json.
|
|
129
|
+
if (editorModule && hasEditorModule(editorModule)) {
|
|
130
|
+
body = (
|
|
131
|
+
<DeclaredEditor
|
|
132
|
+
path={path}
|
|
133
|
+
module={editorModule}
|
|
134
|
+
text={text}
|
|
135
|
+
onChange={onChange}
|
|
136
|
+
files={files}
|
|
137
|
+
onChangeFile={onChangeFile}
|
|
138
|
+
/>
|
|
139
|
+
);
|
|
140
|
+
} else if (kind === 'scene') {
|
|
100
141
|
body = (
|
|
101
142
|
<SceneEditor
|
|
102
143
|
path={path}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Every editor module any deck here declares -- this deck's own and each
|
|
2
|
+
// import's -- keyed by its path from the deck root, which is exactly what
|
|
3
|
+
// `castle.json` `editor` resolves to (see resolveFileTypes in the CLI).
|
|
4
|
+
//
|
|
5
|
+
// A glob rather than a dynamic `import(path)`: the specifier has to be static
|
|
6
|
+
// for the module to survive bundling into a deck's single published file, the
|
|
7
|
+
// same reason behaviorRegistry and files.js glob. NOT eager, so an editor is a
|
|
8
|
+
// separate chunk that the play route never evaluates.
|
|
9
|
+
//
|
|
10
|
+
// Editors live under `editors/` by convention; that is what makes a third
|
|
11
|
+
// party's editor reachable from here without this kit knowing it exists.
|
|
12
|
+
const modules = {
|
|
13
|
+
...import.meta.glob('/imports/*/editors/**/*.{jsx,js}'),
|
|
14
|
+
...import.meta.glob('/editors/**/*.{jsx,js}'),
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export function hasEditorModule(path) {
|
|
18
|
+
return Boolean(path && modules[`/${path}`]);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Load one, returning its default export. Throws a legible error rather than
|
|
22
|
+
// letting the caller see `undefined is not a function` three frames later.
|
|
23
|
+
export async function loadEditorModule(path) {
|
|
24
|
+
const load = modules[`/${path}`];
|
|
25
|
+
if (!load) {
|
|
26
|
+
throw new Error(
|
|
27
|
+
`No editor module at "${path}". Editors must live under editors/ so the registry glob can reach them.`,
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
const mod = await load();
|
|
31
|
+
const Editor = mod.default ?? mod.Editor;
|
|
32
|
+
if (!Editor) throw new Error(`"${path}" has no default export to render as an editor.`);
|
|
33
|
+
return Editor;
|
|
34
|
+
}
|
|
@@ -40,14 +40,20 @@ export function ScenePlayer({ sceneData, sprites, files, behaviorClasses, onFirs
|
|
|
40
40
|
// its document/iframe holds focus. Grab focus on any click into the game
|
|
41
41
|
// (and on mount below) so the keyboard works inside the launcher embed.
|
|
42
42
|
canvas.focus();
|
|
43
|
-
runtime.setPointerFromScreen(canvas, event.clientX, event.clientY, true);
|
|
43
|
+
runtime.setPointerFromScreen(canvas, event.clientX, event.clientY, true, event.pointerId);
|
|
44
44
|
canvas.setPointerCapture(event.pointerId);
|
|
45
45
|
};
|
|
46
46
|
const onPointerMove = (event) => {
|
|
47
|
-
runtime.setPointerFromScreen(
|
|
47
|
+
runtime.setPointerFromScreen(
|
|
48
|
+
canvas,
|
|
49
|
+
event.clientX,
|
|
50
|
+
event.clientY,
|
|
51
|
+
undefined,
|
|
52
|
+
event.pointerId
|
|
53
|
+
);
|
|
48
54
|
};
|
|
49
55
|
const onPointerUp = (event) => {
|
|
50
|
-
runtime.setPointerFromScreen(canvas, event.clientX, event.clientY, false);
|
|
56
|
+
runtime.setPointerFromScreen(canvas, event.clientX, event.clientY, false, event.pointerId);
|
|
51
57
|
try {
|
|
52
58
|
canvas.releasePointerCapture(event.pointerId);
|
|
53
59
|
} catch {
|
|
@@ -12,7 +12,12 @@ export function overrideProps(override, prop) {
|
|
|
12
12
|
onReset: () => override.reset(prop),
|
|
13
13
|
};
|
|
14
14
|
}
|
|
15
|
-
|
|
15
|
+
// `meta` is the behavior's `static propertyMeta`. A numeric prop can declare
|
|
16
|
+
// `{ min, max, step }` there to get a real range instead of NumberField's
|
|
17
|
+
// defaults -- which are an unbounded scrubber stepping by whole numbers, wrong
|
|
18
|
+
// for anything that lives in 0..1. Declaring BOTH min and max makes it a true
|
|
19
|
+
// slider (fill + thumb). Props that declare nothing are unchanged.
|
|
20
|
+
export function AutoFields({ defaultProps, component, setComponent, only, exclude, override, meta }) {
|
|
16
21
|
const keys = Object.keys(defaultProps).filter((key) => {
|
|
17
22
|
if (only) return only.includes(key);
|
|
18
23
|
if (exclude) return !exclude.includes(key);
|
|
@@ -28,7 +33,19 @@ export function AutoFields({ defaultProps, component, setComponent, only, exclud
|
|
|
28
33
|
const sample = fallback ?? current;
|
|
29
34
|
const ov = overrideProps(override, key);
|
|
30
35
|
if (typeof sample === 'number') {
|
|
31
|
-
|
|
36
|
+
const { min, max, step } = meta?.[key] ?? {};
|
|
37
|
+
return (
|
|
38
|
+
<NumberField
|
|
39
|
+
key={key}
|
|
40
|
+
label={label}
|
|
41
|
+
value={current}
|
|
42
|
+
onChange={set}
|
|
43
|
+
{...(min == null ? {} : { min })}
|
|
44
|
+
{...(max == null ? {} : { max })}
|
|
45
|
+
{...(step == null ? {} : { step })}
|
|
46
|
+
{...ov}
|
|
47
|
+
/>
|
|
48
|
+
);
|
|
32
49
|
}
|
|
33
50
|
if (typeof sample === 'boolean') {
|
|
34
51
|
return <CheckboxField key={key} label={label} checked={current} onChange={set} {...ov} />;
|
|
@@ -49,7 +66,14 @@ export function AutoFields({ defaultProps, component, setComponent, only, exclud
|
|
|
49
66
|
</>
|
|
50
67
|
);
|
|
51
68
|
}
|
|
52
|
-
export function AutoInspector({
|
|
69
|
+
export function AutoInspector({
|
|
70
|
+
behaviorName,
|
|
71
|
+
defaultProps,
|
|
72
|
+
component,
|
|
73
|
+
setComponent,
|
|
74
|
+
override,
|
|
75
|
+
meta,
|
|
76
|
+
}) {
|
|
53
77
|
return (
|
|
54
78
|
<Panel title={humanizeKey(behaviorName)} overridden={override?.anyOverridden()}>
|
|
55
79
|
<AutoFields
|
|
@@ -57,6 +81,7 @@ export function AutoInspector({ behaviorName, defaultProps, component, setCompon
|
|
|
57
81
|
component={component}
|
|
58
82
|
setComponent={setComponent}
|
|
59
83
|
override={override}
|
|
84
|
+
meta={meta}
|
|
60
85
|
/>
|
|
61
86
|
</Panel>
|
|
62
87
|
);
|
|
@@ -33,6 +33,7 @@ export class PhysicsSystem {
|
|
|
33
33
|
this.bodies = new Map(); // actorId -> { body, sig, offset: {dx, dy} }
|
|
34
34
|
this.joints = new Map(); // `${ownerId}#${listIndex}` -> { constraints, bodyA, bodyB, sig }
|
|
35
35
|
this.intents = new Map(); // actorId -> { velocity?, impulse?, force? }
|
|
36
|
+
this.grabs = new Map(); // actorId -> { constraint, localPoint }
|
|
36
37
|
this.gravity = { ...DEFAULT_GRAVITY };
|
|
37
38
|
this.acc = 0;
|
|
38
39
|
this.enterPairs = []; // [aId, bId] collected during this frame's steps
|
|
@@ -57,6 +58,7 @@ export class PhysicsSystem {
|
|
|
57
58
|
this.engine = null;
|
|
58
59
|
this.bodies.clear();
|
|
59
60
|
this.joints.clear();
|
|
61
|
+
this.grabs.clear();
|
|
60
62
|
this.intents.clear();
|
|
61
63
|
this.active.clear();
|
|
62
64
|
this.ndActive.clear();
|
|
@@ -189,6 +191,12 @@ export class PhysicsSystem {
|
|
|
189
191
|
removeBody(id) {
|
|
190
192
|
const entry = this.bodies.get(id);
|
|
191
193
|
if (!entry) return;
|
|
194
|
+
// Drop any grab first: its constraint holds a direct reference to this body,
|
|
195
|
+
// and unlike joints (rebuilt every frame by reconcileJoints) nothing else
|
|
196
|
+
// revisits it -- so despawning a held actor, or editing its collider mid-play
|
|
197
|
+
// (a signature change removes and re-adds the body), would otherwise strand a
|
|
198
|
+
// constraint in the world pointing at a body that no longer exists.
|
|
199
|
+
this.release(id);
|
|
192
200
|
Matter.Composite.remove(this.engine.world, entry.body);
|
|
193
201
|
this.bodies.delete(id);
|
|
194
202
|
// Drop live contacts involving this actor so isColliding doesn't go stale
|
|
@@ -314,6 +322,102 @@ export class PhysicsSystem {
|
|
|
314
322
|
return this.bodies.get(id)?.body ?? null;
|
|
315
323
|
}
|
|
316
324
|
|
|
325
|
+
// Exact point-in-collider test. The matter body is already positioned and
|
|
326
|
+
// rotated by the sim, so this respects the collider's real shape (circle,
|
|
327
|
+
// angled box, compound) at its CURRENT orientation for free -- no transform
|
|
328
|
+
// math here, and nothing to keep in sync with Layout.
|
|
329
|
+
//
|
|
330
|
+
// An actor with no body has no touch zone, which is the intended semantics: a
|
|
331
|
+
// thing with no collider is not grabbable.
|
|
332
|
+
containsPoint(actorOrId, x, y) {
|
|
333
|
+
const body = this.bodyOf(actorOrId);
|
|
334
|
+
if (!body) return false;
|
|
335
|
+
const point = { x, y };
|
|
336
|
+
if (!Matter.Bounds.contains(body.bounds, point)) return false;
|
|
337
|
+
// parts[0] is the parent's convex HULL when the body is compound, so testing
|
|
338
|
+
// it would report hits in a concave notch (the underside of a skateboard's
|
|
339
|
+
// deck-plus-nose-and-tail). Test the real parts instead.
|
|
340
|
+
const parts = body.parts.length > 1 ? body.parts.slice(1) : body.parts;
|
|
341
|
+
return parts.some((part) => Matter.Vertices.contains(part.vertices, point));
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Topmost actor whose COLLIDER contains the point, high z first to match draw
|
|
345
|
+
// order. This is the pick a touch control wants; `scene.actorAt` tests the
|
|
346
|
+
// Layout box instead, which is what the editor wants for click-to-select.
|
|
347
|
+
//
|
|
348
|
+
// Deliberately NOT via scene.getActors(): that copies and sorts the actor list
|
|
349
|
+
// on every call, and every Draggable and Slingshot in the scene calls this on
|
|
350
|
+
// the same press frame -- O(n log n) each, once per actor, plus an array per
|
|
351
|
+
// call. Tracking the best z in one pass is O(n) with no allocation, and the
|
|
352
|
+
// `z < bestZ` test skips the shape work for actors that cannot win anyway.
|
|
353
|
+
// Ties go to the later actor, matching the old reverse-iteration order.
|
|
354
|
+
actorAtPoint(scene, x, y) {
|
|
355
|
+
let best = null;
|
|
356
|
+
let bestZ = -Infinity;
|
|
357
|
+
for (const actor of scene.actors.values()) {
|
|
358
|
+
const z = actor.components?.Layout?.z ?? 0;
|
|
359
|
+
if (z < bestZ) continue;
|
|
360
|
+
if (!this.containsPoint(actor, x, y)) continue;
|
|
361
|
+
best = actor;
|
|
362
|
+
bestZ = z;
|
|
363
|
+
}
|
|
364
|
+
return best;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// Grab an actor AT a world point: a constraint from that exact spot on the
|
|
368
|
+
// body to the pointer, so an off-center grab induces torque and the object
|
|
369
|
+
// swings the way a real held object does. Matter rotates the anchor with the
|
|
370
|
+
// body from here on, so the grab stays welded to the spot that was touched.
|
|
371
|
+
//
|
|
372
|
+
// `pointA` is a world-axis offset from the body center at creation time
|
|
373
|
+
// (Constraint.create seeds `angleA` to the body's current angle), and matter
|
|
374
|
+
// mutates it in place each solve -- so never hand it an object we also keep.
|
|
375
|
+
grab(actorOrId, x, y, opts = {}) {
|
|
376
|
+
const body = this.bodyOf(actorOrId);
|
|
377
|
+
if (!body || !this.engine) return false;
|
|
378
|
+
const id = typeof actorOrId === 'string' ? actorOrId : actorOrId?.id;
|
|
379
|
+
this.release(id);
|
|
380
|
+
const constraint = Matter.Constraint.create({
|
|
381
|
+
bodyA: body,
|
|
382
|
+
pointA: { x: x - body.position.x, y: y - body.position.y },
|
|
383
|
+
pointB: { x, y },
|
|
384
|
+
length: 0,
|
|
385
|
+
// Modest stiffness + real damping on purpose: MAX_SPEED backstops a
|
|
386
|
+
// blow-up, but a near-1 stiffness on a heavy body is matter's classic
|
|
387
|
+
// energy-injection case and we would rather not lean on the clamp.
|
|
388
|
+
stiffness: opts.stiffness ?? 0.1,
|
|
389
|
+
damping: opts.damping ?? 0.2,
|
|
390
|
+
});
|
|
391
|
+
Matter.Composite.add(this.engine.world, constraint);
|
|
392
|
+
this.grabs.set(id, { constraint });
|
|
393
|
+
return true;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
moveGrab(actorOrId, x, y) {
|
|
397
|
+
const id = typeof actorOrId === 'string' ? actorOrId : actorOrId?.id;
|
|
398
|
+
const entry = this.grabs.get(id);
|
|
399
|
+
if (!entry) return;
|
|
400
|
+
entry.constraint.pointB = { x, y };
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
release(actorOrId) {
|
|
404
|
+
const id = typeof actorOrId === 'string' ? actorOrId : actorOrId?.id;
|
|
405
|
+
const entry = this.grabs.get(id);
|
|
406
|
+
if (!entry) return;
|
|
407
|
+
if (this.engine) Matter.Composite.remove(this.engine.world, entry.constraint);
|
|
408
|
+
this.grabs.delete(id);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// Where the grab is holding the body right now, in world space -- read from
|
|
412
|
+
// the constraint matter is actually solving, so the drawn handle cannot drift
|
|
413
|
+
// away from the physics.
|
|
414
|
+
grabPoint(actorOrId) {
|
|
415
|
+
const id = typeof actorOrId === 'string' ? actorOrId : actorOrId?.id;
|
|
416
|
+
const entry = this.grabs.get(id);
|
|
417
|
+
if (!entry) return null;
|
|
418
|
+
return Matter.Constraint.pointAWorld(entry.constraint);
|
|
419
|
+
}
|
|
420
|
+
|
|
317
421
|
queue(actorOrId, key, value) {
|
|
318
422
|
const id = typeof actorOrId === 'string' ? actorOrId : actorOrId?.id;
|
|
319
423
|
if (!id) return;
|
|
@@ -350,6 +454,19 @@ export class PhysicsSystem {
|
|
|
350
454
|
return this.active.has(pairKey(aId, bId));
|
|
351
455
|
},
|
|
352
456
|
bodyFor: (actor) => this.bodyOf(actor),
|
|
457
|
+
// Shape-accurate hit testing: does this actor's collider contain the
|
|
458
|
+
// point, and which actor is topmost at it. Respects rotation and compound
|
|
459
|
+
// shapes, unlike the Layout-box `scene.actorAt`.
|
|
460
|
+
containsPoint: (actor, x, y) => this.containsPoint(actor, x, y),
|
|
461
|
+
actorAtPoint: (x, y) =>
|
|
462
|
+
this.lastScene ? this.actorAtPoint(this.lastScene, x, y) : null,
|
|
463
|
+
// Hold an actor at a world point and drag it there; an off-center grab
|
|
464
|
+
// turns the body as well as moving it. `grabPoint` is where the hold sits
|
|
465
|
+
// now, for drawing.
|
|
466
|
+
grab: (actor, x, y, opts) => this.grab(actor, x, y, opts),
|
|
467
|
+
moveGrab: (actor, x, y) => this.moveGrab(actor, x, y),
|
|
468
|
+
release: (actor) => this.release(actor),
|
|
469
|
+
grabPoint: (actor) => this.grabPoint(actor),
|
|
353
470
|
// Resolved joint geometry for custom rendering: each link's world-space
|
|
354
471
|
// endpoints (Layout centers, so art lines up with the sprites), type,
|
|
355
472
|
// length and angle. Draw your own art between `a` and `b` from a behavior's
|
|
@@ -5,6 +5,21 @@
|
|
|
5
5
|
// through scene.physics. (Controls are touch/pointer-first; keyboard, when
|
|
6
6
|
// added, should only DUPLICATE an on-screen control, never be the only input.)
|
|
7
7
|
|
|
8
|
+
// Authored Draggable `stiffness` (0..1, 0 = floppy .. 1 = rigid) -> the matter
|
|
9
|
+
// constraint values that hold the object. Geometric, like joints.js's rope map
|
|
10
|
+
// and for the same reason: matter's useful range here is ~0.001 (slack) to ~0.4
|
|
11
|
+
// (tight), and everything above that is indistinguishably rigid -- so a linear
|
|
12
|
+
// prop spends nearly all of its travel in "stiff" and never reaches the loose
|
|
13
|
+
// end at all.
|
|
14
|
+
//
|
|
15
|
+
// Damping rides along rather than being its own knob: a slack band should be
|
|
16
|
+
// free to oscillate and a tight one shouldn't, so one control moves the whole
|
|
17
|
+
// feel together.
|
|
18
|
+
export function dragHold(stiffness) {
|
|
19
|
+
const s = Math.min(1, Math.max(0, stiffness ?? 0.3));
|
|
20
|
+
return { stiffness: 0.001 * Math.pow(400, s), damping: 0.02 + 0.25 * s };
|
|
21
|
+
}
|
|
22
|
+
|
|
8
23
|
export function centerOf(layout) {
|
|
9
24
|
return { x: layout.x + layout.width / 2, y: layout.y + layout.height / 2 };
|
|
10
25
|
}
|
|
@@ -21,26 +36,35 @@ export function clampLength(v, max) {
|
|
|
21
36
|
return { x: v.x * s, y: v.y * s };
|
|
22
37
|
}
|
|
23
38
|
|
|
24
|
-
// Springy chase velocity toward a target point (Draggable): a fraction of the
|
|
25
|
-
// gap per step, so the body accelerates toward the pointer and still collides.
|
|
26
|
-
export function chaseVelocity(target, layout, props = {}) {
|
|
27
|
-
const stiffness = props.stiffness ?? 0.5;
|
|
28
|
-
const c = centerOf(layout);
|
|
29
|
-
return { x: (target.x - c.x) * stiffness, y: (target.y - c.y) * stiffness };
|
|
30
|
-
}
|
|
31
|
-
|
|
32
39
|
// --- Touch ownership -------------------------------------------------------
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
40
|
+
// Each control claims the pointer it started on (scene.claimPointer), so a
|
|
41
|
+
// finger drives exactly one thing and the others leave it alone. Two priorities
|
|
42
|
+
// are all this needs:
|
|
43
|
+
//
|
|
44
|
+
// TARGETED the press landed on THIS actor's collider, so the intent is
|
|
45
|
+
// unambiguous -- a Draggable being grabbed, a Slingshot pressed on
|
|
46
|
+
// its own body.
|
|
47
|
+
// GREEDY the control takes any press anywhere (a Slingshot with
|
|
48
|
+
// grabAnywhere, an AnalogStick), so it must yield to a targeted one.
|
|
49
|
+
//
|
|
50
|
+
// Ranking them explicitly is what makes the outcome independent of actor order.
|
|
51
|
+
// Previously this was a `pressOnDraggable` query that hardcoded one behavior's
|
|
52
|
+
// name into shared code and had no answer at all once two fingers were down.
|
|
53
|
+
export const TARGETED_CLAIM = 10;
|
|
54
|
+
export const GREEDY_CLAIM = 0;
|
|
55
|
+
|
|
56
|
+
// Take the first fresh press this frame that `actorId` is allowed to have, and
|
|
57
|
+
// claim it. `priorityFor(pointer)` returns the claim priority to bid, or null to
|
|
58
|
+
// pass on that pointer -- which is the only part that differs between controls.
|
|
59
|
+
// Returns the claimed pointer, or null if there was nothing to take.
|
|
60
|
+
export function acquirePress(scene, actorId, priorityFor) {
|
|
61
|
+
for (const p of scene.pointers.values()) {
|
|
62
|
+
if (!p.justPressed) continue;
|
|
63
|
+
const priority = priorityFor(p);
|
|
64
|
+
if (priority == null) continue;
|
|
65
|
+
if (scene.claimPointer(p.id, actorId, priority)) return p;
|
|
66
|
+
}
|
|
67
|
+
return null;
|
|
44
68
|
}
|
|
45
69
|
|
|
46
70
|
// Run `draw(ctx)` in world space, undoing the actor's Layout rotation that the
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Named inspector ranges for behaviors' `static propertyMeta`.
|
|
2
|
+
//
|
|
3
|
+
// A numeric prop with no meta renders as an unbounded scrubber stepping by whole
|
|
4
|
+
// numbers, which is wrong for anything living in 0..1. The recurring shapes get a
|
|
5
|
+
// name here so "a normalized level" is defined once instead of restated in every
|
|
6
|
+
// behavior -- and so a range change lands everywhere at once.
|
|
7
|
+
//
|
|
8
|
+
// A `max` is a hard authoring ceiling, not just a slider bound: typed entry is
|
|
9
|
+
// clamped to it too (see NumberField's `commit`). So only give something a max
|
|
10
|
+
// when exceeding it is meaningless or actively breaks the simulation.
|
|
11
|
+
|
|
12
|
+
// A normalized 0..1 level: volume, and anything else expressed as a fraction.
|
|
13
|
+
export const UNIT = { min: 0, max: 1, step: 0.05 };
|
|
14
|
+
|
|
15
|
+
// Stereo position, -1 (left) .. +1 (right). Hard-bounded by definition.
|
|
16
|
+
export const PAN = { min: -1, max: 1, step: 0.05 };
|
|
17
|
+
|
|
18
|
+
// Open-ended and non-negative -- a floor, but no ceiling, for quantities matter
|
|
19
|
+
// or the browser treats as unbounded (restitution, friction, seconds).
|
|
20
|
+
export const POSITIVE = { min: 0, step: 0.05 };
|
|
21
|
+
|
|
22
|
+
// Matter's air/angular friction. Above 1 it flips the velocity sign and the body
|
|
23
|
+
// destabilizes, so 1 is a real ceiling. Finer step: the useful values are small.
|
|
24
|
+
export const DAMPING = { min: 0, max: 1, step: 0.01 };
|
|
25
|
+
|
|
26
|
+
// A distance in card units (px).
|
|
27
|
+
export const PIXELS = { min: 0, step: 5 };
|