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.
Files changed (106) hide show
  1. package/dist/agent-prompts.d.ts +1 -0
  2. package/dist/agent-prompts.js +3 -0
  3. package/dist/agent.js +70 -24
  4. package/dist/atomicFile.d.ts +29 -0
  5. package/dist/atomicFile.js +120 -0
  6. package/dist/castle-host/host.js +108 -0
  7. package/dist/get-deck.js +2 -11
  8. package/dist/headlessCover.d.ts +4 -0
  9. package/dist/headlessCover.js +7 -5
  10. package/dist/ide.d.ts +10 -0
  11. package/dist/ide.js +19 -11
  12. package/dist/index.js +10 -6
  13. package/dist/init.d.ts +1 -1
  14. package/dist/init.js +11 -1
  15. package/dist/remix.js +2 -14
  16. package/dist/save-deck.d.ts +3 -0
  17. package/dist/save-deck.js +40 -73
  18. package/dist/saveCover.d.ts +18 -0
  19. package/dist/saveCover.js +61 -0
  20. package/dist/serve.js +18 -5
  21. package/dist/serveSecurity.d.ts +11 -1
  22. package/dist/serveSecurity.js +38 -4
  23. package/dist/shell/assets/index-6odVZQSZ.css +1 -0
  24. package/dist/shell/assets/index-Ws0WrCbi.js +445 -0
  25. package/dist/shell/index.html +3 -3
  26. package/dist/versionStore.js +13 -4
  27. package/kits/base/CLAUDE.md +9 -0
  28. package/kits/base/castle.json +16 -11
  29. package/kits/base/sdk/README.md +83 -2
  30. package/kits/base/sdk/commands.d.ts +37 -1
  31. package/kits/base/sdk/commands.js +9 -0
  32. package/kits/base/sdk/lifecycle.d.ts +20 -0
  33. package/kits/base/sdk/lifecycle.js +7 -1
  34. package/kits/base/sdk/resumeState.d.ts +2 -0
  35. package/kits/base/sdk/resumeState.js +84 -0
  36. package/kits/base/sdk/runtime.js +65 -0
  37. package/kits/base/sdk/transport.d.ts +3 -0
  38. package/kits/base/sdk/transport.js +39 -5
  39. package/kits/base/sdk/unloadHandshake.d.ts +6 -0
  40. package/kits/base/sdk/unloadHandshake.js +47 -0
  41. package/kits/base/sdk/user.d.ts +4 -0
  42. package/kits/base/sdk/user.js +36 -1
  43. package/kits/multiplayer-2d/CLAUDE.md +20 -9
  44. package/kits/multiplayer-2d/castle.json +4 -4
  45. package/kits/multiplayer-2d/code/server/players.js +27 -6
  46. package/kits/multiplayer-2d/code/server/world.js +24 -1
  47. package/kits/multiplayer-2d/code/systems/multiplayer.js +45 -4
  48. package/kits/multiplayer-2d/package-lock.json +2 -4
  49. package/kits/multiplayer-2d/package.json +1 -0
  50. package/kits/multiplayer-3d/CLAUDE.md +28 -9
  51. package/kits/multiplayer-3d/castle.json +5 -5
  52. package/kits/multiplayer-3d/code/server/players.js +54 -5
  53. package/kits/multiplayer-3d/code/server/world.js +45 -1
  54. package/kits/multiplayer-3d/code/systems/multiplayer.js +108 -6
  55. package/kits/multiplayer-3d/package-lock.json +2 -4
  56. package/kits/multiplayer-3d/package.json +1 -0
  57. package/kits/physics-2d/CLAUDE.md +75 -43
  58. package/kits/physics-2d/behaviors/AnalogStick.jsx +75 -45
  59. package/kits/physics-2d/behaviors/Slingshot.jsx +1 -2
  60. package/kits/physics-2d/castle.json +6 -6
  61. package/kits/physics-2d/editors/PxArtEditor.jsx +85 -182
  62. package/kits/physics-2d/editors/SceneEditor.jsx +25 -4
  63. package/kits/physics-2d/editors/StyleEditor.jsx +6 -2
  64. package/kits/physics-2d/editors/overlayCanvas.js +77 -0
  65. package/kits/physics-2d/editors/pathOverlay.js +5 -13
  66. package/kits/physics-2d/editors/pixelEditorChrome.jsx +5 -1
  67. package/kits/physics-2d/editors/pixelInspector.jsx +4 -4
  68. package/kits/physics-2d/editors/pxArtTimeline.jsx +8 -36
  69. package/kits/physics-2d/editors/pxArtTimeline.module.css +4 -28
  70. package/kits/physics-2d/editors/styleEditor.module.css +23 -4
  71. package/kits/physics-2d/editors/useArtboardZoomPan.js +322 -0
  72. package/kits/physics-2d/engine/SceneUI.jsx +6 -12
  73. package/kits/physics-2d/engine/icons.js +56 -0
  74. package/kits/physics-2d/engine/panelSorting.jsx +7 -14
  75. package/kits/physics-2d/engine/physics/PhysicsSystem.js +66 -11
  76. package/kits/physics-2d/engine/physics/controls.js +76 -12
  77. package/kits/physics-2d/engine/scene.js +47 -9
  78. package/kits/physics-2d/engine/ui.jsx +13 -125
  79. package/kits/physics-2d/engine/ui.module.css +53 -28
  80. package/kits/physics-2d/package-lock.json +0 -29
  81. package/kits/physics-2d/package.json +3 -1
  82. package/kits/physics-2d/pnpm-lock.yaml +1 -17
  83. package/kits/physics-3d/behaviors/Pickup.jsx +5 -3
  84. package/kits/physics-3d/castle.json +5 -5
  85. package/kits/physics-3d/package-lock.json +0 -29
  86. package/kits/physics-3d/package.json +0 -1
  87. package/kits/physics-3d/pnpm-lock.yaml +0 -17
  88. package/kits/real-time/CLAUDE.md +23 -4
  89. package/kits/real-time/castle.json +1 -1
  90. package/kits/real-time/code/client/connection.js +9 -4
  91. package/kits/real-time/code/client/joinOverlay.js +46 -0
  92. package/kits/real-time/code/client/messages.js +4 -0
  93. package/kits/real-time/code/server/gameHooks.js +4 -0
  94. package/kits/real-time/code/server/persist.js +105 -0
  95. package/kits/real-time/code/server/session.js +289 -19
  96. package/kits/real-time/package-lock.json +1139 -0
  97. package/kits/turn-based/CLAUDE.md +76 -20
  98. package/kits/turn-based/castle.json +3 -3
  99. package/kits/turn-based/code/server/index.js +16 -5
  100. package/kits/turn-based/package-lock.json +0 -27
  101. package/kits/turn-based/package.json +2 -2
  102. package/kits/turn-based/room.js +162 -13
  103. package/kits/turn-based/testing.js +276 -92
  104. package/package.json +5 -5
  105. package/dist/shell/assets/index-CVHj503j.css +0 -1
  106. 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="grid" label="Pixel" disabled={readOnly} onClick={() => actions.addLayer(layerIndex)} />
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="draw-polygon"
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
- <FaGlyph icon={faStopwatch} className={tl.durationGlyph} />
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 (clapperboard), styled like the toolbar's
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
- <FaGlyph icon={faClapperboard} />
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' ? 'draw-polygon' : 'grid'} />
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
- <FaGlyph icon={layer.visible ? faEye : faEyeSlash} />
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
- <FaGlyph icon={faLink} />
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
- display: flex;
57
- flex-direction: column;
58
- gap: 8px;
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
- // Behaviors expose a `ui(actor, scene)` handler -- the React counterpart of
9
- // `draw`. `SceneUI` collects every behavior's UI output each frame and renders
10
- // it into a deck-sized layer that sits exactly over the canvas. The layer is
11
- // `cardSize` units wide/tall and scaled onto the canvas box, so behaviors lay
12
- // out in the same card units as `Layout`. The root clips with `overflow:
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.