castle-web-cli 0.4.11 → 0.4.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent-prompts.d.ts +31 -0
- package/dist/agent-prompts.js +100 -0
- package/dist/agent.d.ts +17 -0
- package/dist/agent.js +894 -0
- package/dist/chat-client.d.ts +1 -0
- package/dist/chat-client.js +398 -0
- package/dist/commonInstructions.d.ts +1 -0
- package/dist/commonInstructions.js +8 -0
- package/dist/ide-client.js +46 -14
- package/dist/ide.d.ts +2 -0
- package/dist/ide.js +321 -36
- package/dist/init.js +12 -1
- package/dist/serve.js +18 -3
- package/kits/basic-2d/CLAUDE.md +3 -1
- package/kits/basic-3d/.prettierrc +8 -0
- package/kits/basic-3d/CLAUDE.md +162 -0
- package/kits/basic-3d/behaviors/Camera.jsx +56 -0
- package/kits/basic-3d/behaviors/Collider.jsx +78 -0
- package/kits/basic-3d/behaviors/Mesh.jsx +82 -0
- package/kits/basic-3d/behaviors/Model.jsx +61 -0
- package/kits/basic-3d/behaviors/Transform.jsx +35 -0
- package/kits/basic-3d/editors/App.jsx +147 -0
- package/kits/basic-3d/editors/CodeEditor.jsx +112 -0
- package/kits/basic-3d/editors/FileBrowser.jsx +143 -0
- package/kits/basic-3d/editors/ModelEditor.jsx +400 -0
- package/kits/basic-3d/editors/PlayOnly.jsx +14 -0
- package/kits/basic-3d/editors/SceneEditor.jsx +1087 -0
- package/kits/basic-3d/editors/behaviorRegistry.js +24 -0
- package/kits/basic-3d/editors/editorHistory.js +52 -0
- package/kits/basic-3d/editors/viewportRig.js +90 -0
- package/kits/basic-3d/engine/ScenePlayer.jsx +55 -0
- package/kits/basic-3d/engine/SceneUI.jsx +67 -0
- package/kits/basic-3d/engine/SceneViewport.jsx +102 -0
- package/kits/basic-3d/engine/TouchControls.jsx +136 -0
- package/kits/basic-3d/engine/autoInspector.jsx +51 -0
- package/kits/basic-3d/engine/files.js +73 -0
- package/kits/basic-3d/engine/scene.js +502 -0
- package/kits/basic-3d/engine/threeUtil.js +260 -0
- package/kits/basic-3d/engine/ui.jsx +352 -0
- package/kits/basic-3d/engine/ui.module.css +944 -0
- package/kits/basic-3d/eslint.config.js +51 -0
- package/kits/basic-3d/index.html +11 -0
- package/kits/basic-3d/main.jsx +10 -0
- package/kits/basic-3d/models/block.model +14 -0
- package/kits/basic-3d/package-lock.json +2713 -0
- package/kits/basic-3d/package.json +41 -0
- package/kits/basic-3d/scenes/main.scene +76 -0
- package/kits/basic-3d/vite.config.js +1 -0
- package/package.json +6 -1
|
@@ -0,0 +1,502 @@
|
|
|
1
|
+
import * as THREE from 'three';
|
|
2
|
+
import { applyLightingSettings, createSceneLights, disposeObject3D } from './threeUtil';
|
|
3
|
+
|
|
4
|
+
// UI overlay design space (see SceneUI) -- world units are separate and
|
|
5
|
+
// unbounded; the ground plane is XZ with +Y up.
|
|
6
|
+
const CARD_WIDTH = 500;
|
|
7
|
+
const CARD_HEIGHT = 700;
|
|
8
|
+
|
|
9
|
+
export const cardSize = { width: CARD_WIDTH, height: CARD_HEIGHT };
|
|
10
|
+
|
|
11
|
+
// View used when no Camera behavior runs (and for a scene's first frame).
|
|
12
|
+
export const defaultCameraSpec = {
|
|
13
|
+
targetX: 0,
|
|
14
|
+
targetY: 0,
|
|
15
|
+
targetZ: 0,
|
|
16
|
+
azimuth: 45,
|
|
17
|
+
elevation: 35,
|
|
18
|
+
distance: 24,
|
|
19
|
+
fov: 50,
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
// The view a scene's own Camera behavior would start play with -- the editor
|
|
23
|
+
// initializes its edit camera from this so edit and play share a vibe. Null
|
|
24
|
+
// when the scene has no Camera actor.
|
|
25
|
+
export function sceneCameraSpec(sceneData) {
|
|
26
|
+
for (const actor of sceneData?.actors ?? []) {
|
|
27
|
+
const camera = actor.components?.Camera;
|
|
28
|
+
if (!camera) continue;
|
|
29
|
+
const target = camera.target
|
|
30
|
+
? (sceneData.actors ?? []).find((candidate) => candidate.id === camera.target)
|
|
31
|
+
: null;
|
|
32
|
+
const transform = target?.components?.Transform;
|
|
33
|
+
return {
|
|
34
|
+
targetX: transform?.x ?? 0,
|
|
35
|
+
targetY: (transform?.y ?? 0) + (camera.lookHeight ?? 0),
|
|
36
|
+
targetZ: transform?.z ?? 0,
|
|
37
|
+
azimuth: camera.azimuth ?? defaultCameraSpec.azimuth,
|
|
38
|
+
elevation: camera.elevation ?? defaultCameraSpec.elevation,
|
|
39
|
+
distance: camera.distance ?? defaultCameraSpec.distance,
|
|
40
|
+
fov: camera.fov ?? defaultCameraSpec.fov,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const raycaster = new THREE.Raycaster();
|
|
47
|
+
const groundPlane = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0);
|
|
48
|
+
|
|
49
|
+
export class SceneRuntime {
|
|
50
|
+
constructor(sceneData, behaviors, models) {
|
|
51
|
+
this.behaviors = new Map(behaviors.map((Behavior) => [Behavior.behaviorName, Behavior]));
|
|
52
|
+
this.models = models;
|
|
53
|
+
this.time = 0;
|
|
54
|
+
this.keys = new Set();
|
|
55
|
+
this.pointer = { x: 0, y: 0, down: false, ground: null };
|
|
56
|
+
this.data = { actors: [] };
|
|
57
|
+
this.actors = new Map();
|
|
58
|
+
this.camera = undefined;
|
|
59
|
+
this.status = undefined;
|
|
60
|
+
// Three-side state. `three` is the retained scene graph; behaviors attach
|
|
61
|
+
// objects under their actor's group (see `actorGroup`).
|
|
62
|
+
this.three = new THREE.Scene();
|
|
63
|
+
this.actorGroups = new Map();
|
|
64
|
+
this.lights = createSceneLights();
|
|
65
|
+
this.three.add(this.lights.group);
|
|
66
|
+
this.grid = new THREE.GridHelper(100, 100, 0x666666, 0x333333);
|
|
67
|
+
this.grid.visible = false;
|
|
68
|
+
this.three.add(this.grid);
|
|
69
|
+
this.selectionBoxes = new Map();
|
|
70
|
+
this.placeholders = new Map();
|
|
71
|
+
// Set by the active viewport every frame; used for pointer raycasts.
|
|
72
|
+
this.activeCamera = null;
|
|
73
|
+
this.load(sceneData);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
load(sceneData) {
|
|
77
|
+
const next = structuredClone(sceneData);
|
|
78
|
+
const nextIds = new Set((next.actors ?? []).map((actor) => actor.id));
|
|
79
|
+
// Reconcile instead of rebuild: keep groups + `runtime` caches for actors
|
|
80
|
+
// that survive so per-frame mesh caches don't churn on every edit.
|
|
81
|
+
for (const [id] of this.actors) {
|
|
82
|
+
if (!nextIds.has(id)) this.removeActorGroup(id);
|
|
83
|
+
}
|
|
84
|
+
const previousActors = this.actors;
|
|
85
|
+
this.data = next;
|
|
86
|
+
this.actors = new Map();
|
|
87
|
+
for (const actor of this.data.actors ?? []) {
|
|
88
|
+
const previous = previousActors.get(actor.id);
|
|
89
|
+
actor.runtime = previous?.runtime ?? {};
|
|
90
|
+
this.actors.set(actor.id, actor);
|
|
91
|
+
for (const [behaviorName, Behavior] of this.behaviors) {
|
|
92
|
+
const component = actor.components[behaviorName];
|
|
93
|
+
if (component) {
|
|
94
|
+
actor.components[behaviorName] = {
|
|
95
|
+
...Behavior.defaultProps,
|
|
96
|
+
...component,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
this.ensureActorGroup(actor.id);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
dispose() {
|
|
105
|
+
for (const id of [...this.actorGroups.keys()]) this.removeActorGroup(id);
|
|
106
|
+
for (const [id] of this.selectionBoxes) this.removeSelectionBox(id);
|
|
107
|
+
disposeObject3D(this.grid);
|
|
108
|
+
disposeObject3D(this.lights.group);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
ensureActorGroup(actorId) {
|
|
112
|
+
let group = this.actorGroups.get(actorId);
|
|
113
|
+
if (!group) {
|
|
114
|
+
group = new THREE.Group();
|
|
115
|
+
group.userData.actorId = actorId;
|
|
116
|
+
this.actorGroups.set(actorId, group);
|
|
117
|
+
this.three.add(group);
|
|
118
|
+
}
|
|
119
|
+
return group;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
removeActorGroup(actorId) {
|
|
123
|
+
const group = this.actorGroups.get(actorId);
|
|
124
|
+
if (!group) return;
|
|
125
|
+
this.three.remove(group);
|
|
126
|
+
disposeObject3D(group);
|
|
127
|
+
this.actorGroups.delete(actorId);
|
|
128
|
+
this.removeSelectionBox(actorId);
|
|
129
|
+
this.removePlaceholder(actorId);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// The actor's retained THREE.Group. Behaviors attach their objects here
|
|
133
|
+
// from `sync` and cache them on `actor.runtime`.
|
|
134
|
+
actorGroup(actorOrId) {
|
|
135
|
+
const id = typeof actorOrId === 'string' ? actorOrId : actorOrId?.id;
|
|
136
|
+
if (id == null || !this.actors.has(id)) return null;
|
|
137
|
+
return this.ensureActorGroup(id);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
clone() {
|
|
141
|
+
return new SceneRuntime(this.serialize(), [...this.behaviors.values()], this.models);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
serialize() {
|
|
145
|
+
const data = structuredClone(this.data);
|
|
146
|
+
for (const actor of data.actors ?? []) delete actor.runtime;
|
|
147
|
+
return data;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
getActor(actorId) {
|
|
151
|
+
return this.actors.get(actorId);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
getActors() {
|
|
155
|
+
return [...this.actors.values()];
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
getComponent(actor, behaviorName) {
|
|
159
|
+
return actor?.components?.[behaviorName] ?? null;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
actorWith(behaviorName) {
|
|
163
|
+
for (const actor of this.actors.values()) {
|
|
164
|
+
if (actor.components?.[behaviorName]) return actor;
|
|
165
|
+
}
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
actorsWith(behaviorName) {
|
|
170
|
+
const out = [];
|
|
171
|
+
for (const actor of this.getActors()) {
|
|
172
|
+
if (actor.components?.[behaviorName]) out.push(actor);
|
|
173
|
+
}
|
|
174
|
+
return out;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Center-based axis-aligned box from Transform + Collider, or null.
|
|
178
|
+
colliderBox(actorOrId) {
|
|
179
|
+
const actor = typeof actorOrId === 'string' ? this.getActor(actorOrId) : actorOrId;
|
|
180
|
+
return getColliderBox(actor);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
overlaps(first, second) {
|
|
184
|
+
const a = this.colliderBox(first);
|
|
185
|
+
const b = this.colliderBox(second);
|
|
186
|
+
return Boolean(a && b && intersectsBox(a, b));
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
spawnActor(actor) {
|
|
190
|
+
const id = actor.id ?? mintActorId(new Set(this.actors.keys()));
|
|
191
|
+
const components = {};
|
|
192
|
+
for (const [name, props] of Object.entries(actor.components ?? {})) {
|
|
193
|
+
const Behavior = this.behaviors.get(name);
|
|
194
|
+
components[name] = Behavior ? { ...Behavior.defaultProps, ...props } : { ...props };
|
|
195
|
+
}
|
|
196
|
+
const next = { ...actor, id, components, runtime: {} };
|
|
197
|
+
this.data.actors.push(next);
|
|
198
|
+
this.actors.set(id, next);
|
|
199
|
+
this.ensureActorGroup(id);
|
|
200
|
+
return next;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
despawnActor(actorId) {
|
|
204
|
+
const actor = this.actors.get(actorId);
|
|
205
|
+
if (!actor) return false;
|
|
206
|
+
this.actors.delete(actorId);
|
|
207
|
+
const idx = this.data.actors.findIndex((a) => a.id === actorId);
|
|
208
|
+
if (idx !== -1) this.data.actors.splice(idx, 1);
|
|
209
|
+
this.removeActorGroup(actorId);
|
|
210
|
+
return true;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Translate a screen-space pointer event into the scene's `pointer`. `x`/`y`
|
|
214
|
+
// are normalized device coordinates (-1..1); `ground` is the pointer ray's
|
|
215
|
+
// hit on the y=0 plane in world units (null when looking away from it).
|
|
216
|
+
// Behaviors read `scene.pointer` -- the runtime owns this mapping so the
|
|
217
|
+
// standalone player and the editor's play mode agree on it.
|
|
218
|
+
setPointerFromScreen(canvas, clientX, clientY, down) {
|
|
219
|
+
const rect = canvas.getBoundingClientRect();
|
|
220
|
+
this.pointer.x = ((clientX - rect.left) / rect.width) * 2 - 1;
|
|
221
|
+
this.pointer.y = -(((clientY - rect.top) / rect.height) * 2 - 1);
|
|
222
|
+
this.pointer.ground = this.raycastGround(this.pointer.x, this.pointer.y);
|
|
223
|
+
if (down !== undefined) this.pointer.down = down;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
raycastGround(ndcX, ndcY) {
|
|
227
|
+
if (!this.activeCamera) return null;
|
|
228
|
+
raycaster.setFromCamera({ x: ndcX, y: ndcY }, this.activeCamera);
|
|
229
|
+
const hit = new THREE.Vector3();
|
|
230
|
+
if (!raycaster.ray.intersectPlane(groundPlane, hit)) return null;
|
|
231
|
+
return { x: hit.x, z: hit.z };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
update(dt) {
|
|
235
|
+
this.time += dt;
|
|
236
|
+
for (const actor of this.getActors()) {
|
|
237
|
+
this.forEachBehavior(actor, (instance) => instance.update?.(actor, this, dt));
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
forEachBehavior(actor, callback) {
|
|
242
|
+
for (const [behaviorName, props] of Object.entries(actor.components)) {
|
|
243
|
+
if (!props) continue;
|
|
244
|
+
const Behavior = this.behaviors.get(behaviorName);
|
|
245
|
+
if (!Behavior) continue;
|
|
246
|
+
callback(new Behavior(props));
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Per-frame scene-graph sync -- the retained-mode analog of the 2d kit's
|
|
251
|
+
// `draw`. Runs every behavior's `sync`, then editor overlays.
|
|
252
|
+
syncFrame(dt = 0, options = {}) {
|
|
253
|
+
this.three.background = new THREE.Color(this.data.background ?? '#10131c');
|
|
254
|
+
applyLightingSettings(this.lights, this.data.lighting);
|
|
255
|
+
this.grid.visible = !!options.showGrid;
|
|
256
|
+
for (const actor of this.getActors()) {
|
|
257
|
+
this.forEachBehavior(actor, (instance) => instance.sync?.(actor, this, dt, options));
|
|
258
|
+
}
|
|
259
|
+
this.syncPlaceholders(options.editPlaceholders);
|
|
260
|
+
this.syncSelectionBoxes(options.selectedActorIds ?? []);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Faint wireframe boxes standing in for actors with nothing visible, edit
|
|
264
|
+
// mode only -- so empty/logic actors stay selectable.
|
|
265
|
+
syncPlaceholders(enabled) {
|
|
266
|
+
const needed = new Set();
|
|
267
|
+
if (enabled) {
|
|
268
|
+
for (const actor of this.getActors()) {
|
|
269
|
+
const group = this.actorGroups.get(actor.id);
|
|
270
|
+
const transform = actor.components.Transform;
|
|
271
|
+
if (!group || !transform) continue;
|
|
272
|
+
if (group.children.some((child) => !child.userData.editorPlaceholder)) continue;
|
|
273
|
+
needed.add(actor.id);
|
|
274
|
+
let placeholder = this.placeholders.get(actor.id);
|
|
275
|
+
if (!placeholder) {
|
|
276
|
+
placeholder = makePlaceholderBox();
|
|
277
|
+
this.placeholders.set(actor.id, placeholder);
|
|
278
|
+
group.add(placeholder);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
for (const [id] of this.placeholders) {
|
|
283
|
+
if (!needed.has(id)) this.removePlaceholder(id);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
removePlaceholder(actorId) {
|
|
288
|
+
const placeholder = this.placeholders.get(actorId);
|
|
289
|
+
if (!placeholder) return;
|
|
290
|
+
placeholder.parent?.remove(placeholder);
|
|
291
|
+
disposeObject3D(placeholder);
|
|
292
|
+
this.placeholders.delete(actorId);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
syncSelectionBoxes(selectedActorIds) {
|
|
296
|
+
const selected = new Set(selectedActorIds);
|
|
297
|
+
for (const [id] of this.selectionBoxes) {
|
|
298
|
+
if (!selected.has(id)) this.removeSelectionBox(id);
|
|
299
|
+
}
|
|
300
|
+
for (const id of selected) {
|
|
301
|
+
const group = this.actorGroups.get(id);
|
|
302
|
+
if (!group) continue;
|
|
303
|
+
let box = this.selectionBoxes.get(id);
|
|
304
|
+
if (!box) {
|
|
305
|
+
box = new THREE.BoxHelper(group, 0xffffff);
|
|
306
|
+
box.userData.editorOverlay = true;
|
|
307
|
+
this.selectionBoxes.set(id, box);
|
|
308
|
+
this.three.add(box);
|
|
309
|
+
}
|
|
310
|
+
box.setFromObject(group);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
removeSelectionBox(actorId) {
|
|
315
|
+
const box = this.selectionBoxes.get(actorId);
|
|
316
|
+
if (!box) return;
|
|
317
|
+
this.three.remove(box);
|
|
318
|
+
disposeObject3D(box);
|
|
319
|
+
this.selectionBoxes.delete(actorId);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// Topmost actor under a screen point, by raycast. Used for edit-mode
|
|
323
|
+
// selection -- the 3d analog of the 2d kit's `actorAt`.
|
|
324
|
+
actorAtScreen(canvas, clientX, clientY) {
|
|
325
|
+
if (!this.activeCamera) return null;
|
|
326
|
+
const rect = canvas.getBoundingClientRect();
|
|
327
|
+
const ndcX = ((clientX - rect.left) / rect.width) * 2 - 1;
|
|
328
|
+
const ndcY = -(((clientY - rect.top) / rect.height) * 2 - 1);
|
|
329
|
+
raycaster.setFromCamera({ x: ndcX, y: ndcY }, this.activeCamera);
|
|
330
|
+
const groups = [...this.actorGroups.values()];
|
|
331
|
+
const hits = raycaster.intersectObjects(groups, true);
|
|
332
|
+
for (const hit of hits) {
|
|
333
|
+
let object = hit.object;
|
|
334
|
+
while (object && object.userData.actorId == null) object = object.parent;
|
|
335
|
+
if (object) return this.getActor(object.userData.actorId) ?? null;
|
|
336
|
+
}
|
|
337
|
+
return null;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// Actors whose projected bounding box overlaps a screen-space rect (rect in
|
|
341
|
+
// canvas client pixels). Used for marquee selection.
|
|
342
|
+
actorIdsInScreenRect(canvas, rect) {
|
|
343
|
+
if (!this.activeCamera) return [];
|
|
344
|
+
const canvasRect = canvas.getBoundingClientRect();
|
|
345
|
+
const ids = [];
|
|
346
|
+
const box = new THREE.Box3();
|
|
347
|
+
const corner = new THREE.Vector3();
|
|
348
|
+
for (const [id, group] of this.actorGroups) {
|
|
349
|
+
box.setFromObject(group);
|
|
350
|
+
if (box.isEmpty()) continue;
|
|
351
|
+
let minX = Infinity;
|
|
352
|
+
let minY = Infinity;
|
|
353
|
+
let maxX = -Infinity;
|
|
354
|
+
let maxY = -Infinity;
|
|
355
|
+
for (let i = 0; i < 8; i++) {
|
|
356
|
+
corner.set(
|
|
357
|
+
i & 1 ? box.max.x : box.min.x,
|
|
358
|
+
i & 2 ? box.max.y : box.min.y,
|
|
359
|
+
i & 4 ? box.max.z : box.min.z
|
|
360
|
+
);
|
|
361
|
+
corner.project(this.activeCamera);
|
|
362
|
+
const sx = ((corner.x + 1) / 2) * canvasRect.width;
|
|
363
|
+
const sy = ((1 - corner.y) / 2) * canvasRect.height;
|
|
364
|
+
minX = Math.min(minX, sx);
|
|
365
|
+
minY = Math.min(minY, sy);
|
|
366
|
+
maxX = Math.max(maxX, sx);
|
|
367
|
+
maxY = Math.max(maxY, sy);
|
|
368
|
+
}
|
|
369
|
+
const overlapsRect =
|
|
370
|
+
maxX >= rect.x &&
|
|
371
|
+
minX <= rect.x + rect.width &&
|
|
372
|
+
maxY >= rect.y &&
|
|
373
|
+
minY <= rect.y + rect.height;
|
|
374
|
+
if (overlapsRect) ids.push(id);
|
|
375
|
+
}
|
|
376
|
+
return ids;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
export function makeScene(sceneData, behaviors, models) {
|
|
381
|
+
return new SceneRuntime(sceneData, behaviors, models);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function makePlaceholderBox() {
|
|
385
|
+
const geometry = new THREE.BoxGeometry(1, 1, 1);
|
|
386
|
+
const edges = new THREE.EdgesGeometry(geometry);
|
|
387
|
+
geometry.dispose();
|
|
388
|
+
const material = new THREE.LineBasicMaterial({
|
|
389
|
+
color: 0xffffff,
|
|
390
|
+
transparent: true,
|
|
391
|
+
opacity: 0.3,
|
|
392
|
+
});
|
|
393
|
+
const lines = new THREE.LineSegments(edges, material);
|
|
394
|
+
lines.userData.editorPlaceholder = true;
|
|
395
|
+
return lines;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
export function setActorComponent(sceneData, actorId, behaviorName, nextProps) {
|
|
399
|
+
const next = structuredClone(sceneData);
|
|
400
|
+
const actor = next.actors.find((candidate) => candidate.id === actorId);
|
|
401
|
+
if (!actor) return sceneData;
|
|
402
|
+
actor.components[behaviorName] = {
|
|
403
|
+
...(actor.components[behaviorName] ?? {}),
|
|
404
|
+
...nextProps,
|
|
405
|
+
};
|
|
406
|
+
return next;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
export function removeActorComponent(sceneData, actorId, behaviorName) {
|
|
410
|
+
const next = structuredClone(sceneData);
|
|
411
|
+
const actor = next.actors.find((candidate) => candidate.id === actorId);
|
|
412
|
+
if (!actor || !(behaviorName in actor.components)) return sceneData;
|
|
413
|
+
delete actor.components[behaviorName];
|
|
414
|
+
return next;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
export function removeActors(sceneData, actorIds) {
|
|
418
|
+
if (actorIds.length === 0) return sceneData;
|
|
419
|
+
const toRemove = new Set(actorIds);
|
|
420
|
+
const next = structuredClone(sceneData);
|
|
421
|
+
next.actors = next.actors.filter((actor) => !toRemove.has(actor.id));
|
|
422
|
+
return next.actors.length === sceneData.actors.length ? sceneData : next;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
export function duplicateActors(sceneData, actorIds) {
|
|
426
|
+
if (actorIds.length === 0) return { sceneData, newIds: [] };
|
|
427
|
+
const next = structuredClone(sceneData);
|
|
428
|
+
const existingIds = new Set(next.actors.map((actor) => actor.id));
|
|
429
|
+
const newIds = [];
|
|
430
|
+
for (const id of actorIds) {
|
|
431
|
+
const source = next.actors.find((candidate) => candidate.id === id);
|
|
432
|
+
if (!source) continue;
|
|
433
|
+
const copy = structuredClone(source);
|
|
434
|
+
delete copy.runtime;
|
|
435
|
+
copy.id = mintActorId(existingIds);
|
|
436
|
+
existingIds.add(copy.id);
|
|
437
|
+
const transform = copy.components.Transform;
|
|
438
|
+
if (transform) {
|
|
439
|
+
copy.components.Transform = {
|
|
440
|
+
...transform,
|
|
441
|
+
x: roundUnits((transform.x ?? 0) + 1),
|
|
442
|
+
z: roundUnits((transform.z ?? 0) + 1),
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
next.actors.push(copy);
|
|
446
|
+
newIds.push(copy.id);
|
|
447
|
+
}
|
|
448
|
+
return { sceneData: next, newIds };
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
export function addActor(sceneData, _files, models) {
|
|
452
|
+
const next = structuredClone(sceneData);
|
|
453
|
+
const existingIds = new Set(next.actors.map((actor) => actor.id));
|
|
454
|
+
const id = mintActorId(existingIds);
|
|
455
|
+
const components = {
|
|
456
|
+
Transform: { x: 0, y: 0.5, z: 0, rotationX: 0, rotationY: 0, rotationZ: 0 },
|
|
457
|
+
};
|
|
458
|
+
const modelPath = Object.keys(models ?? {}).sort()[0];
|
|
459
|
+
if (modelPath) {
|
|
460
|
+
components.Model = { file: modelPath };
|
|
461
|
+
}
|
|
462
|
+
next.actors.push({ id, components });
|
|
463
|
+
return { sceneData: next, newId: id };
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function mintActorId(existing) {
|
|
467
|
+
for (let attempt = 0; attempt < 64; attempt++) {
|
|
468
|
+
const candidate = Math.floor(Math.random() * 0xffffffff)
|
|
469
|
+
.toString(16)
|
|
470
|
+
.padStart(8, '0');
|
|
471
|
+
if (!existing.has(candidate)) return candidate;
|
|
472
|
+
}
|
|
473
|
+
return `${Date.now().toString(16)}${Math.floor(Math.random() * 0xffff).toString(16)}`;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// World-unit rounding for editor writes -- two decimals keeps scene JSON
|
|
477
|
+
// readable without visible snapping.
|
|
478
|
+
export function roundUnits(value) {
|
|
479
|
+
return Math.round(value * 100) / 100;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function getColliderBox(actor) {
|
|
483
|
+
const transform = actor?.components?.Transform;
|
|
484
|
+
const collider = actor?.components?.Collider;
|
|
485
|
+
if (!transform || !collider) return null;
|
|
486
|
+
return {
|
|
487
|
+
x: (transform.x ?? 0) + (collider.offsetX ?? 0),
|
|
488
|
+
y: (transform.y ?? 0) + (collider.offsetY ?? 0),
|
|
489
|
+
z: (transform.z ?? 0) + (collider.offsetZ ?? 0),
|
|
490
|
+
width: collider.width ?? 1,
|
|
491
|
+
height: collider.height ?? 1,
|
|
492
|
+
depth: collider.depth ?? 1,
|
|
493
|
+
};
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function intersectsBox(a, b) {
|
|
497
|
+
return (
|
|
498
|
+
Math.abs(a.x - b.x) * 2 < a.width + b.width &&
|
|
499
|
+
Math.abs(a.y - b.y) * 2 < a.height + b.height &&
|
|
500
|
+
Math.abs(a.z - b.z) * 2 < a.depth + b.depth
|
|
501
|
+
);
|
|
502
|
+
}
|