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
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { useEffect, useRef, useState } from 'react';
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
2
|
import { frameCount, renderSpriteFrame, TRANSPARENT } from '../engine/pxart';
|
|
3
3
|
import { renderSmoothSpriteFrame } from '../engine/pxartSmooth';
|
|
4
4
|
import { basename } from '../engine/files';
|
|
5
|
-
import { EditorBody, styles } from '../engine/ui';
|
|
5
|
+
import { EditorBody, IconButton, styles } from '../engine/ui';
|
|
6
6
|
import { eventToCell } from './pixelCanvas';
|
|
7
7
|
import { forEachDab } from './pixelGeometry';
|
|
8
8
|
import { PixelArtboard, PixelEditorHeader, usePixelEditorShell } from './pixelEditorChrome';
|
|
@@ -74,6 +74,24 @@ import tl from './pxArtTimeline.module.css';
|
|
|
74
74
|
const ONION_WARM = '#ff7a3c';
|
|
75
75
|
const ONION_COOL = '#3ca0ff';
|
|
76
76
|
|
|
77
|
+
// Artboard zoom bounds and wheel sensitivity. zoom 1 = fit (the artboard fills
|
|
78
|
+
// its region); zooming in scrolls the surrounding viewport to pan.
|
|
79
|
+
const MIN_ART_ZOOM = 1;
|
|
80
|
+
const MAX_ART_ZOOM = 12;
|
|
81
|
+
const ART_ZOOM_WHEEL_SPEED = 0.01;
|
|
82
|
+
// Floating zoom/pan control cluster, tucked into the true bottom-right corner of
|
|
83
|
+
// the artboard region (over the palette column's empty lower area — the palette
|
|
84
|
+
// keeps its swatches top-aligned).
|
|
85
|
+
const PXART_ZOOM_CONTROLS_STYLE = {
|
|
86
|
+
position: 'absolute',
|
|
87
|
+
right: 8,
|
|
88
|
+
bottom: 8,
|
|
89
|
+
display: 'flex',
|
|
90
|
+
flexDirection: 'column',
|
|
91
|
+
gap: 4,
|
|
92
|
+
zIndex: 5,
|
|
93
|
+
};
|
|
94
|
+
|
|
77
95
|
// Native CSS cursors over the artboard, keyed by the active tool. The transient
|
|
78
96
|
// eyedropper (picker mode) overrides any of these with 'crosshair'. Tools not
|
|
79
97
|
// listed fall back to the default cursor.
|
|
@@ -147,11 +165,109 @@ export function PxArtEditor({ path, text, onChange, ...chrome }) {
|
|
|
147
165
|
eraseSize,
|
|
148
166
|
activeKey,
|
|
149
167
|
});
|
|
150
|
-
const { canvasStyle } = useArtboardFit({
|
|
168
|
+
const { canvasStyle, displaySize } = useArtboardFit({
|
|
151
169
|
wrapRef: canvasWrapRef,
|
|
152
170
|
sizeBarRef: canvasSizeBarRef,
|
|
153
171
|
resolution: sprite?.resolution ?? { width: 16, height: 16 },
|
|
154
172
|
});
|
|
173
|
+
// Artboard zoom + pan. `zoom` scales the canvas above its fit size; the
|
|
174
|
+
// surrounding viewport (overflow: auto) scrolls to pan. Middle-drag pans and
|
|
175
|
+
// the wheel zooms at the cursor (wired below); the button cluster drives the
|
|
176
|
+
// same via `applyZoom`.
|
|
177
|
+
const viewportRef = useRef(null);
|
|
178
|
+
const panRef = useRef(null);
|
|
179
|
+
const [zoom, setZoom] = useState(1);
|
|
180
|
+
const zoomRef = useRef(zoom);
|
|
181
|
+
zoomRef.current = zoom;
|
|
182
|
+
// Set zoom to `nextRaw` (clamped) while pinning the point under (anchorX,
|
|
183
|
+
// anchorY) client coords — defaulting to the viewport center. Scroll is fixed
|
|
184
|
+
// up on the next frame, once the resized canvas has laid out.
|
|
185
|
+
const applyZoom = useCallback((nextRaw, anchorX, anchorY) => {
|
|
186
|
+
const vp = viewportRef.current;
|
|
187
|
+
const z0 = zoomRef.current;
|
|
188
|
+
const z1 = Math.max(MIN_ART_ZOOM, Math.min(MAX_ART_ZOOM, nextRaw));
|
|
189
|
+
if (!vp || z1 === z0) return;
|
|
190
|
+
const rect = vp.getBoundingClientRect();
|
|
191
|
+
const ax = (Number.isFinite(anchorX) ? anchorX : rect.left + rect.width / 2) - rect.left;
|
|
192
|
+
const ay = (Number.isFinite(anchorY) ? anchorY : rect.top + rect.height / 2) - rect.top;
|
|
193
|
+
const contentX = vp.scrollLeft + ax;
|
|
194
|
+
const contentY = vp.scrollTop + ay;
|
|
195
|
+
const ratio = z1 / z0;
|
|
196
|
+
setZoom(z1);
|
|
197
|
+
requestAnimationFrame(() => {
|
|
198
|
+
vp.scrollLeft = contentX * ratio - ax;
|
|
199
|
+
vp.scrollTop = contentY * ratio - ay;
|
|
200
|
+
});
|
|
201
|
+
}, []);
|
|
202
|
+
const resetZoom = useCallback(() => {
|
|
203
|
+
setZoom(1);
|
|
204
|
+
const vp = viewportRef.current;
|
|
205
|
+
if (vp) requestAnimationFrame(() => {
|
|
206
|
+
vp.scrollLeft = 0;
|
|
207
|
+
vp.scrollTop = 0;
|
|
208
|
+
});
|
|
209
|
+
}, []);
|
|
210
|
+
// Wheel zoom as a NATIVE non-passive listener so preventDefault stops the
|
|
211
|
+
// page/browser zoom underneath. Delta is clamped so a chunky mouse notch is a
|
|
212
|
+
// sane step while trackpad deltas stay smooth.
|
|
213
|
+
useEffect(() => {
|
|
214
|
+
const vp = viewportRef.current;
|
|
215
|
+
if (!vp) return undefined;
|
|
216
|
+
const onWheel = (event) => {
|
|
217
|
+
event.preventDefault();
|
|
218
|
+
const clamped = Math.max(-50, Math.min(50, event.deltaY));
|
|
219
|
+
applyZoom(zoomRef.current * Math.exp(-clamped * ART_ZOOM_WHEEL_SPEED), event.clientX, event.clientY);
|
|
220
|
+
};
|
|
221
|
+
vp.addEventListener('wheel', onWheel, { passive: false });
|
|
222
|
+
return () => vp.removeEventListener('wheel', onWheel, { passive: false });
|
|
223
|
+
}, [applyZoom]);
|
|
224
|
+
// Middle-button drag pans by scrolling the viewport. Intercept in the capture
|
|
225
|
+
// phase so the press never reaches the canvas's draw handler (startTool).
|
|
226
|
+
const onViewportPointerDownCapture = (event) => {
|
|
227
|
+
// Pan on a middle-button drag or a ⌘+left-drag (metaKey). Both are caught in
|
|
228
|
+
// the capture phase so the press never reaches the canvas's draw handler.
|
|
229
|
+
const startsPan = event.button === 1 || (event.button === 0 && event.metaKey);
|
|
230
|
+
if (!startsPan) return;
|
|
231
|
+
event.preventDefault();
|
|
232
|
+
event.stopPropagation();
|
|
233
|
+
const vp = viewportRef.current;
|
|
234
|
+
vp.setPointerCapture(event.pointerId);
|
|
235
|
+
panRef.current = { id: event.pointerId, x: event.clientX, y: event.clientY };
|
|
236
|
+
};
|
|
237
|
+
const onViewportPointerMove = (event) => {
|
|
238
|
+
const pan = panRef.current;
|
|
239
|
+
if (!pan || pan.id !== event.pointerId) return;
|
|
240
|
+
const vp = viewportRef.current;
|
|
241
|
+
vp.scrollLeft -= event.clientX - pan.x;
|
|
242
|
+
vp.scrollTop -= event.clientY - pan.y;
|
|
243
|
+
pan.x = event.clientX;
|
|
244
|
+
pan.y = event.clientY;
|
|
245
|
+
};
|
|
246
|
+
const onViewportPointerUp = (event) => {
|
|
247
|
+
const pan = panRef.current;
|
|
248
|
+
if (!pan || pan.id !== event.pointerId) return;
|
|
249
|
+
try {
|
|
250
|
+
viewportRef.current?.releasePointerCapture(event.pointerId);
|
|
251
|
+
} catch {
|
|
252
|
+
// pointer capture may already be gone
|
|
253
|
+
}
|
|
254
|
+
panRef.current = null;
|
|
255
|
+
};
|
|
256
|
+
// Viewport is sized to the fit baseline; the canvas inside scales by zoom, so
|
|
257
|
+
// zoom > 1 overflows and the viewport scrolls (= pan).
|
|
258
|
+
const artboardViewportStyle = displaySize
|
|
259
|
+
? {
|
|
260
|
+
width: displaySize.width,
|
|
261
|
+
height: displaySize.height,
|
|
262
|
+
maxWidth: '100%',
|
|
263
|
+
maxHeight: '100%',
|
|
264
|
+
overflow: 'auto',
|
|
265
|
+
touchAction: 'none',
|
|
266
|
+
}
|
|
267
|
+
: { overflow: 'auto' };
|
|
268
|
+
const artboardCanvasStyle = displaySize
|
|
269
|
+
? { width: displaySize.width * zoom, height: displaySize.height * zoom }
|
|
270
|
+
: canvasStyle;
|
|
155
271
|
// `canvasStyle`'s width is the artboard's CURRENT display size (recomputed
|
|
156
272
|
// by useArtboardFit's own ResizeObserver whenever the wrap resizes — e.g. a
|
|
157
273
|
// dockview panel drag). Feeding it in here is what keeps the supersampled
|
|
@@ -437,7 +553,7 @@ export function PxArtEditor({ path, text, onChange, ...chrome }) {
|
|
|
437
553
|
<PxArtShell path={path} subtitle={pxArtSubtitle(sprite)} shell={headerShell} chrome={chrome} editorBodyRef={editorBodyRef}>
|
|
438
554
|
<div className={styles.drawingEditor}>
|
|
439
555
|
<div className={tl.editorLeft}>
|
|
440
|
-
<div className={tl.artboardRegion}>
|
|
556
|
+
<div className={tl.artboardRegion} style={{ position: 'relative' }}>
|
|
441
557
|
<div className={styles.artboardToolColumn}>
|
|
442
558
|
<PixelToolStrip
|
|
443
559
|
tools={PIXEL_TOOLS}
|
|
@@ -464,19 +580,28 @@ export function PxArtEditor({ path, text, onChange, ...chrome }) {
|
|
|
464
580
|
/>
|
|
465
581
|
</div>
|
|
466
582
|
</div>
|
|
467
|
-
<
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
583
|
+
<div
|
|
584
|
+
ref={viewportRef}
|
|
585
|
+
style={artboardViewportStyle}
|
|
586
|
+
onPointerDownCapture={onViewportPointerDownCapture}
|
|
587
|
+
onPointerMove={onViewportPointerMove}
|
|
588
|
+
onPointerUp={onViewportPointerUp}
|
|
589
|
+
onPointerCancel={onViewportPointerUp}
|
|
590
|
+
>
|
|
591
|
+
<PixelArtboard
|
|
592
|
+
canvasRef={canvasRef}
|
|
593
|
+
smoothRef={smoothRef}
|
|
594
|
+
overlayRef={overlayRef}
|
|
595
|
+
style={{ ...artboardCanvasStyle, cursor }}
|
|
596
|
+
handlers={{
|
|
597
|
+
onPointerDown: startTool,
|
|
598
|
+
onPointerMove: handlePointerMove,
|
|
599
|
+
onPointerUp: finishTool,
|
|
600
|
+
onPointerCancel: finishTool,
|
|
601
|
+
onPointerLeave: clearHover,
|
|
602
|
+
}}
|
|
603
|
+
/>
|
|
604
|
+
</div>
|
|
480
605
|
</div>
|
|
481
606
|
</div>
|
|
482
607
|
<div className={styles.paintColumn}>
|
|
@@ -494,6 +619,19 @@ export function PxArtEditor({ path, text, onChange, ...chrome }) {
|
|
|
494
619
|
selectActions={selectActions}
|
|
495
620
|
/>
|
|
496
621
|
</div>
|
|
622
|
+
<div style={PXART_ZOOM_CONTROLS_STYLE}>
|
|
623
|
+
<IconButton
|
|
624
|
+
icon="zoom-in"
|
|
625
|
+
label="Zoom in"
|
|
626
|
+
onClick={() => applyZoom(zoomRef.current * 1.25)}
|
|
627
|
+
/>
|
|
628
|
+
<IconButton
|
|
629
|
+
icon="zoom-out"
|
|
630
|
+
label="Zoom out"
|
|
631
|
+
onClick={() => applyZoom(zoomRef.current * 0.8)}
|
|
632
|
+
/>
|
|
633
|
+
<IconButton icon="fit" label="Fit" onClick={resetZoom} />
|
|
634
|
+
</div>
|
|
497
635
|
</div>
|
|
498
636
|
<PxArtTimeline
|
|
499
637
|
sprite={sprite}
|
|
@@ -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 = {
|
|
@@ -464,35 +479,22 @@ export function SceneEditor({
|
|
|
464
479
|
<div className={styles.sceneWorkspace}>
|
|
465
480
|
<div className={styles.sceneTools}>
|
|
466
481
|
{isBlueprintFile ? null : (
|
|
467
|
-
|
|
468
|
-
{
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
files={files}
|
|
481
|
-
sprites={sprites}
|
|
482
|
-
canvasRef={canvasRef}
|
|
483
|
-
editCameraRef={editCameraRef}
|
|
484
|
-
isPlaying={isPlaying}
|
|
485
|
-
selectedBlueprintPath={selectedBlueprintPath}
|
|
486
|
-
instanceBlueprintPath={rawSelectedActor?.blueprint ?? null}
|
|
487
|
-
onSelectBlueprint={onSelectBlueprint}
|
|
488
|
-
onAddActor={onAddActor}
|
|
489
|
-
dragPlace={dragPlace}
|
|
490
|
-
onDeleteBlueprint={onDeleteBlueprint}
|
|
491
|
-
/>
|
|
492
|
-
</>
|
|
482
|
+
<BlueprintLibrary
|
|
483
|
+
files={files}
|
|
484
|
+
sprites={sprites}
|
|
485
|
+
canvasRef={canvasRef}
|
|
486
|
+
editCameraRef={editCameraRef}
|
|
487
|
+
isPlaying={isPlaying}
|
|
488
|
+
selectedBlueprintPath={selectedBlueprintPath}
|
|
489
|
+
instanceBlueprintPath={rawSelectedActor?.blueprint ?? null}
|
|
490
|
+
onSelectBlueprint={onSelectBlueprint}
|
|
491
|
+
onAddActor={onAddActor}
|
|
492
|
+
dragPlace={dragPlace}
|
|
493
|
+
onDeleteBlueprint={onDeleteBlueprint}
|
|
494
|
+
/>
|
|
493
495
|
)}
|
|
494
496
|
</div>
|
|
495
|
-
<div className={styles.stageWrap} style={stageWrapStyle}>
|
|
497
|
+
<div className={styles.stageWrap} style={{ ...stageWrapStyle, position: 'relative' }}>
|
|
496
498
|
<div className={styles.stageCard} data-castle-card>
|
|
497
499
|
<div className={cx(styles.stageCardClip, !isPlaying && styles.stageCardEditSurface)}>
|
|
498
500
|
<canvas
|
|
@@ -507,7 +509,12 @@ export function SceneEditor({
|
|
|
507
509
|
onPointerDown={(event) => {
|
|
508
510
|
event.currentTarget.focus({ preventScroll: true });
|
|
509
511
|
if (isPlaying) return playPointer.onPointerDown(event);
|
|
510
|
-
if (
|
|
512
|
+
if (
|
|
513
|
+
panGesture.isSpacePanning() ||
|
|
514
|
+
event.button === 1 ||
|
|
515
|
+
(event.button === 0 && event.metaKey)
|
|
516
|
+
)
|
|
517
|
+
return panGesture.onPointerDown(event);
|
|
511
518
|
return gesture.onPointerDown(event);
|
|
512
519
|
}}
|
|
513
520
|
onPointerMove={(event) =>
|
|
@@ -551,6 +558,25 @@ export function SceneEditor({
|
|
|
551
558
|
/>
|
|
552
559
|
)}
|
|
553
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
|
+
)}
|
|
554
580
|
</div>
|
|
555
581
|
<aside {...inspectorSheet.rootProps}>
|
|
556
582
|
<div {...inspectorSheet.grabProps}>
|
|
@@ -655,7 +681,17 @@ function HeaderPlaybackButtons({ isPlaying, setIsPlaying, history }) {
|
|
|
655
681
|
// remove-behavior stay whole-component operations straight against the
|
|
656
682
|
// instance's own sparse `components` -- there's no per-property baseline to
|
|
657
683
|
// diff when a component doesn't exist on one side at all.
|
|
658
|
-
|
|
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 }) {
|
|
659
695
|
function commitScene(next, options) {
|
|
660
696
|
commit(serialize(next), options);
|
|
661
697
|
}
|
|
@@ -667,8 +703,9 @@ function makeSceneActions({ sceneData, files, serialize, commit, selectedActorId
|
|
|
667
703
|
addBehavior: (actorId, behaviorName) => {
|
|
668
704
|
const Behavior = findBehaviorClass(behaviorName);
|
|
669
705
|
if (!Behavior) return;
|
|
706
|
+
const resolved = resolvedActors?.find((actor) => actor.id === actorId);
|
|
670
707
|
commitScene(
|
|
671
|
-
setActorComponent(sceneData, actorId, behaviorName,
|
|
708
|
+
setActorComponent(sceneData, actorId, behaviorName, initialBehaviorProps(Behavior, resolved, sprites))
|
|
672
709
|
);
|
|
673
710
|
},
|
|
674
711
|
removeBehavior: (actorId, behaviorName) =>
|
|
@@ -1021,18 +1058,19 @@ function usePanGesture({ canvasRef, editCameraRef, isPlaying }) {
|
|
|
1021
1058
|
const onWheel = (event) => {
|
|
1022
1059
|
if (isPlayingRef.current) return;
|
|
1023
1060
|
event.preventDefault();
|
|
1024
|
-
//
|
|
1025
|
-
//
|
|
1026
|
-
if (
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
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
|
+
);
|
|
1036
1074
|
};
|
|
1037
1075
|
const onGestureStart = (event) => {
|
|
1038
1076
|
if (isPlayingRef.current) return;
|
|
@@ -1073,7 +1111,13 @@ function usePanGesture({ canvasRef, editCameraRef, isPlaying }) {
|
|
|
1073
1111
|
};
|
|
1074
1112
|
}, [canvasRef, panByScreenDelta, zoomByFactorAtPointer]);
|
|
1075
1113
|
const onPointerDown = useCallback((event) => {
|
|
1076
|
-
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;
|
|
1077
1121
|
event.preventDefault();
|
|
1078
1122
|
event.currentTarget.setPointerCapture(event.pointerId);
|
|
1079
1123
|
dragRef.current = { id: event.pointerId, lastX: event.clientX, lastY: event.clientY };
|
|
@@ -1102,7 +1146,29 @@ function usePanGesture({ canvasRef, editCameraRef, isPlaying }) {
|
|
|
1102
1146
|
}, []);
|
|
1103
1147
|
const isSpacePanning = useCallback(() => spaceDownRef.current, []);
|
|
1104
1148
|
const isActive = useCallback(() => Boolean(dragRef.current), []);
|
|
1105
|
-
|
|
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
|
+
};
|
|
1106
1172
|
}
|
|
1107
1173
|
function handleShiftPointerDown(drag, actor, current) {
|
|
1108
1174
|
if (actor) {
|
|
@@ -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
|
+
}
|
|
@@ -174,9 +174,13 @@ export function getColliderShape(actor, sprites) {
|
|
|
174
174
|
// dynamically. Returns null when there's nothing to fit to (no Sprite, tile
|
|
175
175
|
// mode, or fully transparent art). Powers the inspector's "Auto-fit to sprite".
|
|
176
176
|
export function computeAutoFit(actor, sprites) {
|
|
177
|
-
const
|
|
177
|
+
const rawLayout = actor?.components?.Layout;
|
|
178
178
|
const spriteProps = actor?.components?.Sprite;
|
|
179
|
-
if (!
|
|
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 };
|
|
180
184
|
const rect = autoRect(layout, spriteProps, sprites?.[spriteProps.file]);
|
|
181
185
|
if (!rect || rect.width <= 0 || rect.height <= 0) return null;
|
|
182
186
|
// manualRect centers a width x height box in the Layout box; the offset is the
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { initialFiles, parseJsonFile } from './files';
|
|
2
2
|
import { getBlueprintTemplate, mergeComponents } from './blueprint';
|
|
3
|
-
import { installPhysics } from '../physics';
|
|
4
3
|
import { getColliderRect, intersects, spriteIsEmpty } from './collider';
|
|
4
|
+
import { systemInstallers } from './systemRegistry';
|
|
5
5
|
|
|
6
6
|
const CARD_WIDTH = 500;
|
|
7
7
|
const CARD_HEIGHT = 700;
|
|
@@ -45,14 +45,13 @@ export class SceneRuntime {
|
|
|
45
45
|
this.status = undefined;
|
|
46
46
|
// Registered systems run once per frame after all behavior `update`s (see
|
|
47
47
|
// `update`). A system is a plain object with an optional
|
|
48
|
-
// `afterBehaviors(scene, dt)` hook;
|
|
49
|
-
//
|
|
50
|
-
// other kits.
|
|
48
|
+
// `afterBehaviors(scene, dt)` hook; kits register systems from `systems/*.js`
|
|
49
|
+
// (see makeScene), so the engine core stays decoupled from any specific one.
|
|
51
50
|
this.systems = [];
|
|
52
51
|
this.load(sceneData);
|
|
53
52
|
}
|
|
54
53
|
|
|
55
|
-
// Register a per-frame system (e.g.
|
|
54
|
+
// Register a per-frame system (e.g. a physics simulation). Systems step in
|
|
56
55
|
// registration order, after behaviors, every `update`. Returns the system.
|
|
57
56
|
registerSystem(system) {
|
|
58
57
|
this.systems.push(system);
|
|
@@ -102,8 +101,8 @@ export class SceneRuntime {
|
|
|
102
101
|
}
|
|
103
102
|
|
|
104
103
|
clone() {
|
|
105
|
-
// Route through makeScene so the clone gets the same registered systems
|
|
106
|
-
//
|
|
104
|
+
// Route through makeScene so the clone gets the same registered systems as a
|
|
105
|
+
// freshly-made runtime.
|
|
107
106
|
return makeScene(this.serialize(), [...this.behaviors.values()], this.sprites, this.files);
|
|
108
107
|
}
|
|
109
108
|
|
|
@@ -206,9 +205,9 @@ export class SceneRuntime {
|
|
|
206
205
|
for (const actor of this.getActors()) {
|
|
207
206
|
this.forEachBehavior(actor, (instance) => instance.update?.(actor, this, dt));
|
|
208
207
|
}
|
|
209
|
-
// Behaviors have expressed their intent (velocities, forces) for this
|
|
210
|
-
//
|
|
211
|
-
//
|
|
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).
|
|
212
211
|
for (const system of this.systems) {
|
|
213
212
|
system.afterBehaviors?.(this, dt);
|
|
214
213
|
}
|
|
@@ -322,10 +321,10 @@ export class SceneRuntime {
|
|
|
322
321
|
|
|
323
322
|
export function makeScene(sceneData, behaviors, sprites, files) {
|
|
324
323
|
const runtime = new SceneRuntime(sceneData, behaviors, sprites, files);
|
|
325
|
-
//
|
|
326
|
-
//
|
|
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
|
|
327
326
|
// editor previews (which never call update) stay free.
|
|
328
|
-
|
|
327
|
+
for (const install of systemInstallers) install(runtime);
|
|
329
328
|
return runtime;
|
|
330
329
|
}
|
|
331
330
|
|
|
@@ -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,7 @@ import {
|
|
|
13
13
|
faCode,
|
|
14
14
|
faCodeBranch,
|
|
15
15
|
faEraser,
|
|
16
|
+
faExpand,
|
|
16
17
|
faExternalLinkAlt,
|
|
17
18
|
faEyeDropper,
|
|
18
19
|
faFile,
|
|
@@ -27,6 +28,8 @@ import {
|
|
|
27
28
|
faPlay,
|
|
28
29
|
faPlus,
|
|
29
30
|
faRedo,
|
|
31
|
+
faSearchMinus,
|
|
32
|
+
faSearchPlus,
|
|
30
33
|
faShapes,
|
|
31
34
|
faSlash,
|
|
32
35
|
faVectorSquare,
|
|
@@ -180,6 +183,7 @@ const icons = {
|
|
|
180
183
|
// FA5 has no faCodeFork (that's the FA6 name); faCodeBranch is the fork glyph.
|
|
181
184
|
'code-fork': faCodeBranch,
|
|
182
185
|
eraser: faEraser,
|
|
186
|
+
expand: faExpand,
|
|
183
187
|
external: faExternalLinkAlt,
|
|
184
188
|
eyedropper: faEyeDropper,
|
|
185
189
|
fill: faFillDrip,
|
|
@@ -199,6 +203,9 @@ const icons = {
|
|
|
199
203
|
plus: faPlus,
|
|
200
204
|
redo: faRedo,
|
|
201
205
|
rotate: faSyncAlt,
|
|
206
|
+
'zoom-in': faSearchPlus,
|
|
207
|
+
'zoom-out': faSearchMinus,
|
|
208
|
+
fit: faExpand,
|
|
202
209
|
shapes: faShapes,
|
|
203
210
|
slash: faSlash,
|
|
204
211
|
square: faSquare,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import { Panel, SelectField } from '../../engine/ui';
|
|
3
3
|
import { AutoFields, overrideProps } from '../../engine/autoInspector';
|
|
4
|
-
import { analogVelocity, inActorWorldSpace, stickVector } from '../controls';
|
|
4
|
+
import { analogVelocity, inActorWorldSpace, pressOnDraggable, stickVector } from '../controls';
|
|
5
5
|
|
|
6
6
|
// AnalogStick: an on-screen virtual joystick that drives the actor it's on --
|
|
7
7
|
// the look and feel of castle-client's Analog Stick. Press anywhere to place
|
|
@@ -29,7 +29,9 @@ export class AnalogStick {
|
|
|
29
29
|
const rt = actor.runtime;
|
|
30
30
|
const pressed = p.down && !rt._stickWasDown;
|
|
31
31
|
rt._stickWasDown = p.down;
|
|
32
|
-
|
|
32
|
+
// A press on a draggable is consumed by the drag; every other press drives
|
|
33
|
+
// the stick.
|
|
34
|
+
if (pressed && !pressOnDraggable(scene)) rt._stickOrigin = { x: p.x, y: p.y };
|
|
33
35
|
|
|
34
36
|
// Only drive velocity while the stick is actually held.
|
|
35
37
|
if (p.down && rt._stickOrigin) {
|