castle-web-cli 0.4.119 → 0.4.121

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.
Files changed (44) hide show
  1. package/dist/api.js +13 -2
  2. package/dist/castle-host/host.d.ts +16 -0
  3. package/dist/castle-host/host.js +249 -11
  4. package/dist/castle-host/leaderboardPanel.d.ts +73 -0
  5. package/dist/castle-host/leaderboardPanel.js +714 -0
  6. package/dist/ide.d.ts +3 -0
  7. package/dist/ide.js +79 -0
  8. package/dist/index.js +1 -6
  9. package/dist/init.js +1 -1
  10. package/dist/preview.d.ts +0 -1
  11. package/dist/preview.js +18 -23
  12. package/dist/save-deck.js +33 -2
  13. package/dist/serve.js +9 -4
  14. package/dist/shell/assets/index-BkJ87APM.css +1 -0
  15. package/dist/shell/assets/index-UEwjXWKI.js +434 -0
  16. package/dist/shell/index.html +2 -2
  17. package/kits/physics-2d/CLAUDE.md +64 -9
  18. package/kits/physics-2d/behaviors/AnalogStick.jsx +40 -9
  19. package/kits/physics-2d/behaviors/Collider.jsx +12 -0
  20. package/kits/physics-2d/behaviors/Draggable.jsx +67 -23
  21. package/kits/physics-2d/behaviors/Goal.jsx +2 -0
  22. package/kits/physics-2d/behaviors/Joints.jsx +5 -0
  23. package/kits/physics-2d/behaviors/RigidBody.jsx +16 -0
  24. package/kits/physics-2d/behaviors/Slingshot.jsx +48 -16
  25. package/kits/physics-2d/behaviors/Sound.jsx +11 -0
  26. package/kits/physics-2d/behaviors/Sprite.jsx +7 -0
  27. package/kits/physics-2d/behaviors/Tone.jsx +12 -0
  28. package/kits/physics-2d/behaviors/Video.jsx +6 -0
  29. package/kits/physics-2d/castle.json +1 -1
  30. package/kits/physics-2d/editors/SceneEditor.jsx +53 -8
  31. package/kits/physics-2d/editors/pixelInspector.jsx +30 -9
  32. package/kits/physics-2d/editors/pxArtTimeline.jsx +147 -18
  33. package/kits/physics-2d/engine/ScenePlayer.jsx +9 -3
  34. package/kits/physics-2d/engine/autoInspector.jsx +28 -3
  35. package/kits/physics-2d/engine/physics/PhysicsSystem.js +117 -0
  36. package/kits/physics-2d/engine/physics/controls.js +43 -19
  37. package/kits/physics-2d/engine/propertyRanges.js +27 -0
  38. package/kits/physics-2d/engine/scene.js +86 -4
  39. package/kits/physics-2d/engine/ui.jsx +14 -1
  40. package/kits/physics-2d/engine/ui.module.css +1 -0
  41. package/kits/physics-2d/package-lock.json +1 -1
  42. package/package.json +2 -1
  43. package/dist/shell/assets/index-WtgFx1s8.js +0 -144
  44. package/dist/shell/assets/index-Y6cJRRCX.css +0 -1
@@ -10,8 +10,8 @@
10
10
  <link rel="icon" type="image/png" sizes="32x32" href="/__castle/ide/favicon-32x32.png" />
11
11
  <link rel="icon" type="image/png" sizes="16x16" href="/__castle/ide/favicon-16x16.png" />
12
12
  <link rel="icon" href="/__castle/ide/favicon.ico" sizes="any" />
13
- <script type="module" crossorigin src="/__castle/ide/assets/index-WtgFx1s8.js"></script>
14
- <link rel="stylesheet" crossorigin href="/__castle/ide/assets/index-Y6cJRRCX.css">
13
+ <script type="module" crossorigin src="/__castle/ide/assets/index-UEwjXWKI.js"></script>
14
+ <link rel="stylesheet" crossorigin href="/__castle/ide/assets/index-BkJ87APM.css">
15
15
  </head>
16
16
  <body>
17
17
  <div id="root"></div>
@@ -72,7 +72,21 @@ A behavior is a class. Minimal contract:
72
72
  // behaviors/MyThing.jsx
73
73
  export class MyThing {
74
74
  static behaviorName = 'MyThing'; // must match the key used in .scene
75
- static defaultProps = { speed: 200 };
75
+ static defaultProps = { speed: 200, power: 0.5 };
76
+
77
+ // Optional. Inspector ranges for numeric props. Without one, a number field is
78
+ // an unbounded scrubber stepping by 1 — wrong for anything living in 0..1.
79
+ // Giving BOTH min and max makes it a real slider. A `max` also caps typed
80
+ // entry, so only set one where going past it is meaningless.
81
+ static propertyMeta = {
82
+ power: { min: 0, max: 1, step: 0.05 },
83
+ };
84
+
85
+ // Optional. Other components this one needs BESIDE IT on the same actor.
86
+ // Adding this behavior in the editor adds any that are missing. It does NOT
87
+ // fix a blueprint you write by hand — so when you author a blueprint, include
88
+ // the siblings yourself.
89
+ static expectedSiblings = ['Collider'];
76
90
 
77
91
  constructor(props) {
78
92
  this.props = props;
@@ -192,10 +206,14 @@ make it move.**
192
206
  (`mode`, `width`/`height`, `offsetX`/`offsetY`, `debug`) it has:
193
207
  - `shape: 'box' | 'circle'` — the physics shape (default `box`). A circle uses
194
208
  `radius`, or half the smaller collider dimension if `radius` is 0.
195
- - A collider can hold **multiple shapes** (a compound body) — edit them in the
196
- inspector's shape list. A **box shape takes an `angle`** (degrees), so one
197
- actor can have an angled collider (e.g. a skateboard: a flat deck box plus an
198
- upturned nose and tail box) instead of gluing separate actors together.
209
+ - A collider can hold **multiple shapes** (a compound body) — add and edit them
210
+ in the inspector's shape list. Each entry in `shapes` is `{ type, ... }` sized
211
+ in **fractions of the Layout box** (`0.5` = centered, `1` = full width), so a
212
+ shape keeps its proportions when the actor is resized: a box takes
213
+ `x`/`y`/`w`/`h`, a circle `x`/`y`/`r`.
214
+ - A **box shape takes an `angle`** (degrees), so one actor can have an angled
215
+ collider (e.g. a skateboard: a flat deck box plus an upturned nose and tail
216
+ box) instead of gluing separate actors together.
199
217
  - `isTrigger` — a **sensor**: detects overlaps (fires collision callbacks) but
200
218
  does **not** block. Use for pickups, goals, zones.
201
219
  - `bounciness` — restitution, 0 (dead) to ~1 (very bouncy), can exceed 1.
@@ -227,6 +245,11 @@ In a behavior's `update`, steer bodies through `scene.physics` (never write
227
245
  - `scene.physics.applyForce(actor, { x, y })` — add a matter-space force.
228
246
  - `scene.physics.getVelocity(actor)` — current velocity `{ x, y }`.
229
247
  - `scene.physics.isColliding(a, b)` — are these two actors touching right now?
248
+ - `scene.physics.actorAtPoint(x, y)` — topmost actor whose **collider** contains
249
+ the point, or null. Shape-accurate and rotation-aware, so it's what a tap test
250
+ should use. (`scene.actorAt` tests the rectangular Layout box instead, which is
251
+ looser — it's what the editor uses for click-to-select.)
252
+ - `scene.physics.containsPoint(actor, x, y)` — the same test for one actor.
230
253
 
231
254
  ### Collision callbacks
232
255
 
@@ -250,13 +273,37 @@ export class Coin {
250
273
  ### Built-in controls (add these behaviors, no code needed)
251
274
 
252
275
  These read input and drive the actor through the physics system, so the actor
253
- still collides with the world. Each needs a `RigidBody` (dynamic) + `Collider`.
276
+ still collides with the world. Each needs a `RigidBody` (dynamic) + `Collider` --
277
+ **not optional**: no Collider means no physics body at all, and the control is
278
+ silently inert. The editor adds them for you (`expectedSiblings`), but a
279
+ blueprint you write by hand must include them itself.
254
280
  Controls are **touch/pointer-first** (Castle games are played on mobile first;
255
281
  touch degrades to mouse). Keyboard, if added, should only *duplicate* an
256
282
  on-screen control -- never be the only way to play.
257
283
 
258
- - **`Draggable`** — grab the actor with the pointer and fling it (springy,
259
- collides). `stiffness`.
284
+ - **`Draggable`** — grab the actor with the pointer and fling it (collides).
285
+ `stiffness`.
286
+ - **The touch zone is the `Collider`, at its live orientation** — not the
287
+ Layout box. A circle is round to the touch, and a rotated actor's grabbable
288
+ area rotates with it. An actor whose collider is smaller than its Layout box
289
+ (which `mode: 'auto'` produces, since it shrink-wraps the sprite's opaque
290
+ pixels) is grabbable only where the collider is.
291
+ - **The hold is at the point you touched**, so grabbing off-center turns the
292
+ object as well as moving it — grab a plank by one end and it swings.
293
+ - **A drag belongs to the finger that started it.** Later fingers can't steal
294
+ or drop it, and a second finger can drag a second actor at the same time.
295
+ - `stiffness` is `0` (loose, swingy) to `1` (rigid, snaps to the pointer),
296
+ default `0.3`. It's mapped onto matter's very compressed useful band, so the
297
+ low end is genuinely floppy — reach for `0.1`–`0.3` for a rag-doll feel and
298
+ `0.7`+ for a tight one.
299
+
300
+ **All three controls are per-finger**, and each claims the pointer it started on,
301
+ so they coexist: a player can work an `AnalogStick` with one thumb while dragging
302
+ with the other, and two `Draggable` actors can be dragged at once. A press that
303
+ lands on a control's own collider outranks one that merely takes any press, so a
304
+ `Slingshot` set to `grabAnywhere` never steals a finger that grabbed a
305
+ `Draggable`. Custom controls get the same treatment via `scene.claimPointer` —
306
+ see the SceneRuntime API below.
260
307
  - **`Slingshot`** — press the actor, pull back, release to launch it the
261
308
  opposite way (angry-birds), with an aim arrow. `speed`, `maxDrag`.
262
309
  - **`AnalogStick`** — on-screen virtual joystick (press anywhere) that drives
@@ -362,6 +409,12 @@ draw(actor, scene, ctx) {
362
409
  directly is only for `static`/`kinematic` bodies.
363
410
  - **Editor vs play.** Physics only steps during play; in the editor actors sit
364
411
  where you place them. `RigidBody.velocityX/Y` apply once when play starts.
412
+ - **No `Collider` means no physics at all.** A body only exists for an actor with
413
+ a Collider, so `RigidBody`, `Draggable`, `Slingshot`, `AnalogStick`, `Joints`
414
+ and collision callbacks are all silently inert without one — nothing errors,
415
+ the actor just sits there. The editor's add-behavior button pulls the missing
416
+ pieces in, but a blueprint you write yourself gets no such help: if it has any
417
+ of those, give it a `Collider` too.
365
418
  - **A joint binds only when both actors have a body.** If a `Joints` entry's
366
419
  owner or `target` has no `Collider` (or the target id is wrong/missing), that
367
420
  link is silently skipped until both bodies exist. `weld`/`pin` want the two
@@ -427,7 +480,9 @@ To generate path art, emit an svg of **shapes** rather than per-pixel rects and
427
480
 
428
481
  - `scene.time` — seconds since start.
429
482
  - `scene.keys` — `Set` of currently-held KeyboardEvent codes (e.g. `'ArrowLeft'`, `'KeyA'`, `'Space'`). Read in `update`.
430
- - `scene.pointer` — `{ x, y, down }` in world (card) coordinates, camera-adjusted.
483
+ - `scene.pointer` — `{ x, y, down }` in world (card) coordinates, camera-adjusted. The **primary** pointer: the one held longest. Use this for anything single-touch.
484
+ - `scene.pointers` — `Map` of every active pointer (finger / mouse / stylus) by id, each `{ id, x, y, down, justPressed, claimedBy }`, same coordinates. For multi-touch: latch the `id` a gesture began on and follow that entry, so another finger landing can't hijack it. `justPressed` is that pointer's press edge for this frame (a shared `down` flag would miss a finger landing while another is already held). Entries disappear on release.
485
+ - `scene.claimPointer(id, ownerId, priority?)` / `scene.ownsPointer(id, ownerId)` — take a finger for one gesture so two controls can't both act on it, and check you still hold it. A higher `priority` takes it from a lower one, which is how a control that was actually pressed (a `Draggable`'s collider) beats one that takes any press (an `AnalogStick`), regardless of actor order. Claims vanish with the pointer, so there's nothing to release. This is what lets a player work a stick with one thumb and drag with the other.
431
486
  - `scene.getActor(id)` / `scene.getActors()` (sorted by Layout.z) / `scene.getComponent(actor, name)`.
432
487
  - `scene.actorWith('GameController')` / `scene.actorsWith('Brick')` — find one / all actors carrying a given behavior. Prefer these to `getActors().find(a => a.components.X)`.
433
488
  - `scene.colliderRect(actorOrId)` — rect from Layout + Collider, or null.
@@ -1,7 +1,13 @@
1
1
  import React from 'react';
2
2
  import { Panel, SelectField } from '../engine/ui';
3
3
  import { AutoFields, overrideProps } from '../engine/autoInspector';
4
- import { analogVelocity, inActorWorldSpace, pressOnDraggable, stickVector } from '../engine/physics/controls';
4
+ import {
5
+ GREEDY_CLAIM,
6
+ acquirePress,
7
+ analogVelocity,
8
+ inActorWorldSpace,
9
+ stickVector,
10
+ } from '../engine/physics/controls';
5
11
 
6
12
  // AnalogStick: an on-screen virtual joystick that drives the actor it's on --
7
13
  // the look and feel of castle-client's Analog Stick. Press anywhere to place
@@ -13,6 +19,8 @@ import { analogVelocity, inActorWorldSpace, pressOnDraggable, stickVector } from
13
19
  // axes -> restrict to 'horizontal' / 'vertical' / 'both'.
14
20
  export class AnalogStick {
15
21
  static behaviorName = 'AnalogStick';
22
+ // Drives a body, so it needs one: Collider + dynamic RigidBody.
23
+ static expectedSiblings = ['Collider', 'RigidBody'];
16
24
 
17
25
  static defaultProps = {
18
26
  speed: 6,
@@ -20,27 +28,49 @@ export class AnalogStick {
20
28
  axes: 'both',
21
29
  };
22
30
 
31
+ // `maxRadius` is the stick's travel in px; below ~10 the knob has no usable
32
+ // throw and the control reads as broken.
33
+ static propertyMeta = {
34
+ speed: { min: 0, step: 0.5 },
35
+ maxRadius: { min: 10, step: 5 },
36
+ };
37
+
23
38
  constructor(props) {
24
39
  this.props = props;
25
40
  }
26
41
 
27
- update(actor, scene) {
28
- const p = scene.pointer;
42
+ // The finger this stick is following, placing it on a fresh press if it isn't
43
+ // following one yet. The stick stays with the finger that placed it, so a
44
+ // second finger elsewhere (another stick, a drag) never yanks it -- and it
45
+ // gives the pointer up if a targeted control outranks its claim.
46
+ trackedPointer(actor, scene) {
29
47
  const rt = actor.runtime;
30
- const pressed = p.down && !rt._stickWasDown;
31
- rt._stickWasDown = p.down;
32
- // A press on a draggable is consumed by the drag; every other press drives
33
- // the stick.
34
- if (pressed && !pressOnDraggable(scene)) rt._stickOrigin = { x: p.x, y: p.y };
48
+ if (rt._stickPointerId != null && scene.ownsPointer(rt._stickPointerId, actor.id)) {
49
+ const held = scene.pointers.get(rt._stickPointerId);
50
+ if (held?.down) return held;
51
+ }
52
+ rt._stickPointerId = null;
53
+ // Any fresh press will do -- the stick is placed wherever you touch -- but
54
+ // only one nobody more specific wants.
55
+ const press = acquirePress(scene, actor.id, () => GREEDY_CLAIM);
56
+ if (!press) return null;
57
+ rt._stickPointerId = press.id;
58
+ rt._stickOrigin = { x: press.x, y: press.y };
59
+ return press;
60
+ }
35
61
 
62
+ update(actor, scene) {
63
+ const rt = actor.runtime;
64
+ const p = this.trackedPointer(actor, scene);
36
65
  // Only drive velocity while the stick is actually held.
37
- if (p.down && rt._stickOrigin) {
66
+ if (p?.down && rt._stickOrigin) {
38
67
  rt._stickKnob = stickVector(p, rt._stickOrigin, this.props.maxRadius ?? 60);
39
68
  scene.physics.setVelocity(actor, analogVelocity(p, rt._stickOrigin, this.props));
40
69
  rt._stickActive = true;
41
70
  return;
42
71
  }
43
72
 
73
+ rt._stickPointerId = null;
44
74
  rt._stickOrigin = null;
45
75
  rt._stickKnob = null;
46
76
  // Stop ONCE on release, and only on the driven axes (so a top-down mover
@@ -92,6 +122,7 @@ export class AnalogStick {
92
122
  />
93
123
  <AutoFields
94
124
  defaultProps={AnalogStick.defaultProps}
125
+ meta={AnalogStick.propertyMeta}
95
126
  component={component}
96
127
  setComponent={setComponent}
97
128
  exclude={['axes']}
@@ -1,6 +1,7 @@
1
1
  import React, { useEffect, useState } from 'react';
2
2
  import { NumberField, Panel, SelectField } from '../engine/ui';
3
3
  import { AutoFields } from '../engine/autoInspector';
4
+ import { POSITIVE } from '../engine/propertyRanges';
4
5
  import { computeAutoFit, getColliderRect, getColliderShapes, intersects } from '../engine/collider';
5
6
 
6
7
  // --- inspector styles ------------------------------------------------------
@@ -99,6 +100,16 @@ export class Collider {
99
100
  density: 0.001,
100
101
  };
101
102
 
103
+ // Inspector ranges. No `max` on any of these: matter treats them as open-ended
104
+ // (bounciness "can exceed 1" per the docs, and stiction above 1 is meaningful),
105
+ // and a max is a hard authoring ceiling here -- typed entry is clamped to it,
106
+ // not just the slider. `min: 0` and a fine step are the parts that are safe.
107
+ static propertyMeta = {
108
+ bounciness: POSITIVE,
109
+ friction: POSITIVE,
110
+ frictionStatic: POSITIVE,
111
+ };
112
+
102
113
  constructor(props) {
103
114
  this.props = props;
104
115
  }
@@ -276,6 +287,7 @@ export class Collider {
276
287
  <NumberField label="Density" value={density} onChange={(v) => setComponent({ density: v })} />
277
288
  <AutoFields
278
289
  defaultProps={Collider.defaultProps}
290
+ meta={Collider.propertyMeta}
279
291
  component={component}
280
292
  setComponent={setComponent}
281
293
  exclude={['density']}
@@ -1,20 +1,40 @@
1
1
  import React from 'react';
2
2
  import { Panel } from '../engine/ui';
3
3
  import { AutoFields } from '../engine/autoInspector';
4
- import { centerOf, chaseVelocity, inActorWorldSpace } from '../engine/physics/controls';
4
+ import { UNIT } from '../engine/propertyRanges';
5
+ import { TARGETED_CLAIM, dragHold, inActorWorldSpace } from '../engine/physics/controls';
5
6
 
6
- // Draggable: grab the actor with the pointer and fling it around. Chases the
7
- // pointer with a springy velocity (via the physics system) so it still collides
8
- // with the world -- the same feel as castle-client's Drag. Grabs the topmost
9
- // actor under the press (uses scene.actorAt), and keeps dragging until release
10
- // even if the pointer slips off. Needs a RigidBody (dynamic) + Collider.
7
+ // Draggable: grab the actor with the pointer and fling it around. The grab is a
8
+ // real physics constraint from the exact spot that was touched to the pointer,
9
+ // so the object still collides with the world AND turns when held off-center --
10
+ // grab a plank by its end and it swings.
11
11
  //
12
- // stiffness -> how hard it chases the pointer (fraction of the gap per step).
12
+ // The touch zone is the actor's COLLIDER at its current orientation (via
13
+ // scene.physics.actorAtPoint), not its Layout box, so a circle is round and a
14
+ // rotated actor's grabbable area rotates with it.
15
+ //
16
+ // A drag belongs to the finger that started it: it follows that pointer alone
17
+ // until that pointer lifts, so later fingers can't steal or drop it, and a
18
+ // second finger can drag a second actor at the same time.
19
+ //
20
+ // stiffness -> how tightly the object is held, 0 (loose and swingy) to 1
21
+ // (rigid, snaps to the pointer). Mapped through `dragHold`, which spreads the
22
+ // authored range over matter's very compressed useful band and eases damping
23
+ // along with it, so the low end is genuinely floppy rather than just slow.
13
24
  export class Draggable {
14
25
  static behaviorName = 'Draggable';
26
+ // Inert without a body to hold: needs a Collider (to have one at all) and a
27
+ // dynamic RigidBody (so the grab can actually move it).
28
+ static expectedSiblings = ['Collider', 'RigidBody'];
15
29
 
16
30
  static defaultProps = {
17
- stiffness: 0.1,
31
+ stiffness: 0.3,
32
+ };
33
+
34
+ // 0..1 with a fine step, so the inspector renders a real slider over the
35
+ // whole range rather than an unbounded whole-number scrubber.
36
+ static propertyMeta = {
37
+ stiffness: UNIT,
18
38
  };
19
39
 
20
40
  constructor(props) {
@@ -22,26 +42,49 @@ export class Draggable {
22
42
  }
23
43
 
24
44
  update(actor, scene) {
25
- const p = scene.pointer;
26
45
  const rt = actor.runtime;
27
- const pressed = p.down && !rt._dragWasDown;
28
- rt._dragWasDown = p.down;
29
- if (pressed && scene.actorAt(p.x, p.y)?.id === actor.id) rt._dragging = true;
30
- if (!p.down) rt._dragging = false;
31
- if (!rt._dragging) return;
46
+ const held = rt._dragPointerId == null ? null : scene.pointers.get(rt._dragPointerId);
47
+
48
+ // The finger that started this drag lifted (or its contact was cancelled).
49
+ if (rt._dragPointerId != null && !held?.down) {
50
+ scene.physics.release(actor);
51
+ rt._dragPointerId = null;
52
+ return;
53
+ }
54
+ if (held) return scene.physics.moveGrab(actor, held.x, held.y);
32
55
 
33
- scene.physics.setVelocity(actor, chaseVelocity(p, actor.components.Layout, this.props));
56
+ // Idle: look for a new press on this actor. Each pointer carries its own
57
+ // press edge, so a finger landing while another is already down still
58
+ // registers -- and an actor already being dragged never gets here, so the
59
+ // first finger keeps it.
60
+ for (const p of scene.pointers.values()) {
61
+ if (!p.justPressed) continue;
62
+ if (scene.physics.actorAtPoint(p.x, p.y)?.id !== actor.id) continue;
63
+ // A press on this collider is unambiguous, so it outranks any control that
64
+ // merely takes presses anywhere -- whatever order the actors update in.
65
+ if (!scene.claimPointer(p.id, actor.id, TARGETED_CLAIM)) continue;
66
+ if (scene.physics.grab(actor, p.x, p.y, dragHold(this.props.stiffness))) {
67
+ rt._dragPointerId = p.id;
68
+ }
69
+ return;
70
+ }
34
71
  }
35
72
 
36
- // Feedback while held (castle-client's Drag overlay): a circle on the grabbed
37
- // object + a circle at the touch, connected by a line -- both translucent white
38
- // with a solid white border. Drawn in world space so it doesn't spin with the
39
- // actor.
73
+ // Feedback while held (castle-client's Drag overlay): a circle where the
74
+ // object is actually being held + a circle at the touch, connected by a line
75
+ // -- both translucent white with a solid white border. The handle comes from
76
+ // the constraint the simulation is solving, so it sits exactly where the
77
+ // physics holds the object and tracks it through rotation. Drawn in world
78
+ // space so the overlay itself doesn't spin with the actor.
40
79
  draw(actor, scene, ctx) {
41
- if (!actor.runtime._dragging) return;
42
- const handle = centerOf(actor.components.Layout);
43
- const touch = { x: scene.pointer.x, y: scene.pointer.y };
44
- inActorWorldSpace(ctx, actor.components.Layout, (g) => drawDrag(g, handle, touch));
80
+ const id = actor.runtime._dragPointerId;
81
+ if (id == null) return;
82
+ const handle = scene.physics.grabPoint(actor);
83
+ const touch = scene.pointers.get(id);
84
+ if (!handle || !touch) return;
85
+ inActorWorldSpace(ctx, actor.components.Layout, (g) =>
86
+ drawDrag(g, handle, { x: touch.x, y: touch.y })
87
+ );
45
88
  }
46
89
 
47
90
  static Inspector({ component, setComponent, override }) {
@@ -49,6 +92,7 @@ export class Draggable {
49
92
  <Panel title="Draggable" overridden={override?.anyOverridden()}>
50
93
  <AutoFields
51
94
  defaultProps={Draggable.defaultProps}
95
+ meta={Draggable.propertyMeta}
52
96
  component={component}
53
97
  setComponent={setComponent}
54
98
  override={override}
@@ -3,6 +3,8 @@
3
3
  // `isTrigger: true`. Put it on an actor with a Layout and a trigger Collider.
4
4
  export class Goal {
5
5
  static behaviorName = 'Goal';
6
+ // Scores on collision callbacks, which only fire for an actor with a body.
7
+ static expectedSiblings = ['Collider'];
6
8
 
7
9
  static defaultProps = {};
8
10
 
@@ -24,6 +24,11 @@ const ANCHOR_MAX = 120;
24
24
  // point every instance at the same actor.
25
25
  export class Joints {
26
26
  static behaviorName = 'Joints';
27
+ // A link binds two bodies, so the OWNER needs a Collider to have one. Only the
28
+ // owner: the `target` actor needs one too, but it isn't a sibling -- it's a
29
+ // separate actor the author picks later, and inventing a Collider on it isn't
30
+ // this hook's business.
31
+ static expectedSiblings = ['Collider'];
27
32
 
28
33
  static defaultProps = { list: [] };
29
34
 
@@ -1,6 +1,7 @@
1
1
  import React from 'react';
2
2
  import { Panel, SelectField } from '../engine/ui';
3
3
  import { AutoFields, overrideProps } from '../engine/autoInspector';
4
+ import { DAMPING } from '../engine/propertyRanges';
4
5
 
5
6
  // RigidBody makes an actor a physics object. Pair it with a Collider (which
6
7
  // gives the shape + material). The simulation reads these props each frame; the
@@ -17,6 +18,9 @@ import { AutoFields, overrideProps } from '../engine/autoInspector';
17
18
  // velocityX/velocityY -> initial velocity, applied once when play starts.
18
19
  export class RigidBody {
19
20
  static behaviorName = 'RigidBody';
21
+ // No Collider means no body at all (matterBridge's `isPhysicsActor`), so a
22
+ // RigidBody on its own simulates nothing.
23
+ static expectedSiblings = ['Collider'];
20
24
 
21
25
  static defaultProps = {
22
26
  bodyType: 'dynamic',
@@ -31,6 +35,17 @@ export class RigidBody {
31
35
  angularVelocity: 0,
32
36
  };
33
37
 
38
+ // `drag`/`angularDrag` are matter's air friction, where values above 1 flip
39
+ // the velocity sign and destabilize the body -- so 1 is a real ceiling, not a
40
+ // convenience. `gravityScale` deliberately has none: negative is legitimate
41
+ // (it inverts gravity), as is scaling well past 1.
42
+ static propertyMeta = {
43
+ gravityScale: { step: 0.1 },
44
+ drag: DAMPING,
45
+ angularDrag: DAMPING,
46
+ angularVelocity: { step: 0.1 },
47
+ };
48
+
34
49
  constructor(props) {
35
50
  this.props = props;
36
51
  }
@@ -47,6 +62,7 @@ export class RigidBody {
47
62
  />
48
63
  <AutoFields
49
64
  defaultProps={RigidBody.defaultProps}
65
+ meta={RigidBody.propertyMeta}
50
66
  component={component}
51
67
  setComponent={setComponent}
52
68
  exclude={['bodyType']}
@@ -1,7 +1,15 @@
1
1
  import React from 'react';
2
2
  import { Panel } from '../engine/ui';
3
3
  import { AutoFields } from '../engine/autoInspector';
4
- import { inActorWorldSpace, pressOnDraggable, slingLaunch, slingPull } from '../engine/physics/controls';
4
+ import { PIXELS } from '../engine/propertyRanges';
5
+ import {
6
+ GREEDY_CLAIM,
7
+ TARGETED_CLAIM,
8
+ acquirePress,
9
+ inActorWorldSpace,
10
+ slingLaunch,
11
+ slingPull,
12
+ } from '../engine/physics/controls';
5
13
 
6
14
  // Slingshot: pull back and release to fling the actor the opposite way
7
15
  // (angry-birds style) -- the physics feel of castle-client's Sling. While
@@ -14,6 +22,8 @@ import { inActorWorldSpace, pressOnDraggable, slingLaunch, slingPull } from '../
14
22
  // like castle-client); set false to require pressing the actor.
15
23
  export class Slingshot {
16
24
  static behaviorName = 'Slingshot';
25
+ // Launches a body, so it needs one: Collider + dynamic RigidBody.
26
+ static expectedSiblings = ['Collider', 'RigidBody'];
17
27
 
18
28
  static defaultProps = {
19
29
  speed: 0.08,
@@ -21,31 +31,51 @@ export class Slingshot {
21
31
  grabAnywhere: true,
22
32
  };
23
33
 
34
+ // `speed` is a small multiplier (0.08 default), so whole-number steps were
35
+ // useless here. `maxDrag` is pixels of pull.
36
+ static propertyMeta = {
37
+ speed: { min: 0, step: 0.01 },
38
+ maxDrag: PIXELS,
39
+ };
40
+
24
41
  constructor(props) {
25
42
  this.props = props;
26
43
  }
27
44
 
28
45
  update(actor, scene) {
29
- const p = scene.pointer;
30
46
  const rt = actor.runtime;
31
- const pressed = p.down && !rt._slingWasDown;
32
- rt._slingWasDown = p.down;
33
47
 
34
- // A press on a draggable is consumed by the drag; otherwise the sling grabs
35
- // (anywhere, or on its own actor when grabAnywhere is off).
36
- const grabAnywhere = this.props.grabAnywhere ?? true;
37
- if (
38
- pressed &&
39
- !pressOnDraggable(scene) &&
40
- (grabAnywhere || scene.actorAt(p.x, p.y)?.id === actor.id)
41
- ) {
42
- rt._slinging = true;
43
- // Anchor at the press point (like castle-client): the aim/launch depends
44
- // on the drag gesture, not where the press landed relative to the actor.
45
- rt._slingAnchor = { x: p.x, y: p.y };
48
+ if (!rt._slinging) {
49
+ const grabAnywhere = this.props.grabAnywhere ?? true;
50
+ const press = acquirePress(scene, actor.id, (p) => {
51
+ // Collider-accurate, at the body's live orientation -- the same pick
52
+ // Draggable uses, so the two agree on what "on this actor" means.
53
+ const onSelf = scene.physics.actorAtPoint(p.x, p.y)?.id === actor.id;
54
+ if (!grabAnywhere && !onSelf) return null;
55
+ // Pressing the body itself is an unambiguous aim; taking any press
56
+ // anywhere is not, so it yields to a control that was actually targeted.
57
+ return onSelf ? TARGETED_CLAIM : GREEDY_CLAIM;
58
+ });
59
+ if (press) {
60
+ rt._slingPointerId = press.id;
61
+ rt._slinging = true;
62
+ // Anchor at the press point (like castle-client): the aim/launch depends
63
+ // on the drag gesture, not where the press landed relative to the actor.
64
+ rt._slingAnchor = { x: press.x, y: press.y };
65
+ }
46
66
  }
47
67
  if (!rt._slinging) return;
48
68
 
69
+ const p = scene.pointers.get(rt._slingPointerId);
70
+ // Outranked mid-aim, or the contact was cancelled: drop the shot rather than
71
+ // launching from an anchor the player has stopped steering.
72
+ if (!p || !scene.ownsPointer(rt._slingPointerId, actor.id)) {
73
+ rt._slinging = false;
74
+ rt._slingPointerId = null;
75
+ rt._slingAim = null;
76
+ return;
77
+ }
78
+
49
79
  if (p.down) {
50
80
  // Hold still while aiming, and remember the aim point for draw().
51
81
  scene.physics.setVelocity(actor, { x: 0, y: 0 });
@@ -53,6 +83,7 @@ export class Slingshot {
53
83
  } else {
54
84
  scene.physics.setVelocity(actor, slingLaunch(rt._slingAnchor, p, this.props));
55
85
  rt._slinging = false;
86
+ rt._slingPointerId = null;
56
87
  rt._slingAim = null;
57
88
  }
58
89
  }
@@ -79,6 +110,7 @@ export class Slingshot {
79
110
  <Panel title="Slingshot" overridden={override?.anyOverridden()}>
80
111
  <AutoFields
81
112
  defaultProps={Slingshot.defaultProps}
113
+ meta={Slingshot.propertyMeta}
82
114
  component={component}
83
115
  setComponent={setComponent}
84
116
  override={override}
@@ -4,6 +4,7 @@ import { mediaElement, playMedia, playOneShot, setMediaPan, stopMedia } from '..
4
4
  import { mediaFilesOfKind } from '../engine/files';
5
5
  import { Panel, SelectField } from '../engine/ui';
6
6
  import { AutoFields, overrideProps } from '../engine/autoInspector';
7
+ import { PAN, UNIT } from '../engine/propertyRanges';
7
8
 
8
9
  // Plays a sound file (.mp3 / .wav / .m4a) from an actor.
9
10
  //
@@ -39,6 +40,15 @@ export class Sound {
39
40
  playOnStart: true,
40
41
  };
41
42
 
43
+ // Mirrors the clamps `readSettings` already applies at runtime, so the
44
+ // inspector can't author a value the sound would silently ignore -- including
45
+ // playbackRate's 0.01..10, where the classic editor's ceiling lives.
46
+ static propertyMeta = {
47
+ volume: UNIT,
48
+ playbackRate: { min: 0.01, max: 10, step: 0.05 },
49
+ pan: PAN,
50
+ };
51
+
42
52
  constructor(props) {
43
53
  this.props = props;
44
54
  }
@@ -80,6 +90,7 @@ export class Sound {
80
90
  />
81
91
  <AutoFields
82
92
  defaultProps={Sound.defaultProps}
93
+ meta={Sound.propertyMeta}
83
94
  component={component}
84
95
  setComponent={setComponent}
85
96
  only={['volume', 'playbackRate', 'pan', 'loop', 'polyphonic', 'playOnStart']}
@@ -38,6 +38,12 @@ export class Sprite {
38
38
  tileSize: 50,
39
39
  };
40
40
 
41
+ // Tile cell size in card units -- a floor of 1 (0 would divide by zero when
42
+ // laying out the repeat), no ceiling since a deck may tile larger than a card.
43
+ static propertyMeta = {
44
+ tileSize: { min: 1, step: 1 },
45
+ };
46
+
41
47
  constructor(props) {
42
48
  this.props = props;
43
49
  }
@@ -173,6 +179,7 @@ export class Sprite {
173
179
  />
174
180
  <AutoFields
175
181
  defaultProps={Sprite.defaultProps}
182
+ meta={Sprite.propertyMeta}
176
183
  component={component}
177
184
  setComponent={setComponent}
178
185
  only={['playing', 'tint', 'tileSize']}
@@ -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']}