castle-web-cli 0.4.120 → 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.
@@ -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
- const Behavior = findBehaviorClass(behaviorName);
801
- if (!Behavior) return;
832
+ if (!findBehaviorClass(behaviorName)) return;
802
833
  const resolved = resolvedActors?.find((actor) => actor.id === actorId);
803
- commitScene(
804
- setActorComponent(sceneData, actorId, behaviorName, initialBehaviorProps(Behavior, resolved, sprites))
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
  );
@@ -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(canvas, event.clientX, event.clientY);
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
- export function AutoFields({ defaultProps, component, setComponent, only, exclude, override }) {
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
- return <NumberField key={key} label={label} value={current} onChange={set} {...ov} />;
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({ behaviorName, defaultProps, component, setComponent, override }) {
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
- // Hierarchy: a press that lands ON a Draggable object is CONSUMED by that drag,
34
- // so Slingshot/AnalogStick ignore it. A press anywhere else is shared -- both
35
- // Slingshot and AnalogStick act (no fair way to rank them). So the only gate the
36
- // non-drag controls need is this: did the current press land on a draggable?
37
- // Stateless -- a pure per-press query (the controls only ask on their press
38
- // edge), so there's no owner to latch and nothing to reset.
39
- export function pressOnDraggable(scene) {
40
- const p = scene.pointer;
41
- if (!p.down) return false;
42
- const hit = scene.actorAt(p.x, p.y);
43
- return !!hit && !!hit.components.Draggable;
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 };
@@ -41,6 +41,12 @@ export class SceneRuntime {
41
41
  this.files = files ?? {};
42
42
  this.time = 0;
43
43
  this.keys = new Set();
44
+ // Every active pointer (finger / mouse / stylus), keyed by the browser's
45
+ // pointerId. A control that starts a gesture latches the id it started on
46
+ // and follows THAT pointer, so a second finger landing can't steal or drop
47
+ // an in-progress drag. `pointer` stays the primary (first-down) pointer for
48
+ // single-touch behaviors and for decks reading the documented API.
49
+ this.pointers = new Map();
44
50
  this.pointer = { x: 0, y: 0, down: false };
45
51
  this.data = { actors: [] };
46
52
  this.actors = new Map();
@@ -210,11 +216,85 @@ export class SceneRuntime {
210
216
  // input path -- the standalone ScenePlayer and the editor's play mode --
211
217
  // agree on it; behaviors read `scene.pointer` in world coordinates. Pass
212
218
  // `down` to also update the press state.
213
- setPointerFromScreen(canvas, clientX, clientY, down) {
219
+ // `pointerId` identifies which finger/device this event came from; the
220
+ // browser reuses it for every event of one contact, press through release.
221
+ setPointerFromScreen(canvas, clientX, clientY, down, pointerId = 0) {
214
222
  const point = screenToCard(canvas, clientX, clientY);
215
- this.pointer.x = point.x + (this.camera?.x ?? 0);
216
- this.pointer.y = point.y + (this.camera?.y ?? 0);
217
- if (down !== undefined) this.pointer.down = down;
223
+ const x = point.x + (this.camera?.x ?? 0);
224
+ const y = point.y + (this.camera?.y ?? 0);
225
+ let p = this.pointers.get(pointerId);
226
+ if (!p) {
227
+ // Only a press creates a pointer. A move with no press is a mouse hover,
228
+ // and a stray event after release must not resurrect a finished contact.
229
+ if (down !== true) return this.syncPrimaryPointer(x, y);
230
+ p = { id: pointerId, x, y, down: false, justPressed: false, claimedBy: null, claimPriority: 0 };
231
+ this.pointers.set(pointerId, p);
232
+ }
233
+ p.x = x;
234
+ p.y = y;
235
+ // The press edge is per-pointer, so a finger landing while another is
236
+ // already held still registers as a new press (a shared `down` flag would
237
+ // show no rising edge and the second press would be invisible).
238
+ if (down === true && !p.down) p.justPressed = true;
239
+ if (down !== undefined) p.down = down;
240
+ this.syncPrimaryPointer(x, y);
241
+ }
242
+
243
+ // Claim a pointer for one gesture, so two controls can't both act on the same
244
+ // finger. Returns whether `ownerId` holds it afterwards.
245
+ //
246
+ // `priority` resolves the case that actor order otherwise decides arbitrarily:
247
+ // a TARGETED control (a Draggable whose collider was actually pressed) must
248
+ // beat a GREEDY one (a Slingshot set to grab anywhere, an AnalogStick that
249
+ // takes any press), no matter which actor updates first. A higher priority
250
+ // takes the pointer from a lower one; equal or lower leaves it alone.
251
+ //
252
+ // Losing a claim is how the greedy control learns to stand down, and it costs
253
+ // nothing visible: on the press frame its own displacement from its origin is
254
+ // still zero, so it has commanded no velocity yet.
255
+ claimPointer(pointerId, ownerId, priority = 0) {
256
+ const p = this.pointers.get(pointerId);
257
+ if (!p) return false;
258
+ if (p.claimedBy == null || p.claimedBy === ownerId || priority > p.claimPriority) {
259
+ p.claimedBy = ownerId;
260
+ p.claimPriority = priority;
261
+ }
262
+ return p.claimedBy === ownerId;
263
+ }
264
+
265
+ // True while `ownerId` still holds this pointer. A control that latched a
266
+ // pointer checks this each frame so it releases when outranked. Claims need no
267
+ // cleanup: they live on the pointer entry, which disappears when it lifts.
268
+ ownsPointer(pointerId, ownerId) {
269
+ return this.pointers.get(pointerId)?.claimedBy === ownerId;
270
+ }
271
+
272
+ // Released pointers are reaped at the end of the frame rather than on the
273
+ // event, so a tap that begins and ends between two frames is still seen once
274
+ // -- and so a control latched to a pointer observes `down: false` before the
275
+ // entry disappears.
276
+ reapPointers() {
277
+ for (const [id, p] of this.pointers) {
278
+ p.justPressed = false;
279
+ if (!p.down) this.pointers.delete(id);
280
+ }
281
+ this.syncPrimaryPointer();
282
+ }
283
+
284
+ // `pointer` mirrors the primary -- the oldest pointer still held (Map keeps
285
+ // insertion order) -- so single-touch behaviors and decks reading the
286
+ // documented `scene.pointer` API behave exactly as before.
287
+ syncPrimaryPointer(fallbackX, fallbackY) {
288
+ for (const p of this.pointers.values()) {
289
+ if (!p.down) continue;
290
+ this.pointer.x = p.x;
291
+ this.pointer.y = p.y;
292
+ this.pointer.down = true;
293
+ return;
294
+ }
295
+ if (fallbackX !== undefined) this.pointer.x = fallbackX;
296
+ if (fallbackY !== undefined) this.pointer.y = fallbackY;
297
+ this.pointer.down = false;
218
298
  }
219
299
 
220
300
  update(dt) {
@@ -228,6 +308,8 @@ export class SceneRuntime {
228
308
  for (const system of this.systems) {
229
309
  system.afterBehaviors?.(this, dt);
230
310
  }
311
+ // Every behavior and system has now seen this frame's presses and releases.
312
+ this.reapPointers();
231
313
  }
232
314
 
233
315
  forEachBehavior(actor, callback) {
@@ -458,6 +458,19 @@ function NumberInlineInput({ value, min, max, step, onCommit, onDone }) {
458
458
  );
459
459
  }
460
460
 
461
+ // Snap to a multiple of `step`. `Math.round(v / step) * step` alone reintroduces
462
+ // binary error the moment `step` isn't representable in base 2 -- 0.05 x 14 is
463
+ // 0.7000000000000001, which then gets stored and rendered in full -- so re-round
464
+ // to the decimal places the step itself implies.
465
+ function snapToStep(v, step) {
466
+ const snapped = Math.round(v / step) * step;
467
+ const text = String(step);
468
+ const decimals = text.includes('e-')
469
+ ? Number(text.split('e-')[1])
470
+ : (text.split('.')[1] ?? '').length;
471
+ return Number(snapped.toFixed(Math.min(decimals, 100)));
472
+ }
473
+
461
474
  export function NumberField({ label, value, onChange, min, max, step = 1, overridden, defaultValue, onReset }) {
462
475
  const current = Number.isFinite(value) ? (value ?? 0) : 0;
463
476
  // A field with BOTH a min and max is a true slider (fill + thumb), the pointer's X
@@ -481,7 +494,7 @@ export function NumberField({ label, value, onChange, min, max, step = 1, overri
481
494
  return v;
482
495
  }
483
496
  function scrubTo(v) {
484
- if (step) v = Math.round(v / step) * step;
497
+ if (step) v = snapToStep(v, step);
485
498
  v = clampVal(v);
486
499
  if (v !== current) onChange(v);
487
500
  }
@@ -28,7 +28,7 @@
28
28
  },
29
29
  "../../sdk": {
30
30
  "name": "castle-web-sdk",
31
- "version": "0.4.12",
31
+ "version": "0.4.13",
32
32
  "devDependencies": {
33
33
  "eslint": "^9.0.0",
34
34
  "jscpd": "^4.0.5",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "castle-web-cli",
3
- "version": "0.4.120",
3
+ "version": "0.4.121",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "castle-web": "./dist/index.js"
@@ -37,6 +37,7 @@
37
37
  "@xterm/xterm": "^6.0.0",
38
38
  "codemirror": "^6.0.2",
39
39
  "dockview": "^4.13.1",
40
+ "html2canvas": "^1.4.1",
40
41
  "marked": "^18.0.5",
41
42
  "nanoid": "^5.1.7",
42
43
  "open": "^10.0.0",