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.
- package/dist/api.js +13 -2
- package/dist/castle-host/host.d.ts +16 -0
- package/dist/castle-host/host.js +249 -11
- package/dist/castle-host/leaderboardPanel.d.ts +73 -0
- package/dist/castle-host/leaderboardPanel.js +714 -0
- package/dist/ide.d.ts +3 -0
- package/dist/ide.js +79 -0
- package/dist/index.js +1 -6
- package/dist/init.js +1 -1
- package/dist/preview.d.ts +0 -1
- package/dist/preview.js +18 -23
- package/dist/save-deck.js +33 -2
- package/dist/serve.js +9 -4
- package/dist/shell/assets/index-BkJ87APM.css +1 -0
- package/dist/shell/assets/index-UEwjXWKI.js +434 -0
- package/dist/shell/index.html +2 -2
- package/kits/physics-2d/CLAUDE.md +64 -9
- package/kits/physics-2d/behaviors/AnalogStick.jsx +40 -9
- package/kits/physics-2d/behaviors/Collider.jsx +12 -0
- package/kits/physics-2d/behaviors/Draggable.jsx +67 -23
- package/kits/physics-2d/behaviors/Goal.jsx +2 -0
- package/kits/physics-2d/behaviors/Joints.jsx +5 -0
- package/kits/physics-2d/behaviors/RigidBody.jsx +16 -0
- package/kits/physics-2d/behaviors/Slingshot.jsx +48 -16
- package/kits/physics-2d/behaviors/Sound.jsx +11 -0
- package/kits/physics-2d/behaviors/Sprite.jsx +7 -0
- package/kits/physics-2d/behaviors/Tone.jsx +12 -0
- package/kits/physics-2d/behaviors/Video.jsx +6 -0
- package/kits/physics-2d/castle.json +1 -1
- package/kits/physics-2d/editors/SceneEditor.jsx +53 -8
- package/kits/physics-2d/editors/pixelInspector.jsx +30 -9
- package/kits/physics-2d/editors/pxArtTimeline.jsx +147 -18
- package/kits/physics-2d/engine/ScenePlayer.jsx +9 -3
- package/kits/physics-2d/engine/autoInspector.jsx +28 -3
- package/kits/physics-2d/engine/physics/PhysicsSystem.js +117 -0
- package/kits/physics-2d/engine/physics/controls.js +43 -19
- package/kits/physics-2d/engine/propertyRanges.js +27 -0
- package/kits/physics-2d/engine/scene.js +86 -4
- package/kits/physics-2d/engine/ui.jsx +14 -1
- package/kits/physics-2d/engine/ui.module.css +1 -0
- package/kits/physics-2d/package-lock.json +1 -1
- package/package.json +2 -1
- package/dist/shell/assets/index-WtgFx1s8.js +0 -144
- 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']}
|
|
@@ -442,8 +442,16 @@ export function SceneEditor({
|
|
|
442
442
|
sceneData,
|
|
443
443
|
files
|
|
444
444
|
);
|
|
445
|
-
|
|
446
|
-
|
|
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
|
-
|
|
793
|
-
if (!Behavior) return;
|
|
832
|
+
if (!findBehaviorClass(behaviorName)) return;
|
|
794
833
|
const resolved = resolvedActors?.find((actor) => actor.id === actorId);
|
|
795
|
-
|
|
796
|
-
|
|
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
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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
|
-
//
|
|
332
|
-
//
|
|
333
|
-
//
|
|
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
|
-
|
|
374
|
+
openMenu({ kind: 'layer', layerIndex }, event);
|
|
338
375
|
};
|
|
339
376
|
const openFrameMenu = (event, frameIndex) => {
|
|
340
377
|
event.preventDefault();
|
|
341
378
|
event.stopPropagation();
|
|
342
|
-
|
|
379
|
+
openMenu({ kind: 'frame', frameIndex }, event);
|
|
343
380
|
};
|
|
344
381
|
const openCellMenu = (event, layerIndex, frameIndex) => {
|
|
345
382
|
event.preventDefault();
|
|
346
383
|
event.stopPropagation();
|
|
347
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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}
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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(
|
|
47
|
+
runtime.setPointerFromScreen(
|
|
48
|
+
canvas,
|
|
49
|
+
event.clientX,
|
|
50
|
+
event.clientY,
|
|
51
|
+
undefined,
|
|
52
|
+
event.pointerId
|
|
53
|
+
);
|
|
48
54
|
};
|
|
49
55
|
const onPointerUp = (event) => {
|
|
50
|
-
runtime.setPointerFromScreen(canvas, event.clientX, event.clientY, false);
|
|
56
|
+
runtime.setPointerFromScreen(canvas, event.clientX, event.clientY, false, event.pointerId);
|
|
51
57
|
try {
|
|
52
58
|
canvas.releasePointerCapture(event.pointerId);
|
|
53
59
|
} catch {
|
|
@@ -12,7 +12,12 @@ export function overrideProps(override, prop) {
|
|
|
12
12
|
onReset: () => override.reset(prop),
|
|
13
13
|
};
|
|
14
14
|
}
|
|
15
|
-
|
|
15
|
+
// `meta` is the behavior's `static propertyMeta`. A numeric prop can declare
|
|
16
|
+
// `{ min, max, step }` there to get a real range instead of NumberField's
|
|
17
|
+
// defaults -- which are an unbounded scrubber stepping by whole numbers, wrong
|
|
18
|
+
// for anything that lives in 0..1. Declaring BOTH min and max makes it a true
|
|
19
|
+
// slider (fill + thumb). Props that declare nothing are unchanged.
|
|
20
|
+
export function AutoFields({ defaultProps, component, setComponent, only, exclude, override, meta }) {
|
|
16
21
|
const keys = Object.keys(defaultProps).filter((key) => {
|
|
17
22
|
if (only) return only.includes(key);
|
|
18
23
|
if (exclude) return !exclude.includes(key);
|
|
@@ -28,7 +33,19 @@ export function AutoFields({ defaultProps, component, setComponent, only, exclud
|
|
|
28
33
|
const sample = fallback ?? current;
|
|
29
34
|
const ov = overrideProps(override, key);
|
|
30
35
|
if (typeof sample === 'number') {
|
|
31
|
-
|
|
36
|
+
const { min, max, step } = meta?.[key] ?? {};
|
|
37
|
+
return (
|
|
38
|
+
<NumberField
|
|
39
|
+
key={key}
|
|
40
|
+
label={label}
|
|
41
|
+
value={current}
|
|
42
|
+
onChange={set}
|
|
43
|
+
{...(min == null ? {} : { min })}
|
|
44
|
+
{...(max == null ? {} : { max })}
|
|
45
|
+
{...(step == null ? {} : { step })}
|
|
46
|
+
{...ov}
|
|
47
|
+
/>
|
|
48
|
+
);
|
|
32
49
|
}
|
|
33
50
|
if (typeof sample === 'boolean') {
|
|
34
51
|
return <CheckboxField key={key} label={label} checked={current} onChange={set} {...ov} />;
|
|
@@ -49,7 +66,14 @@ export function AutoFields({ defaultProps, component, setComponent, only, exclud
|
|
|
49
66
|
</>
|
|
50
67
|
);
|
|
51
68
|
}
|
|
52
|
-
export function AutoInspector({
|
|
69
|
+
export function AutoInspector({
|
|
70
|
+
behaviorName,
|
|
71
|
+
defaultProps,
|
|
72
|
+
component,
|
|
73
|
+
setComponent,
|
|
74
|
+
override,
|
|
75
|
+
meta,
|
|
76
|
+
}) {
|
|
53
77
|
return (
|
|
54
78
|
<Panel title={humanizeKey(behaviorName)} overridden={override?.anyOverridden()}>
|
|
55
79
|
<AutoFields
|
|
@@ -57,6 +81,7 @@ export function AutoInspector({ behaviorName, defaultProps, component, setCompon
|
|
|
57
81
|
component={component}
|
|
58
82
|
setComponent={setComponent}
|
|
59
83
|
override={override}
|
|
84
|
+
meta={meta}
|
|
60
85
|
/>
|
|
61
86
|
</Panel>
|
|
62
87
|
);
|
|
@@ -33,6 +33,7 @@ export class PhysicsSystem {
|
|
|
33
33
|
this.bodies = new Map(); // actorId -> { body, sig, offset: {dx, dy} }
|
|
34
34
|
this.joints = new Map(); // `${ownerId}#${listIndex}` -> { constraints, bodyA, bodyB, sig }
|
|
35
35
|
this.intents = new Map(); // actorId -> { velocity?, impulse?, force? }
|
|
36
|
+
this.grabs = new Map(); // actorId -> { constraint, localPoint }
|
|
36
37
|
this.gravity = { ...DEFAULT_GRAVITY };
|
|
37
38
|
this.acc = 0;
|
|
38
39
|
this.enterPairs = []; // [aId, bId] collected during this frame's steps
|
|
@@ -57,6 +58,7 @@ export class PhysicsSystem {
|
|
|
57
58
|
this.engine = null;
|
|
58
59
|
this.bodies.clear();
|
|
59
60
|
this.joints.clear();
|
|
61
|
+
this.grabs.clear();
|
|
60
62
|
this.intents.clear();
|
|
61
63
|
this.active.clear();
|
|
62
64
|
this.ndActive.clear();
|
|
@@ -189,6 +191,12 @@ export class PhysicsSystem {
|
|
|
189
191
|
removeBody(id) {
|
|
190
192
|
const entry = this.bodies.get(id);
|
|
191
193
|
if (!entry) return;
|
|
194
|
+
// Drop any grab first: its constraint holds a direct reference to this body,
|
|
195
|
+
// and unlike joints (rebuilt every frame by reconcileJoints) nothing else
|
|
196
|
+
// revisits it -- so despawning a held actor, or editing its collider mid-play
|
|
197
|
+
// (a signature change removes and re-adds the body), would otherwise strand a
|
|
198
|
+
// constraint in the world pointing at a body that no longer exists.
|
|
199
|
+
this.release(id);
|
|
192
200
|
Matter.Composite.remove(this.engine.world, entry.body);
|
|
193
201
|
this.bodies.delete(id);
|
|
194
202
|
// Drop live contacts involving this actor so isColliding doesn't go stale
|
|
@@ -314,6 +322,102 @@ export class PhysicsSystem {
|
|
|
314
322
|
return this.bodies.get(id)?.body ?? null;
|
|
315
323
|
}
|
|
316
324
|
|
|
325
|
+
// Exact point-in-collider test. The matter body is already positioned and
|
|
326
|
+
// rotated by the sim, so this respects the collider's real shape (circle,
|
|
327
|
+
// angled box, compound) at its CURRENT orientation for free -- no transform
|
|
328
|
+
// math here, and nothing to keep in sync with Layout.
|
|
329
|
+
//
|
|
330
|
+
// An actor with no body has no touch zone, which is the intended semantics: a
|
|
331
|
+
// thing with no collider is not grabbable.
|
|
332
|
+
containsPoint(actorOrId, x, y) {
|
|
333
|
+
const body = this.bodyOf(actorOrId);
|
|
334
|
+
if (!body) return false;
|
|
335
|
+
const point = { x, y };
|
|
336
|
+
if (!Matter.Bounds.contains(body.bounds, point)) return false;
|
|
337
|
+
// parts[0] is the parent's convex HULL when the body is compound, so testing
|
|
338
|
+
// it would report hits in a concave notch (the underside of a skateboard's
|
|
339
|
+
// deck-plus-nose-and-tail). Test the real parts instead.
|
|
340
|
+
const parts = body.parts.length > 1 ? body.parts.slice(1) : body.parts;
|
|
341
|
+
return parts.some((part) => Matter.Vertices.contains(part.vertices, point));
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Topmost actor whose COLLIDER contains the point, high z first to match draw
|
|
345
|
+
// order. This is the pick a touch control wants; `scene.actorAt` tests the
|
|
346
|
+
// Layout box instead, which is what the editor wants for click-to-select.
|
|
347
|
+
//
|
|
348
|
+
// Deliberately NOT via scene.getActors(): that copies and sorts the actor list
|
|
349
|
+
// on every call, and every Draggable and Slingshot in the scene calls this on
|
|
350
|
+
// the same press frame -- O(n log n) each, once per actor, plus an array per
|
|
351
|
+
// call. Tracking the best z in one pass is O(n) with no allocation, and the
|
|
352
|
+
// `z < bestZ` test skips the shape work for actors that cannot win anyway.
|
|
353
|
+
// Ties go to the later actor, matching the old reverse-iteration order.
|
|
354
|
+
actorAtPoint(scene, x, y) {
|
|
355
|
+
let best = null;
|
|
356
|
+
let bestZ = -Infinity;
|
|
357
|
+
for (const actor of scene.actors.values()) {
|
|
358
|
+
const z = actor.components?.Layout?.z ?? 0;
|
|
359
|
+
if (z < bestZ) continue;
|
|
360
|
+
if (!this.containsPoint(actor, x, y)) continue;
|
|
361
|
+
best = actor;
|
|
362
|
+
bestZ = z;
|
|
363
|
+
}
|
|
364
|
+
return best;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// Grab an actor AT a world point: a constraint from that exact spot on the
|
|
368
|
+
// body to the pointer, so an off-center grab induces torque and the object
|
|
369
|
+
// swings the way a real held object does. Matter rotates the anchor with the
|
|
370
|
+
// body from here on, so the grab stays welded to the spot that was touched.
|
|
371
|
+
//
|
|
372
|
+
// `pointA` is a world-axis offset from the body center at creation time
|
|
373
|
+
// (Constraint.create seeds `angleA` to the body's current angle), and matter
|
|
374
|
+
// mutates it in place each solve -- so never hand it an object we also keep.
|
|
375
|
+
grab(actorOrId, x, y, opts = {}) {
|
|
376
|
+
const body = this.bodyOf(actorOrId);
|
|
377
|
+
if (!body || !this.engine) return false;
|
|
378
|
+
const id = typeof actorOrId === 'string' ? actorOrId : actorOrId?.id;
|
|
379
|
+
this.release(id);
|
|
380
|
+
const constraint = Matter.Constraint.create({
|
|
381
|
+
bodyA: body,
|
|
382
|
+
pointA: { x: x - body.position.x, y: y - body.position.y },
|
|
383
|
+
pointB: { x, y },
|
|
384
|
+
length: 0,
|
|
385
|
+
// Modest stiffness + real damping on purpose: MAX_SPEED backstops a
|
|
386
|
+
// blow-up, but a near-1 stiffness on a heavy body is matter's classic
|
|
387
|
+
// energy-injection case and we would rather not lean on the clamp.
|
|
388
|
+
stiffness: opts.stiffness ?? 0.1,
|
|
389
|
+
damping: opts.damping ?? 0.2,
|
|
390
|
+
});
|
|
391
|
+
Matter.Composite.add(this.engine.world, constraint);
|
|
392
|
+
this.grabs.set(id, { constraint });
|
|
393
|
+
return true;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
moveGrab(actorOrId, x, y) {
|
|
397
|
+
const id = typeof actorOrId === 'string' ? actorOrId : actorOrId?.id;
|
|
398
|
+
const entry = this.grabs.get(id);
|
|
399
|
+
if (!entry) return;
|
|
400
|
+
entry.constraint.pointB = { x, y };
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
release(actorOrId) {
|
|
404
|
+
const id = typeof actorOrId === 'string' ? actorOrId : actorOrId?.id;
|
|
405
|
+
const entry = this.grabs.get(id);
|
|
406
|
+
if (!entry) return;
|
|
407
|
+
if (this.engine) Matter.Composite.remove(this.engine.world, entry.constraint);
|
|
408
|
+
this.grabs.delete(id);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// Where the grab is holding the body right now, in world space -- read from
|
|
412
|
+
// the constraint matter is actually solving, so the drawn handle cannot drift
|
|
413
|
+
// away from the physics.
|
|
414
|
+
grabPoint(actorOrId) {
|
|
415
|
+
const id = typeof actorOrId === 'string' ? actorOrId : actorOrId?.id;
|
|
416
|
+
const entry = this.grabs.get(id);
|
|
417
|
+
if (!entry) return null;
|
|
418
|
+
return Matter.Constraint.pointAWorld(entry.constraint);
|
|
419
|
+
}
|
|
420
|
+
|
|
317
421
|
queue(actorOrId, key, value) {
|
|
318
422
|
const id = typeof actorOrId === 'string' ? actorOrId : actorOrId?.id;
|
|
319
423
|
if (!id) return;
|
|
@@ -350,6 +454,19 @@ export class PhysicsSystem {
|
|
|
350
454
|
return this.active.has(pairKey(aId, bId));
|
|
351
455
|
},
|
|
352
456
|
bodyFor: (actor) => this.bodyOf(actor),
|
|
457
|
+
// Shape-accurate hit testing: does this actor's collider contain the
|
|
458
|
+
// point, and which actor is topmost at it. Respects rotation and compound
|
|
459
|
+
// shapes, unlike the Layout-box `scene.actorAt`.
|
|
460
|
+
containsPoint: (actor, x, y) => this.containsPoint(actor, x, y),
|
|
461
|
+
actorAtPoint: (x, y) =>
|
|
462
|
+
this.lastScene ? this.actorAtPoint(this.lastScene, x, y) : null,
|
|
463
|
+
// Hold an actor at a world point and drag it there; an off-center grab
|
|
464
|
+
// turns the body as well as moving it. `grabPoint` is where the hold sits
|
|
465
|
+
// now, for drawing.
|
|
466
|
+
grab: (actor, x, y, opts) => this.grab(actor, x, y, opts),
|
|
467
|
+
moveGrab: (actor, x, y) => this.moveGrab(actor, x, y),
|
|
468
|
+
release: (actor) => this.release(actor),
|
|
469
|
+
grabPoint: (actor) => this.grabPoint(actor),
|
|
353
470
|
// Resolved joint geometry for custom rendering: each link's world-space
|
|
354
471
|
// endpoints (Layout centers, so art lines up with the sprites), type,
|
|
355
472
|
// length and angle. Draw your own art between `a` and `b` from a behavior's
|