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.
- package/dist/ide.js +35 -13
- package/dist/shell/assets/{index-BOgm5T3W.js → index-BJLaUTJE.js} +21 -21
- package/dist/shell/index.html +1 -1
- package/kits/physics-2d/.prettierrc +8 -0
- package/kits/physics-2d/CLAUDE.md +329 -0
- package/kits/physics-2d/behaviors/Camera.jsx +43 -0
- package/kits/physics-2d/behaviors/Collider.jsx +199 -0
- package/kits/physics-2d/behaviors/Goal.jsx +29 -0
- package/kits/physics-2d/behaviors/Layout.jsx +53 -0
- package/kits/physics-2d/behaviors/Sprite.jsx +352 -0
- package/kits/physics-2d/behaviors/tint.js +47 -0
- package/kits/physics-2d/blueprints/ball.scene +14 -0
- package/kits/physics-2d/blueprints/block.scene +12 -0
- package/kits/physics-2d/blueprints/cauldron.scene +18 -0
- package/kits/physics-2d/blueprints/crate.scene +14 -0
- package/kits/physics-2d/blueprints/goal.scene +12 -0
- package/kits/physics-2d/castle.json +13 -0
- package/kits/physics-2d/docs/pxart-format.md +377 -0
- package/kits/physics-2d/drawings/block.pxart +25 -0
- package/kits/physics-2d/drawings/cauldron.pxart +113 -0
- package/kits/physics-2d/editors/BlueprintLibrary.jsx +247 -0
- package/kits/physics-2d/editors/ErrorBoundary.jsx +59 -0
- package/kits/physics-2d/editors/PlayOnly.jsx +31 -0
- package/kits/physics-2d/editors/PxArtEditor.jsx +954 -0
- package/kits/physics-2d/editors/SceneEditor.jsx +1681 -0
- package/kits/physics-2d/editors/SelectionOverlay.jsx +909 -0
- package/kits/physics-2d/editors/SingleEditor.jsx +122 -0
- package/kits/physics-2d/editors/behaviorRegistry.js +30 -0
- package/kits/physics-2d/editors/editorHistory.js +157 -0
- package/kits/physics-2d/editors/inspectorSheet.js +13 -0
- package/kits/physics-2d/editors/pixelCanvas.js +11 -0
- package/kits/physics-2d/editors/pixelEditorChrome.jsx +74 -0
- package/kits/physics-2d/editors/pixelGeometry.js +140 -0
- package/kits/physics-2d/editors/pixelInspector.jsx +633 -0
- package/kits/physics-2d/editors/pxArtEditorModel.js +732 -0
- package/kits/physics-2d/editors/pxArtPlayback.js +92 -0
- package/kits/physics-2d/editors/pxArtTimeline.jsx +752 -0
- package/kits/physics-2d/editors/pxArtTimeline.module.css +506 -0
- package/kits/physics-2d/editors/pxArtTools.js +232 -0
- package/kits/physics-2d/editors/useArtboardFit.js +102 -0
- package/kits/physics-2d/engine/ScenePlayer.jsx +196 -0
- package/kits/physics-2d/engine/SceneUI.jsx +59 -0
- package/kits/physics-2d/engine/assets.js +15 -0
- package/kits/physics-2d/engine/autoInspector.jsx +70 -0
- package/kits/physics-2d/engine/blueprint.js +521 -0
- package/kits/physics-2d/engine/collider.js +196 -0
- package/kits/physics-2d/engine/files.js +117 -0
- package/kits/physics-2d/engine/liveReload.js +88 -0
- package/kits/physics-2d/engine/pxart.js +1032 -0
- package/kits/physics-2d/engine/pxartSmooth.js +222 -0
- package/kits/physics-2d/engine/scene.js +686 -0
- package/kits/physics-2d/engine/spriteGeometry.js +32 -0
- package/kits/physics-2d/engine/ui.jsx +688 -0
- package/kits/physics-2d/engine/ui.module.css +2287 -0
- package/kits/physics-2d/eslint.config.js +71 -0
- package/kits/physics-2d/index.html +24 -0
- package/kits/physics-2d/main.jsx +24 -0
- package/kits/physics-2d/package-lock.json +2706 -0
- package/kits/physics-2d/package.json +42 -0
- package/kits/physics-2d/physics/PhysicsSystem.js +290 -0
- package/kits/physics-2d/physics/behaviors/AnalogStick.jsx +101 -0
- package/kits/physics-2d/physics/behaviors/Draggable.jsx +79 -0
- package/kits/physics-2d/physics/behaviors/RigidBody.jsx +55 -0
- package/kits/physics-2d/physics/behaviors/Slingshot.jsx +118 -0
- package/kits/physics-2d/physics/controls.js +79 -0
- package/kits/physics-2d/physics/index.js +26 -0
- package/kits/physics-2d/physics/matterBridge.js +126 -0
- package/kits/physics-2d/pnpm-lock.yaml +1761 -0
- package/kits/physics-2d/scenes/main.scene +12 -0
- package/kits/physics-2d/scenes/sandbox.scene +13 -0
- package/kits/physics-2d/scripts/draw.mjs +121 -0
- package/kits/physics-2d/vite.config.js +1 -0
- 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
|
+
}
|