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.
- package/FAQ.md +841 -820
- package/README.md +29 -25
- package/dist/littlejs.d.ts +2857 -955
- package/dist/littlejs.esm.js +33428 -20995
- package/dist/littlejs.esm.min.js +4 -3
- package/dist/littlejs.js +33386 -20996
- package/dist/littlejs.min.js +2 -2
- package/dist/littlejs.release.js +27841 -20422
- package/package.json +10 -5
- package/plugins/audioEffects.js +28 -24
- package/plugins/box2d.js +2516 -2073
- package/plugins/drawUtilities.js +206 -31
- package/plugins/gltf.js +786 -0
- package/plugins/jsconfig.json +19 -0
- package/plugins/lightSystem.js +719 -332
- package/plugins/math3d.js +148 -27
- package/plugins/medalSystem.js +326 -280
- package/plugins/newgrounds.js +400 -199
- package/plugins/pathFinder.js +208 -175
- package/plugins/pluginExport.js +25 -0
- package/plugins/postProcess.js +96 -24
- package/plugins/render3d.js +2067 -1632
- package/plugins/render3dDebug.js +110 -0
- package/plugins/render3dEditor.js +1812 -0
- package/plugins/render3dExtras.js +1948 -0
- package/plugins/render3dLevel.js +189 -0
- package/plugins/render3dVoxels.js +514 -0
- package/plugins/sceneSystem.js +58 -0
- package/plugins/textureSheet.js +496 -458
- package/plugins/threejs.js +183 -163
- package/plugins/tweakables.js +411 -0
- package/plugins/tweenSystem.js +265 -145
- package/plugins/uiSystem.js +2024 -1687
- package/plugins/zzfxm.js +159 -159
- package/src/engine.js +378 -97
- package/src/engineAudio.js +231 -127
- package/src/engineBuild.mjs +292 -256
- package/src/engineDebug.js +967 -743
- package/src/engineDraw.js +391 -198
- package/src/engineEditor.js +2452 -0
- package/src/engineExport.js +18 -0
- package/src/engineInput.js +1466 -1293
- package/src/engineLogo.js +19 -0
- package/src/engineMath.js +1274 -1252
- package/src/engineObject.js +197 -82
- package/src/engineParticles.js +583 -532
- package/src/engineRelease.js +79 -45
- package/src/engineSettings.js +115 -29
- package/src/engineTileLayer.js +995 -741
- package/src/engineUtilities.js +19 -13
- package/src/engineWebGL.js +239 -82
- package/src/jsconfig.json +17 -13
- package/plugins/box2d.wasm.js +0 -630
- 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});
|