littlejsengine 1.20.0 → 1.22.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/dist/littlejs.d.ts +1041 -81
- package/dist/littlejs.esm.js +6191 -379
- package/dist/littlejs.esm.min.js +22 -9
- package/dist/littlejs.js +6238 -476
- package/dist/littlejs.min.js +21 -8
- package/dist/littlejs.release.js +3500 -387
- package/package.json +3 -2
- package/plugins/gltf.js +61 -9
- package/plugins/lightSystem.js +44 -1
- package/plugins/math3d.js +132 -30
- package/plugins/particleEffects.js +745 -0
- package/plugins/pluginExport.js +42 -0
- package/plugins/postProcess.js +246 -39
- package/plugins/render3d.js +850 -182
- package/plugins/render3dDebug.js +2 -1
- package/plugins/render3dEditor.js +2576 -0
- package/plugins/render3dExtras.js +421 -15
- package/plugins/render3dLevel.js +352 -0
- package/plugins/render3dVoxels.js +12 -0
- package/plugins/sceneSystem.js +59 -0
- package/plugins/tweenSystem.js +4 -2
- package/src/engine.js +199 -34
- package/src/engineAudio.js +18 -9
- package/src/engineBuild.mjs +4 -0
- package/src/engineDebug.js +55 -1
- package/src/engineDraw.js +141 -7
- package/src/engineEditor.js +52 -22
- package/src/engineExport.js +8 -0
- package/src/engineInput.js +8 -4
- package/src/engineLogo.js +19 -0
- package/src/engineObject.js +3 -2
- package/src/engineParticles.js +80 -11
- package/src/engineRelease.js +13 -1
- package/src/engineSettings.js +38 -0
- package/src/engineWebGL.js +27 -10
|
@@ -0,0 +1,2576 @@
|
|
|
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
|
+
// the level's, each entry its object list and its parts, the scene and the blocks, before and after an edit
|
|
295
|
+
let editor3DUndoList = [], editor3DRedoList = [];
|
|
296
|
+
// the edit being made, a drag is one: the objects and the parts as they were before it, and painted when blocks
|
|
297
|
+
// were set in the game's map and are not in the level yet
|
|
298
|
+
/** @type {{before: Array<Object>, parts: Object, painted?: boolean, sculpted?: boolean}|undefined} */
|
|
299
|
+
let editor3DStroke;
|
|
300
|
+
|
|
301
|
+
const editor3DCopy = (value)=> JSON.parse(JSON.stringify(value));
|
|
302
|
+
const editor3DSame = (a, b)=> JSON.stringify(a) === JSON.stringify(b);
|
|
303
|
+
const editor3DRound = (v)=> round(v * 1e4) / 1e4; // as the file keeps a number
|
|
304
|
+
|
|
305
|
+
// the level's objects, and the one of an id
|
|
306
|
+
const editor3DObjects = ()=> isArray(editor3DLevel?.objects) ? editor3DLevel.objects : [];
|
|
307
|
+
const editor3DObject = (id)=> editor3DObjects().find((o)=> o.id === id);
|
|
308
|
+
const editor3DSelected = ()=> editor3DObjects().filter((o)=> editor3DSelection.has(o.id));
|
|
309
|
+
|
|
310
|
+
// the parts of a level beside its objects that the editor edits: its scene block and its map of blocks; each is
|
|
311
|
+
// undone, autosaved, reset and saved as the objects are
|
|
312
|
+
const editor3DLevelPartNames = ['scene', 'voxels', 'terrain'];
|
|
313
|
+
|
|
314
|
+
// the last copy made of each part, as its text and the copy: a part that has not changed since is not copied
|
|
315
|
+
// again, so undo steps share one copy of a terrain until it is sculpted; a copy is never changed
|
|
316
|
+
/** @type {Object<string, {text: string, part: Object}>} */
|
|
317
|
+
const editor3DPartCopies = {};
|
|
318
|
+
|
|
319
|
+
// a part of a level, undefined when it has none
|
|
320
|
+
function editor3DLevelPart(name, level=editor3DLevel)
|
|
321
|
+
{
|
|
322
|
+
const part = level?.[name];
|
|
323
|
+
return part && typeof part === 'object' && !isArray(part) ? part : undefined;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// a copy of every part a level has, {} for a level with none; the copies are shared, see editor3DPartCopies, so
|
|
327
|
+
// what is given is copied again before it is changed or made a level's own
|
|
328
|
+
function editor3DLevelParts(level=editor3DLevel)
|
|
329
|
+
{
|
|
330
|
+
const parts = {};
|
|
331
|
+
for (const name of editor3DLevelPartNames)
|
|
332
|
+
{
|
|
333
|
+
const part = editor3DLevelPart(name, level);
|
|
334
|
+
if (!part) continue;
|
|
335
|
+
const text = JSON.stringify(part);
|
|
336
|
+
if (editor3DPartCopies[name]?.text !== text)
|
|
337
|
+
editor3DPartCopies[name] = {text, part: JSON.parse(text)};
|
|
338
|
+
parts[name] = editor3DPartCopies[name].part;
|
|
339
|
+
}
|
|
340
|
+
return parts;
|
|
341
|
+
}
|
|
342
|
+
const editor3DScene = ()=> editor3DLevelPart('scene');
|
|
343
|
+
|
|
344
|
+
// what a level's hash covers: its objects, and the parts it has, so a level with none hashes as before
|
|
345
|
+
const editor3DContentHash = (objects, parts)=> editor3DHash(JSON.stringify(objects) +
|
|
346
|
+
editor3DLevelPartNames.map((name)=> parts[name] ? JSON.stringify(parts[name]) : '').join(''));
|
|
347
|
+
|
|
348
|
+
// the renderer's settings a scene block can set, as they are now, and put back as they were: what the game set
|
|
349
|
+
// itself shows again when a level's block changes or goes
|
|
350
|
+
function editor3DSceneState()
|
|
351
|
+
{
|
|
352
|
+
const r = render3D;
|
|
353
|
+
return {sky: r.sky, ambientColor: r.ambientColor.copy(), ambientGroundColor: r.ambientGroundColor?.copy(),
|
|
354
|
+
fogStart: r.fogStart, fogEnd: r.fogEnd, fogColor: r.fogColor?.copy(), sunDirection: r.sunDirection.copy(),
|
|
355
|
+
sunColor: r.sunColor.copy(), shadows: r.shadows};
|
|
356
|
+
}
|
|
357
|
+
function editor3DSceneRestore(state)
|
|
358
|
+
{
|
|
359
|
+
const r = render3D;
|
|
360
|
+
if (!r || !state) return;
|
|
361
|
+
r.sky !== state.sky && level3DSkies.has(r.sky) && r.sky.dispose(); // a dome a level made, not the game's
|
|
362
|
+
r.sky = state.sky;
|
|
363
|
+
r.ambientColor = state.ambientColor.copy();
|
|
364
|
+
r.ambientGroundColor = state.ambientGroundColor?.copy();
|
|
365
|
+
r.fogStart = state.fogStart, r.fogEnd = state.fogEnd;
|
|
366
|
+
r.fogColor = state.fogColor?.copy();
|
|
367
|
+
r.sunDirection = state.sunDirection.copy();
|
|
368
|
+
r.sunColor = state.sunColor.copy();
|
|
369
|
+
r.shadows = state.shadows;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// the scene on screen as a block, to start a level's scene from: a sky only when the dome's colors are known
|
|
373
|
+
const editor3DHex = (color)=> new Color(clamp(color.r), clamp(color.g), clamp(color.b)).toString(false);
|
|
374
|
+
function editor3DSceneFromView()
|
|
375
|
+
{
|
|
376
|
+
const r = render3D, scene = {}, colors = r.sky && render3DSkyColors.get(r.sky);
|
|
377
|
+
if (colors)
|
|
378
|
+
{
|
|
379
|
+
// how much of the top color the ambient light is
|
|
380
|
+
const top = max(colors[0].r, colors[0].g, colors[0].b);
|
|
381
|
+
const lit = max(r.ambientColor.r, r.ambientColor.g, r.ambientColor.b);
|
|
382
|
+
scene.sky = colors.map(editor3DHex);
|
|
383
|
+
scene.ambient = top ? round(clamp(lit / top) * 100) / 100 : .5;
|
|
384
|
+
}
|
|
385
|
+
const sun = r.sunDirection.normalize();
|
|
386
|
+
scene.sunDirection = [sun.x, sun.y, sun.z].map((v)=> round(v * 1e3) / 1e3);
|
|
387
|
+
scene.sunColor = editor3DHex(r.sunColor);
|
|
388
|
+
scene.fog = [editor3DRound(r.fogStart || 0), editor3DRound(r.fogEnd || 0)];
|
|
389
|
+
scene.fogColor = editor3DHex(r.fogColor || canvasClearColor);
|
|
390
|
+
scene.shadows = !!r.shadows;
|
|
391
|
+
return scene;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// the sun's direction as two angles in degrees, for two sliders: around, from +z toward +x, and its height over
|
|
395
|
+
// the horizon; and the direction of two angles, as the file keeps it
|
|
396
|
+
function editor3DSunAngles(direction)
|
|
397
|
+
{
|
|
398
|
+
const d = level3DVector(direction, vec3(0, 1, 0)).normalize();
|
|
399
|
+
return [round((atan2(d.x, d.z) * 180 / PI + 360) % 360) % 360, round(Math.asin(clamp(d.y, -1, 1)) * 180 / PI)];
|
|
400
|
+
}
|
|
401
|
+
function editor3DSunDirection(around, height)
|
|
402
|
+
{
|
|
403
|
+
const a = around * PI / 180, h = height * PI / 180;
|
|
404
|
+
return [sin(a) * cos(h), sin(h), cos(a) * cos(h)].map((v)=> round(v * 1e3) / 1e3 + 0);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// an object's position, rotation in degrees and scale, the defaults where the level leaves them out
|
|
408
|
+
const editor3DPos = (object)=> level3DVector(object.pos, vec3());
|
|
409
|
+
const editor3DRotation = (object)=> level3DVector(object.rotation, vec3());
|
|
410
|
+
const editor3DScale = (object)=> level3DVector(object.scale, vec3(1));
|
|
411
|
+
const editor3DTurned = (object)=> editor3DRotation(object).lengthSquared() > 0;
|
|
412
|
+
|
|
413
|
+
// the size of an object's box: what the game made for it says, a marker is 1 unit
|
|
414
|
+
function editor3DSize(object)
|
|
415
|
+
{
|
|
416
|
+
const made = editor3DInstances.get(object.id);
|
|
417
|
+
if (!(made instanceof EngineObject3D) || !(made.mesh || made.tileInfo)) return vec3(1);
|
|
418
|
+
const s = made.size3D, k = made.scale3D;
|
|
419
|
+
return vec3(s.x * abs(k.x), s.y * abs(k.y), s.z * abs(k.z));
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// every object gets an id of its own, a whole number: one without, or with one already used, gets a new one
|
|
423
|
+
function editor3DFixIds(objects)
|
|
424
|
+
{
|
|
425
|
+
const isObject = (o)=> !!o && typeof o === 'object', seen = new Set;
|
|
426
|
+
let next = 1;
|
|
427
|
+
for (const o of objects)
|
|
428
|
+
isObject(o) && Number.isInteger(o.id) && (next = max(next, o.id + 1));
|
|
429
|
+
for (const o of objects)
|
|
430
|
+
{
|
|
431
|
+
if (!isObject(o)) continue;
|
|
432
|
+
if (!Number.isInteger(o.id) || seen.has(o.id))
|
|
433
|
+
o.id = next++;
|
|
434
|
+
seen.add(o.id);
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// a quick hash of a text, to know when the file changed under its autosave
|
|
439
|
+
function editor3DHash(text)
|
|
440
|
+
{
|
|
441
|
+
let hash = 2166136261;
|
|
442
|
+
for (let i = 0; i < text.length; ++i)
|
|
443
|
+
hash = Math.imul(hash ^ text.charCodeAt(i), 16777619);
|
|
444
|
+
return hash >>> 0;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// called by level3DLoad before it makes the objects: the editor takes the level, and the first time it sees it,
|
|
448
|
+
// puts in the edits it autosaved for the file, or keeps them waiting when the file changed since
|
|
449
|
+
function editor3DLevelLoaded(level)
|
|
450
|
+
{
|
|
451
|
+
if (editor3DLevel !== level)
|
|
452
|
+
{
|
|
453
|
+
// another level, the selection and the edit being made were the last one's
|
|
454
|
+
editor3DSelection.clear();
|
|
455
|
+
editor3DStroke = undefined;
|
|
456
|
+
}
|
|
457
|
+
editor3DLevel = level;
|
|
458
|
+
editor3DInstances.clear();
|
|
459
|
+
if (isArray(level.objects))
|
|
460
|
+
{
|
|
461
|
+
// an entry that is not an object is dropped, in place, a game may keep the list
|
|
462
|
+
for (let i = level.objects.length; i--;)
|
|
463
|
+
level.objects[i] && typeof level.objects[i] === 'object' || level.objects.splice(i, 1);
|
|
464
|
+
editor3DFixIds(level.objects);
|
|
465
|
+
}
|
|
466
|
+
// its blocks as the editor writes them, so the same map always reads the same, however the file wrote its runs
|
|
467
|
+
const voxels = editor3DLevelPart('voxels', level), shape = level3DVoxelsShape(voxels);
|
|
468
|
+
if (shape)
|
|
469
|
+
{
|
|
470
|
+
const data = new Uint8Array(shape.size.x * shape.size.y * shape.size.z);
|
|
471
|
+
level3DVoxelsDecode(voxels.blocks, data);
|
|
472
|
+
voxels.blocks = level3DVoxelsEncode(data);
|
|
473
|
+
}
|
|
474
|
+
const known = editor3DRecords.get(level);
|
|
475
|
+
if (known)
|
|
476
|
+
{
|
|
477
|
+
// loaded again, as a restart does: its edits are in it, and its undo is its own
|
|
478
|
+
editor3DUndoList = known.undo;
|
|
479
|
+
editor3DRedoList = known.redo;
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// a level from a file goes by the file, one made in code by its objects as they were loaded, so each has an
|
|
484
|
+
// autosave of its own
|
|
485
|
+
const url = editorFetchedURLs.get(level)?.split(/[?#]/)[0];
|
|
486
|
+
const original = editor3DCopy(editor3DObjects()), originalParts = editor3DLevelParts();
|
|
487
|
+
const hash = editor3DContentHash(original, originalParts);
|
|
488
|
+
// sceneBase is the scene the game set, taken before the level's own block is applied
|
|
489
|
+
const record = {fileName: url ? url.split('/').pop() : 'level3D.json', key: url ?? 'level #' + hash, original,
|
|
490
|
+
originalParts, sceneBase: render3D ? editor3DSceneState() : undefined, hash, pending: undefined,
|
|
491
|
+
fileHandle: undefined, undo: [], redo: []};
|
|
492
|
+
editor3DRecords.set(level, record);
|
|
493
|
+
editor3DUndoList = record.undo;
|
|
494
|
+
editor3DRedoList = record.redo;
|
|
495
|
+
// an autosave the file already has, from a Save, goes; one of this file, or of the file a Save wrote, comes back
|
|
496
|
+
const saved = editor3DSaves()[record.key];
|
|
497
|
+
if (saved && isArray(saved.objects) && editor3DSame(saved.objects, original) &&
|
|
498
|
+
editor3DSame(editor3DLevelParts(saved), originalParts))
|
|
499
|
+
editor3DAutosave(level);
|
|
500
|
+
else if ((saved?.hash === hash || saved?.savedHash === hash) && isArray(saved.objects))
|
|
501
|
+
{
|
|
502
|
+
// before level3DLoad makes anything, so the scene and the map are made with the edits in them
|
|
503
|
+
const parts = editor3DLevelParts(saved);
|
|
504
|
+
level.objects = editor3DCopy(saved.objects);
|
|
505
|
+
for (const name of editor3DLevelPartNames)
|
|
506
|
+
parts[name] ? level[name] = editor3DCopy(parts[name]) : delete level[name];
|
|
507
|
+
}
|
|
508
|
+
else if (saved)
|
|
509
|
+
record.pending = saved;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// called by level3DLoad for each object, with what its type made
|
|
513
|
+
function editor3DObjectMade(object, made)
|
|
514
|
+
{
|
|
515
|
+
made && typeof made === 'object' && editor3DInstances.set(object.id, made);
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// put what the game made for an object where the level has it, and still
|
|
519
|
+
function editor3DPlaceInstance(made, object)
|
|
520
|
+
{
|
|
521
|
+
made.pos3D = editor3DPos(object);
|
|
522
|
+
made.rotation3D = editor3DRotation(object).scale(PI / 180);
|
|
523
|
+
made.scale3D = (level3DBaseScale.get(made) ?? vec3(1)).multiply(editor3DScale(object));
|
|
524
|
+
made.velocity3D = vec3();
|
|
525
|
+
made.angleVelocity3D = vec3();
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// make an object's game object, again when it has one
|
|
529
|
+
function editor3DMakeInstance(object)
|
|
530
|
+
{
|
|
531
|
+
editor3DInstances.get(object.id)?.destroy?.();
|
|
532
|
+
const made = level3DMake(object);
|
|
533
|
+
made ? editor3DInstances.set(object.id, made) : editor3DInstances.delete(object.id);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// set the level's objects to a list and bring the game's objects in line: one gone is destroyed, a new one made,
|
|
537
|
+
// one that only moved, turned or changed size is moved where it is, and one whose type or properties changed is
|
|
538
|
+
// made again, since its constructor takes them; a type made by an arrow function is called again
|
|
539
|
+
function editor3DSetObjects(list)
|
|
540
|
+
{
|
|
541
|
+
const before = new Map(editor3DObjects().map((o)=> [o.id, o]));
|
|
542
|
+
const objects = editor3DLevel.objects = editor3DCopy(list);
|
|
543
|
+
const ids = new Set(objects.map((o)=> o.id));
|
|
544
|
+
for (const [id, made] of editor3DInstances)
|
|
545
|
+
{
|
|
546
|
+
if (ids.has(id)) continue;
|
|
547
|
+
made.destroy?.();
|
|
548
|
+
editor3DInstances.delete(id);
|
|
549
|
+
}
|
|
550
|
+
for (const id of [...editor3DSelection])
|
|
551
|
+
ids.has(id) || editor3DSelection.delete(id);
|
|
552
|
+
for (const object of objects)
|
|
553
|
+
{
|
|
554
|
+
const old = before.get(object.id), made = editor3DInstances.get(object.id);
|
|
555
|
+
if (!level3DTypes.has(object.type) || old && editor3DSame(old, object)) continue;
|
|
556
|
+
const onlyMoved = old && old.type === object.type && editor3DSame(old.properties, object.properties) &&
|
|
557
|
+
made instanceof EngineObject3D && !made.destroyed;
|
|
558
|
+
onlyMoved ? editor3DPlaceInstance(made, object) : editor3DMakeInstance(object);
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// change the level's objects as part of the edit being made: change edits a copy of the list; the changes until
|
|
563
|
+
// editor3DStrokeEnd are one undo, so a drag is one undo; false when nothing changed
|
|
564
|
+
function editor3DChange(change)
|
|
565
|
+
{
|
|
566
|
+
if (!editor3DLevel || editor3DRecords.get(editor3DLevel)?.pending) return false; // its autosave waits first
|
|
567
|
+
const before = editor3DCopy(editor3DObjects()), after = editor3DCopy(before);
|
|
568
|
+
change(after);
|
|
569
|
+
if (editor3DSame(before, after)) return false;
|
|
570
|
+
editor3DSetObjects(after);
|
|
571
|
+
editor3DStroke ||= {before, parts: editor3DLevelParts()};
|
|
572
|
+
return true;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
// make a part the level's, or take it away, and show it: for the scene the game's own setup, then what the block
|
|
576
|
+
// sets; for the blocks the game's map, brought in line
|
|
577
|
+
function editor3DSetPart(name, part)
|
|
578
|
+
{
|
|
579
|
+
const level = editor3DLevel;
|
|
580
|
+
part ? level[name] = editor3DCopy(part) : delete level[name];
|
|
581
|
+
if (name === 'scene')
|
|
582
|
+
{
|
|
583
|
+
editor3DSceneRestore(editor3DRecords.get(level)?.sceneBase);
|
|
584
|
+
level3DSceneApply(level.scene);
|
|
585
|
+
}
|
|
586
|
+
else if (name === 'voxels')
|
|
587
|
+
editor3DVoxelShow();
|
|
588
|
+
else
|
|
589
|
+
editor3DTerrainShow();
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// make the level's parts these, the ones that differ
|
|
593
|
+
function editor3DSetParts(parts)
|
|
594
|
+
{
|
|
595
|
+
for (const name of editor3DLevelPartNames)
|
|
596
|
+
editor3DSame(parts[name], editor3DLevelPart(name)) || editor3DSetPart(name, parts[name]);
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// change a part of the level as part of the edit being made, as editor3DChange does its objects: change is given
|
|
600
|
+
// a copy of the part, undefined when the level has none, and returns the new one, or undefined for none; false
|
|
601
|
+
// when nothing changed
|
|
602
|
+
function editor3DChangePart(name, change)
|
|
603
|
+
{
|
|
604
|
+
if (!editor3DLevel || editor3DRecords.get(editor3DLevel)?.pending) return false; // its autosave waits first
|
|
605
|
+
const parts = editor3DLevelParts(), after = change(parts[name] && editor3DCopy(parts[name]));
|
|
606
|
+
if (editor3DSame(parts[name], after)) return false;
|
|
607
|
+
editor3DStroke ||= {before: editor3DCopy(editor3DObjects()), parts};
|
|
608
|
+
editor3DSetPart(name, after);
|
|
609
|
+
return true;
|
|
610
|
+
}
|
|
611
|
+
const editor3DChangeScene = (change)=> editor3DChangePart('scene', change);
|
|
612
|
+
|
|
613
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
614
|
+
// the level's map of blocks: the level's voxels block is the source of truth, the game's VoxelMap follows it;
|
|
615
|
+
// painting sets blocks in the game's map as it goes, and the stroke's end writes them into the level
|
|
616
|
+
|
|
617
|
+
// the game's map of the level's blocks, undefined when the level has none
|
|
618
|
+
function editor3DVoxelMap()
|
|
619
|
+
{
|
|
620
|
+
const map = level3DVoxelMap;
|
|
621
|
+
return map && !map.destroyed && editor3DLevelPart('voxels') ? map : undefined;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
// bring the game's map in line with the level's voxels block: made, made again when its place or size changed,
|
|
625
|
+
// its cells set, or destroyed with the block gone
|
|
626
|
+
function editor3DVoxelShow()
|
|
627
|
+
{
|
|
628
|
+
const voxels = editor3DLevelPart('voxels'), shape = level3DVoxelsShape(voxels);
|
|
629
|
+
let map = level3DVoxelMap && !level3DVoxelMap.destroyed ? level3DVoxelMap : undefined;
|
|
630
|
+
const sameVector = (a, b)=> a.x === b.x && a.y === b.y && a.z === b.z;
|
|
631
|
+
if (map && !(shape && sameVector(map.pos3D, shape.pos) && sameVector(map.mapSize, shape.size)))
|
|
632
|
+
{
|
|
633
|
+
map.destroy();
|
|
634
|
+
map = level3DVoxelMap = undefined;
|
|
635
|
+
}
|
|
636
|
+
if (!shape) return;
|
|
637
|
+
if (!map)
|
|
638
|
+
{
|
|
639
|
+
level3DVoxelMap = level3DVoxelsMake(voxels);
|
|
640
|
+
return;
|
|
641
|
+
}
|
|
642
|
+
level3DVoxelsDecode(voxels.blocks, map.data);
|
|
643
|
+
map.rebuild();
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
// write the game's map into the level, as a stroke of painting ends
|
|
647
|
+
function editor3DVoxelStore()
|
|
648
|
+
{
|
|
649
|
+
const map = editor3DVoxelMap(), voxels = editor3DLevelPart('voxels');
|
|
650
|
+
map && voxels && (voxels.blocks = level3DVoxelsEncode(map.data));
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
// give the level an empty map of a size in cells, centered on the origin with its bottom on the ground, as one undo
|
|
654
|
+
function editor3DVoxelAdd(size=vec3(32, 16, 32))
|
|
655
|
+
{
|
|
656
|
+
const changed = editor3DChangePart('voxels', ()=> ({pos: [-floor(size.x / 2), 0, -floor(size.z / 2)],
|
|
657
|
+
size: [size.x, size.y, size.z], blocks: [size.x * size.y * size.z, 0]}));
|
|
658
|
+
editor3DStrokeEnd();
|
|
659
|
+
return changed;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
// take the level's map away, as one undo
|
|
663
|
+
function editor3DVoxelRemove()
|
|
664
|
+
{
|
|
665
|
+
const changed = editor3DChangePart('voxels', ()=> undefined);
|
|
666
|
+
editor3DStrokeEnd();
|
|
667
|
+
return changed;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
// is a cell in a map
|
|
671
|
+
function editor3DVoxelInside(map, cell)
|
|
672
|
+
{
|
|
673
|
+
const s = map.mapSize;
|
|
674
|
+
return cell.x >= 0 && cell.y >= 0 && cell.z >= 0 && cell.x < s.x && cell.y < s.y && cell.z < s.z;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
// set a block as part of the edit being made, 0 for none: in the game's map now, in the level when the stroke
|
|
678
|
+
// ends; false when it is that already, or there is no such cell
|
|
679
|
+
function editor3DVoxelSet(cell, type)
|
|
680
|
+
{
|
|
681
|
+
const map = editor3DVoxelMap();
|
|
682
|
+
if (!map || editor3DRecords.get(editor3DLevel)?.pending) return false; // its autosave waits first
|
|
683
|
+
if (!editor3DVoxelInside(map, cell) || map.getVoxel(cell) === type) return false;
|
|
684
|
+
editor3DStroke ||= {before: editor3DCopy(editor3DObjects()), parts: editor3DLevelParts()};
|
|
685
|
+
editor3DStroke.painted = true;
|
|
686
|
+
map.setVoxel(cell, type);
|
|
687
|
+
return true;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
// the cell a ray picks on a layer of the map: drag has the axis the layer is across, the plane the ray is met
|
|
691
|
+
// with, in cells from the map's corner, and the layer, the cell's place along the axis; undefined off the map
|
|
692
|
+
function editor3DVoxelDragCell(drag, ray)
|
|
693
|
+
{
|
|
694
|
+
const map = editor3DVoxelMap(), axis = drag.axis, along = ray.direction[axis];
|
|
695
|
+
if (!map || !along) return;
|
|
696
|
+
const distance = (map.pos3D[axis] + drag.plane - ray.origin[axis]) / along;
|
|
697
|
+
if (distance < 0) return;
|
|
698
|
+
const p = ray.getPosition(distance).subtract(map.pos3D), cell = vec3(floor(p.x), floor(p.y), floor(p.z));
|
|
699
|
+
cell[axis] = drag.layer;
|
|
700
|
+
return editor3DVoxelInside(map, cell) ? cell : undefined;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
704
|
+
// the level's terrain: the level's terrain block is the source of truth, the game's HeightMap follows it;
|
|
705
|
+
// sculpting changes the game's map as it goes, and the stroke's end writes its heights into the level
|
|
706
|
+
|
|
707
|
+
// the brush: its size across in world units, and how strong it is, 0 to 1
|
|
708
|
+
const editor3DTerrainBrush = {size: 8, strength: .5};
|
|
709
|
+
|
|
710
|
+
// the game's map of the level's terrain, undefined when the level has none
|
|
711
|
+
function editor3DTerrainMap()
|
|
712
|
+
{
|
|
713
|
+
const map = level3DTerrainMap;
|
|
714
|
+
return map && !map.destroyed && editor3DLevelPart('terrain') ? map : undefined;
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
// bring the game's map in line with the level's terrain block: made, made again when its place or shape changed,
|
|
718
|
+
// its heights and color set, or destroyed with the block gone
|
|
719
|
+
function editor3DTerrainShow()
|
|
720
|
+
{
|
|
721
|
+
const terrain = editor3DLevelPart('terrain'), shape = level3DTerrainShape(terrain);
|
|
722
|
+
let map = level3DTerrainMap && !level3DTerrainMap.destroyed ? level3DTerrainMap : undefined;
|
|
723
|
+
const fits = map && shape && map.pos3D.distance(shape.pos) === 0 && map.mapSize.distance(shape.size) === 0 &&
|
|
724
|
+
map.height === shape.height && map.rows === shape.heights.length && map.columns === shape.heights[0].length;
|
|
725
|
+
if (map && !fits)
|
|
726
|
+
{
|
|
727
|
+
map.destroy();
|
|
728
|
+
map = level3DTerrainMap = undefined;
|
|
729
|
+
}
|
|
730
|
+
if (!shape) return;
|
|
731
|
+
if (!map)
|
|
732
|
+
{
|
|
733
|
+
level3DTerrainMap = level3DTerrainMake(terrain);
|
|
734
|
+
return;
|
|
735
|
+
}
|
|
736
|
+
map.heights = shape.heights;
|
|
737
|
+
map.color = /^#([0-9a-f]{6}|[0-9a-f]{8})$/i.test(terrain.color) ? new Color().setHex(terrain.color) : WHITE;
|
|
738
|
+
map.rebuild();
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
// write the game's map into the level, as a stroke of sculpting ends, its heights to a thousandth
|
|
742
|
+
function editor3DTerrainStore()
|
|
743
|
+
{
|
|
744
|
+
const map = editor3DTerrainMap(), terrain = editor3DLevelPart('terrain');
|
|
745
|
+
map && terrain && (terrain.heights = map.heights.map((row)=> row.map((v)=> round(v * 1e3) / 1e3)));
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
// give the level a flat terrain, as one undo: its size in the world, how many cells a side, and how tall a full
|
|
749
|
+
// height is; it starts a fifth of the way up with its surface on the ground, so it can be dug as well as raised
|
|
750
|
+
function editor3DTerrainAdd(size=vec2(64), cells=64, height=16)
|
|
751
|
+
{
|
|
752
|
+
const samples = clamp(floor(cells), 1, LEVEL3D_TERRAIN_SAMPLES - 1) + 1;
|
|
753
|
+
const changed = editor3DChangePart('terrain', ()=> ({pos: [0, editor3DRound(-height * .2), 0],
|
|
754
|
+
size: [size.x, size.y], height, color: '#6a9955',
|
|
755
|
+
heights: Array.from({length: samples}, ()=> Array(samples).fill(.2))}));
|
|
756
|
+
editor3DStrokeEnd();
|
|
757
|
+
return changed;
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
// take the level's terrain away, as one undo
|
|
761
|
+
function editor3DTerrainRemove()
|
|
762
|
+
{
|
|
763
|
+
const changed = editor3DChangePart('terrain', ()=> undefined);
|
|
764
|
+
editor3DStrokeEnd();
|
|
765
|
+
return changed;
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
// sculpt the ground around a point for some seconds, as part of the edit being made: raise, lower or smooth it
|
|
769
|
+
// under the brush, a soft circle, most in its middle and nothing at its edge; in the game's map now, in the level
|
|
770
|
+
// when the stroke ends; false when nothing changed
|
|
771
|
+
function editor3DTerrainSculpt(point, mode, seconds)
|
|
772
|
+
{
|
|
773
|
+
const map = editor3DTerrainMap();
|
|
774
|
+
if (!map || editor3DRecords.get(editor3DLevel)?.pending) return false; // its autosave waits first
|
|
775
|
+
const {size, strength} = editor3DTerrainBrush, radius = size / 2, heights = map.heights;
|
|
776
|
+
const rows = map.rows, columns = map.columns;
|
|
777
|
+
const stepX = map.mapSize.x / (columns - 1), stepZ = map.mapSize.y / (rows - 1);
|
|
778
|
+
const x0 = map.pos3D.x - map.mapSize.x / 2, z0 = map.pos3D.z - map.mapSize.y / 2;
|
|
779
|
+
const c0 = max(0, ceil((point.x - radius - x0) / stepX)), c1 = min(columns - 1, floor((point.x + radius - x0) / stepX));
|
|
780
|
+
const r0 = max(0, ceil((point.z - radius - z0) / stepZ)), r1 = min(rows - 1, floor((point.z + radius - z0) / stepZ));
|
|
781
|
+
// smoothing reads the ground as it was, so the order the samples are done in does not show
|
|
782
|
+
const old = mode === 'smooth' ? heights.map((row)=> row.slice()) : heights;
|
|
783
|
+
const rate = strength * 10 * seconds; // world units of height at full strength, 10 a second
|
|
784
|
+
let changed = false;
|
|
785
|
+
for (let r = r0; r <= r1; ++r)
|
|
786
|
+
for (let c = c0; c <= c1; ++c)
|
|
787
|
+
{
|
|
788
|
+
const distance = hypot(x0 + c * stepX - point.x, z0 + r * stepZ - point.z);
|
|
789
|
+
if (distance >= radius) continue;
|
|
790
|
+
const falloff = .5 + .5 * cos(PI * distance / radius), h = heights[r][c];
|
|
791
|
+
let v;
|
|
792
|
+
if (mode === 'smooth')
|
|
793
|
+
{
|
|
794
|
+
// toward the average of the samples beside it
|
|
795
|
+
let sum = 0, count = 0;
|
|
796
|
+
for (const [dr, dc] of [[-1, 0], [1, 0], [0, -1], [0, 1]])
|
|
797
|
+
old[r + dr]?.[c + dc] !== undefined && (sum += old[r + dr][c + dc], ++count);
|
|
798
|
+
v = h + (sum / count - h) * min(1, rate * falloff);
|
|
799
|
+
}
|
|
800
|
+
else
|
|
801
|
+
v = clamp(h + (mode === 'lower' ? -1 : 1) * rate * falloff / map.height);
|
|
802
|
+
if (v === h) continue;
|
|
803
|
+
heights[r][c] = v;
|
|
804
|
+
changed = true;
|
|
805
|
+
}
|
|
806
|
+
if (!changed) return false;
|
|
807
|
+
editor3DStroke ||= {before: editor3DCopy(editor3DObjects()), parts: editor3DLevelParts()};
|
|
808
|
+
editor3DStroke.sculpted = true;
|
|
809
|
+
map.rebuild();
|
|
810
|
+
return true;
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
// what the Blocks tool does to a cell: place puts the current type in an empty one, paint gives a block the
|
|
814
|
+
// current type, remove empties it
|
|
815
|
+
function editor3DVoxelApply(cell, mode)
|
|
816
|
+
{
|
|
817
|
+
const map = editor3DVoxelMap(), has = map ? map.getVoxel(cell) : 0;
|
|
818
|
+
return mode === 'place' ? !has && editor3DVoxelSet(cell, editor3DBlockType) :
|
|
819
|
+
mode === 'paint' ? !!has && editor3DVoxelSet(cell, editor3DBlockType) :
|
|
820
|
+
editor3DVoxelSet(cell, 0);
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
// a press of the Blocks tool: the cell under the ray is changed, and what a drag needs to go on from it on the
|
|
824
|
+
// same layer; undefined with no cell there
|
|
825
|
+
function editor3DVoxelPress(ray, mode)
|
|
826
|
+
{
|
|
827
|
+
const target = editor3DVoxelTarget(ray, mode);
|
|
828
|
+
if (!target) return;
|
|
829
|
+
const {cell, axis, plane} = target;
|
|
830
|
+
editor3DVoxelApply(cell, mode);
|
|
831
|
+
return {kind: 'blocks', mode, axis, plane, layer: cell[axis], last: cell};
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
// a drag of the Blocks tool goes on: every cell of the layer from the last one to the one under the ray
|
|
835
|
+
function editor3DVoxelDragTo(drag, ray)
|
|
836
|
+
{
|
|
837
|
+
const cell = editor3DVoxelDragCell(drag, ray), last = drag.last;
|
|
838
|
+
if (!cell) return;
|
|
839
|
+
const d = cell.subtract(last), steps = max(abs(d.x), abs(d.y), abs(d.z));
|
|
840
|
+
for (let i = 1; i <= steps; ++i)
|
|
841
|
+
editor3DVoxelApply(vec3(round(last.x + d.x * i / steps), round(last.y + d.y * i / steps),
|
|
842
|
+
round(last.z + d.z * i / steps)), drag.mode);
|
|
843
|
+
drag.last = cell;
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
// make the type of the block under a ray the one to place; false with no block there
|
|
847
|
+
function editor3DVoxelPick(ray)
|
|
848
|
+
{
|
|
849
|
+
const hit = editor3DVoxelMap()?.raycast(ray);
|
|
850
|
+
if (!hit) return false;
|
|
851
|
+
editor3DBlockType = hit.type;
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
// the cell a click is for: to place, the empty cell against the face the ray hits, or the one standing on the
|
|
855
|
+
// map's floor where it hits no block; otherwise the block it hits; with the axis the face is across and the plane
|
|
856
|
+
// of the face, for a drag to stay on; undefined with no such cell
|
|
857
|
+
function editor3DVoxelTarget(ray, mode)
|
|
858
|
+
{
|
|
859
|
+
const map = editor3DVoxelMap();
|
|
860
|
+
if (!map) return;
|
|
861
|
+
const place = mode === 'place', hit = map.raycast(ray);
|
|
862
|
+
if (!hit)
|
|
863
|
+
{
|
|
864
|
+
const cell = place ? editor3DVoxelDragCell({axis: 'y', plane: 0, layer: 0}, ray) : undefined;
|
|
865
|
+
return cell && {cell, axis: 'y', plane: 0};
|
|
866
|
+
}
|
|
867
|
+
const n = hit.normal, axis = n.x ? 'x' : n.y ? 'y' : 'z', cell = place ? hit.cell.add(n) : hit.cell;
|
|
868
|
+
if (!editor3DVoxelInside(map, cell)) return;
|
|
869
|
+
// the face between the block hit and the cell in front of it
|
|
870
|
+
return {cell, axis, plane: cell[axis] + ((n[axis] > 0) === place ? 0 : 1)};
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
// end the edit being made: one undo, and the autosave
|
|
874
|
+
function editor3DStrokeEnd()
|
|
875
|
+
{
|
|
876
|
+
const stroke = editor3DStroke;
|
|
877
|
+
editor3DStroke = undefined;
|
|
878
|
+
if (!stroke) return;
|
|
879
|
+
stroke.painted && editor3DVoxelStore(); // the blocks painted go into the level
|
|
880
|
+
stroke.sculpted && editor3DTerrainStore(); // and the ground sculpted
|
|
881
|
+
const parts = editor3DLevelParts();
|
|
882
|
+
if (editor3DSame(stroke.before, editor3DObjects()) && editor3DSame(stroke.parts, parts)) return;
|
|
883
|
+
editor3DUndoList.push({before: stroke.before, after: editor3DCopy(editor3DObjects()),
|
|
884
|
+
partsBefore: stroke.parts, partsAfter: parts});
|
|
885
|
+
editor3DUndoList.length > 100 && editor3DUndoList.shift();
|
|
886
|
+
editor3DRedoList.length = 0;
|
|
887
|
+
editor3DAutosave();
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
// take the edit being made back, with nothing to undo
|
|
891
|
+
function editor3DStrokeCancel()
|
|
892
|
+
{
|
|
893
|
+
const stroke = editor3DStroke;
|
|
894
|
+
editor3DStroke = undefined;
|
|
895
|
+
if (!stroke) return;
|
|
896
|
+
editor3DSetObjects(stroke.before);
|
|
897
|
+
stroke.painted && editor3DVoxelShow(); // the level never had them, the game's map goes back to it
|
|
898
|
+
stroke.sculpted && editor3DTerrainShow();
|
|
899
|
+
editor3DSetParts(stroke.parts);
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
// undo the last edit, or redo the last one undone; false with none
|
|
903
|
+
function editor3DUndo(redo=false)
|
|
904
|
+
{
|
|
905
|
+
editor3DStrokeEnd();
|
|
906
|
+
const entry = (redo ? editor3DRedoList : editor3DUndoList).pop();
|
|
907
|
+
if (!entry) return false;
|
|
908
|
+
(redo ? editor3DUndoList : editor3DRedoList).push(entry);
|
|
909
|
+
editor3DSetObjects(redo ? entry.after : entry.before);
|
|
910
|
+
editor3DSetParts(redo ? entry.partsAfter : entry.partsBefore);
|
|
911
|
+
editor3DAutosave();
|
|
912
|
+
return true;
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
// write an object's position, rotation in degrees and scale as the file keeps them: rounded, and the rotation and
|
|
916
|
+
// scale left out when they are the default; one not given is left as it is
|
|
917
|
+
function editor3DSetTransform(object, pos, rotation, scale)
|
|
918
|
+
{
|
|
919
|
+
const list = (v)=> [editor3DRound(v.x), editor3DRound(v.y), editor3DRound(v.z)];
|
|
920
|
+
pos && (object.pos = list(pos));
|
|
921
|
+
if (rotation)
|
|
922
|
+
list(rotation).some((v)=> v) ? object.rotation = list(rotation) : delete object.rotation;
|
|
923
|
+
if (scale)
|
|
924
|
+
list(scale).some((v)=> v !== 1) ? object.scale = list(scale) : delete object.scale;
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
// set an object's property as the file keeps it, only where it differs from its type's default: a Color as a hex
|
|
928
|
+
// string, with its alpha when it has one, and a vector as an array
|
|
929
|
+
function editor3DSetProperty(object, name, value, defaultValue)
|
|
930
|
+
{
|
|
931
|
+
const encode = (v)=> isColor(v) ? v.toString(v.a < 1) : isVector3(v) ? [v.x, v.y, v.z] :
|
|
932
|
+
isVector2(v) ? [v.x, v.y] : v;
|
|
933
|
+
const properties = {...object.properties};
|
|
934
|
+
if (editor3DSame(encode(value), encode(defaultValue)))
|
|
935
|
+
delete properties[name];
|
|
936
|
+
else
|
|
937
|
+
properties[name] = encode(value);
|
|
938
|
+
Object.keys(properties).length ? object.properties = properties : delete object.properties;
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
// add an object of a type at a position, selected; its id, or undefined when the level can not be edited
|
|
942
|
+
function editor3DPlace(type, pos)
|
|
943
|
+
{
|
|
944
|
+
let id;
|
|
945
|
+
const placed = editor3DChange((list)=>
|
|
946
|
+
{
|
|
947
|
+
const object = {id: id = list.reduce((next, o)=> max(next, o.id + 1), 1), type};
|
|
948
|
+
editor3DSetTransform(object, pos);
|
|
949
|
+
list.push(object);
|
|
950
|
+
});
|
|
951
|
+
if (!placed) return;
|
|
952
|
+
editor3DSelection.clear();
|
|
953
|
+
editor3DSelection.add(id);
|
|
954
|
+
return id;
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
// remove the selected objects, as one undo
|
|
958
|
+
function editor3DDelete()
|
|
959
|
+
{
|
|
960
|
+
if (!editor3DSelection.size) return false;
|
|
961
|
+
editor3DStrokeEnd();
|
|
962
|
+
editor3DChange((list)=>
|
|
963
|
+
{
|
|
964
|
+
for (let i = list.length; i--;)
|
|
965
|
+
editor3DSelection.has(list[i].id) && list.splice(i, 1);
|
|
966
|
+
});
|
|
967
|
+
editor3DStrokeEnd();
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
// copy the selected objects
|
|
971
|
+
function editor3DCopySelection()
|
|
972
|
+
{
|
|
973
|
+
const selected = editor3DSelected();
|
|
974
|
+
if (!selected.length) return false;
|
|
975
|
+
editor3DClipboard = editor3DCopy(selected);
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
// copy the selected objects and remove them
|
|
979
|
+
function editor3DCut() { return editor3DCopySelection() === false ? false : editor3DDelete(); }
|
|
980
|
+
|
|
981
|
+
// add the copied objects, moved by an offset, with new ids and selected, as one undo
|
|
982
|
+
function editor3DPaste(offset=vec3())
|
|
983
|
+
{
|
|
984
|
+
const copied = editor3DClipboard, ids = [];
|
|
985
|
+
if (!copied?.length) return false;
|
|
986
|
+
editor3DStrokeEnd();
|
|
987
|
+
const pasted = editor3DChange((list)=>
|
|
988
|
+
{
|
|
989
|
+
let id = list.reduce((next, o)=> max(next, o.id + 1), 1);
|
|
990
|
+
for (const from of copied)
|
|
991
|
+
{
|
|
992
|
+
const object = {...editor3DCopy(from), id: id++};
|
|
993
|
+
editor3DSetTransform(object, editor3DPos(from).add(offset));
|
|
994
|
+
list.push(object);
|
|
995
|
+
ids.push(object.id);
|
|
996
|
+
}
|
|
997
|
+
});
|
|
998
|
+
editor3DStrokeEnd();
|
|
999
|
+
if (!pasted) return false;
|
|
1000
|
+
editor3DSelection.clear();
|
|
1001
|
+
ids.forEach((id)=> editor3DSelection.add(id));
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
// copy the selected objects where they are and select the copies, the clipboard stays as it was
|
|
1005
|
+
function editor3DDuplicate()
|
|
1006
|
+
{
|
|
1007
|
+
const clipboard = editor3DClipboard;
|
|
1008
|
+
const done = editor3DCopySelection() === false ? false : editor3DPaste();
|
|
1009
|
+
editor3DClipboard = clipboard;
|
|
1010
|
+
return done;
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
1014
|
+
// the session: opening, playing and closing
|
|
1015
|
+
|
|
1016
|
+
// if the level editor is the 3D one: as levelEditor.use3D says, or when a 3D level was loaded
|
|
1017
|
+
function editor3DWanted() { return !!render3D && (levelEditor.use3D ?? !!editor3DLevel); }
|
|
1018
|
+
|
|
1019
|
+
// put every object back where the level has it, and make one the game destroyed again, so what is on screen is
|
|
1020
|
+
// what Save writes; a type made by an arrow function made nothing to put back
|
|
1021
|
+
function editor3DRestore()
|
|
1022
|
+
{
|
|
1023
|
+
for (const object of editor3DObjects())
|
|
1024
|
+
{
|
|
1025
|
+
const type = level3DTypes.get(object.type), made = editor3DInstances.get(object.id);
|
|
1026
|
+
if (!type) continue;
|
|
1027
|
+
if (made instanceof EngineObject3D && !made.destroyed)
|
|
1028
|
+
editor3DPlaceInstance(made, object);
|
|
1029
|
+
else if (type.make.prototype && (!made || made.destroyed))
|
|
1030
|
+
editor3DMakeInstance(object);
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
// open or close the editor, the session goes on: the game is paused under it and reads no input
|
|
1035
|
+
function editor3DSetOpen(open)
|
|
1036
|
+
{
|
|
1037
|
+
open = !!open;
|
|
1038
|
+
if (!debug || editor3DIsOpen === open) return;
|
|
1039
|
+
if (open)
|
|
1040
|
+
{
|
|
1041
|
+
editor3DSetFreeCamera(false);
|
|
1042
|
+
editor3DLevel || editor3DLevelLoaded({}); // a new level, with nothing loaded
|
|
1043
|
+
editor3DCamera || editor3DCameraStart();
|
|
1044
|
+
editor3DGamePaused = paused;
|
|
1045
|
+
setPaused(true);
|
|
1046
|
+
setDebugOverlay(false); // out of the way of the level
|
|
1047
|
+
editor3DIsOpen = true;
|
|
1048
|
+
editor3DRestore();
|
|
1049
|
+
}
|
|
1050
|
+
else
|
|
1051
|
+
{
|
|
1052
|
+
editor3DDrag = editor3DHover = undefined;
|
|
1053
|
+
editor3DMouseOnPanel = false; // the panel hides, with no mouseleave
|
|
1054
|
+
editor3DStrokeEnd();
|
|
1055
|
+
editor3DIsOpen = false;
|
|
1056
|
+
setPaused(editor3DGamePaused);
|
|
1057
|
+
}
|
|
1058
|
+
inputCapture(editor3DViewOn());
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
// open the editor, a new session starts at the game's camera and a return keeps the editor's
|
|
1062
|
+
function editor3DOpen()
|
|
1063
|
+
{
|
|
1064
|
+
editor3DSession || editor3DCameraStart();
|
|
1065
|
+
editor3DSession = true;
|
|
1066
|
+
editor3DSetOpen(true);
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
// close the editor and end its session, Escape opens the debug overlay again
|
|
1070
|
+
function editor3DClose()
|
|
1071
|
+
{
|
|
1072
|
+
editor3DSession = false;
|
|
1073
|
+
editor3DSetOpen(false);
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
// switch to playing, from a position when Play from mouse is on
|
|
1077
|
+
function editor3DPlay(pos)
|
|
1078
|
+
{
|
|
1079
|
+
editor3DSetOpen(false);
|
|
1080
|
+
editor3DPlayFromMouse && pos && levelEditor.onPlayFrom?.(pos.copy());
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
// rebuild the level through the game's hook and play it
|
|
1084
|
+
function editor3DRestart()
|
|
1085
|
+
{
|
|
1086
|
+
if (!levelEditor.onRestart) return;
|
|
1087
|
+
editor3DSetOpen(false);
|
|
1088
|
+
levelEditor.onRestart();
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
// the level editor's open, close and isOpen go to the 3D editor when the game has a 3D level
|
|
1092
|
+
if (debug)
|
|
1093
|
+
{
|
|
1094
|
+
const open2D = levelEditor.open.bind(levelEditor), close2D = levelEditor.close.bind(levelEditor);
|
|
1095
|
+
levelEditor.open = ()=> editor3DWanted() ? editor3DOpen() : open2D();
|
|
1096
|
+
levelEditor.close = ()=> editor3DIsOpen || editor3DSession ? editor3DClose() : close2D();
|
|
1097
|
+
Object.defineProperty(levelEditor, 'isOpen', {get: ()=> editor3DIsOpen || editorIsOpen});
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
1101
|
+
// files: the level as JSON, Save, and the autosave
|
|
1102
|
+
|
|
1103
|
+
// a value as JSON on one line, with a space after each comma and colon, as a person would write it
|
|
1104
|
+
function editor3DJSON(value)
|
|
1105
|
+
{
|
|
1106
|
+
if (isArray(value))
|
|
1107
|
+
return '[' + value.map(editor3DJSON).join(', ') + ']';
|
|
1108
|
+
if (value && typeof value === 'object')
|
|
1109
|
+
return '{' + Object.entries(value).filter(([, v])=> v !== undefined)
|
|
1110
|
+
.map(([key, v])=> JSON.stringify(key) + ': ' + editor3DJSON(v)).join(', ') + '}';
|
|
1111
|
+
return JSON.stringify(value) ?? 'null';
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
// a level as the file has it: its version, what else it holds, and its objects, one to a line
|
|
1115
|
+
function editor3DLevelJSON(level=editor3DLevel)
|
|
1116
|
+
{
|
|
1117
|
+
const {littlejs3D, objects, ...rest} = level;
|
|
1118
|
+
const lines = [`"littlejs3D": ${LEVEL3D_VERSION}`];
|
|
1119
|
+
for (const [key, value] of Object.entries(rest))
|
|
1120
|
+
lines.push(JSON.stringify(key) + ': ' + editor3DJSON(value));
|
|
1121
|
+
const list = (isArray(objects) ? objects : []).map((o)=> ' ' + editor3DJSON(o));
|
|
1122
|
+
lines.push('"objects": [' + (list.length ? '\n' + list.join(',\n') + '\n ]' : ']'));
|
|
1123
|
+
return '{\n ' + lines.join(',\n ') + '\n}\n';
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
// every page keeps its own autosaves, one for each level by its key
|
|
1127
|
+
function editor3DSaveName() { return 'LittleJS editor 3D ' + (globalThis.location?.pathname ?? ''); }
|
|
1128
|
+
const editor3DSaves = ()=> readSaveData(editor3DSaveName(), {});
|
|
1129
|
+
|
|
1130
|
+
// if the last autosave did not fit in storage, the panel says so
|
|
1131
|
+
let editor3DSaveFailed = false;
|
|
1132
|
+
|
|
1133
|
+
// remember the level's objects and parts, or forget them when they are back to the file
|
|
1134
|
+
function editor3DAutosave(level=editor3DLevel)
|
|
1135
|
+
{
|
|
1136
|
+
const record = editor3DRecords.get(level);
|
|
1137
|
+
if (!record || record.pending) return; // edits waiting to be applied keep their autosave
|
|
1138
|
+
const saves = editor3DSaves(), objects = isArray(level.objects) ? level.objects : [];
|
|
1139
|
+
const parts = editor3DLevelParts(level);
|
|
1140
|
+
// the level as it was loaded has nothing to keep; one a Save wrote is kept until a reload shows the file has it,
|
|
1141
|
+
// since the browser gives a picked file's name and not its folder, and a file of the same name may be a copy
|
|
1142
|
+
if (editor3DSame(objects, record.original) && editor3DSame(parts, record.originalParts) && !record.savedHash)
|
|
1143
|
+
delete saves[record.key];
|
|
1144
|
+
else
|
|
1145
|
+
saves[record.key] = {hash: record.hash, savedHash: record.savedHash, objects: editor3DCopy(objects),
|
|
1146
|
+
...parts};
|
|
1147
|
+
try
|
|
1148
|
+
{
|
|
1149
|
+
localStorage.setItem(editor3DSaveName(), JSON.stringify(saves));
|
|
1150
|
+
editor3DSaveFailed = false;
|
|
1151
|
+
}
|
|
1152
|
+
catch { editor3DSaveFailed = true; }
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
// put the autosaved edits of a file that changed into the level, as one undo
|
|
1156
|
+
function editor3DApplyPending()
|
|
1157
|
+
{
|
|
1158
|
+
const record = editor3DRecords.get(editor3DLevel), saved = record?.pending;
|
|
1159
|
+
if (!saved || !isArray(saved.objects)) return false;
|
|
1160
|
+
record.pending = undefined;
|
|
1161
|
+
editor3DFixIds(saved.objects);
|
|
1162
|
+
editor3DChange((list)=> { list.length = 0; list.push(...editor3DCopy(saved.objects)); });
|
|
1163
|
+
const parts = editor3DLevelParts(saved);
|
|
1164
|
+
for (const name of editor3DLevelPartNames)
|
|
1165
|
+
editor3DChangePart(name, ()=> parts[name]);
|
|
1166
|
+
editor3DStrokeEnd();
|
|
1167
|
+
editor3DAutosave();
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
// drop the autosaved edits of a file that changed
|
|
1171
|
+
function editor3DDropPending()
|
|
1172
|
+
{
|
|
1173
|
+
const record = editor3DRecords.get(editor3DLevel);
|
|
1174
|
+
if (!record?.pending) return false;
|
|
1175
|
+
record.pending = undefined;
|
|
1176
|
+
editor3DAutosave();
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
// 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
|
|
1180
|
+
// file that changed are dropped, and a level already as its file has it is left alone
|
|
1181
|
+
function editor3DRevert()
|
|
1182
|
+
{
|
|
1183
|
+
const record = editor3DRecords.get(editor3DLevel);
|
|
1184
|
+
if (!record) return false;
|
|
1185
|
+
record.pending = undefined;
|
|
1186
|
+
if (editor3DSame(editor3DObjects(), record.original) && editor3DSame(editor3DLevelParts(), record.originalParts))
|
|
1187
|
+
return editor3DAutosave(), false;
|
|
1188
|
+
editor3DChange((list)=> { list.length = 0; list.push(...editor3DCopy(record.original)); });
|
|
1189
|
+
for (const name of editor3DLevelPartNames)
|
|
1190
|
+
editor3DChangePart(name, ()=> record.originalParts[name] && editor3DCopy(record.originalParts[name]));
|
|
1191
|
+
editor3DStrokeEnd();
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
// save the level as JSON: where the browser lets a page write files, Chrome and Edge, to a file picked once and
|
|
1195
|
+
// written again on each Save after, or picked again with Save As; elsewhere as a download; a file of the level's
|
|
1196
|
+
// own name that was written is the file from then on, for the autosave; resolves to how it saved, undefined when
|
|
1197
|
+
// the picker was closed; saves of a level run one at a time in the order asked, so the file ends with the last
|
|
1198
|
+
async function editor3DSave(pickAgain=false)
|
|
1199
|
+
{
|
|
1200
|
+
const level = editor3DLevel, record = editor3DRecords.get(level);
|
|
1201
|
+
if (!record) return;
|
|
1202
|
+
editor3DStrokeEnd();
|
|
1203
|
+
// what is written, kept as it is now: the level can change while the file is picked and written, and those
|
|
1204
|
+
// edits are not in the file, so they stay in the autosave; the level and record are this one's, whichever is
|
|
1205
|
+
// open by then
|
|
1206
|
+
const text = editor3DLevelJSON(level);
|
|
1207
|
+
const saved = (async ()=> { await record.saving; return editor3DSaveText(level, record, text, pickAgain); })();
|
|
1208
|
+
record.saving = saved.catch(()=> {});
|
|
1209
|
+
return saved;
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
// write one save of a level, the one before it done
|
|
1213
|
+
async function editor3DSaveText(level, record, text, pickAgain)
|
|
1214
|
+
{
|
|
1215
|
+
const file = JSON.parse(text), written = editor3DCopy(file.objects), writtenParts = editor3DLevelParts(file);
|
|
1216
|
+
const picker = /** @type {any} */ (globalThis).showSaveFilePicker;
|
|
1217
|
+
const fileKey = (globalThis.location?.pathname ?? '') + ' 3D ' + record.key;
|
|
1218
|
+
if (picker)
|
|
1219
|
+
{
|
|
1220
|
+
try
|
|
1221
|
+
{
|
|
1222
|
+
if (!pickAgain && !record.fileHandle)
|
|
1223
|
+
{
|
|
1224
|
+
const handle = await editorFileStore.get(fileKey), mode = {mode: 'readwrite'};
|
|
1225
|
+
if (handle && (await handle.queryPermission(mode) === 'granted' ||
|
|
1226
|
+
await handle.requestPermission(mode) === 'granted'))
|
|
1227
|
+
record.fileHandle = handle;
|
|
1228
|
+
}
|
|
1229
|
+
if (pickAgain || !record.fileHandle)
|
|
1230
|
+
{
|
|
1231
|
+
record.fileHandle = await picker.call(globalThis, {suggestedName: record.fileName,
|
|
1232
|
+
types: [{description: 'LittleJS 3D level', accept: {'application/json': ['.json']}}]});
|
|
1233
|
+
editorFileStore.set(fileKey, record.fileHandle);
|
|
1234
|
+
}
|
|
1235
|
+
const writable = await record.fileHandle.createWritable();
|
|
1236
|
+
await writable.write(text);
|
|
1237
|
+
await writable.close();
|
|
1238
|
+
if (record.fileHandle.name === record.fileName)
|
|
1239
|
+
{
|
|
1240
|
+
record.original = written;
|
|
1241
|
+
record.originalParts = writtenParts;
|
|
1242
|
+
record.savedHash = editor3DContentHash(written, writtenParts);
|
|
1243
|
+
editor3DAutosave(level);
|
|
1244
|
+
}
|
|
1245
|
+
return 'written';
|
|
1246
|
+
}
|
|
1247
|
+
catch (error)
|
|
1248
|
+
{
|
|
1249
|
+
if (error?.name === 'AbortError') return; // the picker was closed, nothing saved
|
|
1250
|
+
record.fileHandle = undefined; // a file it could not write, a download instead, and not kept
|
|
1251
|
+
editorFileStore.set(fileKey, undefined);
|
|
1252
|
+
}
|
|
1253
|
+
}
|
|
1254
|
+
saveText(text, record.fileName, 'application/json');
|
|
1255
|
+
return 'downloaded';
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
1259
|
+
// the tools: handles on the selection, dragged with the mouse
|
|
1260
|
+
|
|
1261
|
+
const EDITOR3D_HANDLE_SIZE = .15; // a handle's length as a part of the view's height
|
|
1262
|
+
const EDITOR3D_HANDLE_REACH = 10; // how many pixels from a handle still grab it
|
|
1263
|
+
const EDITOR3D_AXES = {x: vec3(1, 0, 0), y: vec3(0, 1, 0), z: vec3(0, 0, 1)};
|
|
1264
|
+
|
|
1265
|
+
let editor3DTool = 'move'; // 'select', 'move', 'rotate', 'scale', 'blocks' or 'terrain'
|
|
1266
|
+
let editor3DBlockType = 1; // the block type the Blocks tool places
|
|
1267
|
+
let editor3DSeconds = 0; // how long the last step was, for a brush held down
|
|
1268
|
+
// where the mouse is on the terrain, for the Terrain tool's brush
|
|
1269
|
+
/** @type {Vector3|undefined} */
|
|
1270
|
+
let editor3DTerrainHover;
|
|
1271
|
+
// the cell the Blocks tool would change, for its outline: {cell, mode}
|
|
1272
|
+
/** @type {{cell: Vector3, mode: string}|undefined} */
|
|
1273
|
+
let editor3DBlockHover;
|
|
1274
|
+
let editor3DGrid = true; // moves, turns and sizes go in steps, Ctrl flips it for a drag
|
|
1275
|
+
let editor3DGroundSnap = true; // a body drag slides along what is under the mouse
|
|
1276
|
+
let editor3DMoveStep = 1, editor3DRotateStep = 15, editor3DScaleStep = .25; // the rotate step in degrees
|
|
1277
|
+
let editor3DHelp = false; // the keys are shown
|
|
1278
|
+
let editor3DMouseOnPanel = false;
|
|
1279
|
+
// the drag being made: {kind, ...}, a 'box' drag has from and to in screen pixels
|
|
1280
|
+
/** @type {any} */
|
|
1281
|
+
let editor3DDrag;
|
|
1282
|
+
// the handle under the mouse
|
|
1283
|
+
/** @type {any} */
|
|
1284
|
+
let editor3DHover;
|
|
1285
|
+
// what an Alt drag orbits around
|
|
1286
|
+
/** @type {Vector3|undefined} */
|
|
1287
|
+
let editor3DOrbitPivot;
|
|
1288
|
+
|
|
1289
|
+
// the middle of the selection, where the handles are
|
|
1290
|
+
function editor3DSelectionCenter()
|
|
1291
|
+
{
|
|
1292
|
+
const selected = editor3DSelected();
|
|
1293
|
+
if (selected.length)
|
|
1294
|
+
return selected.reduce((sum, o)=> sum.add(editor3DPos(o)), vec3()).scale(1 / selected.length);
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
// the handles of the tool on the selection, each {kind, axis, center, length, direction or axes, points}: Move has
|
|
1298
|
+
// an arrow along each world axis and a square between each pair, Rotate a ring for the pitch, yaw and roll of the
|
|
1299
|
+
// first selected object, Scale a handle along each of its own axes and a box in the middle for all three
|
|
1300
|
+
function editor3DHandles()
|
|
1301
|
+
{
|
|
1302
|
+
const center = editor3DSelectionCenter(), tool = editor3DTool, handles = [];
|
|
1303
|
+
if (!center || tool === 'select' || tool === 'blocks' || tool === 'terrain') return handles;
|
|
1304
|
+
const length = editor3DScreenScale(center) * EDITOR3D_HANDLE_SIZE;
|
|
1305
|
+
const rotation = editor3DRotation(editor3DSelected()[0]).scale(PI / 180), frame = buildMatrix(vec3(), rotation);
|
|
1306
|
+
for (const axis of 'xyz')
|
|
1307
|
+
{
|
|
1308
|
+
if (tool === 'rotate')
|
|
1309
|
+
{
|
|
1310
|
+
const axes = editor3DRingAxes(rotation, axis), points = [];
|
|
1311
|
+
for (let i = 0; i <= 48; ++i)
|
|
1312
|
+
{
|
|
1313
|
+
const a = i / 48 * PI * 2;
|
|
1314
|
+
points.push(center.add(axes.across.scale(cos(a) * length)).add(axes.along.scale(sin(a) * length)));
|
|
1315
|
+
}
|
|
1316
|
+
handles.push({kind: 'ring', axis, center, length, axes, points});
|
|
1317
|
+
continue;
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
// an arrow or scale handle starts a little out from the middle, which is left for the object's body
|
|
1321
|
+
const direction = tool === 'move' ? EDITOR3D_AXES[axis] : frame.transformDirection(EDITOR3D_AXES[axis]);
|
|
1322
|
+
handles.push({kind: tool === 'move' ? 'arrow' : 'scale', axis, center, length, direction,
|
|
1323
|
+
points: [center.add(direction.scale(length * .2)), center.add(direction.scale(length))]});
|
|
1324
|
+
if (tool === 'move')
|
|
1325
|
+
{
|
|
1326
|
+
// the square between the other two axes moves along both
|
|
1327
|
+
const [a, b] = 'xyz'.replace(axis, '');
|
|
1328
|
+
handles.push({kind: 'plane', axis: a + b, center, length, direction,
|
|
1329
|
+
points: [center.add(EDITOR3D_AXES[a].add(EDITOR3D_AXES[b]).scale(length * .35))]});
|
|
1330
|
+
}
|
|
1331
|
+
}
|
|
1332
|
+
tool === 'scale' && handles.push({kind: 'scaleAll', axis: 'xyz', center, length, points: [center]});
|
|
1333
|
+
return handles;
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
// the handle nearest a screen position, within reach; a square or box is a point and a little easier to take
|
|
1337
|
+
function editor3DHandleAt(screenPos)
|
|
1338
|
+
{
|
|
1339
|
+
let nearest, reach = EDITOR3D_HANDLE_REACH;
|
|
1340
|
+
for (const handle of editor3DHandles())
|
|
1341
|
+
{
|
|
1342
|
+
// a ring seen edge on is a line on screen and the mouse's ray runs along its plane, it can not be dragged
|
|
1343
|
+
const {points, center} = handle, toward = center.subtract(editor3DCamera.pos).normalize();
|
|
1344
|
+
if (handle.kind === 'ring' && abs(handle.axes.axis.dot(toward)) < .15) continue;
|
|
1345
|
+
|
|
1346
|
+
// an arrow or scale handle seen end on is a dot over the object, which is left for the object's body
|
|
1347
|
+
if (points.length === 2)
|
|
1348
|
+
{
|
|
1349
|
+
const a = render3D.worldToScreen(points[0]), b = render3D.worldToScreen(points[1]);
|
|
1350
|
+
if (a && b && a.distance(b) < 12) continue;
|
|
1351
|
+
}
|
|
1352
|
+
let distance = Infinity;
|
|
1353
|
+
if (points.length === 1)
|
|
1354
|
+
{
|
|
1355
|
+
const at = render3D.worldToScreen(points[0]);
|
|
1356
|
+
at && (distance = screenPos.distance(at) - 4);
|
|
1357
|
+
}
|
|
1358
|
+
else
|
|
1359
|
+
for (let i = 1; i < points.length; ++i)
|
|
1360
|
+
distance = min(distance, editor3DSegmentDistance(screenPos, points[i-1], points[i]));
|
|
1361
|
+
if (distance < reach)
|
|
1362
|
+
reach = distance, nearest = handle;
|
|
1363
|
+
}
|
|
1364
|
+
return nearest;
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
// 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
|
|
1368
|
+
// where it made nothing to hit, like a light or a player start; its id
|
|
1369
|
+
function editor3DPickAt(ray)
|
|
1370
|
+
{
|
|
1371
|
+
let nearest, id;
|
|
1372
|
+
for (const object of editor3DObjects())
|
|
1373
|
+
{
|
|
1374
|
+
const parts = editor3DVisible(object) ? editor3DParts(editor3DInstances.get(object.id)) : [];
|
|
1375
|
+
let distance = parts.length ? undefined : raycastBox(ray, editor3DPos(object), vec3(1));
|
|
1376
|
+
for (const part of parts)
|
|
1377
|
+
{
|
|
1378
|
+
const d = render3DRaycastObject(ray, part);
|
|
1379
|
+
if (d !== undefined && (distance === undefined || d < distance))
|
|
1380
|
+
distance = d;
|
|
1381
|
+
}
|
|
1382
|
+
if (distance !== undefined && (nearest === undefined || distance < nearest))
|
|
1383
|
+
nearest = distance, id = object.id;
|
|
1384
|
+
}
|
|
1385
|
+
return id;
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
// what the game made for an object and the parts attached to it, its children and theirs
|
|
1389
|
+
function editor3DParts(made, parts=[])
|
|
1390
|
+
{
|
|
1391
|
+
if (made instanceof EngineObject && !made.destroyed)
|
|
1392
|
+
{
|
|
1393
|
+
parts.push(made);
|
|
1394
|
+
for (const child of made.children)
|
|
1395
|
+
editor3DParts(child, parts);
|
|
1396
|
+
}
|
|
1397
|
+
return parts;
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
// what the game made for the selected objects, with their parts, left out when the selection looks for a surface
|
|
1401
|
+
// to stand on
|
|
1402
|
+
function editor3DSelectedInstances()
|
|
1403
|
+
{
|
|
1404
|
+
return new Set(editor3DSelected().flatMap((o)=> editor3DParts(editor3DInstances.get(o.id))));
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
// where each selected object is, as a drag starts from
|
|
1408
|
+
function editor3DDragStart()
|
|
1409
|
+
{
|
|
1410
|
+
return new Map(editor3DSelected().map((o)=>
|
|
1411
|
+
[o.id, {pos: editor3DPos(o), rotation: editor3DRotation(o), scale: editor3DScale(o)}]));
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
// the left button went down: on a handle it drags it, with a brush it places one, on an object it selects it and
|
|
1415
|
+
// may drag it, and on nothing it drags out a box to select with
|
|
1416
|
+
function editor3DPress(mouse, ray, shift)
|
|
1417
|
+
{
|
|
1418
|
+
const handle = editor3DHandleAt(mouse), from = mouse.copy();
|
|
1419
|
+
if (handle)
|
|
1420
|
+
{
|
|
1421
|
+
const {kind, center, direction, axes} = handle;
|
|
1422
|
+
const grab = kind === 'ring' ? editor3DRingAngle(ray, center, axes) :
|
|
1423
|
+
kind === 'plane' ? editor3DPlanePoint(ray, center, direction) :
|
|
1424
|
+
kind === 'scaleAll' ? 0 : editor3DAxisDistance(ray, center, direction);
|
|
1425
|
+
editor3DDrag = {kind, handle, from, grab, start: editor3DDragStart()};
|
|
1426
|
+
return;
|
|
1427
|
+
}
|
|
1428
|
+
if (editor3DBrush)
|
|
1429
|
+
{
|
|
1430
|
+
editor3DPlaceAt(editor3DBrush, ray);
|
|
1431
|
+
shift || (editor3DBrush = undefined);
|
|
1432
|
+
editor3DTool = 'move';
|
|
1433
|
+
return;
|
|
1434
|
+
}
|
|
1435
|
+
const id = editor3DPickAt(ray);
|
|
1436
|
+
if (id === undefined)
|
|
1437
|
+
{
|
|
1438
|
+
editor3DDrag = {kind: 'box', from, to: from.copy(), shift};
|
|
1439
|
+
return;
|
|
1440
|
+
}
|
|
1441
|
+
if (shift)
|
|
1442
|
+
{
|
|
1443
|
+
editor3DSelection.has(id) ? editor3DSelection.delete(id) : editor3DSelection.add(id);
|
|
1444
|
+
return;
|
|
1445
|
+
}
|
|
1446
|
+
if (!editor3DSelection.has(id))
|
|
1447
|
+
{
|
|
1448
|
+
editor3DSelection.clear();
|
|
1449
|
+
editor3DSelection.add(id);
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1452
|
+
// a drag of its body moves it level, at its height, so it stays under the mouse; seen from the side, where the
|
|
1453
|
+
// mouse's ray runs along that plane, it moves across the view instead, in the upright plane facing it
|
|
1454
|
+
const pos = editor3DPos(editor3DObject(id)), forward = editor3DCamera.getForward();
|
|
1455
|
+
const side = abs(ray.direction.y) < .2 && vec3(forward.x, 0, forward.z).normalize();
|
|
1456
|
+
const normal = side && side.lengthSquared() ? side : EDITOR3D_UP;
|
|
1457
|
+
editor3DDrag = {kind: 'body', id, from, moving: false, ignore: editor3DSelectedInstances(), normal,
|
|
1458
|
+
start: editor3DDragStart(), level: editor3DPlanePoint(ray, pos, normal) ?? pos};
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
// place an object of a type where a ray lands, standing on what is there, snapped, selected, as one undo
|
|
1462
|
+
function editor3DPlaceAt(type, ray)
|
|
1463
|
+
{
|
|
1464
|
+
const point = editor3DSurface(ray);
|
|
1465
|
+
if (!point) return;
|
|
1466
|
+
editor3DStrokeEnd();
|
|
1467
|
+
const id = editor3DPlace(type, point);
|
|
1468
|
+
if (id === undefined) return;
|
|
1469
|
+
const size = editor3DSize(editor3DObject(id)), ignore = new Set(editor3DParts(editor3DInstances.get(id)));
|
|
1470
|
+
const snapped = editor3DSnapPos(editor3DRest(point, size), size, editor3DGrid ? editor3DMoveStep : 0);
|
|
1471
|
+
const y = editor3DLand(snapped.x, snapped.z, point.y, size, ignore);
|
|
1472
|
+
editor3DChange((list)=> editor3DSetTransform(list.find((o)=> o.id === id), vec3(snapped.x, y, snapped.z)));
|
|
1473
|
+
editor3DStrokeEnd();
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
// the mouse moved with a drag held: the selection goes from where the drag started to where the mouse says
|
|
1477
|
+
function editor3DDragTo(drag, mouse, ray, snap)
|
|
1478
|
+
{
|
|
1479
|
+
if (drag.kind === 'blocks')
|
|
1480
|
+
return editor3DVoxelDragTo(drag, ray);
|
|
1481
|
+
if (drag.kind === 'terrain')
|
|
1482
|
+
return editor3DTerrainHover && editor3DTerrainSculpt(editor3DTerrainHover, drag.mode, editor3DSeconds);
|
|
1483
|
+
if (drag.kind === 'box')
|
|
1484
|
+
{
|
|
1485
|
+
drag.to = mouse.copy();
|
|
1486
|
+
return;
|
|
1487
|
+
}
|
|
1488
|
+
if (drag.kind === 'body' && !(drag.moving ||= mouse.distance(drag.from) > 4)) return;
|
|
1489
|
+
const {kind, handle, start, grab} = drag, first = editor3DObject(drag.id ?? [...start.keys()][0]);
|
|
1490
|
+
const was = first && start.get(first.id);
|
|
1491
|
+
if (!was) return;
|
|
1492
|
+
const size = editor3DSize(first), turned = editor3DTurned(first), step = snap ? editor3DMoveStep : 0;
|
|
1493
|
+
const snapTo = (to)=> editor3DSnapPos(to, size, step, turned).subtract(was.pos);
|
|
1494
|
+
let move, turn, factor;
|
|
1495
|
+
if (kind === 'arrow')
|
|
1496
|
+
{
|
|
1497
|
+
const d = editor3DAxisDistance(ray, handle.center, handle.direction) - grab;
|
|
1498
|
+
move = handle.direction.multiply(snapTo(was.pos.add(handle.direction.scale(d)))); // along its axis alone
|
|
1499
|
+
}
|
|
1500
|
+
else if (kind === 'plane')
|
|
1501
|
+
{
|
|
1502
|
+
const point = editor3DPlanePoint(ray, handle.center, handle.direction);
|
|
1503
|
+
if (!point || !grab) return;
|
|
1504
|
+
move = vec3(1).subtract(handle.direction).multiply(snapTo(was.pos.add(point.subtract(grab))));
|
|
1505
|
+
}
|
|
1506
|
+
else if (kind === 'body')
|
|
1507
|
+
{
|
|
1508
|
+
// level under the mouse, and with ground snap it lands on what is under it there, from where it started
|
|
1509
|
+
const point = editor3DPlanePoint(ray, drag.level, drag.normal);
|
|
1510
|
+
if (!point) return;
|
|
1511
|
+
const across = snapTo(was.pos.add(point.subtract(drag.level)));
|
|
1512
|
+
const y = editor3DGroundSnap ? editor3DLand(was.pos.x + across.x, was.pos.z + across.z,
|
|
1513
|
+
was.pos.y - size.y / 2, size, drag.ignore) : was.pos.y;
|
|
1514
|
+
move = vec3(across.x, y - was.pos.y, across.z);
|
|
1515
|
+
}
|
|
1516
|
+
else if (kind === 'ring')
|
|
1517
|
+
{
|
|
1518
|
+
const angle = editor3DRingAngle(ray, handle.center, handle.axes);
|
|
1519
|
+
if (angle === undefined || grab === undefined) return;
|
|
1520
|
+
turn = mod((angle - grab) * 180 / PI + 180, 360) - 180; // the short way around
|
|
1521
|
+
}
|
|
1522
|
+
else if (kind === 'scale')
|
|
1523
|
+
{
|
|
1524
|
+
if (abs(grab) < 1e-6) return;
|
|
1525
|
+
factor = vec3(1);
|
|
1526
|
+
factor[handle.axis] = max(editor3DAxisDistance(ray, handle.center, handle.direction) / grab, .01);
|
|
1527
|
+
}
|
|
1528
|
+
else // the box in the middle sizes all three, right and up is larger
|
|
1529
|
+
factor = vec3(Math.exp((mouse.x - drag.from.x - mouse.y + drag.from.y) / 200));
|
|
1530
|
+
|
|
1531
|
+
editor3DChange((list)=>
|
|
1532
|
+
{
|
|
1533
|
+
for (const object of list)
|
|
1534
|
+
{
|
|
1535
|
+
const s = start.get(object.id);
|
|
1536
|
+
if (!s) continue;
|
|
1537
|
+
if (move)
|
|
1538
|
+
editor3DSetTransform(object, s.pos.add(move));
|
|
1539
|
+
if (turn !== undefined)
|
|
1540
|
+
{
|
|
1541
|
+
const rotation = s.rotation.copy();
|
|
1542
|
+
rotation[handle.axis] = editor3DSnap(rotation[handle.axis] + turn, snap ? editor3DRotateStep : 0);
|
|
1543
|
+
editor3DSetTransform(object, undefined, rotation);
|
|
1544
|
+
}
|
|
1545
|
+
if (factor)
|
|
1546
|
+
{
|
|
1547
|
+
const size = snap ? editor3DScaleStep : 0, least = size || .01;
|
|
1548
|
+
const scale = s.scale.multiply(factor);
|
|
1549
|
+
editor3DSetTransform(object, undefined, undefined, vec3(max(editor3DSnap(scale.x, size), least),
|
|
1550
|
+
max(editor3DSnap(scale.y, size), least), max(editor3DSnap(scale.z, size), least)));
|
|
1551
|
+
}
|
|
1552
|
+
}
|
|
1553
|
+
});
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1556
|
+
// the left button went up: a box selects what is inside it, by where each object's middle is on screen, and a
|
|
1557
|
+
// press that never moved clears the selection; any other drag is one undo
|
|
1558
|
+
function editor3DDragEnd(drag, mouse)
|
|
1559
|
+
{
|
|
1560
|
+
editor3DDrag = undefined;
|
|
1561
|
+
if (drag.kind !== 'box')
|
|
1562
|
+
return editor3DStrokeEnd();
|
|
1563
|
+
drag.shift || editor3DSelection.clear();
|
|
1564
|
+
const {from} = drag;
|
|
1565
|
+
if (mouse.distance(from) < 4) return;
|
|
1566
|
+
const low = vec2(min(from.x, mouse.x), min(from.y, mouse.y));
|
|
1567
|
+
const high = vec2(max(from.x, mouse.x), max(from.y, mouse.y));
|
|
1568
|
+
for (const object of editor3DObjects())
|
|
1569
|
+
{
|
|
1570
|
+
const at = render3D.worldToScreen(editor3DPos(object));
|
|
1571
|
+
at && at.x >= low.x && at.x <= high.x && at.y >= low.y && at.y <= high.y &&
|
|
1572
|
+
editor3DSelection.add(object.id);
|
|
1573
|
+
}
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1576
|
+
// take the drag being made back, with nothing to undo
|
|
1577
|
+
function editor3DDragCancel()
|
|
1578
|
+
{
|
|
1579
|
+
editor3DDrag = undefined;
|
|
1580
|
+
editor3DStrokeCancel();
|
|
1581
|
+
}
|
|
1582
|
+
|
|
1583
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
1584
|
+
// the editor's camera, keys and update
|
|
1585
|
+
|
|
1586
|
+
// the surface under a screen position, under the mouse and under the middle of the view, seen through the
|
|
1587
|
+
// editor's camera
|
|
1588
|
+
function editor3DScreenPoint(screenPos)
|
|
1589
|
+
{ return editor3DWithView(()=> editor3DSurface(render3D.screenToRay(screenPos))); }
|
|
1590
|
+
function editor3DMousePoint() { return editor3DScreenPoint(mousePosScreen); }
|
|
1591
|
+
function editor3DViewPoint() { return editor3DScreenPoint(mainCanvasSize.scale(.5)); }
|
|
1592
|
+
|
|
1593
|
+
// move the camera back along its view until the selection is in the middle and fits
|
|
1594
|
+
function editor3DFrame()
|
|
1595
|
+
{
|
|
1596
|
+
const center = editor3DSelectionCenter(), camera = editor3DCamera;
|
|
1597
|
+
if (!center) return false;
|
|
1598
|
+
const radius = editor3DSelected().reduce((r, o)=>
|
|
1599
|
+
max(r, editor3DPos(o).distance(center) + editor3DSize(o).length() / 2), 0);
|
|
1600
|
+
camera.pos = center.subtract(camera.getForward().scale(max(radius * 2.5, 3)));
|
|
1601
|
+
render3D.updateMatrices();
|
|
1602
|
+
}
|
|
1603
|
+
|
|
1604
|
+
// drop the selection straight down onto what is under it, each object by itself, as one undo
|
|
1605
|
+
function editor3DDropSelection()
|
|
1606
|
+
{
|
|
1607
|
+
const ignore = editor3DSelectedInstances();
|
|
1608
|
+
if (!editor3DSelection.size) return false;
|
|
1609
|
+
editor3DStrokeEnd();
|
|
1610
|
+
editor3DChange((list)=>
|
|
1611
|
+
{
|
|
1612
|
+
for (const object of list)
|
|
1613
|
+
{
|
|
1614
|
+
const to = editor3DSelection.has(object.id) &&
|
|
1615
|
+
editor3DDrop(editor3DPos(object), editor3DSize(object), ignore);
|
|
1616
|
+
to && editor3DSetTransform(object, to);
|
|
1617
|
+
}
|
|
1618
|
+
});
|
|
1619
|
+
editor3DStrokeEnd();
|
|
1620
|
+
}
|
|
1621
|
+
|
|
1622
|
+
// paste the copied objects with their middle under the mouse, the lowest standing on what is there, snapped by
|
|
1623
|
+
// the first of them; where they were when the mouse is over nothing
|
|
1624
|
+
function editor3DPasteAtMouse()
|
|
1625
|
+
{
|
|
1626
|
+
const copied = editor3DClipboard, point = !editor3DMouseOnPanel && editor3DMousePoint();
|
|
1627
|
+
if (!copied?.length) return false;
|
|
1628
|
+
if (!point) return editor3DPaste();
|
|
1629
|
+
const center = copied.reduce((sum, o)=> sum.add(editor3DPos(o)), vec3()).scale(1 / copied.length);
|
|
1630
|
+
const bottom = copied.reduce((low, o)=> min(low, editor3DPos(o).y - editor3DSize(o).y / 2), Infinity);
|
|
1631
|
+
const first = copied[0], was = editor3DPos(first);
|
|
1632
|
+
const snapped = editor3DSnapPos(was.add(vec3(point.x - center.x, 0, point.z - center.z)), editor3DSize(first),
|
|
1633
|
+
editor3DGrid ? editor3DMoveStep : 0, editor3DTurned(first));
|
|
1634
|
+
const land = editor3DLand(snapped.x, snapped.z, point.y, editor3DSize(first)) - editor3DSize(first).y / 2;
|
|
1635
|
+
return editor3DPaste(vec3(snapped.x - was.x, land - bottom, snapped.z - was.z));
|
|
1636
|
+
}
|
|
1637
|
+
|
|
1638
|
+
// the keys by their position on the keyboard, each called with whether Shift is held; the tool keys are the ones
|
|
1639
|
+
// Unity, Unreal and Godot use
|
|
1640
|
+
/** @type {Object<string, function(boolean=): any>} */
|
|
1641
|
+
const editor3DKeys =
|
|
1642
|
+
{
|
|
1643
|
+
KeyQ: ()=> { editor3DTool = 'select'; editor3DBrush = undefined; },
|
|
1644
|
+
KeyW: ()=> { editor3DTool = 'move'; },
|
|
1645
|
+
KeyE: ()=> { editor3DTool = 'rotate'; },
|
|
1646
|
+
KeyR: ()=> { editor3DTool = 'scale'; },
|
|
1647
|
+
KeyB: ()=> { editor3DTool = 'blocks'; editor3DBrush = undefined; },
|
|
1648
|
+
KeyT: ()=> { editor3DTool = 'terrain'; editor3DBrush = undefined; },
|
|
1649
|
+
KeyP: ()=> editor3DTool === 'blocks' && editor3DVoxelPick(render3D.screenToRay(mousePosScreen)),
|
|
1650
|
+
KeyG: ()=> { editor3DGrid = !editor3DGrid; },
|
|
1651
|
+
KeyF: ()=> editor3DFrame(),
|
|
1652
|
+
End: ()=> editor3DDropSelection(),
|
|
1653
|
+
Delete: ()=> editor3DDelete(),
|
|
1654
|
+
Backspace: ()=> editor3DDelete(),
|
|
1655
|
+
Slash: ()=> { editor3DHelp = !editor3DHelp; },
|
|
1656
|
+
};
|
|
1657
|
+
/** @type {Object<string, function(boolean=): any>} */
|
|
1658
|
+
const editor3DCtrlKeys =
|
|
1659
|
+
{
|
|
1660
|
+
KeyZ: (shift)=> editor3DUndo(shift),
|
|
1661
|
+
KeyY: ()=> editor3DUndo(true),
|
|
1662
|
+
KeyC: ()=> editor3DCopySelection(),
|
|
1663
|
+
KeyX: ()=> editor3DCut(),
|
|
1664
|
+
KeyV: ()=> editor3DPasteAtMouse(),
|
|
1665
|
+
KeyD: ()=> editor3DDuplicate(),
|
|
1666
|
+
};
|
|
1667
|
+
|
|
1668
|
+
// the camera: the right button looks and the keys fly while it is held, the middle button or Space and the left
|
|
1669
|
+
// pans, Alt and the left orbits the selection, and the wheel zooms toward what is under the mouse
|
|
1670
|
+
function editor3DCameraUpdate(seconds, shift, alt)
|
|
1671
|
+
{
|
|
1672
|
+
const camera = editor3DCamera, delta = inputCaptureDeltaScreen, wheel = inputCaptureWheel;
|
|
1673
|
+
const ahead = ()=> camera.pos.add(camera.getForward().scale(10));
|
|
1674
|
+
if (mouseIsDown(2))
|
|
1675
|
+
{
|
|
1676
|
+
editor3DFlySpeed = clamp(editor3DFlySpeed * Math.exp(-wheel * .2), .01, 10);
|
|
1677
|
+
editor3DFly(camera, editor3DFlyKeys(), delta, seconds, shift);
|
|
1678
|
+
}
|
|
1679
|
+
else if (mouseIsDown(1) || keyIsDown('Space') && mouseIsDown(0))
|
|
1680
|
+
{
|
|
1681
|
+
// the scene follows the mouse, at the depth of what is in the middle of the view
|
|
1682
|
+
const perPixel = editor3DScreenScale(editor3DViewPoint() ?? ahead()) / mainCanvasSize.y;
|
|
1683
|
+
camera.pos = camera.pos.subtract(camera.getRight().scale(delta.x * perPixel))
|
|
1684
|
+
.add(camera.getUp().scale(delta.y * perPixel));
|
|
1685
|
+
}
|
|
1686
|
+
else if (alt && mouseIsDown(0))
|
|
1687
|
+
{
|
|
1688
|
+
if (mouseWasPressed(0) || !editor3DOrbitPivot)
|
|
1689
|
+
editor3DOrbitPivot = editor3DSelectionCenter() ?? editor3DViewPoint() ?? ahead();
|
|
1690
|
+
const pivot = editor3DOrbitPivot, distance = camera.pos.distance(pivot);
|
|
1691
|
+
editor3DFly(camera, vec3(), delta, 0);
|
|
1692
|
+
camera.pos = pivot.subtract(camera.getForward().scale(distance));
|
|
1693
|
+
}
|
|
1694
|
+
else if (wheel && !editor3DMouseOnPanel)
|
|
1695
|
+
{
|
|
1696
|
+
// toward what is under the mouse, a part of the way there for each notch, and never through it
|
|
1697
|
+
render3D.updateMatrices();
|
|
1698
|
+
const ray = render3D.screenToRay(mousePosScreen), target = editor3DSurface(ray);
|
|
1699
|
+
const distance = target ? camera.pos.distance(target) : 10;
|
|
1700
|
+
camera.pos = camera.pos.add(ray.direction.scale(min(max(distance * .15, .2) * -wheel, distance - .5)));
|
|
1701
|
+
}
|
|
1702
|
+
render3D.updateMatrices(); // what follows picks and projects through the camera where it is now
|
|
1703
|
+
}
|
|
1704
|
+
|
|
1705
|
+
// the editor's update, called while it is open with the input readable and render3D looking through its camera
|
|
1706
|
+
function editor3DEditorUpdate(seconds)
|
|
1707
|
+
{
|
|
1708
|
+
editor3DSeconds = seconds;
|
|
1709
|
+
const held = (...codes)=> codes.some((code)=> keyIsDown(code));
|
|
1710
|
+
const shift = held('ShiftLeft', 'ShiftRight'), alt = held('AltLeft', 'AltRight');
|
|
1711
|
+
const ctrl = held('ControlLeft', 'ControlRight', 'MetaLeft', 'MetaRight');
|
|
1712
|
+
const mouse = mousePosScreen;
|
|
1713
|
+
|
|
1714
|
+
// Escape, the debug key, puts a drag back, or else switches to playing; taken, so the debug overlay stays shut
|
|
1715
|
+
if (debugKey && keyWasPressed(debugKey))
|
|
1716
|
+
{
|
|
1717
|
+
inputClearKey(debugKey);
|
|
1718
|
+
editor3DDrag ? editor3DDragCancel() : editor3DPlay(editor3DMousePoint());
|
|
1719
|
+
return;
|
|
1720
|
+
}
|
|
1721
|
+
|
|
1722
|
+
// 0 exits the editor; cleared, so debugKeysAlways does not open it again in the same step
|
|
1723
|
+
if (keyWasPressed('Digit0'))
|
|
1724
|
+
{
|
|
1725
|
+
inputClearKey('Digit0');
|
|
1726
|
+
editor3DClose();
|
|
1727
|
+
return;
|
|
1728
|
+
}
|
|
1729
|
+
|
|
1730
|
+
editor3DCameraUpdate(seconds, shift, alt);
|
|
1731
|
+
const looking = mouseIsDown(2), cameraDrag = looking || mouseIsDown(1) || keyIsDown('Space') || alt;
|
|
1732
|
+
|
|
1733
|
+
// the keys, which wait for a drag to end, and fly while the right button is held
|
|
1734
|
+
if (!editor3DDrag && !looking)
|
|
1735
|
+
for (const [code, action] of Object.entries(ctrl ? editor3DCtrlKeys : editor3DKeys))
|
|
1736
|
+
keyWasPressed(code) && action(shift);
|
|
1737
|
+
|
|
1738
|
+
// a right press during a drag puts it back
|
|
1739
|
+
if (editor3DDrag && mouseWasPressed(2))
|
|
1740
|
+
return editor3DDragCancel();
|
|
1741
|
+
|
|
1742
|
+
const ray = render3D.screenToRay(mouse), idle = !cameraDrag && !editor3DMouseOnPanel && !editor3DDrag;
|
|
1743
|
+
editor3DHover = idle ? editor3DHandleAt(mouse) : undefined;
|
|
1744
|
+
|
|
1745
|
+
// the Blocks tool takes the mouse while the level has a map: Shift removes, Ctrl repaints
|
|
1746
|
+
const painting = editor3DTool === 'blocks' && !!editor3DVoxelMap();
|
|
1747
|
+
const mode = shift ? 'remove' : ctrl ? 'paint' : 'place';
|
|
1748
|
+
const target = painting && idle ? editor3DVoxelTarget(ray, mode) : undefined;
|
|
1749
|
+
editor3DBlockHover = target && {cell: target.cell, mode};
|
|
1750
|
+
// the Terrain tool takes it while the level has a terrain: held down it raises the ground, Shift lowers,
|
|
1751
|
+
// Ctrl smooths
|
|
1752
|
+
const terrain = editor3DTool === 'terrain' ? editor3DTerrainMap() : undefined;
|
|
1753
|
+
const reach = terrain && !cameraDrag && !editor3DMouseOnPanel ? terrain.raycast(ray) : undefined;
|
|
1754
|
+
editor3DTerrainHover = reach === undefined ? undefined : ray.getPosition(reach);
|
|
1755
|
+
if (painting)
|
|
1756
|
+
idle && mouseWasPressed(0) && (editor3DDrag = editor3DVoxelPress(ray, mode));
|
|
1757
|
+
else if (terrain)
|
|
1758
|
+
idle && mouseWasPressed(0) && editor3DTerrainHover &&
|
|
1759
|
+
(editor3DDrag = {kind: 'terrain', mode: shift ? 'lower' : ctrl ? 'smooth' : 'raise'});
|
|
1760
|
+
else if (idle && mouseWasPressed(0))
|
|
1761
|
+
editor3DPress(mouse, ray, shift);
|
|
1762
|
+
|
|
1763
|
+
// a quick click, down and up before this step, is a press and its release in one
|
|
1764
|
+
const drag = editor3DDrag;
|
|
1765
|
+
if (drag)
|
|
1766
|
+
mouseIsDown(0) ? editor3DDragTo(drag, mouse, ray, editor3DGrid !== ctrl) : editor3DDragEnd(drag, mouse);
|
|
1767
|
+
}
|
|
1768
|
+
|
|
1769
|
+
// Ctrl with a letter the editor uses is not the browser's while the editor is open, Ctrl+D would bookmark the page
|
|
1770
|
+
if (debug && globalThis.document?.addEventListener)
|
|
1771
|
+
document.addEventListener('keydown', (e)=>
|
|
1772
|
+
{
|
|
1773
|
+
if (editor3DIsOpen && (e.ctrlKey || e.metaKey) && /^Key[ZYCXVD]$/.test(e.code) && !editorIsTextField(e.target))
|
|
1774
|
+
e.preventDefault();
|
|
1775
|
+
});
|
|
1776
|
+
|
|
1777
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
1778
|
+
// what the panel does to the level, and what it says
|
|
1779
|
+
|
|
1780
|
+
// the help, every control
|
|
1781
|
+
const editor3DHelpLines =
|
|
1782
|
+
[
|
|
1783
|
+
'Left: select · Shift+Left: add or take away · Left drag from empty space: box select',
|
|
1784
|
+
'Q select · W move · E rotate · R scale: drag a handle, or the object itself',
|
|
1785
|
+
'G: grid snap, Ctrl flips it for a drag · End: drop to the ground',
|
|
1786
|
+
'Right button: look, and WASD and QE fly while it is held, Shift faster',
|
|
1787
|
+
'Wheel: zoom · Middle drag or Space+drag: pan · Alt+drag: orbit · F: frame the selection',
|
|
1788
|
+
'Pick a type, then click to place it, Shift+click keeps placing',
|
|
1789
|
+
'B blocks: click or drag places · Shift removes · Ctrl repaints · P picks the type under the mouse',
|
|
1790
|
+
'T terrain: hold to raise the ground · Shift lowers · Ctrl smooths · the brush is in the panel',
|
|
1791
|
+
'Delete · Ctrl+C / X / V: copy, cut, paste · Ctrl+D: duplicate · Ctrl+Z / Y: undo, redo',
|
|
1792
|
+
'Reset to file: the level as its file has it, Restart keeps your edits, Undo brings them back',
|
|
1793
|
+
'Esc: play and edit · 0: exit the editor · ?: keys',
|
|
1794
|
+
];
|
|
1795
|
+
|
|
1796
|
+
// the hint line, for what is held and what is selected
|
|
1797
|
+
function editor3DHint()
|
|
1798
|
+
{
|
|
1799
|
+
if (editor3DRecords.get(editor3DLevel)?.pending)
|
|
1800
|
+
return 'The level file changed: apply your edits or drop them';
|
|
1801
|
+
if (editor3DDrag)
|
|
1802
|
+
return editor3DDrag.kind === 'box' ? 'Let go to select what is inside' :
|
|
1803
|
+
'Esc or right click puts it back · Ctrl flips the snap';
|
|
1804
|
+
if (inputCaptureRead(()=> mouseIsDown(2)))
|
|
1805
|
+
return 'WASD and QE fly · Shift faster · the wheel sets the speed';
|
|
1806
|
+
if (editor3DBrush)
|
|
1807
|
+
return `Click to place a ${editor3DBrush} · Shift keeps placing · Q puts it down`;
|
|
1808
|
+
if (editor3DTool === 'terrain')
|
|
1809
|
+
return editor3DTerrainMap() ? 'Hold to raise the ground · Shift lowers · Ctrl smooths' :
|
|
1810
|
+
'Add a terrain in the panel to sculpt it';
|
|
1811
|
+
if (editor3DTool === 'blocks')
|
|
1812
|
+
return editor3DVoxelMap() ? 'Click or drag places · Shift removes · Ctrl repaints · P picks a type' :
|
|
1813
|
+
'Add a block map in the panel to paint blocks';
|
|
1814
|
+
if (editor3DSelection.size)
|
|
1815
|
+
return 'Drag a handle or the object · W move · E rotate · R scale · End drops it';
|
|
1816
|
+
return 'Click to select · pick a type to place · the right button looks · ? keys';
|
|
1817
|
+
}
|
|
1818
|
+
|
|
1819
|
+
// if the properties box has an input for a default's type
|
|
1820
|
+
const editor3DPropertyEditable = (value)=> ['number', 'boolean', 'string'].includes(typeof value) ||
|
|
1821
|
+
isColor(value) || isVector2(value) || isVector3(value);
|
|
1822
|
+
|
|
1823
|
+
// set the position, rotation in degrees or scale of the one selected object, from the panel, as one undo
|
|
1824
|
+
function editor3DSetSelectedTransform(field, value)
|
|
1825
|
+
{
|
|
1826
|
+
const selected = editor3DSelected(), id = selected[0]?.id;
|
|
1827
|
+
if (selected.length !== 1 || !isVector3(value)) return false;
|
|
1828
|
+
editor3DStrokeEnd();
|
|
1829
|
+
editor3DChange((list)=> editor3DSetTransform(list.find((o)=> o.id === id), field === 'pos' ? value : undefined,
|
|
1830
|
+
field === 'rotation' ? value : undefined, field === 'scale' ? value : undefined));
|
|
1831
|
+
editor3DStrokeEnd();
|
|
1832
|
+
return true;
|
|
1833
|
+
}
|
|
1834
|
+
|
|
1835
|
+
// set a property of the one selected object, from the panel, as one undo; the value has the type of its default
|
|
1836
|
+
function editor3DSetSelectedProperty(name, value)
|
|
1837
|
+
{
|
|
1838
|
+
const selected = editor3DSelected(), id = selected[0]?.id;
|
|
1839
|
+
const defaults = selected[0] && level3DTypes.get(selected[0].type)?.defaults;
|
|
1840
|
+
const d = defaults && Object.hasOwn(defaults, name) ? defaults[name] : undefined;
|
|
1841
|
+
const sameType = isColor(d) ? isColor(value) : isVector3(d) ? isVector3(value) :
|
|
1842
|
+
isVector2(d) ? isVector2(value) : typeof value === typeof d;
|
|
1843
|
+
if (selected.length !== 1 || !editor3DPropertyEditable(d) || !sameType) return false;
|
|
1844
|
+
editor3DStrokeEnd();
|
|
1845
|
+
editor3DChange((list)=> editor3DSetProperty(list.find((o)=> o.id === id), name, value, d));
|
|
1846
|
+
editor3DStrokeEnd();
|
|
1847
|
+
return true;
|
|
1848
|
+
}
|
|
1849
|
+
|
|
1850
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
1851
|
+
// what the editor draws
|
|
1852
|
+
|
|
1853
|
+
const EDITOR3D_AXIS_COLORS = {x: hsl(0, .9, .55), y: hsl(.33, .9, .45), z: hsl(.6, .9, .6)};
|
|
1854
|
+
const EDITOR3D_SELECT_COLOR = hsl(.15, 1, .6), EDITOR3D_MARKER_COLOR = hsl(.55, 1, .7);
|
|
1855
|
+
const EDITOR3D_SOLID_COLOR = hsl(.08, 1, .55);
|
|
1856
|
+
|
|
1857
|
+
// a width in world units that shows as a number of pixels at a point
|
|
1858
|
+
function editor3DPixels(point, pixels) { return editor3DScreenScale(point) / mainCanvasSize.y * pixels; }
|
|
1859
|
+
|
|
1860
|
+
// the 12 edges of the unit box a matrix places
|
|
1861
|
+
function editor3DDrawWire(matrix, color, pixels=2)
|
|
1862
|
+
{
|
|
1863
|
+
const corner = (i)=> matrix.transformPoint(vec3(i & 1 ? .5 : -.5, i & 2 ? .5 : -.5, i & 4 ? .5 : -.5));
|
|
1864
|
+
const width = editor3DPixels(matrix.getTranslation(), pixels);
|
|
1865
|
+
for (let i = 0; i < 8; ++i)
|
|
1866
|
+
for (const bit of [1, 2, 4])
|
|
1867
|
+
i & bit || render3D.drawLine(corner(i), corner(i | bit), width, color);
|
|
1868
|
+
}
|
|
1869
|
+
|
|
1870
|
+
// a ring around a point across two axes
|
|
1871
|
+
function editor3DDrawRing(center, across, along, radius, color, pixels=2)
|
|
1872
|
+
{
|
|
1873
|
+
const points = [];
|
|
1874
|
+
for (let i = 0; i <= 32; ++i)
|
|
1875
|
+
{
|
|
1876
|
+
const a = i / 32 * PI * 2;
|
|
1877
|
+
points.push(center.add(across.scale(cos(a) * radius)).add(along.scale(sin(a) * radius)));
|
|
1878
|
+
}
|
|
1879
|
+
render3D.drawRibbon(points, editor3DPixels(center, pixels), undefined, color);
|
|
1880
|
+
}
|
|
1881
|
+
|
|
1882
|
+
// what the game made for an object, when it has something to see and click
|
|
1883
|
+
function editor3DVisible(object)
|
|
1884
|
+
{
|
|
1885
|
+
const made = editor3DInstances.get(object.id);
|
|
1886
|
+
return made instanceof EngineObject3D && !made.destroyed && (made.mesh || made.tileInfo) ? made : undefined;
|
|
1887
|
+
}
|
|
1888
|
+
|
|
1889
|
+
// 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
|
|
1890
|
+
// marker
|
|
1891
|
+
function editor3DBoxMatrix(object)
|
|
1892
|
+
{
|
|
1893
|
+
const made = editor3DVisible(object), bounds = made?.mesh && (made.mesh.bounds || made.mesh.getBounds());
|
|
1894
|
+
if (!made)
|
|
1895
|
+
return buildMatrix(editor3DPos(object));
|
|
1896
|
+
const matrix = render3DObjectMatrix(made).copy();
|
|
1897
|
+
return bounds ? matrix.multiply(buildMatrix(bounds.min.add(bounds.max).scale(.5), undefined,
|
|
1898
|
+
bounds.max.subtract(bounds.min))) : matrix.multiply(buildMatrix(vec3(), undefined, made.size3D));
|
|
1899
|
+
}
|
|
1900
|
+
|
|
1901
|
+
// the ground grid at height 0 around where the camera is, a line each unit and a stronger one each 10, fading
|
|
1902
|
+
// with distance; drawn with the depth test so the level hides it, and a hair over 0 so a floor there does not
|
|
1903
|
+
function editor3DDrawGrid()
|
|
1904
|
+
{
|
|
1905
|
+
const camera = editor3DCamera.pos, reach = 40, y = .01;
|
|
1906
|
+
const x0 = round(camera.x), z0 = round(camera.z);
|
|
1907
|
+
for (let i = -reach; i <= reach; ++i)
|
|
1908
|
+
for (const across of [false, true])
|
|
1909
|
+
{
|
|
1910
|
+
const at = (across ? z0 : x0) + i, strong = at % 10 === 0;
|
|
1911
|
+
if (!strong && abs(i) > reach / 2) continue;
|
|
1912
|
+
const fade = 1 - abs(i) / (strong ? reach : reach / 2);
|
|
1913
|
+
const a = across ? vec3(x0 - reach, y, at) : vec3(at, y, z0 - reach);
|
|
1914
|
+
const b = across ? vec3(x0 + reach, y, at) : vec3(at, y, z0 + reach);
|
|
1915
|
+
render3D.drawLine(a, b, strong ? .04 : .02, hsl(0, 0, 1, (strong ? .35 : .15) * fade));
|
|
1916
|
+
}
|
|
1917
|
+
}
|
|
1918
|
+
|
|
1919
|
+
// the cone a move arrow ends in, standing on Y, made the first time one is drawn
|
|
1920
|
+
let editor3DCone;
|
|
1921
|
+
const editor3DConeMesh = ()=> editor3DCone ||= buildCone(1, 1, 12);
|
|
1922
|
+
|
|
1923
|
+
// the editor's drawing in the 3D pass: the grid, a marker for what has nothing to see, the collision shape of
|
|
1924
|
+
// what is solid and selected, the selection, the brush where it would go, and the handles
|
|
1925
|
+
function editor3DDraw()
|
|
1926
|
+
{
|
|
1927
|
+
const r = render3D;
|
|
1928
|
+
render3DWithState({depthTest: true}, editor3DDrawGrid);
|
|
1929
|
+
for (const object of editor3DObjects())
|
|
1930
|
+
{
|
|
1931
|
+
const made = editor3DVisible(object), selected = editor3DSelection.has(object.id);
|
|
1932
|
+
made || editor3DDrawWire(editor3DBoxMatrix(object), EDITOR3D_MARKER_COLOR, 1.5);
|
|
1933
|
+
selected && editor3DDrawWire(editor3DBoxMatrix(object), EDITOR3D_SELECT_COLOR, 2.5);
|
|
1934
|
+
if (made?.collideSolidObjects && !made.parent && selected)
|
|
1935
|
+
{
|
|
1936
|
+
// what it collides as, which turns with it, where its size3D is not its mesh's
|
|
1937
|
+
const shape = render3DSolidShape(made), color = EDITOR3D_SOLID_COLOR;
|
|
1938
|
+
if (shape.size)
|
|
1939
|
+
editor3DDrawWire(buildMatrix(shape.pos, shape.axes && made.rotation3D, shape.size), color, 1.5);
|
|
1940
|
+
else
|
|
1941
|
+
for (const [across, along] of [['x', 'y'], ['y', 'z'], ['z', 'x']])
|
|
1942
|
+
editor3DDrawRing(shape.pos, EDITOR3D_AXES[across], EDITOR3D_AXES[along], shape.radius, color, 1.5);
|
|
1943
|
+
}
|
|
1944
|
+
}
|
|
1945
|
+
|
|
1946
|
+
// the cell the Blocks tool would change: white to place, red to remove, yellow to repaint
|
|
1947
|
+
const map = editor3DTool === 'blocks' && !editor3DMouseOnPanel ? editor3DVoxelMap() : undefined;
|
|
1948
|
+
if (map && editor3DBlockHover && !editor3DDrag)
|
|
1949
|
+
{
|
|
1950
|
+
const {cell, mode} = editor3DBlockHover;
|
|
1951
|
+
editor3DDrawWire(buildMatrix(map.pos3D.add(cell).add(vec3(.5)), undefined, vec3(1.02)),
|
|
1952
|
+
mode === 'place' ? hsl(0, 0, 1, .9) : mode === 'remove' ? hsl(0, 1, .6) : hsl(.15, 1, .6), 2.5);
|
|
1953
|
+
}
|
|
1954
|
+
// the Terrain tool's brush, a ring on the ground
|
|
1955
|
+
if (editor3DTool === 'terrain' && editor3DTerrainHover && editor3DTerrainMap())
|
|
1956
|
+
editor3DDrawRing(editor3DTerrainHover.add(vec3(0, .05, 0)), EDITOR3D_AXES.x, EDITOR3D_AXES.z,
|
|
1957
|
+
editor3DTerrainBrush.size / 2, hsl(0, 0, 1, .9), 2.5);
|
|
1958
|
+
|
|
1959
|
+
// the map's edges, to see where it ends
|
|
1960
|
+
if (map)
|
|
1961
|
+
editor3DDrawWire(buildMatrix(map.pos3D.add(map.mapSize.scale(.5)), undefined, map.mapSize),
|
|
1962
|
+
hsl(.55, .6, .6, .5), 1);
|
|
1963
|
+
|
|
1964
|
+
// the brush, a box where a click would place it
|
|
1965
|
+
const point = editor3DBrush && !editor3DMouseOnPanel && !editor3DDrag && editor3DMousePoint();
|
|
1966
|
+
if (point)
|
|
1967
|
+
{
|
|
1968
|
+
const snapped = editor3DSnapPos(editor3DRest(point, vec3(1)), vec3(1), editor3DGrid ? editor3DMoveStep : 0);
|
|
1969
|
+
editor3DDrawWire(buildMatrix(vec3(snapped.x, point.y + .5, snapped.z)), hsl(0, 0, 1, .7));
|
|
1970
|
+
}
|
|
1971
|
+
|
|
1972
|
+
// the handles, the one under the mouse or being dragged in the selection's color
|
|
1973
|
+
for (const handle of editor3DHandles())
|
|
1974
|
+
{
|
|
1975
|
+
const {kind, axis, center, length, points} = handle;
|
|
1976
|
+
const taken = (editor3DDrag?.handle ?? editor3DHover);
|
|
1977
|
+
const lit = taken && taken.kind === kind && taken.axis === axis;
|
|
1978
|
+
const color = lit ? EDITOR3D_SELECT_COLOR : EDITOR3D_AXIS_COLORS[axis[0]] ?? WHITE;
|
|
1979
|
+
const width = editor3DPixels(center, lit ? 4 : 3), tip = length * .1;
|
|
1980
|
+
if (kind === 'ring')
|
|
1981
|
+
r.drawRibbon(points, width, undefined, color);
|
|
1982
|
+
else if (kind === 'plane')
|
|
1983
|
+
{
|
|
1984
|
+
// a flat square across its two axes, in the color of the axis it does not move along
|
|
1985
|
+
const size = vec3(tip * 1.6).subtract(handle.direction.scale(tip * 1.5));
|
|
1986
|
+
r.drawBox(points[0], size, lit ? color : EDITOR3D_AXIS_COLORS['xyz'.replace(axis[0], '')
|
|
1987
|
+
.replace(axis[1], '')].scale(1, .7));
|
|
1988
|
+
}
|
|
1989
|
+
else if (kind === 'scaleAll')
|
|
1990
|
+
r.drawBox(center, tip * 1.4, lit ? color : WHITE);
|
|
1991
|
+
else if (kind === 'arrow')
|
|
1992
|
+
{
|
|
1993
|
+
// a move arrow ends in a cone pointing along it, which says move this way, where scale has a box
|
|
1994
|
+
r.drawLine(points[0], points[1], width, color);
|
|
1995
|
+
const turn = axis === 'x' ? vec3(0, 0, -PI / 2) : axis === 'z' ? vec3(PI / 2, 0, 0) : undefined;
|
|
1996
|
+
const at = points[1].add(handle.direction.scale(tip));
|
|
1997
|
+
r.drawMesh(editor3DConeMesh(), buildMatrix(at, turn, vec3(tip * 1.4, tip * 2, tip * 1.4)), undefined, color);
|
|
1998
|
+
}
|
|
1999
|
+
else
|
|
2000
|
+
{
|
|
2001
|
+
r.drawLine(points[0], points[1], width, color);
|
|
2002
|
+
r.drawBox(points[1], tip, color, editor3DRotation(editor3DSelected()[0]).scale(PI / 180));
|
|
2003
|
+
}
|
|
2004
|
+
}
|
|
2005
|
+
}
|
|
2006
|
+
|
|
2007
|
+
// the editor's drawing on the 2D layer: the name of each marker, and the box being dragged out to select with
|
|
2008
|
+
function editor3DDrawLabels()
|
|
2009
|
+
{
|
|
2010
|
+
for (const object of editor3DObjects())
|
|
2011
|
+
{
|
|
2012
|
+
if (editor3DVisible(object)) continue;
|
|
2013
|
+
const at = render3D.worldToScreen(editor3DPos(object));
|
|
2014
|
+
at && drawTextScreen(object.type + '', at, 14, WHITE, 3, BLACK);
|
|
2015
|
+
}
|
|
2016
|
+
const drag = editor3DDrag;
|
|
2017
|
+
if (drag?.kind === 'box')
|
|
2018
|
+
{
|
|
2019
|
+
const {from: a, to: b} = drag, corners = [a, vec2(b.x, a.y), b, vec2(a.x, b.y)];
|
|
2020
|
+
corners.forEach((c, i)=> drawLine(c, corners[(i + 1) % 4], 2, EDITOR3D_SELECT_COLOR, undefined, 0, glEnable, true));
|
|
2021
|
+
}
|
|
2022
|
+
}
|
|
2023
|
+
|
|
2024
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2025
|
+
// the panel, in the look of the 2D level editor's, made the first time the editor opens
|
|
2026
|
+
|
|
2027
|
+
let editor3DPanel, editor3DPanelParts;
|
|
2028
|
+
|
|
2029
|
+
function editor3DPanelInit()
|
|
2030
|
+
{
|
|
2031
|
+
const panel = editor3DPanel = editorElement('div', document.body,
|
|
2032
|
+
'position:fixed;top:8px;left:8px;width:260px;max-height:calc(100% - 16px);overflow-y:auto;' +
|
|
2033
|
+
'box-sizing:border-box;padding:8px;background:#111d;color:#eee;font:12px monospace;' +
|
|
2034
|
+
'border-radius:4px;z-index:9999;user-select:none');
|
|
2035
|
+
|
|
2036
|
+
// a click or touch on the panel is not the level's, a mouse up still goes on so a drag can let go
|
|
2037
|
+
for (const type of ['mousedown','wheel','touchstart','touchmove','touchend','touchcancel'])
|
|
2038
|
+
panel.addEventListener(type, (e)=> e.stopPropagation());
|
|
2039
|
+
panel.addEventListener('mouseenter', ()=> editor3DMouseOnPanel = true);
|
|
2040
|
+
panel.addEventListener('mouseleave', ()=> editor3DMouseOnPanel = false);
|
|
2041
|
+
|
|
2042
|
+
const box = 'margin:4px 0;padding:4px;background:#222;border-radius:3px';
|
|
2043
|
+
const row = (parent=panel)=> editorElement('div', parent, 'display:flex;gap:4px;margin:4px 0;flex-wrap:wrap');
|
|
2044
|
+
const button = (parent, text, onclick, title='')=>
|
|
2045
|
+
{
|
|
2046
|
+
const b = editorElement('button', parent, 'flex:1;padding:3px;cursor:pointer', text);
|
|
2047
|
+
b.onclick = (e)=> { onclick(e); b.blur(); }; // the keys go back to the editor
|
|
2048
|
+
b.title = title;
|
|
2049
|
+
return b;
|
|
2050
|
+
};
|
|
2051
|
+
const check = (parent, text, onchange, title='')=>
|
|
2052
|
+
{
|
|
2053
|
+
const label = editorElement('label', parent, 'display:flex;gap:6px;align-items:center;margin:2px 0');
|
|
2054
|
+
const input = editorElement('input', label);
|
|
2055
|
+
input.type = 'checkbox';
|
|
2056
|
+
input.onchange = ()=> { onchange(input.checked); input.blur(); };
|
|
2057
|
+
editorElement('span', label, '', text);
|
|
2058
|
+
label.title = title;
|
|
2059
|
+
return input;
|
|
2060
|
+
};
|
|
2061
|
+
const choice = (parent, text, values, onchange)=>
|
|
2062
|
+
{
|
|
2063
|
+
const label = editorElement('label', parent, 'display:flex;gap:3px;align-items:center;flex:1');
|
|
2064
|
+
editorElement('span', label, '', text);
|
|
2065
|
+
const select = editorElement('select', label, 'flex:1;background:#333;color:#eee');
|
|
2066
|
+
for (const value of values)
|
|
2067
|
+
editorElement('option', select, '', value + '').value = value + '';
|
|
2068
|
+
select.onchange = ()=> { onchange(parseFloat(select.value)); select.blur(); };
|
|
2069
|
+
return select;
|
|
2070
|
+
};
|
|
2071
|
+
|
|
2072
|
+
editorElement('div', panel, 'font-weight:bold', '3D Level Editor');
|
|
2073
|
+
const top = row();
|
|
2074
|
+
button(top, 'Play', ()=> editor3DPlay(editor3DViewPoint()), 'Esc, and Esc again comes back to the editor');
|
|
2075
|
+
const restart = button(top, 'Restart', editor3DRestart, 'Rebuild the level and play it');
|
|
2076
|
+
button(top, 'Exit', ()=> levelEditor.close(), '0, then Esc opens the debug overlay again');
|
|
2077
|
+
const undo = row();
|
|
2078
|
+
button(undo, 'Undo', ()=> editor3DUndo(), 'Ctrl+Z');
|
|
2079
|
+
button(undo, 'Redo', ()=> editor3DUndo(true), 'Ctrl+Y');
|
|
2080
|
+
button(undo, 'Keys', ()=> editor3DHelp = !editor3DHelp, 'Every control, ?');
|
|
2081
|
+
|
|
2082
|
+
// a file that changed under its autosave
|
|
2083
|
+
const pending = editorElement('div', panel, 'padding:4px;margin:4px 0;background:#630;border-radius:3px');
|
|
2084
|
+
editorElement('div', pending, '', 'The level file changed since your autosaved edits');
|
|
2085
|
+
const pendingRow = editorElement('div', pending, 'display:flex;gap:4px;margin-top:4px');
|
|
2086
|
+
button(pendingRow, 'Apply edits', ()=> editor3DApplyPending());
|
|
2087
|
+
button(pendingRow, 'Drop them', ()=> editor3DDropPending());
|
|
2088
|
+
|
|
2089
|
+
// the tools, and the snapping
|
|
2090
|
+
const tools = row(), toolButtons = {};
|
|
2091
|
+
for (const [tool, text, key] of [['select', 'Select', 'Q'], ['move', 'Move', 'W'], ['rotate', 'Rotate', 'E'],
|
|
2092
|
+
['scale', 'Scale', 'R'], ['blocks', 'Blocks', 'B'], ['terrain', 'Terrain', 'T']])
|
|
2093
|
+
toolButtons[tool] = button(tools, text, ()=>
|
|
2094
|
+
{
|
|
2095
|
+
editor3DTool = tool;
|
|
2096
|
+
(tool === 'select' || tool === 'blocks' || tool === 'terrain') && (editor3DBrush = undefined);
|
|
2097
|
+
}, key);
|
|
2098
|
+
const snap = editorElement('div', panel, box);
|
|
2099
|
+
const grid = check(snap, 'Grid snap', (on)=> editor3DGrid = on, 'G, and Ctrl flips it for a drag');
|
|
2100
|
+
const steps = row(snap);
|
|
2101
|
+
const moveStep = choice(steps, 'Move', [1, .5, .25], (v)=> editor3DMoveStep = v);
|
|
2102
|
+
const rotateStep = choice(steps, 'Turn', [15, 45, 90], (v)=> editor3DRotateStep = v);
|
|
2103
|
+
const scaleStep = choice(steps, 'Size', [1, .5, .25], (v)=> editor3DScaleStep = v);
|
|
2104
|
+
const ground = check(snap, 'Ground snap', (on)=> editor3DGroundSnap = on,
|
|
2105
|
+
'A drag of an object slides it along what is under the mouse');
|
|
2106
|
+
|
|
2107
|
+
// the types, a click picks one up to place, made again when types are added
|
|
2108
|
+
editorElement('div', panel, 'color:#aaa;margin-top:4px', 'Place');
|
|
2109
|
+
const types = row();
|
|
2110
|
+
const properties = editorElement('div', panel, box);
|
|
2111
|
+
|
|
2112
|
+
// the Blocks tool's box: a map to add, or the types to paint with
|
|
2113
|
+
const blocks = editorElement('div', panel, box);
|
|
2114
|
+
|
|
2115
|
+
// the Terrain tool's box: a terrain to add, or the brush to sculpt with
|
|
2116
|
+
const terrainBox = editorElement('div', panel, box);
|
|
2117
|
+
|
|
2118
|
+
// the scene the level sets: its sky, sun, fog and shadows, or none, the game's own
|
|
2119
|
+
const scene = editorElement('div', panel, box);
|
|
2120
|
+
const sceneOn = check(scene, 'Level sets the scene', (on)=>
|
|
2121
|
+
{
|
|
2122
|
+
editor3DChangeScene(()=> on ? editor3DSceneFromView() : undefined);
|
|
2123
|
+
editor3DStrokeEnd();
|
|
2124
|
+
}, 'The sky, sun, fog and shadows are saved in the level; off leaves them to the game');
|
|
2125
|
+
const sceneRows = editorElement('div', scene);
|
|
2126
|
+
|
|
2127
|
+
const file = row();
|
|
2128
|
+
// Save says so for a moment when it saved, Save As shows where a page can write files
|
|
2129
|
+
const saved = (b, label)=> (result)=> result && (b.textContent = 'Saved', setTimeout(()=> b.textContent = label, 1e3));
|
|
2130
|
+
const save = button(file, 'Save', ()=> editor3DSave().then(saved(save, 'Save')),
|
|
2131
|
+
'Save, to the file picked the first time, or a download');
|
|
2132
|
+
const saveAs = button(file, 'Save As', ()=> editor3DSave(true).then(saved(saveAs, 'Save As')),
|
|
2133
|
+
'Save As, to a file picked again');
|
|
2134
|
+
button(file, 'Reset to file', ()=> editor3DRevert(),
|
|
2135
|
+
'Put the level back as its file has it, or as Save last wrote it; Undo brings the edits back');
|
|
2136
|
+
/** @type {any} */ (globalThis).showSaveFilePicker || (saveAs.style.display = 'none');
|
|
2137
|
+
const playFrom = check(panel, 'Play from mouse', (on)=> editor3DPlayFromMouse = on,
|
|
2138
|
+
'Escape starts play with the player at the mouse, Play at the middle of the view');
|
|
2139
|
+
|
|
2140
|
+
const storage = editorElement('div', panel, 'color:#f86;margin-top:4px',
|
|
2141
|
+
'Autosave failed, storage is full: Save to a file');
|
|
2142
|
+
const hint = editorElement('div', panel, 'color:#8ab;margin-top:4px');
|
|
2143
|
+
const help = editorElement('div', panel, 'color:#aaa;margin-top:4px;border-top:1px solid #444;padding-top:4px');
|
|
2144
|
+
for (const line of editor3DHelpLines)
|
|
2145
|
+
editorElement('div', help, 'margin:2px 0', line);
|
|
2146
|
+
button(help, 'Close', ()=> editor3DHelp = false, '?');
|
|
2147
|
+
|
|
2148
|
+
editor3DPanelParts = {restart, pending, toolButtons, grid, moveStep, rotateStep, scaleStep, ground, types,
|
|
2149
|
+
properties, blocks, terrainBox, sceneOn, sceneRows, playFrom, storage, hint, help, typeNames: ''};
|
|
2150
|
+
}
|
|
2151
|
+
|
|
2152
|
+
// show the panel as the editor is now
|
|
2153
|
+
function editor3DPanelUpdate()
|
|
2154
|
+
{
|
|
2155
|
+
if (!editor3DIsOpen)
|
|
2156
|
+
{
|
|
2157
|
+
editor3DPanel && (editor3DPanel.style.display = 'none');
|
|
2158
|
+
return;
|
|
2159
|
+
}
|
|
2160
|
+
editor3DPanel || editor3DPanelInit();
|
|
2161
|
+
editor3DPanel.style.display = '';
|
|
2162
|
+
const p = editor3DPanelParts, lit = '2px solid #4af';
|
|
2163
|
+
p.restart.style.display = levelEditor.onRestart ? '' : 'none';
|
|
2164
|
+
p.pending.style.display = editor3DRecords.get(editor3DLevel)?.pending ? '' : 'none';
|
|
2165
|
+
for (const tool in p.toolButtons)
|
|
2166
|
+
p.toolButtons[tool].style.outline = tool === editor3DTool ? lit : '';
|
|
2167
|
+
p.grid.checked = editor3DGrid;
|
|
2168
|
+
p.ground.checked = editor3DGroundSnap;
|
|
2169
|
+
p.moveStep.value = editor3DMoveStep + '';
|
|
2170
|
+
p.rotateStep.value = editor3DRotateStep + '';
|
|
2171
|
+
p.scaleStep.value = editor3DScaleStep + '';
|
|
2172
|
+
p.playFrom.checked = editor3DPlayFromMouse;
|
|
2173
|
+
p.playFrom.parentElement.style.display = levelEditor.onPlayFrom ? 'flex' : 'none';
|
|
2174
|
+
p.storage.style.display = editor3DSaveFailed ? '' : 'none';
|
|
2175
|
+
p.hint.textContent = editor3DHint();
|
|
2176
|
+
p.help.style.display = editor3DHelp ? '' : 'none';
|
|
2177
|
+
|
|
2178
|
+
// a button for each type, made again when the types changed
|
|
2179
|
+
const names = [...level3DTypes.keys()], typeNames = names.join('\n');
|
|
2180
|
+
if (p.typeNames !== typeNames)
|
|
2181
|
+
{
|
|
2182
|
+
p.typeNames = typeNames;
|
|
2183
|
+
p.types.replaceChildren(...names.map((name)=>
|
|
2184
|
+
{
|
|
2185
|
+
const b = editorElement('button', undefined, 'padding:3px 6px;cursor:pointer', name);
|
|
2186
|
+
b.onclick = ()=> { editor3DBrush = editor3DBrush === name ? undefined : name; b.blur(); };
|
|
2187
|
+
return b;
|
|
2188
|
+
}));
|
|
2189
|
+
}
|
|
2190
|
+
names.forEach((name, i)=> p.types.children[i].style.outline = name === editor3DBrush ? lit : '');
|
|
2191
|
+
editor3DPropertiesUpdate(p.properties);
|
|
2192
|
+
editor3DBlocksUpdate(p.blocks);
|
|
2193
|
+
editor3DTerrainBoxUpdate(p.terrainBox);
|
|
2194
|
+
p.sceneOn.checked = !!editor3DScene();
|
|
2195
|
+
editor3DSceneUpdate(p.sceneRows);
|
|
2196
|
+
}
|
|
2197
|
+
|
|
2198
|
+
// the terrain box, shown with the Terrain tool: without a terrain, its size, cells and height and a button to add
|
|
2199
|
+
// it; with one, the brush's size and strength, the terrain's color, and a button to take it away
|
|
2200
|
+
function editor3DTerrainBoxUpdate(box)
|
|
2201
|
+
{
|
|
2202
|
+
const map = editor3DTerrainMap(), shown = editor3DTool === 'terrain';
|
|
2203
|
+
box.style.display = shown ? '' : 'none';
|
|
2204
|
+
if (!shown) return;
|
|
2205
|
+
const terrain = editor3DLevelPart('terrain'), key = map ? 'terrain ' + terrain.color : 'none';
|
|
2206
|
+
if (box.dataset.key === key || box.contains(document.activeElement)) return;
|
|
2207
|
+
box.dataset.key = key;
|
|
2208
|
+
box.replaceChildren();
|
|
2209
|
+
const field = 'background:#222;color:#eee';
|
|
2210
|
+
const line = (name, title='')=>
|
|
2211
|
+
{
|
|
2212
|
+
const row = editorElement('label', box, 'display:flex;gap:4px;align-items:center;margin:2px 0');
|
|
2213
|
+
editorElement('span', row, 'flex:1', name);
|
|
2214
|
+
row.title = title;
|
|
2215
|
+
return row;
|
|
2216
|
+
};
|
|
2217
|
+
const number = (name, title, value, most)=>
|
|
2218
|
+
{
|
|
2219
|
+
const input = editorElement('input', line(name, title), field + ';width:60px');
|
|
2220
|
+
input.type = 'number';
|
|
2221
|
+
input.min = '1', input.max = most + '', input.step = '1';
|
|
2222
|
+
input.value = value + '';
|
|
2223
|
+
return input;
|
|
2224
|
+
};
|
|
2225
|
+
if (!map)
|
|
2226
|
+
{
|
|
2227
|
+
const size = number('Size', 'How far across it is in the world, each way', 64, 1024);
|
|
2228
|
+
const cellsRow = line('Cells', 'How many cells a side, more is finer and slower to sculpt');
|
|
2229
|
+
const cells = editorElement('select', cellsRow, field + ';width:68px');
|
|
2230
|
+
for (const n of [32, 64, 128])
|
|
2231
|
+
editorElement('option', cells, '', n + '').value = n + '';
|
|
2232
|
+
cells.value = '64';
|
|
2233
|
+
const height = number('Height', 'How tall its highest ground can be', 16, 1024);
|
|
2234
|
+
const add = editorElement('button', box, 'width:100%;padding:3px;cursor:pointer', 'Add terrain');
|
|
2235
|
+
add.title = 'A flat terrain, centered, its surface on the ground, to raise and dig';
|
|
2236
|
+
add.onclick = ()=>
|
|
2237
|
+
{
|
|
2238
|
+
const read = (input, fallback)=> clamp(parseFloat(input.value) || fallback, 1, 1024);
|
|
2239
|
+
editor3DTerrainAdd(vec2(read(size, 64)), parseInt(cells.value), read(height, 16));
|
|
2240
|
+
add.blur();
|
|
2241
|
+
};
|
|
2242
|
+
return;
|
|
2243
|
+
}
|
|
2244
|
+
const slider = (name, title, min, max, step, value, set)=>
|
|
2245
|
+
{
|
|
2246
|
+
const input = editorElement('input', line(name, title), 'width:120px');
|
|
2247
|
+
input.type = 'range';
|
|
2248
|
+
input.min = min, input.max = max, input.step = step;
|
|
2249
|
+
input.value = value + '';
|
|
2250
|
+
input.oninput = ()=> set(parseFloat(input.value));
|
|
2251
|
+
input.onchange = ()=> input.blur();
|
|
2252
|
+
};
|
|
2253
|
+
slider('Brush size', 'How far across the brush is', 1, 32, .5, editor3DTerrainBrush.size,
|
|
2254
|
+
(v)=> editor3DTerrainBrush.size = v);
|
|
2255
|
+
slider('Strength', 'How fast the brush works', .05, 1, .05, editor3DTerrainBrush.strength,
|
|
2256
|
+
(v)=> editor3DTerrainBrush.strength = v);
|
|
2257
|
+
const color = editorElement('input', line('Color'), field + ';width:60px');
|
|
2258
|
+
color.type = 'color';
|
|
2259
|
+
color.value = /^#[0-9a-f]{6}/i.test(terrain.color) ? terrain.color.slice(0, 7) : '#ffffff';
|
|
2260
|
+
color.oninput = ()=> editor3DChangePart('terrain', (t)=> ({...t, color: color.value}));
|
|
2261
|
+
color.onchange = ()=> { color.oninput(); editor3DStrokeEnd(); color.blur(); };
|
|
2262
|
+
const remove = editorElement('button', box, 'margin-top:4px;padding:2px 6px;cursor:pointer', 'Remove terrain');
|
|
2263
|
+
remove.title = 'Take the terrain out of the level, Undo brings it back';
|
|
2264
|
+
remove.onclick = ()=> { editor3DTerrainRemove(); remove.blur(); };
|
|
2265
|
+
}
|
|
2266
|
+
|
|
2267
|
+
// the blocks box, shown with the Blocks tool: without a map, its size and a button to add it; with one, a tile
|
|
2268
|
+
// for each block type to paint with, and a button to take the map away; made again when what it shows changes
|
|
2269
|
+
function editor3DBlocksUpdate(box)
|
|
2270
|
+
{
|
|
2271
|
+
const map = editor3DVoxelMap(), shown = editor3DTool === 'blocks';
|
|
2272
|
+
box.style.display = shown ? '' : 'none';
|
|
2273
|
+
if (!shown) return;
|
|
2274
|
+
const first = map?.tileInfo, texture = first?.textureInfo;
|
|
2275
|
+
const key = map ? ['map', editor3DBlockType, texture?.size.x, map.blockTypes.length].join() : 'none';
|
|
2276
|
+
if (box.dataset.key === key || box.contains(document.activeElement)) return;
|
|
2277
|
+
box.dataset.key = key;
|
|
2278
|
+
box.replaceChildren();
|
|
2279
|
+
const field = 'background:#222;color:#eee;width:46px';
|
|
2280
|
+
if (!map)
|
|
2281
|
+
{
|
|
2282
|
+
const row = editorElement('label', box, 'display:flex;gap:4px;align-items:center;margin:2px 0');
|
|
2283
|
+
editorElement('span', row, 'flex:1', 'Map size');
|
|
2284
|
+
const inputs = [32, 16, 32].map((n)=>
|
|
2285
|
+
{
|
|
2286
|
+
const input = editorElement('input', row, field);
|
|
2287
|
+
input.type = 'number';
|
|
2288
|
+
input.min = '1', input.max = '256', input.step = '1';
|
|
2289
|
+
input.value = n + '';
|
|
2290
|
+
return input;
|
|
2291
|
+
});
|
|
2292
|
+
const add = editorElement('button', box, 'width:100%;padding:3px;cursor:pointer', 'Add block map');
|
|
2293
|
+
add.title = 'An empty map of blocks, centered, its bottom on the ground';
|
|
2294
|
+
add.onclick = ()=>
|
|
2295
|
+
{
|
|
2296
|
+
const [x, y, z] = inputs.map((i)=> clamp(floor(parseFloat(i.value)) || 1, 1, 256));
|
|
2297
|
+
editor3DVoxelAdd(vec3(x, y, z));
|
|
2298
|
+
add.blur();
|
|
2299
|
+
};
|
|
2300
|
+
return;
|
|
2301
|
+
}
|
|
2302
|
+
|
|
2303
|
+
// the tiles of the sheet, a type shows the tile of its side; a click picks the type
|
|
2304
|
+
const image = texture?.image, padding = first.padding || 0;
|
|
2305
|
+
const cell = first.size.add(vec2(padding * 2));
|
|
2306
|
+
const count = texture && cell.x && cell.y ? floor(texture.size.x / cell.x) * floor(texture.size.y / cell.y) : 0;
|
|
2307
|
+
const types = [];
|
|
2308
|
+
for (let type = 1; type < 256 && types.length < 96; ++type)
|
|
2309
|
+
(type < count || map.blockTypes[type]) && types.push(type);
|
|
2310
|
+
types.includes(editor3DBlockType) || types.push(editor3DBlockType);
|
|
2311
|
+
const grid = editorElement('div', box, 'display:flex;flex-wrap:wrap;gap:2px');
|
|
2312
|
+
for (const type of types)
|
|
2313
|
+
{
|
|
2314
|
+
const canvas = editorElement('canvas', grid, 'width:26px;height:26px;cursor:pointer;background:#444;' +
|
|
2315
|
+
'image-rendering:pixelated;outline:' + (type === editor3DBlockType ? '2px solid #4af' : 'none'));
|
|
2316
|
+
canvas.width = canvas.height = 26;
|
|
2317
|
+
canvas.title = 'Block ' + type;
|
|
2318
|
+
canvas.onclick = ()=> editor3DBlockType = type;
|
|
2319
|
+
// the tile of its side; read without map.blockType, which would make every type one the map has
|
|
2320
|
+
const index = map.blockTypes[type]?.faces[0] ?? type;
|
|
2321
|
+
const tile = first.columns ? first.frame(index) : first.index(index);
|
|
2322
|
+
const context = canvas.getContext('2d');
|
|
2323
|
+
context.imageSmoothingEnabled = false;
|
|
2324
|
+
try
|
|
2325
|
+
{
|
|
2326
|
+
image && context.drawImage(image, tile.pos.x, tile.pos.y, tile.size.x, tile.size.y, 0, 0, 26, 26);
|
|
2327
|
+
// a tile with nothing on it is left out, unless it is the type picked, one in use or one the game set up
|
|
2328
|
+
const pixels = context.getImageData(0, 0, 26, 26).data;
|
|
2329
|
+
let blank = !!image;
|
|
2330
|
+
for (let i = 3; blank && i < pixels.length; i += 4)
|
|
2331
|
+
blank = !pixels[i];
|
|
2332
|
+
blank && type !== editor3DBlockType && !map.blockTypes[type] && canvas.remove();
|
|
2333
|
+
}
|
|
2334
|
+
catch {} // an image that can not be drawn or read, the title still says the type
|
|
2335
|
+
}
|
|
2336
|
+
const remove = editorElement('button', box, 'margin-top:4px;padding:2px 6px;cursor:pointer', 'Remove map');
|
|
2337
|
+
remove.title = 'Take the block map out of the level, Undo brings it back';
|
|
2338
|
+
remove.onclick = ()=> { editor3DVoxelRemove(); remove.blur(); };
|
|
2339
|
+
}
|
|
2340
|
+
|
|
2341
|
+
// the scene box: an input for each setting of the level's scene block, made again when the block changes, but not
|
|
2342
|
+
// while one of its inputs is being used; a slider or a color changes the scene as it moves, and is one undo
|
|
2343
|
+
function editor3DSceneUpdate(box)
|
|
2344
|
+
{
|
|
2345
|
+
const scene = editor3DScene(), key = scene ? JSON.stringify(scene) : '';
|
|
2346
|
+
box.style.display = scene ? '' : 'none';
|
|
2347
|
+
if (box.dataset.key === key || box.contains(document.activeElement)) return;
|
|
2348
|
+
box.dataset.key = key;
|
|
2349
|
+
box.replaceChildren();
|
|
2350
|
+
if (!scene) return;
|
|
2351
|
+
|
|
2352
|
+
// a setting the block leaves out shows as it is on screen
|
|
2353
|
+
const view = editor3DSceneFromView(), value = (name)=> scene[name] ?? view[name];
|
|
2354
|
+
const change = (name, v)=> editor3DChangeScene((s={})=> ({...s, [name]: v}));
|
|
2355
|
+
const field = 'background:#222;color:#eee';
|
|
2356
|
+
const line = (name, title='')=>
|
|
2357
|
+
{
|
|
2358
|
+
const row = editorElement('label', box, 'display:flex;gap:4px;align-items:center;margin:2px 0');
|
|
2359
|
+
editorElement('span', row, 'flex:1', name);
|
|
2360
|
+
row.title = title;
|
|
2361
|
+
return row;
|
|
2362
|
+
};
|
|
2363
|
+
// an input that changes the scene as it moves and ends the edit when let go
|
|
2364
|
+
const live = (input, read)=>
|
|
2365
|
+
{
|
|
2366
|
+
input.oninput = ()=> read();
|
|
2367
|
+
input.onchange = ()=> { read(); editor3DStrokeEnd(); input.blur(); };
|
|
2368
|
+
return input;
|
|
2369
|
+
};
|
|
2370
|
+
const slider = (name, title, min, max, step, v, set)=>
|
|
2371
|
+
{
|
|
2372
|
+
const input = editorElement('input', line(name, title), 'width:120px');
|
|
2373
|
+
input.type = 'range';
|
|
2374
|
+
input.min = min, input.max = max, input.step = step;
|
|
2375
|
+
input.value = v + '';
|
|
2376
|
+
return live(input, ()=> set(parseFloat(input.value)));
|
|
2377
|
+
};
|
|
2378
|
+
const color = (name, title, hex, set)=>
|
|
2379
|
+
{
|
|
2380
|
+
const input = editorElement('input', line(name, title), field + ';width:60px');
|
|
2381
|
+
input.type = 'color';
|
|
2382
|
+
input.value = /^#[0-9a-f]{6}/i.test(hex) ? hex.slice(0, 7) : '#ffffff';
|
|
2383
|
+
return live(input, ()=> set(input.value));
|
|
2384
|
+
};
|
|
2385
|
+
const number = (row, v, set)=>
|
|
2386
|
+
{
|
|
2387
|
+
const input = editorElement('input', row, field + ';width:56px');
|
|
2388
|
+
input.type = 'number';
|
|
2389
|
+
input.step = 'any';
|
|
2390
|
+
input.min = '0';
|
|
2391
|
+
input.value = v + '';
|
|
2392
|
+
input.onchange = ()=>
|
|
2393
|
+
{
|
|
2394
|
+
const n = parseFloat(input.value);
|
|
2395
|
+
isNumber(n) && n >= 0 && set(n);
|
|
2396
|
+
editor3DStrokeEnd();
|
|
2397
|
+
input.blur();
|
|
2398
|
+
};
|
|
2399
|
+
return input;
|
|
2400
|
+
};
|
|
2401
|
+
const toggle = (name, title, on, set)=>
|
|
2402
|
+
{
|
|
2403
|
+
const input = editorElement('input', line(name, title));
|
|
2404
|
+
input.type = 'checkbox';
|
|
2405
|
+
input.checked = on;
|
|
2406
|
+
input.onchange = ()=> { set(input.checked); editor3DStrokeEnd(); input.blur(); };
|
|
2407
|
+
};
|
|
2408
|
+
|
|
2409
|
+
// the sky: three colors and how much of them lights the scene, or no sky
|
|
2410
|
+
const sky = isArray(scene.sky) && scene.sky.length === 3 ? scene.sky : undefined;
|
|
2411
|
+
toggle('Sky', 'A sky dome, and light from it; off leaves the sky to the game', !!sky, (on)=>
|
|
2412
|
+
editor3DChangeScene((s={})=>
|
|
2413
|
+
{
|
|
2414
|
+
const {sky, ambient, ...rest} = s;
|
|
2415
|
+
return on ? {sky: ['#3d9be9', '#ccebff', '#ccebff'], ambient: .5, ...rest} : rest;
|
|
2416
|
+
}));
|
|
2417
|
+
if (sky)
|
|
2418
|
+
{
|
|
2419
|
+
['Sky top', 'Sky horizon', 'Sky bottom'].forEach((name, i)=> color(name, '', sky[i], (hex)=>
|
|
2420
|
+
change('sky', sky.map((c, k)=> k === i ? hex : c))));
|
|
2421
|
+
slider('Ambient', 'How much of the sky colors lights the scene', 0, 1, .05,
|
|
2422
|
+
isNumber(scene.ambient) ? scene.ambient : .5, (v)=> change('ambient', v));
|
|
2423
|
+
}
|
|
2424
|
+
|
|
2425
|
+
// the sun, as two angles
|
|
2426
|
+
const [around, height] = editor3DSunAngles(value('sunDirection'));
|
|
2427
|
+
slider('Sun around', 'Where the sun is around the scene, in degrees', 0, 359, 1, around, (v)=>
|
|
2428
|
+
change('sunDirection', editor3DSunDirection(v, editor3DSunAngles(value('sunDirection'))[1])));
|
|
2429
|
+
slider('Sun height', 'How high the sun is over the horizon, in degrees', 5, 90, 1, height, (v)=>
|
|
2430
|
+
change('sunDirection', editor3DSunDirection(editor3DSunAngles(value('sunDirection'))[0], v)));
|
|
2431
|
+
color('Sun color', '', value('sunColor'), (hex)=> change('sunColor', hex));
|
|
2432
|
+
|
|
2433
|
+
// the fog, by its distances, an end of 0 for none
|
|
2434
|
+
const fog = isArray(value('fog')) ? value('fog') : [0, 0], fogRow = line('Fog start, end',
|
|
2435
|
+
'Where the fog starts and where it is total, an end of 0 for no fog');
|
|
2436
|
+
number(fogRow, fog[0], (v)=> change('fog', [v, fog[1]]));
|
|
2437
|
+
number(fogRow, fog[1], (v)=> change('fog', [fog[0], v]));
|
|
2438
|
+
color('Fog color', 'The sky\'s horizon color when the level has a sky and no fog color',
|
|
2439
|
+
scene.fogColor ?? (sky ? sky[1] : view.fogColor), (hex)=> change('fogColor', hex));
|
|
2440
|
+
toggle('Shadows', 'The sun casts shadows', !!value('shadows'), (on)=> change('shadows', on));
|
|
2441
|
+
}
|
|
2442
|
+
|
|
2443
|
+
// the properties box: the position, rotation and scale of the one selected object and an input for each default
|
|
2444
|
+
// of its type, made again when the selection or the object changes, but not while one of its inputs is typed in
|
|
2445
|
+
function editor3DPropertiesUpdate(box)
|
|
2446
|
+
{
|
|
2447
|
+
const selected = editor3DSelected(), object = selected.length === 1 && selected[0];
|
|
2448
|
+
const type = object && level3DTypes.get(object.type);
|
|
2449
|
+
const key = object ? JSON.stringify(object) : selected.length ? selected.length + ' objects' : '';
|
|
2450
|
+
box.style.display = key ? '' : 'none';
|
|
2451
|
+
if (box.dataset.key === key || box.contains(document.activeElement)) return;
|
|
2452
|
+
box.dataset.key = key;
|
|
2453
|
+
box.replaceChildren();
|
|
2454
|
+
if (!object)
|
|
2455
|
+
{
|
|
2456
|
+
key && editorElement('div', box, 'color:#aaa', key + ' selected');
|
|
2457
|
+
return;
|
|
2458
|
+
}
|
|
2459
|
+
editorElement('div', box, 'color:#aaa;margin-bottom:2px', `${object.type} ${object.id}`);
|
|
2460
|
+
const field = 'background:#222;color:#eee';
|
|
2461
|
+
const line = (name)=>
|
|
2462
|
+
{
|
|
2463
|
+
const row = editorElement('label', box, 'display:flex;gap:4px;align-items:center;margin:2px 0');
|
|
2464
|
+
editorElement('span', row, 'flex:1', name);
|
|
2465
|
+
return row;
|
|
2466
|
+
};
|
|
2467
|
+
|
|
2468
|
+
// the numbers of a vector, set together once every one of them reads as a number
|
|
2469
|
+
const numbers = (name, value, set)=>
|
|
2470
|
+
{
|
|
2471
|
+
const row = line(name), axes = isVector3(value) ? 'xyz' : 'xy', inputs = [];
|
|
2472
|
+
for (const axis of axes)
|
|
2473
|
+
{
|
|
2474
|
+
const input = editorElement('input', row, field + ';width:' + (axes.length > 2 ? 46 : 52) + 'px');
|
|
2475
|
+
input.type = 'number';
|
|
2476
|
+
input.step = 'any';
|
|
2477
|
+
input.value = editor3DRound(value[axis]) + '';
|
|
2478
|
+
input.onchange = ()=>
|
|
2479
|
+
{
|
|
2480
|
+
const v = inputs.map((i)=> parseFloat(i.value));
|
|
2481
|
+
if (v.every((n)=> isNumber(n)))
|
|
2482
|
+
set(axes.length > 2 ? vec3(v[0], v[1], v[2]) : vec2(v[0], v[1]));
|
|
2483
|
+
else
|
|
2484
|
+
inputs.forEach((i, k)=> i.value = editor3DRound(value[axes[k]]) + '');
|
|
2485
|
+
input.blur();
|
|
2486
|
+
};
|
|
2487
|
+
inputs.push(input);
|
|
2488
|
+
}
|
|
2489
|
+
};
|
|
2490
|
+
numbers('pos', editor3DPos(object), (v)=> editor3DSetSelectedTransform('pos', v));
|
|
2491
|
+
numbers('rotation', editor3DRotation(object), (v)=> editor3DSetSelectedTransform('rotation', v));
|
|
2492
|
+
numbers('scale', editor3DScale(object), (v)=> editor3DSetSelectedTransform('scale', v));
|
|
2493
|
+
if (!type) return;
|
|
2494
|
+
|
|
2495
|
+
const values = level3DProperties(type, object);
|
|
2496
|
+
for (const [name, defaultValue] of Object.entries(type.defaults))
|
|
2497
|
+
{
|
|
2498
|
+
const value = values[name];
|
|
2499
|
+
if (isVector3(defaultValue) || isVector2(defaultValue))
|
|
2500
|
+
{
|
|
2501
|
+
numbers(name, value, (v)=> editor3DSetSelectedProperty(name, v));
|
|
2502
|
+
continue;
|
|
2503
|
+
}
|
|
2504
|
+
const input = editorElement('input', line(name), field + ';width:110px');
|
|
2505
|
+
const set = (v)=> { editor3DSetSelectedProperty(name, v); input.blur(); };
|
|
2506
|
+
if (typeof defaultValue === 'boolean')
|
|
2507
|
+
{
|
|
2508
|
+
input.type = 'checkbox';
|
|
2509
|
+
input.checked = !!value;
|
|
2510
|
+
input.onchange = ()=> set(input.checked);
|
|
2511
|
+
}
|
|
2512
|
+
else if (isColor(defaultValue))
|
|
2513
|
+
{
|
|
2514
|
+
input.type = 'color';
|
|
2515
|
+
input.value = value.toString(false);
|
|
2516
|
+
input.onchange = ()=> { const color = new Color().setHex(input.value); color.a = value.a; set(color); };
|
|
2517
|
+
}
|
|
2518
|
+
else if (typeof defaultValue === 'number')
|
|
2519
|
+
{
|
|
2520
|
+
input.type = 'number';
|
|
2521
|
+
input.step = 'any';
|
|
2522
|
+
input.value = value + '';
|
|
2523
|
+
input.onchange = ()=> { const v = parseFloat(input.value); isNumber(v) && set(v); };
|
|
2524
|
+
}
|
|
2525
|
+
else if (typeof defaultValue === 'string')
|
|
2526
|
+
{
|
|
2527
|
+
input.type = 'text';
|
|
2528
|
+
input.value = (value ?? '') + '';
|
|
2529
|
+
input.onchange = ()=> set(input.value);
|
|
2530
|
+
}
|
|
2531
|
+
else
|
|
2532
|
+
{
|
|
2533
|
+
// a type it has no input for, an array say, shown as it is
|
|
2534
|
+
input.readOnly = true;
|
|
2535
|
+
input.value = value + '';
|
|
2536
|
+
}
|
|
2537
|
+
}
|
|
2538
|
+
}
|
|
2539
|
+
|
|
2540
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2541
|
+
// plugin
|
|
2542
|
+
|
|
2543
|
+
function editor3DUpdate()
|
|
2544
|
+
{
|
|
2545
|
+
const seconds = clamp(timeReal - editor3DTimeLast, 0, .1);
|
|
2546
|
+
editor3DTimeLast = timeReal;
|
|
2547
|
+
if (!render3D) return;
|
|
2548
|
+
if (editor3DFreeCamera)
|
|
2549
|
+
return inputCaptureRead(()=> editor3DFreeCameraUpdate(seconds));
|
|
2550
|
+
if (editor3DIsOpen)
|
|
2551
|
+
return inputCaptureRead(()=> editor3DWithView(()=> editor3DEditorUpdate(seconds)));
|
|
2552
|
+
|
|
2553
|
+
// playing in an editing session, Escape, the debug key, goes back to the editor; taken, so the debug overlay
|
|
2554
|
+
// waits till the session ends; a captured mouse the browser let go of is Escape too, Chrome keeps that key
|
|
2555
|
+
const lostLock = editor3DLostLock(pointerLockIsActive());
|
|
2556
|
+
if (editor3DSession && (lostLock || debugKey && keyWasPressed(debugKey)))
|
|
2557
|
+
{
|
|
2558
|
+
debugKey && inputClearKey(debugKey);
|
|
2559
|
+
editor3DSetOpen(true);
|
|
2560
|
+
return;
|
|
2561
|
+
}
|
|
2562
|
+
editor3DFreeCameraKey();
|
|
2563
|
+
}
|
|
2564
|
+
|
|
2565
|
+
function editor3DRender()
|
|
2566
|
+
{
|
|
2567
|
+
if (headlessMode) return;
|
|
2568
|
+
editor3DPanelUpdate();
|
|
2569
|
+
if (editor3DFreeCamera)
|
|
2570
|
+
drawTextScreen('Free camera · C or Esc to leave', vec2(mainCanvasSize.x / 2, mainCanvasSize.y - 30), 20, WHITE,
|
|
2571
|
+
4, BLACK);
|
|
2572
|
+
editor3DIsOpen && editor3DDrawLabels();
|
|
2573
|
+
}
|
|
2574
|
+
|
|
2575
|
+
debug && engineAddPlugin(editor3DUpdate, editor3DRender);
|
|
2576
|
+
debug && debugOverlayKeys.push(()=> render3D && {text: 'C: Free Camera', on: editor3DFreeCamera});
|