littlejsengine 1.19.3 → 1.21.0

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 (54) hide show
  1. package/FAQ.md +841 -820
  2. package/README.md +29 -25
  3. package/dist/littlejs.d.ts +2857 -955
  4. package/dist/littlejs.esm.js +33428 -20995
  5. package/dist/littlejs.esm.min.js +4 -3
  6. package/dist/littlejs.js +33386 -20996
  7. package/dist/littlejs.min.js +2 -2
  8. package/dist/littlejs.release.js +27841 -20422
  9. package/package.json +10 -5
  10. package/plugins/audioEffects.js +28 -24
  11. package/plugins/box2d.js +2516 -2073
  12. package/plugins/drawUtilities.js +206 -31
  13. package/plugins/gltf.js +786 -0
  14. package/plugins/jsconfig.json +19 -0
  15. package/plugins/lightSystem.js +719 -332
  16. package/plugins/math3d.js +148 -27
  17. package/plugins/medalSystem.js +326 -280
  18. package/plugins/newgrounds.js +400 -199
  19. package/plugins/pathFinder.js +208 -175
  20. package/plugins/pluginExport.js +25 -0
  21. package/plugins/postProcess.js +96 -24
  22. package/plugins/render3d.js +2067 -1632
  23. package/plugins/render3dDebug.js +110 -0
  24. package/plugins/render3dEditor.js +1812 -0
  25. package/plugins/render3dExtras.js +1948 -0
  26. package/plugins/render3dLevel.js +189 -0
  27. package/plugins/render3dVoxels.js +514 -0
  28. package/plugins/sceneSystem.js +58 -0
  29. package/plugins/textureSheet.js +496 -458
  30. package/plugins/threejs.js +183 -163
  31. package/plugins/tweakables.js +411 -0
  32. package/plugins/tweenSystem.js +265 -145
  33. package/plugins/uiSystem.js +2024 -1687
  34. package/plugins/zzfxm.js +159 -159
  35. package/src/engine.js +378 -97
  36. package/src/engineAudio.js +231 -127
  37. package/src/engineBuild.mjs +292 -256
  38. package/src/engineDebug.js +967 -743
  39. package/src/engineDraw.js +391 -198
  40. package/src/engineEditor.js +2452 -0
  41. package/src/engineExport.js +18 -0
  42. package/src/engineInput.js +1466 -1293
  43. package/src/engineLogo.js +19 -0
  44. package/src/engineMath.js +1274 -1252
  45. package/src/engineObject.js +197 -82
  46. package/src/engineParticles.js +583 -532
  47. package/src/engineRelease.js +79 -45
  48. package/src/engineSettings.js +115 -29
  49. package/src/engineTileLayer.js +995 -741
  50. package/src/engineUtilities.js +19 -13
  51. package/src/engineWebGL.js +239 -82
  52. package/src/jsconfig.json +17 -13
  53. package/plugins/box2d.wasm.js +0 -630
  54. package/plugins/box2d.wasm.wasm +0 -0
@@ -0,0 +1,1812 @@
1
+ /*
2
+ * LittleJS 3D Editor Plugin
3
+ * - A free camera to look around any 3D game as it runs, C on the debug overlay
4
+ * - The 3D level editor, for a level loaded with level3DLoad, 0 on the debug overlay
5
+ * - Debug builds only, like the 2D level editor: engineRelease.js has empty stubs for what other files call, so
6
+ * release builds carry none of it
7
+ * - Goes after the Render3D plugins and the 2D level editor
8
+ */
9
+
10
+ 'use strict';
11
+
12
+ ///////////////////////////////////////////////////////////////////////////////
13
+ // the view: a camera of the editor's own, drawn with while the free camera or the editor is on
14
+
15
+ const EDITOR3D_LOOK_SPEED = .003; // radians a pixel of mouse movement turns the view
16
+
17
+ // the camera of the free camera and the editor, made at the first use
18
+
19
+ /** @type {Camera3D|undefined} */
20
+ let editor3DCamera;
21
+ let editor3DFreeCamera = false; // the free camera is on
22
+ let editor3DFlySpeed = .2; // world units flown in a frame of 1/60, the wheel changes it
23
+ let editor3DTimeLast = 0; // real time of the last update, flying goes by real time so it works while paused
24
+ let editor3DWasLocked = false; // the mouse was captured last update, a lock that is lost leaves the free camera
25
+ // the game's camera while the editor's is drawn with
26
+ /** @type {Camera3D|undefined} */
27
+ let editor3DGameCamera;
28
+
29
+ // if something draws with the editor's camera
30
+ function editor3DViewOn() { return editor3DFreeCamera || editor3DIsOpen; }
31
+
32
+ // the editor's camera starts as the game's: where it is, the way it looks and its field of view
33
+ function editor3DCameraStart()
34
+ {
35
+ const from = render3D.camera, camera = editor3DCamera = new Camera3D;
36
+ camera.pos = from.pos.copy();
37
+ camera.rotation = vec3(from.rotation.x, from.rotation.y, 0);
38
+ camera.fov = from.fov;
39
+ camera.near = from.near;
40
+ camera.far = from.far;
41
+ }
42
+
43
+ // turn the free camera on or off, it needs a Render3DPlugin and takes the keyboard and mouse while it is on
44
+ function editor3DSetFreeCamera(on)
45
+ {
46
+ on = !!on && !!render3D;
47
+ if (on === editor3DFreeCamera) return;
48
+ editor3DFreeCamera = on;
49
+ editor3DWasLocked = false;
50
+ if (on)
51
+ {
52
+ editor3DCameraStart();
53
+ pointerLockRequest();
54
+ }
55
+ else
56
+ pointerLockIsActive() && pointerLockExit();
57
+ inputCapture(editor3DViewOn());
58
+ }
59
+
60
+ // fly a camera: turn is the mouse movement in pixels, move is right, up and forward from -1 to 1
61
+ function editor3DFly(camera, move, turn, seconds, fast=false)
62
+ {
63
+ const r = camera.rotation;
64
+ camera.rotation = vec3(clamp(r.x - turn.y * EDITOR3D_LOOK_SPEED, -1.55, 1.55), r.y - turn.x * EDITOR3D_LOOK_SPEED, 0);
65
+ const distance = editor3DFlySpeed * 60 * seconds * (fast ? 4 : 1);
66
+ camera.pos = camera.pos.add(camera.getRight().scale(move.x * distance))
67
+ .add(vec3(0, move.y * distance, 0))
68
+ .add(camera.getForward().scale(move.z * distance));
69
+ }
70
+
71
+ // the keys that fly, right, up and forward from -1 to 1
72
+ function editor3DFlyKeys()
73
+ {
74
+ const key = (code)=> keyIsDown(code) ? 1 : 0;
75
+ return vec3(key('KeyD') - key('KeyA'), key('KeyE') - key('KeyQ'), key('KeyW') - key('KeyS'));
76
+ }
77
+
78
+ // run a function with render3D looking through the editor's camera, for the editor's picking and projecting
79
+ function editor3DWithView(fn)
80
+ {
81
+ const r = render3D, camera = r.camera;
82
+ r.camera = editor3DCamera;
83
+ r.updateMatrices();
84
+ try { return fn(); }
85
+ finally
86
+ {
87
+ r.camera = camera;
88
+ r.updateMatrices();
89
+ }
90
+ }
91
+
92
+ // called by the 3D pass before it works out its matrices: the frame is drawn with the editor's camera
93
+ function editor3DCameraBegin()
94
+ {
95
+ if (!editor3DViewOn() || !editor3DCamera) return;
96
+ const r = render3D, camera = editor3DGameCamera = r.camera;
97
+ r.camera = editor3DCamera;
98
+
99
+ // where the game is looking from, a box turned its way and a line along its view; the editor has enough to show
100
+ if (editor3DFreeCamera)
101
+ {
102
+ const color = hsl(.15, 1, .6);
103
+ debugBox3D(camera.pos, vec3(.5, .5, .8), color, 0, camera.rotation);
104
+ debugLine3D(camera.pos, camera.pos.add(camera.getForward().scale(2)), color);
105
+ }
106
+
107
+ // the editor's own drawing, in this frame's 3D pass
108
+ editor3DIsOpen && render3DDebugPush(0, editor3DDraw);
109
+ }
110
+
111
+ // called by the 3D pass after the frame: the game's camera is back, with its matrices, for the game's next update
112
+ function editor3DCameraEnd()
113
+ {
114
+ if (!editor3DGameCamera) return;
115
+ render3D.camera = editor3DGameCamera;
116
+ editor3DGameCamera = undefined;
117
+ render3D.updateMatrices();
118
+ }
119
+
120
+ // if a captured mouse was let go of by the browser since the last look, as Chrome does on Escape, and not by the
121
+ // game with pointerLockExit
122
+ function editor3DLostLock(locked)
123
+ {
124
+ const lost = editor3DWasLocked && !locked && !inputLockLetGo;
125
+ editor3DWasLocked = locked;
126
+ inputLockLetGo = false;
127
+ return lost;
128
+ }
129
+
130
+ // C turns the free camera on while the debug keys work, the level editor has its own view
131
+ function editor3DFreeCameraKey()
132
+ {
133
+ if (!(debugOverlay || debugKeysAlways) || levelEditor.isOpen || !keyWasPressed('KeyC')) return;
134
+ inputClearKey('KeyC');
135
+ editor3DSetFreeCamera(true);
136
+ }
137
+
138
+ // the free camera's update: C or Escape leaves, and so does a captured mouse the browser let go of, since Chrome
139
+ // takes Escape for that and may not send the key; the mouse looks while it is captured or the right button is held
140
+ function editor3DFreeCameraUpdate(seconds)
141
+ {
142
+ const locked = pointerLockIsActive(), lostLock = editor3DLostLock(locked);
143
+ if (keyWasPressed('KeyC') || debugKey && keyWasPressed(debugKey) || lostLock)
144
+ {
145
+ inputClearKey('KeyC');
146
+ debugKey && inputClearKey(debugKey);
147
+ editor3DSetFreeCamera(false);
148
+ return;
149
+ }
150
+ editor3DFlySpeed = clamp(editor3DFlySpeed * Math.exp(-inputCaptureWheel * .2), .01, 10);
151
+ const look = locked || mouseIsDown(2);
152
+ editor3DFly(editor3DCamera, editor3DFlyKeys(), look ? inputCaptureDeltaScreen : vec2(), seconds,
153
+ keyIsDown('ShiftLeft') || keyIsDown('ShiftRight'));
154
+ }
155
+
156
+ ///////////////////////////////////////////////////////////////////////////////
157
+ // the math of the handles: each is dragged with the mouse's ray
158
+
159
+ const EDITOR3D_UP = vec3(0, 1, 0);
160
+
161
+ // how far along an axis line, from its origin, the point nearest a ray is: the two lines' closest points, solved
162
+ // for the axis; a ray along the axis has no nearest point, and gives 0
163
+ function editor3DAxisDistance(ray, origin, axis)
164
+ {
165
+ const d = ray.direction, w = origin.subtract(ray.origin);
166
+ const a = axis.dot(axis), b = axis.dot(d), c = d.dot(d), e = axis.dot(w), f = d.dot(w);
167
+ const denominator = a * c - b * b;
168
+ return abs(denominator) < 1e-9 ? 0 : (b * f - c * e) / denominator;
169
+ }
170
+
171
+ // where a ray meets the plane through a point, undefined when it runs along the plane or away from it
172
+ function editor3DPlanePoint(ray, point, normal)
173
+ {
174
+ const distance = raycastPlane(ray, point, normal);
175
+ return distance === undefined ? undefined : ray.getPosition(distance);
176
+ }
177
+
178
+ // the axis a ring turns around and two axes across it, for the pitch 'x', yaw 'y' or roll 'z' of a rotation:
179
+ // yaw turns around world up, pitch around the side axis after the yaw, roll around the forward axis after both,
180
+ // so each ring changes one number of rotation3D; a turn goes from across toward along
181
+ function editor3DRingAxes(rotation, ring)
182
+ {
183
+ const frame = buildMatrix(vec3(), ring === 'y' ? vec3() : ring === 'x' ? vec3(0, rotation.y, 0) :
184
+ vec3(rotation.x, rotation.y, 0));
185
+ const x = frame.transformDirection(vec3(1, 0, 0)), y = frame.transformDirection(vec3(0, 1, 0));
186
+ const z = frame.transformDirection(vec3(0, 0, 1));
187
+ return ring === 'x' ? {axis: x, across: y, along: z} : ring === 'y' ? {axis: y, across: z, along: x} :
188
+ {axis: z, across: x, along: y};
189
+ }
190
+
191
+ // the angle of a ray's point on a ring's plane, around the ring's axis from across toward along
192
+ function editor3DRingAngle(ray, center, axes)
193
+ {
194
+ const point = editor3DPlanePoint(ray, center, axes.axis);
195
+ if (!point) return;
196
+ const offset = point.subtract(center);
197
+ return atan2(offset.dot(axes.along), offset.dot(axes.across));
198
+ }
199
+
200
+ // a value on the nearest step, a step of 0 leaves it
201
+ function editor3DSnap(value, step) { return step ? round(value / step) * step : value; }
202
+
203
+ // a position snapped to the grid by the low corner of its box, so its faces land on grid lines and boxes tile
204
+ // with no gaps; a turned object has no such corner and goes by its center
205
+ function editor3DSnapPos(pos, size, step, turned=false)
206
+ {
207
+ const half = turned ? vec3() : size.scale(.5), low = pos.subtract(half);
208
+ return vec3(editor3DSnap(low.x, step), editor3DSnap(low.y, step), editor3DSnap(low.z, step)).add(half);
209
+ }
210
+
211
+ // the height of the editor's view in world units at a point's depth, handles are sized by it to keep their size
212
+ // on screen
213
+ function editor3DScreenScale(point)
214
+ {
215
+ const camera = editor3DCamera, depth = point.subtract(camera.pos).dot(camera.getForward());
216
+ return max(depth, .01) * tan(camera.fov / 2) * 2;
217
+ }
218
+
219
+ // how far in pixels a screen position is from the line between two world points as the view shows it, Infinity
220
+ // when an end is behind the camera
221
+ function editor3DSegmentDistance(screenPos, a, b)
222
+ {
223
+ const sa = render3D.worldToScreen(a), sb = render3D.worldToScreen(b);
224
+ if (!sa || !sb) return Infinity;
225
+ const ab = sb.subtract(sa), t = clamp(screenPos.subtract(sa).dot(ab) / (ab.lengthSquared() || 1), 0, 1);
226
+ return screenPos.distance(sa.add(ab.scale(t)));
227
+ }
228
+
229
+ // the first point a ray hits: on an object, a height map or a voxel map, leaving some out, or on the ground plane
230
+ // at height 0 when it hits none
231
+ function editor3DSurface(ray, ignore=new Set)
232
+ {
233
+ let distance;
234
+ for (const o of engineObjects)
235
+ {
236
+ if (ignore.has(o)) continue;
237
+ const d = render3DRaycastObject(ray, o);
238
+ if (d !== undefined && (distance === undefined || d < distance))
239
+ distance = d;
240
+ }
241
+ distance ??= raycastPlane(ray, vec3(), EDITOR3D_UP);
242
+ return distance === undefined ? undefined : ray.getPosition(distance);
243
+ }
244
+
245
+ // where a box of a size stands with its bottom on a point
246
+ function editor3DRest(point, size) { return vec3(point.x, point.y + size.y / 2, point.z); }
247
+
248
+ // the height of the middle of a box that lands at a place across the ground: dropped from its own height over a
249
+ // height, onto what is under it, so it climbs a step as tall as itself and no more; with nothing under it, it
250
+ // stands at that height
251
+ function editor3DLand(x, z, height, size, ignore)
252
+ {
253
+ const landed = editor3DDrop(vec3(x, height + size.y * 1.5 + .01, z), size, ignore);
254
+ return landed ? landed.y : height + size.y / 2;
255
+ }
256
+
257
+ // where a box lands dropped straight down from its bottom onto what is under it, undefined with nothing there
258
+ function editor3DDrop(pos, size, ignore=new Set)
259
+ {
260
+ const bottom = vec3(pos.x, pos.y - size.y / 2, pos.z);
261
+ const point = editor3DSurface(new Ray3D(bottom, vec3(0, -1, 0)), ignore);
262
+ return point && editor3DRest(point, size);
263
+ }
264
+
265
+ ///////////////////////////////////////////////////////////////////////////////
266
+ // the level: the object the game gave level3DLoad is the source of truth, the editor changes it in place and
267
+ // brings what the game made in line with it
268
+
269
+ // the level being edited, the one given to level3DLoad last
270
+
271
+ /** @type {Object|undefined} */
272
+ let editor3DLevel;
273
+
274
+ // what the game made for each object of the level, by the object's id
275
+ const editor3DInstances = new Map;
276
+
277
+ // what the editor keeps for each level: {fileName, key, original, hash, pending, fileHandle, undo, redo}, the file
278
+ // it came from, the file's objects and their hash for the autosave, an autosave waiting for a file that changed,
279
+ // and its undo and redo lists
280
+ const editor3DRecords = new WeakMap;
281
+
282
+ // if the editor is open, and if it was opened and not exited, while Escape switches between playing and editing
283
+ let editor3DIsOpen = false, editor3DSession = false;
284
+ let editor3DGamePaused = false; // the game's pause from before the editor opened
285
+ let editor3DPlayFromMouse = false; // Escape and Play hand the game a position to play from
286
+
287
+ const editor3DSelection = new Set; // the ids of the selected objects
288
+ // the type a click places
289
+ /** @type {string|undefined} */
290
+ let editor3DBrush;
291
+ // the copied objects
292
+ /** @type {Array<Object>|undefined} */
293
+ let editor3DClipboard;
294
+ let editor3DUndoList = [], editor3DRedoList = []; // the level's, each entry its object list before and after an edit
295
+ // the edit being made, a drag is one
296
+ /** @type {{before: Array<Object>}|undefined} */
297
+ let editor3DStroke;
298
+
299
+ const editor3DCopy = (value)=> JSON.parse(JSON.stringify(value));
300
+ const editor3DSame = (a, b)=> JSON.stringify(a) === JSON.stringify(b);
301
+ const editor3DRound = (v)=> round(v * 1e4) / 1e4; // as the file keeps a number
302
+
303
+ // the level's objects, and the one of an id
304
+ const editor3DObjects = ()=> isArray(editor3DLevel?.objects) ? editor3DLevel.objects : [];
305
+ const editor3DObject = (id)=> editor3DObjects().find((o)=> o.id === id);
306
+ const editor3DSelected = ()=> editor3DObjects().filter((o)=> editor3DSelection.has(o.id));
307
+
308
+ // an object's position, rotation in degrees and scale, the defaults where the level leaves them out
309
+ const editor3DPos = (object)=> level3DVector(object.pos, vec3());
310
+ const editor3DRotation = (object)=> level3DVector(object.rotation, vec3());
311
+ const editor3DScale = (object)=> level3DVector(object.scale, vec3(1));
312
+ const editor3DTurned = (object)=> editor3DRotation(object).lengthSquared() > 0;
313
+
314
+ // the size of an object's box: what the game made for it says, a marker is 1 unit
315
+ function editor3DSize(object)
316
+ {
317
+ const made = editor3DInstances.get(object.id);
318
+ if (!(made instanceof EngineObject3D) || !(made.mesh || made.tileInfo)) return vec3(1);
319
+ const s = made.size3D, k = made.scale3D;
320
+ return vec3(s.x * abs(k.x), s.y * abs(k.y), s.z * abs(k.z));
321
+ }
322
+
323
+ // every object gets an id of its own, a whole number: one without, or with one already used, gets a new one
324
+ function editor3DFixIds(objects)
325
+ {
326
+ const isObject = (o)=> !!o && typeof o === 'object', seen = new Set;
327
+ let next = 1;
328
+ for (const o of objects)
329
+ isObject(o) && Number.isInteger(o.id) && (next = max(next, o.id + 1));
330
+ for (const o of objects)
331
+ {
332
+ if (!isObject(o)) continue;
333
+ if (!Number.isInteger(o.id) || seen.has(o.id))
334
+ o.id = next++;
335
+ seen.add(o.id);
336
+ }
337
+ }
338
+
339
+ // a quick hash of a text, to know when the file changed under its autosave
340
+ function editor3DHash(text)
341
+ {
342
+ let hash = 2166136261;
343
+ for (let i = 0; i < text.length; ++i)
344
+ hash = Math.imul(hash ^ text.charCodeAt(i), 16777619);
345
+ return hash >>> 0;
346
+ }
347
+
348
+ // called by level3DLoad before it makes the objects: the editor takes the level, and the first time it sees it,
349
+ // puts in the edits it autosaved for the file, or keeps them waiting when the file changed since
350
+ function editor3DLevelLoaded(level)
351
+ {
352
+ if (editor3DLevel !== level)
353
+ {
354
+ // another level, the selection and the edit being made were the last one's
355
+ editor3DSelection.clear();
356
+ editor3DStroke = undefined;
357
+ }
358
+ editor3DLevel = level;
359
+ editor3DInstances.clear();
360
+ if (isArray(level.objects))
361
+ {
362
+ // an entry that is not an object is dropped, in place, a game may keep the list
363
+ for (let i = level.objects.length; i--;)
364
+ level.objects[i] && typeof level.objects[i] === 'object' || level.objects.splice(i, 1);
365
+ editor3DFixIds(level.objects);
366
+ }
367
+ const known = editor3DRecords.get(level);
368
+ if (known)
369
+ {
370
+ // loaded again, as a restart does: its edits are in it, and its undo is its own
371
+ editor3DUndoList = known.undo;
372
+ editor3DRedoList = known.redo;
373
+ return;
374
+ }
375
+
376
+ // a level from a file goes by the file, one made in code by its objects as they were loaded, so each has an
377
+ // autosave of its own
378
+ const url = editorFetchedURLs.get(level)?.split(/[?#]/)[0];
379
+ const original = editor3DCopy(editor3DObjects()), hash = editor3DHash(JSON.stringify(original));
380
+ const record = {fileName: url ? url.split('/').pop() : 'level3D.json', key: url ?? 'level #' + hash, original,
381
+ hash, pending: undefined, fileHandle: undefined, undo: [], redo: []};
382
+ editor3DRecords.set(level, record);
383
+ editor3DUndoList = record.undo;
384
+ editor3DRedoList = record.redo;
385
+ // an autosave the file already has, from a Save, goes; one of this file, or of the file a Save wrote, comes back
386
+ const saved = editor3DSaves()[record.key];
387
+ if (saved && isArray(saved.objects) && editor3DSame(saved.objects, original))
388
+ editor3DAutosave(level);
389
+ else if ((saved?.hash === hash || saved?.savedHash === hash) && isArray(saved.objects))
390
+ level.objects = editor3DCopy(saved.objects);
391
+ else if (saved)
392
+ record.pending = saved;
393
+ }
394
+
395
+ // called by level3DLoad for each object, with what its type made
396
+ function editor3DObjectMade(object, made)
397
+ {
398
+ made && typeof made === 'object' && editor3DInstances.set(object.id, made);
399
+ }
400
+
401
+ // put what the game made for an object where the level has it, and still
402
+ function editor3DPlaceInstance(made, object)
403
+ {
404
+ made.pos3D = editor3DPos(object);
405
+ made.rotation3D = editor3DRotation(object).scale(PI / 180);
406
+ made.scale3D = (level3DBaseScale.get(made) ?? vec3(1)).multiply(editor3DScale(object));
407
+ made.velocity3D = vec3();
408
+ made.angleVelocity3D = vec3();
409
+ }
410
+
411
+ // make an object's game object, again when it has one
412
+ function editor3DMakeInstance(object)
413
+ {
414
+ editor3DInstances.get(object.id)?.destroy?.();
415
+ const made = level3DMake(object);
416
+ made ? editor3DInstances.set(object.id, made) : editor3DInstances.delete(object.id);
417
+ }
418
+
419
+ // set the level's objects to a list and bring the game's objects in line: one gone is destroyed, a new one made,
420
+ // one that only moved, turned or changed size is moved where it is, and one whose type or properties changed is
421
+ // made again, since its constructor takes them; a type made by an arrow function is called again
422
+ function editor3DSetObjects(list)
423
+ {
424
+ const before = new Map(editor3DObjects().map((o)=> [o.id, o]));
425
+ const objects = editor3DLevel.objects = editor3DCopy(list);
426
+ const ids = new Set(objects.map((o)=> o.id));
427
+ for (const [id, made] of editor3DInstances)
428
+ {
429
+ if (ids.has(id)) continue;
430
+ made.destroy?.();
431
+ editor3DInstances.delete(id);
432
+ }
433
+ for (const id of [...editor3DSelection])
434
+ ids.has(id) || editor3DSelection.delete(id);
435
+ for (const object of objects)
436
+ {
437
+ const old = before.get(object.id), made = editor3DInstances.get(object.id);
438
+ if (!level3DTypes.has(object.type) || old && editor3DSame(old, object)) continue;
439
+ const onlyMoved = old && old.type === object.type && editor3DSame(old.properties, object.properties) &&
440
+ made instanceof EngineObject3D && !made.destroyed;
441
+ onlyMoved ? editor3DPlaceInstance(made, object) : editor3DMakeInstance(object);
442
+ }
443
+ }
444
+
445
+ // change the level's objects as part of the edit being made: change edits a copy of the list; the changes until
446
+ // editor3DStrokeEnd are one undo, so a drag is one undo; false when nothing changed
447
+ function editor3DChange(change)
448
+ {
449
+ if (!editor3DLevel || editor3DRecords.get(editor3DLevel)?.pending) return false; // its autosave waits first
450
+ const before = editor3DCopy(editor3DObjects()), after = editor3DCopy(before);
451
+ change(after);
452
+ if (editor3DSame(before, after)) return false;
453
+ editor3DSetObjects(after);
454
+ editor3DStroke ||= {before};
455
+ return true;
456
+ }
457
+
458
+ // end the edit being made: one undo, and the autosave
459
+ function editor3DStrokeEnd()
460
+ {
461
+ const stroke = editor3DStroke;
462
+ editor3DStroke = undefined;
463
+ if (!stroke || editor3DSame(stroke.before, editor3DObjects())) return;
464
+ editor3DUndoList.push({before: stroke.before, after: editor3DCopy(editor3DObjects())});
465
+ editor3DUndoList.length > 100 && editor3DUndoList.shift();
466
+ editor3DRedoList.length = 0;
467
+ editor3DAutosave();
468
+ }
469
+
470
+ // take the edit being made back, with nothing to undo
471
+ function editor3DStrokeCancel()
472
+ {
473
+ const stroke = editor3DStroke;
474
+ editor3DStroke = undefined;
475
+ stroke && editor3DSetObjects(stroke.before);
476
+ }
477
+
478
+ // undo the last edit, or redo the last one undone; false with none
479
+ function editor3DUndo(redo=false)
480
+ {
481
+ editor3DStrokeEnd();
482
+ const entry = (redo ? editor3DRedoList : editor3DUndoList).pop();
483
+ if (!entry) return false;
484
+ (redo ? editor3DUndoList : editor3DRedoList).push(entry);
485
+ editor3DSetObjects(redo ? entry.after : entry.before);
486
+ editor3DAutosave();
487
+ return true;
488
+ }
489
+
490
+ // write an object's position, rotation in degrees and scale as the file keeps them: rounded, and the rotation and
491
+ // scale left out when they are the default; one not given is left as it is
492
+ function editor3DSetTransform(object, pos, rotation, scale)
493
+ {
494
+ const list = (v)=> [editor3DRound(v.x), editor3DRound(v.y), editor3DRound(v.z)];
495
+ pos && (object.pos = list(pos));
496
+ if (rotation)
497
+ list(rotation).some((v)=> v) ? object.rotation = list(rotation) : delete object.rotation;
498
+ if (scale)
499
+ list(scale).some((v)=> v !== 1) ? object.scale = list(scale) : delete object.scale;
500
+ }
501
+
502
+ // set an object's property as the file keeps it, only where it differs from its type's default: a Color as a hex
503
+ // string, with its alpha when it has one, and a vector as an array
504
+ function editor3DSetProperty(object, name, value, defaultValue)
505
+ {
506
+ const encode = (v)=> isColor(v) ? v.toString(v.a < 1) : isVector3(v) ? [v.x, v.y, v.z] :
507
+ isVector2(v) ? [v.x, v.y] : v;
508
+ const properties = {...object.properties};
509
+ if (editor3DSame(encode(value), encode(defaultValue)))
510
+ delete properties[name];
511
+ else
512
+ properties[name] = encode(value);
513
+ Object.keys(properties).length ? object.properties = properties : delete object.properties;
514
+ }
515
+
516
+ // add an object of a type at a position, selected; its id, or undefined when the level can not be edited
517
+ function editor3DPlace(type, pos)
518
+ {
519
+ let id;
520
+ const placed = editor3DChange((list)=>
521
+ {
522
+ const object = {id: id = list.reduce((next, o)=> max(next, o.id + 1), 1), type};
523
+ editor3DSetTransform(object, pos);
524
+ list.push(object);
525
+ });
526
+ if (!placed) return;
527
+ editor3DSelection.clear();
528
+ editor3DSelection.add(id);
529
+ return id;
530
+ }
531
+
532
+ // remove the selected objects, as one undo
533
+ function editor3DDelete()
534
+ {
535
+ if (!editor3DSelection.size) return false;
536
+ editor3DStrokeEnd();
537
+ editor3DChange((list)=>
538
+ {
539
+ for (let i = list.length; i--;)
540
+ editor3DSelection.has(list[i].id) && list.splice(i, 1);
541
+ });
542
+ editor3DStrokeEnd();
543
+ }
544
+
545
+ // copy the selected objects
546
+ function editor3DCopySelection()
547
+ {
548
+ const selected = editor3DSelected();
549
+ if (!selected.length) return false;
550
+ editor3DClipboard = editor3DCopy(selected);
551
+ }
552
+
553
+ // copy the selected objects and remove them
554
+ function editor3DCut() { return editor3DCopySelection() === false ? false : editor3DDelete(); }
555
+
556
+ // add the copied objects, moved by an offset, with new ids and selected, as one undo
557
+ function editor3DPaste(offset=vec3())
558
+ {
559
+ const copied = editor3DClipboard, ids = [];
560
+ if (!copied?.length) return false;
561
+ editor3DStrokeEnd();
562
+ const pasted = editor3DChange((list)=>
563
+ {
564
+ let id = list.reduce((next, o)=> max(next, o.id + 1), 1);
565
+ for (const from of copied)
566
+ {
567
+ const object = {...editor3DCopy(from), id: id++};
568
+ editor3DSetTransform(object, editor3DPos(from).add(offset));
569
+ list.push(object);
570
+ ids.push(object.id);
571
+ }
572
+ });
573
+ editor3DStrokeEnd();
574
+ if (!pasted) return false;
575
+ editor3DSelection.clear();
576
+ ids.forEach((id)=> editor3DSelection.add(id));
577
+ }
578
+
579
+ // copy the selected objects where they are and select the copies, the clipboard stays as it was
580
+ function editor3DDuplicate()
581
+ {
582
+ const clipboard = editor3DClipboard;
583
+ const done = editor3DCopySelection() === false ? false : editor3DPaste();
584
+ editor3DClipboard = clipboard;
585
+ return done;
586
+ }
587
+
588
+ ///////////////////////////////////////////////////////////////////////////////
589
+ // the session: opening, playing and closing
590
+
591
+ // if the level editor is the 3D one: as levelEditor.use3D says, or when a 3D level was loaded
592
+ function editor3DWanted() { return !!render3D && (levelEditor.use3D ?? !!editor3DLevel); }
593
+
594
+ // put every object back where the level has it, and make one the game destroyed again, so what is on screen is
595
+ // what Save writes; a type made by an arrow function made nothing to put back
596
+ function editor3DRestore()
597
+ {
598
+ for (const object of editor3DObjects())
599
+ {
600
+ const type = level3DTypes.get(object.type), made = editor3DInstances.get(object.id);
601
+ if (!type) continue;
602
+ if (made instanceof EngineObject3D && !made.destroyed)
603
+ editor3DPlaceInstance(made, object);
604
+ else if (type.make.prototype && (!made || made.destroyed))
605
+ editor3DMakeInstance(object);
606
+ }
607
+ }
608
+
609
+ // open or close the editor, the session goes on: the game is paused under it and reads no input
610
+ function editor3DSetOpen(open)
611
+ {
612
+ open = !!open;
613
+ if (!debug || editor3DIsOpen === open) return;
614
+ if (open)
615
+ {
616
+ editor3DSetFreeCamera(false);
617
+ editor3DLevel || editor3DLevelLoaded({}); // a new level, with nothing loaded
618
+ editor3DCamera || editor3DCameraStart();
619
+ editor3DGamePaused = paused;
620
+ setPaused(true);
621
+ setDebugOverlay(false); // out of the way of the level
622
+ editor3DIsOpen = true;
623
+ editor3DRestore();
624
+ }
625
+ else
626
+ {
627
+ editor3DDrag = editor3DHover = undefined;
628
+ editor3DMouseOnPanel = false; // the panel hides, with no mouseleave
629
+ editor3DStrokeEnd();
630
+ editor3DIsOpen = false;
631
+ setPaused(editor3DGamePaused);
632
+ }
633
+ inputCapture(editor3DViewOn());
634
+ }
635
+
636
+ // open the editor, a new session starts at the game's camera and a return keeps the editor's
637
+ function editor3DOpen()
638
+ {
639
+ editor3DSession || editor3DCameraStart();
640
+ editor3DSession = true;
641
+ editor3DSetOpen(true);
642
+ }
643
+
644
+ // close the editor and end its session, Escape opens the debug overlay again
645
+ function editor3DClose()
646
+ {
647
+ editor3DSession = false;
648
+ editor3DSetOpen(false);
649
+ }
650
+
651
+ // switch to playing, from a position when Play from mouse is on
652
+ function editor3DPlay(pos)
653
+ {
654
+ editor3DSetOpen(false);
655
+ editor3DPlayFromMouse && pos && levelEditor.onPlayFrom?.(pos.copy());
656
+ }
657
+
658
+ // rebuild the level through the game's hook and play it
659
+ function editor3DRestart()
660
+ {
661
+ if (!levelEditor.onRestart) return;
662
+ editor3DSetOpen(false);
663
+ levelEditor.onRestart();
664
+ }
665
+
666
+ // the level editor's open, close and isOpen go to the 3D editor when the game has a 3D level
667
+ if (debug)
668
+ {
669
+ const open2D = levelEditor.open.bind(levelEditor), close2D = levelEditor.close.bind(levelEditor);
670
+ levelEditor.open = ()=> editor3DWanted() ? editor3DOpen() : open2D();
671
+ levelEditor.close = ()=> editor3DIsOpen || editor3DSession ? editor3DClose() : close2D();
672
+ Object.defineProperty(levelEditor, 'isOpen', {get: ()=> editor3DIsOpen || editorIsOpen});
673
+ }
674
+
675
+ ///////////////////////////////////////////////////////////////////////////////
676
+ // files: the level as JSON, Save, and the autosave
677
+
678
+ // a value as JSON on one line, with a space after each comma and colon, as a person would write it
679
+ function editor3DJSON(value)
680
+ {
681
+ if (isArray(value))
682
+ return '[' + value.map(editor3DJSON).join(', ') + ']';
683
+ if (value && typeof value === 'object')
684
+ return '{' + Object.entries(value).filter(([, v])=> v !== undefined)
685
+ .map(([key, v])=> JSON.stringify(key) + ': ' + editor3DJSON(v)).join(', ') + '}';
686
+ return JSON.stringify(value) ?? 'null';
687
+ }
688
+
689
+ // a level as the file has it: its version, what else it holds, and its objects, one to a line
690
+ function editor3DLevelJSON(level=editor3DLevel)
691
+ {
692
+ const {littlejs3D, objects, ...rest} = level;
693
+ const lines = [`"littlejs3D": ${LEVEL3D_VERSION}`];
694
+ for (const [key, value] of Object.entries(rest))
695
+ lines.push(JSON.stringify(key) + ': ' + editor3DJSON(value));
696
+ const list = (isArray(objects) ? objects : []).map((o)=> ' ' + editor3DJSON(o));
697
+ lines.push('"objects": [' + (list.length ? '\n' + list.join(',\n') + '\n ]' : ']'));
698
+ return '{\n ' + lines.join(',\n ') + '\n}\n';
699
+ }
700
+
701
+ // every page keeps its own autosaves, one for each level by its key
702
+ function editor3DSaveName() { return 'LittleJS editor 3D ' + (globalThis.location?.pathname ?? ''); }
703
+ const editor3DSaves = ()=> readSaveData(editor3DSaveName(), {});
704
+
705
+ // if the last autosave did not fit in storage, the panel says so
706
+ let editor3DSaveFailed = false;
707
+
708
+ // remember the level's objects, or forget them when they are back to the file
709
+ function editor3DAutosave(level=editor3DLevel)
710
+ {
711
+ const record = editor3DRecords.get(level);
712
+ if (!record || record.pending) return; // edits waiting to be applied keep their autosave
713
+ const saves = editor3DSaves(), objects = isArray(level.objects) ? level.objects : [];
714
+ // the level as it was loaded has nothing to keep; one a Save wrote is kept until a reload shows the file has it,
715
+ // since the browser gives a picked file's name and not its folder, and a file of the same name may be a copy
716
+ if (editor3DSame(objects, record.original) && !record.savedHash)
717
+ delete saves[record.key];
718
+ else
719
+ saves[record.key] = {hash: record.hash, savedHash: record.savedHash, objects: editor3DCopy(objects)};
720
+ try
721
+ {
722
+ localStorage.setItem(editor3DSaveName(), JSON.stringify(saves));
723
+ editor3DSaveFailed = false;
724
+ }
725
+ catch { editor3DSaveFailed = true; }
726
+ }
727
+
728
+ // put the autosaved edits of a file that changed into the level, as one undo
729
+ function editor3DApplyPending()
730
+ {
731
+ const record = editor3DRecords.get(editor3DLevel), saved = record?.pending;
732
+ if (!saved || !isArray(saved.objects)) return false;
733
+ record.pending = undefined;
734
+ editor3DFixIds(saved.objects);
735
+ editor3DChange((list)=> { list.length = 0; list.push(...editor3DCopy(saved.objects)); });
736
+ editor3DStrokeEnd();
737
+ editor3DAutosave();
738
+ }
739
+
740
+ // drop the autosaved edits of a file that changed
741
+ function editor3DDropPending()
742
+ {
743
+ const record = editor3DRecords.get(editor3DLevel);
744
+ if (!record?.pending) return false;
745
+ record.pending = undefined;
746
+ editor3DAutosave();
747
+ }
748
+
749
+ // Reset to file: put the level back as its file has it, or as Save last wrote it, as one undo; edits waiting for a
750
+ // file that changed are dropped, and a level already as its file has it is left alone
751
+ function editor3DRevert()
752
+ {
753
+ const record = editor3DRecords.get(editor3DLevel);
754
+ if (!record) return false;
755
+ record.pending = undefined;
756
+ if (editor3DSame(editor3DObjects(), record.original))
757
+ return editor3DAutosave(), false;
758
+ editor3DChange((list)=> { list.length = 0; list.push(...editor3DCopy(record.original)); });
759
+ editor3DStrokeEnd();
760
+ }
761
+
762
+ // save the level as JSON: where the browser lets a page write files, Chrome and Edge, to a file picked once and
763
+ // written again on each Save after, or picked again with Save As; elsewhere as a download; a file of the level's
764
+ // own name that was written is the file from then on, for the autosave; resolves to how it saved, undefined when
765
+ // the picker was closed
766
+ async function editor3DSave(pickAgain=false)
767
+ {
768
+ const level = editor3DLevel, record = editor3DRecords.get(level);
769
+ if (!record) return;
770
+ editor3DStrokeEnd();
771
+ // what is written, kept as it is now: the level can change while the file is picked and written, and those
772
+ // edits are not in the file, so they stay in the autosave; the level and record are this one's, whichever is
773
+ // open by then
774
+ const text = editor3DLevelJSON(level), written = editor3DCopy(JSON.parse(text).objects);
775
+ const picker = /** @type {any} */ (globalThis).showSaveFilePicker;
776
+ const fileKey = (globalThis.location?.pathname ?? '') + ' 3D ' + record.key;
777
+ if (picker)
778
+ {
779
+ try
780
+ {
781
+ if (!pickAgain && !record.fileHandle)
782
+ {
783
+ const handle = await editorFileStore.get(fileKey), mode = {mode: 'readwrite'};
784
+ if (handle && (await handle.queryPermission(mode) === 'granted' ||
785
+ await handle.requestPermission(mode) === 'granted'))
786
+ record.fileHandle = handle;
787
+ }
788
+ if (pickAgain || !record.fileHandle)
789
+ {
790
+ record.fileHandle = await picker.call(globalThis, {suggestedName: record.fileName,
791
+ types: [{description: 'LittleJS 3D level', accept: {'application/json': ['.json']}}]});
792
+ editorFileStore.set(fileKey, record.fileHandle);
793
+ }
794
+ const writable = await record.fileHandle.createWritable();
795
+ await writable.write(text);
796
+ await writable.close();
797
+ if (record.fileHandle.name === record.fileName)
798
+ {
799
+ record.original = written;
800
+ record.savedHash = editor3DHash(JSON.stringify(written));
801
+ editor3DAutosave(level);
802
+ }
803
+ return 'written';
804
+ }
805
+ catch (error)
806
+ {
807
+ if (error?.name === 'AbortError') return; // the picker was closed, nothing saved
808
+ record.fileHandle = undefined; // a file it could not write, a download instead, and not kept
809
+ editorFileStore.set(fileKey, undefined);
810
+ }
811
+ }
812
+ saveText(text, record.fileName, 'application/json');
813
+ return 'downloaded';
814
+ }
815
+
816
+ ///////////////////////////////////////////////////////////////////////////////
817
+ // the tools: handles on the selection, dragged with the mouse
818
+
819
+ const EDITOR3D_HANDLE_SIZE = .15; // a handle's length as a part of the view's height
820
+ const EDITOR3D_HANDLE_REACH = 10; // how many pixels from a handle still grab it
821
+ const EDITOR3D_AXES = {x: vec3(1, 0, 0), y: vec3(0, 1, 0), z: vec3(0, 0, 1)};
822
+
823
+ let editor3DTool = 'move'; // 'select', 'move', 'rotate' or 'scale'
824
+ let editor3DGrid = true; // moves, turns and sizes go in steps, Ctrl flips it for a drag
825
+ let editor3DGroundSnap = true; // a body drag slides along what is under the mouse
826
+ let editor3DMoveStep = 1, editor3DRotateStep = 15, editor3DScaleStep = .25; // the rotate step in degrees
827
+ let editor3DHelp = false; // the keys are shown
828
+ let editor3DMouseOnPanel = false;
829
+ // the drag being made: {kind, ...}, a 'box' drag has from and to in screen pixels
830
+ /** @type {any} */
831
+ let editor3DDrag;
832
+ // the handle under the mouse
833
+ /** @type {any} */
834
+ let editor3DHover;
835
+ // what an Alt drag orbits around
836
+ /** @type {Vector3|undefined} */
837
+ let editor3DOrbitPivot;
838
+
839
+ // the middle of the selection, where the handles are
840
+ function editor3DSelectionCenter()
841
+ {
842
+ const selected = editor3DSelected();
843
+ if (selected.length)
844
+ return selected.reduce((sum, o)=> sum.add(editor3DPos(o)), vec3()).scale(1 / selected.length);
845
+ }
846
+
847
+ // the handles of the tool on the selection, each {kind, axis, center, length, direction or axes, points}: Move has
848
+ // an arrow along each world axis and a square between each pair, Rotate a ring for the pitch, yaw and roll of the
849
+ // first selected object, Scale a handle along each of its own axes and a box in the middle for all three
850
+ function editor3DHandles()
851
+ {
852
+ const center = editor3DSelectionCenter(), tool = editor3DTool, handles = [];
853
+ if (!center || tool === 'select') return handles;
854
+ const length = editor3DScreenScale(center) * EDITOR3D_HANDLE_SIZE;
855
+ const rotation = editor3DRotation(editor3DSelected()[0]).scale(PI / 180), frame = buildMatrix(vec3(), rotation);
856
+ for (const axis of 'xyz')
857
+ {
858
+ if (tool === 'rotate')
859
+ {
860
+ const axes = editor3DRingAxes(rotation, axis), points = [];
861
+ for (let i = 0; i <= 48; ++i)
862
+ {
863
+ const a = i / 48 * PI * 2;
864
+ points.push(center.add(axes.across.scale(cos(a) * length)).add(axes.along.scale(sin(a) * length)));
865
+ }
866
+ handles.push({kind: 'ring', axis, center, length, axes, points});
867
+ continue;
868
+ }
869
+
870
+ // an arrow or scale handle starts a little out from the middle, which is left for the object's body
871
+ const direction = tool === 'move' ? EDITOR3D_AXES[axis] : frame.transformDirection(EDITOR3D_AXES[axis]);
872
+ handles.push({kind: tool === 'move' ? 'arrow' : 'scale', axis, center, length, direction,
873
+ points: [center.add(direction.scale(length * .2)), center.add(direction.scale(length))]});
874
+ if (tool === 'move')
875
+ {
876
+ // the square between the other two axes moves along both
877
+ const [a, b] = 'xyz'.replace(axis, '');
878
+ handles.push({kind: 'plane', axis: a + b, center, length, direction,
879
+ points: [center.add(EDITOR3D_AXES[a].add(EDITOR3D_AXES[b]).scale(length * .35))]});
880
+ }
881
+ }
882
+ tool === 'scale' && handles.push({kind: 'scaleAll', axis: 'xyz', center, length, points: [center]});
883
+ return handles;
884
+ }
885
+
886
+ // the handle nearest a screen position, within reach; a square or box is a point and a little easier to take
887
+ function editor3DHandleAt(screenPos)
888
+ {
889
+ let nearest, reach = EDITOR3D_HANDLE_REACH;
890
+ for (const handle of editor3DHandles())
891
+ {
892
+ // a ring seen edge on is a line on screen and the mouse's ray runs along its plane, it can not be dragged
893
+ const {points, center} = handle, toward = center.subtract(editor3DCamera.pos).normalize();
894
+ if (handle.kind === 'ring' && abs(handle.axes.axis.dot(toward)) < .15) continue;
895
+
896
+ // an arrow or scale handle seen end on is a dot over the object, which is left for the object's body
897
+ if (points.length === 2)
898
+ {
899
+ const a = render3D.worldToScreen(points[0]), b = render3D.worldToScreen(points[1]);
900
+ if (a && b && a.distance(b) < 12) continue;
901
+ }
902
+ let distance = Infinity;
903
+ if (points.length === 1)
904
+ {
905
+ const at = render3D.worldToScreen(points[0]);
906
+ at && (distance = screenPos.distance(at) - 4);
907
+ }
908
+ else
909
+ for (let i = 1; i < points.length; ++i)
910
+ distance = min(distance, editor3DSegmentDistance(screenPos, points[i-1], points[i]));
911
+ if (distance < reach)
912
+ reach = distance, nearest = handle;
913
+ }
914
+ return nearest;
915
+ }
916
+
917
+ // the object of the level a ray hits first: what the game made for it or a part attached to that, or a unit box
918
+ // where it made nothing to hit, like a light or a player start; its id
919
+ function editor3DPickAt(ray)
920
+ {
921
+ let nearest, id;
922
+ for (const object of editor3DObjects())
923
+ {
924
+ const parts = editor3DVisible(object) ? editor3DParts(editor3DInstances.get(object.id)) : [];
925
+ let distance = parts.length ? undefined : raycastBox(ray, editor3DPos(object), vec3(1));
926
+ for (const part of parts)
927
+ {
928
+ const d = render3DRaycastObject(ray, part);
929
+ if (d !== undefined && (distance === undefined || d < distance))
930
+ distance = d;
931
+ }
932
+ if (distance !== undefined && (nearest === undefined || distance < nearest))
933
+ nearest = distance, id = object.id;
934
+ }
935
+ return id;
936
+ }
937
+
938
+ // what the game made for an object and the parts attached to it, its children and theirs
939
+ function editor3DParts(made, parts=[])
940
+ {
941
+ if (made instanceof EngineObject && !made.destroyed)
942
+ {
943
+ parts.push(made);
944
+ for (const child of made.children)
945
+ editor3DParts(child, parts);
946
+ }
947
+ return parts;
948
+ }
949
+
950
+ // what the game made for the selected objects, with their parts, left out when the selection looks for a surface
951
+ // to stand on
952
+ function editor3DSelectedInstances()
953
+ {
954
+ return new Set(editor3DSelected().flatMap((o)=> editor3DParts(editor3DInstances.get(o.id))));
955
+ }
956
+
957
+ // where each selected object is, as a drag starts from
958
+ function editor3DDragStart()
959
+ {
960
+ return new Map(editor3DSelected().map((o)=>
961
+ [o.id, {pos: editor3DPos(o), rotation: editor3DRotation(o), scale: editor3DScale(o)}]));
962
+ }
963
+
964
+ // the left button went down: on a handle it drags it, with a brush it places one, on an object it selects it and
965
+ // may drag it, and on nothing it drags out a box to select with
966
+ function editor3DPress(mouse, ray, shift)
967
+ {
968
+ const handle = editor3DHandleAt(mouse), from = mouse.copy();
969
+ if (handle)
970
+ {
971
+ const {kind, center, direction, axes} = handle;
972
+ const grab = kind === 'ring' ? editor3DRingAngle(ray, center, axes) :
973
+ kind === 'plane' ? editor3DPlanePoint(ray, center, direction) :
974
+ kind === 'scaleAll' ? 0 : editor3DAxisDistance(ray, center, direction);
975
+ editor3DDrag = {kind, handle, from, grab, start: editor3DDragStart()};
976
+ return;
977
+ }
978
+ if (editor3DBrush)
979
+ {
980
+ editor3DPlaceAt(editor3DBrush, ray);
981
+ shift || (editor3DBrush = undefined);
982
+ editor3DTool = 'move';
983
+ return;
984
+ }
985
+ const id = editor3DPickAt(ray);
986
+ if (id === undefined)
987
+ {
988
+ editor3DDrag = {kind: 'box', from, to: from.copy(), shift};
989
+ return;
990
+ }
991
+ if (shift)
992
+ {
993
+ editor3DSelection.has(id) ? editor3DSelection.delete(id) : editor3DSelection.add(id);
994
+ return;
995
+ }
996
+ if (!editor3DSelection.has(id))
997
+ {
998
+ editor3DSelection.clear();
999
+ editor3DSelection.add(id);
1000
+ }
1001
+
1002
+ // a drag of its body moves it level, at its height, so it stays under the mouse; seen from the side, where the
1003
+ // mouse's ray runs along that plane, it moves across the view instead, in the upright plane facing it
1004
+ const pos = editor3DPos(editor3DObject(id)), forward = editor3DCamera.getForward();
1005
+ const side = abs(ray.direction.y) < .2 && vec3(forward.x, 0, forward.z).normalize();
1006
+ const normal = side && side.lengthSquared() ? side : EDITOR3D_UP;
1007
+ editor3DDrag = {kind: 'body', id, from, moving: false, ignore: editor3DSelectedInstances(), normal,
1008
+ start: editor3DDragStart(), level: editor3DPlanePoint(ray, pos, normal) ?? pos};
1009
+ }
1010
+
1011
+ // place an object of a type where a ray lands, standing on what is there, snapped, selected, as one undo
1012
+ function editor3DPlaceAt(type, ray)
1013
+ {
1014
+ const point = editor3DSurface(ray);
1015
+ if (!point) return;
1016
+ editor3DStrokeEnd();
1017
+ const id = editor3DPlace(type, point);
1018
+ if (id === undefined) return;
1019
+ const size = editor3DSize(editor3DObject(id)), ignore = new Set(editor3DParts(editor3DInstances.get(id)));
1020
+ const snapped = editor3DSnapPos(editor3DRest(point, size), size, editor3DGrid ? editor3DMoveStep : 0);
1021
+ const y = editor3DLand(snapped.x, snapped.z, point.y, size, ignore);
1022
+ editor3DChange((list)=> editor3DSetTransform(list.find((o)=> o.id === id), vec3(snapped.x, y, snapped.z)));
1023
+ editor3DStrokeEnd();
1024
+ }
1025
+
1026
+ // the mouse moved with a drag held: the selection goes from where the drag started to where the mouse says
1027
+ function editor3DDragTo(drag, mouse, ray, snap)
1028
+ {
1029
+ if (drag.kind === 'box')
1030
+ {
1031
+ drag.to = mouse.copy();
1032
+ return;
1033
+ }
1034
+ if (drag.kind === 'body' && !(drag.moving ||= mouse.distance(drag.from) > 4)) return;
1035
+ const {kind, handle, start, grab} = drag, first = editor3DObject(drag.id ?? [...start.keys()][0]);
1036
+ const was = first && start.get(first.id);
1037
+ if (!was) return;
1038
+ const size = editor3DSize(first), turned = editor3DTurned(first), step = snap ? editor3DMoveStep : 0;
1039
+ const snapTo = (to)=> editor3DSnapPos(to, size, step, turned).subtract(was.pos);
1040
+ let move, turn, factor;
1041
+ if (kind === 'arrow')
1042
+ {
1043
+ const d = editor3DAxisDistance(ray, handle.center, handle.direction) - grab;
1044
+ move = handle.direction.multiply(snapTo(was.pos.add(handle.direction.scale(d)))); // along its axis alone
1045
+ }
1046
+ else if (kind === 'plane')
1047
+ {
1048
+ const point = editor3DPlanePoint(ray, handle.center, handle.direction);
1049
+ if (!point || !grab) return;
1050
+ move = vec3(1).subtract(handle.direction).multiply(snapTo(was.pos.add(point.subtract(grab))));
1051
+ }
1052
+ else if (kind === 'body')
1053
+ {
1054
+ // level under the mouse, and with ground snap it lands on what is under it there, from where it started
1055
+ const point = editor3DPlanePoint(ray, drag.level, drag.normal);
1056
+ if (!point) return;
1057
+ const across = snapTo(was.pos.add(point.subtract(drag.level)));
1058
+ const y = editor3DGroundSnap ? editor3DLand(was.pos.x + across.x, was.pos.z + across.z,
1059
+ was.pos.y - size.y / 2, size, drag.ignore) : was.pos.y;
1060
+ move = vec3(across.x, y - was.pos.y, across.z);
1061
+ }
1062
+ else if (kind === 'ring')
1063
+ {
1064
+ const angle = editor3DRingAngle(ray, handle.center, handle.axes);
1065
+ if (angle === undefined || grab === undefined) return;
1066
+ turn = mod((angle - grab) * 180 / PI + 180, 360) - 180; // the short way around
1067
+ }
1068
+ else if (kind === 'scale')
1069
+ {
1070
+ if (abs(grab) < 1e-6) return;
1071
+ factor = vec3(1);
1072
+ factor[handle.axis] = max(editor3DAxisDistance(ray, handle.center, handle.direction) / grab, .01);
1073
+ }
1074
+ else // the box in the middle sizes all three, right and up is larger
1075
+ factor = vec3(Math.exp((mouse.x - drag.from.x - mouse.y + drag.from.y) / 200));
1076
+
1077
+ editor3DChange((list)=>
1078
+ {
1079
+ for (const object of list)
1080
+ {
1081
+ const s = start.get(object.id);
1082
+ if (!s) continue;
1083
+ if (move)
1084
+ editor3DSetTransform(object, s.pos.add(move));
1085
+ if (turn !== undefined)
1086
+ {
1087
+ const rotation = s.rotation.copy();
1088
+ rotation[handle.axis] = editor3DSnap(rotation[handle.axis] + turn, snap ? editor3DRotateStep : 0);
1089
+ editor3DSetTransform(object, undefined, rotation);
1090
+ }
1091
+ if (factor)
1092
+ {
1093
+ const size = snap ? editor3DScaleStep : 0, least = size || .01;
1094
+ const scale = s.scale.multiply(factor);
1095
+ editor3DSetTransform(object, undefined, undefined, vec3(max(editor3DSnap(scale.x, size), least),
1096
+ max(editor3DSnap(scale.y, size), least), max(editor3DSnap(scale.z, size), least)));
1097
+ }
1098
+ }
1099
+ });
1100
+ }
1101
+
1102
+ // the left button went up: a box selects what is inside it, by where each object's middle is on screen, and a
1103
+ // press that never moved clears the selection; any other drag is one undo
1104
+ function editor3DDragEnd(drag, mouse)
1105
+ {
1106
+ editor3DDrag = undefined;
1107
+ if (drag.kind !== 'box')
1108
+ return editor3DStrokeEnd();
1109
+ drag.shift || editor3DSelection.clear();
1110
+ const {from} = drag;
1111
+ if (mouse.distance(from) < 4) return;
1112
+ const low = vec2(min(from.x, mouse.x), min(from.y, mouse.y));
1113
+ const high = vec2(max(from.x, mouse.x), max(from.y, mouse.y));
1114
+ for (const object of editor3DObjects())
1115
+ {
1116
+ const at = render3D.worldToScreen(editor3DPos(object));
1117
+ at && at.x >= low.x && at.x <= high.x && at.y >= low.y && at.y <= high.y &&
1118
+ editor3DSelection.add(object.id);
1119
+ }
1120
+ }
1121
+
1122
+ // take the drag being made back, with nothing to undo
1123
+ function editor3DDragCancel()
1124
+ {
1125
+ editor3DDrag = undefined;
1126
+ editor3DStrokeCancel();
1127
+ }
1128
+
1129
+ ///////////////////////////////////////////////////////////////////////////////
1130
+ // the editor's camera, keys and update
1131
+
1132
+ // the surface under a screen position, under the mouse and under the middle of the view, seen through the
1133
+ // editor's camera
1134
+ function editor3DScreenPoint(screenPos)
1135
+ { return editor3DWithView(()=> editor3DSurface(render3D.screenToRay(screenPos))); }
1136
+ function editor3DMousePoint() { return editor3DScreenPoint(mousePosScreen); }
1137
+ function editor3DViewPoint() { return editor3DScreenPoint(mainCanvasSize.scale(.5)); }
1138
+
1139
+ // move the camera back along its view until the selection is in the middle and fits
1140
+ function editor3DFrame()
1141
+ {
1142
+ const center = editor3DSelectionCenter(), camera = editor3DCamera;
1143
+ if (!center) return false;
1144
+ const radius = editor3DSelected().reduce((r, o)=>
1145
+ max(r, editor3DPos(o).distance(center) + editor3DSize(o).length() / 2), 0);
1146
+ camera.pos = center.subtract(camera.getForward().scale(max(radius * 2.5, 3)));
1147
+ render3D.updateMatrices();
1148
+ }
1149
+
1150
+ // drop the selection straight down onto what is under it, each object by itself, as one undo
1151
+ function editor3DDropSelection()
1152
+ {
1153
+ const ignore = editor3DSelectedInstances();
1154
+ if (!editor3DSelection.size) return false;
1155
+ editor3DStrokeEnd();
1156
+ editor3DChange((list)=>
1157
+ {
1158
+ for (const object of list)
1159
+ {
1160
+ const to = editor3DSelection.has(object.id) &&
1161
+ editor3DDrop(editor3DPos(object), editor3DSize(object), ignore);
1162
+ to && editor3DSetTransform(object, to);
1163
+ }
1164
+ });
1165
+ editor3DStrokeEnd();
1166
+ }
1167
+
1168
+ // paste the copied objects with their middle under the mouse, the lowest standing on what is there, snapped by
1169
+ // the first of them; where they were when the mouse is over nothing
1170
+ function editor3DPasteAtMouse()
1171
+ {
1172
+ const copied = editor3DClipboard, point = !editor3DMouseOnPanel && editor3DMousePoint();
1173
+ if (!copied?.length) return false;
1174
+ if (!point) return editor3DPaste();
1175
+ const center = copied.reduce((sum, o)=> sum.add(editor3DPos(o)), vec3()).scale(1 / copied.length);
1176
+ const bottom = copied.reduce((low, o)=> min(low, editor3DPos(o).y - editor3DSize(o).y / 2), Infinity);
1177
+ const first = copied[0], was = editor3DPos(first);
1178
+ const snapped = editor3DSnapPos(was.add(vec3(point.x - center.x, 0, point.z - center.z)), editor3DSize(first),
1179
+ editor3DGrid ? editor3DMoveStep : 0, editor3DTurned(first));
1180
+ const land = editor3DLand(snapped.x, snapped.z, point.y, editor3DSize(first)) - editor3DSize(first).y / 2;
1181
+ return editor3DPaste(vec3(snapped.x - was.x, land - bottom, snapped.z - was.z));
1182
+ }
1183
+
1184
+ // the keys by their position on the keyboard, each called with whether Shift is held; the tool keys are the ones
1185
+ // Unity, Unreal and Godot use
1186
+ /** @type {Object<string, function(boolean=): any>} */
1187
+ const editor3DKeys =
1188
+ {
1189
+ KeyQ: ()=> { editor3DTool = 'select'; editor3DBrush = undefined; },
1190
+ KeyW: ()=> { editor3DTool = 'move'; },
1191
+ KeyE: ()=> { editor3DTool = 'rotate'; },
1192
+ KeyR: ()=> { editor3DTool = 'scale'; },
1193
+ KeyG: ()=> { editor3DGrid = !editor3DGrid; },
1194
+ KeyF: ()=> editor3DFrame(),
1195
+ End: ()=> editor3DDropSelection(),
1196
+ Delete: ()=> editor3DDelete(),
1197
+ Backspace: ()=> editor3DDelete(),
1198
+ Slash: ()=> { editor3DHelp = !editor3DHelp; },
1199
+ };
1200
+ /** @type {Object<string, function(boolean=): any>} */
1201
+ const editor3DCtrlKeys =
1202
+ {
1203
+ KeyZ: (shift)=> editor3DUndo(shift),
1204
+ KeyY: ()=> editor3DUndo(true),
1205
+ KeyC: ()=> editor3DCopySelection(),
1206
+ KeyX: ()=> editor3DCut(),
1207
+ KeyV: ()=> editor3DPasteAtMouse(),
1208
+ KeyD: ()=> editor3DDuplicate(),
1209
+ };
1210
+
1211
+ // the camera: the right button looks and the keys fly while it is held, the middle button or Space and the left
1212
+ // pans, Alt and the left orbits the selection, and the wheel zooms toward what is under the mouse
1213
+ function editor3DCameraUpdate(seconds, shift, alt)
1214
+ {
1215
+ const camera = editor3DCamera, delta = inputCaptureDeltaScreen, wheel = inputCaptureWheel;
1216
+ const ahead = ()=> camera.pos.add(camera.getForward().scale(10));
1217
+ if (mouseIsDown(2))
1218
+ {
1219
+ editor3DFlySpeed = clamp(editor3DFlySpeed * Math.exp(-wheel * .2), .01, 10);
1220
+ editor3DFly(camera, editor3DFlyKeys(), delta, seconds, shift);
1221
+ }
1222
+ else if (mouseIsDown(1) || keyIsDown('Space') && mouseIsDown(0))
1223
+ {
1224
+ // the scene follows the mouse, at the depth of what is in the middle of the view
1225
+ const perPixel = editor3DScreenScale(editor3DViewPoint() ?? ahead()) / mainCanvasSize.y;
1226
+ camera.pos = camera.pos.subtract(camera.getRight().scale(delta.x * perPixel))
1227
+ .add(camera.getUp().scale(delta.y * perPixel));
1228
+ }
1229
+ else if (alt && mouseIsDown(0))
1230
+ {
1231
+ if (mouseWasPressed(0) || !editor3DOrbitPivot)
1232
+ editor3DOrbitPivot = editor3DSelectionCenter() ?? editor3DViewPoint() ?? ahead();
1233
+ const pivot = editor3DOrbitPivot, distance = camera.pos.distance(pivot);
1234
+ editor3DFly(camera, vec3(), delta, 0);
1235
+ camera.pos = pivot.subtract(camera.getForward().scale(distance));
1236
+ }
1237
+ else if (wheel && !editor3DMouseOnPanel)
1238
+ {
1239
+ // toward what is under the mouse, a part of the way there for each notch, and never through it
1240
+ render3D.updateMatrices();
1241
+ const ray = render3D.screenToRay(mousePosScreen), target = editor3DSurface(ray);
1242
+ const distance = target ? camera.pos.distance(target) : 10;
1243
+ camera.pos = camera.pos.add(ray.direction.scale(min(max(distance * .15, .2) * -wheel, distance - .5)));
1244
+ }
1245
+ render3D.updateMatrices(); // what follows picks and projects through the camera where it is now
1246
+ }
1247
+
1248
+ // the editor's update, called while it is open with the input readable and render3D looking through its camera
1249
+ function editor3DEditorUpdate(seconds)
1250
+ {
1251
+ const held = (...codes)=> codes.some((code)=> keyIsDown(code));
1252
+ const shift = held('ShiftLeft', 'ShiftRight'), alt = held('AltLeft', 'AltRight');
1253
+ const ctrl = held('ControlLeft', 'ControlRight', 'MetaLeft', 'MetaRight');
1254
+ const mouse = mousePosScreen;
1255
+
1256
+ // Escape, the debug key, puts a drag back, or else switches to playing; taken, so the debug overlay stays shut
1257
+ if (debugKey && keyWasPressed(debugKey))
1258
+ {
1259
+ inputClearKey(debugKey);
1260
+ editor3DDrag ? editor3DDragCancel() : editor3DPlay(editor3DMousePoint());
1261
+ return;
1262
+ }
1263
+
1264
+ // 0 exits the editor; cleared, so debugKeysAlways does not open it again in the same step
1265
+ if (keyWasPressed('Digit0'))
1266
+ {
1267
+ inputClearKey('Digit0');
1268
+ editor3DClose();
1269
+ return;
1270
+ }
1271
+
1272
+ editor3DCameraUpdate(seconds, shift, alt);
1273
+ const looking = mouseIsDown(2), cameraDrag = looking || mouseIsDown(1) || keyIsDown('Space') || alt;
1274
+
1275
+ // the keys, which wait for a drag to end, and fly while the right button is held
1276
+ if (!editor3DDrag && !looking)
1277
+ for (const [code, action] of Object.entries(ctrl ? editor3DCtrlKeys : editor3DKeys))
1278
+ keyWasPressed(code) && action(shift);
1279
+
1280
+ // a right press during a drag puts it back
1281
+ if (editor3DDrag && mouseWasPressed(2))
1282
+ return editor3DDragCancel();
1283
+
1284
+ const ray = render3D.screenToRay(mouse), idle = !cameraDrag && !editor3DMouseOnPanel && !editor3DDrag;
1285
+ editor3DHover = idle ? editor3DHandleAt(mouse) : undefined;
1286
+ if (idle && mouseWasPressed(0))
1287
+ editor3DPress(mouse, ray, shift);
1288
+
1289
+ // a quick click, down and up before this step, is a press and its release in one
1290
+ const drag = editor3DDrag;
1291
+ if (drag)
1292
+ mouseIsDown(0) ? editor3DDragTo(drag, mouse, ray, editor3DGrid !== ctrl) : editor3DDragEnd(drag, mouse);
1293
+ }
1294
+
1295
+ // Ctrl with a letter the editor uses is not the browser's while the editor is open, Ctrl+D would bookmark the page
1296
+ if (debug && globalThis.document?.addEventListener)
1297
+ document.addEventListener('keydown', (e)=>
1298
+ {
1299
+ if (editor3DIsOpen && (e.ctrlKey || e.metaKey) && /^Key[ZYCXVD]$/.test(e.code) && !editorIsTextField(e.target))
1300
+ e.preventDefault();
1301
+ });
1302
+
1303
+ ///////////////////////////////////////////////////////////////////////////////
1304
+ // what the panel does to the level, and what it says
1305
+
1306
+ // the help, every control
1307
+ const editor3DHelpLines =
1308
+ [
1309
+ 'Left: select · Shift+Left: add or take away · Left drag from empty space: box select',
1310
+ 'Q select · W move · E rotate · R scale: drag a handle, or the object itself',
1311
+ 'G: grid snap, Ctrl flips it for a drag · End: drop to the ground',
1312
+ 'Right button: look, and WASD and QE fly while it is held, Shift faster',
1313
+ 'Wheel: zoom · Middle drag or Space+drag: pan · Alt+drag: orbit · F: frame the selection',
1314
+ 'Pick a type, then click to place it, Shift+click keeps placing',
1315
+ 'Delete · Ctrl+C / X / V: copy, cut, paste · Ctrl+D: duplicate · Ctrl+Z / Y: undo, redo',
1316
+ 'Reset to file: the level as its file has it, Restart keeps your edits, Undo brings them back',
1317
+ 'Esc: play and edit · 0: exit the editor · ?: keys',
1318
+ ];
1319
+
1320
+ // the hint line, for what is held and what is selected
1321
+ function editor3DHint()
1322
+ {
1323
+ if (editor3DRecords.get(editor3DLevel)?.pending)
1324
+ return 'The level file changed: apply your edits or drop them';
1325
+ if (editor3DDrag)
1326
+ return editor3DDrag.kind === 'box' ? 'Let go to select what is inside' :
1327
+ 'Esc or right click puts it back · Ctrl flips the snap';
1328
+ if (inputCaptureRead(()=> mouseIsDown(2)))
1329
+ return 'WASD and QE fly · Shift faster · the wheel sets the speed';
1330
+ if (editor3DBrush)
1331
+ return `Click to place a ${editor3DBrush} · Shift keeps placing · Q puts it down`;
1332
+ if (editor3DSelection.size)
1333
+ return 'Drag a handle or the object · W move · E rotate · R scale · End drops it';
1334
+ return 'Click to select · pick a type to place · the right button looks · ? keys';
1335
+ }
1336
+
1337
+ // if the properties box has an input for a default's type
1338
+ const editor3DPropertyEditable = (value)=> ['number', 'boolean', 'string'].includes(typeof value) ||
1339
+ isColor(value) || isVector2(value) || isVector3(value);
1340
+
1341
+ // set the position, rotation in degrees or scale of the one selected object, from the panel, as one undo
1342
+ function editor3DSetSelectedTransform(field, value)
1343
+ {
1344
+ const selected = editor3DSelected(), id = selected[0]?.id;
1345
+ if (selected.length !== 1 || !isVector3(value)) return false;
1346
+ editor3DStrokeEnd();
1347
+ editor3DChange((list)=> editor3DSetTransform(list.find((o)=> o.id === id), field === 'pos' ? value : undefined,
1348
+ field === 'rotation' ? value : undefined, field === 'scale' ? value : undefined));
1349
+ editor3DStrokeEnd();
1350
+ return true;
1351
+ }
1352
+
1353
+ // set a property of the one selected object, from the panel, as one undo; the value has the type of its default
1354
+ function editor3DSetSelectedProperty(name, value)
1355
+ {
1356
+ const selected = editor3DSelected(), id = selected[0]?.id;
1357
+ const defaults = selected[0] && level3DTypes.get(selected[0].type)?.defaults;
1358
+ const d = defaults && Object.hasOwn(defaults, name) ? defaults[name] : undefined;
1359
+ const sameType = isColor(d) ? isColor(value) : isVector3(d) ? isVector3(value) :
1360
+ isVector2(d) ? isVector2(value) : typeof value === typeof d;
1361
+ if (selected.length !== 1 || !editor3DPropertyEditable(d) || !sameType) return false;
1362
+ editor3DStrokeEnd();
1363
+ editor3DChange((list)=> editor3DSetProperty(list.find((o)=> o.id === id), name, value, d));
1364
+ editor3DStrokeEnd();
1365
+ return true;
1366
+ }
1367
+
1368
+ ///////////////////////////////////////////////////////////////////////////////
1369
+ // what the editor draws
1370
+
1371
+ const EDITOR3D_AXIS_COLORS = {x: hsl(0, .9, .55), y: hsl(.33, .9, .45), z: hsl(.6, .9, .6)};
1372
+ const EDITOR3D_SELECT_COLOR = hsl(.15, 1, .6), EDITOR3D_MARKER_COLOR = hsl(.55, 1, .7);
1373
+ const EDITOR3D_SOLID_COLOR = hsl(.08, 1, .55);
1374
+
1375
+ // a width in world units that shows as a number of pixels at a point
1376
+ function editor3DPixels(point, pixels) { return editor3DScreenScale(point) / mainCanvasSize.y * pixels; }
1377
+
1378
+ // the 12 edges of the unit box a matrix places
1379
+ function editor3DDrawWire(matrix, color, pixels=2)
1380
+ {
1381
+ const corner = (i)=> matrix.transformPoint(vec3(i & 1 ? .5 : -.5, i & 2 ? .5 : -.5, i & 4 ? .5 : -.5));
1382
+ const width = editor3DPixels(matrix.getTranslation(), pixels);
1383
+ for (let i = 0; i < 8; ++i)
1384
+ for (const bit of [1, 2, 4])
1385
+ i & bit || render3D.drawLine(corner(i), corner(i | bit), width, color);
1386
+ }
1387
+
1388
+ // a ring around a point across two axes
1389
+ function editor3DDrawRing(center, across, along, radius, color, pixels=2)
1390
+ {
1391
+ const points = [];
1392
+ for (let i = 0; i <= 32; ++i)
1393
+ {
1394
+ const a = i / 32 * PI * 2;
1395
+ points.push(center.add(across.scale(cos(a) * radius)).add(along.scale(sin(a) * radius)));
1396
+ }
1397
+ render3D.drawRibbon(points, editor3DPixels(center, pixels), undefined, color);
1398
+ }
1399
+
1400
+ // what the game made for an object, when it has something to see and click
1401
+ function editor3DVisible(object)
1402
+ {
1403
+ const made = editor3DInstances.get(object.id);
1404
+ return made instanceof EngineObject3D && !made.destroyed && (made.mesh || made.tileInfo) ? made : undefined;
1405
+ }
1406
+
1407
+ // the box around an object as a matrix: around its mesh where it is, turned and sized as it is, a unit box for a
1408
+ // marker
1409
+ function editor3DBoxMatrix(object)
1410
+ {
1411
+ const made = editor3DVisible(object), bounds = made?.mesh && (made.mesh.bounds || made.mesh.getBounds());
1412
+ if (!made)
1413
+ return buildMatrix(editor3DPos(object));
1414
+ const matrix = render3DObjectMatrix(made).copy();
1415
+ return bounds ? matrix.multiply(buildMatrix(bounds.min.add(bounds.max).scale(.5), undefined,
1416
+ bounds.max.subtract(bounds.min))) : matrix.multiply(buildMatrix(vec3(), undefined, made.size3D));
1417
+ }
1418
+
1419
+ // the ground grid at height 0 around where the camera is, a line each unit and a stronger one each 10, fading
1420
+ // with distance; drawn with the depth test so the level hides it, and a hair over 0 so a floor there does not
1421
+ function editor3DDrawGrid()
1422
+ {
1423
+ const camera = editor3DCamera.pos, reach = 40, y = .01;
1424
+ const x0 = round(camera.x), z0 = round(camera.z);
1425
+ for (let i = -reach; i <= reach; ++i)
1426
+ for (const across of [false, true])
1427
+ {
1428
+ const at = (across ? z0 : x0) + i, strong = at % 10 === 0;
1429
+ if (!strong && abs(i) > reach / 2) continue;
1430
+ const fade = 1 - abs(i) / (strong ? reach : reach / 2);
1431
+ const a = across ? vec3(x0 - reach, y, at) : vec3(at, y, z0 - reach);
1432
+ const b = across ? vec3(x0 + reach, y, at) : vec3(at, y, z0 + reach);
1433
+ render3D.drawLine(a, b, strong ? .04 : .02, hsl(0, 0, 1, (strong ? .35 : .15) * fade));
1434
+ }
1435
+ }
1436
+
1437
+ // the cone a move arrow ends in, standing on Y, made the first time one is drawn
1438
+ let editor3DCone;
1439
+ const editor3DConeMesh = ()=> editor3DCone ||= buildCone(1, 1, 12);
1440
+
1441
+ // the editor's drawing in the 3D pass: the grid, a marker for what has nothing to see, the collision shape of
1442
+ // what is solid and selected, the selection, the brush where it would go, and the handles
1443
+ function editor3DDraw()
1444
+ {
1445
+ const r = render3D;
1446
+ render3DWithState({depthTest: true}, editor3DDrawGrid);
1447
+ for (const object of editor3DObjects())
1448
+ {
1449
+ const made = editor3DVisible(object), selected = editor3DSelection.has(object.id);
1450
+ made || editor3DDrawWire(editor3DBoxMatrix(object), EDITOR3D_MARKER_COLOR, 1.5);
1451
+ selected && editor3DDrawWire(editor3DBoxMatrix(object), EDITOR3D_SELECT_COLOR, 2.5);
1452
+ if (made?.collideSolidObjects && !made.parent && selected)
1453
+ {
1454
+ // what it collides as, which turns with it, where its size3D is not its mesh's
1455
+ const shape = render3DSolidShape(made), color = EDITOR3D_SOLID_COLOR;
1456
+ if (shape.size)
1457
+ editor3DDrawWire(buildMatrix(shape.pos, shape.axes && made.rotation3D, shape.size), color, 1.5);
1458
+ else
1459
+ for (const [across, along] of [['x', 'y'], ['y', 'z'], ['z', 'x']])
1460
+ editor3DDrawRing(shape.pos, EDITOR3D_AXES[across], EDITOR3D_AXES[along], shape.radius, color, 1.5);
1461
+ }
1462
+ }
1463
+
1464
+ // the brush, a box where a click would place it
1465
+ const point = editor3DBrush && !editor3DMouseOnPanel && !editor3DDrag && editor3DMousePoint();
1466
+ if (point)
1467
+ {
1468
+ const snapped = editor3DSnapPos(editor3DRest(point, vec3(1)), vec3(1), editor3DGrid ? editor3DMoveStep : 0);
1469
+ editor3DDrawWire(buildMatrix(vec3(snapped.x, point.y + .5, snapped.z)), hsl(0, 0, 1, .7));
1470
+ }
1471
+
1472
+ // the handles, the one under the mouse or being dragged in the selection's color
1473
+ for (const handle of editor3DHandles())
1474
+ {
1475
+ const {kind, axis, center, length, points} = handle;
1476
+ const taken = (editor3DDrag?.handle ?? editor3DHover);
1477
+ const lit = taken && taken.kind === kind && taken.axis === axis;
1478
+ const color = lit ? EDITOR3D_SELECT_COLOR : EDITOR3D_AXIS_COLORS[axis[0]] ?? WHITE;
1479
+ const width = editor3DPixels(center, lit ? 4 : 3), tip = length * .1;
1480
+ if (kind === 'ring')
1481
+ r.drawRibbon(points, width, undefined, color);
1482
+ else if (kind === 'plane')
1483
+ {
1484
+ // a flat square across its two axes, in the color of the axis it does not move along
1485
+ const size = vec3(tip * 1.6).subtract(handle.direction.scale(tip * 1.5));
1486
+ r.drawBox(points[0], size, lit ? color : EDITOR3D_AXIS_COLORS['xyz'.replace(axis[0], '')
1487
+ .replace(axis[1], '')].scale(1, .7));
1488
+ }
1489
+ else if (kind === 'scaleAll')
1490
+ r.drawBox(center, tip * 1.4, lit ? color : WHITE);
1491
+ else if (kind === 'arrow')
1492
+ {
1493
+ // a move arrow ends in a cone pointing along it, which says move this way, where scale has a box
1494
+ r.drawLine(points[0], points[1], width, color);
1495
+ const turn = axis === 'x' ? vec3(0, 0, -PI / 2) : axis === 'z' ? vec3(PI / 2, 0, 0) : undefined;
1496
+ const at = points[1].add(handle.direction.scale(tip));
1497
+ r.drawMesh(editor3DConeMesh(), buildMatrix(at, turn, vec3(tip * 1.4, tip * 2, tip * 1.4)), undefined, color);
1498
+ }
1499
+ else
1500
+ {
1501
+ r.drawLine(points[0], points[1], width, color);
1502
+ r.drawBox(points[1], tip, color, editor3DRotation(editor3DSelected()[0]).scale(PI / 180));
1503
+ }
1504
+ }
1505
+ }
1506
+
1507
+ // the editor's drawing on the 2D layer: the name of each marker, and the box being dragged out to select with
1508
+ function editor3DDrawLabels()
1509
+ {
1510
+ for (const object of editor3DObjects())
1511
+ {
1512
+ if (editor3DVisible(object)) continue;
1513
+ const at = render3D.worldToScreen(editor3DPos(object));
1514
+ at && drawTextScreen(object.type + '', at, 14, WHITE, 3, BLACK);
1515
+ }
1516
+ const drag = editor3DDrag;
1517
+ if (drag?.kind === 'box')
1518
+ {
1519
+ const {from: a, to: b} = drag, corners = [a, vec2(b.x, a.y), b, vec2(a.x, b.y)];
1520
+ corners.forEach((c, i)=> drawLine(c, corners[(i + 1) % 4], 2, EDITOR3D_SELECT_COLOR, undefined, 0, glEnable, true));
1521
+ }
1522
+ }
1523
+
1524
+ ///////////////////////////////////////////////////////////////////////////////
1525
+ // the panel, in the look of the 2D level editor's, made the first time the editor opens
1526
+
1527
+ let editor3DPanel, editor3DPanelParts;
1528
+
1529
+ function editor3DPanelInit()
1530
+ {
1531
+ const panel = editor3DPanel = editorElement('div', document.body,
1532
+ 'position:fixed;top:8px;left:8px;width:260px;max-height:calc(100% - 16px);overflow-y:auto;' +
1533
+ 'box-sizing:border-box;padding:8px;background:#111d;color:#eee;font:12px monospace;' +
1534
+ 'border-radius:4px;z-index:9999;user-select:none');
1535
+
1536
+ // a click or touch on the panel is not the level's, a mouse up still goes on so a drag can let go
1537
+ for (const type of ['mousedown','wheel','touchstart','touchmove','touchend','touchcancel'])
1538
+ panel.addEventListener(type, (e)=> e.stopPropagation());
1539
+ panel.addEventListener('mouseenter', ()=> editor3DMouseOnPanel = true);
1540
+ panel.addEventListener('mouseleave', ()=> editor3DMouseOnPanel = false);
1541
+
1542
+ const box = 'margin:4px 0;padding:4px;background:#222;border-radius:3px';
1543
+ const row = (parent=panel)=> editorElement('div', parent, 'display:flex;gap:4px;margin:4px 0;flex-wrap:wrap');
1544
+ const button = (parent, text, onclick, title='')=>
1545
+ {
1546
+ const b = editorElement('button', parent, 'flex:1;padding:3px;cursor:pointer', text);
1547
+ b.onclick = (e)=> { onclick(e); b.blur(); }; // the keys go back to the editor
1548
+ b.title = title;
1549
+ return b;
1550
+ };
1551
+ const check = (parent, text, onchange, title='')=>
1552
+ {
1553
+ const label = editorElement('label', parent, 'display:flex;gap:6px;align-items:center;margin:2px 0');
1554
+ const input = editorElement('input', label);
1555
+ input.type = 'checkbox';
1556
+ input.onchange = ()=> { onchange(input.checked); input.blur(); };
1557
+ editorElement('span', label, '', text);
1558
+ label.title = title;
1559
+ return input;
1560
+ };
1561
+ const choice = (parent, text, values, onchange)=>
1562
+ {
1563
+ const label = editorElement('label', parent, 'display:flex;gap:3px;align-items:center;flex:1');
1564
+ editorElement('span', label, '', text);
1565
+ const select = editorElement('select', label, 'flex:1;background:#333;color:#eee');
1566
+ for (const value of values)
1567
+ editorElement('option', select, '', value + '').value = value + '';
1568
+ select.onchange = ()=> { onchange(parseFloat(select.value)); select.blur(); };
1569
+ return select;
1570
+ };
1571
+
1572
+ editorElement('div', panel, 'font-weight:bold', '3D Level Editor');
1573
+ const top = row();
1574
+ button(top, 'Play', ()=> editor3DPlay(editor3DViewPoint()), 'Esc, and Esc again comes back to the editor');
1575
+ const restart = button(top, 'Restart', editor3DRestart, 'Rebuild the level and play it');
1576
+ button(top, 'Exit', ()=> levelEditor.close(), '0, then Esc opens the debug overlay again');
1577
+ const undo = row();
1578
+ button(undo, 'Undo', ()=> editor3DUndo(), 'Ctrl+Z');
1579
+ button(undo, 'Redo', ()=> editor3DUndo(true), 'Ctrl+Y');
1580
+ button(undo, 'Keys', ()=> editor3DHelp = !editor3DHelp, 'Every control, ?');
1581
+
1582
+ // a file that changed under its autosave
1583
+ const pending = editorElement('div', panel, 'padding:4px;margin:4px 0;background:#630;border-radius:3px');
1584
+ editorElement('div', pending, '', 'The level file changed since your autosaved edits');
1585
+ const pendingRow = editorElement('div', pending, 'display:flex;gap:4px;margin-top:4px');
1586
+ button(pendingRow, 'Apply edits', ()=> editor3DApplyPending());
1587
+ button(pendingRow, 'Drop them', ()=> editor3DDropPending());
1588
+
1589
+ // the tools, and the snapping
1590
+ const tools = row(), toolButtons = {};
1591
+ for (const [tool, text, key] of [['select', 'Select', 'Q'], ['move', 'Move', 'W'], ['rotate', 'Rotate', 'E'],
1592
+ ['scale', 'Scale', 'R']])
1593
+ toolButtons[tool] = button(tools, text, ()=>
1594
+ {
1595
+ editor3DTool = tool;
1596
+ tool === 'select' && (editor3DBrush = undefined);
1597
+ }, key);
1598
+ const snap = editorElement('div', panel, box);
1599
+ const grid = check(snap, 'Grid snap', (on)=> editor3DGrid = on, 'G, and Ctrl flips it for a drag');
1600
+ const steps = row(snap);
1601
+ const moveStep = choice(steps, 'Move', [1, .5, .25], (v)=> editor3DMoveStep = v);
1602
+ const rotateStep = choice(steps, 'Turn', [15, 45, 90], (v)=> editor3DRotateStep = v);
1603
+ const scaleStep = choice(steps, 'Size', [1, .5, .25], (v)=> editor3DScaleStep = v);
1604
+ const ground = check(snap, 'Ground snap', (on)=> editor3DGroundSnap = on,
1605
+ 'A drag of an object slides it along what is under the mouse');
1606
+
1607
+ // the types, a click picks one up to place, made again when types are added
1608
+ editorElement('div', panel, 'color:#aaa;margin-top:4px', 'Place');
1609
+ const types = row();
1610
+ const properties = editorElement('div', panel, box);
1611
+
1612
+ const file = row();
1613
+ // Save says so for a moment when it saved, Save As shows where a page can write files
1614
+ const saved = (b, label)=> (result)=> result && (b.textContent = 'Saved', setTimeout(()=> b.textContent = label, 1e3));
1615
+ const save = button(file, 'Save', ()=> editor3DSave().then(saved(save, 'Save')),
1616
+ 'Save, to the file picked the first time, or a download');
1617
+ const saveAs = button(file, 'Save As', ()=> editor3DSave(true).then(saved(saveAs, 'Save As')),
1618
+ 'Save As, to a file picked again');
1619
+ button(file, 'Reset to file', ()=> editor3DRevert(),
1620
+ 'Put the level back as its file has it, or as Save last wrote it; Undo brings the edits back');
1621
+ /** @type {any} */ (globalThis).showSaveFilePicker || (saveAs.style.display = 'none');
1622
+ const playFrom = check(panel, 'Play from mouse', (on)=> editor3DPlayFromMouse = on,
1623
+ 'Escape starts play with the player at the mouse, Play at the middle of the view');
1624
+
1625
+ const storage = editorElement('div', panel, 'color:#f86;margin-top:4px',
1626
+ 'Autosave failed, storage is full: Save to a file');
1627
+ const hint = editorElement('div', panel, 'color:#8ab;margin-top:4px');
1628
+ const help = editorElement('div', panel, 'color:#aaa;margin-top:4px;border-top:1px solid #444;padding-top:4px');
1629
+ for (const line of editor3DHelpLines)
1630
+ editorElement('div', help, 'margin:2px 0', line);
1631
+ button(help, 'Close', ()=> editor3DHelp = false, '?');
1632
+
1633
+ editor3DPanelParts = {restart, pending, toolButtons, grid, moveStep, rotateStep, scaleStep, ground, types,
1634
+ properties, playFrom, storage, hint, help, typeNames: ''};
1635
+ }
1636
+
1637
+ // show the panel as the editor is now
1638
+ function editor3DPanelUpdate()
1639
+ {
1640
+ if (!editor3DIsOpen)
1641
+ {
1642
+ editor3DPanel && (editor3DPanel.style.display = 'none');
1643
+ return;
1644
+ }
1645
+ editor3DPanel || editor3DPanelInit();
1646
+ editor3DPanel.style.display = '';
1647
+ const p = editor3DPanelParts, lit = '2px solid #4af';
1648
+ p.restart.style.display = levelEditor.onRestart ? '' : 'none';
1649
+ p.pending.style.display = editor3DRecords.get(editor3DLevel)?.pending ? '' : 'none';
1650
+ for (const tool in p.toolButtons)
1651
+ p.toolButtons[tool].style.outline = tool === editor3DTool ? lit : '';
1652
+ p.grid.checked = editor3DGrid;
1653
+ p.ground.checked = editor3DGroundSnap;
1654
+ p.moveStep.value = editor3DMoveStep + '';
1655
+ p.rotateStep.value = editor3DRotateStep + '';
1656
+ p.scaleStep.value = editor3DScaleStep + '';
1657
+ p.playFrom.checked = editor3DPlayFromMouse;
1658
+ p.playFrom.parentElement.style.display = levelEditor.onPlayFrom ? 'flex' : 'none';
1659
+ p.storage.style.display = editor3DSaveFailed ? '' : 'none';
1660
+ p.hint.textContent = editor3DHint();
1661
+ p.help.style.display = editor3DHelp ? '' : 'none';
1662
+
1663
+ // a button for each type, made again when the types changed
1664
+ const names = [...level3DTypes.keys()], typeNames = names.join('\n');
1665
+ if (p.typeNames !== typeNames)
1666
+ {
1667
+ p.typeNames = typeNames;
1668
+ p.types.replaceChildren(...names.map((name)=>
1669
+ {
1670
+ const b = editorElement('button', undefined, 'padding:3px 6px;cursor:pointer', name);
1671
+ b.onclick = ()=> { editor3DBrush = editor3DBrush === name ? undefined : name; b.blur(); };
1672
+ return b;
1673
+ }));
1674
+ }
1675
+ names.forEach((name, i)=> p.types.children[i].style.outline = name === editor3DBrush ? lit : '');
1676
+ editor3DPropertiesUpdate(p.properties);
1677
+ }
1678
+
1679
+ // the properties box: the position, rotation and scale of the one selected object and an input for each default
1680
+ // of its type, made again when the selection or the object changes, but not while one of its inputs is typed in
1681
+ function editor3DPropertiesUpdate(box)
1682
+ {
1683
+ const selected = editor3DSelected(), object = selected.length === 1 && selected[0];
1684
+ const type = object && level3DTypes.get(object.type);
1685
+ const key = object ? JSON.stringify(object) : selected.length ? selected.length + ' objects' : '';
1686
+ box.style.display = key ? '' : 'none';
1687
+ if (box.dataset.key === key || box.contains(document.activeElement)) return;
1688
+ box.dataset.key = key;
1689
+ box.replaceChildren();
1690
+ if (!object)
1691
+ {
1692
+ key && editorElement('div', box, 'color:#aaa', key + ' selected');
1693
+ return;
1694
+ }
1695
+ editorElement('div', box, 'color:#aaa;margin-bottom:2px', `${object.type} ${object.id}`);
1696
+ const field = 'background:#222;color:#eee';
1697
+ const line = (name)=>
1698
+ {
1699
+ const row = editorElement('label', box, 'display:flex;gap:4px;align-items:center;margin:2px 0');
1700
+ editorElement('span', row, 'flex:1', name);
1701
+ return row;
1702
+ };
1703
+
1704
+ // the numbers of a vector, set together once every one of them reads as a number
1705
+ const numbers = (name, value, set)=>
1706
+ {
1707
+ const row = line(name), axes = isVector3(value) ? 'xyz' : 'xy', inputs = [];
1708
+ for (const axis of axes)
1709
+ {
1710
+ const input = editorElement('input', row, field + ';width:' + (axes.length > 2 ? 46 : 52) + 'px');
1711
+ input.type = 'number';
1712
+ input.step = 'any';
1713
+ input.value = editor3DRound(value[axis]) + '';
1714
+ input.onchange = ()=>
1715
+ {
1716
+ const v = inputs.map((i)=> parseFloat(i.value));
1717
+ if (v.every((n)=> isNumber(n)))
1718
+ set(axes.length > 2 ? vec3(v[0], v[1], v[2]) : vec2(v[0], v[1]));
1719
+ else
1720
+ inputs.forEach((i, k)=> i.value = editor3DRound(value[axes[k]]) + '');
1721
+ input.blur();
1722
+ };
1723
+ inputs.push(input);
1724
+ }
1725
+ };
1726
+ numbers('pos', editor3DPos(object), (v)=> editor3DSetSelectedTransform('pos', v));
1727
+ numbers('rotation', editor3DRotation(object), (v)=> editor3DSetSelectedTransform('rotation', v));
1728
+ numbers('scale', editor3DScale(object), (v)=> editor3DSetSelectedTransform('scale', v));
1729
+ if (!type) return;
1730
+
1731
+ const values = level3DProperties(type, object);
1732
+ for (const [name, defaultValue] of Object.entries(type.defaults))
1733
+ {
1734
+ const value = values[name];
1735
+ if (isVector3(defaultValue) || isVector2(defaultValue))
1736
+ {
1737
+ numbers(name, value, (v)=> editor3DSetSelectedProperty(name, v));
1738
+ continue;
1739
+ }
1740
+ const input = editorElement('input', line(name), field + ';width:110px');
1741
+ const set = (v)=> { editor3DSetSelectedProperty(name, v); input.blur(); };
1742
+ if (typeof defaultValue === 'boolean')
1743
+ {
1744
+ input.type = 'checkbox';
1745
+ input.checked = !!value;
1746
+ input.onchange = ()=> set(input.checked);
1747
+ }
1748
+ else if (isColor(defaultValue))
1749
+ {
1750
+ input.type = 'color';
1751
+ input.value = value.toString(false);
1752
+ input.onchange = ()=> { const color = new Color().setHex(input.value); color.a = value.a; set(color); };
1753
+ }
1754
+ else if (typeof defaultValue === 'number')
1755
+ {
1756
+ input.type = 'number';
1757
+ input.step = 'any';
1758
+ input.value = value + '';
1759
+ input.onchange = ()=> { const v = parseFloat(input.value); isNumber(v) && set(v); };
1760
+ }
1761
+ else if (typeof defaultValue === 'string')
1762
+ {
1763
+ input.type = 'text';
1764
+ input.value = (value ?? '') + '';
1765
+ input.onchange = ()=> set(input.value);
1766
+ }
1767
+ else
1768
+ {
1769
+ // a type it has no input for, an array say, shown as it is
1770
+ input.readOnly = true;
1771
+ input.value = value + '';
1772
+ }
1773
+ }
1774
+ }
1775
+
1776
+ ///////////////////////////////////////////////////////////////////////////////
1777
+ // plugin
1778
+
1779
+ function editor3DUpdate()
1780
+ {
1781
+ const seconds = clamp(timeReal - editor3DTimeLast, 0, .1);
1782
+ editor3DTimeLast = timeReal;
1783
+ if (!render3D) return;
1784
+ if (editor3DFreeCamera)
1785
+ return inputCaptureRead(()=> editor3DFreeCameraUpdate(seconds));
1786
+ if (editor3DIsOpen)
1787
+ return inputCaptureRead(()=> editor3DWithView(()=> editor3DEditorUpdate(seconds)));
1788
+
1789
+ // playing in an editing session, Escape, the debug key, goes back to the editor; taken, so the debug overlay
1790
+ // waits till the session ends; a captured mouse the browser let go of is Escape too, Chrome keeps that key
1791
+ const lostLock = editor3DLostLock(pointerLockIsActive());
1792
+ if (editor3DSession && (lostLock || debugKey && keyWasPressed(debugKey)))
1793
+ {
1794
+ debugKey && inputClearKey(debugKey);
1795
+ editor3DSetOpen(true);
1796
+ return;
1797
+ }
1798
+ editor3DFreeCameraKey();
1799
+ }
1800
+
1801
+ function editor3DRender()
1802
+ {
1803
+ if (headlessMode) return;
1804
+ editor3DPanelUpdate();
1805
+ if (editor3DFreeCamera)
1806
+ drawTextScreen('Free camera · C or Esc to leave', vec2(mainCanvasSize.x / 2, mainCanvasSize.y - 30), 20, WHITE,
1807
+ 4, BLACK);
1808
+ editor3DIsOpen && editor3DDrawLabels();
1809
+ }
1810
+
1811
+ debug && engineAddPlugin(editor3DUpdate, editor3DRender);
1812
+ debug && debugOverlayKeys.push(()=> render3D && {text: 'C: Free Camera', on: editor3DFreeCamera});