castle-web-cli 0.4.169 → 0.4.171
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/agent-prompts.d.ts +1 -0
- package/dist/agent-prompts.js +3 -0
- package/dist/agent.js +70 -24
- package/dist/atomicFile.d.ts +29 -0
- package/dist/atomicFile.js +120 -0
- package/dist/castle-host/host.js +108 -0
- package/dist/get-deck.js +2 -11
- package/dist/headlessCover.d.ts +4 -0
- package/dist/headlessCover.js +7 -5
- package/dist/ide.d.ts +10 -0
- package/dist/ide.js +19 -11
- package/dist/index.js +10 -6
- package/dist/init.d.ts +1 -1
- package/dist/init.js +11 -1
- package/dist/remix.js +2 -14
- package/dist/save-deck.d.ts +3 -0
- package/dist/save-deck.js +40 -73
- package/dist/saveCover.d.ts +18 -0
- package/dist/saveCover.js +61 -0
- package/dist/serve.js +18 -5
- package/dist/serveSecurity.d.ts +11 -1
- package/dist/serveSecurity.js +38 -4
- package/dist/shell/assets/index-6odVZQSZ.css +1 -0
- package/dist/shell/assets/index-Ws0WrCbi.js +445 -0
- package/dist/shell/index.html +3 -3
- package/dist/versionStore.js +13 -4
- package/kits/base/CLAUDE.md +9 -0
- package/kits/base/castle.json +16 -11
- package/kits/base/sdk/README.md +83 -2
- package/kits/base/sdk/commands.d.ts +37 -1
- package/kits/base/sdk/commands.js +9 -0
- package/kits/base/sdk/lifecycle.d.ts +20 -0
- package/kits/base/sdk/lifecycle.js +7 -1
- package/kits/base/sdk/resumeState.d.ts +2 -0
- package/kits/base/sdk/resumeState.js +84 -0
- package/kits/base/sdk/runtime.js +65 -0
- package/kits/base/sdk/transport.d.ts +3 -0
- package/kits/base/sdk/transport.js +39 -5
- package/kits/base/sdk/unloadHandshake.d.ts +6 -0
- package/kits/base/sdk/unloadHandshake.js +47 -0
- package/kits/base/sdk/user.d.ts +4 -0
- package/kits/base/sdk/user.js +36 -1
- package/kits/multiplayer-2d/CLAUDE.md +20 -9
- package/kits/multiplayer-2d/castle.json +4 -4
- package/kits/multiplayer-2d/code/server/players.js +27 -6
- package/kits/multiplayer-2d/code/server/world.js +24 -1
- package/kits/multiplayer-2d/code/systems/multiplayer.js +45 -4
- package/kits/multiplayer-2d/package-lock.json +2 -4
- package/kits/multiplayer-2d/package.json +1 -0
- package/kits/multiplayer-3d/CLAUDE.md +28 -9
- package/kits/multiplayer-3d/castle.json +5 -5
- package/kits/multiplayer-3d/code/server/players.js +54 -5
- package/kits/multiplayer-3d/code/server/world.js +45 -1
- package/kits/multiplayer-3d/code/systems/multiplayer.js +108 -6
- package/kits/multiplayer-3d/package-lock.json +2 -4
- package/kits/multiplayer-3d/package.json +1 -0
- package/kits/physics-2d/CLAUDE.md +75 -43
- package/kits/physics-2d/behaviors/AnalogStick.jsx +75 -45
- package/kits/physics-2d/behaviors/Slingshot.jsx +1 -2
- package/kits/physics-2d/castle.json +6 -6
- package/kits/physics-2d/editors/PxArtEditor.jsx +85 -182
- package/kits/physics-2d/editors/SceneEditor.jsx +25 -4
- package/kits/physics-2d/editors/StyleEditor.jsx +6 -2
- package/kits/physics-2d/editors/overlayCanvas.js +77 -0
- package/kits/physics-2d/editors/pathOverlay.js +5 -13
- package/kits/physics-2d/editors/pixelEditorChrome.jsx +5 -1
- package/kits/physics-2d/editors/pixelInspector.jsx +4 -4
- package/kits/physics-2d/editors/pxArtTimeline.jsx +8 -36
- package/kits/physics-2d/editors/pxArtTimeline.module.css +4 -28
- package/kits/physics-2d/editors/styleEditor.module.css +23 -4
- package/kits/physics-2d/editors/useArtboardZoomPan.js +322 -0
- package/kits/physics-2d/engine/SceneUI.jsx +6 -12
- package/kits/physics-2d/engine/icons.js +56 -0
- package/kits/physics-2d/engine/panelSorting.jsx +7 -14
- package/kits/physics-2d/engine/physics/PhysicsSystem.js +66 -11
- package/kits/physics-2d/engine/physics/controls.js +76 -12
- package/kits/physics-2d/engine/scene.js +47 -9
- package/kits/physics-2d/engine/ui.jsx +13 -125
- package/kits/physics-2d/engine/ui.module.css +53 -28
- package/kits/physics-2d/package-lock.json +0 -29
- package/kits/physics-2d/package.json +3 -1
- package/kits/physics-2d/pnpm-lock.yaml +1 -17
- package/kits/physics-3d/behaviors/Pickup.jsx +5 -3
- package/kits/physics-3d/castle.json +5 -5
- package/kits/physics-3d/package-lock.json +0 -29
- package/kits/physics-3d/package.json +0 -1
- package/kits/physics-3d/pnpm-lock.yaml +0 -17
- package/kits/real-time/CLAUDE.md +23 -4
- package/kits/real-time/castle.json +1 -1
- package/kits/real-time/code/client/connection.js +9 -4
- package/kits/real-time/code/client/joinOverlay.js +46 -0
- package/kits/real-time/code/client/messages.js +4 -0
- package/kits/real-time/code/server/gameHooks.js +4 -0
- package/kits/real-time/code/server/persist.js +105 -0
- package/kits/real-time/code/server/session.js +289 -19
- package/kits/real-time/package-lock.json +1139 -0
- package/kits/turn-based/CLAUDE.md +76 -20
- package/kits/turn-based/castle.json +3 -3
- package/kits/turn-based/code/server/index.js +16 -5
- package/kits/turn-based/package-lock.json +0 -27
- package/kits/turn-based/package.json +2 -2
- package/kits/turn-based/room.js +162 -13
- package/kits/turn-based/testing.js +276 -92
- package/package.json +5 -5
- package/dist/shell/assets/index-CVHj503j.css +0 -1
- package/dist/shell/assets/index-x_QkP3Xq.js +0 -444
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
|
2
|
-
import { faEye, faEyeSlash, faLink, faStopwatch } from '@fortawesome/free-solid-svg-icons';
|
|
3
2
|
import { colorForKeyV2, displayFinish, paletteLookup, resolveCell, resolveCellGrid } from '../engine/pxart';
|
|
4
3
|
import { renderSmoothLayerCell } from '../engine/pxartSmooth';
|
|
5
4
|
import {
|
|
@@ -34,21 +33,6 @@ const ONION_RANGE = 1;
|
|
|
34
33
|
const TIMELINE_LONG_PRESS_MS = 500;
|
|
35
34
|
const TIMELINE_LONG_PRESS_MOVE_TOLERANCE = 10;
|
|
36
35
|
|
|
37
|
-
// faClapperboard ships in Font Awesome 6, but this kit is pinned to FA5 (see
|
|
38
|
-
// package.json), which doesn't export it. Rather than silently swap in a
|
|
39
|
-
// different-meaning icon, inline the exact FA6 solid clapperboard glyph
|
|
40
|
-
// (viewBox 0 0 512 512) in the same shape <FaGlyph> consumes (an FA icon
|
|
41
|
-
// tuple: [w, h, ligatures, unicode, path]). Drop-in once the kit moves to FA6.
|
|
42
|
-
const faClapperboard = {
|
|
43
|
-
icon: [
|
|
44
|
-
512,
|
|
45
|
-
512,
|
|
46
|
-
[],
|
|
47
|
-
'',
|
|
48
|
-
'M448 32l-86.1 0-1 1-127 127 92.1 0 1-1L453.8 32.3c-1.9-.2-3.8-.3-5.8-.3zm64 128l0-64c0-15.1-5.3-29.1-14-40l-104 104L512 160zM294.1 32l-92.1 0-1 1L73.9 160l92.1 0 1-1 127-127zM64 32C28.7 32 0 60.7 0 96l0 64 6.1 0 1-1 127-127L64 32zM512 192L0 192 0 416c0 35.3 28.7 64 64 64l384 0c35.3 0 64-28.7 64-64l0-224z',
|
|
49
|
-
],
|
|
50
|
-
};
|
|
51
|
-
|
|
52
36
|
// User-resizable timeline height (persisted per-`.pxart`-editor). The panel is
|
|
53
37
|
// bottom-anchored in the editor's left column, so dragging the top handle up
|
|
54
38
|
// makes the timeline taller and the artboard shorter. Clamped so the timeline
|
|
@@ -235,12 +219,12 @@ function TimelineBar({ sprite, selection, playing, onion, actions, readOnly = fa
|
|
|
235
219
|
return (
|
|
236
220
|
<div className={tl.bar}>
|
|
237
221
|
<div className={tl.barGroup}>
|
|
238
|
-
<IconButton icon="
|
|
222
|
+
<IconButton icon="checkerboard" label="Pixel" disabled={readOnly} onClick={() => actions.addLayer(layerIndex)} />
|
|
239
223
|
<span className={tl.barLabel}>Pixel</span>
|
|
240
224
|
</div>
|
|
241
225
|
<div className={tl.barGroup}>
|
|
242
226
|
<IconButton
|
|
243
|
-
icon="
|
|
227
|
+
icon="bounding-box"
|
|
244
228
|
label="Path"
|
|
245
229
|
disabled={readOnly}
|
|
246
230
|
onClick={() => actions.addLayer(layerIndex, 'path')}
|
|
@@ -256,7 +240,7 @@ function TimelineBar({ sprite, selection, playing, onion, actions, readOnly = fa
|
|
|
256
240
|
<>
|
|
257
241
|
<div className={tl.barGroup}>
|
|
258
242
|
<span className={tl.durationIcon} title="Default frame duration (ms) — applies to all frames">
|
|
259
|
-
<
|
|
243
|
+
<Icon name="stopwatch" />
|
|
260
244
|
</span>
|
|
261
245
|
<GlobalDurationInput
|
|
262
246
|
value={sprite.defaultDurationMs}
|
|
@@ -296,7 +280,7 @@ function GlobalDurationInput({ value, onCommit, disabled = false }) {
|
|
|
296
280
|
);
|
|
297
281
|
}
|
|
298
282
|
|
|
299
|
-
// Onion skin as a single icon toggle
|
|
283
|
+
// Onion skin as a single icon toggle, styled like the toolbar's
|
|
300
284
|
// other IconButtons with an active state when enabled. Enabling pins the range
|
|
301
285
|
// to ONION_RANGE so the (hidden) count is always 1; the range input is gated
|
|
302
286
|
// behind SHOW_ONION_RANGE and the setOnionRange action stays wired for an easy
|
|
@@ -319,7 +303,7 @@ function OnionControls({ onion, actions }) {
|
|
|
319
303
|
aria-pressed={onion.enabled}
|
|
320
304
|
title={onion.enabled ? 'Onion skin on' : 'Onion skin off'}
|
|
321
305
|
onClick={toggleOnion}>
|
|
322
|
-
<
|
|
306
|
+
<Icon name="subtract" />
|
|
323
307
|
</button>
|
|
324
308
|
{SHOW_ONION_RANGE ? (
|
|
325
309
|
<input
|
|
@@ -812,18 +796,6 @@ function DurationInput({ value, globalMs, onCommit }) {
|
|
|
812
796
|
);
|
|
813
797
|
}
|
|
814
798
|
|
|
815
|
-
// Inline Font Awesome glyph. engine/ui's <Icon> registry has no eye/eye-slash
|
|
816
|
-
// and lives outside this editor (which we shouldn't edit), so the layer
|
|
817
|
-
// visibility toggle renders the FA path directly here.
|
|
818
|
-
function FaGlyph({ icon, className = tl.visGlyph }) {
|
|
819
|
-
const [w, h, , , path] = icon.icon;
|
|
820
|
-
return (
|
|
821
|
-
<svg className={className} viewBox={`0 0 ${w} ${h}`} aria-hidden="true">
|
|
822
|
-
<path d={path} />
|
|
823
|
-
</svg>
|
|
824
|
-
);
|
|
825
|
-
}
|
|
826
|
-
|
|
827
799
|
function LayerHeader({ layer, index, active, onActivate, onPointerDown, actions, onContextMenu, readOnly = false }) {
|
|
828
800
|
return (
|
|
829
801
|
<div
|
|
@@ -835,7 +807,7 @@ function LayerHeader({ layer, index, active, onActivate, onPointerDown, actions,
|
|
|
835
807
|
<span
|
|
836
808
|
style={kindBadgeStyle}
|
|
837
809
|
title={layer.kind === 'path' ? 'Path layer' : 'Pixel layer'}>
|
|
838
|
-
<Icon name={layer.kind === 'path' ? '
|
|
810
|
+
<Icon name={layer.kind === 'path' ? 'bounding-box' : 'checkerboard'} />
|
|
839
811
|
</span>
|
|
840
812
|
<span className={tl.name} title={layer.name}>
|
|
841
813
|
{layer.name}
|
|
@@ -862,7 +834,7 @@ function LayerHeader({ layer, index, active, onActivate, onPointerDown, actions,
|
|
|
862
834
|
event.stopPropagation();
|
|
863
835
|
actions.patchLayer(index, { visible: !layer.visible });
|
|
864
836
|
}}>
|
|
865
|
-
<
|
|
837
|
+
<Icon name={layer.visible ? 'eye' : 'eye-closed'} />
|
|
866
838
|
</button>
|
|
867
839
|
</div>
|
|
868
840
|
</div>
|
|
@@ -904,7 +876,7 @@ function CellThumb({ sprite, text, layerIndex, frameIndex, active, onActivate, o
|
|
|
904
876
|
)}
|
|
905
877
|
{kind === 'link' ? (
|
|
906
878
|
<span className={tl.linkBadge}>
|
|
907
|
-
<
|
|
879
|
+
<Icon name="link" />
|
|
908
880
|
</span>
|
|
909
881
|
) : null}
|
|
910
882
|
</button>
|
|
@@ -120,12 +120,7 @@
|
|
|
120
120
|
display: inline-flex;
|
|
121
121
|
align-items: center;
|
|
122
122
|
color: var(--castle-inspector-muted);
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
.durationGlyph {
|
|
126
|
-
width: 14px;
|
|
127
|
-
height: 14px;
|
|
128
|
-
fill: currentColor;
|
|
123
|
+
font-size: 14px;
|
|
129
124
|
}
|
|
130
125
|
|
|
131
126
|
.miniInput {
|
|
@@ -285,24 +280,13 @@
|
|
|
285
280
|
color: var(--castle-inspector-text);
|
|
286
281
|
cursor: pointer;
|
|
287
282
|
padding: 0;
|
|
283
|
+
font-size: 12px;
|
|
288
284
|
}
|
|
289
285
|
|
|
290
286
|
.visBtn:hover {
|
|
291
287
|
background: var(--castle-inspector-input-bg);
|
|
292
288
|
}
|
|
293
289
|
|
|
294
|
-
.visBtn svg {
|
|
295
|
-
width: 12px;
|
|
296
|
-
height: 12px;
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
/* Inline FA eye / eye-slash glyph for the visibility toggle (see FaGlyph). */
|
|
300
|
-
.visGlyph {
|
|
301
|
-
width: 12px;
|
|
302
|
-
height: 12px;
|
|
303
|
-
fill: currentColor;
|
|
304
|
-
}
|
|
305
|
-
|
|
306
290
|
/* Hidden layer: the visibility toggle borrows the standard disabled LOOK
|
|
307
291
|
(0.35 dimming, matching the undo button) without being disabled -- this
|
|
308
292
|
button stays interactive and toggles default <-> disabled-look. */
|
|
@@ -448,11 +432,7 @@
|
|
|
448
432
|
border-radius: 2px;
|
|
449
433
|
background: var(--castle-blue);
|
|
450
434
|
color: var(--castle-selected-ink);
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
.linkBadge svg {
|
|
454
|
-
width: 9px;
|
|
455
|
-
height: 9px;
|
|
435
|
+
font-size: 9px;
|
|
456
436
|
}
|
|
457
437
|
|
|
458
438
|
.linkRing {
|
|
@@ -531,6 +511,7 @@
|
|
|
531
511
|
color: var(--castle-inspector-muted);
|
|
532
512
|
cursor: pointer;
|
|
533
513
|
padding: 0;
|
|
514
|
+
font-size: 12px;
|
|
534
515
|
}
|
|
535
516
|
|
|
536
517
|
.tagDefault:hover {
|
|
@@ -544,11 +525,6 @@
|
|
|
544
525
|
color: var(--castle-selected-ink);
|
|
545
526
|
}
|
|
546
527
|
|
|
547
|
-
.tagDefault svg {
|
|
548
|
-
width: 12px;
|
|
549
|
-
height: 12px;
|
|
550
|
-
}
|
|
551
|
-
|
|
552
528
|
.muted {
|
|
553
529
|
color: var(--castle-inspector-muted);
|
|
554
530
|
font-size: 12px;
|
|
@@ -38,6 +38,8 @@
|
|
|
38
38
|
whatever the face's width. Falls back to the panel font until the specimen
|
|
39
39
|
has been fetched (see editors/fontPreview.js). */
|
|
40
40
|
.specimen {
|
|
41
|
+
width: 100%;
|
|
42
|
+
min-width: 0;
|
|
41
43
|
overflow: hidden;
|
|
42
44
|
font-size: 26px;
|
|
43
45
|
line-height: 1.25;
|
|
@@ -53,9 +55,13 @@
|
|
|
53
55
|
}
|
|
54
56
|
|
|
55
57
|
.card {
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
58
|
+
/* Block, not flex: WebKit's button anonymous box shrink-wraps flex
|
|
59
|
+
children (style editor palette strips on some iOS). appearance wipe
|
|
60
|
+
matches other kit buttons. */
|
|
61
|
+
display: block;
|
|
62
|
+
box-sizing: border-box;
|
|
63
|
+
appearance: none;
|
|
64
|
+
-webkit-appearance: none;
|
|
59
65
|
width: 100%;
|
|
60
66
|
margin: 0;
|
|
61
67
|
padding: 12px;
|
|
@@ -63,10 +69,19 @@
|
|
|
63
69
|
border-radius: var(--castle-radius, 4px);
|
|
64
70
|
background: var(--castle-card, #121213);
|
|
65
71
|
color: inherit;
|
|
72
|
+
font: inherit;
|
|
66
73
|
text-align: left;
|
|
67
74
|
cursor: pointer;
|
|
68
75
|
}
|
|
69
76
|
|
|
77
|
+
.cardBody {
|
|
78
|
+
display: flex;
|
|
79
|
+
flex-direction: column;
|
|
80
|
+
gap: 8px;
|
|
81
|
+
width: 100%;
|
|
82
|
+
min-width: 0;
|
|
83
|
+
}
|
|
84
|
+
|
|
70
85
|
.card:hover {
|
|
71
86
|
background: var(--castle-panel-raised, #101010);
|
|
72
87
|
}
|
|
@@ -94,6 +109,8 @@
|
|
|
94
109
|
align-items: baseline;
|
|
95
110
|
justify-content: space-between;
|
|
96
111
|
gap: 12px;
|
|
112
|
+
width: 100%;
|
|
113
|
+
min-width: 0;
|
|
97
114
|
}
|
|
98
115
|
|
|
99
116
|
.cardName {
|
|
@@ -119,6 +136,8 @@
|
|
|
119
136
|
|
|
120
137
|
.strip {
|
|
121
138
|
display: flex;
|
|
139
|
+
width: 100%;
|
|
140
|
+
min-width: 0;
|
|
122
141
|
height: 18px;
|
|
123
142
|
overflow: hidden;
|
|
124
143
|
border: 1px solid var(--castle-inspector-border, #2a2a2a);
|
|
@@ -126,7 +145,7 @@
|
|
|
126
145
|
}
|
|
127
146
|
|
|
128
147
|
.chip {
|
|
129
|
-
flex: 1 1 0
|
|
148
|
+
flex: 1 1 0%;
|
|
130
149
|
min-width: 2px;
|
|
131
150
|
height: 100%;
|
|
132
151
|
}
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
// Artboard zoom + pan for the pixel editors.
|
|
4
|
+
//
|
|
5
|
+
// zoom 1 = fit (the artboard fills its region); above that the canvas overflows
|
|
6
|
+
// the viewport and the viewport scrolls to pan. Scrolling is entirely
|
|
7
|
+
// PROGRAMMATIC -- `touch-action: none` hands the pinch to us and the wheel is
|
|
8
|
+
// preventDefault'd for zoom -- so the viewport is `overflow: hidden` and shows
|
|
9
|
+
// no scrollbars.
|
|
10
|
+
// Below fit the artboard just gets smaller inside the viewport, which is worth
|
|
11
|
+
// having: it is how you see a sprite whole with room around it rather than
|
|
12
|
+
// edge-to-edge under your thumbs.
|
|
13
|
+
const MIN_ART_ZOOM = 0.25;
|
|
14
|
+
const MAX_ART_ZOOM = 12;
|
|
15
|
+
const ART_ZOOM_WHEEL_SPEED = 0.01;
|
|
16
|
+
// A chunky mouse notch should be one sane step while trackpad deltas stay smooth.
|
|
17
|
+
const MAX_WHEEL_DELTA = 50;
|
|
18
|
+
// A pinch step this close to 1 is finger jitter during a pure pan rather than a
|
|
19
|
+
// scale change -- but see applyZoomPan: it is accumulated, not discarded.
|
|
20
|
+
const ZOOM_EPSILON = 0.002;
|
|
21
|
+
// Fit is the one zoom worth landing on exactly: a hair above it the artboard is
|
|
22
|
+
// wider than the viewport and the last pixel column sits under the edge, and a
|
|
23
|
+
// pinch cannot realistically stop on 1.000 by hand. So a gesture that SETTLES
|
|
24
|
+
// within this band of fit finishes on exactly fit. Snapping mid-pinch instead
|
|
25
|
+
// would make the view stick and fight the fingers on the way past.
|
|
26
|
+
const ZOOM_SNAP_BAND = 0.08;
|
|
27
|
+
// How long after the last wheel notch counts as the wheel having settled.
|
|
28
|
+
const WHEEL_SETTLE_MS = 180;
|
|
29
|
+
|
|
30
|
+
// Empty room around the artboard inside the viewport, as a fraction of the
|
|
31
|
+
// viewport. Half a viewport on every side is exactly what it takes to bring ANY
|
|
32
|
+
// point of the sprite to the middle of the screen -- corners included, which is
|
|
33
|
+
// the whole point: without it the corner of a deeply zoomed sprite can only ever
|
|
34
|
+
// sit in the corner of the viewport, where a thumb covers it.
|
|
35
|
+
const MAX_OVERSCROLL = 0.5;
|
|
36
|
+
|
|
37
|
+
function clamp(value, lo, hi) {
|
|
38
|
+
return Math.min(hi, Math.max(lo, value));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Ramped in over the first octave rather than applied flat, because at fit there
|
|
42
|
+
// is nothing to pan TO: the artboard already fills the viewport, so slack there
|
|
43
|
+
// would just let a two-finger drag slide the whole sprite off-centre with nothing
|
|
44
|
+
// asking it to come back. 0 at fit (pinned, as before), full half-viewport from
|
|
45
|
+
// 2x up -- well below any zoom at which you would be picking at a corner.
|
|
46
|
+
function overscrollPad(displaySize, zoom, axis) {
|
|
47
|
+
return Math.round(displaySize[axis] * MAX_OVERSCROLL * clamp(zoom - 1, 0, 1));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// How close to the sprite's edge the viewport centre may sit, as a fraction of
|
|
51
|
+
// the artboard. The overscroll pad is what buys this range, so it is derived from
|
|
52
|
+
// the same number the layout uses: `m` and `1 - m` are the scroll limits
|
|
53
|
+
// expressed in centre terms, and m == 0 means any point including a corner can be
|
|
54
|
+
// centred. Falls back to dead centre when the artboard cannot fill the viewport.
|
|
55
|
+
function centreLimit(displaySize, zoom, axis, viewportSize) {
|
|
56
|
+
const canvas = displaySize[axis] * zoom;
|
|
57
|
+
const margin = (viewportSize / 2 - overscrollPad(displaySize, zoom, axis)) / canvas;
|
|
58
|
+
return margin >= 0.5 ? 0.5 : Math.max(0, margin);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// The view is stored as the SPRITE-relative point under the viewport centre
|
|
62
|
+
// (`cx`, `cy`, across the artboard) plus `zoom` -- not as a scroll offset. Two
|
|
63
|
+
// things fall out of that: clamping the centre to `centreLimit` IS the pan
|
|
64
|
+
// limit, so there is no second set of bounds math to keep in step with the
|
|
65
|
+
// layout; and a viewport resize keeps looking at the same part of the sprite for
|
|
66
|
+
// free, because nothing in the stored view is in pixels.
|
|
67
|
+
function centreOf(view, displaySize, viewport) {
|
|
68
|
+
const canvasW = displaySize.width * view.zoom;
|
|
69
|
+
const canvasH = displaySize.height * view.zoom;
|
|
70
|
+
const padX = overscrollPad(displaySize, view.zoom, 'width');
|
|
71
|
+
const padY = overscrollPad(displaySize, view.zoom, 'height');
|
|
72
|
+
return {
|
|
73
|
+
left: clamp(
|
|
74
|
+
padX + view.cx * canvasW - viewport.clientWidth / 2,
|
|
75
|
+
0,
|
|
76
|
+
Math.max(0, canvasW + padX * 2 - viewport.clientWidth)
|
|
77
|
+
),
|
|
78
|
+
top: clamp(
|
|
79
|
+
padY + view.cy * canvasH - viewport.clientHeight / 2,
|
|
80
|
+
0,
|
|
81
|
+
Math.max(0, canvasH + padY * 2 - viewport.clientHeight)
|
|
82
|
+
),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function useArtboardZoomPan({ viewportRef, displaySize }) {
|
|
87
|
+
const [zoom, setZoom] = useState(1);
|
|
88
|
+
// The authoritative view, updated SYNCHRONOUSLY by every gesture step. `zoom`
|
|
89
|
+
// state only mirrors it to drive layout: a pinch emits several steps per
|
|
90
|
+
// frame, and reading the committed state instead would make each step of the
|
|
91
|
+
// burst compose against a zoom two steps stale.
|
|
92
|
+
const viewRef = useRef({ zoom: 1, cx: 0.5, cy: 0.5 });
|
|
93
|
+
const panRef = useRef(null);
|
|
94
|
+
// Pinch scale seen since the last step big enough to apply -- see applyZoomPan.
|
|
95
|
+
const pendingFactorRef = useRef(1);
|
|
96
|
+
const wheelSettleRef = useRef(null);
|
|
97
|
+
|
|
98
|
+
const writeScroll = useCallback(() => {
|
|
99
|
+
const vp = viewportRef.current;
|
|
100
|
+
if (!vp || !displaySize) return;
|
|
101
|
+
const { left, top } = centreOf(viewRef.current, displaySize, vp);
|
|
102
|
+
vp.scrollLeft = left;
|
|
103
|
+
vp.scrollTop = top;
|
|
104
|
+
}, [displaySize, viewportRef]);
|
|
105
|
+
|
|
106
|
+
// A LAYOUT effect, not a rAF: the scroll that re-centres a zoom step has to be
|
|
107
|
+
// written after the canvas has been laid out at its new size and before the
|
|
108
|
+
// frame is painted. Writing it from a rAF raced the React commit -- the scroll
|
|
109
|
+
// clamped against the canvas's OLD width, so the anchor slid a little on every
|
|
110
|
+
// step and a pinch drifted away from the fingers.
|
|
111
|
+
useLayoutEffect(writeScroll, [writeScroll, zoom]);
|
|
112
|
+
|
|
113
|
+
// Clamp the stored centre to what the current zoom's overscroll actually
|
|
114
|
+
// allows. Both the pan and the zoom go through here, so neither can leave the
|
|
115
|
+
// view somewhere the scroll cannot express.
|
|
116
|
+
const clampCentre = useCallback(
|
|
117
|
+
(view, vp) => {
|
|
118
|
+
const mx = centreLimit(displaySize, view.zoom, 'width', vp.clientWidth);
|
|
119
|
+
const my = centreLimit(displaySize, view.zoom, 'height', vp.clientHeight);
|
|
120
|
+
view.cx = clamp(view.cx, mx, 1 - mx);
|
|
121
|
+
view.cy = clamp(view.cy, my, 1 - my);
|
|
122
|
+
},
|
|
123
|
+
[displaySize]
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
const panBy = useCallback(
|
|
127
|
+
(dx, dy) => {
|
|
128
|
+
const vp = viewportRef.current;
|
|
129
|
+
if (!vp || !displaySize || (!dx && !dy)) return;
|
|
130
|
+
const view = viewRef.current;
|
|
131
|
+
view.cx -= dx / (displaySize.width * view.zoom);
|
|
132
|
+
view.cy -= dy / (displaySize.height * view.zoom);
|
|
133
|
+
clampCentre(view, vp);
|
|
134
|
+
writeScroll();
|
|
135
|
+
},
|
|
136
|
+
[displaySize, writeScroll, clampCentre, viewportRef]
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
// Zoom to `nextRaw` (clamped) while pinning the sprite point under (anchorX,
|
|
140
|
+
// anchorY) client coords -- defaulting to the viewport centre.
|
|
141
|
+
const applyZoom = useCallback(
|
|
142
|
+
(nextRaw, anchorX, anchorY) => {
|
|
143
|
+
const vp = viewportRef.current;
|
|
144
|
+
if (!vp || !displaySize) return;
|
|
145
|
+
const view = viewRef.current;
|
|
146
|
+
const z1 = clamp(nextRaw, MIN_ART_ZOOM, MAX_ART_ZOOM);
|
|
147
|
+
if (z1 === view.zoom) return;
|
|
148
|
+
const rect = vp.getBoundingClientRect();
|
|
149
|
+
const offX = (Number.isFinite(anchorX) ? anchorX : rect.left + rect.width / 2) - rect.left
|
|
150
|
+
- vp.clientWidth / 2;
|
|
151
|
+
const offY = (Number.isFinite(anchorY) ? anchorY : rect.top + rect.height / 2) - rect.top
|
|
152
|
+
- vp.clientHeight / 2;
|
|
153
|
+
// Sprite point under the anchor, held fixed across the step.
|
|
154
|
+
const spriteX = view.cx + offX / (displaySize.width * view.zoom);
|
|
155
|
+
const spriteY = view.cy + offY / (displaySize.height * view.zoom);
|
|
156
|
+
view.zoom = z1;
|
|
157
|
+
view.cx = spriteX - offX / (displaySize.width * z1);
|
|
158
|
+
view.cy = spriteY - offY / (displaySize.height * z1);
|
|
159
|
+
clampCentre(view, vp);
|
|
160
|
+
setZoom(z1);
|
|
161
|
+
},
|
|
162
|
+
[displaySize, viewportRef, clampCentre]
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
// A button step that would cross fit stops on it instead. Stepping straight
|
|
166
|
+
// past 100% and having to hunt back for it is the worse trade.
|
|
167
|
+
const zoomBy = useCallback(
|
|
168
|
+
(factor) => {
|
|
169
|
+
const from = viewRef.current.zoom;
|
|
170
|
+
const to = from * factor;
|
|
171
|
+
applyZoom((from - 1) * (to - 1) < 0 ? 1 : to);
|
|
172
|
+
},
|
|
173
|
+
[applyZoom]
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
// Land on exactly fit when a gesture ends near it. Anchorless, so it settles
|
|
177
|
+
// about the viewport centre.
|
|
178
|
+
const settleZoom = useCallback(() => {
|
|
179
|
+
const { zoom: z } = viewRef.current;
|
|
180
|
+
if (z !== 1 && z < 1 + ZOOM_SNAP_BAND && z > 1 / (1 + ZOOM_SNAP_BAND)) applyZoom(1);
|
|
181
|
+
}, [applyZoom]);
|
|
182
|
+
|
|
183
|
+
const resetZoom = useCallback(() => {
|
|
184
|
+
viewRef.current = { zoom: 1, cx: 0.5, cy: 0.5 };
|
|
185
|
+
setZoom(1);
|
|
186
|
+
// setZoom is a no-op when we were already at fit, so the layout effect will
|
|
187
|
+
// not run -- re-centre here for that case.
|
|
188
|
+
writeScroll();
|
|
189
|
+
}, [writeScroll]);
|
|
190
|
+
|
|
191
|
+
// One two-finger step: pan by the centroid's movement, then zoom about it.
|
|
192
|
+
const applyZoomPan = useCallback(
|
|
193
|
+
({ factor, cx, cy, dx, dy }) => {
|
|
194
|
+
panBy(dx, dy);
|
|
195
|
+
// Sub-threshold steps ACCUMULATE rather than being thrown away. A slow,
|
|
196
|
+
// deliberate pinch -- which is exactly how you close the last few percent
|
|
197
|
+
// onto fit -- emits per-frame factors under the jitter threshold, and
|
|
198
|
+
// dropping each one outright is what made those last percent unreachable.
|
|
199
|
+
// Real jitter cancels in the running product; a pinch does not.
|
|
200
|
+
const pending = pendingFactorRef.current * factor;
|
|
201
|
+
if (Math.abs(pending - 1) <= ZOOM_EPSILON) {
|
|
202
|
+
pendingFactorRef.current = pending;
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
pendingFactorRef.current = 1;
|
|
206
|
+
applyZoom(viewRef.current.zoom * pending, cx, cy);
|
|
207
|
+
},
|
|
208
|
+
[panBy, applyZoom]
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
// A pinch begins: drop any factor left over from the previous one.
|
|
212
|
+
const beginGesture = useCallback(() => {
|
|
213
|
+
pendingFactorRef.current = 1;
|
|
214
|
+
}, []);
|
|
215
|
+
const endGesture = useCallback(() => {
|
|
216
|
+
pendingFactorRef.current = 1;
|
|
217
|
+
settleZoom();
|
|
218
|
+
}, [settleZoom]);
|
|
219
|
+
|
|
220
|
+
// Wheel zoom as a NATIVE non-passive listener so preventDefault stops the
|
|
221
|
+
// page/browser zoom underneath.
|
|
222
|
+
useEffect(() => {
|
|
223
|
+
const vp = viewportRef.current;
|
|
224
|
+
if (!vp) return undefined;
|
|
225
|
+
const onWheel = (event) => {
|
|
226
|
+
event.preventDefault();
|
|
227
|
+
const delta = clamp(event.deltaY, -MAX_WHEEL_DELTA, MAX_WHEEL_DELTA);
|
|
228
|
+
applyZoom(
|
|
229
|
+
viewRef.current.zoom * Math.exp(-delta * ART_ZOOM_WHEEL_SPEED),
|
|
230
|
+
event.clientX,
|
|
231
|
+
event.clientY
|
|
232
|
+
);
|
|
233
|
+
clearTimeout(wheelSettleRef.current);
|
|
234
|
+
wheelSettleRef.current = setTimeout(settleZoom, WHEEL_SETTLE_MS);
|
|
235
|
+
};
|
|
236
|
+
vp.addEventListener('wheel', onWheel, { passive: false });
|
|
237
|
+
return () => {
|
|
238
|
+
clearTimeout(wheelSettleRef.current);
|
|
239
|
+
vp.removeEventListener('wheel', onWheel, { passive: false });
|
|
240
|
+
};
|
|
241
|
+
}, [applyZoom, settleZoom, viewportRef]);
|
|
242
|
+
|
|
243
|
+
// Middle-button drag (or ⌘+left-drag) pans. Caught in the capture phase so the
|
|
244
|
+
// press never reaches the canvas's own draw handler.
|
|
245
|
+
const viewportHandlers = {
|
|
246
|
+
onPointerDownCapture: (event) => {
|
|
247
|
+
if (!(event.button === 1 || (event.button === 0 && event.metaKey))) return;
|
|
248
|
+
event.preventDefault();
|
|
249
|
+
event.stopPropagation();
|
|
250
|
+
viewportRef.current?.setPointerCapture(event.pointerId);
|
|
251
|
+
panRef.current = { id: event.pointerId, x: event.clientX, y: event.clientY };
|
|
252
|
+
},
|
|
253
|
+
onPointerMove: (event) => {
|
|
254
|
+
const pan = panRef.current;
|
|
255
|
+
if (!pan || pan.id !== event.pointerId) return;
|
|
256
|
+
panBy(event.clientX - pan.x, event.clientY - pan.y);
|
|
257
|
+
pan.x = event.clientX;
|
|
258
|
+
pan.y = event.clientY;
|
|
259
|
+
},
|
|
260
|
+
onPointerUp: (event) => {
|
|
261
|
+
const pan = panRef.current;
|
|
262
|
+
if (!pan || pan.id !== event.pointerId) return;
|
|
263
|
+
try {
|
|
264
|
+
viewportRef.current?.releasePointerCapture(event.pointerId);
|
|
265
|
+
} catch {
|
|
266
|
+
// pointer capture may already be gone
|
|
267
|
+
}
|
|
268
|
+
panRef.current = null;
|
|
269
|
+
},
|
|
270
|
+
};
|
|
271
|
+
viewportHandlers.onPointerCancel = viewportHandlers.onPointerUp;
|
|
272
|
+
|
|
273
|
+
// The viewport is the fit baseline; the scroll content is the zoomed canvas
|
|
274
|
+
// plus half a viewport of overscroll on every side.
|
|
275
|
+
const viewportStyle = displaySize
|
|
276
|
+
? {
|
|
277
|
+
width: displaySize.width,
|
|
278
|
+
height: displaySize.height,
|
|
279
|
+
maxWidth: '100%',
|
|
280
|
+
maxHeight: '100%',
|
|
281
|
+
overflow: 'hidden',
|
|
282
|
+
touchAction: 'none',
|
|
283
|
+
}
|
|
284
|
+
: { overflow: 'hidden' };
|
|
285
|
+
const padX = displaySize ? overscrollPad(displaySize, zoom, 'width') : 0;
|
|
286
|
+
const padY = displaySize ? overscrollPad(displaySize, zoom, 'height') : 0;
|
|
287
|
+
// Sized to the artboard plus its overscroll, but never smaller than the
|
|
288
|
+
// viewport: below fit the artboard no longer fills the viewport, and without
|
|
289
|
+
// the minimum the scroll content would shrink around it and strand it in the
|
|
290
|
+
// top-left corner. Centring is flex rather than padding so the same rule
|
|
291
|
+
// covers both cases -- the extra room is the overscroll above fit and the
|
|
292
|
+
// empty surround below it.
|
|
293
|
+
const contentStyle = displaySize
|
|
294
|
+
? {
|
|
295
|
+
boxSizing: 'border-box',
|
|
296
|
+
width: displaySize.width * zoom + padX * 2,
|
|
297
|
+
height: displaySize.height * zoom + padY * 2,
|
|
298
|
+
minWidth: '100%',
|
|
299
|
+
minHeight: '100%',
|
|
300
|
+
display: 'flex',
|
|
301
|
+
alignItems: 'center',
|
|
302
|
+
justifyContent: 'center',
|
|
303
|
+
}
|
|
304
|
+
: undefined;
|
|
305
|
+
const canvasSize = displaySize
|
|
306
|
+
? { width: displaySize.width * zoom, height: displaySize.height * zoom }
|
|
307
|
+
: null;
|
|
308
|
+
|
|
309
|
+
return {
|
|
310
|
+
zoom,
|
|
311
|
+
applyZoom,
|
|
312
|
+
zoomBy,
|
|
313
|
+
resetZoom,
|
|
314
|
+
applyZoomPan,
|
|
315
|
+
beginGesture,
|
|
316
|
+
endGesture,
|
|
317
|
+
viewportHandlers,
|
|
318
|
+
viewportStyle,
|
|
319
|
+
contentStyle,
|
|
320
|
+
canvasSize,
|
|
321
|
+
};
|
|
322
|
+
}
|
|
@@ -3,19 +3,13 @@ import styles from './ui.module.css';
|
|
|
3
3
|
import { useElementSize } from './ui';
|
|
4
4
|
import { cardSize, isBehaviorEnabled } from './scene';
|
|
5
5
|
import { deckFontFamily } from './fonts';
|
|
6
|
-
// Game-time UI overlay.
|
|
6
|
+
// Game-time React UI overlay -- deprecated.
|
|
7
7
|
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
// it
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
// hidden`, so deck UI can never render outside the card.
|
|
14
|
-
//
|
|
15
|
-
// The layer also carries the deck's font, so a behavior returning React gets it
|
|
16
|
-
// with no extra work, and `--castle-deck-font` is there for deck CSS that wants
|
|
17
|
-
// to name it. Canvas text can't inherit anything -- `draw` hooks read
|
|
18
|
-
// `scene.font` instead.
|
|
8
|
+
// Prefer behavior `drawUi(actor, scene, ctx)` for HUD. `ui(actor, scene)` still
|
|
9
|
+
// works: this layer collects every behavior's React output each frame and
|
|
10
|
+
// renders it over the canvas in card units (`cardSize`, scaled onto the canvas
|
|
11
|
+
// box, clipped). Kept so existing decks keep rendering; new HUD should use
|
|
12
|
+
// `drawUi` and `scene.font` instead.
|
|
19
13
|
export function SceneUI({ getRuntime }) {
|
|
20
14
|
const rootRef = useRef(null);
|
|
21
15
|
// Track the canvas-sized overlay box so the card-unit layer scales onto it.
|