castle-web-cli 0.4.129 → 0.4.130

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 (144) hide show
  1. package/dist/agent-prompts.js +4 -4
  2. package/dist/filesChanged.d.ts +2 -0
  3. package/dist/filesChanged.js +6 -2
  4. package/dist/ide.js +15 -4
  5. package/dist/importBrowse.js +1 -0
  6. package/dist/index.js +10 -4
  7. package/dist/init.js +63 -15
  8. package/dist/native/loop.js +1 -1
  9. package/dist/native/tools.js +1 -1
  10. package/dist/serve.d.ts +1 -0
  11. package/dist/serve.js +20 -1
  12. package/dist/shell/assets/index-BpOLUyVO.js +441 -0
  13. package/dist/shell/assets/{index-CAq6f9B5.css → index-D6K-0YDB.css} +1 -1
  14. package/dist/shell/index.html +2 -2
  15. package/kits/physics-2d/CLAUDE.md +9 -10
  16. package/kits/physics-2d/castle.json +1 -1
  17. package/kits/physics-2d/editors/PlayOnly.jsx +15 -6
  18. package/kits/physics-2d/editors/PxArtEditor.jsx +95 -31
  19. package/kits/physics-2d/editors/SceneEditor.jsx +5 -5
  20. package/kits/physics-2d/editors/SingleEditor.jsx +37 -8
  21. package/kits/physics-2d/editors/StyleEditor.jsx +7 -4
  22. package/kits/physics-2d/editors/pixelEditorChrome.jsx +7 -7
  23. package/kits/physics-2d/editors/pixelInspector.jsx +13 -7
  24. package/kits/physics-2d/editors/pxArtTimeline.jsx +29 -11
  25. package/kits/physics-2d/editors/pxArtTimeline.module.css +11 -0
  26. package/kits/physics-2d/engine/blueprint.js +15 -3
  27. package/kits/physics-2d/engine/liveReload.js +33 -21
  28. package/kits/physics-2d/engine/systemRegistry.js +1 -1
  29. package/kits/physics-2d/engine/ui.jsx +2 -2
  30. package/kits/physics-2d/engine/ui.module.css +42 -0
  31. package/kits/physics-3d/CLAUDE.md +109 -0
  32. package/kits/physics-3d/behaviors/Body.jsx +51 -0
  33. package/kits/physics-3d/behaviors/Door.jsx +31 -0
  34. package/kits/physics-3d/behaviors/Lookable.jsx +11 -0
  35. package/kits/physics-3d/behaviors/Model.jsx +14 -0
  36. package/kits/physics-3d/behaviors/Pickup.jsx +39 -0
  37. package/kits/physics-3d/behaviors/Player.jsx +172 -0
  38. package/kits/physics-3d/behaviors/Portal.jsx +19 -0
  39. package/kits/physics-3d/behaviors/Shape.jsx +75 -0
  40. package/kits/physics-3d/behaviors/Solid.jsx +11 -0
  41. package/kits/physics-3d/behaviors/Transform.jsx +24 -0
  42. package/kits/physics-3d/blueprints/barrel.scene +15 -0
  43. package/kits/physics-3d/blueprints/crate.scene +29 -0
  44. package/kits/physics-3d/blueprints/door.scene +35 -0
  45. package/kits/physics-3d/blueprints/gem.scene +26 -0
  46. package/kits/physics-3d/blueprints/pillar.scene +25 -0
  47. package/kits/physics-3d/blueprints/platform.scene +23 -0
  48. package/kits/physics-3d/blueprints/player.scene +29 -0
  49. package/kits/physics-3d/blueprints/portal.scene +21 -0
  50. package/kits/physics-3d/blueprints/rock.scene +14 -0
  51. package/kits/physics-3d/blueprints/statue.scene +26 -0
  52. package/kits/physics-3d/blueprints/tree.scene +14 -0
  53. package/kits/physics-3d/blueprints/wall.scene +26 -0
  54. package/kits/{basic-2d → physics-3d}/castle.json +23 -5
  55. package/kits/physics-3d/docs/pxmodel-format.md +111 -0
  56. package/kits/physics-3d/drawings/crate.pxart +26 -0
  57. package/kits/physics-3d/drawings/door-edge.pxart +26 -0
  58. package/kits/physics-3d/drawings/door.pxart +27 -0
  59. package/kits/physics-3d/drawings/face.pxart +26 -0
  60. package/kits/physics-3d/drawings/floor.pxart +27 -0
  61. package/kits/physics-3d/drawings/platform.pxart +27 -0
  62. package/kits/physics-3d/drawings/statue.pxart +27 -0
  63. package/kits/physics-3d/drawings/wall.pxart +27 -0
  64. package/kits/physics-3d/engine3d/PlayOnly3D.jsx +32 -0
  65. package/kits/physics-3d/engine3d/PxModelEditor.jsx +619 -0
  66. package/kits/physics-3d/engine3d/Scene3DEditor.jsx +618 -0
  67. package/kits/physics-3d/engine3d/Scene3DPlayer.jsx +309 -0
  68. package/kits/physics-3d/engine3d/editor3dData.js +114 -0
  69. package/kits/physics-3d/engine3d/editor3dInspector.jsx +404 -0
  70. package/kits/physics-3d/engine3d/editorChrome.jsx +218 -0
  71. package/kits/physics-3d/engine3d/editorWorld.js +249 -0
  72. package/kits/physics-3d/engine3d/materials.js +174 -0
  73. package/kits/physics-3d/engine3d/meshops.js +123 -0
  74. package/kits/physics-3d/engine3d/modelEditorWorld.js +439 -0
  75. package/kits/physics-3d/engine3d/modelFiles.js +139 -0
  76. package/kits/physics-3d/engine3d/pxmodel.js +265 -0
  77. package/kits/physics-3d/engine3d/thumbnails.js +137 -0
  78. package/kits/physics-3d/engine3d/world3d.js +216 -0
  79. package/kits/{basic-2d → physics-3d}/eslint.config.js +10 -25
  80. package/kits/physics-3d/index.html +20 -0
  81. package/kits/physics-3d/main.jsx +30 -0
  82. package/kits/physics-3d/models/barrel.pxmodel +10 -0
  83. package/kits/physics-3d/models/player.pxmodel +222 -0
  84. package/kits/physics-3d/models/rock.pxmodel +30 -0
  85. package/kits/physics-3d/models/tree.pxmodel +10 -0
  86. package/kits/{basic-2d → physics-3d}/package-lock.json +140 -119
  87. package/kits/{basic-2d → physics-3d}/package.json +11 -9
  88. package/kits/{basic-2d → physics-3d}/pnpm-lock.yaml +162 -137
  89. package/kits/physics-3d/scenes/main.scene +414 -0
  90. package/kits/physics-3d/scenes/model-lab.scene +127 -0
  91. package/kits/physics-3d/systems/physics3d.js +356 -0
  92. package/package.json +5 -2
  93. package/dist/shell/assets/index-DKu9ejyh.js +0 -436
  94. package/kits/basic-2d/CLAUDE.md +0 -221
  95. package/kits/basic-2d/behaviors/Camera.jsx +0 -43
  96. package/kits/basic-2d/behaviors/Collider.jsx +0 -213
  97. package/kits/basic-2d/behaviors/Layout.jsx +0 -53
  98. package/kits/basic-2d/behaviors/Sprite.jsx +0 -357
  99. package/kits/basic-2d/behaviors/tint.js +0 -47
  100. package/kits/basic-2d/blueprints/cauldron.scene +0 -20
  101. package/kits/basic-2d/docs/pxart-format.md +0 -377
  102. package/kits/basic-2d/drawings/cauldron.pxart +0 -113
  103. package/kits/basic-2d/editors/BlueprintLibrary.jsx +0 -270
  104. package/kits/basic-2d/editors/ErrorBoundary.jsx +0 -59
  105. package/kits/basic-2d/editors/PlayOnly.jsx +0 -31
  106. package/kits/basic-2d/editors/PxArtEditor.jsx +0 -1092
  107. package/kits/basic-2d/editors/SceneEditor.jsx +0 -1780
  108. package/kits/basic-2d/editors/SelectionOverlay.jsx +0 -909
  109. package/kits/basic-2d/editors/SingleEditor.jsx +0 -122
  110. package/kits/basic-2d/editors/behaviorRegistry.js +0 -34
  111. package/kits/basic-2d/editors/editorHistory.js +0 -157
  112. package/kits/basic-2d/editors/inspectorSheet.js +0 -13
  113. package/kits/basic-2d/editors/pixelCanvas.js +0 -11
  114. package/kits/basic-2d/editors/pixelEditorChrome.jsx +0 -74
  115. package/kits/basic-2d/editors/pixelGeometry.js +0 -140
  116. package/kits/basic-2d/editors/pixelInspector.jsx +0 -633
  117. package/kits/basic-2d/editors/pxArtEditorModel.js +0 -732
  118. package/kits/basic-2d/editors/pxArtPlayback.js +0 -92
  119. package/kits/basic-2d/editors/pxArtTimeline.jsx +0 -752
  120. package/kits/basic-2d/editors/pxArtTimeline.module.css +0 -506
  121. package/kits/basic-2d/editors/pxArtTools.js +0 -232
  122. package/kits/basic-2d/editors/useArtboardFit.js +0 -105
  123. package/kits/basic-2d/engine/ScenePlayer.jsx +0 -209
  124. package/kits/basic-2d/engine/SceneUI.jsx +0 -59
  125. package/kits/basic-2d/engine/assets.js +0 -15
  126. package/kits/basic-2d/engine/autoInspector.jsx +0 -70
  127. package/kits/basic-2d/engine/behaviorExtensions.js +0 -32
  128. package/kits/basic-2d/engine/blueprint.js +0 -557
  129. package/kits/basic-2d/engine/collider.js +0 -200
  130. package/kits/basic-2d/engine/files.js +0 -141
  131. package/kits/basic-2d/engine/liveReload.js +0 -88
  132. package/kits/basic-2d/engine/pxart.js +0 -1032
  133. package/kits/basic-2d/engine/pxartSmooth.js +0 -222
  134. package/kits/basic-2d/engine/scene.js +0 -696
  135. package/kits/basic-2d/engine/spriteGeometry.js +0 -32
  136. package/kits/basic-2d/engine/systemRegistry.js +0 -16
  137. package/kits/basic-2d/engine/ui.jsx +0 -695
  138. package/kits/basic-2d/engine/ui.module.css +0 -2287
  139. package/kits/basic-2d/index.html +0 -24
  140. package/kits/basic-2d/main.jsx +0 -24
  141. package/kits/basic-2d/scenes/main.scene +0 -16
  142. package/kits/basic-2d/scripts/draw.mjs +0 -121
  143. /package/kits/{basic-2d → physics-3d}/.prettierrc +0 -0
  144. /package/kits/{basic-2d → physics-3d}/vite.config.js +0 -0
@@ -0,0 +1,309 @@
1
+ // 3d scene player: mounts a SceneRuntime (from the imported physics-2d kit's
2
+ // engine -- the actor/behavior model is shared) against a three.js world.
3
+ // Behaviors update, the physics3d system steps rapier, then Transforms sync to
4
+ // three nodes and the composer renders. No game logic here.
5
+ import React, { useCallback, useEffect, useRef, useState } from 'react';
6
+ import { makeScene } from '@imports/castle.physics-2d/engine/scene';
7
+ import {
8
+ makeWorld3D,
9
+ resizeWorld,
10
+ syncActors,
11
+ renderWorld,
12
+ disposeWorld,
13
+ updatePhysicsDebug,
14
+ } from './world3d';
15
+
16
+ export function Scene3DPlayer({
17
+ sceneData,
18
+ sprites,
19
+ files,
20
+ behaviorClasses,
21
+ scenePath,
22
+ onFirstFrame,
23
+ }) {
24
+ const canvasRef = useRef(null);
25
+ const scoreRef = useRef(null);
26
+ const lookRef = useRef(null);
27
+ const [error, setError] = useState(null);
28
+ const [runId, setRunId] = useState(0);
29
+
30
+ useEffect(() => {
31
+ const canvas = canvasRef.current;
32
+ if (!canvas) return undefined;
33
+ const runtime = makeScene(sceneData, behaviorClasses, sprites, files);
34
+ const world = makeWorld3D(canvas);
35
+ runtime.three = world;
36
+ runtime.hud = {};
37
+ // Scene-independent state: survives `loadFromFile` (the scene data is
38
+ // replaced, the runtime instance is not). Player score/inventory and the
39
+ // camera angle live here so they cross scene switches.
40
+ runtime.persistent = {};
41
+ runtime.currentScenePath = scenePath ?? 'scenes/main.scene';
42
+
43
+ const detachInput = attachInput(canvas, runtime, world);
44
+ const stopResize = attachResize(canvas, world);
45
+ const onLoopError = (err) => {
46
+ console.error(err);
47
+ setError(err);
48
+ };
49
+ const hudRefs = { score: scoreRef, look: lookRef };
50
+ const stopLoop = startLoop(runtime, world, sprites, hudRefs, onFirstFrame, onLoopError);
51
+ return () => {
52
+ stopLoop();
53
+ stopResize();
54
+ detachInput();
55
+ runtime.dispose();
56
+ disposeWorld(world);
57
+ };
58
+ // eslint-disable-next-line react-hooks/exhaustive-deps
59
+ }, [runId]);
60
+
61
+ const onRestart = useCallback(() => {
62
+ setError(null);
63
+ setRunId((n) => n + 1);
64
+ }, []);
65
+
66
+ return (
67
+ <div style={{ position: 'fixed', inset: 0, background: '#000' }}>
68
+ <canvas
69
+ ref={canvasRef}
70
+ tabIndex={0}
71
+ style={{
72
+ width: '100%',
73
+ height: '100%',
74
+ display: 'block',
75
+ outline: 'none',
76
+ touchAction: 'none',
77
+ userSelect: 'none',
78
+ WebkitUserSelect: 'none',
79
+ WebkitTouchCallout: 'none',
80
+ }}
81
+ />
82
+ <div ref={scoreRef} style={hudStyles.score} />
83
+ <div ref={lookRef} style={hudStyles.look} />
84
+ {error ? <LoopErrorOverlay error={error} onRestart={onRestart} /> : null}
85
+ </div>
86
+ );
87
+ }
88
+
89
+ function attachInput(canvas, runtime, world) {
90
+ const onKeyDown = (event) => {
91
+ // Space is gameplay (jump); keep it from scrolling the page.
92
+ if (event.code === 'Space' && event.target === canvas) event.preventDefault();
93
+ if (event.code === 'KeyP' && !event.repeat) {
94
+ world.debugLines.visible = !world.debugLines.visible;
95
+ }
96
+ runtime.keys.add(event.code);
97
+ runtime.keys.add(event.key);
98
+ };
99
+ const onKeyUp = (event) => {
100
+ runtime.keys.delete(event.code);
101
+ runtime.keys.delete(event.key);
102
+ };
103
+ // Pointer (mouse or touch) input is multitouch, tracked per pointerId:
104
+ // - a DRAG is the virtual stick -- the offset from where the drag started
105
+ // maps to a walk direction (drag up = forward). The first pointer to
106
+ // travel past the tap slop owns the stick until it lifts.
107
+ // - a TAP (down and up quickly without moving much) is a jump, even while
108
+ // another finger is dragging. `moveInput.consumeJump()` hands the
109
+ // pending tap to the Player behavior exactly once.
110
+ // `runtime.moveInput` is what the Player behavior reads.
111
+ const STICK_RADIUS_PX = 56;
112
+ const TAP_SLOP_PX = 12;
113
+ const TAP_MS = 300;
114
+ const pointers = new Map();
115
+ let stickId = null;
116
+ let jumpPending = false;
117
+ const move = {
118
+ x: 0,
119
+ y: 0,
120
+ active: false,
121
+ consumeJump() {
122
+ const had = jumpPending;
123
+ jumpPending = false;
124
+ return had;
125
+ },
126
+ };
127
+ runtime.moveInput = move;
128
+ const clearStick = () => {
129
+ stickId = null;
130
+ move.active = false;
131
+ move.x = 0;
132
+ move.y = 0;
133
+ };
134
+ const onPointerDown = (event) => {
135
+ canvas.focus();
136
+ pointers.set(event.pointerId, {
137
+ x: event.clientX,
138
+ y: event.clientY,
139
+ time: performance.now(),
140
+ moved: false,
141
+ });
142
+ canvas.setPointerCapture(event.pointerId);
143
+ };
144
+ const onPointerMove = (event) => {
145
+ const p = pointers.get(event.pointerId);
146
+ if (!p) return;
147
+ const dx = event.clientX - p.x;
148
+ const dy = event.clientY - p.y;
149
+ if (!p.moved && Math.hypot(dx, dy) > TAP_SLOP_PX) p.moved = true;
150
+ // A pointer claims the stick once it is clearly a drag (and nothing else
151
+ // holds it) -- so a tapping finger never yanks the walk direction.
152
+ if (p.moved && stickId === null) stickId = event.pointerId;
153
+ if (stickId !== event.pointerId) return;
154
+ const nx = dx / STICK_RADIUS_PX;
155
+ const ny = dy / STICK_RADIUS_PX;
156
+ const len = Math.hypot(nx, ny);
157
+ const scale = len > 1 ? 1 / len : 1;
158
+ move.active = true;
159
+ move.x = nx * scale;
160
+ move.y = ny * scale;
161
+ };
162
+ const onPointerUp = (event) => {
163
+ const p = pointers.get(event.pointerId);
164
+ pointers.delete(event.pointerId);
165
+ if (stickId === event.pointerId) clearStick();
166
+ if (event.type === 'pointercancel' || !p) return;
167
+ if (!p.moved && performance.now() - p.time < TAP_MS) jumpPending = true;
168
+ };
169
+ window.addEventListener('keydown', onKeyDown);
170
+ window.addEventListener('keyup', onKeyUp);
171
+ canvas.addEventListener('pointerdown', onPointerDown);
172
+ canvas.addEventListener('pointermove', onPointerMove);
173
+ canvas.addEventListener('pointerup', onPointerUp);
174
+ canvas.addEventListener('pointercancel', onPointerUp);
175
+ if (document.hasFocus() || window.parent === window) canvas.focus();
176
+ return () => {
177
+ window.removeEventListener('keydown', onKeyDown);
178
+ window.removeEventListener('keyup', onKeyUp);
179
+ canvas.removeEventListener('pointerdown', onPointerDown);
180
+ canvas.removeEventListener('pointermove', onPointerMove);
181
+ canvas.removeEventListener('pointerup', onPointerUp);
182
+ canvas.removeEventListener('pointercancel', onPointerUp);
183
+ };
184
+ }
185
+
186
+ function attachResize(canvas, world) {
187
+ const fit = () => resizeWorld(world, canvas.clientWidth, canvas.clientHeight);
188
+ const observer = new ResizeObserver(fit);
189
+ observer.observe(canvas);
190
+ fit();
191
+ return () => observer.disconnect();
192
+ }
193
+
194
+ function startLoop(runtime, world, sprites, hudRefs, onFirstFrame, onError) {
195
+ let raf = 0;
196
+ let previousTime = performance.now();
197
+ let firstFrameSignaled = false;
198
+ const tick = (now) => {
199
+ const dt = Math.min(0.033, (now - previousTime) / 1000);
200
+ previousTime = now;
201
+ try {
202
+ runtime.update(dt);
203
+ // A Portal (or any behavior) may have requested a scene switch; apply
204
+ // it here, between frames, never mid physics dispatch. The load resets
205
+ // registered systems -- the rapier world frees and lazily rebuilds --
206
+ // while `runtime.persistent` carries the player's cross-scene state.
207
+ if (runtime.requestedScene) {
208
+ const next = runtime.requestedScene;
209
+ runtime.requestedScene = null;
210
+ runtime.loadFromFile(next);
211
+ runtime.currentScenePath = next;
212
+ runtime.persistent.portalGraceUntil = runtime.time + 1;
213
+ }
214
+ syncActors(world, runtime, sprites, dt);
215
+ if (world.debugLines.visible) updatePhysicsDebug(world, runtime.physics3d?.debugRender());
216
+ renderWorld(world);
217
+ updateHud(runtime, hudRefs);
218
+ } catch (err) {
219
+ onError?.(err);
220
+ return;
221
+ }
222
+ if (!firstFrameSignaled) {
223
+ firstFrameSignaled = true;
224
+ requestAnimationFrame(() => onFirstFrame?.());
225
+ }
226
+ raf = requestAnimationFrame(tick);
227
+ };
228
+ raf = requestAnimationFrame(tick);
229
+ return () => cancelAnimationFrame(raf);
230
+ }
231
+
232
+ // Behaviors write `scene.hud = { score, look }`; the loop pushes it into the
233
+ // overlay divs imperatively (no react re-render at frame rate).
234
+ function updateHud(runtime, hudRefs) {
235
+ const hud = runtime.hud ?? {};
236
+ const scoreText = hud.score ? String(hud.score) : '';
237
+ const lookText = hud.look ?? '';
238
+ if (hudRefs.score.current && hudRefs.score.current.textContent !== scoreText) {
239
+ hudRefs.score.current.textContent = scoreText;
240
+ }
241
+ if (hudRefs.look.current && hudRefs.look.current.textContent !== lookText) {
242
+ hudRefs.look.current.textContent = lookText;
243
+ }
244
+ }
245
+
246
+ const hudStyles = {
247
+ score: {
248
+ position: 'absolute',
249
+ top: 14,
250
+ left: 16,
251
+ color: '#7bd9ed',
252
+ font: "16px/1.4 'SFMono-Regular', Consolas, monospace",
253
+ textShadow: '0 1px 2px rgba(0,0,0,0.8)',
254
+ pointerEvents: 'none',
255
+ },
256
+ look: {
257
+ position: 'absolute',
258
+ bottom: 22,
259
+ left: 0,
260
+ right: 0,
261
+ textAlign: 'center',
262
+ color: '#e6e6e2',
263
+ fontStyle: 'italic',
264
+ font: 'italic 15px/1.4 Georgia, serif',
265
+ textShadow: '0 1px 3px rgba(0,0,0,0.9)',
266
+ pointerEvents: 'none',
267
+ },
268
+ };
269
+
270
+ function LoopErrorOverlay({ error, onRestart }) {
271
+ return (
272
+ <div
273
+ style={{
274
+ position: 'absolute',
275
+ inset: 0,
276
+ zIndex: 2147483000,
277
+ padding: '20px 22px',
278
+ background: 'rgba(20, 8, 8, 0.94)',
279
+ color: '#ffd9dc',
280
+ font: "13px/1.5 'SFMono-Regular', Consolas, monospace",
281
+ overflow: 'auto',
282
+ }}>
283
+ <div style={{ display: 'flex', gap: 10, alignItems: 'center', marginBottom: 12 }}>
284
+ <span style={{ fontSize: 15, fontWeight: 700, color: '#ff6b74' }}>Play error</span>
285
+ <button
286
+ type="button"
287
+ onClick={onRestart}
288
+ style={{
289
+ marginLeft: 'auto',
290
+ padding: '4px 12px',
291
+ border: '1px solid #a02828',
292
+ borderRadius: 4,
293
+ background: '#a02828',
294
+ color: '#fff',
295
+ font: 'inherit',
296
+ cursor: 'pointer',
297
+ }}>
298
+ Restart
299
+ </button>
300
+ </div>
301
+ <div style={{ color: '#fff', fontWeight: 600 }}>
302
+ {error?.name ?? 'Error'}: {error?.message ?? String(error)}
303
+ </div>
304
+ {typeof error?.stack === 'string' ? (
305
+ <pre style={{ whiteSpace: 'pre-wrap', color: '#e79aa0', fontSize: 12 }}>{error.stack}</pre>
306
+ ) : null}
307
+ </div>
308
+ );
309
+ }
@@ -0,0 +1,114 @@
1
+ // Data-side helpers for the 3d scene editor: the 3d counterparts of the
2
+ // imported engine's placement / duplication helpers (which write 2d Layout),
3
+ // plus the blueprint-file template-id shim. Everything here works on parsed
4
+ // scene JSON and returns fresh copies -- commits go through the editor's
5
+ // history.
6
+ import {
7
+ getBlueprintTemplate,
8
+ mintActorIdFromName,
9
+ mintBlueprintFile,
10
+ } from '@imports/castle.physics-2d/engine/blueprint';
11
+
12
+ export const TEMPLATE_ACTOR_ID = '__template__';
13
+
14
+ export const round2 = (v) => Math.round(v * 100) / 100;
15
+
16
+ // Grid snap for placements; matches the gizmo's 0.1m translation snap.
17
+ const snap = (v) => Math.round(v * 10) / 10;
18
+
19
+ // A blueprint file's template actor has no id on disk; inject a synthetic one
20
+ // so selection / inspector / gizmo code sees a normal actor, and strip it
21
+ // before every write.
22
+ export function withTemplateId(sceneData, isBlueprintFile) {
23
+ if (!sceneData || !isBlueprintFile) return sceneData;
24
+ const next = structuredClone(sceneData);
25
+ next.actors = next.actors ?? [];
26
+ if (next.actors[0]) next.actors[0].id = TEMPLATE_ACTOR_ID;
27
+ return next;
28
+ }
29
+
30
+ export function stripTemplateId(sceneData) {
31
+ const next = structuredClone(sceneData);
32
+ if (next.actors?.[0]?.id === TEMPLATE_ACTOR_ID) delete next.actors[0].id;
33
+ return next;
34
+ }
35
+
36
+ // Place a new instance of a blueprint at a ground point. Only Transform x/z
37
+ // are written on the instance -- height and everything else inherit.
38
+ export function placeBlueprintInstance3D(sceneData, files, blueprintPath, point) {
39
+ const next = structuredClone(sceneData);
40
+ next.actors = next.actors ?? [];
41
+ const existingIds = new Set(next.actors.map((actor) => actor.id));
42
+ const template = getBlueprintTemplate(files, blueprintPath);
43
+ const newId = mintActorIdFromName(existingIds, template?.name);
44
+ next.actors.push({
45
+ id: newId,
46
+ blueprint: blueprintPath,
47
+ components: { Transform: { x: snap(point.x), z: snap(point.z) } },
48
+ });
49
+ return { sceneData: next, newId };
50
+ }
51
+
52
+ // Duplicate selected actors with a 1m diagonal offset (the 3d version of the
53
+ // imported engine's duplicateActors, which offsets 2d Layout instead).
54
+ // `resolvedActors` supplies merged Transforms so an instance that inherits its
55
+ // position still lands next to the original.
56
+ export function duplicateActors3D(sceneData, actorIds, resolvedActors) {
57
+ const next = structuredClone(sceneData);
58
+ const existingIds = new Set(next.actors.map((actor) => actor.id));
59
+ const newIds = [];
60
+ for (const id of actorIds) {
61
+ const source = next.actors.find((actor) => actor.id === id);
62
+ if (!source) continue;
63
+ const copy = structuredClone(source);
64
+ copy.id = mintActorIdFromName(existingIds, id.replace(/-\d+$/, ''));
65
+ existingIds.add(copy.id);
66
+ const merged = resolvedActors?.find((actor) => actor.id === id)?.components?.Transform ?? {};
67
+ copy.components = {
68
+ ...(copy.components ?? {}),
69
+ Transform: {
70
+ ...(copy.components?.Transform ?? {}),
71
+ x: round2((merged.x ?? 0) + 1),
72
+ z: round2((merged.z ?? 0) + 1),
73
+ },
74
+ };
75
+ next.actors.push(copy);
76
+ newIds.push(copy.id);
77
+ }
78
+ return { sceneData: next, newIds };
79
+ }
80
+
81
+ // The hotbar's "+" slot: mint a fresh 3d blueprint file (a solid box) and
82
+ // place one instance of it at the origin.
83
+ export function addActorWithBlueprint3D(sceneData, files) {
84
+ const blueprintFile = mintBlueprintFile(files, {
85
+ Transform: { y: 0.5 },
86
+ Shape: { kind: 'box' },
87
+ Solid: {},
88
+ });
89
+ const filesWithNew = { ...files, [blueprintFile.path]: blueprintFile.text };
90
+ const { sceneData: next, newId } = placeBlueprintInstance3D(
91
+ sceneData,
92
+ filesWithNew,
93
+ blueprintFile.path,
94
+ { x: 0, z: 0 }
95
+ );
96
+ return { sceneData: next, newId, blueprintFile };
97
+ }
98
+
99
+ // A gizmo gesture's end state -> sparse Transform override patch. Position is
100
+ // always written (instance-local); rotation only when meaningful or already
101
+ // present.
102
+ export function transformPatch(existing, change) {
103
+ const next = {
104
+ ...(existing ?? {}),
105
+ x: round2(change.x),
106
+ y: round2(change.y),
107
+ z: round2(change.z),
108
+ };
109
+ for (const key of ['rotationX', 'rotationY', 'rotationZ']) {
110
+ const value = Math.round((change[key] ?? 0) * 10) / 10;
111
+ if (value !== 0 || (existing && existing[key] !== undefined)) next[key] = value;
112
+ }
113
+ return next;
114
+ }