castle-web-cli 0.4.84 → 0.4.85

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 (73) hide show
  1. package/dist/ide.js +35 -13
  2. package/dist/shell/assets/{index-BOgm5T3W.js → index-BJLaUTJE.js} +21 -21
  3. package/dist/shell/index.html +1 -1
  4. package/kits/physics-2d/.prettierrc +8 -0
  5. package/kits/physics-2d/CLAUDE.md +329 -0
  6. package/kits/physics-2d/behaviors/Camera.jsx +43 -0
  7. package/kits/physics-2d/behaviors/Collider.jsx +199 -0
  8. package/kits/physics-2d/behaviors/Goal.jsx +29 -0
  9. package/kits/physics-2d/behaviors/Layout.jsx +53 -0
  10. package/kits/physics-2d/behaviors/Sprite.jsx +352 -0
  11. package/kits/physics-2d/behaviors/tint.js +47 -0
  12. package/kits/physics-2d/blueprints/ball.scene +14 -0
  13. package/kits/physics-2d/blueprints/block.scene +12 -0
  14. package/kits/physics-2d/blueprints/cauldron.scene +18 -0
  15. package/kits/physics-2d/blueprints/crate.scene +14 -0
  16. package/kits/physics-2d/blueprints/goal.scene +12 -0
  17. package/kits/physics-2d/castle.json +13 -0
  18. package/kits/physics-2d/docs/pxart-format.md +377 -0
  19. package/kits/physics-2d/drawings/block.pxart +25 -0
  20. package/kits/physics-2d/drawings/cauldron.pxart +113 -0
  21. package/kits/physics-2d/editors/BlueprintLibrary.jsx +247 -0
  22. package/kits/physics-2d/editors/ErrorBoundary.jsx +59 -0
  23. package/kits/physics-2d/editors/PlayOnly.jsx +31 -0
  24. package/kits/physics-2d/editors/PxArtEditor.jsx +954 -0
  25. package/kits/physics-2d/editors/SceneEditor.jsx +1681 -0
  26. package/kits/physics-2d/editors/SelectionOverlay.jsx +909 -0
  27. package/kits/physics-2d/editors/SingleEditor.jsx +122 -0
  28. package/kits/physics-2d/editors/behaviorRegistry.js +30 -0
  29. package/kits/physics-2d/editors/editorHistory.js +157 -0
  30. package/kits/physics-2d/editors/inspectorSheet.js +13 -0
  31. package/kits/physics-2d/editors/pixelCanvas.js +11 -0
  32. package/kits/physics-2d/editors/pixelEditorChrome.jsx +74 -0
  33. package/kits/physics-2d/editors/pixelGeometry.js +140 -0
  34. package/kits/physics-2d/editors/pixelInspector.jsx +633 -0
  35. package/kits/physics-2d/editors/pxArtEditorModel.js +732 -0
  36. package/kits/physics-2d/editors/pxArtPlayback.js +92 -0
  37. package/kits/physics-2d/editors/pxArtTimeline.jsx +752 -0
  38. package/kits/physics-2d/editors/pxArtTimeline.module.css +506 -0
  39. package/kits/physics-2d/editors/pxArtTools.js +232 -0
  40. package/kits/physics-2d/editors/useArtboardFit.js +102 -0
  41. package/kits/physics-2d/engine/ScenePlayer.jsx +196 -0
  42. package/kits/physics-2d/engine/SceneUI.jsx +59 -0
  43. package/kits/physics-2d/engine/assets.js +15 -0
  44. package/kits/physics-2d/engine/autoInspector.jsx +70 -0
  45. package/kits/physics-2d/engine/blueprint.js +521 -0
  46. package/kits/physics-2d/engine/collider.js +196 -0
  47. package/kits/physics-2d/engine/files.js +117 -0
  48. package/kits/physics-2d/engine/liveReload.js +88 -0
  49. package/kits/physics-2d/engine/pxart.js +1032 -0
  50. package/kits/physics-2d/engine/pxartSmooth.js +222 -0
  51. package/kits/physics-2d/engine/scene.js +686 -0
  52. package/kits/physics-2d/engine/spriteGeometry.js +32 -0
  53. package/kits/physics-2d/engine/ui.jsx +688 -0
  54. package/kits/physics-2d/engine/ui.module.css +2287 -0
  55. package/kits/physics-2d/eslint.config.js +71 -0
  56. package/kits/physics-2d/index.html +24 -0
  57. package/kits/physics-2d/main.jsx +24 -0
  58. package/kits/physics-2d/package-lock.json +2706 -0
  59. package/kits/physics-2d/package.json +42 -0
  60. package/kits/physics-2d/physics/PhysicsSystem.js +290 -0
  61. package/kits/physics-2d/physics/behaviors/AnalogStick.jsx +101 -0
  62. package/kits/physics-2d/physics/behaviors/Draggable.jsx +79 -0
  63. package/kits/physics-2d/physics/behaviors/RigidBody.jsx +55 -0
  64. package/kits/physics-2d/physics/behaviors/Slingshot.jsx +118 -0
  65. package/kits/physics-2d/physics/controls.js +79 -0
  66. package/kits/physics-2d/physics/index.js +26 -0
  67. package/kits/physics-2d/physics/matterBridge.js +126 -0
  68. package/kits/physics-2d/pnpm-lock.yaml +1761 -0
  69. package/kits/physics-2d/scenes/main.scene +12 -0
  70. package/kits/physics-2d/scenes/sandbox.scene +13 -0
  71. package/kits/physics-2d/scripts/draw.mjs +121 -0
  72. package/kits/physics-2d/vite.config.js +1 -0
  73. package/package.json +1 -1
@@ -0,0 +1,909 @@
1
+ import React, { useCallback, useEffect, useRef, useState } from 'react';
2
+ import { getColliderShape } from '../behaviors/Collider';
3
+ import { cardSize, screenToCard } from '../engine/scene';
4
+ import { cx, Icon, styles, useElementSize } from '../engine/ui';
5
+
6
+ // On-canvas selection toolbar ("selection handles"). Mounts inside `.stageCard`
7
+ // as a sibling of the `<canvas>` and mirrors the `SceneUI` overlay approach:
8
+ //
9
+ // - A ResizeObserver measures the canvas-sized box so a card-unit (500x700)
10
+ // layer can be `scale()`d onto it -- the chrome (solid box, corner dots,
11
+ // center pivot) lives in card units inside that scaled layer and is
12
+ // translated by -camera so it tracks the edit camera exactly like the canvas
13
+ // draw path (`ctx.translate(-camera.x, -camera.y)`).
14
+ // - The floating buttons live in a separate, un-scaled px layer. Their
15
+ // positions are projected from world card units into screen px, so they stay
16
+ // crisp and fixed-size with fixed px gaps regardless of the responsive card
17
+ // scale (the alternative -- buttons inside the scaled layer -- would distort
18
+ // them and make gaps scale).
19
+ //
20
+ // Clone/Delete reuse the existing duplicate/remove scene actions; Move and
21
+ // Rotate are pointer drags that translate / rotate every selected actor and
22
+ // commit one history entry per gesture.
23
+
24
+ const HANDLE_OFFSET = 8; // card units between the real bounds and transform handles
25
+ const GAP_PX = 30; // px from a box edge to a floating button's center
26
+ const ACTION_GAP_PX = 22; // px from a box edge to clone/delete buttons
27
+ const ROTATE_STEP = 15; // degrees -- rotation snaps to this increment when grid snap is on
28
+ const ROTATE_MAGNET = 7; // degrees -- within this band of the pre-drag angle, snap back to it exactly
29
+ const MIN_LAYOUT_SIZE = 4; // card units; prevents inverted / zero-size Layout boxes
30
+ // Below this on-screen box extent (px) along an axis, the mid-edge scale handle
31
+ // for that axis would collide with the corner handles, so we drop it and keep
32
+ // only the corners. Gated per-axis: n/s depend on the box's px width, e/w on its
33
+ // px height (a wide, short box can keep n/s while dropping e/w, and vice versa).
34
+ const EDGE_HANDLE_MIN_PX = 40;
35
+ // Move/grab handle is intentionally hidden for now (its drag logic + helpers
36
+ // stay wired below so it can be re-enabled later); flip this to render it.
37
+ const SHOW_MOVE_HANDLE = false;
38
+ const ARRANGE_OPTIONS = [
39
+ { label: 'Bring to Front', action: 'front' },
40
+ { label: 'Bring Forward', action: 'forward' },
41
+ { label: 'Send Backward', action: 'backward' },
42
+ { label: 'Send to Back', action: 'back' },
43
+ ];
44
+
45
+ const SCALE_HANDLES = [
46
+ { id: 'nw', x: -1, y: -1, cursor: 'nwse-resize', corner: true },
47
+ { id: 'n', x: 0, y: -1, cursor: 'ns-resize' },
48
+ { id: 'ne', x: 1, y: -1, cursor: 'nesw-resize', corner: true },
49
+ { id: 'e', x: 1, y: 0, cursor: 'ew-resize' },
50
+ { id: 'se', x: 1, y: 1, cursor: 'nwse-resize', corner: true },
51
+ { id: 's', x: 0, y: 1, cursor: 'ns-resize' },
52
+ { id: 'sw', x: -1, y: 1, cursor: 'nesw-resize', corner: true },
53
+ { id: 'w', x: -1, y: 0, cursor: 'ew-resize' },
54
+ ];
55
+
56
+ // `sceneData` (raw, sparse) is the clone target for every write here --
57
+ // writes only ever add/replace the exact prop keys a gesture changed (x/y,
58
+ // rotation, width/height), preserving whatever sparse overrides already
59
+ // existed. `previewSceneData` (blueprint template merged with overrides) is
60
+ // the read side for every geometry computation (bounds, colliders, drag-start
61
+ // snapshots) -- an instance that inherits width/height from its blueprint has
62
+ // no `Layout.width` at all in `sceneData`, so reading raw here would produce
63
+ // NaN boxes. See engine/blueprint.js for the merge this mirrors.
64
+ export function SelectionOverlay({
65
+ canvasRef,
66
+ editCameraRef,
67
+ sceneData,
68
+ previewSceneData,
69
+ sprites,
70
+ selectedActorIds,
71
+ snap,
72
+ onArrange,
73
+ onClone,
74
+ onDelete,
75
+ applyScene,
76
+ recordSnapshot,
77
+ }) {
78
+ const rootRef = useRef(null);
79
+ // Track the canvas-sized overlay box so the card-unit layer scales onto it.
80
+ const box = useElementSize(rootRef);
81
+ const [camera, setCamera] = useState({ x: 0, y: 0, zoom: 1 });
82
+ const [groupFrameRotation, setGroupFrameRotation] = useState(null);
83
+ const [arrangeOpen, setArrangeOpen] = useState(false);
84
+ const selectionKey = [...selectedActorIds].sort().join('|');
85
+
86
+ // Follow the edit camera on a RAF tick so the toolbar pans with the canvas.
87
+ // setState is skipped when the camera is unchanged, so this idles cheaply.
88
+ useEffect(() => {
89
+ let raf = 0;
90
+ const tick = () => {
91
+ const cam = editCameraRef.current ?? { x: 0, y: 0, zoom: 1 };
92
+ const zoom = cam.zoom ?? 1;
93
+ setCamera((prev) =>
94
+ prev.x === cam.x && prev.y === cam.y && prev.zoom === zoom
95
+ ? prev
96
+ : { x: cam.x, y: cam.y, zoom }
97
+ );
98
+ raf = requestAnimationFrame(tick);
99
+ };
100
+ raf = requestAnimationFrame(tick);
101
+ return () => cancelAnimationFrame(raf);
102
+ }, [editCameraRef]);
103
+
104
+ useEffect(() => {
105
+ setGroupFrameRotation(null);
106
+ setArrangeOpen(false);
107
+ }, [selectionKey]);
108
+
109
+ const frame = getSelectionFrame(previewSceneData, selectedActorIds, groupFrameRotation);
110
+
111
+ const onMoveDown = usePointerDragHandle((event) => {
112
+ const canvas = canvasRef.current;
113
+ if (!canvas || !sceneData || selectedActorIds.length === 0) return null;
114
+ const startPoint = screenToCard(canvas, event.clientX, event.clientY);
115
+ const starts = collectLayoutStarts(previewSceneData, selectedActorIds);
116
+ const state = { recorded: false, lastKey: '0,0' };
117
+ return {
118
+ onMove: (moveEvent) => {
119
+ const point = screenToCard(canvas, moveEvent.clientX, moveEvent.clientY);
120
+ const dx = snapDelta(point.x - startPoint.x, snap);
121
+ const dy = snapDelta(point.y - startPoint.y, snap);
122
+ const next = moveSelected(sceneData, previewSceneData, selectedActorIds, starts, dx, dy);
123
+ applyDragResult(next, sceneData, `${dx},${dy}`, state, recordSnapshot, applyScene);
124
+ },
125
+ };
126
+ });
127
+
128
+ const onRotateDown = usePointerDragHandle((event) => {
129
+ const canvas = canvasRef.current;
130
+ if (!canvas || !sceneData || !frame || selectedActorIds.length === 0) return null;
131
+ const center = { x: frame.x + frame.width / 2, y: frame.y + frame.height / 2 };
132
+ const startAngle = pointerAngle(canvas, editCameraRef, event, center);
133
+ let lastAngle = startAngle;
134
+ let totalDelta = 0;
135
+ const startFrameRotation = frame.rotation;
136
+ const starts = collectLayoutStarts(previewSceneData, selectedActorIds);
137
+ const state = { recorded: false, lastDelta: 0 };
138
+ return {
139
+ onMove: (moveEvent) => {
140
+ const angle = pointerAngle(canvas, editCameraRef, moveEvent, center);
141
+ totalDelta += shortestAngleDelta(lastAngle, angle);
142
+ lastAngle = angle;
143
+ const delta = snapRotationDelta(totalDelta, snap?.enabled ?? true);
144
+ // Key the commit on the snapped delta itself, not on a diff against the
145
+ // pointer-down snapshot. `onMove` closes over the `sceneData` captured at
146
+ // drag-start, where the actor still sits at its original angle, so when
147
+ // the magnet pulls `delta` back to 0 `rotateSelected` returns that
148
+ // unchanged snapshot; an identity guard would treat that as a no-op and
149
+ // skip it, stranding the actor on the last grid step -- the reason
150
+ // returning to the original angle felt impossible. Re-applying whenever
151
+ // the delta changes lets delta 0 actually restore the start angle.
152
+ // (Scale/move share this contract via `applyDragResult`'s `key` param.)
153
+ if (delta === state.lastDelta) return;
154
+ state.lastDelta = delta;
155
+ setGroupFrameRotation(normalizeAngle(startFrameRotation + delta));
156
+ const next = rotateSelected(sceneData, previewSceneData, selectedActorIds, starts, center, delta);
157
+ if (!state.recorded) {
158
+ recordSnapshot();
159
+ state.recorded = true;
160
+ }
161
+ applyScene(next);
162
+ },
163
+ };
164
+ });
165
+
166
+ const onScaleDown = usePointerDragHandle((event) => {
167
+ const handleId = event.currentTarget.dataset.handle;
168
+ const handle = SCALE_HANDLES.find((candidate) => candidate.id === handleId);
169
+ const canvas = canvasRef.current;
170
+ if (!handle || !canvas || !sceneData || !frame || selectedActorIds.length === 0) return null;
171
+ const startTransform = makeBoundsTransform(frame, frame.rotation);
172
+ const startPointer = pointerLocal(canvas, editCameraRef, event, startTransform);
173
+ const starts = collectLayoutStarts(previewSceneData, selectedActorIds);
174
+ const state = { recorded: false, lastKey: '0,0,0' };
175
+ return {
176
+ onMove: (moveEvent) => {
177
+ const point = pointerLocal(canvas, editCameraRef, moveEvent, startTransform);
178
+ const dx = snapDelta(point.x - startPointer.x, snap);
179
+ const dy = snapDelta(point.y - startPointer.y, snap);
180
+ const nextBounds = scaleBoundsFromHandle(
181
+ startTransform.localBounds,
182
+ handle,
183
+ dx,
184
+ dy,
185
+ moveEvent.shiftKey
186
+ );
187
+ const next = scaleSelected(sceneData, previewSceneData, selectedActorIds, starts, startTransform, nextBounds);
188
+ // Include shift (uniform) in the key so toggling it without moving still re-applies.
189
+ applyDragResult(next, sceneData, `${dx},${dy},${moveEvent.shiftKey ? 1 : 0}`, state, recordSnapshot, applyScene);
190
+ },
191
+ };
192
+ });
193
+
194
+ if (!frame || box.width === 0 || box.height === 0) {
195
+ // Keep the root mounted so the ResizeObserver always has a node to measure.
196
+ return <div ref={rootRef} className={styles.selOverlayRoot} aria-hidden="true" />;
197
+ }
198
+
199
+ const geometry = getOverlayGeometry(frame, box, camera);
200
+ const colliderFrames = getSelectedColliderFrames(previewSceneData, selectedActorIds, sprites);
201
+
202
+ return (
203
+ <div ref={rootRef} className={styles.selOverlayRoot}>
204
+ <div
205
+ className={styles.selChromeLayer}
206
+ style={{
207
+ width: cardSize.width,
208
+ height: cardSize.height,
209
+ transform: `scale(${geometry.sx}, ${geometry.sy})`,
210
+ }}>
211
+ <div
212
+ className={styles.selChromeWorld}
213
+ style={{ transform: `translate(${-geometry.camX}px, ${-geometry.camY}px)` }}>
214
+ {colliderFrames.map((collider) => (
215
+ <div
216
+ key={collider.actorId}
217
+ className={cx(
218
+ styles.selColliderBox,
219
+ collider.isTrigger && styles.selColliderPickup
220
+ )}
221
+ style={{
222
+ left: collider.x,
223
+ top: collider.y,
224
+ width: collider.width,
225
+ height: collider.height,
226
+ borderRadius: collider.shape === 'circle' ? '50%' : undefined,
227
+ transformOrigin: `${collider.originX}px ${collider.originY}px`,
228
+ transform: `rotate(${collider.rotation}deg)`,
229
+ }}
230
+ />
231
+ ))}
232
+ </div>
233
+ </div>
234
+
235
+ {/* Buttons stay screen-upright (never rotated) so their glyphs are always
236
+ readable. The rotate handle orbits via its anchor; clone/delete flip
237
+ above/below to stay clear of it. */}
238
+ <div className={styles.selButtonLayer}>
239
+ {/* Solid bounding box, connector stem, and center pivot render here
240
+ (un-scaled px layer) rather than the chrome layer, so their stroke
241
+ width and pivot size stay constant with zoom. Their px dimensions
242
+ are projected from the box's screen extents, so the box still traces
243
+ the actual bounds. The stem is anchored at the box's rotated
244
+ right-center edge and rotates about that point out to the handle. */}
245
+ <div
246
+ className={styles.selStem}
247
+ style={{
248
+ left: geometry.stemStart.x,
249
+ top: geometry.stemStart.y,
250
+ width: geometry.stemLengthPx,
251
+ transform: `translateY(-50%) rotate(${geometry.rotation}deg)`,
252
+ }}
253
+ />
254
+ <div
255
+ className={styles.selBox}
256
+ style={{
257
+ left: geometry.centerX,
258
+ top: geometry.centerY,
259
+ width: geometry.boxWidthPx,
260
+ height: geometry.boxHeightPx,
261
+ transform: `translate(-50%, -50%) rotate(${geometry.rotation}deg)`,
262
+ }}
263
+ />
264
+ <div className={styles.selPivot} style={{ left: geometry.centerX, top: geometry.centerY }} />
265
+ {/* Scale handles live here (un-scaled px layer), not in the chrome layer:
266
+ projecting them to screen px keeps them crisp and a constant size at
267
+ any zoom, instead of being rasterized small then stretched by the
268
+ layer's `scale()`. Each is rotated so edge handles hug the box side. */}
269
+ {geometry.scaleHandles.map((handle) => (
270
+ <div
271
+ key={handle.id}
272
+ role="button"
273
+ aria-label={`Scale ${handle.id}`}
274
+ title={`Scale ${handle.id}`}
275
+ className={cx(
276
+ styles.selScaleHandle,
277
+ handle.corner ? styles.selScaleCorner : styles.selScaleEdge
278
+ )}
279
+ data-handle={handle.id}
280
+ style={{
281
+ left: handle.x,
282
+ top: handle.y,
283
+ cursor: handle.cursor,
284
+ transform: `translate(-50%, -50%) rotate(${geometry.rotation}deg)`,
285
+ }}
286
+ onPointerDown={(event) => {
287
+ event.stopPropagation();
288
+ onScaleDown(event);
289
+ }}
290
+ />
291
+ ))}
292
+ <Floating x={geometry.cloneAnchor.x} y={geometry.cloneAnchor.y}>
293
+ <div className={styles.selBtnGroup}>
294
+ <div className={styles.selArrangeWrap}>
295
+ <OverlayButton
296
+ label="Arrange"
297
+ icon="layer-group"
298
+ disabled={!onArrange}
299
+ onActivate={() => setArrangeOpen((open) => !open)}
300
+ />
301
+ {arrangeOpen ? (
302
+ <div
303
+ className={styles.selArrangeMenu}
304
+ role="menu"
305
+ aria-label="Arrange"
306
+ onPointerDown={(event) => event.stopPropagation()}
307
+ onClick={(event) => event.stopPropagation()}>
308
+ {ARRANGE_OPTIONS.map((option) => (
309
+ <button
310
+ key={option.action}
311
+ type="button"
312
+ role="menuitem"
313
+ className={styles.selArrangeItem}
314
+ onClick={() => {
315
+ onArrange?.(option.action);
316
+ setArrangeOpen(false);
317
+ }}>
318
+ {option.label}
319
+ </button>
320
+ ))}
321
+ </div>
322
+ ) : null}
323
+ </div>
324
+ <OverlayButton label="Clone" icon="clone" disabled={!onClone} onActivate={onClone} />
325
+ <OverlayButton label="Delete" icon="trash" disabled={!onDelete} onActivate={onDelete} />
326
+ </div>
327
+ </Floating>
328
+ <Floating x={geometry.rotateAnchor.x} y={geometry.rotateAnchor.y}>
329
+ <OverlayButton label="Rotate" icon="rotate" extraClass={styles.selBtnRotate} onPointerDown={onRotateDown} />
330
+ </Floating>
331
+ {/* Move/grab handle hidden for now; drag wiring (onMoveDown) preserved. */}
332
+ {SHOW_MOVE_HANDLE && (
333
+ <Floating x={geometry.centerX} y={geometry.centerY + geometry.halfH + GAP_PX}>
334
+ <OverlayButton label="Move" icon="move" extraClass={styles.selBtnGrab} onPointerDown={onMoveDown} />
335
+ </Floating>
336
+ )}
337
+ </div>
338
+ </div>
339
+ );
340
+ }
341
+
342
+ // `sceneData` here is always the MERGED preview scene (see the note on
343
+ // `SelectionOverlay` above) -- every Layout read in this file is.
344
+ function getSelectionFrame(sceneData, actorIds, preferredRotation = null) {
345
+ if (!sceneData || !actorIds || actorIds.length === 0) return null;
346
+ const wanted = new Set(actorIds);
347
+ const layouts = sceneData.actors
348
+ .filter((actor) => wanted.has(actor.id))
349
+ .map((actor) => actor.components.Layout)
350
+ .filter(Boolean);
351
+ if (layouts.length === 0) return null;
352
+ const sharedRotation = getSharedRotation(layouts);
353
+ const rotation = sharedRotation ?? (layouts.length > 1 ? preferredRotation : null) ?? 0;
354
+ return getOrientedLayoutBounds(layouts, rotation);
355
+ }
356
+
357
+ function getOverlayGeometry(frame, box, camera) {
358
+ // Fold the edit zoom into the card-unit -> screen-px scale. The canvas draws
359
+ // the card at (box / card) * zoom px-per-unit (the effective viewport extent
360
+ // and origin both scale by 1/zoom), and the chrome layer's `transform-origin:
361
+ // 0 0` projection from world (0,0) matches once sx/sy carry the same zoom.
362
+ const zoom = camera.zoom ?? 1;
363
+ const sx = (box.width / cardSize.width) * zoom;
364
+ const sy = (box.height / cardSize.height) * zoom;
365
+ const camX = Math.round(camera.x);
366
+ const camY = Math.round(camera.y);
367
+ const rotation = frame.rotation;
368
+ const centerCardX = frame.x + frame.width / 2;
369
+ const centerCardY = frame.y + frame.height / 2;
370
+ const centerX = (centerCardX - camX) * sx;
371
+ const centerY = (centerCardY - camY) * sy;
372
+ const halfW = (frame.width / 2 + HANDLE_OFFSET) * sx;
373
+ const halfH = (frame.height / 2 + HANDLE_OFFSET) * sy;
374
+ const rotateAnchor = getRotateAnchor({ centerX, centerY, halfW, rotation });
375
+ // Stem starts at the box's rotated right-center edge and runs out to the
376
+ // rotate handle. Projected to screen px (like the box + handles) so its
377
+ // thickness stays constant with zoom instead of scaling with the chrome layer.
378
+ const rad = (rotation * Math.PI) / 180;
379
+ const halfWpx = (frame.width / 2) * sx;
380
+ const stemStart = {
381
+ x: centerX + halfWpx * Math.cos(rad),
382
+ y: centerY + halfWpx * Math.sin(rad),
383
+ };
384
+ return {
385
+ sx,
386
+ sy,
387
+ camX,
388
+ camY,
389
+ rotation,
390
+ centerX,
391
+ centerY,
392
+ halfH,
393
+ rotateAnchor,
394
+ cloneAnchor: getActionAnchor({ centerX, centerY, halfH, rotateAnchor }),
395
+ stemStart,
396
+ stemLengthPx: Math.hypot(rotateAnchor.x - stemStart.x, rotateAnchor.y - stemStart.y),
397
+ boxWidthPx: frame.width * sx,
398
+ boxHeightPx: frame.height * sy,
399
+ scaleHandles: getScaleHandleAnchors({
400
+ centerX,
401
+ centerY,
402
+ halfWpx: (frame.width / 2) * sx,
403
+ halfHpx: (frame.height / 2) * sy,
404
+ rotation,
405
+ }),
406
+ };
407
+ }
408
+
409
+ // Project the scale handles from the box's rotated corners/edges into screen px
410
+ // so they can render in the un-scaled button layer at a constant size. Corners
411
+ // are always shown; mid-edge handles are dropped once the box is too small along
412
+ // their axis (see EDGE_HANDLE_MIN_PX) so they don't overlap the corners.
413
+ function getScaleHandleAnchors({ centerX, centerY, halfWpx, halfHpx, rotation }) {
414
+ const rad = (rotation * Math.PI) / 180;
415
+ const cosR = Math.cos(rad);
416
+ const sinR = Math.sin(rad);
417
+ const boxWidthPx = halfWpx * 2;
418
+ const boxHeightPx = halfHpx * 2;
419
+ return SCALE_HANDLES.filter((handle) => {
420
+ if (handle.corner) return true;
421
+ // n/s run along the box's width; e/w run along its height.
422
+ return handle.x === 0
423
+ ? boxWidthPx >= EDGE_HANDLE_MIN_PX
424
+ : boxHeightPx >= EDGE_HANDLE_MIN_PX;
425
+ }).map((handle) => {
426
+ const lx = handle.x * halfWpx;
427
+ const ly = handle.y * halfHpx;
428
+ return {
429
+ ...handle,
430
+ x: centerX + lx * cosR - ly * sinR,
431
+ y: centerY + lx * sinR + ly * cosR,
432
+ };
433
+ });
434
+ }
435
+
436
+ function getRotateAnchor({ centerX, centerY, halfW, rotation }) {
437
+ const rad = (rotation * Math.PI) / 180;
438
+ const cosR = Math.cos(rad);
439
+ const sinR = Math.sin(rad);
440
+ const lx = halfW + GAP_PX;
441
+ return {
442
+ x: centerX + lx * cosR,
443
+ y: centerY + lx * sinR,
444
+ };
445
+ }
446
+
447
+ function getActionAnchor({ centerX, centerY, halfH, rotateAnchor }) {
448
+ const topY = centerY - halfH - ACTION_GAP_PX;
449
+ const bottomY = centerY + halfH + ACTION_GAP_PX;
450
+ const overlapsTop =
451
+ Math.abs(rotateAnchor.x - centerX) < GAP_PX * 1.9 &&
452
+ Math.abs(rotateAnchor.y - topY) < GAP_PX * 1.4;
453
+ return {
454
+ x: centerX,
455
+ y: overlapsTop ? bottomY : topY,
456
+ };
457
+ }
458
+
459
+ function getSelectedColliderFrames(sceneData, actorIds, sprites) {
460
+ if (!sceneData || !actorIds || actorIds.length === 0) return [];
461
+ const wanted = new Set(actorIds);
462
+ return sceneData.actors
463
+ .filter((actor) => wanted.has(actor.id))
464
+ .map((actor) => {
465
+ const layout = actor.components.Layout;
466
+ const collider = actor.components.Collider;
467
+ const geom = getColliderShape(actor, sprites);
468
+ if (!layout || !collider || !geom) return null;
469
+ // For a circle we render a radius-sized square with border-radius; for a
470
+ // box, the rect itself. Both come from the shared geometry so the preview
471
+ // matches the physics body exactly (shape, radius, offset, auto/manual).
472
+ const isCircle = geom.shape === 'circle';
473
+ const x = isCircle ? geom.cx - geom.radius : geom.x;
474
+ const y = isCircle ? geom.cy - geom.radius : geom.y;
475
+ const width = isCircle ? geom.radius * 2 : geom.width;
476
+ const height = isCircle ? geom.radius * 2 : geom.height;
477
+ return {
478
+ actorId: actor.id,
479
+ shape: geom.shape,
480
+ isTrigger: Boolean(collider.isTrigger) || collider.kind === 'pickup',
481
+ x,
482
+ y,
483
+ width,
484
+ height,
485
+ rotation: layout.rotation ?? 0,
486
+ originX: layout.x + layout.width / 2 - x,
487
+ originY: layout.y + layout.height / 2 - y,
488
+ };
489
+ })
490
+ .filter(Boolean);
491
+ }
492
+
493
+ function getSharedRotation(layouts) {
494
+ const first = normalizeAngle(layouts[0].rotation ?? 0);
495
+ return layouts.every((layout) => normalizeAngle(layout.rotation ?? 0) === first) ? first : null;
496
+ }
497
+
498
+ function getOrientedLayoutBounds(layouts, rotation) {
499
+ let minX = Infinity;
500
+ let minY = Infinity;
501
+ let maxX = -Infinity;
502
+ let maxY = -Infinity;
503
+ for (const layout of layouts) {
504
+ for (const corner of getLayoutWorldCorners(layout)) {
505
+ const local = rotateAroundOrigin(corner, -rotation);
506
+ minX = Math.min(minX, local.x);
507
+ minY = Math.min(minY, local.y);
508
+ maxX = Math.max(maxX, local.x);
509
+ maxY = Math.max(maxY, local.y);
510
+ }
511
+ }
512
+ const width = maxX - minX;
513
+ const height = maxY - minY;
514
+ const center = rotateAroundOrigin({ x: minX + width / 2, y: minY + height / 2 }, rotation);
515
+ return {
516
+ x: center.x - width / 2,
517
+ y: center.y - height / 2,
518
+ width,
519
+ height,
520
+ rotation,
521
+ };
522
+ }
523
+
524
+ function getLayoutWorldCorners(layout) {
525
+ const center = {
526
+ x: layout.x + layout.width / 2,
527
+ y: layout.y + layout.height / 2,
528
+ };
529
+ const halfW = layout.width / 2;
530
+ const halfH = layout.height / 2;
531
+ const rotation = layout.rotation ?? 0;
532
+ return [
533
+ { x: -halfW, y: -halfH },
534
+ { x: halfW, y: -halfH },
535
+ { x: halfW, y: halfH },
536
+ { x: -halfW, y: halfH },
537
+ ].map((corner) => {
538
+ const rotated = rotateAroundOrigin(corner, rotation);
539
+ return {
540
+ x: center.x + rotated.x,
541
+ y: center.y + rotated.y,
542
+ };
543
+ });
544
+ }
545
+
546
+ function rotateAroundOrigin(point, deg) {
547
+ const rad = (deg * Math.PI) / 180;
548
+ const cos = Math.cos(rad);
549
+ const sin = Math.sin(rad);
550
+ return {
551
+ x: point.x * cos - point.y * sin,
552
+ y: point.x * sin + point.y * cos,
553
+ };
554
+ }
555
+
556
+ function Floating({ x, y, children }) {
557
+ return (
558
+ <div className={styles.selBtnWrap} style={{ left: x, top: y }}>
559
+ {children}
560
+ </div>
561
+ );
562
+ }
563
+
564
+ function OverlayButton({ label, icon, onActivate, onPointerDown, extraClass, disabled = false }) {
565
+ return (
566
+ <button
567
+ type="button"
568
+ aria-label={label}
569
+ title={label}
570
+ disabled={disabled}
571
+ className={cx(styles.selBtn, extraClass)}
572
+ onPointerDown={(event) => {
573
+ event.stopPropagation();
574
+ if (disabled) return;
575
+ onPointerDown?.(event);
576
+ }}
577
+ onClick={(event) => {
578
+ event.stopPropagation();
579
+ if (disabled) return;
580
+ onActivate?.();
581
+ }}>
582
+ <Icon name={icon} />
583
+ </button>
584
+ );
585
+ }
586
+
587
+ // Shared pointer-drag scaffolding. `begin(downEvent)` runs on pointerdown and
588
+ // returns `{ onMove }` (or null to ignore). Window listeners + pointer capture
589
+ // are wired here so the move/rotate gestures only describe their own math.
590
+ function usePointerDragHandle(begin) {
591
+ const beginRef = useRef(begin);
592
+ beginRef.current = begin;
593
+ return useCallback((event) => {
594
+ const handlers = beginRef.current(event);
595
+ if (!handlers) return;
596
+ event.preventDefault();
597
+ try {
598
+ event.currentTarget.setPointerCapture(event.pointerId);
599
+ } catch {
600
+ // Pointer capture may be unavailable; window listeners still drive it.
601
+ }
602
+ const pointerId = event.pointerId;
603
+ const move = (moveEvent) => {
604
+ if (moveEvent.pointerId !== pointerId) return;
605
+ handlers.onMove(moveEvent);
606
+ };
607
+ const end = (endEvent) => {
608
+ if (endEvent.pointerId !== pointerId) return;
609
+ window.removeEventListener('pointermove', move);
610
+ window.removeEventListener('pointerup', end);
611
+ window.removeEventListener('pointercancel', end);
612
+ };
613
+ window.addEventListener('pointermove', move);
614
+ window.addEventListener('pointerup', end);
615
+ window.addEventListener('pointercancel', end);
616
+ }, []);
617
+ }
618
+
619
+ // Gate the commit on the gesture's snapped input (`key`), not on
620
+ // `next === sceneData`. During a drag the handlers compute `next` relative to
621
+ // the pointer-down `sceneData`, so returning the handle to its start value
622
+ // makes `next` identity-equal to that snapshot -- an identity guard would treat
623
+ // that as a no-op and skip `applyScene`, stranding the actor on the last grid
624
+ // step (the reason handles couldn't be dragged back to their origin). Keying on
625
+ // the snapped input re-applies whenever it changes, including back to the start
626
+ // key, so the origin is reachable. The identity check now only decides whether
627
+ // to record the undo snapshot, so a pure no-op drag never touches history. This
628
+ // mirrors the rotate handle's delta-keyed commit.
629
+ function applyDragResult(next, sceneData, key, state, recordSnapshot, applyScene) {
630
+ if (key === state.lastKey) return;
631
+ state.lastKey = key;
632
+ if (next !== sceneData && !state.recorded) {
633
+ recordSnapshot();
634
+ state.recorded = true;
635
+ }
636
+ applyScene(next);
637
+ }
638
+
639
+ function collectLayoutStarts(sceneData, actorIds) {
640
+ const starts = {};
641
+ const wanted = new Set(actorIds);
642
+ for (const actor of sceneData.actors) {
643
+ if (!wanted.has(actor.id)) continue;
644
+ const layout = actor.components.Layout;
645
+ if (layout) {
646
+ starts[actor.id] = {
647
+ x: layout.x,
648
+ y: layout.y,
649
+ width: layout.width,
650
+ height: layout.height,
651
+ rotation: layout.rotation ?? 0,
652
+ };
653
+ }
654
+ }
655
+ return starts;
656
+ }
657
+
658
+ function snapDelta(delta, snap) {
659
+ if (!snap || !snap.enabled) return delta;
660
+ return Math.round(delta / snap.gridSize) * snap.gridSize;
661
+ }
662
+
663
+ // Clone the RAW `sceneData` (the sparse write target) and patch the Layout
664
+ // override of every selected actor, but read each actor's CURRENT layout
665
+ // from `previewSceneData` (blueprint-merged) so `patch` sees real width/
666
+ // height/rotation even when an instance inherits them and has no Layout
667
+ // override of its own yet. `patch(layout, start)` returns the props to merge
668
+ // into that actor's Layout OVERRIDE (not the merged layout), or null to leave
669
+ // it untouched -- this is exactly the sparse-override write path (drag =
670
+ // x/y, always non-inherited; scale additionally promotes width/height to
671
+ // instance overrides). Returns the original `sceneData` when nothing changed
672
+ // so callers can skip a no-op commit.
673
+ function updateSelectedLayouts(sceneData, previewSceneData, actorIds, starts, patch) {
674
+ const wanted = new Set(actorIds);
675
+ const next = structuredClone(sceneData);
676
+ let changed = false;
677
+ for (const actor of next.actors) {
678
+ if (!wanted.has(actor.id)) continue;
679
+ const start = starts[actor.id];
680
+ const previewLayout = previewSceneData.actors.find((candidate) => candidate.id === actor.id)
681
+ ?.components?.Layout;
682
+ if (!start || !previewLayout) continue;
683
+ const props = patch(previewLayout, start);
684
+ if (!props) continue;
685
+ actor.components ??= {};
686
+ actor.components.Layout = { ...(actor.components.Layout ?? {}), ...props };
687
+ changed = true;
688
+ }
689
+ return changed ? next : sceneData;
690
+ }
691
+
692
+ function moveSelected(sceneData, previewSceneData, actorIds, starts, dx, dy) {
693
+ return updateSelectedLayouts(sceneData, previewSceneData, actorIds, starts, (layout, start) => {
694
+ const x = Math.round(start.x + dx);
695
+ const y = Math.round(start.y + dy);
696
+ return layout.x === x && layout.y === y ? null : { x, y };
697
+ });
698
+ }
699
+
700
+ // Common-pivot rotation: actor centers orbit the selection pivot while each
701
+ // actor also spins by the same delta, matching a temporary group transform.
702
+ function rotateSelected(sceneData, previewSceneData, actorIds, starts, pivot, deltaDeg) {
703
+ return updateSelectedLayouts(sceneData, previewSceneData, actorIds, starts, (layout, start) => {
704
+ const center = {
705
+ x: start.x + start.width / 2,
706
+ y: start.y + start.height / 2,
707
+ };
708
+ const nextCenter = rotatePoint(center, pivot, deltaDeg);
709
+ const x = Math.round(nextCenter.x - start.width / 2);
710
+ const y = Math.round(nextCenter.y - start.height / 2);
711
+ const rotation = normalizeAngle((start.rotation ?? 0) + deltaDeg);
712
+ if (layout.x === x && layout.y === y && (layout.rotation ?? 0) === rotation) return null;
713
+ return { x, y, rotation };
714
+ });
715
+ }
716
+
717
+ function scaleSelected(sceneData, previewSceneData, actorIds, starts, startTransform, nextLocalBounds) {
718
+ const startBounds = startTransform.localBounds;
719
+ const scaleX = startBounds.width === 0 ? 1 : nextLocalBounds.width / startBounds.width;
720
+ const scaleY = startBounds.height === 0 ? 1 : nextLocalBounds.height / startBounds.height;
721
+ return updateSelectedLayouts(sceneData, previewSceneData, actorIds, starts, (layout, start) => {
722
+ const startCenter = {
723
+ x: start.x + start.width / 2,
724
+ y: start.y + start.height / 2,
725
+ };
726
+ const localCenter = worldToLocal(startCenter, startTransform);
727
+ const nextLocalCenter = {
728
+ x: nextLocalBounds.left + (localCenter.x - startBounds.left) * scaleX,
729
+ y: nextLocalBounds.top + (localCenter.y - startBounds.top) * scaleY,
730
+ };
731
+ const nextCenter = localToWorld(nextLocalCenter, startTransform);
732
+ const width = Math.max(MIN_LAYOUT_SIZE, Math.round(start.width * scaleX));
733
+ const height = Math.max(MIN_LAYOUT_SIZE, Math.round(start.height * scaleY));
734
+ const x = Math.round(nextCenter.x - width / 2);
735
+ const y = Math.round(nextCenter.y - height / 2);
736
+ if (layout.x === x && layout.y === y && layout.width === width && layout.height === height) {
737
+ return null;
738
+ }
739
+ return { x, y, width, height };
740
+ });
741
+ }
742
+
743
+ function scaleBoundsFromHandle(bounds, handle, dx, dy, uniform = false) {
744
+ if (uniform) return uniformScaleBoundsFromHandle(bounds, handle, dx, dy);
745
+ let left = bounds.left;
746
+ let right = bounds.right;
747
+ let top = bounds.top;
748
+ let bottom = bounds.bottom;
749
+ if (handle.x < 0) left = clampMax(bounds.left + dx, right - MIN_LAYOUT_SIZE);
750
+ else if (handle.x > 0) right = clampMin(bounds.right + dx, left + MIN_LAYOUT_SIZE);
751
+ if (handle.y < 0) top = clampMax(bounds.top + dy, bottom - MIN_LAYOUT_SIZE);
752
+ else if (handle.y > 0) bottom = clampMin(bounds.bottom + dy, top + MIN_LAYOUT_SIZE);
753
+ return {
754
+ left,
755
+ right,
756
+ top,
757
+ bottom,
758
+ width: right - left,
759
+ height: bottom - top,
760
+ };
761
+ }
762
+
763
+ function uniformScaleBoundsFromHandle(bounds, handle, dx, dy) {
764
+ const raw = scaleBoundsFromHandle(bounds, handle, dx, dy);
765
+ const scale = uniformScaleForHandle(bounds, handle, raw);
766
+ return boundsFromUniformScale(bounds, handle, scale);
767
+ }
768
+
769
+ function uniformScaleForHandle(bounds, handle, raw) {
770
+ const scaleX = raw.width / bounds.width;
771
+ const scaleY = raw.height / bounds.height;
772
+ const minScale = Math.max(MIN_LAYOUT_SIZE / bounds.width, MIN_LAYOUT_SIZE / bounds.height);
773
+ if (handle.x === 0) return Math.max(scaleY, minScale);
774
+ if (handle.y === 0) return Math.max(scaleX, minScale);
775
+ const xDelta = Math.abs(scaleX - 1);
776
+ const yDelta = Math.abs(scaleY - 1);
777
+ return Math.max(xDelta >= yDelta ? scaleX : scaleY, minScale);
778
+ }
779
+
780
+ function boundsFromUniformScale(bounds, handle, scale) {
781
+ const width = bounds.width * scale;
782
+ const height = bounds.height * scale;
783
+ const centerX = (bounds.left + bounds.right) / 2;
784
+ const centerY = (bounds.top + bounds.bottom) / 2;
785
+ let left = centerX - width / 2;
786
+ let right = centerX + width / 2;
787
+ let top = centerY - height / 2;
788
+ let bottom = centerY + height / 2;
789
+ if (handle.x < 0) left = bounds.right - width;
790
+ else if (handle.x > 0) right = bounds.left + width;
791
+ if (handle.x !== 0) {
792
+ if (handle.x < 0) right = bounds.right;
793
+ else left = bounds.left;
794
+ }
795
+ if (handle.y < 0) top = bounds.bottom - height;
796
+ else if (handle.y > 0) bottom = bounds.top + height;
797
+ if (handle.y !== 0) {
798
+ if (handle.y < 0) bottom = bounds.bottom;
799
+ else top = bounds.top;
800
+ }
801
+ return {
802
+ left,
803
+ right,
804
+ top,
805
+ bottom,
806
+ width: right - left,
807
+ height: bottom - top,
808
+ };
809
+ }
810
+
811
+ function makeBoundsTransform(bounds, rotation) {
812
+ const pivot = {
813
+ x: bounds.x + bounds.width / 2,
814
+ y: bounds.y + bounds.height / 2,
815
+ };
816
+ const left = -bounds.width / 2;
817
+ const right = bounds.width / 2;
818
+ const top = -bounds.height / 2;
819
+ const bottom = bounds.height / 2;
820
+ return {
821
+ pivot,
822
+ rotation,
823
+ localBounds: {
824
+ left,
825
+ right,
826
+ top,
827
+ bottom,
828
+ width: bounds.width,
829
+ height: bounds.height,
830
+ },
831
+ };
832
+ }
833
+
834
+ function pointerLocal(canvas, editCameraRef, event, transform) {
835
+ const raw = screenToCard(canvas, event.clientX, event.clientY);
836
+ const cam = editCameraRef.current ?? { x: 0, y: 0 };
837
+ return worldToLocal({ x: raw.x + cam.x, y: raw.y + cam.y }, transform);
838
+ }
839
+
840
+ function worldToLocal(point, transform) {
841
+ const dx = point.x - transform.pivot.x;
842
+ const dy = point.y - transform.pivot.y;
843
+ const rad = (-transform.rotation * Math.PI) / 180;
844
+ const cos = Math.cos(rad);
845
+ const sin = Math.sin(rad);
846
+ return {
847
+ x: dx * cos - dy * sin,
848
+ y: dx * sin + dy * cos,
849
+ };
850
+ }
851
+
852
+ function localToWorld(point, transform) {
853
+ const rad = (transform.rotation * Math.PI) / 180;
854
+ const cos = Math.cos(rad);
855
+ const sin = Math.sin(rad);
856
+ return {
857
+ x: transform.pivot.x + point.x * cos - point.y * sin,
858
+ y: transform.pivot.y + point.x * sin + point.y * cos,
859
+ };
860
+ }
861
+
862
+ function rotatePoint(point, pivot, deltaDeg) {
863
+ const rad = (deltaDeg * Math.PI) / 180;
864
+ const cos = Math.cos(rad);
865
+ const sin = Math.sin(rad);
866
+ const dx = point.x - pivot.x;
867
+ const dy = point.y - pivot.y;
868
+ return {
869
+ x: pivot.x + dx * cos - dy * sin,
870
+ y: pivot.y + dx * sin + dy * cos,
871
+ };
872
+ }
873
+
874
+ function clampMin(value, min) {
875
+ return value < min ? min : value;
876
+ }
877
+
878
+ function clampMax(value, max) {
879
+ return value > max ? max : value;
880
+ }
881
+
882
+ function normalizeAngle(deg) {
883
+ const wrapped = ((deg % 360) + 360) % 360;
884
+ return Math.round(wrapped * 100) / 100;
885
+ }
886
+
887
+ function shortestAngleDelta(from, to) {
888
+ return ((((to - from) % 360) + 540) % 360) - 180;
889
+ }
890
+
891
+ // Snap a raw rotation delta (degrees from the angle at drag-start) to a value.
892
+ // The original (pre-drag) angle lives at delta 0, so a magnet band there lets
893
+ // the actor always return to exactly where it began -- independent of the
894
+ // 15-degree grid, which would otherwise skip an off-grid original. Outside the
895
+ // magnet, snap to the grid when enabled, else rotate freely (whole degrees).
896
+ function snapRotationDelta(delta, snapEnabled) {
897
+ if (Math.abs(delta) <= ROTATE_MAGNET) return 0;
898
+ if (!snapEnabled) return Math.round(delta);
899
+ return Math.round(delta / ROTATE_STEP) * ROTATE_STEP;
900
+ }
901
+
902
+ // Angle (degrees) of the pointer around `center`, in world card units.
903
+ function pointerAngle(canvas, editCameraRef, event, center) {
904
+ const raw = screenToCard(canvas, event.clientX, event.clientY);
905
+ const cam = editCameraRef.current ?? { x: 0, y: 0 };
906
+ const wx = raw.x + cam.x;
907
+ const wy = raw.y + cam.y;
908
+ return (Math.atan2(wy - center.y, wx - center.x) * 180) / Math.PI;
909
+ }