castle-web-cli 0.4.85 → 0.4.87
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/shell/assets/{index-BJLaUTJE.js → index-BFCG4tLs.js} +3 -3
- package/dist/shell/assets/{index-DonnH--m.css → index-DSIr52Kl.css} +1 -1
- package/dist/shell/index.html +2 -2
- package/kits/basic-2d/CLAUDE.md +3 -3
- package/kits/basic-2d/behaviors/Collider.jsx +172 -26
- package/kits/basic-2d/behaviors/Layout.jsx +24 -0
- package/kits/basic-2d/editors/PlayOnly.jsx +11 -9
- package/kits/basic-2d/editors/PxArtEditor.jsx +155 -17
- package/kits/basic-2d/editors/SceneEditor.jsx +103 -19
- package/kits/basic-2d/editors/SelectionOverlay.jsx +21 -11
- package/kits/basic-2d/editors/behaviorRegistry.js +9 -3
- package/kits/basic-2d/editors/useArtboardFit.js +4 -1
- package/kits/basic-2d/engine/ScenePlayer.jsx +14 -1
- package/kits/basic-2d/engine/behaviorExtensions.js +28 -0
- package/kits/basic-2d/engine/collider.js +60 -6
- package/kits/basic-2d/engine/scene.js +28 -2
- package/kits/basic-2d/engine/systemRegistry.js +12 -0
- package/kits/basic-2d/engine/ui.jsx +9 -0
- package/kits/basic-2d/main.jsx +3 -2
- package/kits/physics-2d/behaviors/Collider.jsx +20 -6
- package/kits/physics-2d/editors/PxArtEditor.jsx +155 -17
- package/kits/physics-2d/editors/SceneEditor.jsx +111 -45
- package/kits/physics-2d/editors/useArtboardFit.js +4 -1
- package/kits/physics-2d/engine/ScenePlayer.jsx +14 -1
- package/kits/physics-2d/engine/behaviorExtensions.js +28 -0
- package/kits/physics-2d/engine/collider.js +6 -2
- package/kits/physics-2d/engine/scene.js +12 -13
- package/kits/physics-2d/engine/systemRegistry.js +12 -0
- package/kits/physics-2d/engine/ui.jsx +7 -0
- package/kits/physics-2d/physics/behaviors/AnalogStick.jsx +4 -2
- package/kits/physics-2d/physics/behaviors/Slingshot.jsx +8 -2
- package/kits/physics-2d/physics/controls.js +14 -0
- package/kits/physics-2d/physics/extensions/collider.js +15 -0
- package/kits/physics-2d/systems/physics.js +8 -0
- package/package.json +1 -1
|
@@ -58,6 +58,17 @@ const BLUEPRINT_TEMPLATE_ACTOR_ID = '__template__';
|
|
|
58
58
|
const LONG_PRESS_MS = 500;
|
|
59
59
|
const DRAG_THRESHOLD = 4;
|
|
60
60
|
const DEFAULT_GRID_SIZE = 25;
|
|
61
|
+
// Floating zoom/pan control cluster, tucked into the bottom-right corner of the
|
|
62
|
+
// stage workspace (matches the art editor's placement).
|
|
63
|
+
const ZOOM_CONTROLS_STYLE = {
|
|
64
|
+
position: 'absolute',
|
|
65
|
+
right: 8,
|
|
66
|
+
bottom: 8,
|
|
67
|
+
display: 'flex',
|
|
68
|
+
flexDirection: 'column',
|
|
69
|
+
gap: 4,
|
|
70
|
+
zIndex: 5,
|
|
71
|
+
};
|
|
61
72
|
const EDIT_VIEWPORT = {
|
|
62
73
|
width: cardSize.width * 3,
|
|
63
74
|
height: cardSize.height * 3,
|
|
@@ -321,7 +332,9 @@ export function SceneEditor({
|
|
|
321
332
|
const Behavior = findBehaviorClass(behaviorName);
|
|
322
333
|
if (!Behavior) return;
|
|
323
334
|
updateBlueprintTemplate((components) => {
|
|
324
|
-
components
|
|
335
|
+
// The template's own components (Layout/Sprite) are the actor context an
|
|
336
|
+
// initialProps hook (e.g. Collider auto-fit) reads from.
|
|
337
|
+
components[behaviorName] = initialBehaviorProps(Behavior, { components }, sprites);
|
|
325
338
|
});
|
|
326
339
|
},
|
|
327
340
|
removeBehavior: (behaviorName) =>
|
|
@@ -347,6 +360,8 @@ export function SceneEditor({
|
|
|
347
360
|
selectedActorIds,
|
|
348
361
|
onSelectActorIds,
|
|
349
362
|
isBlueprintFile,
|
|
363
|
+
sprites,
|
|
364
|
+
resolvedActors: previewSceneData?.actors,
|
|
350
365
|
});
|
|
351
366
|
const snapSettings = getSnapSettings(sceneData);
|
|
352
367
|
const stageWrapStyle = {
|
|
@@ -479,7 +494,7 @@ export function SceneEditor({
|
|
|
479
494
|
/>
|
|
480
495
|
)}
|
|
481
496
|
</div>
|
|
482
|
-
<div className={styles.stageWrap} style={stageWrapStyle}>
|
|
497
|
+
<div className={styles.stageWrap} style={{ ...stageWrapStyle, position: 'relative' }}>
|
|
483
498
|
<div className={styles.stageCard} data-castle-card>
|
|
484
499
|
<div className={cx(styles.stageCardClip, !isPlaying && styles.stageCardEditSurface)}>
|
|
485
500
|
<canvas
|
|
@@ -494,7 +509,12 @@ export function SceneEditor({
|
|
|
494
509
|
onPointerDown={(event) => {
|
|
495
510
|
event.currentTarget.focus({ preventScroll: true });
|
|
496
511
|
if (isPlaying) return playPointer.onPointerDown(event);
|
|
497
|
-
if (
|
|
512
|
+
if (
|
|
513
|
+
panGesture.isSpacePanning() ||
|
|
514
|
+
event.button === 1 ||
|
|
515
|
+
(event.button === 0 && event.metaKey)
|
|
516
|
+
)
|
|
517
|
+
return panGesture.onPointerDown(event);
|
|
498
518
|
return gesture.onPointerDown(event);
|
|
499
519
|
}}
|
|
500
520
|
onPointerMove={(event) =>
|
|
@@ -538,6 +558,25 @@ export function SceneEditor({
|
|
|
538
558
|
/>
|
|
539
559
|
)}
|
|
540
560
|
</div>
|
|
561
|
+
{!isPlaying && (
|
|
562
|
+
<div style={ZOOM_CONTROLS_STYLE}>
|
|
563
|
+
<IconButton
|
|
564
|
+
icon="zoom-in"
|
|
565
|
+
label="Zoom in"
|
|
566
|
+
onClick={() => panGesture.zoomAtCenter(1.25)}
|
|
567
|
+
/>
|
|
568
|
+
<IconButton
|
|
569
|
+
icon="zoom-out"
|
|
570
|
+
label="Zoom out"
|
|
571
|
+
onClick={() => panGesture.zoomAtCenter(0.8)}
|
|
572
|
+
/>
|
|
573
|
+
<IconButton
|
|
574
|
+
icon="fit"
|
|
575
|
+
label="Reset view"
|
|
576
|
+
onClick={() => panGesture.resetView()}
|
|
577
|
+
/>
|
|
578
|
+
</div>
|
|
579
|
+
)}
|
|
541
580
|
</div>
|
|
542
581
|
<aside {...inspectorSheet.rootProps}>
|
|
543
582
|
<div {...inspectorSheet.grabProps}>
|
|
@@ -601,6 +640,10 @@ export function SceneEditor({
|
|
|
601
640
|
}
|
|
602
641
|
// Header-right playback controls, in the mockup's order: Undo, Redo, Play.
|
|
603
642
|
// Play stays a play/stop toggle. Duplicate/remove live on the on-canvas toolbar.
|
|
643
|
+
// "Open preview" asks the shell (parent window) to open/focus a Play panel for
|
|
644
|
+
// THIS scene -- a separate, persistent preview alongside the editor (vs the
|
|
645
|
+
// in-panel play toggle). Defaults to an already-open preview for this scene,
|
|
646
|
+
// else opens a new one (handled shell-side, keyed by scene).
|
|
604
647
|
function HeaderPlaybackButtons({ isPlaying, setIsPlaying, history }) {
|
|
605
648
|
return (
|
|
606
649
|
<>
|
|
@@ -638,7 +681,17 @@ function HeaderPlaybackButtons({ isPlaying, setIsPlaying, history }) {
|
|
|
638
681
|
// remove-behavior stay whole-component operations straight against the
|
|
639
682
|
// instance's own sparse `components` -- there's no per-property baseline to
|
|
640
683
|
// diff when a component doesn't exist on one side at all.
|
|
641
|
-
|
|
684
|
+
// Props to seed a behavior with when it's freshly added to `actor`. A behavior
|
|
685
|
+
// may define `static initialProps(actor, { sprites })` to derive props from the
|
|
686
|
+
// actor it lands on (e.g. Collider snaps to the sprite's opaque-pixel bounds);
|
|
687
|
+
// otherwise it starts from `defaultProps`. `actor` is the resolved actor
|
|
688
|
+
// (Layout/Sprite merged) or, for a blueprint template, `{ components }`.
|
|
689
|
+
function initialBehaviorProps(Behavior, actor, sprites) {
|
|
690
|
+
if (Behavior.initialProps && actor) return Behavior.initialProps(actor, { sprites });
|
|
691
|
+
return { ...Behavior.defaultProps };
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
function makeSceneActions({ sceneData, files, serialize, commit, selectedActorIds, onSelectActorIds, isBlueprintFile, sprites, resolvedActors }) {
|
|
642
695
|
function commitScene(next, options) {
|
|
643
696
|
commit(serialize(next), options);
|
|
644
697
|
}
|
|
@@ -650,8 +703,9 @@ function makeSceneActions({ sceneData, files, serialize, commit, selectedActorId
|
|
|
650
703
|
addBehavior: (actorId, behaviorName) => {
|
|
651
704
|
const Behavior = findBehaviorClass(behaviorName);
|
|
652
705
|
if (!Behavior) return;
|
|
706
|
+
const resolved = resolvedActors?.find((actor) => actor.id === actorId);
|
|
653
707
|
commitScene(
|
|
654
|
-
setActorComponent(sceneData, actorId, behaviorName,
|
|
708
|
+
setActorComponent(sceneData, actorId, behaviorName, initialBehaviorProps(Behavior, resolved, sprites))
|
|
655
709
|
);
|
|
656
710
|
},
|
|
657
711
|
removeBehavior: (actorId, behaviorName) =>
|
|
@@ -1004,18 +1058,19 @@ function usePanGesture({ canvasRef, editCameraRef, isPlaying }) {
|
|
|
1004
1058
|
const onWheel = (event) => {
|
|
1005
1059
|
if (isPlayingRef.current) return;
|
|
1006
1060
|
event.preventDefault();
|
|
1007
|
-
//
|
|
1008
|
-
//
|
|
1009
|
-
if (
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1061
|
+
// Safari fires ctrl+wheel alongside its own gesture* pinch events; skip the
|
|
1062
|
+
// wheel while a pinch is mid-flight so zoom isn't applied twice.
|
|
1063
|
+
if (gestureActive) return;
|
|
1064
|
+
// The wheel zooms at the cursor (standard editor affordance). Trackpad
|
|
1065
|
+
// pinch and Ctrl+wheel arrive here too (Chromium sets ctrlKey) and map to
|
|
1066
|
+
// the same zoom. Clamp per-event delta so a chunky mouse-wheel notch
|
|
1067
|
+
// (deltaY ~100) is a sane step (≤1.65x) while trackpad deltas stay smooth.
|
|
1068
|
+
const clampedDeltaY = Math.max(-50, Math.min(50, event.deltaY));
|
|
1069
|
+
zoomByFactorAtPointer(
|
|
1070
|
+
event.clientX,
|
|
1071
|
+
event.clientY,
|
|
1072
|
+
Math.exp(-clampedDeltaY * ZOOM_WHEEL_SPEED)
|
|
1073
|
+
);
|
|
1019
1074
|
};
|
|
1020
1075
|
const onGestureStart = (event) => {
|
|
1021
1076
|
if (isPlayingRef.current) return;
|
|
@@ -1056,7 +1111,13 @@ function usePanGesture({ canvasRef, editCameraRef, isPlaying }) {
|
|
|
1056
1111
|
};
|
|
1057
1112
|
}, [canvasRef, panByScreenDelta, zoomByFactorAtPointer]);
|
|
1058
1113
|
const onPointerDown = useCallback((event) => {
|
|
1059
|
-
if (isPlaying
|
|
1114
|
+
if (isPlaying) return;
|
|
1115
|
+
// Pan when Space is held (existing modifier), on a middle-button drag, or on
|
|
1116
|
+
// a ⌘+left-drag (metaKey) — matching the art editor. preventDefault also
|
|
1117
|
+
// suppresses the browser's middle-click autoscroll.
|
|
1118
|
+
const middle = event.button === 1;
|
|
1119
|
+
const cmdDrag = event.button === 0 && event.metaKey;
|
|
1120
|
+
if (!spaceDownRef.current && !middle && !cmdDrag) return;
|
|
1060
1121
|
event.preventDefault();
|
|
1061
1122
|
event.currentTarget.setPointerCapture(event.pointerId);
|
|
1062
1123
|
dragRef.current = { id: event.pointerId, lastX: event.clientX, lastY: event.clientY };
|
|
@@ -1085,7 +1146,29 @@ function usePanGesture({ canvasRef, editCameraRef, isPlaying }) {
|
|
|
1085
1146
|
}, []);
|
|
1086
1147
|
const isSpacePanning = useCallback(() => spaceDownRef.current, []);
|
|
1087
1148
|
const isActive = useCallback(() => Boolean(dragRef.current), []);
|
|
1088
|
-
|
|
1149
|
+
// Button-driven zoom about the canvas center, and a reset-to-fit that restores
|
|
1150
|
+
// the initial camera (zoom 1 = the card exactly fills the viewport).
|
|
1151
|
+
const zoomAtCenter = useCallback(
|
|
1152
|
+
(factor) => {
|
|
1153
|
+
const canvas = canvasRef.current;
|
|
1154
|
+
if (!canvas) return;
|
|
1155
|
+
const rect = canvas.getBoundingClientRect();
|
|
1156
|
+
zoomByFactorAtPointer(rect.left + rect.width / 2, rect.top + rect.height / 2, factor);
|
|
1157
|
+
},
|
|
1158
|
+
[canvasRef, zoomByFactorAtPointer]
|
|
1159
|
+
);
|
|
1160
|
+
const resetView = useCallback(() => {
|
|
1161
|
+
editCameraRef.current = { x: 0, y: 0, zoom: 1 };
|
|
1162
|
+
}, [editCameraRef]);
|
|
1163
|
+
return {
|
|
1164
|
+
onPointerDown,
|
|
1165
|
+
onPointerMove,
|
|
1166
|
+
onPointerUp,
|
|
1167
|
+
isSpacePanning,
|
|
1168
|
+
isActive,
|
|
1169
|
+
zoomAtCenter,
|
|
1170
|
+
resetView,
|
|
1171
|
+
};
|
|
1089
1172
|
}
|
|
1090
1173
|
function handleShiftPointerDown(drag, actor, current) {
|
|
1091
1174
|
if (actor) {
|
|
@@ -1565,6 +1648,7 @@ function ActorInspector({
|
|
|
1565
1648
|
actor={selectedActor}
|
|
1566
1649
|
component={component}
|
|
1567
1650
|
files={files}
|
|
1651
|
+
sprites={sprites}
|
|
1568
1652
|
setComponent={setComponent}
|
|
1569
1653
|
override={override}
|
|
1570
1654
|
/>
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
-
import {
|
|
2
|
+
import { getColliderShape } from '../behaviors/Collider';
|
|
3
3
|
import { cardSize, screenToCard } from '../engine/scene';
|
|
4
4
|
import { cx, Icon, styles, useElementSize } from '../engine/ui';
|
|
5
5
|
|
|
@@ -216,13 +216,14 @@ export function SelectionOverlay({
|
|
|
216
216
|
key={collider.actorId}
|
|
217
217
|
className={cx(
|
|
218
218
|
styles.selColliderBox,
|
|
219
|
-
collider.
|
|
219
|
+
collider.isTrigger && styles.selColliderPickup
|
|
220
220
|
)}
|
|
221
221
|
style={{
|
|
222
222
|
left: collider.x,
|
|
223
223
|
top: collider.y,
|
|
224
224
|
width: collider.width,
|
|
225
225
|
height: collider.height,
|
|
226
|
+
borderRadius: collider.shape === 'circle' ? '50%' : undefined,
|
|
226
227
|
transformOrigin: `${collider.originX}px ${collider.originY}px`,
|
|
227
228
|
transform: `rotate(${collider.rotation}deg)`,
|
|
228
229
|
}}
|
|
@@ -463,18 +464,27 @@ function getSelectedColliderFrames(sceneData, actorIds, sprites) {
|
|
|
463
464
|
.map((actor) => {
|
|
464
465
|
const layout = actor.components.Layout;
|
|
465
466
|
const collider = actor.components.Collider;
|
|
466
|
-
const
|
|
467
|
-
if (!layout || !collider || !
|
|
467
|
+
const geom = getColliderShape(actor, sprites);
|
|
468
|
+
if (!layout || !collider || !geom) return null;
|
|
469
|
+
// For a circle we render a radius-sized square with border-radius; for a
|
|
470
|
+
// box, the rect itself. Both come from the shared geometry so the preview
|
|
471
|
+
// matches the physics body exactly (shape, radius, offset, auto/manual).
|
|
472
|
+
const isCircle = geom.shape === 'circle';
|
|
473
|
+
const x = isCircle ? geom.cx - geom.radius : geom.x;
|
|
474
|
+
const y = isCircle ? geom.cy - geom.radius : geom.y;
|
|
475
|
+
const width = isCircle ? geom.radius * 2 : geom.width;
|
|
476
|
+
const height = isCircle ? geom.radius * 2 : geom.height;
|
|
468
477
|
return {
|
|
469
478
|
actorId: actor.id,
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
479
|
+
shape: geom.shape,
|
|
480
|
+
isTrigger: Boolean(collider.isTrigger) || collider.kind === 'pickup',
|
|
481
|
+
x,
|
|
482
|
+
y,
|
|
483
|
+
width,
|
|
484
|
+
height,
|
|
475
485
|
rotation: layout.rotation ?? 0,
|
|
476
|
-
originX: layout.x + layout.width / 2 -
|
|
477
|
-
originY: layout.y + layout.height / 2 -
|
|
486
|
+
originX: layout.x + layout.width / 2 - x,
|
|
487
|
+
originY: layout.y + layout.height / 2 - y,
|
|
478
488
|
};
|
|
479
489
|
})
|
|
480
490
|
.filter(Boolean);
|
|
@@ -1,6 +1,12 @@
|
|
|
1
|
-
// Discover every behavior class from `behaviors/*.
|
|
2
|
-
//
|
|
3
|
-
|
|
1
|
+
// Discover every behavior class from `behaviors/*.jsx` and the physics module's
|
|
2
|
+
// `physics/behaviors/*.jsx`. Vite HMR is off, so a newly-added behavior file is
|
|
3
|
+
// picked up on the next reload/restart. The physics glob is what lets the
|
|
4
|
+
// self-contained physics module ship its behaviors without polluting the core
|
|
5
|
+
// `behaviors/` folder — a kit adopts physics by copying `physics/` in.
|
|
6
|
+
const modules = {
|
|
7
|
+
...import.meta.glob('../behaviors/*.jsx', { eager: true }),
|
|
8
|
+
...import.meta.glob('../physics/behaviors/*.jsx', { eager: true }),
|
|
9
|
+
};
|
|
4
10
|
function isBehaviorClass(value) {
|
|
5
11
|
return typeof value === 'function' && typeof value.behaviorName === 'string';
|
|
6
12
|
}
|
|
@@ -98,5 +98,8 @@ export function useArtboardFit({ wrapRef, sizeBarRef, resolution }) {
|
|
|
98
98
|
? { width: `${displaySize.width}px`, height: `${displaySize.height}px` }
|
|
99
99
|
: undefined;
|
|
100
100
|
|
|
101
|
-
|
|
101
|
+
// `displaySize` (numeric px, or null) is the fit baseline the zoom/pan layer
|
|
102
|
+
// multiplies against; `canvasStyle` is the same value pre-formatted for the
|
|
103
|
+
// unzoomed render path.
|
|
104
|
+
return { canvasStyle, displaySize, refit: fit };
|
|
102
105
|
}
|
|
@@ -93,7 +93,20 @@ export function ScenePlayer({ sceneData, sprites, files, behaviorClasses, onFirs
|
|
|
93
93
|
<canvas
|
|
94
94
|
ref={canvasRef}
|
|
95
95
|
tabIndex={0}
|
|
96
|
-
style={{
|
|
96
|
+
style={{
|
|
97
|
+
width: '100%',
|
|
98
|
+
height: '100%',
|
|
99
|
+
display: 'block',
|
|
100
|
+
outline: 'none',
|
|
101
|
+
// Claim touches for the game: without this, a mobile WebView
|
|
102
|
+
// (castle-client) treats a drag as a scroll/pan gesture and fires
|
|
103
|
+
// `pointercancel` mid-drag, so a held Draggable/Slingshot loses its
|
|
104
|
+
// grip. Also suppress long-press text selection / callout.
|
|
105
|
+
touchAction: 'none',
|
|
106
|
+
userSelect: 'none',
|
|
107
|
+
WebkitUserSelect: 'none',
|
|
108
|
+
WebkitTouchCallout: 'none',
|
|
109
|
+
}}
|
|
97
110
|
/>
|
|
98
111
|
<SceneUI getRuntime={getRuntime} />
|
|
99
112
|
{error ? <PlayErrorOverlay error={error} onRestart={onRestart} /> : null}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Behavior field extensions. A self-contained kit module (like `physics/`) can
|
|
2
|
+
// add fields to a behavior defined in the shared core WITHOUT editing that
|
|
3
|
+
// behavior's file -- so the core behavior stays byte-identical across kits and
|
|
4
|
+
// the module owns its own additions. Each extension module under any
|
|
5
|
+
// `<module>/extensions/*.js` exports:
|
|
6
|
+
//
|
|
7
|
+
// export const behaviorExtension = {
|
|
8
|
+
// behaviorName: 'Collider',
|
|
9
|
+
// defaultProps: { bounciness: 0, friction: 0.1 },
|
|
10
|
+
// };
|
|
11
|
+
//
|
|
12
|
+
// A behavior folds its registered extensions into its own `defaultProps` (see
|
|
13
|
+
// behaviors/Collider.jsx `...extensionDefaultProps('Collider')`); the inspector
|
|
14
|
+
// already renders leftover defaultProps generically, so registered fields show
|
|
15
|
+
// up with no inspector change. Empty in a kit with no `*/extensions/` dir (e.g.
|
|
16
|
+
// basic-2d). Symmetric with engine/systemRegistry.js and editors/behaviorRegistry.js.
|
|
17
|
+
const modules = import.meta.glob('../*/extensions/*.js', { eager: true });
|
|
18
|
+
const extensions = Object.values(modules)
|
|
19
|
+
.map((mod) => mod.behaviorExtension)
|
|
20
|
+
.filter(Boolean);
|
|
21
|
+
|
|
22
|
+
// Merged defaultProps that registered extensions contribute to `behaviorName`
|
|
23
|
+
// (empty object when none). Later extensions win on a key collision.
|
|
24
|
+
export function extensionDefaultProps(behaviorName) {
|
|
25
|
+
return extensions
|
|
26
|
+
.filter((ext) => ext.behaviorName === behaviorName)
|
|
27
|
+
.reduce((acc, ext) => ({ ...acc, ...ext.defaultProps }), {});
|
|
28
|
+
}
|
|
@@ -113,13 +113,16 @@ function manualRect(layout, collider) {
|
|
|
113
113
|
|
|
114
114
|
const fullLayoutRect = (layout) => ({ x: layout.x, y: layout.y, width: layout.width, height: layout.height });
|
|
115
115
|
|
|
116
|
-
// The un-offset rect
|
|
117
|
-
//
|
|
118
|
-
//
|
|
116
|
+
// The un-offset rect. Colliders are sized by their explicit `width`/`height`
|
|
117
|
+
// (the "auto vs manual" mode is gone -- use the inspector's "Auto-fit to sprite"
|
|
118
|
+
// action to snap those to the sprite). Legacy decks that still carry
|
|
119
|
+
// `mode: 'auto'` keep their dynamic sprite fit for back-compat.
|
|
119
120
|
function modeRect(layout, collider, spriteProps, sprites) {
|
|
120
|
-
if (
|
|
121
|
-
|
|
122
|
-
|
|
121
|
+
if (collider.mode === 'auto') {
|
|
122
|
+
const sprite = spriteProps ? sprites?.[spriteProps.file] : null;
|
|
123
|
+
return autoRect(layout, spriteProps, sprite) ?? fullLayoutRect(layout);
|
|
124
|
+
}
|
|
125
|
+
return manualRect(layout, collider);
|
|
123
126
|
}
|
|
124
127
|
|
|
125
128
|
// The Collider rect for an actor, from its Layout + Collider (+ Sprite, for
|
|
@@ -141,6 +144,57 @@ export function getColliderRect(actor, sprites) {
|
|
|
141
144
|
};
|
|
142
145
|
}
|
|
143
146
|
|
|
147
|
+
// Full collider geometry: the AABB rect (`x/y/width/height`, offset-applied)
|
|
148
|
+
// PLUS the shape and, for circles, the center + radius. This is the SINGLE
|
|
149
|
+
// SOURCE OF TRUTH for collider shape -- the physics body (matterBridge), the
|
|
150
|
+
// play-mode debug draw (Collider.draw), and the editor selection overlay all
|
|
151
|
+
// derive their geometry from here, so the visual preview always matches the
|
|
152
|
+
// simulated collider. `radius` of 0 means "derive from the rect" (min side / 2),
|
|
153
|
+
// so a circle shows a real size even before you set an explicit radius.
|
|
154
|
+
export function getColliderShape(actor, sprites) {
|
|
155
|
+
const rect = getColliderRect(actor, sprites);
|
|
156
|
+
if (!rect) return null;
|
|
157
|
+
const collider = actor.components.Collider;
|
|
158
|
+
const shape = collider.shape === 'circle' ? 'circle' : 'box';
|
|
159
|
+
const radius = collider.radius > 0 ? collider.radius : Math.min(rect.width, rect.height) / 2;
|
|
160
|
+
return {
|
|
161
|
+
shape,
|
|
162
|
+
x: rect.x,
|
|
163
|
+
y: rect.y,
|
|
164
|
+
width: rect.width,
|
|
165
|
+
height: rect.height,
|
|
166
|
+
cx: rect.x + rect.width / 2,
|
|
167
|
+
cy: rect.y + rect.height / 2,
|
|
168
|
+
radius,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// The explicit `width`/`height`/`offsetX`/`offsetY` a collider would need to
|
|
173
|
+
// exactly match its sprite's opaque-pixel fit -- what "auto" used to compute
|
|
174
|
+
// dynamically. Returns null when there's nothing to fit to (no Sprite, tile
|
|
175
|
+
// mode, or fully transparent art). Powers the inspector's "Auto-fit to sprite".
|
|
176
|
+
export function computeAutoFit(actor, sprites) {
|
|
177
|
+
const rawLayout = actor?.components?.Layout;
|
|
178
|
+
const spriteProps = actor?.components?.Sprite;
|
|
179
|
+
if (!rawLayout || !spriteProps) return null;
|
|
180
|
+
// Blueprint templates omit x/y (position is instance-local). x/y cancel out of
|
|
181
|
+
// the offset delta below, so default them to 0 -- otherwise an undefined x/y
|
|
182
|
+
// (auto-fitting a template, e.g. on add) turns the offsets into NaN.
|
|
183
|
+
const layout = { ...rawLayout, x: rawLayout.x ?? 0, y: rawLayout.y ?? 0 };
|
|
184
|
+
const rect = autoRect(layout, spriteProps, sprites?.[spriteProps.file]);
|
|
185
|
+
if (!rect || rect.width <= 0 || rect.height <= 0) return null;
|
|
186
|
+
// manualRect centers a width x height box in the Layout box; the offset is the
|
|
187
|
+
// delta from that centered position to the sprite-fit rect.
|
|
188
|
+
const centeredX = layout.x + (layout.width - rect.width) / 2;
|
|
189
|
+
const centeredY = layout.y + (layout.height - rect.height) / 2;
|
|
190
|
+
return {
|
|
191
|
+
width: Math.round(rect.width),
|
|
192
|
+
height: Math.round(rect.height),
|
|
193
|
+
offsetX: Math.round(rect.x - centeredX),
|
|
194
|
+
offsetY: Math.round(rect.y - centeredY),
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
144
198
|
export function intersects(a, b) {
|
|
145
199
|
return a.x < b.x + b.width && a.x + a.width > b.x && a.y < b.y + b.height && a.y + a.height > b.y;
|
|
146
200
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { initialFiles, parseJsonFile } from './files';
|
|
2
2
|
import { getBlueprintTemplate, mergeComponents } from './blueprint';
|
|
3
3
|
import { getColliderRect, intersects, spriteIsEmpty } from './collider';
|
|
4
|
+
import { systemInstallers } from './systemRegistry';
|
|
4
5
|
|
|
5
6
|
const CARD_WIDTH = 500;
|
|
6
7
|
const CARD_HEIGHT = 700;
|
|
@@ -42,9 +43,21 @@ export class SceneRuntime {
|
|
|
42
43
|
this.actors = new Map();
|
|
43
44
|
this.camera = undefined;
|
|
44
45
|
this.status = undefined;
|
|
46
|
+
// Registered systems run once per frame after all behavior `update`s (see
|
|
47
|
+
// `update`). A system is a plain object with an optional
|
|
48
|
+
// `afterBehaviors(scene, dt)` hook; kits register systems from `systems/*.js`
|
|
49
|
+
// (see makeScene), so the engine core stays decoupled from any specific one.
|
|
50
|
+
this.systems = [];
|
|
45
51
|
this.load(sceneData);
|
|
46
52
|
}
|
|
47
53
|
|
|
54
|
+
// Register a per-frame system (e.g. a physics simulation). Systems step in
|
|
55
|
+
// registration order, after behaviors, every `update`. Returns the system.
|
|
56
|
+
registerSystem(system) {
|
|
57
|
+
this.systems.push(system);
|
|
58
|
+
return system;
|
|
59
|
+
}
|
|
60
|
+
|
|
48
61
|
load(sceneData) {
|
|
49
62
|
this.data = structuredClone(sceneData);
|
|
50
63
|
this.actors = new Map();
|
|
@@ -88,7 +101,9 @@ export class SceneRuntime {
|
|
|
88
101
|
}
|
|
89
102
|
|
|
90
103
|
clone() {
|
|
91
|
-
|
|
104
|
+
// Route through makeScene so the clone gets the same registered systems as a
|
|
105
|
+
// freshly-made runtime.
|
|
106
|
+
return makeScene(this.serialize(), [...this.behaviors.values()], this.sprites, this.files);
|
|
92
107
|
}
|
|
93
108
|
|
|
94
109
|
serialize() {
|
|
@@ -190,6 +205,12 @@ export class SceneRuntime {
|
|
|
190
205
|
for (const actor of this.getActors()) {
|
|
191
206
|
this.forEachBehavior(actor, (instance) => instance.update?.(actor, this, dt));
|
|
192
207
|
}
|
|
208
|
+
// Behaviors have expressed their intent (velocities, forces) for this frame;
|
|
209
|
+
// now let registered systems advance (e.g. physics integrates, writes results
|
|
210
|
+
// back to Layout, and dispatches collision callbacks).
|
|
211
|
+
for (const system of this.systems) {
|
|
212
|
+
system.afterBehaviors?.(this, dt);
|
|
213
|
+
}
|
|
193
214
|
}
|
|
194
215
|
|
|
195
216
|
forEachBehavior(actor, callback) {
|
|
@@ -299,7 +320,12 @@ export class SceneRuntime {
|
|
|
299
320
|
}
|
|
300
321
|
|
|
301
322
|
export function makeScene(sceneData, behaviors, sprites, files) {
|
|
302
|
-
|
|
323
|
+
const runtime = new SceneRuntime(sceneData, behaviors, sprites, files);
|
|
324
|
+
// Install any runtime systems the kit provides (systems/*.js) -- no-op in a kit
|
|
325
|
+
// with none. A system's engine is created lazily on first use, so draw-only
|
|
326
|
+
// editor previews (which never call update) stay free.
|
|
327
|
+
for (const install of systemInstallers) install(runtime);
|
|
328
|
+
return runtime;
|
|
303
329
|
}
|
|
304
330
|
|
|
305
331
|
export function setActorComponent(sceneData, actorId, behaviorName, nextProps) {
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// Discover per-frame runtime systems from `systems/*.js`. Each such module
|
|
2
|
+
// exports `installSystem(runtime)`, which is called once for every created
|
|
3
|
+
// SceneRuntime (see engine/scene.js `makeScene`) to register itself via
|
|
4
|
+
// `runtime.registerSystem(...)`. A system is a plain object with an optional
|
|
5
|
+
// `afterBehaviors(scene, dt)` hook, run once per frame after all behavior
|
|
6
|
+
// updates. Empty in a kit with no `systems/` dir (e.g. basic-2d); a kit adds a
|
|
7
|
+
// system by dropping a file here -- no edits to the engine required. Symmetric
|
|
8
|
+
// with editors/behaviorRegistry.js.
|
|
9
|
+
const modules = import.meta.glob('../systems/*.js', { eager: true });
|
|
10
|
+
export const systemInstallers = Object.values(modules)
|
|
11
|
+
.map((mod) => mod.installSystem)
|
|
12
|
+
.filter((fn) => typeof fn === 'function');
|
|
@@ -13,6 +13,8 @@ import {
|
|
|
13
13
|
faCode,
|
|
14
14
|
faCodeBranch,
|
|
15
15
|
faEraser,
|
|
16
|
+
faExpand,
|
|
17
|
+
faExternalLinkAlt,
|
|
16
18
|
faEyeDropper,
|
|
17
19
|
faFile,
|
|
18
20
|
faFilm,
|
|
@@ -26,6 +28,8 @@ import {
|
|
|
26
28
|
faPlay,
|
|
27
29
|
faPlus,
|
|
28
30
|
faRedo,
|
|
31
|
+
faSearchMinus,
|
|
32
|
+
faSearchPlus,
|
|
29
33
|
faShapes,
|
|
30
34
|
faSlash,
|
|
31
35
|
faVectorSquare,
|
|
@@ -179,6 +183,8 @@ const icons = {
|
|
|
179
183
|
// FA5 has no faCodeFork (that's the FA6 name); faCodeBranch is the fork glyph.
|
|
180
184
|
'code-fork': faCodeBranch,
|
|
181
185
|
eraser: faEraser,
|
|
186
|
+
expand: faExpand,
|
|
187
|
+
external: faExternalLinkAlt,
|
|
182
188
|
eyedropper: faEyeDropper,
|
|
183
189
|
fill: faFillDrip,
|
|
184
190
|
'flip-h': faArrowsAltH,
|
|
@@ -197,6 +203,9 @@ const icons = {
|
|
|
197
203
|
plus: faPlus,
|
|
198
204
|
redo: faRedo,
|
|
199
205
|
rotate: faSyncAlt,
|
|
206
|
+
'zoom-in': faSearchPlus,
|
|
207
|
+
'zoom-out': faSearchMinus,
|
|
208
|
+
fit: faExpand,
|
|
200
209
|
shapes: faShapes,
|
|
201
210
|
slash: faSlash,
|
|
202
211
|
square: faSquare,
|
package/kits/basic-2d/main.jsx
CHANGED
|
@@ -15,9 +15,10 @@ if (!root) throw new Error('Missing root element');
|
|
|
15
15
|
// ?file=<path>[&editor=<id>] -> a single rich editor for that file
|
|
16
16
|
const params = new URLSearchParams(window.location.search);
|
|
17
17
|
function pick() {
|
|
18
|
-
|
|
18
|
+
const initialScene = params.get('scene') ?? undefined;
|
|
19
|
+
if (!isEdit()) return <PlayOnly initialScene={initialScene} />;
|
|
19
20
|
const file = params.get('file');
|
|
20
21
|
if (file) return <SingleEditor path={file} editor={params.get('editor') ?? undefined} />;
|
|
21
|
-
return <PlayOnly />;
|
|
22
|
+
return <PlayOnly initialScene={initialScene} />;
|
|
22
23
|
}
|
|
23
24
|
createRoot(root).render(<ErrorBoundary>{pick()}</ErrorBoundary>);
|
|
@@ -2,6 +2,7 @@ import React, { useState } from 'react';
|
|
|
2
2
|
import { Icon, NumberField, Panel, SelectField } from '../engine/ui';
|
|
3
3
|
import { AutoFields, overrideProps } from '../engine/autoInspector';
|
|
4
4
|
import { computeAutoFit, getColliderRect, getColliderShape, intersects } from '../engine/collider';
|
|
5
|
+
import { extensionDefaultProps } from '../engine/behaviorExtensions';
|
|
5
6
|
|
|
6
7
|
// Collapsible "Dimensions" section. The header row's label lines up exactly with
|
|
7
8
|
// the field labels above/below (both start at the panel body's 16px left pad);
|
|
@@ -62,20 +63,33 @@ export class Collider {
|
|
|
62
63
|
radius: 0,
|
|
63
64
|
offsetX: 0,
|
|
64
65
|
offsetY: 0,
|
|
65
|
-
//
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
//
|
|
66
|
+
// `isTrigger` marks a sensor: it still reports overlaps (draws yellow, reads
|
|
67
|
+
// as a pass-through zone for pickup/goal logic) but does not block on its
|
|
68
|
+
// own -- collisions are data you act on. Kit modules can register MORE
|
|
69
|
+
// Collider fields from outside this file (the physics module adds
|
|
70
|
+
// bounciness/friction material); see engine/behaviorExtensions.js. That's
|
|
71
|
+
// why this file is byte-identical across kits -- the physics-only fields
|
|
72
|
+
// live in physics/extensions/, not here.
|
|
69
73
|
isTrigger: false,
|
|
70
|
-
bounciness: 0,
|
|
71
|
-
friction: 0.1,
|
|
72
74
|
debug: false,
|
|
75
|
+
...extensionDefaultProps('Collider'),
|
|
73
76
|
};
|
|
74
77
|
|
|
75
78
|
constructor(props) {
|
|
76
79
|
this.props = props;
|
|
77
80
|
}
|
|
78
81
|
|
|
82
|
+
// Seed props when the collider is first added to an actor: snap it to the
|
|
83
|
+
// sprite's opaque-pixel bounds (what "Auto-fit to sprite" computes) so a new
|
|
84
|
+
// collider frames the art rather than the whole Layout box. Falls back to the
|
|
85
|
+
// unset (Layout-box) size when there's nothing to fit to -- no sprite, tile
|
|
86
|
+
// mode, or transparent art. The editor's addBehavior calls this (see
|
|
87
|
+
// editors/SceneEditor.jsx initialBehaviorProps).
|
|
88
|
+
static initialProps(actor, ctx) {
|
|
89
|
+
const fit = computeAutoFit(actor, ctx?.sprites);
|
|
90
|
+
return fit ? { ...Collider.defaultProps, ...fit } : { ...Collider.defaultProps };
|
|
91
|
+
}
|
|
92
|
+
|
|
79
93
|
draw(actor, scene, ctx, options) {
|
|
80
94
|
if (!options.showDebugColliders && !this.props.debug) return;
|
|
81
95
|
const geom = getColliderShape(actor, scene.sprites);
|