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
@@ -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']}
@@ -123,5 +123,5 @@
123
123
  "main": "main.jsx",
124
124
  "autoUpdateWhenImported": true,
125
125
  "title": "physics-2d",
126
- "publishedVersion": "2026-08-08T19:00:43.040Z"
126
+ "publishedVersion": "2026-08-14T21:20:31.152Z"
127
127
  }
@@ -442,8 +442,16 @@ export function SceneEditor({
442
442
  sceneData,
443
443
  files
444
444
  );
445
- void writeFile(drawingFile.path, drawingFile.text);
446
- void writeFile(blueprintFile.path, blueprintFile.text);
445
+ // Add both generated files to the live map before committing the instance.
446
+ // Otherwise another + click can mint the same numbered paths while the fs
447
+ // watcher's asynchronous echo of these direct writes is still pending.
448
+ if (onChangeFile) {
449
+ onChangeFile(drawingFile.path, drawingFile.text);
450
+ onChangeFile(blueprintFile.path, blueprintFile.text);
451
+ } else {
452
+ void writeFile(drawingFile.path, drawingFile.text);
453
+ void writeFile(blueprintFile.path, blueprintFile.text);
454
+ }
447
455
  history.commit(serialize(next));
448
456
  onSelectActorIds([newId]);
449
457
  };
@@ -779,6 +787,38 @@ function initialBehaviorProps(Behavior, actor, sprites) {
779
787
  return { ...Behavior.defaultProps };
780
788
  }
781
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
+
782
822
  function makeSceneActions({ sceneData, files, serialize, commit, selectedActorIds, onSelectActorIds, isBlueprintFile, sprites, resolvedActors }) {
783
823
  function commitScene(next, options) {
784
824
  commit(serialize(next), options);
@@ -789,12 +829,16 @@ function makeSceneActions({ sceneData, files, serialize, commit, selectedActorId
789
829
  coalesceKey: componentCoalesceKey(actorId, behaviorName, nextProps),
790
830
  }),
791
831
  addBehavior: (actorId, behaviorName) => {
792
- const Behavior = findBehaviorClass(behaviorName);
793
- if (!Behavior) return;
832
+ if (!findBehaviorClass(behaviorName)) return;
794
833
  const resolved = resolvedActors?.find((actor) => actor.id === actorId);
795
- commitScene(
796
- setActorComponent(sceneData, actorId, behaviorName, initialBehaviorProps(Behavior, resolved, sprites))
797
- );
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);
798
842
  },
799
843
  removeBehavior: (actorId, behaviorName) =>
800
844
  commitScene(removeActorComponent(sceneData, actorId, behaviorName)),
@@ -1064,7 +1108,7 @@ function usePlayPointerGesture({ canvasRef, runtimeRef }) {
1064
1108
  const canvas = canvasRef.current;
1065
1109
  const runtime = runtimeRef.current;
1066
1110
  if (!canvas || !runtime) return;
1067
- runtime.setPointerFromScreen(canvas, event.clientX, event.clientY, down);
1111
+ runtime.setPointerFromScreen(canvas, event.clientX, event.clientY, down, event.pointerId);
1068
1112
  },
1069
1113
  [canvasRef, runtimeRef]
1070
1114
  );
@@ -1675,6 +1719,7 @@ function BlueprintInspector({
1675
1719
  selectedActor={templateActor}
1676
1720
  rawActor={templateActor}
1677
1721
  files={files}
1722
+ sprites={sprites}
1678
1723
  onSetComponent={(actorId, behaviorName, nextProps) => onSetComponent(behaviorName, nextProps)}
1679
1724
  onAddBehavior={(actorId, behaviorName) => onAddBehavior(behaviorName)}
1680
1725
  onRemoveBehavior={(actorId, behaviorName) => onRemoveBehavior(behaviorName)}
@@ -271,15 +271,36 @@ export function PalettePopover({ open, anchorRef, onClose, children }) {
271
271
 
272
272
  useLayoutEffect(() => {
273
273
  if (!open || !anchorRef.current) return;
274
- const anchor = anchorRef.current.getBoundingClientRect();
275
- const popoverWidth = 280;
276
- const margin = 8;
277
- let left = anchor.left - popoverWidth - margin;
278
- if (left < margin) left = anchor.right + margin;
279
- let top = anchor.top;
280
- const maxTop = window.innerHeight - margin;
281
- if (top + 320 > maxTop) top = Math.max(margin, maxTop - 320);
282
- setPosition({ top, left });
274
+
275
+ function positionPopover() {
276
+ const anchor = anchorRef.current.getBoundingClientRect();
277
+ const rect = popoverRef.current?.getBoundingClientRect();
278
+ const margin = 8;
279
+ const width = rect?.width ?? 280;
280
+ const height = rect?.height ?? 320;
281
+ const maxLeft = Math.max(margin, window.innerWidth - width - margin);
282
+ const maxTop = Math.max(margin, window.innerHeight - height - margin);
283
+ const leftSide = anchor.left - width - margin;
284
+ const rightSide = anchor.right + margin;
285
+ // Prefer the side with room, then clamp a too-wide picker inside this
286
+ // iframe rather than sending it beyond either viewport edge.
287
+ const left =
288
+ leftSide >= margin
289
+ ? leftSide
290
+ : rightSide <= maxLeft
291
+ ? rightSide
292
+ : Math.min(maxLeft, Math.max(margin, anchor.left + anchor.width / 2 - width / 2));
293
+ const top = Math.min(maxTop, Math.max(margin, anchor.top));
294
+ setPosition({ top, left });
295
+ }
296
+
297
+ positionPopover();
298
+ window.addEventListener('resize', positionPopover);
299
+ window.addEventListener('scroll', positionPopover, true);
300
+ return () => {
301
+ window.removeEventListener('resize', positionPopover);
302
+ window.removeEventListener('scroll', positionPopover, true);
303
+ };
283
304
  }, [open, anchorRef]);
284
305
 
285
306
  useEffect(() => {
@@ -31,6 +31,8 @@ const SHOW_PER_FRAME_DURATION = false;
31
31
  // re-renders the count input, so this is a trivial, easily-revertible toggle.
32
32
  const SHOW_ONION_RANGE = false;
33
33
  const ONION_RANGE = 1;
34
+ const TIMELINE_LONG_PRESS_MS = 500;
35
+ const TIMELINE_LONG_PRESS_MOVE_TOLERANCE = 10;
34
36
 
35
37
  // faClapperboard ships in Font Awesome 6, but this kit is pinned to FA5 (see
36
38
  // package.json), which doesn't export it. Rather than silently swap in a
@@ -328,26 +330,58 @@ function TimelineGrid({ sprite, text, selection, actions }) {
328
330
  const [convertConfirm, setConvertConfirm] = useState(null);
329
331
  const closeMenu = useCallback(() => setMenu(null), []);
330
332
 
331
- // Each opener stops the browser's native menu, anchors at the cursor, and
332
- // stashes just enough to rebuild items from the latest props at render time
333
- // (so relocated actions always run against current sprite/selection state).
333
+ // A timeline entity is selected independently by its relevant axis: a layer
334
+ // by layer index, a frame by frame index, and a cel by the complete pair.
335
+ // First tap selects; a second tap on that same entity opens its action menu.
336
+ // A touch long-press always selects its target and opens the same menu.
337
+ const selectTarget = useCallback(
338
+ (target) => {
339
+ if (target.kind === 'layer') actions.selectCell(target.layerIndex, selection.frameIndex);
340
+ else if (target.kind === 'frame') actions.selectCell(selection.layerIndex, target.frameIndex);
341
+ else actions.selectCell(target.layerIndex, target.frameIndex);
342
+ },
343
+ [actions, selection.frameIndex, selection.layerIndex]
344
+ );
345
+ const targetIsSelected = useCallback(
346
+ (target) => {
347
+ if (target.kind === 'layer') return selection.layerIndex === target.layerIndex;
348
+ if (target.kind === 'frame') return selection.frameIndex === target.frameIndex;
349
+ return selection.layerIndex === target.layerIndex && selection.frameIndex === target.frameIndex;
350
+ },
351
+ [selection.frameIndex, selection.layerIndex]
352
+ );
353
+ const openMenu = useCallback(
354
+ (target, event) => {
355
+ selectTarget(target);
356
+ const rect = event.currentTarget?.getBoundingClientRect?.();
357
+ const x = Number.isFinite(event.clientX) ? event.clientX : rect?.left + rect?.width / 2;
358
+ const y = Number.isFinite(event.clientY) ? event.clientY : rect?.top + rect?.height / 2;
359
+ setMenu({ ...target, x: x ?? 0, y: y ?? 0 });
360
+ },
361
+ [selectTarget]
362
+ );
363
+ const press = useTimelineItemPress(openMenu);
364
+ const activateTarget = (target, event) => {
365
+ if (isTimelineNestedControl(event) || press.consumeClick(target)) return;
366
+ if (targetIsSelected(target)) openMenu(target, event);
367
+ else selectTarget(target);
368
+ };
369
+ // Right-click keeps the desktop path and deliberately opens its target even
370
+ // when it was not already selected.
334
371
  const openLayerMenu = (event, layerIndex) => {
335
372
  event.preventDefault();
336
373
  event.stopPropagation();
337
- setMenu({ kind: 'layer', x: event.clientX, y: event.clientY, layerIndex });
374
+ openMenu({ kind: 'layer', layerIndex }, event);
338
375
  };
339
376
  const openFrameMenu = (event, frameIndex) => {
340
377
  event.preventDefault();
341
378
  event.stopPropagation();
342
- setMenu({ kind: 'frame', x: event.clientX, y: event.clientY, frameIndex });
379
+ openMenu({ kind: 'frame', frameIndex }, event);
343
380
  };
344
381
  const openCellMenu = (event, layerIndex, frameIndex) => {
345
382
  event.preventDefault();
346
383
  event.stopPropagation();
347
- // Link/Unlink are bound to the active cell, so select this one first; the
348
- // menu re-renders with rebuilt actions that target (layerIndex, frameIndex).
349
- actions.selectCell(layerIndex, frameIndex);
350
- setMenu({ kind: 'cell', x: event.clientX, y: event.clientY, layerIndex, frameIndex });
384
+ openMenu({ kind: 'cell', layerIndex, frameIndex }, event);
351
385
  };
352
386
 
353
387
  return (
@@ -361,7 +395,8 @@ function TimelineGrid({ sprite, text, selection, actions }) {
361
395
  index={f}
362
396
  globalMs={sprite.defaultDurationMs}
363
397
  active={f === selection.frameIndex}
364
- onSelect={() => actions.selectCell(selection.layerIndex, f)}
398
+ onActivate={(event) => activateTarget({ kind: 'frame', frameIndex: f }, event)}
399
+ onPointerDown={(event) => press.onPointerDown(event, { kind: 'frame', frameIndex: f })}
365
400
  onDuration={(ms) => actions.setFrameDuration(f, ms)}
366
401
  onContextMenu={(event) => openFrameMenu(event, f)}
367
402
  />
@@ -373,7 +408,8 @@ function TimelineGrid({ sprite, text, selection, actions }) {
373
408
  layer={sprite.layers[li]}
374
409
  index={li}
375
410
  active={li === selection.layerIndex}
376
- onSelect={() => actions.selectCell(li, selection.frameIndex)}
411
+ onActivate={(event) => activateTarget({ kind: 'layer', layerIndex: li }, event)}
412
+ onPointerDown={(event) => press.onPointerDown(event, { kind: 'layer', layerIndex: li })}
377
413
  actions={actions}
378
414
  onContextMenu={openLayerMenu}
379
415
  />
@@ -385,7 +421,8 @@ function TimelineGrid({ sprite, text, selection, actions }) {
385
421
  layerIndex={li}
386
422
  frameIndex={f}
387
423
  active={li === selection.layerIndex && f === selection.frameIndex}
388
- onSelect={() => actions.selectCell(li, f)}
424
+ onActivate={(event) => activateTarget({ kind: 'cell', layerIndex: li, frameIndex: f }, event)}
425
+ onPointerDown={(event) => press.onPointerDown(event, { kind: 'cell', layerIndex: li, frameIndex: f })}
389
426
  onContextMenu={(event) => openCellMenu(event, li, f)}
390
427
  />
391
428
  ))}
@@ -425,6 +462,93 @@ function TimelineGrid({ sprite, text, selection, actions }) {
425
462
  );
426
463
  }
427
464
 
465
+ function targetKey(target) {
466
+ if (target.kind === 'layer') return `layer:${target.layerIndex}`;
467
+ if (target.kind === 'frame') return `frame:${target.frameIndex}`;
468
+ return `cell:${target.layerIndex}:${target.frameIndex}`;
469
+ }
470
+
471
+ // Form controls inside a selectable timeline header keep their direct behavior;
472
+ // pressing the layer visibility toggle or a duration input must not arm a menu.
473
+ function isTimelineNestedControl(event) {
474
+ if (!(event.target instanceof Element) || event.target === event.currentTarget) return false;
475
+ return Boolean(event.target.closest('input, select, textarea, [data-timeline-control]'));
476
+ }
477
+
478
+ // Touch-only long press. Scroll intent cancels it after a small move threshold;
479
+ // the subsequent synthetic click is consumed after a successful hold so it
480
+ // doesn't immediately close/reopen the action menu.
481
+ function useTimelineItemPress(openMenu) {
482
+ const openMenuRef = useRef(openMenu);
483
+ const pressRef = useRef(null);
484
+ const suppressClickRef = useRef(null);
485
+ openMenuRef.current = openMenu;
486
+
487
+ useEffect(() => {
488
+ const clearPress = () => {
489
+ const press = pressRef.current;
490
+ if (!press) return null;
491
+ window.clearTimeout(press.timer);
492
+ pressRef.current = null;
493
+ return press;
494
+ };
495
+ const onPointerMove = (event) => {
496
+ const press = pressRef.current;
497
+ if (!press || press.pointerId !== event.pointerId || press.opened) return;
498
+ if (Math.hypot(event.clientX - press.startX, event.clientY - press.startY) <= TIMELINE_LONG_PRESS_MOVE_TOLERANCE) return;
499
+ clearPress();
500
+ };
501
+ const onPointerEnd = (event) => {
502
+ const press = pressRef.current;
503
+ if (!press || press.pointerId !== event.pointerId) return;
504
+ const ended = clearPress();
505
+ if (ended?.opened) suppressClickRef.current = targetKey(ended.target);
506
+ };
507
+ window.addEventListener('pointermove', onPointerMove);
508
+ window.addEventListener('pointerup', onPointerEnd);
509
+ window.addEventListener('pointercancel', onPointerEnd);
510
+ return () => {
511
+ clearPress();
512
+ window.removeEventListener('pointermove', onPointerMove);
513
+ window.removeEventListener('pointerup', onPointerEnd);
514
+ window.removeEventListener('pointercancel', onPointerEnd);
515
+ };
516
+ }, []);
517
+
518
+ const onPointerDown = useCallback((event, target) => {
519
+ if (event.pointerType !== 'touch' || event.button !== 0 || isTimelineNestedControl(event)) return;
520
+ const prior = pressRef.current;
521
+ if (prior) window.clearTimeout(prior.timer);
522
+ const menuEvent = {
523
+ clientX: event.clientX,
524
+ clientY: event.clientY,
525
+ currentTarget: event.currentTarget,
526
+ };
527
+ const press = {
528
+ pointerId: event.pointerId,
529
+ target,
530
+ startX: event.clientX,
531
+ startY: event.clientY,
532
+ opened: false,
533
+ timer: 0,
534
+ };
535
+ press.timer = window.setTimeout(() => {
536
+ if (pressRef.current !== press) return;
537
+ press.opened = true;
538
+ openMenuRef.current(target, menuEvent);
539
+ }, TIMELINE_LONG_PRESS_MS);
540
+ pressRef.current = press;
541
+ }, []);
542
+
543
+ const consumeClick = useCallback((target) => {
544
+ if (suppressClickRef.current !== targetKey(target)) return false;
545
+ suppressClickRef.current = null;
546
+ return true;
547
+ }, []);
548
+
549
+ return { onPointerDown, consumeClick };
550
+ }
551
+
428
552
  // Cursor-anchored right-click menu. Reuses the scene editor's "Arrange" menu
429
553
  // chrome (the shared `selArrangeMenu`/`selArrangeItem` classes from
430
554
  // engine/ui.module.css) for visual consistency, switching to fixed positioning
@@ -588,12 +712,14 @@ function cellMenuItems(menu, sprite, actions) {
588
712
  ];
589
713
  }
590
714
 
591
- function FrameHeader({ frame, index, globalMs, active, onSelect, onDuration, onContextMenu }) {
715
+ function FrameHeader({ frame, index, globalMs, active, onActivate, onPointerDown, onDuration, onContextMenu }) {
592
716
  return (
593
717
  <div
594
718
  className={active ? tl.frameHead + ' ' + tl.frameHeadActive : tl.frameHead}
719
+ onPointerDown={onPointerDown}
720
+ onClick={onActivate}
595
721
  onContextMenu={onContextMenu}>
596
- <button type="button" className={tl.frameNum} onClick={onSelect} style={btnReset}>
722
+ <button type="button" className={tl.frameNum} style={btnReset}>
597
723
  {index + 1}
598
724
  </button>
599
725
  {SHOW_PER_FRAME_DURATION ? (
@@ -681,11 +807,12 @@ function FaGlyph({ icon, className = tl.visGlyph }) {
681
807
  );
682
808
  }
683
809
 
684
- function LayerHeader({ layer, index, active, onSelect, actions, onContextMenu }) {
810
+ function LayerHeader({ layer, index, active, onActivate, onPointerDown, actions, onContextMenu }) {
685
811
  return (
686
812
  <div
687
813
  className={active ? tl.layerHead + ' ' + tl.layerHeadActive : tl.layerHead}
688
- onClick={onSelect}
814
+ onPointerDown={onPointerDown}
815
+ onClick={onActivate}
689
816
  onContextMenu={(event) => onContextMenu(event, index)}>
690
817
  <div className={tl.layerTopRow}>
691
818
  <span
@@ -710,6 +837,7 @@ function LayerHeader({ layer, index, active, onSelect, actions, onContextMenu })
710
837
  />
711
838
  <button
712
839
  type="button"
840
+ data-timeline-control
713
841
  className={layer.visible ? tl.visBtn : tl.visBtn + ' ' + tl.visOff}
714
842
  title={layer.visible ? 'Hide layer' : 'Show layer'}
715
843
  onClick={(event) => {
@@ -723,7 +851,7 @@ function LayerHeader({ layer, index, active, onSelect, actions, onContextMenu })
723
851
  );
724
852
  }
725
853
 
726
- function CellThumb({ sprite, text, layerIndex, frameIndex, active, onSelect, onContextMenu }) {
854
+ function CellThumb({ sprite, text, layerIndex, frameIndex, active, onActivate, onPointerDown, onContextMenu }) {
727
855
  const canvasRef = useRef(null);
728
856
  const layer = sprite.layers[layerIndex];
729
857
  const kind = cellKind(layer, frameIndex);
@@ -743,7 +871,8 @@ function CellThumb({ sprite, text, layerIndex, frameIndex, active, onSelect, onC
743
871
  type="button"
744
872
  className={active ? tl.cell + ' ' + tl.cellActive : tl.cell}
745
873
  title={`Layer ${layerIndex + 1}, frame ${frameIndex + 1} (${kind})`}
746
- onClick={onSelect}
874
+ onPointerDown={onPointerDown}
875
+ onClick={onActivate}
747
876
  onContextMenu={onContextMenu}>
748
877
  {kind === 'empty' ? (
749
878
  <span className={tl.empty} />
@@ -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