littlejsengine 1.20.0 → 1.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/littlejs.d.ts +408 -26
- package/dist/littlejs.esm.js +3360 -161
- package/dist/littlejs.esm.min.js +3 -3
- package/dist/littlejs.js +3344 -161
- package/dist/littlejs.min.js +2 -2
- package/dist/littlejs.release.js +1448 -141
- package/package.json +1 -1
- package/plugins/gltf.js +54 -8
- package/plugins/math3d.js +90 -10
- package/plugins/pluginExport.js +10 -0
- package/plugins/postProcess.js +41 -12
- package/plugins/render3d.js +424 -55
- package/plugins/render3dDebug.js +2 -1
- package/plugins/render3dEditor.js +1812 -0
- package/plugins/render3dExtras.js +199 -5
- package/plugins/render3dLevel.js +189 -0
- package/plugins/render3dVoxels.js +12 -0
- package/plugins/sceneSystem.js +58 -0
- package/plugins/tweenSystem.js +4 -2
- package/src/engine.js +199 -34
- package/src/engineAudio.js +2 -2
- package/src/engineBuild.mjs +3 -0
- package/src/engineDebug.js +55 -1
- package/src/engineDraw.js +97 -6
- package/src/engineEditor.js +41 -20
- package/src/engineExport.js +6 -0
- package/src/engineInput.js +8 -4
- package/src/engineLogo.js +19 -0
- package/src/engineObject.js +3 -2
- package/src/engineRelease.js +13 -1
- package/src/engineSettings.js +38 -0
package/dist/littlejs.js
CHANGED
|
@@ -35,7 +35,7 @@ const engineName = 'LittleJS';
|
|
|
35
35
|
* @type {string}
|
|
36
36
|
* @default
|
|
37
37
|
* @memberof Engine */
|
|
38
|
-
const engineVersion = '1.
|
|
38
|
+
const engineVersion = '1.21.0';
|
|
39
39
|
|
|
40
40
|
/** Frames per second to update
|
|
41
41
|
* @type {number}
|
|
@@ -43,11 +43,12 @@ const engineVersion = '1.20.0';
|
|
|
43
43
|
* @memberof Engine */
|
|
44
44
|
const frameRate = 60;
|
|
45
45
|
|
|
46
|
-
/** How many seconds
|
|
46
|
+
/** How many seconds the update covers: 1/60 with the fixed time step, or with engineVariableStep the time of the
|
|
47
|
+
* display frame it runs on, times timeScale; while paused it keeps the last update's
|
|
47
48
|
* @type {number}
|
|
48
49
|
* @default 1/60
|
|
49
50
|
* @memberof Engine */
|
|
50
|
-
|
|
51
|
+
let timeDelta = 1/frameRate;
|
|
51
52
|
|
|
52
53
|
/** Array containing all engine objects
|
|
53
54
|
* @type {Array<EngineObject>}
|
|
@@ -95,6 +96,75 @@ function setPaused(isPaused=true) { paused = isPaused; }
|
|
|
95
96
|
|
|
96
97
|
// Engine internal variables
|
|
97
98
|
let frameTimeLastMS = 0, frameTimeBufferMS = 0, averageFPS = 0;
|
|
99
|
+
|
|
100
|
+
// delta smoothing, after Time Delta Smoothing by Frank Force (2013): a frame is on screen for whole display frames,
|
|
101
|
+
// so each delta is rounded to them and the rest is carried to the next, keeping the total real time; the frame
|
|
102
|
+
// length is estimated from recent frames and kept internal, since the browser does not say what it is
|
|
103
|
+
const frameDeltaHistory = [];
|
|
104
|
+
let frameIntervalMS = 1e3 / 60, frameDeltaCarryMS = 0, frameDeltaCarryAverageMS = 0, frameDeltaSmoothing = true;
|
|
105
|
+
function engineSmoothDelta(deltaMS)
|
|
106
|
+
{
|
|
107
|
+
if (!(deltaMS > 0)) return 0;
|
|
108
|
+
if (deltaMS < 250) // a gap from a hidden tab is not a display frame
|
|
109
|
+
{
|
|
110
|
+
frameDeltaHistory.push(deltaMS);
|
|
111
|
+
frameDeltaHistory.length > 64 && frameDeltaHistory.shift();
|
|
112
|
+
|
|
113
|
+
// the frames each delta held are counted in the last estimate or in the lower quartile of the last 16,
|
|
114
|
+
// whichever the deltas fit better: the quartile is one frame even when a busy game misses most of them, and
|
|
115
|
+
// it takes over when the window moves to a display with another refresh rate, or the estimate went wrong
|
|
116
|
+
const recent = frameDeltaHistory.slice(-16).sort((a, b)=> a - b);
|
|
117
|
+
const [lastMS, lastOff] = engineFrameFit(frameIntervalMS);
|
|
118
|
+
const [quartileMS, quartileOff] = engineFrameFit(recent[recent.length >> 2]);
|
|
119
|
+
frameIntervalMS = lastOff <= quartileOff ? lastMS : quartileMS;
|
|
120
|
+
|
|
121
|
+
// deltas far from whole frames mean the display has no fixed refresh, with a margin so it does not flip
|
|
122
|
+
frameDeltaSmoothing = min(lastOff, quartileOff) < (frameDeltaSmoothing ? .2 : .12);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (!frameDeltaSmoothing)
|
|
126
|
+
{
|
|
127
|
+
// deltas that are not whole frames of one length, like a variable refresh display, are the frame times
|
|
128
|
+
// as they are, with any carry paid, and smoothing starts over once they fit again
|
|
129
|
+
const rawMS = deltaMS + frameDeltaCarryMS;
|
|
130
|
+
frameDeltaCarryMS = min(rawMS, 0);
|
|
131
|
+
frameDeltaCarryAverageMS = 0;
|
|
132
|
+
return max(rawMS, 0);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// whole frames, 0 for one that came early or 2 after one was missed, the carry stays within half a frame; a
|
|
136
|
+
// little of its slow average is paid each frame, so jitter can not hold it at half a frame and flip every frame
|
|
137
|
+
const pullMS = frameDeltaCarryAverageMS / 32;
|
|
138
|
+
frameDeltaCarryMS += deltaMS;
|
|
139
|
+
const frames = round((frameDeltaCarryMS - pullMS) / frameIntervalMS);
|
|
140
|
+
if (frames < 1)
|
|
141
|
+
return 0; // the carry keeps it
|
|
142
|
+
const smoothMS = frames * frameIntervalMS + pullMS;
|
|
143
|
+
frameDeltaCarryMS -= smoothMS;
|
|
144
|
+
frameDeltaCarryAverageMS += (frameDeltaCarryMS - frameDeltaCarryAverageMS) / 32;
|
|
145
|
+
return smoothMS;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// fit the delta history as whole frames of about unitMS: the frame length is the least squares slope of time over
|
|
149
|
+
// frames held, so jitter and whole ms timestamps average out, with how far the deltas are from whole frames
|
|
150
|
+
function engineFrameFit(unitMS)
|
|
151
|
+
{
|
|
152
|
+
let timeMS = 0, frames = 0, offMS = 0, sumF = 0, sumT = 0, sumFF = 0, sumFT = 0;
|
|
153
|
+
const count = frameDeltaHistory.length;
|
|
154
|
+
for (const d of frameDeltaHistory)
|
|
155
|
+
{
|
|
156
|
+
const held = max(1, round(d / unitMS));
|
|
157
|
+
timeMS += d;
|
|
158
|
+
frames += held;
|
|
159
|
+
offMS += abs(d - held * unitMS);
|
|
160
|
+
sumF += frames, sumT += timeMS, sumFF += frames * frames, sumFT += frames * timeMS;
|
|
161
|
+
}
|
|
162
|
+
const spread = count * sumFF - sumF * sumF;
|
|
163
|
+
return [spread ? (count * sumFT - sumF * sumT) / spread : unitMS, offMS / count / unitMS];
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// where the fixed step's time counts from, moved when the variable step hands back so time goes on from there
|
|
167
|
+
let timeFixedStart = 0, frameFixedStart = 0;
|
|
98
168
|
let windowWidthLast = 0, windowHeightLast = 0, windowPixelRatioLast = 0;
|
|
99
169
|
let engineUpdateInternal; // assigned by engineInit so engineStep can drive it
|
|
100
170
|
let engineFrameScheduled = false; // a frame of the loop is asked for and has not run yet
|
|
@@ -112,6 +182,9 @@ function engineObjectsCollidePairAdd(asker, other, resolve=false)
|
|
|
112
182
|
others.set(other, resolve);
|
|
113
183
|
}
|
|
114
184
|
let engineInitialized = false; // engineInit ran, with or without a canvas
|
|
185
|
+
// the loads startup waits for, each counted for the loading screen, and how many are done; undefined once the game
|
|
186
|
+
// loop starts
|
|
187
|
+
let engineLoads, engineLoadsDone = 0;
|
|
115
188
|
let engineObjectsUpdateCount = 0; // passes of engineObjectsUpdate so far, how a child knows it moved this pass
|
|
116
189
|
const engineChildStack = []; // the children being updated, taken off the live lists so one leaving does not skip the next
|
|
117
190
|
let showEngineVersion = true;
|
|
@@ -161,11 +234,68 @@ function engineAddPlugin(update, render, glContextLost, glContextRestored, preRe
|
|
|
161
234
|
///////////////////////////////////////////////////////////////////////////////
|
|
162
235
|
// Main Engine Functions
|
|
163
236
|
|
|
237
|
+
/** Add something the game loads to what startup waits for: while engineInit and gameInit run, the game loop starts
|
|
238
|
+
* once it is done, and the loading screen counts it; images from loadTexture and sounds from files are added on
|
|
239
|
+
* their own, and a load that fails counts as done; after startup it does nothing
|
|
240
|
+
* @param {Promise<any>} promise
|
|
241
|
+
* @return {Promise<any>} - The same promise
|
|
242
|
+
* @example
|
|
243
|
+
* async function gameInit() { level = await engineAddLoad(fetchJSON('level.json')); }
|
|
244
|
+
* @memberof Engine */
|
|
245
|
+
function engineAddLoad(promise)
|
|
246
|
+
{
|
|
247
|
+
if (engineLoads)
|
|
248
|
+
{
|
|
249
|
+
engineLoads.push(promise);
|
|
250
|
+
const done = ()=> { ++engineLoadsDone; };
|
|
251
|
+
promise.then(done, done);
|
|
252
|
+
}
|
|
253
|
+
return promise;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// wait for every load, the ones added while waiting too, drawing the loading screen each frame meanwhile
|
|
257
|
+
async function engineWaitForLoads()
|
|
258
|
+
{
|
|
259
|
+
let waiting = true;
|
|
260
|
+
const start = performance.now();
|
|
261
|
+
const drawFrame = ()=>
|
|
262
|
+
{
|
|
263
|
+
if (!waiting) return;
|
|
264
|
+
engineLoadingScreenDraw((performance.now() - start) / 1e3);
|
|
265
|
+
setTimeout(drawFrame, 16);
|
|
266
|
+
};
|
|
267
|
+
headlessMode || drawFrame();
|
|
268
|
+
try
|
|
269
|
+
{
|
|
270
|
+
for (let count; count !== engineLoads.length;)
|
|
271
|
+
{
|
|
272
|
+
count = engineLoads.length;
|
|
273
|
+
await Promise.allSettled(engineLoads);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
finally { waiting = false; }
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// one frame of the loading screen, once loading has taken half a second, so a fast load never shows it; input
|
|
280
|
+
// while it shows is dropped, as it is under the splash
|
|
281
|
+
function engineLoadingScreenDraw(elapsed)
|
|
282
|
+
{
|
|
283
|
+
if (headlessMode || !loadingScreen || elapsed < .5) return;
|
|
284
|
+
inputClear();
|
|
285
|
+
engineUpdateCanvas();
|
|
286
|
+
loadingScreen(engineLoadsDone / engineLoads.length);
|
|
287
|
+
}
|
|
288
|
+
|
|
164
289
|
/**
|
|
165
290
|
* @callback GameInitCallback - Called after the engine starts, can be async
|
|
166
291
|
* @return {void|Promise<void>}
|
|
167
292
|
* @memberof Engine
|
|
168
293
|
*/
|
|
294
|
+
/**
|
|
295
|
+
* @callback LoadingScreenCallback - Draws the loading screen on mainContext, each frame while the game loads
|
|
296
|
+
* @param {number} progress - The part of the loads done, 0 to 1
|
|
297
|
+
* @memberof Engine
|
|
298
|
+
*/
|
|
169
299
|
/**
|
|
170
300
|
* @callback GameCallback - Update or render function for the game
|
|
171
301
|
* @memberof Engine
|
|
@@ -200,6 +330,7 @@ async function engineInit(gameInit, gameUpdate, gameUpdatePost, gameRender, game
|
|
|
200
330
|
// double-register listeners / double-add canvases on a second call
|
|
201
331
|
if (engineInitialized) return;
|
|
202
332
|
engineInitialized = true;
|
|
333
|
+
engineLoads = [], engineLoadsDone = 0;
|
|
203
334
|
ASSERT(isArray(imageSources), 'pass in images as array');
|
|
204
335
|
|
|
205
336
|
// allow passing in empty functions
|
|
@@ -239,6 +370,9 @@ async function engineInit(gameInit, gameUpdate, gameUpdatePost, gameRender, game
|
|
|
239
370
|
frameTimeLastMS = frameTimeMS;
|
|
240
371
|
if (debug || debugWatermark)
|
|
241
372
|
averageFPS = lerp(averageFPS, 1e3/(frameTimeDeltaMS||1), .05);
|
|
373
|
+
// the time the frame will be on screen, in whole display frames; engineStep's steps are exact already
|
|
374
|
+
if (!manualStepAtStart)
|
|
375
|
+
frameTimeDeltaMS = engineSmoothDelta(frameTimeDeltaMS);
|
|
242
376
|
audioUpdateVolume();
|
|
243
377
|
// the time keys work while the debug overlay is open, or always when debugKeysAlways is set
|
|
244
378
|
const debugKeys = debug && (debugOverlay || debugKeysAlways);
|
|
@@ -251,32 +385,61 @@ async function engineInit(gameInit, gameUpdate, gameUpdatePost, gameRender, game
|
|
|
251
385
|
timeReal += frameTimeDeltaMS * debugScale / 1e3;
|
|
252
386
|
const combinedScale = timeScale * debugScale;
|
|
253
387
|
frameTimeDeltaMS *= combinedScale;
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
const frozen = paused || !combinedScale;
|
|
257
|
-
frameTimeBufferMS += frozen ? frameTimeDeltaUnscaledMS : frameTimeDeltaMS;
|
|
258
|
-
frameTimeBufferMS = min(frameTimeBufferMS, 50 * (frozen ? 1 : max(1, combinedScale))); // clamp min framerate
|
|
259
|
-
|
|
260
|
-
// apply time delta smoothing, improves smoothness of framerate in some browsers
|
|
261
|
-
let wasUpdated = false, deltaSmooth = 0;
|
|
262
|
-
if (frameTimeBufferMS < 0 && frameTimeBufferMS > -9)
|
|
388
|
+
let wasUpdated = false;
|
|
389
|
+
if (engineVariableStep)
|
|
263
390
|
{
|
|
264
|
-
//
|
|
265
|
-
|
|
266
|
-
|
|
391
|
+
// one update for the frame with timeDelta the time it covers, per-frame values are the game's to scale;
|
|
392
|
+
// engineStep's frames are exactly 1/60, its first too, which has no frame before it to take a delta from
|
|
393
|
+
if (frameTimeDeltaUnscaledMS > 0 || manualStepAtStart)
|
|
394
|
+
{
|
|
395
|
+
// frozen stands still as in the fixed step, and timeDelta keeps the last update's
|
|
396
|
+
const frozenTick = paused || !combinedScale;
|
|
397
|
+
if (!frozenTick)
|
|
398
|
+
{
|
|
399
|
+
timeDelta = manualStepAtStart ? combinedScale / frameRate :
|
|
400
|
+
min(frameTimeDeltaUnscaledMS, 50) * combinedScale / 1e3; // clamp min framerate
|
|
401
|
+
time += timeDelta;
|
|
402
|
+
++frame;
|
|
403
|
+
}
|
|
404
|
+
engineTick(frozenTick);
|
|
405
|
+
}
|
|
267
406
|
}
|
|
268
|
-
|
|
269
|
-
// update multiple frames if necessary in case of slow framerate
|
|
270
|
-
for (; frameTimeBufferMS >= 0; frameTimeBufferMS -= 1e3 / frameRate)
|
|
407
|
+
else
|
|
271
408
|
{
|
|
272
|
-
//
|
|
273
|
-
|
|
409
|
+
// paused or a time scale of 0 is frozen: it ticks on unscaled time, so the update rate stays fixed instead
|
|
410
|
+
// of following however fast the display refreshes, and gameUpdatePost and input still run to leave it
|
|
411
|
+
const frozen = paused || !combinedScale;
|
|
412
|
+
frameTimeBufferMS += frozen ? frameTimeDeltaUnscaledMS : frameTimeDeltaMS;
|
|
413
|
+
frameTimeBufferMS = min(frameTimeBufferMS, 50 * (frozen ? 1 : max(1, combinedScale))); // clamp min framerate
|
|
274
414
|
|
|
275
|
-
//
|
|
276
|
-
|
|
277
|
-
|
|
415
|
+
// apply time delta smoothing, improves smoothness of framerate in some browsers
|
|
416
|
+
let deltaSmooth = 0;
|
|
417
|
+
if (frameTimeBufferMS < 0 && frameTimeBufferMS > -9)
|
|
418
|
+
{
|
|
419
|
+
// force at least one update each frame since it is waiting for refresh
|
|
420
|
+
deltaSmooth = frameTimeBufferMS;
|
|
421
|
+
frameTimeBufferMS = 0;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// update multiple frames if necessary in case of slow framerate
|
|
425
|
+
for (; frameTimeBufferMS >= 0; frameTimeBufferMS -= 1e3 / frameRate)
|
|
426
|
+
{
|
|
427
|
+
// read again each tick, so a pause set by the game stops the rest of this frame's catch-up ticks
|
|
428
|
+
const frozenTick = paused || !(timeScale * debugScale);
|
|
429
|
+
|
|
430
|
+
// increment frame and update time, frozen does not advance time
|
|
431
|
+
if (!frozenTick)
|
|
432
|
+
time = timeFixedStart + (frame++ - frameFixedStart) / frameRate;
|
|
433
|
+
engineTick(frozenTick);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// add the time smoothing back in
|
|
437
|
+
frameTimeBufferMS += deltaSmooth;
|
|
438
|
+
}
|
|
278
439
|
|
|
279
|
-
|
|
440
|
+
// one tick of the loop: update game and objects, when frozen update everything except them
|
|
441
|
+
function engineTick(frozenTick)
|
|
442
|
+
{
|
|
280
443
|
wasUpdated = true;
|
|
281
444
|
engineUpdateCanvas();
|
|
282
445
|
inputUpdate();
|
|
@@ -303,9 +466,6 @@ async function engineInit(gameInit, gameUpdate, gameUpdatePost, gameRender, game
|
|
|
303
466
|
renderFrame();
|
|
304
467
|
}
|
|
305
468
|
|
|
306
|
-
// add the time smoothing back in
|
|
307
|
-
frameTimeBufferMS += deltaSmooth;
|
|
308
|
-
|
|
309
469
|
// manual step turned on by this frame's updates, set the buffer the loop and smoothing just moved again
|
|
310
470
|
if (engineManualStep && !manualStepAtStart)
|
|
311
471
|
frameTimeBufferMS = -.5e3 / frameRate;
|
|
@@ -364,7 +524,7 @@ async function engineInit(gameInit, gameUpdate, gameUpdatePost, gameRender, game
|
|
|
364
524
|
}
|
|
365
525
|
|
|
366
526
|
// skip setup if headless
|
|
367
|
-
if (headlessMode) return startEngine();
|
|
527
|
+
if (headlessMode) return startEngine([]);
|
|
368
528
|
|
|
369
529
|
// ensure body exists for minimal HTML where the script runs before <body> is parsed
|
|
370
530
|
if (!document.body)
|
|
@@ -442,14 +602,19 @@ async function engineInit(gameInit, gameUpdate, gameUpdatePost, gameRender, game
|
|
|
442
602
|
promises.push(splash);
|
|
443
603
|
}
|
|
444
604
|
|
|
445
|
-
//
|
|
446
|
-
await
|
|
447
|
-
return startEngine();
|
|
605
|
+
// the splash first, the images load under it, then the loading screen for the rest
|
|
606
|
+
showSplashScreen && await promises.at(-1);
|
|
607
|
+
return startEngine(promises);
|
|
448
608
|
|
|
449
|
-
|
|
609
|
+
// gameInit runs once the images are in, and the game loop starts once it and everything loaded while it ran are
|
|
610
|
+
// done, the loading screen showing in the meantime; an error in gameInit reaches the caller
|
|
611
|
+
async function startEngine(images)
|
|
450
612
|
{
|
|
451
|
-
|
|
452
|
-
|
|
613
|
+
const init = (async ()=> { await Promise.all(images); await gameInit(); })();
|
|
614
|
+
engineAddLoad(init);
|
|
615
|
+
await engineWaitForLoads();
|
|
616
|
+
engineLoads = undefined;
|
|
617
|
+
await init;
|
|
453
618
|
engineUpdateInternal = engineUpdate; // engineStep only runs once the game is set up
|
|
454
619
|
engineManualStep || engineUpdate();
|
|
455
620
|
}
|
|
@@ -1449,6 +1614,14 @@ function debugRender()
|
|
|
1449
1614
|
debugContext.fillText('9: Tweakables', x, y += h);
|
|
1450
1615
|
debugContext.fillStyle = levelEditor.isOpen ? '#f00' : '#fff';
|
|
1451
1616
|
debugContext.fillText('0: Edit Level', x, y += h);
|
|
1617
|
+
for (const line of debugOverlayKeys)
|
|
1618
|
+
{
|
|
1619
|
+
// a key a debug plugin added, when it has something to say
|
|
1620
|
+
const key = line();
|
|
1621
|
+
if (!key) continue;
|
|
1622
|
+
debugContext.fillStyle = key.on ? '#f00' : '#fff';
|
|
1623
|
+
debugContext.fillText(key.text, x, y += h);
|
|
1624
|
+
}
|
|
1452
1625
|
debugContext.fillStyle = '#fff';
|
|
1453
1626
|
|
|
1454
1627
|
let keysPressed = '';
|
|
@@ -1676,6 +1849,53 @@ function debugProtectConstant(obj)
|
|
|
1676
1849
|
// freeze the object to prevent adding new properties
|
|
1677
1850
|
return Object.freeze(obj);
|
|
1678
1851
|
}
|
|
1852
|
+
|
|
1853
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
1854
|
+
// Input capture: the free camera and the 3D level editor take the keyboard and mouse, so the game under them reads
|
|
1855
|
+
// every key and mouse button as up, and no mouse movement or wheel; gamepads are left to the game
|
|
1856
|
+
|
|
1857
|
+
let inputCaptureOn = false; // something has taken the keyboard and mouse
|
|
1858
|
+
let inputCaptureReading = false; // it is reading them now
|
|
1859
|
+
let inputCaptureDeltaScreen, inputCaptureWheel = 0; // this update's mouse movement and wheel, for it alone
|
|
1860
|
+
|
|
1861
|
+
// the game let go of a captured mouse itself, with pointerLockExit, since the debug tools last looked: a capture
|
|
1862
|
+
// that is lost without it is the browser's doing, which is how Chrome takes Escape
|
|
1863
|
+
let inputLockLetGo = false;
|
|
1864
|
+
function inputLockExit() { inputLockLetGo = true; }
|
|
1865
|
+
|
|
1866
|
+
// the keys debug plugins add to the overlay's list, each a function that gives {text, on} or nothing to show
|
|
1867
|
+
const debugOverlayKeys = [];
|
|
1868
|
+
|
|
1869
|
+
// take the keyboard and mouse from the game, or hand them back
|
|
1870
|
+
function inputCapture(on=true)
|
|
1871
|
+
{
|
|
1872
|
+
inputCaptureOn = !!on;
|
|
1873
|
+
inputCaptureDeltaScreen = vec2();
|
|
1874
|
+
inputCaptureWheel = 0;
|
|
1875
|
+
}
|
|
1876
|
+
|
|
1877
|
+
// read the input as the one that took it, keyIsDown and the rest work as always inside read
|
|
1878
|
+
function inputCaptureRead(read)
|
|
1879
|
+
{
|
|
1880
|
+
const was = inputCaptureReading;
|
|
1881
|
+
inputCaptureReading = true;
|
|
1882
|
+
try { return read(); }
|
|
1883
|
+
finally { inputCaptureReading = was; }
|
|
1884
|
+
}
|
|
1885
|
+
|
|
1886
|
+
// if a read of the keyboard and mouse, device 0, is the game's while they are taken
|
|
1887
|
+
function inputCaptureHides(device) { return inputCaptureOn && !inputCaptureReading && !device; }
|
|
1888
|
+
|
|
1889
|
+
// called first in inputUpdate: the mouse movement and wheel go to the one that took the input
|
|
1890
|
+
function inputCaptureMouse()
|
|
1891
|
+
{
|
|
1892
|
+
if (!inputCaptureOn) return;
|
|
1893
|
+
inputCaptureDeltaScreen = mouseDeltaScreen;
|
|
1894
|
+
inputCaptureWheel = mouseWheel;
|
|
1895
|
+
mouseDeltaScreen = vec2();
|
|
1896
|
+
mouseWheel = 0;
|
|
1897
|
+
}
|
|
1898
|
+
|
|
1679
1899
|
/**
|
|
1680
1900
|
* LittleJS Math Classes and Functions
|
|
1681
1901
|
* - Comprehensive math utilities for game development
|
|
@@ -3361,6 +3581,13 @@ let fontDefault = 'arial';
|
|
|
3361
3581
|
* @memberof Settings */
|
|
3362
3582
|
let showSplashScreen = false;
|
|
3363
3583
|
|
|
3584
|
+
/** The loading screen, drawn after the splash while the images, gameInit and what they load are loading, once that
|
|
3585
|
+
* takes more than half a second: a function given the part done, 0 to 1, or undefined for none, which leaves the
|
|
3586
|
+
* screen blank; the default says Loading over a bar
|
|
3587
|
+
* @type {LoadingScreenCallback|undefined}
|
|
3588
|
+
* @memberof Settings */
|
|
3589
|
+
let loadingScreen = drawLoadingScreen;
|
|
3590
|
+
|
|
3364
3591
|
/** Disables all rendering, audio, and input for servers, must be set before engineInit
|
|
3365
3592
|
* @type {boolean}
|
|
3366
3593
|
* @default
|
|
@@ -3374,6 +3601,15 @@ let headlessMode = false;
|
|
|
3374
3601
|
* @memberof Settings */
|
|
3375
3602
|
let engineManualStep = false;
|
|
3376
3603
|
|
|
3604
|
+
/** Run one update per display frame with timeDelta the time it covers, in place of fixed updates at frameRate
|
|
3605
|
+
* - Per-frame values are not scaled: velocity, gravity, damping and particle speeds act once per update, so a
|
|
3606
|
+
* game that turns this on scales its own movement by timeDelta
|
|
3607
|
+
* - Values in seconds follow real time as they are: time, timers, particle emit rate and life, animation, Box2D
|
|
3608
|
+
* @type {boolean}
|
|
3609
|
+
* @default
|
|
3610
|
+
* @memberof Settings */
|
|
3611
|
+
let engineVariableStep = false;
|
|
3612
|
+
|
|
3377
3613
|
///////////////////////////////////////////////////////////////////////////////
|
|
3378
3614
|
// WebGL settings
|
|
3379
3615
|
|
|
@@ -3769,6 +4005,11 @@ function setFontDefault(font) { fontDefault = font; }
|
|
|
3769
4005
|
* @memberof Settings */
|
|
3770
4006
|
function setShowSplashScreen(show) { showSplashScreen = show; }
|
|
3771
4007
|
|
|
4008
|
+
/** Set the loading screen, drawn after the splash while the game loads, once that takes more than half a second
|
|
4009
|
+
* @param {LoadingScreenCallback} [callback] - Draws on mainContext given the part done, 0 to 1; undefined for none
|
|
4010
|
+
* @memberof Settings */
|
|
4011
|
+
function setLoadingScreen(callback) { loadingScreen = callback; }
|
|
4012
|
+
|
|
3772
4013
|
/** Set to disable rendering, audio, and input for servers, must be set before engineInit
|
|
3773
4014
|
* @param {boolean} headless
|
|
3774
4015
|
* @memberof Settings */
|
|
@@ -3794,6 +4035,23 @@ function setEngineManualStep(enable=true)
|
|
|
3794
4035
|
}
|
|
3795
4036
|
}
|
|
3796
4037
|
|
|
4038
|
+
/** Set if the engine runs one update per display frame with timeDelta the time it covers
|
|
4039
|
+
* Can be set before engineInit or while running; turned off it goes back to fixed updates at frameRate
|
|
4040
|
+
* @param {boolean} [enable]
|
|
4041
|
+
* @memberof Settings */
|
|
4042
|
+
function setEngineVariableStep(enable=true)
|
|
4043
|
+
{
|
|
4044
|
+
if (engineVariableStep && !enable)
|
|
4045
|
+
{
|
|
4046
|
+
// fixed time goes on one fixed step after the last update, if one ran, and timeDelta is the fixed step again
|
|
4047
|
+
timeFixedStart = frame ? time + 1 / frameRate : time;
|
|
4048
|
+
frameFixedStart = frame;
|
|
4049
|
+
timeDelta = 1 / frameRate;
|
|
4050
|
+
frameTimeBufferMS = engineManualStep ? -.5e3 / frameRate : 0;
|
|
4051
|
+
}
|
|
4052
|
+
engineVariableStep = enable;
|
|
4053
|
+
}
|
|
4054
|
+
|
|
3797
4055
|
/** Set whether the draw and debug functions draw in screen space when a call leaves screenSpace out
|
|
3798
4056
|
* @param {boolean} screenSpace
|
|
3799
4057
|
* @memberof Settings */
|
|
@@ -4233,9 +4491,10 @@ class EngineObject
|
|
|
4233
4491
|
// bail if a collision callback destroyed us mid-frame
|
|
4234
4492
|
if (this.destroyed) return;
|
|
4235
4493
|
|
|
4236
|
-
|
|
4494
|
+
// limit max speed to prevent missing collisions, only for what collides: with solids, or with tiles while it
|
|
4495
|
+
// has a mass, which tile collision needs; anything else moves as fast as it is told
|
|
4496
|
+
if (this.clampSpeed && enablePhysicsSolver && (this.collideSolidObjects || this.collideLevel && this.mass))
|
|
4237
4497
|
{
|
|
4238
|
-
// limit max speed to prevent missing collisions
|
|
4239
4498
|
this.velocity.x = clamp(this.velocity.x, -objectMaxSpeed, objectMaxSpeed);
|
|
4240
4499
|
this.velocity.y = clamp(this.velocity.y, -objectMaxSpeed, objectMaxSpeed);
|
|
4241
4500
|
}
|
|
@@ -5026,12 +5285,20 @@ class TextureInfo
|
|
|
5026
5285
|
}
|
|
5027
5286
|
|
|
5028
5287
|
///////////////////////////////////////////////////////////////////////////////
|
|
5288
|
+
/**
|
|
5289
|
+
* @callback SpriteAnimationEndCallback - Called once when a SpriteAnimation's play ends
|
|
5290
|
+
* @return {void}
|
|
5291
|
+
* @memberof Draw
|
|
5292
|
+
*/
|
|
5293
|
+
|
|
5029
5294
|
/**
|
|
5030
5295
|
* SpriteAnimation - Steps a tile through its frames over time: looping, once, or there and back
|
|
5031
5296
|
* - Driven by the engine time like a Timer, so it pauses with the game and needs no update call
|
|
5032
5297
|
* - Read tileInfo each frame for the frame to draw, from an object's update or before a drawTile
|
|
5033
5298
|
* - loop, play and pingPong each start over from the first frame; stop holds the current one
|
|
5299
|
+
* - play(onEnd) calls onEnd once the play ends, on the first read after it, since there is no update to call it
|
|
5034
5300
|
* - Frames follow each other along the row, as tileInfo.frame counts them
|
|
5301
|
+
* - SpriteAnimator switches between a character's animations by name
|
|
5035
5302
|
* @example
|
|
5036
5303
|
* const walk = new SpriteAnimation(tile(0, 16), 4, .1); // four frames, a tenth of a second each
|
|
5037
5304
|
* const attack = new SpriteAnimation(tile(4, 16), 3, .05).play(); // once, then holds the last frame
|
|
@@ -5063,6 +5330,9 @@ class SpriteAnimation
|
|
|
5063
5330
|
/** @property {number|undefined} - The frame held by stop, undefined while running
|
|
5064
5331
|
* @type {number|undefined} */
|
|
5065
5332
|
this.heldFrame = undefined;
|
|
5333
|
+
/** @property {SpriteAnimationEndCallback|undefined} - Called once when a play ends, on the first read after it
|
|
5334
|
+
* @type {SpriteAnimationEndCallback|undefined} */
|
|
5335
|
+
this.onEnd = undefined;
|
|
5066
5336
|
}
|
|
5067
5337
|
|
|
5068
5338
|
/** Start over from the first frame and repeat forever
|
|
@@ -5070,8 +5340,14 @@ class SpriteAnimation
|
|
|
5070
5340
|
loop() { return this.restart('loop'); }
|
|
5071
5341
|
|
|
5072
5342
|
/** Start over from the first frame, run through once and hold the last frame, last to first at a negative speed
|
|
5343
|
+
* @param {function():void} [onEnd] - Called once when it ends, on the first read of it after that
|
|
5073
5344
|
* @return {SpriteAnimation} */
|
|
5074
|
-
play()
|
|
5345
|
+
play(onEnd)
|
|
5346
|
+
{
|
|
5347
|
+
this.restart('once');
|
|
5348
|
+
this.onEnd = onEnd;
|
|
5349
|
+
return this;
|
|
5350
|
+
}
|
|
5075
5351
|
|
|
5076
5352
|
/** Start over from the first frame and run there and back forever
|
|
5077
5353
|
* @return {SpriteAnimation} */
|
|
@@ -5081,7 +5357,7 @@ class SpriteAnimation
|
|
|
5081
5357
|
* @return {SpriteAnimation} */
|
|
5082
5358
|
stop() { this.heldFrame = this.frame; return this; }
|
|
5083
5359
|
|
|
5084
|
-
/** Start over from the first frame in a mode
|
|
5360
|
+
/** Start over from the first frame in a mode, with no end callback
|
|
5085
5361
|
* @param {string} [mode] - 'loop', 'once' or 'pingPong', the current mode when left out
|
|
5086
5362
|
* @return {SpriteAnimation} */
|
|
5087
5363
|
restart(mode=this.mode)
|
|
@@ -5089,6 +5365,7 @@ class SpriteAnimation
|
|
|
5089
5365
|
this.mode = mode;
|
|
5090
5366
|
this.startTime = time;
|
|
5091
5367
|
this.heldFrame = undefined;
|
|
5368
|
+
this.onEnd = undefined;
|
|
5092
5369
|
return this;
|
|
5093
5370
|
}
|
|
5094
5371
|
|
|
@@ -5100,6 +5377,7 @@ class SpriteAnimation
|
|
|
5100
5377
|
* @return {number} */
|
|
5101
5378
|
get frame()
|
|
5102
5379
|
{
|
|
5380
|
+
this.isDone; // a read after a play ends calls onEnd, which may start it over
|
|
5103
5381
|
if (this.heldFrame !== undefined)
|
|
5104
5382
|
return this.heldFrame;
|
|
5105
5383
|
const n = this.frameCount, f = floor(this.elapsedFrames);
|
|
@@ -5115,9 +5393,81 @@ class SpriteAnimation
|
|
|
5115
5393
|
* @return {TileInfo} */
|
|
5116
5394
|
get tileInfo() { return this.firstTile.frame(this.frame); }
|
|
5117
5395
|
|
|
5118
|
-
/** True once a play has shown its last frame for its time
|
|
5396
|
+
/** True once a play has shown its last frame for its time, the first read after that calls onEnd
|
|
5397
|
+
* @return {boolean} */
|
|
5398
|
+
get isDone()
|
|
5399
|
+
{
|
|
5400
|
+
const done = this.mode == 'once' && this.heldFrame === undefined && abs(this.elapsedFrames) >= this.frameCount;
|
|
5401
|
+
if (done && this.onEnd)
|
|
5402
|
+
{
|
|
5403
|
+
const onEnd = this.onEnd;
|
|
5404
|
+
this.onEnd = undefined; // once, and it can start a new play with a callback of its own
|
|
5405
|
+
onEnd();
|
|
5406
|
+
}
|
|
5407
|
+
return done;
|
|
5408
|
+
}
|
|
5409
|
+
}
|
|
5410
|
+
|
|
5411
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5412
|
+
/**
|
|
5413
|
+
* SpriteAnimator - A character's animations by name, like idle, walk and attack, and the one showing now
|
|
5414
|
+
* - Each clip is a SpriteAnimation and keeps its own mode, loop, play or pingPong
|
|
5415
|
+
* - set starts a clip over only when it changes or a play of it has ended, so it can be called every update
|
|
5416
|
+
* - Read tileInfo each frame for the frame to draw, as with a SpriteAnimation
|
|
5417
|
+
* @example
|
|
5418
|
+
* const hero = new SpriteAnimator({
|
|
5419
|
+
* idle: new SpriteAnimation(tile(0, 16), 2, .4),
|
|
5420
|
+
* walk: new SpriteAnimation(tile(2, 16), 4, .1),
|
|
5421
|
+
* attack: new SpriteAnimation(tile(6, 16), 3, .05).play(),
|
|
5422
|
+
* });
|
|
5423
|
+
* hero.set('attack', ()=> hero.set('idle')); // back to idle when the attack ends
|
|
5424
|
+
* // in update: this.tileInfo = hero.tileInfo;
|
|
5425
|
+
* @memberof Draw
|
|
5426
|
+
*/
|
|
5427
|
+
class SpriteAnimator
|
|
5428
|
+
{
|
|
5429
|
+
/** Create an animator from its clips, showing the first one
|
|
5430
|
+
* @param {Object<string, SpriteAnimation>} clips - The clips by name */
|
|
5431
|
+
constructor(clips)
|
|
5432
|
+
{
|
|
5433
|
+
const names = Object.keys(clips);
|
|
5434
|
+
ASSERT(names.length > 0, 'an animator needs at least one clip');
|
|
5435
|
+
/** @property {Object<string, SpriteAnimation>} - The clips by name */
|
|
5436
|
+
this.clips = clips;
|
|
5437
|
+
/** @property {string} - The name of the clip showing now */
|
|
5438
|
+
this.name = names[0];
|
|
5439
|
+
}
|
|
5440
|
+
|
|
5441
|
+
/** Show a clip, starting it over in its own mode unless it is showing and has not ended
|
|
5442
|
+
* @param {string} name - The clip's name
|
|
5443
|
+
* @param {function():void} [onEnd] - Called once when a play clip ends, as with SpriteAnimation.play
|
|
5444
|
+
* @return {SpriteAnimator} */
|
|
5445
|
+
set(name, onEnd)
|
|
5446
|
+
{
|
|
5447
|
+
ASSERT(this.clips[name], 'no clip named ' + name);
|
|
5448
|
+
if (name == this.name && !this.clip.isDone)
|
|
5449
|
+
return this;
|
|
5450
|
+
this.name = name;
|
|
5451
|
+
this.clip.restart();
|
|
5452
|
+
this.clip.onEnd = onEnd;
|
|
5453
|
+
return this;
|
|
5454
|
+
}
|
|
5455
|
+
|
|
5456
|
+
/** The clip showing now
|
|
5457
|
+
* @return {SpriteAnimation} */
|
|
5458
|
+
get clip() { return this.clips[this.name]; }
|
|
5459
|
+
|
|
5460
|
+
/** The tile of the frame showing now
|
|
5461
|
+
* @return {TileInfo} */
|
|
5462
|
+
get tileInfo() { return this.clip.tileInfo; }
|
|
5463
|
+
|
|
5464
|
+
/** The frame of the clip showing now
|
|
5465
|
+
* @return {number} */
|
|
5466
|
+
get frame() { return this.clip.frame; }
|
|
5467
|
+
|
|
5468
|
+
/** True once the clip showing now is a play that has ended
|
|
5119
5469
|
* @return {boolean} */
|
|
5120
|
-
get isDone() { return this.
|
|
5470
|
+
get isDone() { return this.clip.isDone; }
|
|
5121
5471
|
}
|
|
5122
5472
|
|
|
5123
5473
|
///////////////////////////////////////////////////////////////////////////////
|
|
@@ -5942,7 +6292,7 @@ async function loadTexture(textureIndex, src)
|
|
|
5942
6292
|
const image = new Image;
|
|
5943
6293
|
if (src)
|
|
5944
6294
|
{
|
|
5945
|
-
await new Promise(resolve =>
|
|
6295
|
+
await engineAddLoad(new Promise(resolve => // startup waits for it
|
|
5946
6296
|
{
|
|
5947
6297
|
image.onload = resolve;
|
|
5948
6298
|
image.onerror = ()=>
|
|
@@ -5952,7 +6302,7 @@ async function loadTexture(textureIndex, src)
|
|
|
5952
6302
|
};
|
|
5953
6303
|
image.crossOrigin = 'anonymous';
|
|
5954
6304
|
image.src = src;
|
|
5955
|
-
});
|
|
6305
|
+
}));
|
|
5956
6306
|
}
|
|
5957
6307
|
|
|
5958
6308
|
return textureInfos[textureIndex] = new TextureInfo(image);
|
|
@@ -6683,7 +7033,7 @@ function keyIsDown(key, device=0)
|
|
|
6683
7033
|
ASSERT(isStringLike(key), 'key must be a number or string');
|
|
6684
7034
|
ASSERT(typeof key !== 'string' || key.length > 1, "keys are codes like 'KeyW' or 'Space', not characters");
|
|
6685
7035
|
ASSERT(device > 0 || typeof key !== 'number' || key < 5, 'use code string for keyboard');
|
|
6686
|
-
return !!(inputData[device]?.[key] & 1);
|
|
7036
|
+
return !!(inputData[device]?.[key] & 1) && !inputCaptureHides(device);
|
|
6687
7037
|
}
|
|
6688
7038
|
|
|
6689
7039
|
/** Returns true if device key was pressed this frame
|
|
@@ -6696,7 +7046,7 @@ function keyWasPressed(key, device=0)
|
|
|
6696
7046
|
ASSERT(isStringLike(key), 'key must be a number or string');
|
|
6697
7047
|
ASSERT(typeof key !== 'string' || key.length > 1, "keys are codes like 'KeyW' or 'Space', not characters");
|
|
6698
7048
|
ASSERT(device > 0 || typeof key !== 'number' || key < 5, 'use code string for keyboard');
|
|
6699
|
-
return !!(inputData[device]?.[key] & 2);
|
|
7049
|
+
return !!(inputData[device]?.[key] & 2) && !inputCaptureHides(device);
|
|
6700
7050
|
}
|
|
6701
7051
|
|
|
6702
7052
|
/** Returns true if device key was released this frame
|
|
@@ -6709,7 +7059,7 @@ function keyWasReleased(key, device=0)
|
|
|
6709
7059
|
ASSERT(isStringLike(key), 'key must be a number or string');
|
|
6710
7060
|
ASSERT(typeof key !== 'string' || key.length > 1, "keys are codes like 'KeyW' or 'Space', not characters");
|
|
6711
7061
|
ASSERT(device > 0 || typeof key !== 'number' || key < 5, 'use code string for keyboard');
|
|
6712
|
-
return !!(inputData[device]?.[key] & 4);
|
|
7062
|
+
return !!(inputData[device]?.[key] & 4) && !inputCaptureHides(device);
|
|
6713
7063
|
}
|
|
6714
7064
|
|
|
6715
7065
|
/** Returns input vector from arrow keys or WASD if enabled
|
|
@@ -6912,7 +7262,10 @@ function pointerLockRequest()
|
|
|
6912
7262
|
/** Request to unlock the pointer
|
|
6913
7263
|
* @memberof Input */
|
|
6914
7264
|
function pointerLockExit()
|
|
6915
|
-
{
|
|
7265
|
+
{
|
|
7266
|
+
inputLockExit(); // debug builds: the free camera and the 3D editor tell this from Escape
|
|
7267
|
+
document.exitPointerLock?.();
|
|
7268
|
+
}
|
|
6916
7269
|
|
|
6917
7270
|
/** Check if pointer is locked (true if locked)
|
|
6918
7271
|
* @return {boolean}
|
|
@@ -7229,6 +7582,7 @@ function inputInit()
|
|
|
7229
7582
|
|
|
7230
7583
|
function inputUpdate()
|
|
7231
7584
|
{
|
|
7585
|
+
inputCaptureMouse(); // debug builds: the free camera or the 3D editor may have the mouse
|
|
7232
7586
|
if (headlessMode) return;
|
|
7233
7587
|
|
|
7234
7588
|
// clear input when lost focus (prevent stuck keys)
|
|
@@ -8233,8 +8587,8 @@ class Sound
|
|
|
8233
8587
|
// load the audio file, a URL object as bundlers give works like its string;
|
|
8234
8588
|
// report failures rather than leaving an unhandled rejection, the sound just stays unloaded and silent
|
|
8235
8589
|
const filename = asset + '';
|
|
8236
|
-
this.loadSound(filename).catch(e=>
|
|
8237
|
-
LOG('Sound load failed for', filename, '-', e.message));
|
|
8590
|
+
engineAddLoad(this.loadSound(filename).catch(e=>
|
|
8591
|
+
LOG('Sound load failed for', filename, '-', e.message))); // startup waits for it
|
|
8238
8592
|
}
|
|
8239
8593
|
}
|
|
8240
8594
|
|
|
@@ -11717,6 +12071,25 @@ function glPolyStrip(points)
|
|
|
11717
12071
|
*/
|
|
11718
12072
|
|
|
11719
12073
|
///////////////////////////////////////////////////////////////////////////////
|
|
12074
|
+
// the default loading screen: Loading over a bar across the middle, filled by the part of the loads done
|
|
12075
|
+
function drawLoadingScreen(progress)
|
|
12076
|
+
{
|
|
12077
|
+
const x = mainContext, w = mainCanvasSize.x, h = mainCanvasSize.y;
|
|
12078
|
+
const barWidth = min(w * .6, 400), barHeight = 12, left = (w - barWidth) / 2, top = h / 2;
|
|
12079
|
+
x.save();
|
|
12080
|
+
x.fillStyle = '#000';
|
|
12081
|
+
x.fillRect(0, 0, w, h);
|
|
12082
|
+
x.fillStyle = x.strokeStyle = '#fff';
|
|
12083
|
+
x.font = '28px ' + fontDefault;
|
|
12084
|
+
x.textAlign = 'center';
|
|
12085
|
+
x.textBaseline = 'bottom';
|
|
12086
|
+
x.fillText('Loading', w / 2, top - 16);
|
|
12087
|
+
x.lineWidth = 2;
|
|
12088
|
+
x.strokeRect(left, top, barWidth, barHeight);
|
|
12089
|
+
x.fillRect(left, top, barWidth * clamp(progress), barHeight);
|
|
12090
|
+
x.restore();
|
|
12091
|
+
}
|
|
12092
|
+
|
|
11720
12093
|
function drawEngineLogo(t)
|
|
11721
12094
|
{
|
|
11722
12095
|
const blackAndWhite = 0;
|
|
@@ -12673,18 +13046,7 @@ class PostProcessPlugin
|
|
|
12673
13046
|
'gl_Position=vec4(p+p-1.,1,1);'+ // set position
|
|
12674
13047
|
'}' // end of shader
|
|
12675
13048
|
,
|
|
12676
|
-
|
|
12677
|
-
'precision highp float;'+ // use highp for accuracy
|
|
12678
|
-
'uniform sampler2D iChannel0;'+ // input texture
|
|
12679
|
-
'uniform sampler2D iChannel1;'+ // the previous frame's output, when feedbackTexture is set
|
|
12680
|
-
'uniform vec3 iResolution;'+ // size of output texture
|
|
12681
|
-
'uniform float iTime;'+ // time
|
|
12682
|
-
'out vec4 c;'+ // out color
|
|
12683
|
-
'\n' + shaderCode + '\n'+ // insert custom shader code
|
|
12684
|
-
'void main(){'+ // shader entry point
|
|
12685
|
-
'mainImage(c,gl_FragCoord.xy);'+ // call post process function
|
|
12686
|
-
'c.a=1.;'+ // always use full alpha
|
|
12687
|
-
'}' // end of shader
|
|
13049
|
+
postProcessFragmentSource(shaderCode)
|
|
12688
13050
|
);
|
|
12689
13051
|
|
|
12690
13052
|
// setup VAO for post processing
|
|
@@ -12772,10 +13134,20 @@ class PostProcessPlugin
|
|
|
12772
13134
|
glContext.bindTexture(glContext.TEXTURE_2D, postProcess.feedbackTexture);
|
|
12773
13135
|
}
|
|
12774
13136
|
|
|
13137
|
+
// the 3D depth, when render3D draws it, on the third texture unit, with what sceneDepth needs to read it
|
|
13138
|
+
const depth = typeof render3D != 'undefined' && render3D?.depthTexture ? render3D : undefined;
|
|
13139
|
+
glContext.activeTexture(glContext.TEXTURE2);
|
|
13140
|
+
glContext.bindTexture(glContext.TEXTURE_2D, depth?.cameraDepthTexture || null);
|
|
13141
|
+
glContext.activeTexture(glContext.TEXTURE0);
|
|
13142
|
+
const camera = depth?.camera;
|
|
13143
|
+
camera && glContext.uniform3f(glUniformLocation(postProcess.shader, 'iDepthRange'), camera.near,
|
|
13144
|
+
camera.far == Infinity ? 0 : camera.far, camera.orthographic ? 1 : 0);
|
|
13145
|
+
|
|
12775
13146
|
// set uniforms and draw
|
|
12776
13147
|
const uniformLocation = (name)=>glUniformLocation(postProcess.shader, name);
|
|
12777
13148
|
glContext.uniform1i(uniformLocation('iChannel0'), 0);
|
|
12778
13149
|
glContext.uniform1i(uniformLocation('iChannel1'), 1);
|
|
13150
|
+
glContext.uniform1i(uniformLocation('iChannel2'), 2);
|
|
12779
13151
|
glContext.uniform1f(uniformLocation('iTime'), time);
|
|
12780
13152
|
glContext.uniform3f(uniformLocation('iResolution'), mainCanvas.width, mainCanvas.height, 1);
|
|
12781
13153
|
glContext.drawArrays(glContext.TRIANGLE_STRIP, 0, 4);
|
|
@@ -12787,6 +13159,11 @@ class PostProcessPlugin
|
|
|
12787
13159
|
glContext.activeTexture(glContext.TEXTURE0);
|
|
12788
13160
|
}
|
|
12789
13161
|
|
|
13162
|
+
// the depth leaves the third unit for whatever draws next
|
|
13163
|
+
glContext.activeTexture(glContext.TEXTURE2);
|
|
13164
|
+
glContext.bindTexture(glContext.TEXTURE_2D, null);
|
|
13165
|
+
glContext.activeTexture(glContext.TEXTURE0);
|
|
13166
|
+
|
|
12790
13167
|
// restore defaults so subsequent dynamic texture uploads aren't flipped or premultiplied
|
|
12791
13168
|
glContext.pixelStorei(glContext.UNPACK_FLIP_Y_WEBGL, false);
|
|
12792
13169
|
glContext.pixelStorei(glContext.UNPACK_PREMULTIPLY_ALPHA_WEBGL, false);
|
|
@@ -12803,6 +13180,31 @@ class PostProcessPlugin
|
|
|
12803
13180
|
|
|
12804
13181
|
///////////////////////////////////////////////////////////////////////////////
|
|
12805
13182
|
|
|
13183
|
+
// the fragment shader of the post process pass around its mainImage snippet: the frame on iChannel0, the last output
|
|
13184
|
+
// on iChannel1 with a feedback texture, and the 3D depth on iChannel2 when render3D.depthTexture is on, read with
|
|
13185
|
+
// sceneDepth(uv), the distance from the camera along its view in world units, uv 0 to 1 across the screen
|
|
13186
|
+
function postProcessFragmentSource(shaderCode)
|
|
13187
|
+
{
|
|
13188
|
+
return '#version 300 es\n' + // specify GLSL ES version
|
|
13189
|
+
'precision highp float;'+ // use highp for accuracy
|
|
13190
|
+
'uniform sampler2D iChannel0;'+ // input texture
|
|
13191
|
+
'uniform sampler2D iChannel1;'+ // the previous frame's output, when feedbackTexture is set
|
|
13192
|
+
'uniform sampler2D iChannel2;'+ // the 3D depth, when render3D.depthTexture is on
|
|
13193
|
+
'uniform vec3 iResolution;'+ // size of output texture
|
|
13194
|
+
'uniform float iTime;'+ // time
|
|
13195
|
+
'uniform vec3 iDepthRange;'+ // the camera's near, its far or 0 for none, and 1 when orthographic
|
|
13196
|
+
'out vec4 c;'+ // out color
|
|
13197
|
+
// the depth texture's value back to a distance, as the camera's projection put it there
|
|
13198
|
+
'float sceneDepth(vec2 uv){'+
|
|
13199
|
+
'float d=texture(iChannel2,uv).r*2.-1.,n=iDepthRange.x,f=iDepthRange.y;'+
|
|
13200
|
+
'return iDepthRange.z>0.?(d*(f-n)+f+n)/2.:f>0.?2.*n*f/(f+n-d*(f-n)):2.*n/(1.-d);}'+
|
|
13201
|
+
'\n' + shaderCode + '\n'+ // insert custom shader code
|
|
13202
|
+
'void main(){'+ // shader entry point
|
|
13203
|
+
'mainImage(c,gl_FragCoord.xy);'+ // call post process function
|
|
13204
|
+
'c.a=1.;'+ // always use full alpha
|
|
13205
|
+
'}'; // end of shader
|
|
13206
|
+
}
|
|
13207
|
+
|
|
12806
13208
|
/**
|
|
12807
13209
|
* Shader code for a bloom effect, the bright parts of the image blurred back over it
|
|
12808
13210
|
* - Pass it to PostProcessPlugin, or edit the string to build an effect on top of it
|
|
@@ -20121,7 +20523,9 @@ function tweenUpdate(gameDelta, realDelta)
|
|
|
20121
20523
|
for (let i = list.length; i--;)
|
|
20122
20524
|
{
|
|
20123
20525
|
const t = list[i];
|
|
20124
|
-
|
|
20526
|
+
// stopped, or started again by a callback this update, or during an update a callback ran inside it, which
|
|
20527
|
+
// counts on from this one
|
|
20528
|
+
if (!t.active || t.activePass >= pass) continue;
|
|
20125
20529
|
let dt;
|
|
20126
20530
|
if (enginePath)
|
|
20127
20531
|
{
|
|
@@ -20145,7 +20549,7 @@ function tweenUpdate(gameDelta, realDelta)
|
|
|
20145
20549
|
// Completion: fire end value, remove from active, start the next iteration
|
|
20146
20550
|
// of a loop or pingPong, or when there is none it has completed, fire onComplete
|
|
20147
20551
|
t.callback(t.interp(0));
|
|
20148
|
-
if (!t.active || t.activePass
|
|
20552
|
+
if (!t.active || t.activePass >= pass)
|
|
20149
20553
|
continue; // stopped or restarted by its own callback, the run it was on ends without completing
|
|
20150
20554
|
tweenDeactivate(t);
|
|
20151
20555
|
const next = t.thenCallback;
|
|
@@ -20170,6 +20574,63 @@ function tweenStopAll()
|
|
|
20170
20574
|
// Register with the engine so tweens auto-advance.
|
|
20171
20575
|
engineAddPlugin(tweenUpdate);
|
|
20172
20576
|
|
|
20577
|
+
/**
|
|
20578
|
+
* LittleJS Scene System Plugin
|
|
20579
|
+
* - A game's states, like a title, the game and game over, as scene objects
|
|
20580
|
+
* - setScene leaves the current scene, destroys the objects and enters the next
|
|
20581
|
+
* - Objects with the persistent flag set outlive every switch
|
|
20582
|
+
* - Pause is the engine's paused flag, a scene's update stops with gameUpdate
|
|
20583
|
+
* @namespace SceneSystem
|
|
20584
|
+
*/
|
|
20585
|
+
|
|
20586
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
20587
|
+
|
|
20588
|
+
/** A scene, any object with the hooks it needs, each optional and called as a method so this is the scene
|
|
20589
|
+
* @typedef {Object} Scene
|
|
20590
|
+
* @property {function():void} [enter] - Called when the scene starts, after the old one left and the objects were destroyed
|
|
20591
|
+
* @property {function():void} [leave] - Called before the next scene starts, while the scene is current and its objects are still there
|
|
20592
|
+
* @property {function():void} [update] - Called each update after gameUpdate, not while paused or at time scale 0
|
|
20593
|
+
* @property {function():void} [render] - Called before gameRender, to draw under the objects
|
|
20594
|
+
* @property {function():void} [renderPost] - Called after gameRenderPost, to draw over the game, while paused too; plugins made after the first setScene draw over it
|
|
20595
|
+
* @memberof SceneSystem */
|
|
20596
|
+
|
|
20597
|
+
let sceneCurrent, sceneLeaving = false, scenePluginAdded = false;
|
|
20598
|
+
|
|
20599
|
+
/** Leave the current scene, destroy every object that is not persistent, and enter the next scene
|
|
20600
|
+
* - Setting the current scene again restarts it, and no scene leaves the game with none
|
|
20601
|
+
* - The switch happens at once, it can be called from anywhere but a scene's leave
|
|
20602
|
+
* @param {Scene} [scene] - The scene to enter
|
|
20603
|
+
* @memberof SceneSystem */
|
|
20604
|
+
function setScene(scene)
|
|
20605
|
+
{
|
|
20606
|
+
ASSERT(!sceneLeaving, 'setScene can not be called from a scene leave');
|
|
20607
|
+
if (!scenePluginAdded)
|
|
20608
|
+
{
|
|
20609
|
+
scenePluginAdded = true;
|
|
20610
|
+
engineAddPlugin(sceneUpdate, sceneRenderPost, undefined, undefined, sceneRender);
|
|
20611
|
+
}
|
|
20612
|
+
|
|
20613
|
+
if (sceneCurrent)
|
|
20614
|
+
{
|
|
20615
|
+
sceneLeaving = true;
|
|
20616
|
+
sceneCurrent.leave?.();
|
|
20617
|
+
sceneLeaving = false;
|
|
20618
|
+
}
|
|
20619
|
+
engineObjectsDestroy();
|
|
20620
|
+
sceneCurrent = scene;
|
|
20621
|
+
scene?.enter?.();
|
|
20622
|
+
}
|
|
20623
|
+
|
|
20624
|
+
/** Get the current scene
|
|
20625
|
+
* @return {Scene|undefined}
|
|
20626
|
+
* @memberof SceneSystem */
|
|
20627
|
+
function getScene() { return sceneCurrent; }
|
|
20628
|
+
|
|
20629
|
+
// the plugin hooks, update skips the frozen ticks that skip gameUpdate
|
|
20630
|
+
function sceneUpdate() { paused || !timeScale || sceneCurrent?.update?.(); }
|
|
20631
|
+
function sceneRender() { sceneCurrent?.render?.(); }
|
|
20632
|
+
function sceneRenderPost() { sceneCurrent?.renderPost?.(); }
|
|
20633
|
+
|
|
20173
20634
|
/**
|
|
20174
20635
|
* LittleJS PathFinder Plugin
|
|
20175
20636
|
* - Grid-based A* pathfinder with two-pass smoothing for natural-looking paths
|
|
@@ -21643,36 +22104,62 @@ class Ray3D
|
|
|
21643
22104
|
|
|
21644
22105
|
///////////////////////////////////////////////////////////////////////////////
|
|
21645
22106
|
// 3D collision helpers, none of them change anything that is passed in
|
|
21646
|
-
// Boxes sit centered on pos and take a full size, like drawRect
|
|
22107
|
+
// Boxes sit centered on pos and take a full size, like drawRect, upright unless given a rotation, an Euler vec3 like
|
|
22108
|
+
// rotation3D, which turns them about their center
|
|
21647
22109
|
// Cylinders stand up the Y axis, centered on pos, with a full height
|
|
21648
22110
|
// Names that could be mistaken for 2D functions get a 3D suffix
|
|
21649
22111
|
|
|
22112
|
+
// whether a rotation turns anything, a missing one does not
|
|
22113
|
+
const isTurned3D = (rotation)=> !!rotation && !!(rotation.x || rotation.y || rotation.z);
|
|
22114
|
+
|
|
22115
|
+
// a turned box's three unit axes, the columns of its rotation
|
|
22116
|
+
function boxAxes3D(rotation)
|
|
22117
|
+
{
|
|
22118
|
+
const m = buildMatrix(undefined, rotation).m;
|
|
22119
|
+
return [vec3(m[0], m[1], m[2]), vec3(m[4], m[5], m[6]), vec3(m[8], m[9], m[10])];
|
|
22120
|
+
}
|
|
22121
|
+
|
|
22122
|
+
// a point in a turned box's own space, from its center, and a vector from that space back to the world's
|
|
22123
|
+
function boxLocal3D(point, pos, axes)
|
|
22124
|
+
{
|
|
22125
|
+
const d = point.subtract(pos);
|
|
22126
|
+
return vec3(d.dot(axes[0]), d.dot(axes[1]), d.dot(axes[2]));
|
|
22127
|
+
}
|
|
22128
|
+
const boxWorld3D = (v, axes)=> axes[0].scale(v.x).add(axes[1].scale(v.y)).add(axes[2].scale(v.z));
|
|
22129
|
+
|
|
21650
22130
|
/**
|
|
21651
|
-
* Check if a point is inside
|
|
22131
|
+
* Check if a point is inside a box, boundary is inclusive
|
|
21652
22132
|
* @param {Vector3} point
|
|
21653
22133
|
* @param {Vector3} pos - Center of the box
|
|
21654
22134
|
* @param {Vector3} size - Full size of the box
|
|
22135
|
+
* @param {Vector3} [rotation] - How the box is turned, upright when left out
|
|
21655
22136
|
* @return {boolean}
|
|
21656
22137
|
* @memberof Math3D
|
|
21657
22138
|
*/
|
|
21658
|
-
function isPointInBox3D(point, pos, size)
|
|
22139
|
+
function isPointInBox3D(point, pos, size, rotation)
|
|
21659
22140
|
{
|
|
22141
|
+
if (isTurned3D(rotation))
|
|
22142
|
+
return isPointInBox3D(boxLocal3D(point, pos, boxAxes3D(rotation)), vec3(), size);
|
|
21660
22143
|
return abs(point.x - pos.x) <= size.x/2 &&
|
|
21661
22144
|
abs(point.y - pos.y) <= size.y/2 &&
|
|
21662
22145
|
abs(point.z - pos.z) <= size.z/2;
|
|
21663
22146
|
}
|
|
21664
22147
|
|
|
21665
22148
|
/**
|
|
21666
|
-
* Check if two
|
|
22149
|
+
* Check if two boxes are overlapping, touching edges do not overlap
|
|
21667
22150
|
* @param {Vector3} posA
|
|
21668
22151
|
* @param {Vector3} sizeA - Full size of box A
|
|
21669
22152
|
* @param {Vector3} posB
|
|
21670
22153
|
* @param {Vector3} [sizeB] - Full size of box B, zero for a point
|
|
22154
|
+
* @param {Vector3} [rotationA] - How box A is turned, upright when left out
|
|
22155
|
+
* @param {Vector3} [rotationB] - How box B is turned
|
|
21671
22156
|
* @return {boolean}
|
|
21672
22157
|
* @memberof Math3D
|
|
21673
22158
|
*/
|
|
21674
|
-
function isOverlapping3D(posA, sizeA, posB, sizeB=vec3())
|
|
22159
|
+
function isOverlapping3D(posA, sizeA, posB, sizeB=vec3(), rotationA, rotationB)
|
|
21675
22160
|
{
|
|
22161
|
+
if (isTurned3D(rotationA) || isTurned3D(rotationB))
|
|
22162
|
+
return !!collideBoxBox3D(posA, sizeA, posB, sizeB, rotationA, rotationB);
|
|
21676
22163
|
const d = posA.subtract(posB);
|
|
21677
22164
|
return abs(d.x) < (sizeA.x + sizeB.x)/2 &&
|
|
21678
22165
|
abs(d.y) < (sizeA.y + sizeB.y)/2 &&
|
|
@@ -21701,16 +22188,19 @@ function collideSphereSphere(posA, radiusA, posB, radiusB)
|
|
|
21701
22188
|
}
|
|
21702
22189
|
|
|
21703
22190
|
/**
|
|
21704
|
-
* Returns the vector to move a sphere out of
|
|
22191
|
+
* Returns the vector to move a sphere out of a box, or undefined
|
|
21705
22192
|
* @param {Vector3} pos - Sphere center
|
|
21706
22193
|
* @param {number} radius
|
|
21707
22194
|
* @param {Vector3} boxPos
|
|
21708
22195
|
* @param {Vector3} boxSize - Full size of the box
|
|
22196
|
+
* @param {Vector3} [boxRotation] - How the box is turned, upright when left out
|
|
21709
22197
|
* @return {Vector3|undefined}
|
|
21710
22198
|
* @memberof Math3D
|
|
21711
22199
|
*/
|
|
21712
|
-
function collideSphereBox(pos, radius, boxPos, boxSize)
|
|
22200
|
+
function collideSphereBox(pos, radius, boxPos, boxSize, boxRotation)
|
|
21713
22201
|
{
|
|
22202
|
+
if (isTurned3D(boxRotation))
|
|
22203
|
+
return collideSphereOrientedBox3D(pos, radius, boxPos, boxSize, boxAxes3D(boxRotation));
|
|
21714
22204
|
const h = boxSize.scale(.5);
|
|
21715
22205
|
const closest = vec3(
|
|
21716
22206
|
clamp(pos.x, boxPos.x - h.x, boxPos.x + h.x),
|
|
@@ -21798,15 +22288,20 @@ function collideSphereCylinder(pos, radius, cylinderPos, cylinderRadius, cylinde
|
|
|
21798
22288
|
/**
|
|
21799
22289
|
* Returns the vector to move box A by so it no longer overlaps box B, the shortest way out, or undefined
|
|
21800
22290
|
* - The 3D twin of collideBoxBox
|
|
22291
|
+
* - Turned boxes are tested by the separating axis test, the push is along the direction they overlap least on
|
|
21801
22292
|
* @param {Vector3} posA
|
|
21802
22293
|
* @param {Vector3} sizeA - Full size of box A
|
|
21803
22294
|
* @param {Vector3} posB
|
|
21804
22295
|
* @param {Vector3} sizeB - Full size of box B
|
|
22296
|
+
* @param {Vector3} [rotationA] - How box A is turned, upright when left out
|
|
22297
|
+
* @param {Vector3} [rotationB] - How box B is turned
|
|
21805
22298
|
* @return {Vector3|undefined}
|
|
21806
22299
|
* @memberof Math3D
|
|
21807
22300
|
*/
|
|
21808
|
-
function collideBoxBox3D(posA, sizeA, posB, sizeB)
|
|
22301
|
+
function collideBoxBox3D(posA, sizeA, posB, sizeB, rotationA, rotationB)
|
|
21809
22302
|
{
|
|
22303
|
+
if (isTurned3D(rotationA) || isTurned3D(rotationB))
|
|
22304
|
+
return collideOrientedBoxes3D(posA, sizeA, boxAxes3D(rotationA), posB, sizeB, boxAxes3D(rotationB));
|
|
21810
22305
|
const d = posA.subtract(posB);
|
|
21811
22306
|
const overlapX = (sizeA.x + sizeB.x)/2 - abs(d.x);
|
|
21812
22307
|
const overlapY = (sizeA.y + sizeB.y)/2 - abs(d.y);
|
|
@@ -21816,6 +22311,44 @@ function collideBoxBox3D(posA, sizeA, posB, sizeB)
|
|
|
21816
22311
|
return pushOutAxis3D(d, overlapX, overlapY, overlapZ);
|
|
21817
22312
|
}
|
|
21818
22313
|
|
|
22314
|
+
// the push to move a sphere out of a box turned to these axes: in the box's own space it is upright, and the push
|
|
22315
|
+
// goes back out turned
|
|
22316
|
+
function collideSphereOrientedBox3D(pos, radius, boxPos, boxSize, axes)
|
|
22317
|
+
{
|
|
22318
|
+
const push = collideSphereBox(boxLocal3D(pos, boxPos, axes), radius, vec3(), boxSize);
|
|
22319
|
+
return push && boxWorld3D(push, axes);
|
|
22320
|
+
}
|
|
22321
|
+
|
|
22322
|
+
// the world's axes, for an upright box among turned ones
|
|
22323
|
+
const BOX_WORLD_AXES = Object.freeze([vec3(1, 0, 0), vec3(0, 1, 0), vec3(0, 0, 1)]);
|
|
22324
|
+
|
|
22325
|
+
// the shortest push to move box A out of box B by the separating axis test: each box's three face directions and
|
|
22326
|
+
// the nine across an edge of each; a gap on any of them is no touch, and the push is along the least overlap, from
|
|
22327
|
+
// B to A; an edge direction has to overlap clearly less to win, so a box lying on another leaves by the face
|
|
22328
|
+
function collideOrientedBoxes3D(posA, sizeA, axesA, posB, sizeB, axesB)
|
|
22329
|
+
{
|
|
22330
|
+
const d = posA.subtract(posB), ha = sizeA.scale(.5), hb = sizeB.scale(.5);
|
|
22331
|
+
const reach = (h, axes, n)=> h.x * abs(axes[0].dot(n)) + h.y * abs(axes[1].dot(n)) + h.z * abs(axes[2].dot(n));
|
|
22332
|
+
let push, least = Infinity;
|
|
22333
|
+
const separated = (axis, weight)=>
|
|
22334
|
+
{
|
|
22335
|
+
const lengthSquared = axis.lengthSquared();
|
|
22336
|
+
if (lengthSquared < 1e-12) return false; // parallel edges give no direction to test
|
|
22337
|
+
const n = axis.scale(lengthSquared ** -.5), along = d.dot(n);
|
|
22338
|
+
const overlap = reach(ha, axesA, n) + reach(hb, axesB, n) - abs(along);
|
|
22339
|
+
if (overlap <= 0) return true;
|
|
22340
|
+
if (overlap * weight < least)
|
|
22341
|
+
least = overlap * weight, push = n.scale(along < 0 ? -overlap : overlap);
|
|
22342
|
+
return false;
|
|
22343
|
+
};
|
|
22344
|
+
for (const axis of [...axesA, ...axesB])
|
|
22345
|
+
if (separated(axis, 1)) return;
|
|
22346
|
+
for (const a of axesA)
|
|
22347
|
+
for (const b of axesB)
|
|
22348
|
+
if (separated(a.cross(b), 1.05)) return;
|
|
22349
|
+
return push;
|
|
22350
|
+
}
|
|
22351
|
+
|
|
21819
22352
|
/**
|
|
21820
22353
|
* Returns the distance along the ray to the first intersection with a sphere, or undefined
|
|
21821
22354
|
* - The hit is ray.getPosition(distance), a direction that is not unit length scales the distance
|
|
@@ -21864,17 +22397,25 @@ function raycastPlane(ray, planePos, planeNormal)
|
|
|
21864
22397
|
}
|
|
21865
22398
|
|
|
21866
22399
|
/**
|
|
21867
|
-
* Returns the distance along the ray to the first intersection with
|
|
22400
|
+
* Returns the distance along the ray to the first intersection with a box, or undefined
|
|
21868
22401
|
* - The hit is ray.getPosition(distance), a direction that is not unit length scales the distance
|
|
21869
22402
|
* - A ray starting inside the box is already there, so it gets back 0
|
|
21870
22403
|
* @param {Ray3D} ray
|
|
21871
22404
|
* @param {Vector3} pos - Center of the box
|
|
21872
22405
|
* @param {Vector3} size - Full size of the box
|
|
22406
|
+
* @param {Vector3} [rotation] - How the box is turned, upright when left out
|
|
21873
22407
|
* @return {number|undefined}
|
|
21874
22408
|
* @memberof Math3D
|
|
21875
22409
|
*/
|
|
21876
|
-
function raycastBox(ray, pos, size)
|
|
22410
|
+
function raycastBox(ray, pos, size, rotation)
|
|
21877
22411
|
{
|
|
22412
|
+
if (isTurned3D(rotation))
|
|
22413
|
+
{
|
|
22414
|
+
// the ray in the box's own space, where the box is upright; turning keeps the distances
|
|
22415
|
+
const axes = boxAxes3D(rotation), d = ray.direction;
|
|
22416
|
+
const direction = vec3(d.dot(axes[0]), d.dot(axes[1]), d.dot(axes[2]));
|
|
22417
|
+
return raycastBox(new Ray3D(boxLocal3D(ray.origin, pos, axes), direction), vec3(), size);
|
|
22418
|
+
}
|
|
21878
22419
|
const {origin, direction} = ray;
|
|
21879
22420
|
const h = size.scale(.5);
|
|
21880
22421
|
const boxMin = pos.subtract(h), boxMax = pos.add(h);
|
|
@@ -21973,9 +22514,11 @@ function render3DCanDraw()
|
|
|
21973
22514
|
}
|
|
21974
22515
|
|
|
21975
22516
|
// the draw state fields a batch is drawn under; lights and fog are not captured, they are read live at flush
|
|
21976
|
-
// the three functions below write them out by hand for speed, so a new field goes in all four
|
|
22517
|
+
// the three functions below write them out by hand for speed, so a new field goes in all four; emissiveMapColor is
|
|
22518
|
+
// compared by its values, so two objects with equal colors batch
|
|
21977
22519
|
const RENDER3D_STATE_FIELDS = ['blend', 'additive', 'depthTest', 'depthWrite', 'cullBackFaces', 'mirrored', 'lighting',
|
|
21978
|
-
'emissive', 'receiveShadow', 'specular', 'pixelated', 'shader'
|
|
22520
|
+
'emissive', 'receiveShadow', 'specular', 'pixelated', 'shader', 'normalMap', 'normalScale', 'shininess',
|
|
22521
|
+
'reflectivity', 'emissiveMap', 'emissiveMapColor'];
|
|
21979
22522
|
|
|
21980
22523
|
// a copy of the draw state in one fixed shape, the fields of RENDER3D_STATE_FIELDS written out so the
|
|
21981
22524
|
// compare below stays a handful of direct reads, it runs for every instance drawn
|
|
@@ -21984,7 +22527,9 @@ function render3DCaptureBatchState()
|
|
|
21984
22527
|
const r = render3D;
|
|
21985
22528
|
return {blend: r.blend, additive: r.additive, depthTest: r.depthTest, depthWrite: r.depthWrite,
|
|
21986
22529
|
cullBackFaces: r.cullBackFaces, mirrored: r.mirrored, lighting: r.lighting, emissive: r.emissive,
|
|
21987
|
-
receiveShadow: r.receiveShadow, specular: r.specular, pixelated: r.pixelated, shader: r.shader
|
|
22530
|
+
receiveShadow: r.receiveShadow, specular: r.specular, pixelated: r.pixelated, shader: r.shader,
|
|
22531
|
+
normalMap: r.normalMap, normalScale: r.normalScale, shininess: r.shininess, reflectivity: r.reflectivity,
|
|
22532
|
+
emissiveMap: r.emissiveMap, emissiveMapColor: (r.emissiveMapColor || WHITE).copy()}; // the caller may change it
|
|
21988
22533
|
}
|
|
21989
22534
|
|
|
21990
22535
|
// put a captured draw state back, written out the same way; the transparent stage does this for every queued draw
|
|
@@ -21993,7 +22538,9 @@ function render3DApplyBatchState(s)
|
|
|
21993
22538
|
const r = render3D;
|
|
21994
22539
|
r.blend = s.blend, r.additive = s.additive, r.depthTest = s.depthTest, r.depthWrite = s.depthWrite,
|
|
21995
22540
|
r.cullBackFaces = s.cullBackFaces, r.mirrored = s.mirrored, r.lighting = s.lighting, r.emissive = s.emissive,
|
|
21996
|
-
r.receiveShadow = s.receiveShadow, r.specular = s.specular, r.pixelated = s.pixelated, r.shader = s.shader
|
|
22541
|
+
r.receiveShadow = s.receiveShadow, r.specular = s.specular, r.pixelated = s.pixelated, r.shader = s.shader,
|
|
22542
|
+
r.normalMap = s.normalMap, r.normalScale = s.normalScale, r.shininess = s.shininess,
|
|
22543
|
+
r.reflectivity = s.reflectivity, r.emissiveMap = s.emissiveMap, r.emissiveMapColor = s.emissiveMapColor;
|
|
21997
22544
|
}
|
|
21998
22545
|
|
|
21999
22546
|
// true when the current draw state differs from a captured one, so a pending batch must flush first
|
|
@@ -22003,13 +22550,20 @@ function render3DStateChanged(s)
|
|
|
22003
22550
|
return r.blend !== s.blend || r.additive !== s.additive || r.depthTest !== s.depthTest
|
|
22004
22551
|
|| r.depthWrite !== s.depthWrite || r.cullBackFaces !== s.cullBackFaces || r.mirrored !== s.mirrored
|
|
22005
22552
|
|| r.lighting !== s.lighting || r.emissive !== s.emissive || r.receiveShadow !== s.receiveShadow
|
|
22006
|
-
|| r.specular !== s.specular || r.pixelated !== s.pixelated || r.shader !== s.shader
|
|
22553
|
+
|| r.specular !== s.specular || r.pixelated !== s.pixelated || r.shader !== s.shader
|
|
22554
|
+
|| r.normalMap !== s.normalMap || r.normalScale !== s.normalScale || r.shininess !== s.shininess
|
|
22555
|
+
|| r.reflectivity !== s.reflectivity || r.emissiveMap !== s.emissiveMap
|
|
22556
|
+
|| render3DColorChanged(r.emissiveMapColor || WHITE, s.emissiveMapColor);
|
|
22557
|
+
}
|
|
22558
|
+
|
|
22559
|
+
// whether two colors differ in their rgb, for the batch key's emissiveMapColor
|
|
22560
|
+
function render3DColorChanged(a, b) { return a.r !== b.r || a.g !== b.g || a.b !== b.b;
|
|
22007
22561
|
}
|
|
22008
22562
|
|
|
22009
22563
|
// whether a sphere is inside the view, or the shadow map's box during the shadow pass, without a vector
|
|
22010
22564
|
function render3DSphereVisible(x, y, z, radius)
|
|
22011
22565
|
{
|
|
22012
|
-
const planes = render3D.shadowPass ? render3D.shadowPlanes : render3D.frustumPlanes;
|
|
22566
|
+
const planes = render3D.shadowPass && !render3D.depthPass ? render3D.shadowPlanes : render3D.frustumPlanes;
|
|
22013
22567
|
for (let i = 0; i < planes.length; ++i)
|
|
22014
22568
|
{
|
|
22015
22569
|
const p = planes[i];
|
|
@@ -22109,6 +22663,15 @@ function render3DSetObjectState(o)
|
|
|
22109
22663
|
r.emissive = emissive;
|
|
22110
22664
|
r.additive = !!o?.additive;
|
|
22111
22665
|
r.specular = o?.specular || 0;
|
|
22666
|
+
const shininess = o?.shininess ?? 16, reflectivity = o?.reflectivity || 0;
|
|
22667
|
+
ASSERT(isNumber(shininess) && shininess > 0, 'shininess must be a number above 0', shininess);
|
|
22668
|
+
ASSERT(isNumber(reflectivity) && reflectivity >= 0 && reflectivity <= 1, 'reflectivity must be 0 to 1', reflectivity);
|
|
22669
|
+
r.shininess = shininess;
|
|
22670
|
+
r.reflectivity = reflectivity;
|
|
22671
|
+
r.normalMap = o?.normalMap || undefined;
|
|
22672
|
+
r.normalScale = o?.normalScale ?? 1;
|
|
22673
|
+
r.emissiveMap = o?.emissiveMap || undefined;
|
|
22674
|
+
r.emissiveMapColor = o?.emissiveMapColor || WHITE;
|
|
22112
22675
|
r.receiveShadow = !o || o.receiveShadow;
|
|
22113
22676
|
r.cullBackFaces = r.mirrored = false; // each mesh sets these as it draws
|
|
22114
22677
|
r.pixelated = !!o?.pixelated;
|
|
@@ -22342,6 +22905,11 @@ class Render3DPlugin
|
|
|
22342
22905
|
* flat faces */
|
|
22343
22906
|
this.smoothShading = false;
|
|
22344
22907
|
|
|
22908
|
+
/** @property {boolean} - Draw the camera's depth into a texture each frame for post processing:
|
|
22909
|
+
* PostProcessPlugin hands it to its shader as iChannel2, read with sceneDepth(uv); off by default and free
|
|
22910
|
+
* when off, on it draws the solid objects of the default layer once more, depth only */
|
|
22911
|
+
this.depthTexture = false;
|
|
22912
|
+
|
|
22345
22913
|
// shadows
|
|
22346
22914
|
/** @property {boolean} - Cast real shadows from the sun, off by default and free when off */
|
|
22347
22915
|
this.shadows = false;
|
|
@@ -22378,8 +22946,23 @@ class Render3DPlugin
|
|
|
22378
22946
|
this.cullBackFaces = false;
|
|
22379
22947
|
this.mirrored = false;
|
|
22380
22948
|
/** @property {number} - Strength of the highlight where the sun and the Light3D objects reflect, 0 is none and
|
|
22381
|
-
* 1 adds a light's full color at its brightest; its size
|
|
22949
|
+
* 1 adds a light's full color at its brightest; shininess sets its size */
|
|
22382
22950
|
this.specular = 0;
|
|
22951
|
+
/** @property {number} - The highlight's exponent, how small and sharp it is: 4 is broad like rubber, 100 sharp
|
|
22952
|
+
* like polished metal; set from each object's shininess */
|
|
22953
|
+
this.shininess = 16;
|
|
22954
|
+
/** @property {TextureInfo|undefined} - Normal map for the next draws, set from each object's normalMap
|
|
22955
|
+
* @type {TextureInfo|undefined} */
|
|
22956
|
+
this.normalMap = undefined;
|
|
22957
|
+
/** @property {number} - How strongly the normal map bends the surface, set from each object's normalScale */
|
|
22958
|
+
this.normalScale = 1;
|
|
22959
|
+
/** @property {number} - How much the surface reflects the sky, 0 to 1, set from each object's reflectivity */
|
|
22960
|
+
this.reflectivity = 0;
|
|
22961
|
+
/** @property {TextureInfo|undefined} - Emissive map for the next draws, set from each object's emissiveMap
|
|
22962
|
+
* @type {TextureInfo|undefined} */
|
|
22963
|
+
this.emissiveMap = undefined;
|
|
22964
|
+
/** @property {Color} - Multiplies the emissive map, set from each object's emissiveMapColor */
|
|
22965
|
+
this.emissiveMapColor = WHITE;
|
|
22383
22966
|
/** @property {Shader|undefined} - Custom Shader for the next draws, set from each object's shader; undefined
|
|
22384
22967
|
* draws with the plugin's own
|
|
22385
22968
|
* @type {Shader|undefined} */
|
|
@@ -22481,6 +23064,13 @@ class Render3DPlugin
|
|
|
22481
23064
|
/** @type {WebGLFramebuffer|undefined} */
|
|
22482
23065
|
this.shadowFramebuffer = undefined;
|
|
22483
23066
|
this.shadowTextureSize = 0;
|
|
23067
|
+
/** @type {WebGLTexture|undefined} */
|
|
23068
|
+
this.cameraDepthTexture = undefined; // the camera's depth, drawn when depthTexture is on
|
|
23069
|
+
/** @type {WebGLFramebuffer|undefined} */
|
|
23070
|
+
this.cameraDepthFramebuffer = undefined;
|
|
23071
|
+
this.cameraDepthWidth = 0;
|
|
23072
|
+
this.cameraDepthHeight = 0;
|
|
23073
|
+
this.depthPass = false; // drawing the camera's depth, a shadow pass seen from the camera
|
|
22484
23074
|
this.contextGeneration = 0; // counts context losses, a mesh uploaded under an older one uploads again
|
|
22485
23075
|
this.uniforms = new Map; // uniform locations by program
|
|
22486
23076
|
/** @type {Object<string, Array<number>>} */
|
|
@@ -22908,7 +23498,8 @@ class Render3DPlugin
|
|
|
22908
23498
|
// a far plane at Infinity has no midpoint, so put it a long way out instead
|
|
22909
23499
|
const {near, far} = this.camera;
|
|
22910
23500
|
const radius = far == Infinity ? near * 1e4 : (near + far) / 2;
|
|
22911
|
-
render3DWithState({lighting: false, blend: false, depthTest: false, depthWrite: false, fogEnd: 0, shader: undefined
|
|
23501
|
+
render3DWithState({lighting: false, blend: false, depthTest: false, depthWrite: false, fogEnd: 0, shader: undefined,
|
|
23502
|
+
emissiveMap: undefined}, ()=>
|
|
22912
23503
|
this.drawMesh(this.sky, buildMatrix(this.camera.pos, undefined, vec3(radius))));
|
|
22913
23504
|
}
|
|
22914
23505
|
|
|
@@ -23033,8 +23624,10 @@ class Render3DPlugin
|
|
|
23033
23624
|
drawQuad(a, b, c, d, tileInfo, color=WHITE)
|
|
23034
23625
|
{
|
|
23035
23626
|
if (this.transparentQueue) // the queue replays later, so it keeps copies of what a caller may reuse
|
|
23627
|
+
{
|
|
23036
23628
|
a = a.copy(), b = b.copy(), c = c.copy(), d = d.copy();
|
|
23037
23629
|
color = isArray(color) ? color.map(k=> k.copy()) : color.copy();
|
|
23630
|
+
}
|
|
23038
23631
|
this.drawStrip(render3DQuadStrip(a, b, c, d), render3DFaceNormal(a, b, c, d), RENDER3D_QUAD_UVS,
|
|
23039
23632
|
render3DQuadValues(color), tileInfo);
|
|
23040
23633
|
}
|
|
@@ -23333,7 +23926,9 @@ const RENDER3D_SNIPPET_NAMES =
|
|
|
23333
23926
|
// uniforms: lightDir (xyz the way the sunlight travels, w = emissive, 1 or more skips the lighting),
|
|
23334
23927
|
// lightColor (the sun's rgb, a = specular), ambientFog (rgb, a = fogEnd), fogColor (rgb, a = fogStart),
|
|
23335
23928
|
// cameraPos, tex, shadowMap, shadowParams (x = shadows on, y = bias, z = blur step in texture space,
|
|
23336
|
-
// w = how the draw finishes: 1 opaque and alpha tested, 0 blended, -1 additive)
|
|
23929
|
+
// w = how the draw finishes: 1 opaque and alpha tested, 0 blended, -1 additive),
|
|
23930
|
+
// materialParams (x = normal map scale, 0 skips it, y = shininess, z = reflectivity), emissiveTint (rgb, a = the
|
|
23931
|
+
// emissive map is on), skyTop, skyHorizon, skyBottom (what a reflection shows), normalTex, emissiveTex
|
|
23337
23932
|
function render3DFragmentSource(fragmentCode)
|
|
23338
23933
|
{
|
|
23339
23934
|
return '#version 300 es\n' +
|
|
@@ -23342,7 +23937,8 @@ function render3DFragmentSource(fragmentCode)
|
|
|
23342
23937
|
'uniform vec4 extraLights[' + RENDER3D_MAX_LIGHTS + '],extraLightColors[' + RENDER3D_MAX_LIGHTS + '];' +
|
|
23343
23938
|
'uniform int extraLightCount;' +
|
|
23344
23939
|
'uniform vec3 cameraPos;' +
|
|
23345
|
-
'uniform
|
|
23940
|
+
'uniform vec4 materialParams,emissiveTint,skyTop,skyHorizon,skyBottom;' +
|
|
23941
|
+
'uniform sampler2D tex,normalTex,emissiveTex;' +
|
|
23346
23942
|
'uniform bool premultipliedTexture;' + // is the texture a render target, which holds premultiplied color
|
|
23347
23943
|
'uniform highp sampler2DShadow shadowMap;' +
|
|
23348
23944
|
'in vec3 P,N;in vec2 T,L;in vec4 C,S;' +
|
|
@@ -23357,6 +23953,17 @@ function render3DFragmentSource(fragmentCode)
|
|
|
23357
23953
|
'for(int x=-1;x<=1;++x)for(int y=-1;y<=1;++y)' +
|
|
23358
23954
|
's+=texture(shadowMap,vec3(q.xy+vec2(x,y)*shadowParams.z,q.z));' +
|
|
23359
23955
|
'return s/9.;}' +
|
|
23956
|
+
// the normal map's normal here, in the frame that the position and texture coordinate change along across the
|
|
23957
|
+
// screen, so a mesh needs no tangents; green points up the image and v runs down it, so up is -v;
|
|
23958
|
+
// a mesh with no texture coordinates has no frame and keeps its own normal
|
|
23959
|
+
'vec3 normalMapNormal(vec3 n){' +
|
|
23960
|
+
'vec3 p1=dFdx(P),p2=dFdy(P);vec2 t1=dFdx(T),t2=dFdy(T);' +
|
|
23961
|
+
'vec3 a=cross(p2,n),b=cross(n,p1),u=a*t1.x+b*t2.x,v=a*t1.y+b*t2.y;' +
|
|
23962
|
+
'float k=max(dot(u,u),dot(v,v));' +
|
|
23963
|
+
'if(k<=0.)return n;' +
|
|
23964
|
+
'vec3 m=texture(normalTex,T).xyz*2.-1.;' +
|
|
23965
|
+
'm.xy*=materialParams.x;' +
|
|
23966
|
+
'return normalize((u*m.x-v*m.y)*inversesqrt(k)+n*m.z);}' +
|
|
23360
23967
|
(fragmentCode ? RENDER3D_SNIPPET_NAMES + fragmentCode + '\n' : '') +
|
|
23361
23968
|
'void main(){' +
|
|
23362
23969
|
(fragmentCode ? 'vec4 t;mainImage(t,T);' : 'vec4 t=texture(tex,T);') +
|
|
@@ -23367,6 +23974,7 @@ function render3DFragmentSource(fragmentCode)
|
|
|
23367
23974
|
'if(e<1.){' +
|
|
23368
23975
|
'vec3 n=dot(N,N)>0.?normalize(N):vec3(0,1,0);' +
|
|
23369
23976
|
'if(!gl_FrontFacing)n=-n;' + // only a double sided mesh shows a back face, light it on the side that is seen
|
|
23977
|
+
'if(materialParams.x!=0.)n=normalMapNormal(n);' +
|
|
23370
23978
|
'float nl=dot(n,-lightDir.xyz);' +
|
|
23371
23979
|
'float s=shadow();' +
|
|
23372
23980
|
// the ambient: one color, or blended from the ground color below to the sky color above by the way the face points
|
|
@@ -23385,15 +23993,24 @@ function render3DFragmentSource(fragmentCode)
|
|
|
23385
23993
|
'float ln=dot(n,v);' +
|
|
23386
23994
|
'vec3 lc=extraLightColors[i].rgb*extraLightColors[i].a*a*a;' +
|
|
23387
23995
|
'l+=lc*max(0.,ln);' +
|
|
23388
|
-
'if(lightColor.a>0.)sp+=lc*pow(max(dot(reflect(-v,n),eye),0.),
|
|
23996
|
+
'if(lightColor.a>0.)sp+=lc*pow(max(dot(reflect(-v,n),eye),0.),materialParams.y)*step(0.,ln);' +
|
|
23389
23997
|
'}' +
|
|
23390
23998
|
'c.rgb*=l*(1.-e)+e;' + // lit, blended toward its own color by how emissive it is
|
|
23391
23999
|
// specular: the sun's only where its light hits and out of shadow, then the Light3D highlights,
|
|
23392
24000
|
// skipped entirely when the strength is zero
|
|
23393
24001
|
'if(lightColor.a>0.){' +
|
|
23394
24002
|
'vec3 r=reflect(lightDir.xyz,n);' +
|
|
23395
|
-
'c.rgb+=lightColor.rgb*pow(max(dot(r,eye),0.),
|
|
24003
|
+
'c.rgb+=lightColor.rgb*pow(max(dot(r,eye),0.),materialParams.y)*lightColor.a*step(0.,nl)*s*(1.-e)+sp*lightColor.a*(1.-e);' +
|
|
24004
|
+
'}' +
|
|
24005
|
+
// the sky along the reflected view, more at a glancing angle (Schlick's Fresnel); sky light, so the sun's
|
|
24006
|
+
// shadow does not dim it
|
|
24007
|
+
'if(materialParams.z>0.){' +
|
|
24008
|
+
'vec3 w=normalize(P-cameraPos),q=reflect(w,n);' +
|
|
24009
|
+
'float f=materialParams.z+(1.-materialParams.z)*pow(1.-max(dot(n,-w),0.),5.);' +
|
|
24010
|
+
'c.rgb=mix(c.rgb,q.y>0.?mix(skyHorizon.rgb,skyTop.rgb,q.y):mix(skyHorizon.rgb,skyBottom.rgb,-q.y),f);' +
|
|
23396
24011
|
'}}else c.rgb*=e;' + // fully emissive: its own color, or brighter, with no lighting to work out
|
|
24012
|
+
// the emissive map adds its light on top, lit or not
|
|
24013
|
+
'if(emissiveTint.a>0.)c.rgb+=texture(emissiveTex,T).rgb*emissiveTint.rgb;' +
|
|
23397
24014
|
'if(ambientFog.a>0.){' +
|
|
23398
24015
|
'float z=distance(cameraPos,P);' +
|
|
23399
24016
|
'c.rgb=mix(c.rgb,shadowParams.w<0.?vec3(0):fogColor.rgb,smoothstep(fogColor.a,ambientFog.a,z));' +
|
|
@@ -23420,6 +24037,8 @@ function render3DUseProgram(program)
|
|
|
23420
24037
|
gl.uniformMatrix4fv(render3DUniform('lightViewProj'), false, r.shadowMatrix.m);
|
|
23421
24038
|
gl.uniform1i(render3DUniform('tex'), 0);
|
|
23422
24039
|
gl.uniform1i(render3DUniform('shadowMap'), 1);
|
|
24040
|
+
gl.uniform1i(render3DUniform('normalTex'), 2);
|
|
24041
|
+
gl.uniform1i(render3DUniform('emissiveTex'), 3);
|
|
23423
24042
|
const c = r.camera.pos;
|
|
23424
24043
|
gl.uniform3f(render3DUniform('cameraPos'), c.x, c.y, c.z);
|
|
23425
24044
|
gl.uniform1i(render3DUniform('extraLightCount'), r.lightCount);
|
|
@@ -23512,6 +24131,8 @@ function render3DContextLost()
|
|
|
23512
24131
|
render3DClearInstances();
|
|
23513
24132
|
r.shadowFramebuffer = r.shadowTexture = undefined;
|
|
23514
24133
|
r.shadowTextureSize = 0;
|
|
24134
|
+
r.cameraDepthFramebuffer = r.cameraDepthTexture = undefined;
|
|
24135
|
+
r.cameraDepthWidth = r.cameraDepthHeight = 0;
|
|
23515
24136
|
r.streamCount = 0;
|
|
23516
24137
|
++r.contextGeneration; // every uploaded mesh is stale now, the soft dot is a TextureInfo the engine restores
|
|
23517
24138
|
}
|
|
@@ -23590,25 +24211,68 @@ function render3DSampler(wrap, pixelated)
|
|
|
23590
24211
|
}
|
|
23591
24212
|
|
|
23592
24213
|
// bind the texture of a tile or texture, white when there is none or it is not loaded, with the 3D sampler that
|
|
23593
|
-
// matches its wrap mode; the first time a texture is used in 3D it gets its mipmaps
|
|
23594
|
-
|
|
24214
|
+
// matches its wrap mode; the first time a texture is used in 3D it gets its mipmaps; on unit 0, the color texture,
|
|
24215
|
+
// or another unit, the material maps, after which unit 0 is active again
|
|
24216
|
+
function render3DBindTexture(tileInfo, state=render3D, unit=0)
|
|
23595
24217
|
{
|
|
23596
24218
|
const gl = glContext, r = render3D;
|
|
23597
24219
|
const textureInfo = render3DTextureOf(tileInfo);
|
|
23598
24220
|
const texture = textureInfo?.glTexture || r.whiteTexture;
|
|
24221
|
+
unit && gl.activeTexture(gl.TEXTURE0 + unit);
|
|
23599
24222
|
gl.bindTexture(gl.TEXTURE_2D, texture);
|
|
23600
24223
|
if (texture === r.whiteTexture || !r.mipmaps && !state.pixelated)
|
|
23601
|
-
|
|
23602
|
-
|
|
23603
|
-
|
|
23604
|
-
|
|
23605
|
-
|
|
24224
|
+
gl.bindSampler(unit, null); // the texture's own filtering, as in 2D; the white texel needs no mipmaps
|
|
24225
|
+
// or anisotropy, and filtering it that way costs every untextured fragment
|
|
24226
|
+
else
|
|
24227
|
+
{
|
|
24228
|
+
gl.bindSampler(unit, render3DSampler(textureInfo?.wrap, state.pixelated));
|
|
24229
|
+
glUpdateMipmaps(texture); // drawn into since its mipmaps were made
|
|
24230
|
+
if (!state.pixelated && !glMipmappedTextures.has(texture)) // a hard edged draw never reads them
|
|
24231
|
+
{
|
|
24232
|
+
glMipmappedTextures.add(texture); // the core makes them again when the texture changes
|
|
24233
|
+
gl.generateMipmap(gl.TEXTURE_2D);
|
|
24234
|
+
}
|
|
24235
|
+
}
|
|
24236
|
+
unit && gl.activeTexture(gl.TEXTURE0);
|
|
24237
|
+
}
|
|
24238
|
+
|
|
24239
|
+
// the material's uniforms and maps for a draw: the normal map on unit 2 and the emissive map on unit 3, white when
|
|
24240
|
+
// unused and bound only when they change, and the sky a reflection shows, the colors setSky was given or the
|
|
24241
|
+
// ambient ones; with nothing set every part of it is skipped in the shader, and a map with no GL texture yet, or
|
|
24242
|
+
// one freed, counts as none, since the white texture in its place would bend every normal or light the surface
|
|
24243
|
+
function render3DSetMaterialUniforms(state)
|
|
24244
|
+
{
|
|
24245
|
+
const r = render3D, loaded = (map)=> render3DTextureOf(map)?.glTexture ? map : undefined;
|
|
24246
|
+
const normalMap = state.normalScale ? loaded(state.normalMap) : undefined, emissiveMap = loaded(state.emissiveMap);
|
|
24247
|
+
render3DUniform4f('materialParams', normalMap ? state.normalScale : 0, state.shininess, state.reflectivity, 0);
|
|
24248
|
+
const ec = state.emissiveMapColor || WHITE;
|
|
24249
|
+
emissiveMap ? render3DUniform4f('emissiveTint', ec.r, ec.g, ec.b, 1) : render3DUniform4f('emissiveTint', 0, 0, 0, 0);
|
|
24250
|
+
render3DBindMap(2, normalMap, state);
|
|
24251
|
+
render3DBindMap(3, emissiveMap, state);
|
|
24252
|
+
if (state.reflectivity > 0)
|
|
23606
24253
|
{
|
|
23607
|
-
|
|
23608
|
-
|
|
24254
|
+
const sky = r.sky && render3DSkyColors.get(r.sky), a = r.ambientColor, g = r.ambientGroundColor || a;
|
|
24255
|
+
const top = sky ? sky[0] : a, bottom = sky ? sky[2] : g;
|
|
24256
|
+
render3DUniform4f('skyTop', top.r, top.g, top.b, 1);
|
|
24257
|
+
render3DUniform4f('skyBottom', bottom.r, bottom.g, bottom.b, 1);
|
|
24258
|
+
sky ? render3DUniform4f('skyHorizon', sky[1].r, sky[1].g, sky[1].b, 1) :
|
|
24259
|
+
render3DUniform4f('skyHorizon', (a.r + g.r) / 2, (a.g + g.g) / 2, (a.b + g.b) / 2, 1);
|
|
23609
24260
|
}
|
|
23610
24261
|
}
|
|
23611
24262
|
|
|
24263
|
+
// what each material map unit has bound, index 2 and 3 the maps and 4 and 5 whether each is pixelated, forgotten at
|
|
24264
|
+
// the start and end of each pass
|
|
24265
|
+
let render3DBoundMaps = [];
|
|
24266
|
+
|
|
24267
|
+
// bind a material map to its unit, or white for none, only when it or its filtering changed since the last draw
|
|
24268
|
+
function render3DBindMap(unit, map, state)
|
|
24269
|
+
{
|
|
24270
|
+
const bound = render3DBoundMaps, pixelated = !!map && state.pixelated;
|
|
24271
|
+
if (bound[unit] === map && bound[unit + 2] === pixelated) return;
|
|
24272
|
+
bound[unit] = map, bound[unit + 2] = pixelated;
|
|
24273
|
+
render3DBindTexture(map, state, unit);
|
|
24274
|
+
}
|
|
24275
|
+
|
|
23612
24276
|
// send a vec4 uniform of the main shader only when its value changed since the last send
|
|
23613
24277
|
function render3DUniform4f(name, x, y, z, w)
|
|
23614
24278
|
{
|
|
@@ -23746,6 +24410,7 @@ function render3DSetDrawUniforms(matrix, tileInfo, tint, uvRect, state=render3D)
|
|
|
23746
24410
|
// a render target's texture holds premultiplied color, the blend writes it that way, so the shader undoes it
|
|
23747
24411
|
const textureInfo = render3DTextureOf(tileInfo);
|
|
23748
24412
|
render3DUniform1i('premultipliedTexture', +!!(textureInfo?.glTexture && glPremultipliedTextures.has(textureInfo.glTexture)));
|
|
24413
|
+
render3DSetMaterialUniforms(state);
|
|
23749
24414
|
}
|
|
23750
24415
|
|
|
23751
24416
|
// the six flat sides of the camera's visible box, each as [x, y, z, w] facing inward
|
|
@@ -23767,6 +24432,7 @@ function render3DFrustumPlanes(matrix)
|
|
|
23767
24432
|
function render3DPreRender()
|
|
23768
24433
|
{
|
|
23769
24434
|
const r = render3D;
|
|
24435
|
+
editor3DCameraBegin(); // debug builds draw with the free camera or the 3D editor's, the game's is back after
|
|
23770
24436
|
r.updateMatrices();
|
|
23771
24437
|
r.shadowMapDrawn = false;
|
|
23772
24438
|
render3DRenderPass(false);
|
|
@@ -23776,6 +24442,7 @@ function render3DPreRender()
|
|
|
23776
24442
|
function render3DRender()
|
|
23777
24443
|
{
|
|
23778
24444
|
render3DRenderPass(true);
|
|
24445
|
+
editor3DCameraEnd();
|
|
23779
24446
|
}
|
|
23780
24447
|
|
|
23781
24448
|
// one 3D pass for the objects of a layer: take over the gl state, draw the shadow map once a frame and the stages, hand
|
|
@@ -23825,7 +24492,8 @@ function render3DRenderPass(after2D)
|
|
|
23825
24492
|
// may have left its own texture, which fails every draw; the shadow map goes back there even with shadows off
|
|
23826
24493
|
gl.activeTexture(gl.TEXTURE1);
|
|
23827
24494
|
gl.bindTexture(gl.TEXTURE_2D, r.shadowTexture || null);
|
|
23828
|
-
|
|
24495
|
+
// the material maps' units start white, a 2D plugin may have left its own textures there
|
|
24496
|
+
render3DSetMapUnits(r.whiteTexture);
|
|
23829
24497
|
gl.depthMask(true);
|
|
23830
24498
|
gl.clear(gl.DEPTH_BUFFER_BIT);
|
|
23831
24499
|
|
|
@@ -23838,6 +24506,8 @@ function render3DRenderPass(after2D)
|
|
|
23838
24506
|
render3DRenderShadowMap();
|
|
23839
24507
|
r.shadowMapDrawn = true;
|
|
23840
24508
|
}
|
|
24509
|
+
// the camera's depth for post processing, from the default layer
|
|
24510
|
+
r.depthTexture && isDefault && render3DRenderDepth();
|
|
23841
24511
|
render3DUseProgram(r.program); // after the shadow map, so the light matrix it sends is this frame's
|
|
23842
24512
|
r.renderStages(objects, isDefault);
|
|
23843
24513
|
}
|
|
@@ -23853,6 +24523,7 @@ function render3DRenderPass(after2D)
|
|
|
23853
24523
|
gl.depthMask(true);
|
|
23854
24524
|
gl.frontFace(gl.CCW);
|
|
23855
24525
|
gl.bindSampler(0, null); // back to the textures' own filtering for 2D
|
|
24526
|
+
render3DSetMapUnits(null); // the material maps leave no texture or sampler behind for 2D
|
|
23856
24527
|
if (glActiveTexture)
|
|
23857
24528
|
gl.bindTexture(gl.TEXTURE_2D, glActiveTexture);
|
|
23858
24529
|
// ARRAY_BUFFER is not part of VAO state in WebGL2, so bindVertexArray alone would not restore it
|
|
@@ -23861,6 +24532,21 @@ function render3DRenderPass(after2D)
|
|
|
23861
24532
|
}
|
|
23862
24533
|
}
|
|
23863
24534
|
|
|
24535
|
+
// put a texture on both material map units with no sampler, white at the start of the pass and none at its end, and
|
|
24536
|
+
// forget what the map cache thought was bound; unit 0 is active after
|
|
24537
|
+
function render3DSetMapUnits(texture)
|
|
24538
|
+
{
|
|
24539
|
+
const gl = glContext;
|
|
24540
|
+
for (const unit of [2, 3])
|
|
24541
|
+
{
|
|
24542
|
+
gl.activeTexture(gl.TEXTURE0 + unit);
|
|
24543
|
+
gl.bindTexture(gl.TEXTURE_2D, texture);
|
|
24544
|
+
gl.bindSampler(unit, null);
|
|
24545
|
+
}
|
|
24546
|
+
gl.activeTexture(gl.TEXTURE0);
|
|
24547
|
+
render3DBoundMaps = [];
|
|
24548
|
+
}
|
|
24549
|
+
|
|
23864
24550
|
// create the shadow map depth texture and framebuffer at a size, or keep them when the size matches
|
|
23865
24551
|
function render3DUpdateShadowMap(size)
|
|
23866
24552
|
{
|
|
@@ -23937,6 +24623,65 @@ function render3DRenderShadowMap()
|
|
|
23937
24623
|
}
|
|
23938
24624
|
}
|
|
23939
24625
|
|
|
24626
|
+
// the camera's depth texture and its framebuffer at a size, made again when the canvas changes size; depth values
|
|
24627
|
+
// are not filtered, so it is read texel by texel
|
|
24628
|
+
function render3DUpdateCameraDepth(width, height)
|
|
24629
|
+
{
|
|
24630
|
+
const gl = glContext, r = render3D;
|
|
24631
|
+
if (r.cameraDepthTexture && r.cameraDepthWidth === width && r.cameraDepthHeight === height) return;
|
|
24632
|
+
r.cameraDepthTexture && gl.deleteTexture(r.cameraDepthTexture);
|
|
24633
|
+
r.cameraDepthFramebuffer && gl.deleteFramebuffer(r.cameraDepthFramebuffer);
|
|
24634
|
+
const texture = r.cameraDepthTexture = gl.createTexture();
|
|
24635
|
+
gl.bindTexture(gl.TEXTURE_2D, texture);
|
|
24636
|
+
gl.texImage2D(gl.TEXTURE_2D, 0, gl.DEPTH_COMPONENT24, width, height, 0, gl.DEPTH_COMPONENT, gl.UNSIGNED_INT, null);
|
|
24637
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
|
|
24638
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
|
|
24639
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
|
24640
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
|
24641
|
+
gl.bindTexture(gl.TEXTURE_2D, null);
|
|
24642
|
+
const framebuffer = r.cameraDepthFramebuffer = gl.createFramebuffer();
|
|
24643
|
+
gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer);
|
|
24644
|
+
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.DEPTH_ATTACHMENT, gl.TEXTURE_2D, texture, 0);
|
|
24645
|
+
gl.drawBuffers([gl.NONE]); // depth only
|
|
24646
|
+
gl.readBuffer(gl.NONE);
|
|
24647
|
+
ASSERT(gl.checkFramebufferStatus(gl.FRAMEBUFFER) == gl.FRAMEBUFFER_COMPLETE, 'camera depth framebuffer is incomplete');
|
|
24648
|
+
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
|
|
24649
|
+
r.cameraDepthWidth = width, r.cameraDepthHeight = height;
|
|
24650
|
+
}
|
|
24651
|
+
|
|
24652
|
+
// draw the camera's depth for post processing: the solid objects from the camera with the shadow map's depth only
|
|
24653
|
+
// shader, into a texture the size of the canvas; what is see through is cut by its alpha as it is for shadows
|
|
24654
|
+
function render3DRenderDepth()
|
|
24655
|
+
{
|
|
24656
|
+
const gl = glContext, r = render3D, width = glCanvas.width, height = glCanvas.height;
|
|
24657
|
+
render3DUpdateCameraDepth(width, height);
|
|
24658
|
+
gl.bindFramebuffer(gl.FRAMEBUFFER, r.cameraDepthFramebuffer);
|
|
24659
|
+
gl.viewport(0, 0, width, height);
|
|
24660
|
+
gl.clear(gl.DEPTH_BUFFER_BIT);
|
|
24661
|
+
gl.useProgram(r.shadowShader);
|
|
24662
|
+
gl.uniformMatrix4fv(render3DUniform('viewProj', r.shadowShader), false, r.viewProjection.m);
|
|
24663
|
+
gl.enable(gl.DEPTH_TEST);
|
|
24664
|
+
gl.depthMask(true);
|
|
24665
|
+
gl.disable(gl.BLEND);
|
|
24666
|
+
gl.disable(gl.CULL_FACE);
|
|
24667
|
+
|
|
24668
|
+
r.shadowPass = r.depthPass = true;
|
|
24669
|
+
try
|
|
24670
|
+
{
|
|
24671
|
+
const solids = render3DLayerObjects(!!r.renderAfter2D).filter(o=> !o.additive && (!o.transparent || o.tileInfo));
|
|
24672
|
+
render3DDrawObjects(solids);
|
|
24673
|
+
r.onRenderOpaque?.();
|
|
24674
|
+
r.flush();
|
|
24675
|
+
render3DFlushInstances();
|
|
24676
|
+
}
|
|
24677
|
+
finally
|
|
24678
|
+
{
|
|
24679
|
+
r.shadowPass = r.depthPass = false;
|
|
24680
|
+
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
|
|
24681
|
+
gl.viewport(0, 0, width, height);
|
|
24682
|
+
}
|
|
24683
|
+
}
|
|
24684
|
+
|
|
23940
24685
|
///////////////////////////////////////////////////////////////////////////////
|
|
23941
24686
|
// Strips: every strip repeats its first point once at the start and its last
|
|
23942
24687
|
// point once at the end. Those repeats make flat triangles with no area, which
|
|
@@ -24090,18 +24835,26 @@ function render3DStripTriangles(count, remap, place)
|
|
|
24090
24835
|
}
|
|
24091
24836
|
|
|
24092
24837
|
// a mesh's packed vertex data for the GPU, one vertex per entry of a layout, which is the strip index of each,
|
|
24093
|
-
// written into data when given, the buffer an earlier call returned for the same layout
|
|
24838
|
+
// written into data when given, the buffer an earlier call returned for the same layout; it measures the mesh's
|
|
24839
|
+
// radius and box as it goes, as computeRadius does, over the vertices once instead of every strip entry
|
|
24094
24840
|
function render3DMeshVertexData(mesh, vertices, data=new ArrayBuffer(vertices.length * RENDER3D_VERTEX_BYTES))
|
|
24095
24841
|
{
|
|
24096
24842
|
const count = vertices.length;
|
|
24097
24843
|
const floats = new Float32Array(data), ints = new Uint32Array(data);
|
|
24844
|
+
let r = 0, x0 = Infinity, y0 = Infinity, z0 = Infinity, x1 = -Infinity, y1 = -Infinity, z1 = -Infinity;
|
|
24098
24845
|
for (let j = 0; j < count; ++j)
|
|
24099
24846
|
{
|
|
24100
24847
|
// a hand built mesh may leave normals, uvs and colors empty
|
|
24101
24848
|
const i = vertices[j], p = mesh.points[i], uv = mesh.uvs[i] || RENDER3D_DEFAULT_UV;
|
|
24102
|
-
|
|
24849
|
+
const x = p.x, y = p.y, z = p.z;
|
|
24850
|
+
render3DWriteVertex(floats, ints, j * RENDER3D_VERTEX_FLOATS, x, y, z,
|
|
24103
24851
|
mesh.normals[i] || RENDER3D_DEFAULT_NORMAL, uv.x, uv.y, (mesh.colors[i] || WHITE).rgbaInt());
|
|
24852
|
+
r = max(r, x*x + y*y + z*z);
|
|
24853
|
+
x0 = min(x0, x), y0 = min(y0, y), z0 = min(z0, z);
|
|
24854
|
+
x1 = max(x1, x), y1 = max(y1, y), z1 = max(z1, z);
|
|
24104
24855
|
}
|
|
24856
|
+
mesh.radius = r ** .5;
|
|
24857
|
+
mesh.bounds = count ? {min: vec3(x0, y0, z0), max: vec3(x1, y1, z1)} : {min: vec3(), max: vec3()};
|
|
24105
24858
|
return data;
|
|
24106
24859
|
}
|
|
24107
24860
|
|
|
@@ -24613,8 +25366,8 @@ class Mesh
|
|
|
24613
25366
|
* @return {Mesh} */
|
|
24614
25367
|
upload()
|
|
24615
25368
|
{
|
|
24616
|
-
|
|
24617
|
-
if (!render3D?.program || !glContext) return this;
|
|
25369
|
+
// the packing below measures the radius and box, with nothing to pack to they are measured here
|
|
25370
|
+
if (!render3D?.program || !glContext) return this.computeRadius(), this;
|
|
24618
25371
|
const gl = glContext, layout = this.vertexLayout;
|
|
24619
25372
|
// no layout when dynamicDraw was turned on after an upload, the next upload makes one
|
|
24620
25373
|
if (this.dynamicDraw && layout && this.buffer && this.contextGeneration === render3D.contextGeneration)
|
|
@@ -25116,25 +25869,30 @@ function buildSky(topColor=hsl(.6, .8, .55), horizonColor=hsl(.6, 1, .9), bottom
|
|
|
25116
25869
|
// a sphere turned inside out so it is seen from within, each point colored by how high it is
|
|
25117
25870
|
const mesh = buildSphere(2, sides, rings, true).flipNormals();
|
|
25118
25871
|
mesh.colors = mesh.points.map(p=> p.y < 0 ? horizonColor.lerp(bottomColor, -p.y) : horizonColor.lerp(topColor, p.y));
|
|
25872
|
+
render3DSkyColors.set(mesh, [topColor.copy(), horizonColor.copy(), bottomColor.copy()]);
|
|
25119
25873
|
return mesh;
|
|
25120
25874
|
}
|
|
25121
25875
|
|
|
25876
|
+
// the top, horizon and bottom colors of each sky dome buildSky made, which a reflection shows while that dome is
|
|
25877
|
+
// render3D.sky, whether setSky set it or the game did
|
|
25878
|
+
const render3DSkyColors = new WeakMap;
|
|
25879
|
+
|
|
25122
25880
|
///////////////////////////////////////////////////////////////////////////////
|
|
25123
25881
|
/**
|
|
25124
25882
|
* EngineObject3D - An EngineObject with a 3D transform and a mesh
|
|
25125
25883
|
* - Set pos3D, rotation3D and scale3D instead of the 2D pos, size and angle
|
|
25126
25884
|
* - Gets update, children, timers, destroy and renderOrder from EngineObject
|
|
25127
|
-
* - velocity3D is added to pos3D each frame,
|
|
25885
|
+
* - velocity3D is added to pos3D each frame, slowed by damping, and render3D.gravity pulls it once it has a mass
|
|
25128
25886
|
* - Objects face -Z, the same way the camera does, so lookAt turns them to face a point
|
|
25129
25887
|
* - The 2D pos and velocity are still there but nothing draws them
|
|
25130
|
-
* - These inherited fields are 2D only and do nothing here: angle, angleVelocity,
|
|
25131
|
-
*
|
|
25888
|
+
* - These inherited fields are 2D only and do nothing here: angle, angleVelocity, additiveColor, drawSize and mirror;
|
|
25889
|
+
* damping, angleDamping, clampSpeed, friction and groundObject work as in 2D, on velocity3D and angleVelocity3D
|
|
25132
25890
|
* - The inherited shader works here as in 2D, and with emissive at 1 its snippet does its own lighting
|
|
25133
25891
|
* - Set sync2D for a 2D game with 3D looks, pos and angle then drive pos3D and rotation3D,
|
|
25134
25892
|
* which is the one way those 2D fields reach a 3D object
|
|
25135
25893
|
* - setCollision takes the same flags as in 2D, but the solid collision happens in 3D against size3D
|
|
25136
|
-
* - The solid box
|
|
25137
|
-
*
|
|
25894
|
+
* - The solid box turns with rotation3D, so a turned wall or a ramp collides as it looks; resting on one no steeper
|
|
25895
|
+
* than groundAngle stands there
|
|
25138
25896
|
* - Its tile and raycast halves are 2D only so they default off here, and a child sits solid collision out
|
|
25139
25897
|
* - A sync2D object collides in 2D instead, which needs the 2D size set as well as size3D
|
|
25140
25898
|
* - setMesh swaps the mesh and frees the old one, for text and terrain that get built again
|
|
@@ -25182,7 +25940,7 @@ class EngineObject3D extends EngineObject
|
|
|
25182
25940
|
/** @property {Vector3} - Added to pos3D each frame by the engine before update, like the 2D velocity, no super
|
|
25183
25941
|
* call needed; damping and render3D.gravity act on it once the object has a mass */
|
|
25184
25942
|
this.velocity3D = vec3();
|
|
25185
|
-
/** @property {Vector3} - Added to rotation3D each frame by the engine before update,
|
|
25943
|
+
/** @property {Vector3} - Added to rotation3D each frame by the engine before update, slowed by angleDamping */
|
|
25186
25944
|
this.angleVelocity3D = vec3();
|
|
25187
25945
|
/** @property {Mesh|undefined} - Mesh to draw
|
|
25188
25946
|
* @type {Mesh|undefined} */
|
|
@@ -25213,8 +25971,27 @@ class EngineObject3D extends EngineObject
|
|
|
25213
25971
|
* lamps and glowing things, between is partly self lit, and above 1 is brighter than its color, for bloom */
|
|
25214
25972
|
this.emissive = 0;
|
|
25215
25973
|
/** @property {number} - Strength of the highlight where the sun and the Light3D objects reflect, 0 is none and
|
|
25216
|
-
* 1 adds a light's full color at its brightest; its size
|
|
25974
|
+
* 1 adds a light's full color at its brightest; shininess sets its size */
|
|
25217
25975
|
this.specular = 0;
|
|
25976
|
+
/** @property {number} - The highlight's exponent, how small and sharp it is: 4 is broad like rubber, 16 the
|
|
25977
|
+
* default, 100 sharp like polished metal; shows only with specular above 0 */
|
|
25978
|
+
this.shininess = 16;
|
|
25979
|
+
/** @property {TextureInfo|undefined} - A normal map that bends the surface at each texel so it catches the
|
|
25980
|
+
* light like bumps and grooves, green pointing up the image as OpenGL and glTF have it; read at the color
|
|
25981
|
+
* texture's coordinates, see normalMapFromHeight to make one in code
|
|
25982
|
+
* @type {TextureInfo|undefined} */
|
|
25983
|
+
this.normalMap = undefined;
|
|
25984
|
+
/** @property {number} - How strongly the normal map bends the surface, 0 turns it off, as glTF's scale */
|
|
25985
|
+
this.normalScale = 1;
|
|
25986
|
+
/** @property {number} - How much it reflects the sky, 0 none and 1 a mirror of it; the edges seen at a
|
|
25987
|
+
* glancing angle reflect more either way, as water and glass do */
|
|
25988
|
+
this.reflectivity = 0;
|
|
25989
|
+
/** @property {TextureInfo|undefined} - A texture of where it glows, added on top of the lit surface so it
|
|
25990
|
+
* shows in the dark, like lit windows; read at the color texture's coordinates
|
|
25991
|
+
* @type {TextureInfo|undefined} */
|
|
25992
|
+
this.emissiveMap = undefined;
|
|
25993
|
+
/** @property {Color} - Multiplies the emissive map, as glTF's emissiveFactor */
|
|
25994
|
+
this.emissiveMapColor = WHITE;
|
|
25218
25995
|
/** @property {boolean} - Draw into the shadow map when render3D.shadows is on; sprites and cut out textures
|
|
25219
25996
|
* cast their outline, an object faded below half its alpha casts nothing, a see through one casts only when
|
|
25220
25997
|
* textured, and additive objects never cast */
|
|
@@ -25222,6 +25999,9 @@ class EngineObject3D extends EngineObject
|
|
|
25222
25999
|
/** @property {boolean} - Collide as the sphere that fits size3D instead of as the size3D box, so it rolls
|
|
25223
26000
|
* around corners */
|
|
25224
26001
|
this.collideAsSphere3D = false;
|
|
26002
|
+
/** @property {number} - The steepest slope it stands on, in radians from level, PI/4 by default: resting on
|
|
26003
|
+
* a solid within this of flat sets groundObject and holds it still, steeper it slides down */
|
|
26004
|
+
this.groundAngle = PI / 4;
|
|
25225
26005
|
/** @property {boolean} - Darkened by the shadow map when render3D.shadows is on */
|
|
25226
26006
|
this.receiveShadow = true;
|
|
25227
26007
|
/** @property {boolean|undefined} - Draw this object over the 2D scene, undefined uses render3D.renderAfter2D
|
|
@@ -25252,10 +26032,32 @@ class EngineObject3D extends EngineObject
|
|
|
25252
26032
|
'a sync2D object collides in 2D, so give it a 2D size as well as a size3D', this.size);
|
|
25253
26033
|
if (this.sync2D)
|
|
25254
26034
|
super.updatePhysics();
|
|
26035
|
+
ASSERT(isNumber(this.groundAngle) && this.groundAngle >= 0 && this.groundAngle < PI / 2,
|
|
26036
|
+
'groundAngle must be 0 to less than PI/2, a slope from level', this.groundAngle);
|
|
26037
|
+
// what it stands on is found again each frame, by the level and by the solids it rests on; a sync2D
|
|
26038
|
+
// object's is the 2D physics'
|
|
26039
|
+
const ground = this.groundObject;
|
|
26040
|
+
this.sync2D || (this.groundObject = undefined);
|
|
26041
|
+
if (this.clampSpeed && !this.sync2D && (this.collideSolidObjects || this.collideLevel && this.mass))
|
|
26042
|
+
{
|
|
26043
|
+
// each axis within objectMaxSpeed, as in 2D, so a fast object does not pass through a thin wall; only
|
|
26044
|
+
// for what collides, anything else moves as fast as it is told
|
|
26045
|
+
const v = this.velocity3D, s = objectMaxSpeed;
|
|
26046
|
+
v.x = clamp(v.x, -s, s), v.y = clamp(v.y, -s, s), v.z = clamp(v.z, -s, s);
|
|
26047
|
+
}
|
|
25255
26048
|
// a moving object keeps out of the level, the height maps and voxel maps, from where it was before it moved
|
|
25256
26049
|
const oldPos = this.collideLevel && this.mass && !this.sync2D ? this.pos3D.copy() : undefined;
|
|
25257
26050
|
render3DMove(this);
|
|
25258
|
-
|
|
26051
|
+
if (ground && this.mass && !this.sync2D)
|
|
26052
|
+
{
|
|
26053
|
+
// sliding on what it stood on slows by friction, the less grippy of the two, relative to that one's own
|
|
26054
|
+
// speed so a moving platform carries it, as in 2D
|
|
26055
|
+
const friction = max(this.friction, ground.friction), v = this.velocity3D;
|
|
26056
|
+
const moving = ground instanceof EngineObject3D ? ground.velocity3D : undefined;
|
|
26057
|
+
const gx = moving?.x ?? 0, gz = moving?.z ?? 0;
|
|
26058
|
+
v.x = gx + (v.x - gx) * friction, v.z = gz + (v.z - gz) * friction;
|
|
26059
|
+
}
|
|
26060
|
+
oldPos && render3DCollideLevel(this, oldPos, ground);
|
|
25259
26061
|
// the engine only runs this for objects that own where they are, a child rides along with its parent
|
|
25260
26062
|
if (this.collideSolidObjects && !this.sync2D)
|
|
25261
26063
|
render3DCollideSolid(this);
|
|
@@ -25288,7 +26090,7 @@ class EngineObject3D extends EngineObject
|
|
|
25288
26090
|
|
|
25289
26091
|
/** Set how this object collides, the same flags as in 2D
|
|
25290
26092
|
* - Solid collision happens in 3D here, against size3D boxes or spheres; a child sits it out
|
|
25291
|
-
* - The boxes
|
|
26093
|
+
* - The boxes turn with rotation3D, against height maps and voxel maps an object is still its upright box
|
|
25292
26094
|
* - A sync2D object collides in 2D instead, against the 2D size, so set that as well as size3D
|
|
25293
26095
|
* @param {boolean} [collideSolidObjects] - Take part in solid collision
|
|
25294
26096
|
* @param {boolean} [isSolid] - Block other objects, a pair where neither one blocks passes through;
|
|
@@ -25476,27 +26278,29 @@ function render3DObjectMatrix(o)
|
|
|
25476
26278
|
return o.worldMatrix;
|
|
25477
26279
|
}
|
|
25478
26280
|
|
|
25479
|
-
// move an object by its 3D velocities, an object with mass falling with render3D.gravity
|
|
26281
|
+
// move an object by its 3D velocities, each slowed by its damping, an object with mass falling with render3D.gravity
|
|
25480
26282
|
function render3DMove(o)
|
|
25481
26283
|
{
|
|
25482
26284
|
// the vectors change in place, as the 2D object's do: this runs for every object every frame
|
|
26285
|
+
// a sync2D object's damping is the 2D physics', its 3D velocities are its own to set
|
|
25483
26286
|
const p = o.pos3D, v = o.velocity3D, r = o.rotation3D, a = o.angleVelocity3D;
|
|
26287
|
+
const d = o.sync2D ? 1 : o.damping, e = o.sync2D ? 1 : o.angleDamping;
|
|
26288
|
+
// damped first and gravity added after, the order EngineObject.updatePhysics uses,
|
|
26289
|
+
// so the same mass, damping and gravity fall the same way in both
|
|
26290
|
+
v.x *= d, v.y *= d, v.z *= d;
|
|
25484
26291
|
if (o.mass && !o.sync2D) // a 2D driven object gets the 2D gravity instead
|
|
25485
26292
|
{
|
|
25486
|
-
|
|
25487
|
-
|
|
25488
|
-
const g = render3D.gravity, s = o.gravityScale, d = o.damping;
|
|
25489
|
-
v.x = v.x * d + g.x * s, v.y = v.y * d + g.y * s, v.z = v.z * d + g.z * s;
|
|
26293
|
+
const g = render3D.gravity, s = o.gravityScale;
|
|
26294
|
+
v.x += g.x * s, v.y += g.y * s, v.z += g.z * s;
|
|
25490
26295
|
}
|
|
25491
26296
|
p.x += v.x, p.y += v.y, p.z += v.z;
|
|
25492
|
-
r.x += a.x, r.y += a.y, r.z += a.z;
|
|
26297
|
+
r.x += a.x *= e, r.y += a.y *= e, r.z += a.z *= e;
|
|
25493
26298
|
}
|
|
25494
26299
|
|
|
25495
26300
|
// keep an object that moved out of the level's solid geometry, clearing what it stood on for the level to set again
|
|
25496
|
-
|
|
26301
|
+
// ground is what it stood on last frame, which a height map keeps it on going down a slope
|
|
26302
|
+
function render3DCollideLevel(o, oldPos, ground)
|
|
25497
26303
|
{
|
|
25498
|
-
const ground = o.groundObject;
|
|
25499
|
-
o.groundObject = undefined;
|
|
25500
26304
|
for (const level of render3DLevel)
|
|
25501
26305
|
level.destroyed || level.levelCollide3D(o, oldPos, level === ground);
|
|
25502
26306
|
}
|
|
@@ -25513,7 +26317,23 @@ function render3DSolidShape(o)
|
|
|
25513
26317
|
const kx = abs(k.x), ky = abs(k.y), kz = abs(k.z);
|
|
25514
26318
|
if (o.collideAsSphere3D)
|
|
25515
26319
|
return {pos: o.pos3D.copy(), radius: max(s.x, s.y, s.z) / 2 * max(kx, ky, kz)};
|
|
25516
|
-
|
|
26320
|
+
// a turned box has its axes, a solid is never a child so its rotation is the world's; an upright one has none, and
|
|
26321
|
+
// neither does a sprite, whose rotation turns how it faces the camera, not its box
|
|
26322
|
+
const axes = !render3DIsSprite(o) && isTurned3D(o.rotation3D) ? render3DSolidAxes(o) : undefined;
|
|
26323
|
+
return {pos: o.pos3D.copy(), size: vec3(s.x * kx, s.y * ky, s.z * kz), axes};
|
|
26324
|
+
}
|
|
26325
|
+
|
|
26326
|
+
// a turned solid's three axes, worked out when its rotation changes and kept until then, so the pairs it is tested
|
|
26327
|
+
// against each frame do not each work them out again
|
|
26328
|
+
const render3DSolidAxesCache = new WeakMap;
|
|
26329
|
+
function render3DSolidAxes(o)
|
|
26330
|
+
{
|
|
26331
|
+
const r = o.rotation3D, kept = render3DSolidAxesCache.get(o);
|
|
26332
|
+
if (kept && kept.x === r.x && kept.y === r.y && kept.z === r.z)
|
|
26333
|
+
return kept.axes;
|
|
26334
|
+
const axes = boxAxes3D(r);
|
|
26335
|
+
render3DSolidAxesCache.set(o, {x: r.x, y: r.y, z: r.z, axes});
|
|
26336
|
+
return axes;
|
|
25517
26337
|
}
|
|
25518
26338
|
|
|
25519
26339
|
// how far a solid shape can reach from its own center, for a quick reject before the exact test
|
|
@@ -25531,15 +26351,30 @@ function render3DSolidReach(o)
|
|
|
25531
26351
|
// what it takes to move shape a clear of shape b, whichever pair of shapes they are, or undefined for no touch
|
|
25532
26352
|
function render3DSolidPush(a, b)
|
|
25533
26353
|
{
|
|
26354
|
+
// a turned box is tested by its kept axes, the upright pairs as they always were
|
|
26355
|
+
const sphereBox = (sphere, box)=> box.axes ? collideSphereOrientedBox3D(sphere.pos, sphere.radius, box.pos,
|
|
26356
|
+
box.size, box.axes) : collideSphereBox(sphere.pos, sphere.radius, box.pos, box.size);
|
|
25534
26357
|
if (!a.size) // a is a sphere
|
|
25535
|
-
return b.size ?
|
|
25536
|
-
: collideSphereSphere(a.pos, a.radius, b.pos, b.radius);
|
|
26358
|
+
return b.size ? sphereBox(a, b) : collideSphereSphere(a.pos, a.radius, b.pos, b.radius);
|
|
25537
26359
|
if (!b.size) // only b is, so push b out of a and turn it around
|
|
25538
26360
|
{
|
|
25539
|
-
const push =
|
|
26361
|
+
const push = sphereBox(b, a);
|
|
25540
26362
|
return push && push.scale(-1);
|
|
25541
26363
|
}
|
|
25542
|
-
return
|
|
26364
|
+
return a.axes || b.axes ? collideOrientedBoxes3D(a.pos, a.size, a.axes ?? BOX_WORLD_AXES, b.pos, b.size,
|
|
26365
|
+
b.axes ?? BOX_WORLD_AXES) : collideBoxBox3D(a.pos, a.size, b.pos, b.size);
|
|
26366
|
+
}
|
|
26367
|
+
|
|
26368
|
+
// a sprite, a tile with no mesh, which faces the camera however it is turned
|
|
26369
|
+
const render3DIsSprite = (o)=> !o.mesh && !!o.tileInfo;
|
|
26370
|
+
|
|
26371
|
+
// whether a push leaves a box by one of its faces, along one of its axes, not by an edge or a corner; a sphere has
|
|
26372
|
+
// no face to stand on
|
|
26373
|
+
function render3DOnFace(push, shape)
|
|
26374
|
+
{
|
|
26375
|
+
if (!shape.size) return false;
|
|
26376
|
+
const n = push.normalize(), axes = shape.axes ?? BOX_WORLD_AXES;
|
|
26377
|
+
return max(abs(n.dot(axes[0])), abs(n.dot(axes[1])), abs(n.dot(axes[2]))) > 1 - 1e-6;
|
|
25543
26378
|
}
|
|
25544
26379
|
|
|
25545
26380
|
// push a solid object out of the solids before it in the engine's list of them, so each pair is resolved once:
|
|
@@ -25565,7 +26400,8 @@ function render3DCollideSolid(a)
|
|
|
25565
26400
|
if (dx*dx + dy*dy + dz*dz > reach*reach)
|
|
25566
26401
|
continue;
|
|
25567
26402
|
|
|
25568
|
-
const
|
|
26403
|
+
const shapeB = render3DSolidShape(b);
|
|
26404
|
+
let push = render3DSolidPush(shapeA, shapeB);
|
|
25569
26405
|
if (!push) continue;
|
|
25570
26406
|
|
|
25571
26407
|
// both objects hear about it, and either one can take the touch over
|
|
@@ -25573,6 +26409,19 @@ function render3DCollideSolid(a)
|
|
|
25573
26409
|
const resolveB = b.collideWithObject(a, push.scale(-1));
|
|
25574
26410
|
if (!resolveA || !resolveB) continue;
|
|
25575
26411
|
|
|
26412
|
+
// standing: resting on a box's face within the upper one's groundAngle of level holds it there, the push
|
|
26413
|
+
// turned straight up, as far as it takes to leave the surface, so it does not creep down a ramp; only what
|
|
26414
|
+
// moves stands, and a sphere, an edge or a corner is nothing to stand on, what rests there rolls off
|
|
26415
|
+
const lengthSquared = push.lengthSquared(), up = push.y / lengthSquared ** .5;
|
|
26416
|
+
const aStands = up > 0 && a.mass && up >= cos(a.groundAngle) && render3DOnFace(push, shapeB);
|
|
26417
|
+
const bStands = up < 0 && b.mass && -up >= cos(b.groundAngle) && render3DOnFace(push, shapeA);
|
|
26418
|
+
if (aStands || bStands)
|
|
26419
|
+
{
|
|
26420
|
+
aStands ? a.groundObject = b : b.groundObject = a;
|
|
26421
|
+
if (push.x || push.z) // one straight up already is used as it is
|
|
26422
|
+
push = vec3(0, lengthSquared / push.y, 0);
|
|
26423
|
+
}
|
|
26424
|
+
|
|
25576
26425
|
// heavier objects move less, mass 0 stays put; then bounce apart when moving toward each other
|
|
25577
26426
|
const total = a.mass + b.mass;
|
|
25578
26427
|
const weightA = !a.mass ? 0 : !b.mass ? 1 : b.mass / total;
|
|
@@ -25593,8 +26442,8 @@ function render3DCollideSolid(a)
|
|
|
25593
26442
|
|
|
25594
26443
|
/**
|
|
25595
26444
|
* Collect the EngineObject3D objects whose boxes overlap a sphere or a box, the 3D twin of engineObjectsCollect
|
|
25596
|
-
* -
|
|
25597
|
-
* and are never collected
|
|
26445
|
+
* - Each object is its size3D box at its world position, turned as it is turned; lights, emitters and trails have no
|
|
26446
|
+
* size and are never collected
|
|
25598
26447
|
* @param {Vector3} pos - Center of the area
|
|
25599
26448
|
* @param {Vector3|number} size - Diameter of a sphere if a number, 0 for a point, full size of a box if a Vector3
|
|
25600
26449
|
* @param {Array<EngineObject>} [objects] - Defaults to every object
|
|
@@ -25616,9 +26465,27 @@ function engineObjectsCollect3D(pos, size, objects=engineObjects, testCenters=fa
|
|
|
25616
26465
|
if (!(s.x || s.y || s.z)) continue;
|
|
25617
26466
|
const center = vec3(m[12], m[13], m[14]);
|
|
25618
26467
|
const worldSize = testCenters ? vec3() : render3DWorldSize(o, m);
|
|
26468
|
+
// a turned box is tested as turned, its axes read off the matrix, its columns with the scale taken out
|
|
26469
|
+
const turned = !testCenters && !render3DIsSprite(o) && (m[1] || m[2] || m[4] || m[6] || m[8] || m[9]);
|
|
26470
|
+
const axis = (i)=> vec3(m[i], m[i+1], m[i+2]).normalize();
|
|
26471
|
+
let axes = turned ? [axis(0), axis(4), axis(8)] : undefined;
|
|
26472
|
+
if (axes && (abs(axes[0].dot(axes[1])) > 1e-6 || abs(axes[0].dot(axes[2])) > 1e-6 ||
|
|
26473
|
+
abs(axes[1].dot(axes[2])) > 1e-6))
|
|
26474
|
+
{
|
|
26475
|
+
// a turned child of an unevenly scaled parent is sheared, its edges no longer square, which the box
|
|
26476
|
+
// tests do not take; the upright box around it stands in, which may take a little more but never misses
|
|
26477
|
+
const e = axes.map((a, i)=> a.scale(i ? i > 1 ? worldSize.z / 2 : worldSize.y / 2 : worldSize.x / 2));
|
|
26478
|
+
const reach = (c)=> 2 * (abs(e[0][c]) + abs(e[1][c]) + abs(e[2][c]));
|
|
26479
|
+
worldSize.set(reach('x'), reach('y'), reach('z'));
|
|
26480
|
+
axes = undefined;
|
|
26481
|
+
}
|
|
25619
26482
|
let hit;
|
|
25620
|
-
if (box)
|
|
26483
|
+
if (box && axes)
|
|
26484
|
+
hit = !!collideOrientedBoxes3D(pos, box, BOX_WORLD_AXES, center, worldSize, axes);
|
|
26485
|
+
else if (box)
|
|
25621
26486
|
hit = isOverlapping3D(pos, box, center, worldSize);
|
|
26487
|
+
else if (axes)
|
|
26488
|
+
hit = !!collideSphereOrientedBox3D(pos, radiusSquared ** .5, center, worldSize, axes);
|
|
25622
26489
|
else
|
|
25623
26490
|
{
|
|
25624
26491
|
// a sphere against the nearest point of the box
|
|
@@ -25941,10 +26808,53 @@ class Light3D extends EngineObject3D
|
|
|
25941
26808
|
/** @property {boolean} - Shine from far away, from its position toward the origin, instead of out from its
|
|
25942
26809
|
* position with a falloff; DirectionalLight3D sets it */
|
|
25943
26810
|
this.directional = false;
|
|
26811
|
+
/** @property {number} - Size of a soft hazy glow drawn over the light, like a lamp at night, 0 for none; it
|
|
26812
|
+
* is added onto what is behind it, and what is in front of the light hides it */
|
|
26813
|
+
this.glow = 0;
|
|
26814
|
+
/** @property {number} - How fast the glow fades from its middle: 1 by default, .5 a wide haze, 2 a tight
|
|
26815
|
+
* bright core */
|
|
26816
|
+
this.glowFalloff = 1;
|
|
26817
|
+
this.additive = true; // the glow is added on, in the transparent stage; a light with none draws nothing
|
|
25944
26818
|
}
|
|
25945
26819
|
|
|
25946
|
-
/**
|
|
25947
|
-
|
|
26820
|
+
/** Draw the glow, a quad facing the camera with a soft round glow on it, pulled toward the camera by half its
|
|
26821
|
+
* size so a lamp at the light does not cut into it, but never past the camera */
|
|
26822
|
+
render3D()
|
|
26823
|
+
{
|
|
26824
|
+
if (!(this.glow > 0) || this.directional) return;
|
|
26825
|
+
const r = render3D, c = this.color, pos = render3DObjectMatrix(this).getTranslation();
|
|
26826
|
+
const toCamera = r.camera.pos.subtract(pos), distance = toCamera.length();
|
|
26827
|
+
const at = distance ? pos.add(toCamera.scale(min(this.glow, distance) / 2 / distance)) : pos;
|
|
26828
|
+
const color = rgb(c.r, c.g, c.b, c.a * min(this.intensity, 1));
|
|
26829
|
+
r.drawBillboard(at, vec2(this.glow), render3DGlow(this.glowFalloff), color);
|
|
26830
|
+
}
|
|
26831
|
+
}
|
|
26832
|
+
|
|
26833
|
+
// how strong a light's glow is at a distance from its middle, 0 there to 1 at the edge: a bell, full in the middle
|
|
26834
|
+
// and nothing at the edge, fading faster the higher the falloff
|
|
26835
|
+
function render3DGlowAlpha(r, falloff)
|
|
26836
|
+
{
|
|
26837
|
+
const k = 3.5 * falloff, edge = Math.exp(-k);
|
|
26838
|
+
return (Math.exp(-k * r * r) - edge) / (1 - edge);
|
|
26839
|
+
}
|
|
26840
|
+
|
|
26841
|
+
// the soft round glow of the lights, one texture for each falloff, rounded to a tenth so a changing falloff makes
|
|
26842
|
+
// only a few, each made once from a canvas; undefined headless or without a canvas
|
|
26843
|
+
const render3DGlowTextures = new Map;
|
|
26844
|
+
function render3DGlow(falloff=1)
|
|
26845
|
+
{
|
|
26846
|
+
ASSERT(isNumber(falloff) && falloff > 0, 'glowFalloff must be a number above 0');
|
|
26847
|
+
const key = max(round(falloff * 10), 1) / 10;
|
|
26848
|
+
let texture = render3DGlowTextures.get(key);
|
|
26849
|
+
if (texture || !glContext || typeof OffscreenCanvas == 'undefined') return texture;
|
|
26850
|
+
const size = 64, context = createCanvasContext(size), steps = 16;
|
|
26851
|
+
const gradient = context.createRadialGradient(size / 2, size / 2, 0, size / 2, size / 2, size / 2);
|
|
26852
|
+
for (let i = 0; i <= steps; ++i)
|
|
26853
|
+
gradient.addColorStop(i / steps, 'rgba(255,255,255,' + render3DGlowAlpha(i / steps, key).toFixed(4) + ')');
|
|
26854
|
+
context.fillStyle = gradient;
|
|
26855
|
+
context.fillRect(0, 0, size, size);
|
|
26856
|
+
render3DGlowTextures.set(key, texture = new TextureInfo(context.canvas));
|
|
26857
|
+
return texture;
|
|
25948
26858
|
}
|
|
25949
26859
|
|
|
25950
26860
|
///////////////////////////////////////////////////////////////////////////////
|
|
@@ -26010,6 +26920,54 @@ function render3DSoftDot()
|
|
|
26010
26920
|
return render3DSoftDotTexture = new TextureInfo(context.canvas);
|
|
26011
26921
|
}
|
|
26012
26922
|
|
|
26923
|
+
/** Make a normal map from a height at each pixel, for bumps and grooves with no image file: the slope at each pixel
|
|
26924
|
+
* from its neighbors' heights, taken around the edges so the map tiles; set it as an object's normalMap
|
|
26925
|
+
* @param {Vector2} size - In pixels
|
|
26926
|
+
* @param {function(number, number): number} heightFunction - The height 0 to 1 at a pixel, x across and y down
|
|
26927
|
+
* @param {number} [strength] - How steep the slopes are: a height change of 1 over one pixel leans the normal
|
|
26928
|
+
* by strength
|
|
26929
|
+
* @return {TextureInfo} - Wraps; headless it has no image
|
|
26930
|
+
* @memberof Render3D */
|
|
26931
|
+
function normalMapFromHeight(size, heightFunction, strength=1)
|
|
26932
|
+
{
|
|
26933
|
+
ASSERT(isVector2(size) && size.x >= 1 && size.y >= 1, 'normalMapFromHeight size must be a Vector2 of pixels');
|
|
26934
|
+
const width = size.x | 0, height = size.y | 0;
|
|
26935
|
+
if (typeof OffscreenCanvas == 'undefined')
|
|
26936
|
+
{
|
|
26937
|
+
// headless, nothing to draw into; the right size so what reads it still works
|
|
26938
|
+
const textureInfo = new TextureInfo(undefined, false, true);
|
|
26939
|
+
textureInfo.size = vec2(width, height), textureInfo.sizeInverse = vec2(1 / width, 1 / height);
|
|
26940
|
+
return textureInfo;
|
|
26941
|
+
}
|
|
26942
|
+
const context = createCanvasContext(width, height);
|
|
26943
|
+
const pixels = render3DNormalMapPixels(width, height, heightFunction, strength);
|
|
26944
|
+
context.putImageData(new ImageData(pixels, width, height), 0, 0);
|
|
26945
|
+
return new TextureInfo(context.canvas, true, true);
|
|
26946
|
+
}
|
|
26947
|
+
|
|
26948
|
+
// the pixels of a normal map made from heights, rgba with the top row first: each normal leans away from the uphill
|
|
26949
|
+
// side, green up the image as OpenGL has it, and a flat height is (128, 128, 255); rounded before they are stored,
|
|
26950
|
+
// since the clamped array would round a flat 127.5 to even
|
|
26951
|
+
function render3DNormalMapPixels(width, height, heightFunction, strength)
|
|
26952
|
+
{
|
|
26953
|
+
const heights = new Float32Array(width * height), pixels = new Uint8ClampedArray(width * height * 4);
|
|
26954
|
+
for (let y = 0; y < height; ++y)
|
|
26955
|
+
for (let x = 0; x < width; ++x)
|
|
26956
|
+
heights[y * width + x] = heightFunction(x, y);
|
|
26957
|
+
const h = (x, y)=> heights[mod(y, height) * width + mod(x, width)];
|
|
26958
|
+
for (let y = 0; y < height; ++y)
|
|
26959
|
+
for (let x = 0; x < width; ++x)
|
|
26960
|
+
{
|
|
26961
|
+
const dx = (h(x+1, y) - h(x-1, y)) / 2 * strength, dy = (h(x, y+1) - h(x, y-1)) / 2 * strength;
|
|
26962
|
+
const k = 1 / hypot(dx, dy, 1), i = (y * width + x) * 4;
|
|
26963
|
+
pixels[i] = round((-dx * k * .5 + .5) * 255);
|
|
26964
|
+
pixels[i+1] = round((dy * k * .5 + .5) * 255);
|
|
26965
|
+
pixels[i+2] = round((k * .5 + .5) * 255);
|
|
26966
|
+
pixels[i+3] = 255;
|
|
26967
|
+
}
|
|
26968
|
+
return pixels;
|
|
26969
|
+
}
|
|
26970
|
+
|
|
26013
26971
|
///////////////////////////////////////////////////////////////////////////////
|
|
26014
26972
|
// The rest of the shape builders: buildLathe, buildSphere, buildBox, buildGrid and buildSky live with the
|
|
26015
26973
|
// renderer, since it hands those out itself
|
|
@@ -26546,6 +27504,22 @@ class HeightMap extends EngineObject3D
|
|
|
26546
27504
|
* @ignore */
|
|
26547
27505
|
levelRaycast3D(ray) { return this.raycast(ray); }
|
|
26548
27506
|
|
|
27507
|
+
/** Where a short move goes under the surface, for a particle's move in one frame: the part of the move made
|
|
27508
|
+
* before it, 0 to 1, and the surface normal there; undefined when it stays above, starts under, or is off the map
|
|
27509
|
+
* @param {Vector3} from
|
|
27510
|
+
* @param {Vector3} to
|
|
27511
|
+
* @return {{distance: number, normal: Vector3}|undefined}
|
|
27512
|
+
* @ignore */
|
|
27513
|
+
levelSegment3D(from, to)
|
|
27514
|
+
{
|
|
27515
|
+
const m = this.pos3D, size = this.mapSize;
|
|
27516
|
+
if (abs(to.x - m.x) > size.x / 2 || abs(to.z - m.z) > size.y / 2) return; // off the map
|
|
27517
|
+
const a = from.y - this.getHeight(from.x, from.z), b = to.y - this.getHeight(to.x, to.z);
|
|
27518
|
+
if (a < 0 || b >= 0) return; // above all the way, or under from the start
|
|
27519
|
+
const distance = a / (a - b);
|
|
27520
|
+
return {distance, normal: this.getNormal(from.x + (to.x - from.x) * distance, from.z + (to.z - from.z) * distance)};
|
|
27521
|
+
}
|
|
27522
|
+
|
|
26549
27523
|
/** Keep an object above the ground, called by the engine for each object with collideLevel
|
|
26550
27524
|
* @param {EngineObject3D} o
|
|
26551
27525
|
* @param {Vector3} oldPos - Where it was before it moved
|
|
@@ -26734,7 +27708,7 @@ class FirstPersonCamera3D extends EngineObject3D
|
|
|
26734
27708
|
/** @property {boolean} - Capture the mouse on a click, so looking needs no button held */
|
|
26735
27709
|
this.lockPointer = true;
|
|
26736
27710
|
/** @property {number} - Speed of a jump in world units a frame, 0 for none; Space or gamepad button 0 jumps
|
|
26737
|
-
* while it stands on something, a height map
|
|
27711
|
+
* while it stands on something, a height map, a voxel map or a solid, see groundObject */
|
|
26738
27712
|
this.jumpSpeed = 0;
|
|
26739
27713
|
/** @property {number} - How far above its position the eye is, in its own space, so with a size3D the eye can
|
|
26740
27714
|
* sit toward the top of the body instead of its middle; keep it under half the body's height, or the eye is
|
|
@@ -26805,6 +27779,49 @@ class FirstPersonCamera3D extends EngineObject3D
|
|
|
26805
27779
|
const RENDER3D_PARTICLE_FLOATS = 21;
|
|
26806
27780
|
// the position, color and size of the particle being drawn, shared by every emitter so the draw loop makes no objects
|
|
26807
27781
|
const render3DParticlePos = vec3(), render3DParticleColor = new Color, render3DParticleSize = vec2();
|
|
27782
|
+
// where a particle was and is, for the level collision
|
|
27783
|
+
const render3DParticleFrom = vec3(), render3DParticleTo = vec3();
|
|
27784
|
+
|
|
27785
|
+
// the emitters inside a particle callback now, whose nested calls each get a view of their own
|
|
27786
|
+
const render3DParticlesCalling = new Set;
|
|
27787
|
+
|
|
27788
|
+
// a particle as a ParticleEmitter3D's callbacks see it, set from the particle for each call
|
|
27789
|
+
/** @param {ParticleEmitter3D} emitter
|
|
27790
|
+
* @return {Particle3D} */
|
|
27791
|
+
function render3DParticleView(emitter)
|
|
27792
|
+
{
|
|
27793
|
+
return {emitter, pos: vec3(), velocity: vec3(), age: 0, lifeTime: 0, destroyed: false,
|
|
27794
|
+
destroy() { this.destroyed = true; }};
|
|
27795
|
+
}
|
|
27796
|
+
|
|
27797
|
+
/**
|
|
27798
|
+
* A particle as a ParticleEmitter3D's callbacks see it: one object the emitter reuses, set from the particle for each
|
|
27799
|
+
* call and written back after it, so copy what you keep
|
|
27800
|
+
* @typedef {Object} Particle3D
|
|
27801
|
+
* @property {ParticleEmitter3D} emitter - The emitter it is in
|
|
27802
|
+
* @property {Vector3} pos - Where it is, change it to move it
|
|
27803
|
+
* @property {Vector3} velocity - How far it moves each frame
|
|
27804
|
+
* @property {number} age - Seconds it has lived
|
|
27805
|
+
* @property {number} lifeTime - Seconds it lives
|
|
27806
|
+
* @property {boolean} destroyed - Set by destroy
|
|
27807
|
+
* @property {function(): void} destroy - End it this update, the destroy callback gets it
|
|
27808
|
+
* @memberof Render3D
|
|
27809
|
+
*/
|
|
27810
|
+
|
|
27811
|
+
/**
|
|
27812
|
+
* @callback Particle3DCallback - A function a ParticleEmitter3D calls with one of its particles
|
|
27813
|
+
* @param {Particle3D} particle
|
|
27814
|
+
* @memberof Render3D
|
|
27815
|
+
*/
|
|
27816
|
+
|
|
27817
|
+
/**
|
|
27818
|
+
* @callback Particle3DCollideCallback - Decides whether a particle stops where it hits the level, a filter as in 2D
|
|
27819
|
+
* @param {Particle3D} particle
|
|
27820
|
+
* @param {EngineObject3D} level - The HeightMap or VoxelMap it hit
|
|
27821
|
+
* @param {Vector3} pos - Where it hit
|
|
27822
|
+
* @return {boolean|void} - true to stop it there; a callback that returns nothing lets it pass through
|
|
27823
|
+
* @memberof Render3D
|
|
27824
|
+
*/
|
|
26808
27825
|
|
|
26809
27826
|
/**
|
|
26810
27827
|
* ParticleEmitter3D - Spawns camera facing particles, the 3D twin of ParticleEmitter
|
|
@@ -26818,6 +27835,10 @@ const render3DParticlePos = vec3(), render3DParticleColor = new Color, render3DP
|
|
|
26818
27835
|
* - gravity here is its own number added to velocity y each frame: it is neither the engine's 2D
|
|
26819
27836
|
* gravity nor render3D.gravity, so an effect keeps its own fall wherever it is used
|
|
26820
27837
|
* - An emitter with an emitTime destroys itself once its last particle is gone, like the 2D emitter
|
|
27838
|
+
* - Callbacks as the 2D emitter's: particleCreateCallback, particleUpdateCallback, particleCollideCallback and
|
|
27839
|
+
* particleDestroyCallback, each given a Particle3D, one object the emitter reuses for every particle and call
|
|
27840
|
+
* - collideLevel, off by default, has particles hit the height maps and voxel maps, bouncing by restitution and
|
|
27841
|
+
* sliding by friction
|
|
26821
27842
|
* @extends EngineObject3D
|
|
26822
27843
|
* @memberof Render3D
|
|
26823
27844
|
* @example
|
|
@@ -26890,7 +27911,8 @@ class ParticleEmitter3D extends EngineObject3D
|
|
|
26890
27911
|
this.randomness = randomness;
|
|
26891
27912
|
/** @property {boolean} - Additive blending */
|
|
26892
27913
|
this.additive = additive;
|
|
26893
|
-
/** @property {number} - Seconds of each particle's path to draw as a ribbon behind it, 0 draws billboards
|
|
27914
|
+
/** @property {number} - Seconds of each particle's path to draw as a ribbon behind it, 0 draws billboards;
|
|
27915
|
+
* with engineVariableStep it keeps trailTime * frameRate updates of path */
|
|
26894
27916
|
this.trailTime = 0;
|
|
26895
27917
|
/** @property {number} - Radians per frame each particle turns in the camera plane, either way; 0 is no spin */
|
|
26896
27918
|
this.angleSpeed = 0;
|
|
@@ -26906,6 +27928,25 @@ class ParticleEmitter3D extends EngineObject3D
|
|
|
26906
27928
|
this.trailData = undefined;
|
|
26907
27929
|
/** @property {number} - Trail points kept per particle, from trailTime */
|
|
26908
27930
|
this.trailMax = 0;
|
|
27931
|
+
/** @property {boolean} - Particles hit the level, the height maps and voxel maps, bouncing by restitution and
|
|
27932
|
+
* sliding along by friction; off by default, it tests each particle's move against the level every frame */
|
|
27933
|
+
this.collideLevel = false;
|
|
27934
|
+
/** @property {Particle3DCallback|undefined} - Called with each particle as it is made
|
|
27935
|
+
* @type {Particle3DCallback|undefined} */
|
|
27936
|
+
this.particleCreateCallback = undefined;
|
|
27937
|
+
/** @property {Particle3DCallback|undefined} - Called with each particle each update, after it moves
|
|
27938
|
+
* @type {Particle3DCallback|undefined} */
|
|
27939
|
+
this.particleUpdateCallback = undefined;
|
|
27940
|
+
/** @property {Particle3DCollideCallback|undefined} - Decides if a particle stops where it hits the level,
|
|
27941
|
+
* with collideLevel on; a callback that returns nothing lets it through
|
|
27942
|
+
* @type {Particle3DCollideCallback|undefined} */
|
|
27943
|
+
this.particleCollideCallback = undefined;
|
|
27944
|
+
/** @property {Particle3DCallback|undefined} - Called with each particle as it goes, its life over or destroyed
|
|
27945
|
+
* @type {Particle3DCallback|undefined} */
|
|
27946
|
+
this.particleDestroyCallback = undefined;
|
|
27947
|
+
/** @property {Particle3D} - The particle the callbacks get, one object for every particle and call
|
|
27948
|
+
* @type {Particle3D} */
|
|
27949
|
+
this.particleView = render3DParticleView(this);
|
|
26909
27950
|
/** @property {Vector3|undefined} - Where the emitter was at its last update, for when its parent is destroyed
|
|
26910
27951
|
* @type {Vector3|undefined} */
|
|
26911
27952
|
this.worldPos3D = undefined;
|
|
@@ -26935,8 +27976,9 @@ class ParticleEmitter3D extends EngineObject3D
|
|
|
26935
27976
|
else if (!this.particleCount)
|
|
26936
27977
|
this.destroy();
|
|
26937
27978
|
|
|
26938
|
-
// the trail storage follows trailTime, a change starts every trail over
|
|
26939
|
-
|
|
27979
|
+
// the trail storage follows trailTime at the fixed rate, a change starts every trail over, so a varying
|
|
27980
|
+
// timeDelta can not; with engineVariableStep a trail keeps that many updates
|
|
27981
|
+
const trailMax = this.trailTime ? max(1, round(this.trailTime * frameRate)) : 0;
|
|
26940
27982
|
if (trailMax !== this.trailMax)
|
|
26941
27983
|
{
|
|
26942
27984
|
this.trailMax = trailMax;
|
|
@@ -26946,8 +27988,13 @@ class ParticleEmitter3D extends EngineObject3D
|
|
|
26946
27988
|
}
|
|
26947
27989
|
|
|
26948
27990
|
// move the particles and drop the dead ones, all in the typed array: this runs for every particle every frame
|
|
26949
|
-
|
|
27991
|
+
// a callback can emit, which grows the arrays when they are full, so they are read again after each one
|
|
27992
|
+
const F = RENDER3D_PARTICLE_FLOATS;
|
|
27993
|
+
let data = this.particleData, trail = this.trailData;
|
|
27994
|
+
const reread = ()=> { data = this.particleData, trail = this.trailData; };
|
|
26950
27995
|
const damping = this.damping, gravity = this.gravity * scale, angleDamping = this.angleDamping; // a bigger effect has to fall faster to keep the same arc
|
|
27996
|
+
const collideLevel = this.collideLevel && render3DLevel.length;
|
|
27997
|
+
const updateCallback = this.particleUpdateCallback, destroyCallback = this.particleDestroyCallback;
|
|
26951
27998
|
for (let i = this.particleCount; i--;)
|
|
26952
27999
|
{
|
|
26953
28000
|
// damped first and gravity added after, the order the 2D particle uses, so the same
|
|
@@ -26956,6 +28003,7 @@ class ParticleEmitter3D extends EngineObject3D
|
|
|
26956
28003
|
const vx = data[k+3] *= damping, vy = data[k+4] = data[k+4] * damping + gravity, vz = data[k+5] *= damping;
|
|
26957
28004
|
data[k] += vx, data[k+1] += vy, data[k+2] += vz;
|
|
26958
28005
|
data[k+18] += data[k+19] *= angleDamping;
|
|
28006
|
+
collideLevel && (this.particleCollide(k, data[k] - vx, data[k+1] - vy, data[k+2] - vz), reread());
|
|
26959
28007
|
const t = i * trailMax * 3;
|
|
26960
28008
|
if (trailMax)
|
|
26961
28009
|
{
|
|
@@ -26967,9 +28015,11 @@ class ParticleEmitter3D extends EngineObject3D
|
|
|
26967
28015
|
trail[j] = data[k], trail[j+1] = data[k+1], trail[j+2] = data[k+2];
|
|
26968
28016
|
data[k+20] = n + 1;
|
|
26969
28017
|
}
|
|
28018
|
+
updateCallback && (this.particleCall(updateCallback, k), reread());
|
|
26970
28019
|
if ((data[k+17] += timeDelta) >= data[k+16])
|
|
26971
28020
|
{
|
|
26972
28021
|
// dead: the last particle takes its slot, trail and all, order does not matter
|
|
28022
|
+
destroyCallback && (this.particleCall(destroyCallback, k), reread());
|
|
26973
28023
|
const last = --this.particleCount, kl = last * F;
|
|
26974
28024
|
if (i !== last)
|
|
26975
28025
|
{
|
|
@@ -27036,6 +28086,60 @@ class ParticleEmitter3D extends EngineObject3D
|
|
|
27036
28086
|
data[k+18] = this.angleSpeed ? rand(2*PI) : 0;
|
|
27037
28087
|
data[k+19] = this.angleSpeed ? this.angleSpeed * random() * randSign() : 0;
|
|
27038
28088
|
data[k+20] = 0; // trail points
|
|
28089
|
+
this.particleCreateCallback && this.particleCall(this.particleCreateCallback, k);
|
|
28090
|
+
}
|
|
28091
|
+
|
|
28092
|
+
// call a particle callback with the particle at k: the emitter's one Particle3D is set from its numbers, and
|
|
28093
|
+
// what the callback changes is written back; a particle it destroys has lived its life, the update removes it
|
|
28094
|
+
particleCall(callback, k, level, pos)
|
|
28095
|
+
{
|
|
28096
|
+
// a callback that emits runs the create callback inside itself, which gets a view of its own so this one
|
|
28097
|
+
// keeps its particle; what the callback changes is written to the arrays as they are after it, since an
|
|
28098
|
+
// emit into a full emitter grows them
|
|
28099
|
+
const nested = render3DParticlesCalling.has(this);
|
|
28100
|
+
const view = nested ? render3DParticleView(this) : this.particleView, data = this.particleData;
|
|
28101
|
+
view.pos.set(data[k], data[k+1], data[k+2]);
|
|
28102
|
+
view.velocity.set(data[k+3], data[k+4], data[k+5]);
|
|
28103
|
+
view.age = data[k+17], view.lifeTime = data[k+16], view.destroyed = false;
|
|
28104
|
+
render3DParticlesCalling.add(this);
|
|
28105
|
+
let result;
|
|
28106
|
+
try { result = callback(view, level, pos); }
|
|
28107
|
+
finally { nested || render3DParticlesCalling.delete(this); }
|
|
28108
|
+
const out = this.particleData;
|
|
28109
|
+
out[k] = view.pos.x, out[k+1] = view.pos.y, out[k+2] = view.pos.z;
|
|
28110
|
+
out[k+3] = view.velocity.x, out[k+4] = view.velocity.y, out[k+5] = view.velocity.z;
|
|
28111
|
+
if (view.destroyed)
|
|
28112
|
+
out[k+17] = max(out[k+17], out[k+16] - timeDelta); // aged past its life this update
|
|
28113
|
+
return result;
|
|
28114
|
+
}
|
|
28115
|
+
|
|
28116
|
+
// stop the particle at k where its last move went into the level, from where it was: back at the surface, a hair
|
|
28117
|
+
// off it, its speed into it turned around by restitution and its speed along it kept by friction, unless the
|
|
28118
|
+
// collide callback lets it through; one that starts inside is let go, as a 2D one is
|
|
28119
|
+
particleCollide(k, x, y, z)
|
|
28120
|
+
{
|
|
28121
|
+
let data = this.particleData;
|
|
28122
|
+
const from = render3DParticleFrom.set(x, y, z);
|
|
28123
|
+
const to = render3DParticleTo.set(data[k], data[k+1], data[k+2]);
|
|
28124
|
+
let hit, level;
|
|
28125
|
+
for (const l of render3DLevel)
|
|
28126
|
+
{
|
|
28127
|
+
const h = l.destroyed ? undefined : l.levelSegment3D(from, to);
|
|
28128
|
+
if (h && (!hit || h.distance < hit.distance))
|
|
28129
|
+
hit = h, level = l;
|
|
28130
|
+
}
|
|
28131
|
+
if (!hit) return;
|
|
28132
|
+
const point = from.lerp(to, hit.distance);
|
|
28133
|
+
const callback = this.particleCollideCallback;
|
|
28134
|
+
if (callback && (!this.particleCall(callback, k, level, point.copy()) || this.particleView.destroyed))
|
|
28135
|
+
return; // let through, or destroyed by the callback
|
|
28136
|
+
data = this.particleData; // as the callback left it, an emit may have grown it
|
|
28137
|
+
|
|
28138
|
+
const n = hit.normal, v = vec3(data[k+3], data[k+4], data[k+5]), into = n.scale(v.dot(n));
|
|
28139
|
+
const restitution = max(this.restitution, level.restitution), friction = max(this.friction, level.friction);
|
|
28140
|
+
const out = v.subtract(into).scale(friction).subtract(into.scale(restitution)), p = point.add(n.scale(1e-3));
|
|
28141
|
+
data[k] = p.x, data[k+1] = p.y, data[k+2] = p.z;
|
|
28142
|
+
data[k+3] = out.x, data[k+4] = out.y, data[k+5] = out.z;
|
|
27039
28143
|
}
|
|
27040
28144
|
|
|
27041
28145
|
/** Draw the particles, as flat squares or as streaks when trailTime is set
|
|
@@ -28190,6 +29294,18 @@ class VoxelMap extends EngineObject3D
|
|
|
28190
29294
|
* @ignore */
|
|
28191
29295
|
levelRaycast3D(ray) { return this.raycast(ray)?.distance; }
|
|
28192
29296
|
|
|
29297
|
+
/** Where a short move goes into a block, for a particle's move in one frame: the part of the move made before it,
|
|
29298
|
+
* 0 to 1, and the normal of the face it comes in through; undefined when it hits none, or starts inside one
|
|
29299
|
+
* @param {Vector3} from
|
|
29300
|
+
* @param {Vector3} to
|
|
29301
|
+
* @return {{distance: number, normal: Vector3}|undefined}
|
|
29302
|
+
* @ignore */
|
|
29303
|
+
levelSegment3D(from, to)
|
|
29304
|
+
{
|
|
29305
|
+
const hit = this.raycast(new Ray3D(from, to.subtract(from)), 1);
|
|
29306
|
+
return hit && hit.distance > 0 ? {distance: hit.distance, normal: hit.normal} : undefined;
|
|
29307
|
+
}
|
|
29308
|
+
|
|
28193
29309
|
/** Keeps an eye on its placement, called automatically each frame */
|
|
28194
29310
|
update()
|
|
28195
29311
|
{
|
|
@@ -28226,6 +29342,194 @@ class VoxelMap extends EngineObject3D
|
|
|
28226
29342
|
}
|
|
28227
29343
|
}
|
|
28228
29344
|
|
|
29345
|
+
/**
|
|
29346
|
+
* LittleJS 3D Level Plugin
|
|
29347
|
+
* - A 3D level is a list of objects, each a type name, a position, and a rotation, scale and properties where
|
|
29348
|
+
* they are not the default, kept as plain JSON that can be written by hand
|
|
29349
|
+
* - level3DAddType names the types a game makes them from, as objectLayersAddType does in 2D
|
|
29350
|
+
* - level3DLoad makes every object of a level
|
|
29351
|
+
* - Box, Sphere, Cylinder and Light are built in, to block out and light a level with no code
|
|
29352
|
+
* - The 3D level editor, in debug builds, edits the level a game loaded
|
|
29353
|
+
* @namespace Level3D
|
|
29354
|
+
*/
|
|
29355
|
+
|
|
29356
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
29357
|
+
|
|
29358
|
+
const LEVEL3D_VERSION = 1; // the format a level's littlejs3D names
|
|
29359
|
+
|
|
29360
|
+
// the types a level's objects are made from, by name
|
|
29361
|
+
const level3DTypes = new Map;
|
|
29362
|
+
|
|
29363
|
+
// the scale each object had when it was made, the level's scale multiplies it
|
|
29364
|
+
const level3DBaseScale = new WeakMap;
|
|
29365
|
+
|
|
29366
|
+
/** Add a type of object, so level3DLoad makes one wherever a level has an object of that type
|
|
29367
|
+
* - The name is a string because minified builds rename classes
|
|
29368
|
+
* - A class, or any function with a prototype, is made with new make(pos3D, properties); an arrow function is
|
|
29369
|
+
* called as make(pos3D, properties), for what is not an object, like a player start
|
|
29370
|
+
* - properties is the defaults with the object's own values over them, and each of the type's own is also set on
|
|
29371
|
+
* what was made; a property the type has no default for is in properties and is not set
|
|
29372
|
+
* - Give a class a constructor of its own that takes the position: one that hands every argument on to
|
|
29373
|
+
* EngineObject3D would hand it the properties as its mesh
|
|
29374
|
+
* - An EngineObject3D then gets the object's rotation, and its scale times the scale it was made with
|
|
29375
|
+
* - Adding a name again replaces it, Box, Sphere, Cylinder and Light too
|
|
29376
|
+
* @param {string} name - The type the objects have in the level
|
|
29377
|
+
* @param {Function} make - A class made at each object's position, or a function called with it
|
|
29378
|
+
* @param {Object} [defaults] - Properties of each one made, the level editor shows inputs for them
|
|
29379
|
+
* @param {TileInfo} [tileInfo] - An icon for the level editor
|
|
29380
|
+
* @memberof Level3D
|
|
29381
|
+
* @example
|
|
29382
|
+
* level3DAddType('Crate', Crate, {health: 3});
|
|
29383
|
+
* level3DAddType('PlayerStart', (pos)=> playerStart = pos); */
|
|
29384
|
+
function level3DAddType(name, make, defaults={}, tileInfo)
|
|
29385
|
+
{
|
|
29386
|
+
ASSERT(isStringLike(name), 'object type name must be a string');
|
|
29387
|
+
ASSERT(typeof make === 'function', 'make must be a class or function');
|
|
29388
|
+
ASSERT(!!defaults && typeof defaults === 'object', 'defaults must be an object');
|
|
29389
|
+
level3DTypes.set(String(name), {make, defaults, tileInfo});
|
|
29390
|
+
}
|
|
29391
|
+
|
|
29392
|
+
/** Add a type that is a mesh and nothing more, a static prop with no class to write, for a built mesh or a model
|
|
29393
|
+
* - Each object has a color and a solid property, solid collides as the box around the mesh
|
|
29394
|
+
* @param {string} name - The type the objects have in the level
|
|
29395
|
+
* @param {Mesh} mesh - Shared by every object of the type
|
|
29396
|
+
* @param {TileInfo} [tileInfo] - Its texture
|
|
29397
|
+
* @param {Color} [color] - Its color, an object's own color property goes over it
|
|
29398
|
+
* @memberof Level3D
|
|
29399
|
+
* @example
|
|
29400
|
+
* level3DAddMesh('Tree', treeMesh, tile(4)); */
|
|
29401
|
+
function level3DAddMesh(name, mesh, tileInfo, color=WHITE)
|
|
29402
|
+
{
|
|
29403
|
+
ASSERT(mesh instanceof Mesh, 'mesh must be a Mesh');
|
|
29404
|
+
ASSERT(isColor(color), 'color must be a color');
|
|
29405
|
+
level3DAddType(name, function(pos, properties) { return level3DMakeShape(pos, properties, mesh, tileInfo); },
|
|
29406
|
+
{color, solid: false}, tileInfo);
|
|
29407
|
+
}
|
|
29408
|
+
|
|
29409
|
+
/** Make the objects of a level, each from the type added for its name
|
|
29410
|
+
* - The level is an object: {littlejs3D: 1, objects: [{id, type, pos: [x, y, z]}, ...]}, and {} is a new one
|
|
29411
|
+
* - rotation is pitch, yaw and roll in degrees, scale is a number for each axis, both left out when they are
|
|
29412
|
+
* the default, and properties holds what differs from the type's defaults
|
|
29413
|
+
* - A Color property is a #rrggbb or #rrggbbaa string, a Vector2 or Vector3 an array, as the default says
|
|
29414
|
+
* - An object whose type was not added is skipped, with a warning in debug builds
|
|
29415
|
+
* - What a file written by hand gets wrong uses the default: a value that is not of its default's type
|
|
29416
|
+
* - An object its type can not make is skipped with an error in debug builds, where asserts throw, and the rest
|
|
29417
|
+
* of the level is made
|
|
29418
|
+
* @param {Object} level - The level, the level editor edits this same object
|
|
29419
|
+
* @return {Array<any>} - What each object's type made, a function that made nothing is left out
|
|
29420
|
+
* @memberof Level3D */
|
|
29421
|
+
function level3DLoad(level)
|
|
29422
|
+
{
|
|
29423
|
+
ASSERT(!!level && typeof level === 'object', 'a level is an object, {} for a new one');
|
|
29424
|
+
ASSERT(!(level.littlejs3D > LEVEL3D_VERSION), 'the level was made by a newer LittleJS');
|
|
29425
|
+
editor3DLevelLoaded(level); // debug builds: the 3D editor takes the level, its autosaved edits go in first
|
|
29426
|
+
const made = [];
|
|
29427
|
+
for (const object of isArray(level.objects) ? level.objects : [])
|
|
29428
|
+
{
|
|
29429
|
+
if (!object || typeof object !== 'object') continue;
|
|
29430
|
+
const result = level3DMake(object);
|
|
29431
|
+
editor3DObjectMade(object, result); // debug builds link it for the 3D editor
|
|
29432
|
+
result && made.push(result);
|
|
29433
|
+
}
|
|
29434
|
+
return made;
|
|
29435
|
+
}
|
|
29436
|
+
|
|
29437
|
+
// a vec3 of an array of three numbers, as the file has them, or the fallback
|
|
29438
|
+
function level3DVector(value, fallback)
|
|
29439
|
+
{
|
|
29440
|
+
return isArray(value) && value.length === 3 && value.every((v)=> isNumber(v)) ?
|
|
29441
|
+
vec3(value[0], value[1], value[2]) : fallback;
|
|
29442
|
+
}
|
|
29443
|
+
|
|
29444
|
+
// the defaults of an object's type, a Color or vector copied for each object, then its own properties over them,
|
|
29445
|
+
// each read as its default is; one the file got wrong, a value not of its default's type, keeps the default, and
|
|
29446
|
+
// one the type has no default for is kept as it is
|
|
29447
|
+
function level3DProperties(type, object)
|
|
29448
|
+
{
|
|
29449
|
+
const properties = {}, own = object.properties;
|
|
29450
|
+
for (const [key, value] of Object.entries(type.defaults))
|
|
29451
|
+
properties[key] = value?.copy ? value.copy() : value;
|
|
29452
|
+
for (const [key, value] of Object.entries(own && typeof own === 'object' ? own : {}))
|
|
29453
|
+
{
|
|
29454
|
+
const d = type.defaults[key];
|
|
29455
|
+
if (isColor(d))
|
|
29456
|
+
/^#([0-9a-f]{6}|[0-9a-f]{8})$/i.test(value) && (properties[key] = new Color().setHex(value));
|
|
29457
|
+
else if (isVector3(d))
|
|
29458
|
+
properties[key] = level3DVector(value, properties[key]);
|
|
29459
|
+
else if (isVector2(d))
|
|
29460
|
+
isArray(value) && value.length === 2 && value.every((v)=> isNumber(v)) &&
|
|
29461
|
+
(properties[key] = vec2(value[0], value[1]));
|
|
29462
|
+
else if (d === undefined || typeof value === typeof d)
|
|
29463
|
+
properties[key] = value;
|
|
29464
|
+
}
|
|
29465
|
+
return properties;
|
|
29466
|
+
}
|
|
29467
|
+
|
|
29468
|
+
// make one object of a level from the type added for its name, undefined when there is no such type or it made
|
|
29469
|
+
// nothing
|
|
29470
|
+
function level3DMake(object)
|
|
29471
|
+
{
|
|
29472
|
+
const type = level3DTypes.get(object.type);
|
|
29473
|
+
if (!type)
|
|
29474
|
+
{
|
|
29475
|
+
debug && console.warn(`level3DLoad: no type added for ${object.type}, skipped`);
|
|
29476
|
+
return;
|
|
29477
|
+
}
|
|
29478
|
+
const pos = level3DVector(object.pos, vec3()), properties = level3DProperties(type, object);
|
|
29479
|
+
const {make} = type, count = engineObjects.length;
|
|
29480
|
+
let result;
|
|
29481
|
+
try { result = make.prototype ? new make(pos, properties) : make(pos, properties); }
|
|
29482
|
+
catch (error)
|
|
29483
|
+
{
|
|
29484
|
+
// a value the type can not be made with: in debug builds, where asserts throw, the object is skipped with
|
|
29485
|
+
// what it made so far, so the rest of the level loads and the level editor can put the value right
|
|
29486
|
+
if (!debug) throw error;
|
|
29487
|
+
console.error(`level3DLoad: ${object.type} ${object.id} could not be made, skipped:`, error);
|
|
29488
|
+
for (const o of engineObjects.slice(count))
|
|
29489
|
+
o.destroy();
|
|
29490
|
+
return;
|
|
29491
|
+
}
|
|
29492
|
+
if (!result || typeof result !== 'object') return;
|
|
29493
|
+
if (result instanceof EngineObject3D)
|
|
29494
|
+
{
|
|
29495
|
+
level3DBaseScale.set(result, result.scale3D.copy());
|
|
29496
|
+
result.rotation3D = level3DVector(object.rotation, vec3()).scale(PI / 180);
|
|
29497
|
+
result.scale3D = result.scale3D.multiply(level3DVector(object.scale, vec3(1)));
|
|
29498
|
+
}
|
|
29499
|
+
|
|
29500
|
+
// the type's own properties are set on it, one the type has no default for could be a field of the engine's
|
|
29501
|
+
for (const key in type.defaults)
|
|
29502
|
+
result[key] = properties[key];
|
|
29503
|
+
return result;
|
|
29504
|
+
}
|
|
29505
|
+
|
|
29506
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
29507
|
+
// the built-in types
|
|
29508
|
+
|
|
29509
|
+
// a static object of a mesh, with the color, tile and solid properties the built-in types have
|
|
29510
|
+
function level3DMakeShape(pos, properties, mesh, tileInfo, asSphere=false)
|
|
29511
|
+
{
|
|
29512
|
+
const o = new EngineObject3D(pos, mesh, properties.tile >= 0 ? tile(properties.tile) : tileInfo, properties.color);
|
|
29513
|
+
o.collideAsSphere3D = asSphere;
|
|
29514
|
+
properties.solid && o.setCollision();
|
|
29515
|
+
return o;
|
|
29516
|
+
}
|
|
29517
|
+
|
|
29518
|
+
// the cylinder all Cylinder objects share, built the first time one is made
|
|
29519
|
+
let level3DCylinderMesh;
|
|
29520
|
+
|
|
29521
|
+
level3DAddType('Box', function(pos, properties) { return level3DMakeShape(pos, properties, render3D.boxMesh); },
|
|
29522
|
+
{color: WHITE, tile: -1, solid: true});
|
|
29523
|
+
level3DAddType('Sphere', function(pos, properties)
|
|
29524
|
+
{ return level3DMakeShape(pos, properties, render3D.sphereMesh, undefined, true); },
|
|
29525
|
+
{color: WHITE, tile: -1, solid: true});
|
|
29526
|
+
level3DAddType('Cylinder', function(pos, properties)
|
|
29527
|
+
{ return level3DMakeShape(pos, properties, level3DCylinderMesh ||= buildCylinder()); },
|
|
29528
|
+
{color: WHITE, tile: -1, solid: true});
|
|
29529
|
+
level3DAddType('Light', function(pos, properties)
|
|
29530
|
+
{ return new Light3D(pos, properties.radius, properties.color, properties.intensity); },
|
|
29531
|
+
{color: WHITE, radius: 5, intensity: 1});
|
|
29532
|
+
|
|
28229
29533
|
/**
|
|
28230
29534
|
* LittleJS glTF Plugin
|
|
28231
29535
|
* - Loads glTF 2.0 models: a .gltf with its .bin and images beside it, or a .glb with everything in one file
|
|
@@ -28236,6 +29540,8 @@ class VoxelMap extends EngineObject3D
|
|
|
28236
29540
|
* GLTFObject that createObject makes; a skinned character's walk is not read
|
|
28237
29541
|
* - Materials give a base color and texture and whether they blend; glass made with KHR_materials_transmission blends too,
|
|
28238
29542
|
* and a KHR_materials_unlit material comes in emissive, its own color with no shading
|
|
29543
|
+
* - A material's normal map and emissive map load too, with its normal scale and emissive factor, read at the base
|
|
29544
|
+
* color texture's uvs; roughness, metalness and occlusion maps are not loaded
|
|
28239
29545
|
* - The base color texture reads the uv set its texCoord names, moved by KHR_texture_transform as gltfpack and
|
|
28240
29546
|
* Blender write it
|
|
28241
29547
|
* - An OPAQUE material, the default, ignores its texture's alpha as the format says: a texture only such materials
|
|
@@ -28282,6 +29588,17 @@ class GLTFPart
|
|
|
28282
29588
|
/** @property {boolean} - The material is unlit (KHR_materials_unlit), its own color with no shading; the object
|
|
28283
29589
|
* createObject makes draws it with emissive 1 */
|
|
28284
29590
|
this.unlit = false;
|
|
29591
|
+
/** @property {TextureInfo|undefined} - The material's normal map, drawn with the base color texture's uvs
|
|
29592
|
+
* @type {TextureInfo|undefined} */
|
|
29593
|
+
this.normalMap = undefined;
|
|
29594
|
+
/** @property {number} - The normal map's strength, its scale in the file */
|
|
29595
|
+
this.normalScale = 1;
|
|
29596
|
+
/** @property {TextureInfo|undefined} - The material's emissive map, or a white texture when it has an
|
|
29597
|
+
* emissiveFactor and no texture, so it glows all over
|
|
29598
|
+
* @type {TextureInfo|undefined} */
|
|
29599
|
+
this.emissiveMap = undefined;
|
|
29600
|
+
/** @property {Color} - The emissiveFactor, which multiplies the emissive map */
|
|
29601
|
+
this.emissiveMapColor = WHITE;
|
|
28285
29602
|
}
|
|
28286
29603
|
}
|
|
28287
29604
|
|
|
@@ -28427,8 +29744,8 @@ class GLTFModel
|
|
|
28427
29744
|
for (const part of this.parts)
|
|
28428
29745
|
part.mesh.dispose();
|
|
28429
29746
|
this.mesh.dispose();
|
|
28430
|
-
for (const textureInfo of new Set(this.parts.
|
|
28431
|
-
textureInfo?.destroyWebGLTexture();
|
|
29747
|
+
for (const textureInfo of new Set(this.parts.flatMap(p=> [p.textureInfo, p.normalMap, p.emissiveMap])))
|
|
29748
|
+
textureInfo !== gltfWhiteTextureInfo && textureInfo?.destroyWebGLTexture(); // the white one is shared
|
|
28432
29749
|
}
|
|
28433
29750
|
|
|
28434
29751
|
/** Make an object at a position with a child per part, so each keeps its own texture, color and blending, and
|
|
@@ -28486,6 +29803,8 @@ class GLTFObject extends EngineObject3D
|
|
|
28486
29803
|
o.transparent = part.transparent;
|
|
28487
29804
|
o.pixelated = part.pixelated;
|
|
28488
29805
|
o.emissive = part.unlit ? 1 : 0;
|
|
29806
|
+
o.normalMap = part.normalMap, o.normalScale = part.normalScale;
|
|
29807
|
+
o.emissiveMap = part.emissiveMap, o.emissiveMapColor = part.emissiveMapColor.copy(); // its own, as color is
|
|
28489
29808
|
const rest = model.nodeTree?.restPose?.[part.node];
|
|
28490
29809
|
if (rest)
|
|
28491
29810
|
{
|
|
@@ -28622,13 +29941,15 @@ async function parseGLTF(data, baseUrl='')
|
|
|
28622
29941
|
return gltfFetch(buffer.uri, baseUrl).then(r=> r.arrayBuffer());
|
|
28623
29942
|
}));
|
|
28624
29943
|
|
|
28625
|
-
// the textures, decoded together first; none without WebGL, and a failed image only logs;
|
|
28626
|
-
// textures are drawn with,
|
|
28627
|
-
const
|
|
29944
|
+
// the textures, decoded together first; none without WebGL, and a failed image only logs; the base color, normal
|
|
29945
|
+
// and emissive textures are drawn with, the roughness and occlusion maps are not loaded at all
|
|
29946
|
+
const usedTextures = new Set((json.materials || []).flatMap(m=> [m.pbrMetallicRoughness?.baseColorTexture?.index,
|
|
29947
|
+
m.normalTexture?.index, m.emissiveTexture?.index]));
|
|
29948
|
+
const normalTextures = new Set((json.materials || []).map(m=> m.normalTexture?.index));
|
|
28628
29949
|
const opaqueTextures = gltfOpaqueTextures(json);
|
|
28629
29950
|
const textures = await Promise.all((json.textures || []).map(async (texture, index)=>
|
|
28630
29951
|
{
|
|
28631
|
-
if (!glContext || typeof createImageBitmap == 'undefined' || !
|
|
29952
|
+
if (!glContext || typeof createImageBitmap == 'undefined' || !usedTextures.has(index)) return;
|
|
28632
29953
|
try
|
|
28633
29954
|
{
|
|
28634
29955
|
// a WebP or AVIF image is named by its extension, the browser decodes those like any other
|
|
@@ -28645,7 +29966,11 @@ async function parseGLTF(data, baseUrl='')
|
|
|
28645
29966
|
// an OPAQUE material ignores its texture's alpha, which the 3D pass would cut holes by, so a texture only
|
|
28646
29967
|
// those use has it set to 1; decoded as stored for that, and a jpeg has no alpha to set
|
|
28647
29968
|
const opaque = opaqueTextures.has(index) && blob.type !== 'image/jpeg';
|
|
28648
|
-
|
|
29969
|
+
// a normal map is directions, not colors, so it is decoded as stored; one a material also uses as a
|
|
29970
|
+
// color, an odd file, is decoded that way too
|
|
29971
|
+
const bitmap = normalTextures.has(index) ?
|
|
29972
|
+
await createImageBitmap(blob, {colorSpaceConversion: 'none', premultiplyAlpha: 'none'}) :
|
|
29973
|
+
opaque ? await createImageBitmap(blob, {premultiplyAlpha: 'none'}).then(gltfOpaqueImage) : await createImageBitmap(blob);
|
|
28649
29974
|
return new TextureInfo(bitmap, true, [sampler.wrapS ?? 10497, sampler.wrapT ?? 10497]); // REPEAT by default
|
|
28650
29975
|
}
|
|
28651
29976
|
catch (e) { LOG('glTF image not loaded', e); }
|
|
@@ -28721,7 +30046,7 @@ async function parseGLTF(data, baseUrl='')
|
|
|
28721
30046
|
}
|
|
28722
30047
|
|
|
28723
30048
|
// a texture none of the parts draws with, another scene's, goes now too, since dispose finds them by the parts
|
|
28724
|
-
const used = new Set(parts.
|
|
30049
|
+
const used = new Set(parts.flatMap(p=> [p.textureInfo, p.normalMap, p.emissiveMap]));
|
|
28725
30050
|
for (const texture of textures)
|
|
28726
30051
|
texture && !used.has(texture) && texture.destroyWebGLTexture();
|
|
28727
30052
|
return new GLTFModel(parts, animations, {nodes: json.nodes, parents, restInverse, restPose});
|
|
@@ -28957,9 +30282,34 @@ function gltfPart(json, buffers, textures, primitive, matrix, name)
|
|
|
28957
30282
|
textureRef ? textures[textureRef.index] : undefined, blend || transmission > 0);
|
|
28958
30283
|
part.pixelated = json.samplers?.[texture?.sampler]?.magFilter === 9728; // NEAREST
|
|
28959
30284
|
part.unlit = !!material.extensions?.KHR_materials_unlit;
|
|
30285
|
+
|
|
30286
|
+
// the normal and emissive maps, read at the base color texture's uvs; the emissive texture is multiplied by the
|
|
30287
|
+
// factor, which is black by default as the format says, so a texture alone does not glow; a factor with no
|
|
30288
|
+
// texture glows all over
|
|
30289
|
+
const normalRef = material.normalTexture, emissiveRef = material.emissiveTexture;
|
|
30290
|
+
part.normalMap = normalRef && textures[normalRef.index];
|
|
30291
|
+
part.normalScale = normalRef?.scale ?? 1;
|
|
30292
|
+
const [er, eg, eb] = material.emissiveFactor || [0, 0, 0];
|
|
30293
|
+
if (er || eg || eb)
|
|
30294
|
+
{
|
|
30295
|
+
part.emissiveMap = emissiveRef ? textures[emissiveRef.index] : gltfWhiteTexture();
|
|
30296
|
+
part.emissiveMapColor = rgb(gltfSRGB(er), gltfSRGB(eg), gltfSRGB(eb));
|
|
30297
|
+
}
|
|
28960
30298
|
return part;
|
|
28961
30299
|
}
|
|
28962
30300
|
|
|
30301
|
+
// a 1 by 1 white texture, the emissive map of a material that glows all over with no texture; made once and never
|
|
30302
|
+
// freed, like the engine's own white texture, undefined without WebGL
|
|
30303
|
+
let gltfWhiteTextureInfo;
|
|
30304
|
+
function gltfWhiteTexture()
|
|
30305
|
+
{
|
|
30306
|
+
if (gltfWhiteTextureInfo || !glContext || typeof OffscreenCanvas == 'undefined') return gltfWhiteTextureInfo;
|
|
30307
|
+
const context = createCanvasContext(1);
|
|
30308
|
+
context.fillStyle = '#fff';
|
|
30309
|
+
context.fillRect(0, 0, 1, 1);
|
|
30310
|
+
return gltfWhiteTextureInfo = new TextureInfo(context.canvas);
|
|
30311
|
+
}
|
|
30312
|
+
|
|
28963
30313
|
// a glTF color factor or vertex color is linear, where the renderer works in sRGB like the textures, so it is
|
|
28964
30314
|
// brought across at load or a mid gray material would come out nearly black; 0 and 1 stay exact
|
|
28965
30315
|
function gltfSRGB(c)
|
|
@@ -29577,7 +30927,8 @@ function render3DRenderDebug()
|
|
|
29577
30927
|
render3DDebugPrimitives = render3DDebugPrimitives.filter(p=> p.clearCount === debugClearCount);
|
|
29578
30928
|
if (!debugVideoCaptureIsActive()) // hidden from a video capture like the 2D ones, but they still expire
|
|
29579
30929
|
{
|
|
29580
|
-
render3DWithState({lighting: false, depthTest: false, receiveShadow: false, additive: false, shader: undefined
|
|
30930
|
+
render3DWithState({lighting: false, depthTest: false, receiveShadow: false, additive: false, shader: undefined,
|
|
30931
|
+
emissiveMap: undefined}, ()=>
|
|
29581
30932
|
{
|
|
29582
30933
|
for (const p of render3DDebugPrimitives)
|
|
29583
30934
|
p.draw();
|
|
@@ -29722,6 +31073,14 @@ class LevelEditor
|
|
|
29722
31073
|
* section has Play from mouse, which starts play there, Escape at the mouse and Play at the view center
|
|
29723
31074
|
* @type {EditorPlayFromCallback|undefined} */
|
|
29724
31075
|
this.onPlayFrom = undefined;
|
|
31076
|
+
/** @property {Array<number>|undefined} - The tiles the palette shows, in its order, for a sheet that also holds
|
|
31077
|
+
* sprites and art that are not level tiles; undefined shows every tile of the sheet
|
|
31078
|
+
* @type {Array<number>|undefined} */
|
|
31079
|
+
this.paletteTiles = undefined;
|
|
31080
|
+
/** @property {boolean|undefined} - Which level editor opens: true the 3D one, false the 2D one, undefined the
|
|
31081
|
+
* 3D one when a level was loaded with level3DLoad and there is a Render3DPlugin
|
|
31082
|
+
* @type {boolean|undefined} */
|
|
31083
|
+
this.use3D = undefined;
|
|
29725
31084
|
}
|
|
29726
31085
|
|
|
29727
31086
|
/** True while the editor is open, the game is paused under it
|
|
@@ -29918,7 +31277,7 @@ function editorMapRestore(map)
|
|
|
29918
31277
|
if (!saved) return map;
|
|
29919
31278
|
// an autosave from before the map's shape was in the hash matches on its tiles and objects, and one from before
|
|
29920
31279
|
// objects were matches on its tiles; one of a resized map is resized to first
|
|
29921
|
-
const sameFile = saved.hash === record.hash || !saved.width &&
|
|
31280
|
+
const sameFile = saved.hash === record.hash || saved.savedHash === record.hash || !saved.width &&
|
|
29922
31281
|
(saved.hash === editorMapHash(data, objects) || !saved.objects && saved.hash === editorMapHash(data));
|
|
29923
31282
|
if ((saved.width ?? map.width) === map.width && (saved.height ?? map.height) === map.height &&
|
|
29924
31283
|
editorSameData(saved.layers, data) && editorSameData(saved.objects ?? [], objects))
|
|
@@ -30071,15 +31430,17 @@ async function editorRememberedFile(record)
|
|
|
30071
31430
|
}
|
|
30072
31431
|
const editorFileKey = (record)=> (globalThis.location?.pathname ?? '') + ' ' + record.key;
|
|
30073
31432
|
|
|
30074
|
-
// a map as a file Save wrote is the file from then on: Reset to file goes back to it,
|
|
30075
|
-
//
|
|
30076
|
-
//
|
|
31433
|
+
// a map as a file Save wrote is the file from then on: Reset to file goes back to it, and a reload of it has nothing
|
|
31434
|
+
// to apply; a download can not say it replaced the file, so it does not, and neither does a file of another name, a
|
|
31435
|
+
// copy, since the game still loads the one it came from; the browser gives a picked file's name and not its folder,
|
|
31436
|
+
// so a file of the same name may be a copy too, and the autosave stays, with the hash of the file the game loads and
|
|
31437
|
+
// of the file written, until a reload shows which one the game loads
|
|
30077
31438
|
function editorSetBaseline(record, written)
|
|
30078
31439
|
{
|
|
30079
31440
|
if (record.synthetic) return; // a layer made in code has no file to load it from
|
|
30080
31441
|
const data = editorTileLayerData(written.layers);
|
|
30081
31442
|
const objects = editorObjectGroups(written.layers).map((group)=> group.objects ?? []);
|
|
30082
|
-
Object.assign(record, {original: data, originalObjects: objects,
|
|
31443
|
+
Object.assign(record, {original: data, originalObjects: objects, savedHash: editorMapHash(data, objects,
|
|
30083
31444
|
editorMapLayout(written)), originalSize: {width: written.width, height: written.height}});
|
|
30084
31445
|
record.pending || editorAutosave(record); // edits waiting to be applied keep their autosave
|
|
30085
31446
|
}
|
|
@@ -30192,11 +31553,12 @@ function editorAutosave(record)
|
|
|
30192
31553
|
while (kept.length > original.length && !kept.at(-1).length)
|
|
30193
31554
|
kept.pop(); // an Objects layer the editor made, empty again, is not an edit
|
|
30194
31555
|
const size = record.originalSize, sameSize = map.width === size.width && map.height === size.height;
|
|
30195
|
-
|
|
31556
|
+
// the map as it was loaded has nothing to keep; one a Save wrote is kept until a reload shows the file has it
|
|
31557
|
+
if (sameSize && editorSameData(data, record.original) && editorSameData(kept, original) && !record.savedHash)
|
|
30196
31558
|
delete saves[record.key];
|
|
30197
31559
|
else
|
|
30198
|
-
saves[record.key] = {hash: record.hash, width: map.width, height: map.height,
|
|
30199
|
-
nextobjectid: map.nextobjectid};
|
|
31560
|
+
saves[record.key] = {hash: record.hash, savedHash: record.savedHash, width: map.width, height: map.height,
|
|
31561
|
+
layers: data, objects, nextobjectid: map.nextobjectid};
|
|
30200
31562
|
editorWriteSaves(saves);
|
|
30201
31563
|
}
|
|
30202
31564
|
|
|
@@ -31029,8 +32391,8 @@ function editorBrushTile()
|
|
|
31029
32391
|
// the brush takes the tile in a cell, an empty cell gives the Erase brush
|
|
31030
32392
|
function editorPick(layer, pos) { editorBrush = editorStampTile(editorGidAt(layer, pos) || 0); }
|
|
31031
32393
|
|
|
31032
|
-
// a palette slot into the brush: slot 0 is the Erase brush, slot n
|
|
31033
|
-
// an object layer slot n is the nth object type
|
|
32394
|
+
// a palette slot into the brush: slot 0 is the Erase brush, slot n the palette's nth tile, keeping a one tile
|
|
32395
|
+
// brush's turn; on an object layer slot n is the nth object type
|
|
31034
32396
|
function editorPalettePick(slot)
|
|
31035
32397
|
{
|
|
31036
32398
|
if (editorObjectLayer)
|
|
@@ -31041,7 +32403,8 @@ function editorPalettePick(slot)
|
|
|
31041
32403
|
return;
|
|
31042
32404
|
}
|
|
31043
32405
|
const t = editorBrushTile();
|
|
31044
|
-
|
|
32406
|
+
const tile = slot && (editorPaletteTiles(editorLayer)[slot - 1]?.tile ?? slot - 1); // every tile, in order
|
|
32407
|
+
editorBrush = editorStampTile(slot ? editorTileToGid(tile, t?.direction, t?.mirror) : 0);
|
|
31045
32408
|
}
|
|
31046
32409
|
|
|
31047
32410
|
///////////////////////////////////////////////////////////////////////////////
|
|
@@ -31460,11 +32823,18 @@ function editorPanelInit()
|
|
|
31460
32823
|
// the palette's cell size in pixels and how many to a row
|
|
31461
32824
|
const editorPaletteCell = 30, editorPaletteColumns = 8;
|
|
31462
32825
|
|
|
31463
|
-
// a layer's
|
|
31464
|
-
// ones at the end, a tile of one
|
|
32826
|
+
// the tiles a layer's palette shows, each its index and the tile info it draws with: the game's
|
|
32827
|
+
// levelEditor.paletteTiles, or every tile up to the image's edge without the blank ones at the end, a tile of one
|
|
32828
|
+
// flat color; found once for each layer and list
|
|
31465
32829
|
function editorPaletteTiles(layer)
|
|
31466
32830
|
{
|
|
31467
|
-
const live = layer?.live, image = live?.tileInfo?.textureInfo?.image;
|
|
32831
|
+
const live = layer?.live, image = live?.tileInfo?.textureInfo?.image, list = levelEditor.paletteTiles;
|
|
32832
|
+
if (list && live)
|
|
32833
|
+
{
|
|
32834
|
+
if (layer.palette?.list !== list)
|
|
32835
|
+
layer.palette = {list, tiles: list.map((tile)=> ({tile, tileInfo: editorTileInfo(live, tile)}))};
|
|
32836
|
+
return layer.palette.tiles;
|
|
32837
|
+
}
|
|
31468
32838
|
if (!image) return [];
|
|
31469
32839
|
if (layer.palette?.image === image) return layer.palette.tiles;
|
|
31470
32840
|
|
|
@@ -31473,7 +32843,7 @@ function editorPaletteTiles(layer)
|
|
|
31473
32843
|
{
|
|
31474
32844
|
const t = editorTileInfo(live, i);
|
|
31475
32845
|
if (t.pos.x + t.size.x > image.width || t.pos.y + t.size.y > image.height) break;
|
|
31476
|
-
tiles.push(t);
|
|
32846
|
+
tiles.push({tile: i, tileInfo: t});
|
|
31477
32847
|
}
|
|
31478
32848
|
try
|
|
31479
32849
|
{
|
|
@@ -31482,7 +32852,8 @@ function editorPaletteTiles(layer)
|
|
|
31482
32852
|
context.drawImage(image, 0, 0);
|
|
31483
32853
|
const blank = (t)=>
|
|
31484
32854
|
{
|
|
31485
|
-
const pixels = new Uint32Array(context.getImageData(
|
|
32855
|
+
const {pos, size} = t.tileInfo, pixels = new Uint32Array(context.getImageData(pos.x, pos.y, size.x,
|
|
32856
|
+
size.y).data.buffer);
|
|
31486
32857
|
return pixels.every((p)=> p === pixels[0]);
|
|
31487
32858
|
};
|
|
31488
32859
|
while (tiles.length > 1 && blank(tiles.at(-1)))
|
|
@@ -31542,15 +32913,16 @@ function editorPaletteDraw(canvas, layer)
|
|
|
31542
32913
|
context.moveTo(8, 8), context.lineTo(cell - 8, cell - 8);
|
|
31543
32914
|
context.moveTo(cell - 8, 8), context.lineTo(8, cell - 8);
|
|
31544
32915
|
context.stroke();
|
|
31545
|
-
tiles.forEach((
|
|
32916
|
+
tiles.forEach(({tileInfo: {pos, size}}, i)=>
|
|
31546
32917
|
{
|
|
31547
32918
|
const slot = i + 1, x = slot % columns * cell, y = (slot / columns | 0) * cell;
|
|
31548
|
-
context.drawImage(image,
|
|
32919
|
+
context.drawImage(image, pos.x, pos.y, size.x, size.y, x + 2, y + 2, cell - 4, cell - 4);
|
|
31549
32920
|
});
|
|
31550
32921
|
|
|
31551
|
-
// the brush's slot outlined, when it is one tile or Erase
|
|
32922
|
+
// the brush's slot outlined, when it is one tile the palette shows or Erase
|
|
31552
32923
|
const t = editorBrushTile(), erase = !t && editorBrush.width === 1 && editorBrush.height === 1 &&
|
|
31553
|
-
editorBrush.grids[0][0] === 0,
|
|
32924
|
+
editorBrush.grids[0][0] === 0, shown = t ? tiles.findIndex((p)=> p.tile === t.tile) : -1;
|
|
32925
|
+
const selected = erase ? 0 : shown >= 0 ? shown + 1 : -1;
|
|
31554
32926
|
if (selected < 0) return;
|
|
31555
32927
|
context.strokeStyle = '#4af';
|
|
31556
32928
|
context.strokeRect(selected % columns * cell + 1, (selected / columns | 0) * cell + 1, cell - 2, cell - 2);
|
|
@@ -32095,3 +33467,1814 @@ if (debug && globalThis.document?.addEventListener)
|
|
|
32095
33467
|
document.addEventListener('wheel', editorOnWheel, {passive: true});
|
|
32096
33468
|
}
|
|
32097
33469
|
|
|
33470
|
+
/*
|
|
33471
|
+
* LittleJS 3D Editor Plugin
|
|
33472
|
+
* - A free camera to look around any 3D game as it runs, C on the debug overlay
|
|
33473
|
+
* - The 3D level editor, for a level loaded with level3DLoad, 0 on the debug overlay
|
|
33474
|
+
* - Debug builds only, like the 2D level editor: engineRelease.js has empty stubs for what other files call, so
|
|
33475
|
+
* release builds carry none of it
|
|
33476
|
+
* - Goes after the Render3D plugins and the 2D level editor
|
|
33477
|
+
*/
|
|
33478
|
+
|
|
33479
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
33480
|
+
// the view: a camera of the editor's own, drawn with while the free camera or the editor is on
|
|
33481
|
+
|
|
33482
|
+
const EDITOR3D_LOOK_SPEED = .003; // radians a pixel of mouse movement turns the view
|
|
33483
|
+
|
|
33484
|
+
// the camera of the free camera and the editor, made at the first use
|
|
33485
|
+
|
|
33486
|
+
/** @type {Camera3D|undefined} */
|
|
33487
|
+
let editor3DCamera;
|
|
33488
|
+
let editor3DFreeCamera = false; // the free camera is on
|
|
33489
|
+
let editor3DFlySpeed = .2; // world units flown in a frame of 1/60, the wheel changes it
|
|
33490
|
+
let editor3DTimeLast = 0; // real time of the last update, flying goes by real time so it works while paused
|
|
33491
|
+
let editor3DWasLocked = false; // the mouse was captured last update, a lock that is lost leaves the free camera
|
|
33492
|
+
// the game's camera while the editor's is drawn with
|
|
33493
|
+
/** @type {Camera3D|undefined} */
|
|
33494
|
+
let editor3DGameCamera;
|
|
33495
|
+
|
|
33496
|
+
// if something draws with the editor's camera
|
|
33497
|
+
function editor3DViewOn() { return editor3DFreeCamera || editor3DIsOpen; }
|
|
33498
|
+
|
|
33499
|
+
// the editor's camera starts as the game's: where it is, the way it looks and its field of view
|
|
33500
|
+
function editor3DCameraStart()
|
|
33501
|
+
{
|
|
33502
|
+
const from = render3D.camera, camera = editor3DCamera = new Camera3D;
|
|
33503
|
+
camera.pos = from.pos.copy();
|
|
33504
|
+
camera.rotation = vec3(from.rotation.x, from.rotation.y, 0);
|
|
33505
|
+
camera.fov = from.fov;
|
|
33506
|
+
camera.near = from.near;
|
|
33507
|
+
camera.far = from.far;
|
|
33508
|
+
}
|
|
33509
|
+
|
|
33510
|
+
// turn the free camera on or off, it needs a Render3DPlugin and takes the keyboard and mouse while it is on
|
|
33511
|
+
function editor3DSetFreeCamera(on)
|
|
33512
|
+
{
|
|
33513
|
+
on = !!on && !!render3D;
|
|
33514
|
+
if (on === editor3DFreeCamera) return;
|
|
33515
|
+
editor3DFreeCamera = on;
|
|
33516
|
+
editor3DWasLocked = false;
|
|
33517
|
+
if (on)
|
|
33518
|
+
{
|
|
33519
|
+
editor3DCameraStart();
|
|
33520
|
+
pointerLockRequest();
|
|
33521
|
+
}
|
|
33522
|
+
else
|
|
33523
|
+
pointerLockIsActive() && pointerLockExit();
|
|
33524
|
+
inputCapture(editor3DViewOn());
|
|
33525
|
+
}
|
|
33526
|
+
|
|
33527
|
+
// fly a camera: turn is the mouse movement in pixels, move is right, up and forward from -1 to 1
|
|
33528
|
+
function editor3DFly(camera, move, turn, seconds, fast=false)
|
|
33529
|
+
{
|
|
33530
|
+
const r = camera.rotation;
|
|
33531
|
+
camera.rotation = vec3(clamp(r.x - turn.y * EDITOR3D_LOOK_SPEED, -1.55, 1.55), r.y - turn.x * EDITOR3D_LOOK_SPEED, 0);
|
|
33532
|
+
const distance = editor3DFlySpeed * 60 * seconds * (fast ? 4 : 1);
|
|
33533
|
+
camera.pos = camera.pos.add(camera.getRight().scale(move.x * distance))
|
|
33534
|
+
.add(vec3(0, move.y * distance, 0))
|
|
33535
|
+
.add(camera.getForward().scale(move.z * distance));
|
|
33536
|
+
}
|
|
33537
|
+
|
|
33538
|
+
// the keys that fly, right, up and forward from -1 to 1
|
|
33539
|
+
function editor3DFlyKeys()
|
|
33540
|
+
{
|
|
33541
|
+
const key = (code)=> keyIsDown(code) ? 1 : 0;
|
|
33542
|
+
return vec3(key('KeyD') - key('KeyA'), key('KeyE') - key('KeyQ'), key('KeyW') - key('KeyS'));
|
|
33543
|
+
}
|
|
33544
|
+
|
|
33545
|
+
// run a function with render3D looking through the editor's camera, for the editor's picking and projecting
|
|
33546
|
+
function editor3DWithView(fn)
|
|
33547
|
+
{
|
|
33548
|
+
const r = render3D, camera = r.camera;
|
|
33549
|
+
r.camera = editor3DCamera;
|
|
33550
|
+
r.updateMatrices();
|
|
33551
|
+
try { return fn(); }
|
|
33552
|
+
finally
|
|
33553
|
+
{
|
|
33554
|
+
r.camera = camera;
|
|
33555
|
+
r.updateMatrices();
|
|
33556
|
+
}
|
|
33557
|
+
}
|
|
33558
|
+
|
|
33559
|
+
// called by the 3D pass before it works out its matrices: the frame is drawn with the editor's camera
|
|
33560
|
+
function editor3DCameraBegin()
|
|
33561
|
+
{
|
|
33562
|
+
if (!editor3DViewOn() || !editor3DCamera) return;
|
|
33563
|
+
const r = render3D, camera = editor3DGameCamera = r.camera;
|
|
33564
|
+
r.camera = editor3DCamera;
|
|
33565
|
+
|
|
33566
|
+
// where the game is looking from, a box turned its way and a line along its view; the editor has enough to show
|
|
33567
|
+
if (editor3DFreeCamera)
|
|
33568
|
+
{
|
|
33569
|
+
const color = hsl(.15, 1, .6);
|
|
33570
|
+
debugBox3D(camera.pos, vec3(.5, .5, .8), color, 0, camera.rotation);
|
|
33571
|
+
debugLine3D(camera.pos, camera.pos.add(camera.getForward().scale(2)), color);
|
|
33572
|
+
}
|
|
33573
|
+
|
|
33574
|
+
// the editor's own drawing, in this frame's 3D pass
|
|
33575
|
+
editor3DIsOpen && render3DDebugPush(0, editor3DDraw);
|
|
33576
|
+
}
|
|
33577
|
+
|
|
33578
|
+
// called by the 3D pass after the frame: the game's camera is back, with its matrices, for the game's next update
|
|
33579
|
+
function editor3DCameraEnd()
|
|
33580
|
+
{
|
|
33581
|
+
if (!editor3DGameCamera) return;
|
|
33582
|
+
render3D.camera = editor3DGameCamera;
|
|
33583
|
+
editor3DGameCamera = undefined;
|
|
33584
|
+
render3D.updateMatrices();
|
|
33585
|
+
}
|
|
33586
|
+
|
|
33587
|
+
// if a captured mouse was let go of by the browser since the last look, as Chrome does on Escape, and not by the
|
|
33588
|
+
// game with pointerLockExit
|
|
33589
|
+
function editor3DLostLock(locked)
|
|
33590
|
+
{
|
|
33591
|
+
const lost = editor3DWasLocked && !locked && !inputLockLetGo;
|
|
33592
|
+
editor3DWasLocked = locked;
|
|
33593
|
+
inputLockLetGo = false;
|
|
33594
|
+
return lost;
|
|
33595
|
+
}
|
|
33596
|
+
|
|
33597
|
+
// C turns the free camera on while the debug keys work, the level editor has its own view
|
|
33598
|
+
function editor3DFreeCameraKey()
|
|
33599
|
+
{
|
|
33600
|
+
if (!(debugOverlay || debugKeysAlways) || levelEditor.isOpen || !keyWasPressed('KeyC')) return;
|
|
33601
|
+
inputClearKey('KeyC');
|
|
33602
|
+
editor3DSetFreeCamera(true);
|
|
33603
|
+
}
|
|
33604
|
+
|
|
33605
|
+
// the free camera's update: C or Escape leaves, and so does a captured mouse the browser let go of, since Chrome
|
|
33606
|
+
// takes Escape for that and may not send the key; the mouse looks while it is captured or the right button is held
|
|
33607
|
+
function editor3DFreeCameraUpdate(seconds)
|
|
33608
|
+
{
|
|
33609
|
+
const locked = pointerLockIsActive(), lostLock = editor3DLostLock(locked);
|
|
33610
|
+
if (keyWasPressed('KeyC') || debugKey && keyWasPressed(debugKey) || lostLock)
|
|
33611
|
+
{
|
|
33612
|
+
inputClearKey('KeyC');
|
|
33613
|
+
debugKey && inputClearKey(debugKey);
|
|
33614
|
+
editor3DSetFreeCamera(false);
|
|
33615
|
+
return;
|
|
33616
|
+
}
|
|
33617
|
+
editor3DFlySpeed = clamp(editor3DFlySpeed * Math.exp(-inputCaptureWheel * .2), .01, 10);
|
|
33618
|
+
const look = locked || mouseIsDown(2);
|
|
33619
|
+
editor3DFly(editor3DCamera, editor3DFlyKeys(), look ? inputCaptureDeltaScreen : vec2(), seconds,
|
|
33620
|
+
keyIsDown('ShiftLeft') || keyIsDown('ShiftRight'));
|
|
33621
|
+
}
|
|
33622
|
+
|
|
33623
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
33624
|
+
// the math of the handles: each is dragged with the mouse's ray
|
|
33625
|
+
|
|
33626
|
+
const EDITOR3D_UP = vec3(0, 1, 0);
|
|
33627
|
+
|
|
33628
|
+
// how far along an axis line, from its origin, the point nearest a ray is: the two lines' closest points, solved
|
|
33629
|
+
// for the axis; a ray along the axis has no nearest point, and gives 0
|
|
33630
|
+
function editor3DAxisDistance(ray, origin, axis)
|
|
33631
|
+
{
|
|
33632
|
+
const d = ray.direction, w = origin.subtract(ray.origin);
|
|
33633
|
+
const a = axis.dot(axis), b = axis.dot(d), c = d.dot(d), e = axis.dot(w), f = d.dot(w);
|
|
33634
|
+
const denominator = a * c - b * b;
|
|
33635
|
+
return abs(denominator) < 1e-9 ? 0 : (b * f - c * e) / denominator;
|
|
33636
|
+
}
|
|
33637
|
+
|
|
33638
|
+
// where a ray meets the plane through a point, undefined when it runs along the plane or away from it
|
|
33639
|
+
function editor3DPlanePoint(ray, point, normal)
|
|
33640
|
+
{
|
|
33641
|
+
const distance = raycastPlane(ray, point, normal);
|
|
33642
|
+
return distance === undefined ? undefined : ray.getPosition(distance);
|
|
33643
|
+
}
|
|
33644
|
+
|
|
33645
|
+
// the axis a ring turns around and two axes across it, for the pitch 'x', yaw 'y' or roll 'z' of a rotation:
|
|
33646
|
+
// yaw turns around world up, pitch around the side axis after the yaw, roll around the forward axis after both,
|
|
33647
|
+
// so each ring changes one number of rotation3D; a turn goes from across toward along
|
|
33648
|
+
function editor3DRingAxes(rotation, ring)
|
|
33649
|
+
{
|
|
33650
|
+
const frame = buildMatrix(vec3(), ring === 'y' ? vec3() : ring === 'x' ? vec3(0, rotation.y, 0) :
|
|
33651
|
+
vec3(rotation.x, rotation.y, 0));
|
|
33652
|
+
const x = frame.transformDirection(vec3(1, 0, 0)), y = frame.transformDirection(vec3(0, 1, 0));
|
|
33653
|
+
const z = frame.transformDirection(vec3(0, 0, 1));
|
|
33654
|
+
return ring === 'x' ? {axis: x, across: y, along: z} : ring === 'y' ? {axis: y, across: z, along: x} :
|
|
33655
|
+
{axis: z, across: x, along: y};
|
|
33656
|
+
}
|
|
33657
|
+
|
|
33658
|
+
// the angle of a ray's point on a ring's plane, around the ring's axis from across toward along
|
|
33659
|
+
function editor3DRingAngle(ray, center, axes)
|
|
33660
|
+
{
|
|
33661
|
+
const point = editor3DPlanePoint(ray, center, axes.axis);
|
|
33662
|
+
if (!point) return;
|
|
33663
|
+
const offset = point.subtract(center);
|
|
33664
|
+
return atan2(offset.dot(axes.along), offset.dot(axes.across));
|
|
33665
|
+
}
|
|
33666
|
+
|
|
33667
|
+
// a value on the nearest step, a step of 0 leaves it
|
|
33668
|
+
function editor3DSnap(value, step) { return step ? round(value / step) * step : value; }
|
|
33669
|
+
|
|
33670
|
+
// a position snapped to the grid by the low corner of its box, so its faces land on grid lines and boxes tile
|
|
33671
|
+
// with no gaps; a turned object has no such corner and goes by its center
|
|
33672
|
+
function editor3DSnapPos(pos, size, step, turned=false)
|
|
33673
|
+
{
|
|
33674
|
+
const half = turned ? vec3() : size.scale(.5), low = pos.subtract(half);
|
|
33675
|
+
return vec3(editor3DSnap(low.x, step), editor3DSnap(low.y, step), editor3DSnap(low.z, step)).add(half);
|
|
33676
|
+
}
|
|
33677
|
+
|
|
33678
|
+
// the height of the editor's view in world units at a point's depth, handles are sized by it to keep their size
|
|
33679
|
+
// on screen
|
|
33680
|
+
function editor3DScreenScale(point)
|
|
33681
|
+
{
|
|
33682
|
+
const camera = editor3DCamera, depth = point.subtract(camera.pos).dot(camera.getForward());
|
|
33683
|
+
return max(depth, .01) * tan(camera.fov / 2) * 2;
|
|
33684
|
+
}
|
|
33685
|
+
|
|
33686
|
+
// how far in pixels a screen position is from the line between two world points as the view shows it, Infinity
|
|
33687
|
+
// when an end is behind the camera
|
|
33688
|
+
function editor3DSegmentDistance(screenPos, a, b)
|
|
33689
|
+
{
|
|
33690
|
+
const sa = render3D.worldToScreen(a), sb = render3D.worldToScreen(b);
|
|
33691
|
+
if (!sa || !sb) return Infinity;
|
|
33692
|
+
const ab = sb.subtract(sa), t = clamp(screenPos.subtract(sa).dot(ab) / (ab.lengthSquared() || 1), 0, 1);
|
|
33693
|
+
return screenPos.distance(sa.add(ab.scale(t)));
|
|
33694
|
+
}
|
|
33695
|
+
|
|
33696
|
+
// the first point a ray hits: on an object, a height map or a voxel map, leaving some out, or on the ground plane
|
|
33697
|
+
// at height 0 when it hits none
|
|
33698
|
+
function editor3DSurface(ray, ignore=new Set)
|
|
33699
|
+
{
|
|
33700
|
+
let distance;
|
|
33701
|
+
for (const o of engineObjects)
|
|
33702
|
+
{
|
|
33703
|
+
if (ignore.has(o)) continue;
|
|
33704
|
+
const d = render3DRaycastObject(ray, o);
|
|
33705
|
+
if (d !== undefined && (distance === undefined || d < distance))
|
|
33706
|
+
distance = d;
|
|
33707
|
+
}
|
|
33708
|
+
distance ??= raycastPlane(ray, vec3(), EDITOR3D_UP);
|
|
33709
|
+
return distance === undefined ? undefined : ray.getPosition(distance);
|
|
33710
|
+
}
|
|
33711
|
+
|
|
33712
|
+
// where a box of a size stands with its bottom on a point
|
|
33713
|
+
function editor3DRest(point, size) { return vec3(point.x, point.y + size.y / 2, point.z); }
|
|
33714
|
+
|
|
33715
|
+
// the height of the middle of a box that lands at a place across the ground: dropped from its own height over a
|
|
33716
|
+
// height, onto what is under it, so it climbs a step as tall as itself and no more; with nothing under it, it
|
|
33717
|
+
// stands at that height
|
|
33718
|
+
function editor3DLand(x, z, height, size, ignore)
|
|
33719
|
+
{
|
|
33720
|
+
const landed = editor3DDrop(vec3(x, height + size.y * 1.5 + .01, z), size, ignore);
|
|
33721
|
+
return landed ? landed.y : height + size.y / 2;
|
|
33722
|
+
}
|
|
33723
|
+
|
|
33724
|
+
// where a box lands dropped straight down from its bottom onto what is under it, undefined with nothing there
|
|
33725
|
+
function editor3DDrop(pos, size, ignore=new Set)
|
|
33726
|
+
{
|
|
33727
|
+
const bottom = vec3(pos.x, pos.y - size.y / 2, pos.z);
|
|
33728
|
+
const point = editor3DSurface(new Ray3D(bottom, vec3(0, -1, 0)), ignore);
|
|
33729
|
+
return point && editor3DRest(point, size);
|
|
33730
|
+
}
|
|
33731
|
+
|
|
33732
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
33733
|
+
// the level: the object the game gave level3DLoad is the source of truth, the editor changes it in place and
|
|
33734
|
+
// brings what the game made in line with it
|
|
33735
|
+
|
|
33736
|
+
// the level being edited, the one given to level3DLoad last
|
|
33737
|
+
|
|
33738
|
+
/** @type {Object|undefined} */
|
|
33739
|
+
let editor3DLevel;
|
|
33740
|
+
|
|
33741
|
+
// what the game made for each object of the level, by the object's id
|
|
33742
|
+
const editor3DInstances = new Map;
|
|
33743
|
+
|
|
33744
|
+
// what the editor keeps for each level: {fileName, key, original, hash, pending, fileHandle, undo, redo}, the file
|
|
33745
|
+
// it came from, the file's objects and their hash for the autosave, an autosave waiting for a file that changed,
|
|
33746
|
+
// and its undo and redo lists
|
|
33747
|
+
const editor3DRecords = new WeakMap;
|
|
33748
|
+
|
|
33749
|
+
// if the editor is open, and if it was opened and not exited, while Escape switches between playing and editing
|
|
33750
|
+
let editor3DIsOpen = false, editor3DSession = false;
|
|
33751
|
+
let editor3DGamePaused = false; // the game's pause from before the editor opened
|
|
33752
|
+
let editor3DPlayFromMouse = false; // Escape and Play hand the game a position to play from
|
|
33753
|
+
|
|
33754
|
+
const editor3DSelection = new Set; // the ids of the selected objects
|
|
33755
|
+
// the type a click places
|
|
33756
|
+
/** @type {string|undefined} */
|
|
33757
|
+
let editor3DBrush;
|
|
33758
|
+
// the copied objects
|
|
33759
|
+
/** @type {Array<Object>|undefined} */
|
|
33760
|
+
let editor3DClipboard;
|
|
33761
|
+
let editor3DUndoList = [], editor3DRedoList = []; // the level's, each entry its object list before and after an edit
|
|
33762
|
+
// the edit being made, a drag is one
|
|
33763
|
+
/** @type {{before: Array<Object>}|undefined} */
|
|
33764
|
+
let editor3DStroke;
|
|
33765
|
+
|
|
33766
|
+
const editor3DCopy = (value)=> JSON.parse(JSON.stringify(value));
|
|
33767
|
+
const editor3DSame = (a, b)=> JSON.stringify(a) === JSON.stringify(b);
|
|
33768
|
+
const editor3DRound = (v)=> round(v * 1e4) / 1e4; // as the file keeps a number
|
|
33769
|
+
|
|
33770
|
+
// the level's objects, and the one of an id
|
|
33771
|
+
const editor3DObjects = ()=> isArray(editor3DLevel?.objects) ? editor3DLevel.objects : [];
|
|
33772
|
+
const editor3DObject = (id)=> editor3DObjects().find((o)=> o.id === id);
|
|
33773
|
+
const editor3DSelected = ()=> editor3DObjects().filter((o)=> editor3DSelection.has(o.id));
|
|
33774
|
+
|
|
33775
|
+
// an object's position, rotation in degrees and scale, the defaults where the level leaves them out
|
|
33776
|
+
const editor3DPos = (object)=> level3DVector(object.pos, vec3());
|
|
33777
|
+
const editor3DRotation = (object)=> level3DVector(object.rotation, vec3());
|
|
33778
|
+
const editor3DScale = (object)=> level3DVector(object.scale, vec3(1));
|
|
33779
|
+
const editor3DTurned = (object)=> editor3DRotation(object).lengthSquared() > 0;
|
|
33780
|
+
|
|
33781
|
+
// the size of an object's box: what the game made for it says, a marker is 1 unit
|
|
33782
|
+
function editor3DSize(object)
|
|
33783
|
+
{
|
|
33784
|
+
const made = editor3DInstances.get(object.id);
|
|
33785
|
+
if (!(made instanceof EngineObject3D) || !(made.mesh || made.tileInfo)) return vec3(1);
|
|
33786
|
+
const s = made.size3D, k = made.scale3D;
|
|
33787
|
+
return vec3(s.x * abs(k.x), s.y * abs(k.y), s.z * abs(k.z));
|
|
33788
|
+
}
|
|
33789
|
+
|
|
33790
|
+
// every object gets an id of its own, a whole number: one without, or with one already used, gets a new one
|
|
33791
|
+
function editor3DFixIds(objects)
|
|
33792
|
+
{
|
|
33793
|
+
const isObject = (o)=> !!o && typeof o === 'object', seen = new Set;
|
|
33794
|
+
let next = 1;
|
|
33795
|
+
for (const o of objects)
|
|
33796
|
+
isObject(o) && Number.isInteger(o.id) && (next = max(next, o.id + 1));
|
|
33797
|
+
for (const o of objects)
|
|
33798
|
+
{
|
|
33799
|
+
if (!isObject(o)) continue;
|
|
33800
|
+
if (!Number.isInteger(o.id) || seen.has(o.id))
|
|
33801
|
+
o.id = next++;
|
|
33802
|
+
seen.add(o.id);
|
|
33803
|
+
}
|
|
33804
|
+
}
|
|
33805
|
+
|
|
33806
|
+
// a quick hash of a text, to know when the file changed under its autosave
|
|
33807
|
+
function editor3DHash(text)
|
|
33808
|
+
{
|
|
33809
|
+
let hash = 2166136261;
|
|
33810
|
+
for (let i = 0; i < text.length; ++i)
|
|
33811
|
+
hash = Math.imul(hash ^ text.charCodeAt(i), 16777619);
|
|
33812
|
+
return hash >>> 0;
|
|
33813
|
+
}
|
|
33814
|
+
|
|
33815
|
+
// called by level3DLoad before it makes the objects: the editor takes the level, and the first time it sees it,
|
|
33816
|
+
// puts in the edits it autosaved for the file, or keeps them waiting when the file changed since
|
|
33817
|
+
function editor3DLevelLoaded(level)
|
|
33818
|
+
{
|
|
33819
|
+
if (editor3DLevel !== level)
|
|
33820
|
+
{
|
|
33821
|
+
// another level, the selection and the edit being made were the last one's
|
|
33822
|
+
editor3DSelection.clear();
|
|
33823
|
+
editor3DStroke = undefined;
|
|
33824
|
+
}
|
|
33825
|
+
editor3DLevel = level;
|
|
33826
|
+
editor3DInstances.clear();
|
|
33827
|
+
if (isArray(level.objects))
|
|
33828
|
+
{
|
|
33829
|
+
// an entry that is not an object is dropped, in place, a game may keep the list
|
|
33830
|
+
for (let i = level.objects.length; i--;)
|
|
33831
|
+
level.objects[i] && typeof level.objects[i] === 'object' || level.objects.splice(i, 1);
|
|
33832
|
+
editor3DFixIds(level.objects);
|
|
33833
|
+
}
|
|
33834
|
+
const known = editor3DRecords.get(level);
|
|
33835
|
+
if (known)
|
|
33836
|
+
{
|
|
33837
|
+
// loaded again, as a restart does: its edits are in it, and its undo is its own
|
|
33838
|
+
editor3DUndoList = known.undo;
|
|
33839
|
+
editor3DRedoList = known.redo;
|
|
33840
|
+
return;
|
|
33841
|
+
}
|
|
33842
|
+
|
|
33843
|
+
// a level from a file goes by the file, one made in code by its objects as they were loaded, so each has an
|
|
33844
|
+
// autosave of its own
|
|
33845
|
+
const url = editorFetchedURLs.get(level)?.split(/[?#]/)[0];
|
|
33846
|
+
const original = editor3DCopy(editor3DObjects()), hash = editor3DHash(JSON.stringify(original));
|
|
33847
|
+
const record = {fileName: url ? url.split('/').pop() : 'level3D.json', key: url ?? 'level #' + hash, original,
|
|
33848
|
+
hash, pending: undefined, fileHandle: undefined, undo: [], redo: []};
|
|
33849
|
+
editor3DRecords.set(level, record);
|
|
33850
|
+
editor3DUndoList = record.undo;
|
|
33851
|
+
editor3DRedoList = record.redo;
|
|
33852
|
+
// an autosave the file already has, from a Save, goes; one of this file, or of the file a Save wrote, comes back
|
|
33853
|
+
const saved = editor3DSaves()[record.key];
|
|
33854
|
+
if (saved && isArray(saved.objects) && editor3DSame(saved.objects, original))
|
|
33855
|
+
editor3DAutosave(level);
|
|
33856
|
+
else if ((saved?.hash === hash || saved?.savedHash === hash) && isArray(saved.objects))
|
|
33857
|
+
level.objects = editor3DCopy(saved.objects);
|
|
33858
|
+
else if (saved)
|
|
33859
|
+
record.pending = saved;
|
|
33860
|
+
}
|
|
33861
|
+
|
|
33862
|
+
// called by level3DLoad for each object, with what its type made
|
|
33863
|
+
function editor3DObjectMade(object, made)
|
|
33864
|
+
{
|
|
33865
|
+
made && typeof made === 'object' && editor3DInstances.set(object.id, made);
|
|
33866
|
+
}
|
|
33867
|
+
|
|
33868
|
+
// put what the game made for an object where the level has it, and still
|
|
33869
|
+
function editor3DPlaceInstance(made, object)
|
|
33870
|
+
{
|
|
33871
|
+
made.pos3D = editor3DPos(object);
|
|
33872
|
+
made.rotation3D = editor3DRotation(object).scale(PI / 180);
|
|
33873
|
+
made.scale3D = (level3DBaseScale.get(made) ?? vec3(1)).multiply(editor3DScale(object));
|
|
33874
|
+
made.velocity3D = vec3();
|
|
33875
|
+
made.angleVelocity3D = vec3();
|
|
33876
|
+
}
|
|
33877
|
+
|
|
33878
|
+
// make an object's game object, again when it has one
|
|
33879
|
+
function editor3DMakeInstance(object)
|
|
33880
|
+
{
|
|
33881
|
+
editor3DInstances.get(object.id)?.destroy?.();
|
|
33882
|
+
const made = level3DMake(object);
|
|
33883
|
+
made ? editor3DInstances.set(object.id, made) : editor3DInstances.delete(object.id);
|
|
33884
|
+
}
|
|
33885
|
+
|
|
33886
|
+
// set the level's objects to a list and bring the game's objects in line: one gone is destroyed, a new one made,
|
|
33887
|
+
// one that only moved, turned or changed size is moved where it is, and one whose type or properties changed is
|
|
33888
|
+
// made again, since its constructor takes them; a type made by an arrow function is called again
|
|
33889
|
+
function editor3DSetObjects(list)
|
|
33890
|
+
{
|
|
33891
|
+
const before = new Map(editor3DObjects().map((o)=> [o.id, o]));
|
|
33892
|
+
const objects = editor3DLevel.objects = editor3DCopy(list);
|
|
33893
|
+
const ids = new Set(objects.map((o)=> o.id));
|
|
33894
|
+
for (const [id, made] of editor3DInstances)
|
|
33895
|
+
{
|
|
33896
|
+
if (ids.has(id)) continue;
|
|
33897
|
+
made.destroy?.();
|
|
33898
|
+
editor3DInstances.delete(id);
|
|
33899
|
+
}
|
|
33900
|
+
for (const id of [...editor3DSelection])
|
|
33901
|
+
ids.has(id) || editor3DSelection.delete(id);
|
|
33902
|
+
for (const object of objects)
|
|
33903
|
+
{
|
|
33904
|
+
const old = before.get(object.id), made = editor3DInstances.get(object.id);
|
|
33905
|
+
if (!level3DTypes.has(object.type) || old && editor3DSame(old, object)) continue;
|
|
33906
|
+
const onlyMoved = old && old.type === object.type && editor3DSame(old.properties, object.properties) &&
|
|
33907
|
+
made instanceof EngineObject3D && !made.destroyed;
|
|
33908
|
+
onlyMoved ? editor3DPlaceInstance(made, object) : editor3DMakeInstance(object);
|
|
33909
|
+
}
|
|
33910
|
+
}
|
|
33911
|
+
|
|
33912
|
+
// change the level's objects as part of the edit being made: change edits a copy of the list; the changes until
|
|
33913
|
+
// editor3DStrokeEnd are one undo, so a drag is one undo; false when nothing changed
|
|
33914
|
+
function editor3DChange(change)
|
|
33915
|
+
{
|
|
33916
|
+
if (!editor3DLevel || editor3DRecords.get(editor3DLevel)?.pending) return false; // its autosave waits first
|
|
33917
|
+
const before = editor3DCopy(editor3DObjects()), after = editor3DCopy(before);
|
|
33918
|
+
change(after);
|
|
33919
|
+
if (editor3DSame(before, after)) return false;
|
|
33920
|
+
editor3DSetObjects(after);
|
|
33921
|
+
editor3DStroke ||= {before};
|
|
33922
|
+
return true;
|
|
33923
|
+
}
|
|
33924
|
+
|
|
33925
|
+
// end the edit being made: one undo, and the autosave
|
|
33926
|
+
function editor3DStrokeEnd()
|
|
33927
|
+
{
|
|
33928
|
+
const stroke = editor3DStroke;
|
|
33929
|
+
editor3DStroke = undefined;
|
|
33930
|
+
if (!stroke || editor3DSame(stroke.before, editor3DObjects())) return;
|
|
33931
|
+
editor3DUndoList.push({before: stroke.before, after: editor3DCopy(editor3DObjects())});
|
|
33932
|
+
editor3DUndoList.length > 100 && editor3DUndoList.shift();
|
|
33933
|
+
editor3DRedoList.length = 0;
|
|
33934
|
+
editor3DAutosave();
|
|
33935
|
+
}
|
|
33936
|
+
|
|
33937
|
+
// take the edit being made back, with nothing to undo
|
|
33938
|
+
function editor3DStrokeCancel()
|
|
33939
|
+
{
|
|
33940
|
+
const stroke = editor3DStroke;
|
|
33941
|
+
editor3DStroke = undefined;
|
|
33942
|
+
stroke && editor3DSetObjects(stroke.before);
|
|
33943
|
+
}
|
|
33944
|
+
|
|
33945
|
+
// undo the last edit, or redo the last one undone; false with none
|
|
33946
|
+
function editor3DUndo(redo=false)
|
|
33947
|
+
{
|
|
33948
|
+
editor3DStrokeEnd();
|
|
33949
|
+
const entry = (redo ? editor3DRedoList : editor3DUndoList).pop();
|
|
33950
|
+
if (!entry) return false;
|
|
33951
|
+
(redo ? editor3DUndoList : editor3DRedoList).push(entry);
|
|
33952
|
+
editor3DSetObjects(redo ? entry.after : entry.before);
|
|
33953
|
+
editor3DAutosave();
|
|
33954
|
+
return true;
|
|
33955
|
+
}
|
|
33956
|
+
|
|
33957
|
+
// write an object's position, rotation in degrees and scale as the file keeps them: rounded, and the rotation and
|
|
33958
|
+
// scale left out when they are the default; one not given is left as it is
|
|
33959
|
+
function editor3DSetTransform(object, pos, rotation, scale)
|
|
33960
|
+
{
|
|
33961
|
+
const list = (v)=> [editor3DRound(v.x), editor3DRound(v.y), editor3DRound(v.z)];
|
|
33962
|
+
pos && (object.pos = list(pos));
|
|
33963
|
+
if (rotation)
|
|
33964
|
+
list(rotation).some((v)=> v) ? object.rotation = list(rotation) : delete object.rotation;
|
|
33965
|
+
if (scale)
|
|
33966
|
+
list(scale).some((v)=> v !== 1) ? object.scale = list(scale) : delete object.scale;
|
|
33967
|
+
}
|
|
33968
|
+
|
|
33969
|
+
// set an object's property as the file keeps it, only where it differs from its type's default: a Color as a hex
|
|
33970
|
+
// string, with its alpha when it has one, and a vector as an array
|
|
33971
|
+
function editor3DSetProperty(object, name, value, defaultValue)
|
|
33972
|
+
{
|
|
33973
|
+
const encode = (v)=> isColor(v) ? v.toString(v.a < 1) : isVector3(v) ? [v.x, v.y, v.z] :
|
|
33974
|
+
isVector2(v) ? [v.x, v.y] : v;
|
|
33975
|
+
const properties = {...object.properties};
|
|
33976
|
+
if (editor3DSame(encode(value), encode(defaultValue)))
|
|
33977
|
+
delete properties[name];
|
|
33978
|
+
else
|
|
33979
|
+
properties[name] = encode(value);
|
|
33980
|
+
Object.keys(properties).length ? object.properties = properties : delete object.properties;
|
|
33981
|
+
}
|
|
33982
|
+
|
|
33983
|
+
// add an object of a type at a position, selected; its id, or undefined when the level can not be edited
|
|
33984
|
+
function editor3DPlace(type, pos)
|
|
33985
|
+
{
|
|
33986
|
+
let id;
|
|
33987
|
+
const placed = editor3DChange((list)=>
|
|
33988
|
+
{
|
|
33989
|
+
const object = {id: id = list.reduce((next, o)=> max(next, o.id + 1), 1), type};
|
|
33990
|
+
editor3DSetTransform(object, pos);
|
|
33991
|
+
list.push(object);
|
|
33992
|
+
});
|
|
33993
|
+
if (!placed) return;
|
|
33994
|
+
editor3DSelection.clear();
|
|
33995
|
+
editor3DSelection.add(id);
|
|
33996
|
+
return id;
|
|
33997
|
+
}
|
|
33998
|
+
|
|
33999
|
+
// remove the selected objects, as one undo
|
|
34000
|
+
function editor3DDelete()
|
|
34001
|
+
{
|
|
34002
|
+
if (!editor3DSelection.size) return false;
|
|
34003
|
+
editor3DStrokeEnd();
|
|
34004
|
+
editor3DChange((list)=>
|
|
34005
|
+
{
|
|
34006
|
+
for (let i = list.length; i--;)
|
|
34007
|
+
editor3DSelection.has(list[i].id) && list.splice(i, 1);
|
|
34008
|
+
});
|
|
34009
|
+
editor3DStrokeEnd();
|
|
34010
|
+
}
|
|
34011
|
+
|
|
34012
|
+
// copy the selected objects
|
|
34013
|
+
function editor3DCopySelection()
|
|
34014
|
+
{
|
|
34015
|
+
const selected = editor3DSelected();
|
|
34016
|
+
if (!selected.length) return false;
|
|
34017
|
+
editor3DClipboard = editor3DCopy(selected);
|
|
34018
|
+
}
|
|
34019
|
+
|
|
34020
|
+
// copy the selected objects and remove them
|
|
34021
|
+
function editor3DCut() { return editor3DCopySelection() === false ? false : editor3DDelete(); }
|
|
34022
|
+
|
|
34023
|
+
// add the copied objects, moved by an offset, with new ids and selected, as one undo
|
|
34024
|
+
function editor3DPaste(offset=vec3())
|
|
34025
|
+
{
|
|
34026
|
+
const copied = editor3DClipboard, ids = [];
|
|
34027
|
+
if (!copied?.length) return false;
|
|
34028
|
+
editor3DStrokeEnd();
|
|
34029
|
+
const pasted = editor3DChange((list)=>
|
|
34030
|
+
{
|
|
34031
|
+
let id = list.reduce((next, o)=> max(next, o.id + 1), 1);
|
|
34032
|
+
for (const from of copied)
|
|
34033
|
+
{
|
|
34034
|
+
const object = {...editor3DCopy(from), id: id++};
|
|
34035
|
+
editor3DSetTransform(object, editor3DPos(from).add(offset));
|
|
34036
|
+
list.push(object);
|
|
34037
|
+
ids.push(object.id);
|
|
34038
|
+
}
|
|
34039
|
+
});
|
|
34040
|
+
editor3DStrokeEnd();
|
|
34041
|
+
if (!pasted) return false;
|
|
34042
|
+
editor3DSelection.clear();
|
|
34043
|
+
ids.forEach((id)=> editor3DSelection.add(id));
|
|
34044
|
+
}
|
|
34045
|
+
|
|
34046
|
+
// copy the selected objects where they are and select the copies, the clipboard stays as it was
|
|
34047
|
+
function editor3DDuplicate()
|
|
34048
|
+
{
|
|
34049
|
+
const clipboard = editor3DClipboard;
|
|
34050
|
+
const done = editor3DCopySelection() === false ? false : editor3DPaste();
|
|
34051
|
+
editor3DClipboard = clipboard;
|
|
34052
|
+
return done;
|
|
34053
|
+
}
|
|
34054
|
+
|
|
34055
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
34056
|
+
// the session: opening, playing and closing
|
|
34057
|
+
|
|
34058
|
+
// if the level editor is the 3D one: as levelEditor.use3D says, or when a 3D level was loaded
|
|
34059
|
+
function editor3DWanted() { return !!render3D && (levelEditor.use3D ?? !!editor3DLevel); }
|
|
34060
|
+
|
|
34061
|
+
// put every object back where the level has it, and make one the game destroyed again, so what is on screen is
|
|
34062
|
+
// what Save writes; a type made by an arrow function made nothing to put back
|
|
34063
|
+
function editor3DRestore()
|
|
34064
|
+
{
|
|
34065
|
+
for (const object of editor3DObjects())
|
|
34066
|
+
{
|
|
34067
|
+
const type = level3DTypes.get(object.type), made = editor3DInstances.get(object.id);
|
|
34068
|
+
if (!type) continue;
|
|
34069
|
+
if (made instanceof EngineObject3D && !made.destroyed)
|
|
34070
|
+
editor3DPlaceInstance(made, object);
|
|
34071
|
+
else if (type.make.prototype && (!made || made.destroyed))
|
|
34072
|
+
editor3DMakeInstance(object);
|
|
34073
|
+
}
|
|
34074
|
+
}
|
|
34075
|
+
|
|
34076
|
+
// open or close the editor, the session goes on: the game is paused under it and reads no input
|
|
34077
|
+
function editor3DSetOpen(open)
|
|
34078
|
+
{
|
|
34079
|
+
open = !!open;
|
|
34080
|
+
if (!debug || editor3DIsOpen === open) return;
|
|
34081
|
+
if (open)
|
|
34082
|
+
{
|
|
34083
|
+
editor3DSetFreeCamera(false);
|
|
34084
|
+
editor3DLevel || editor3DLevelLoaded({}); // a new level, with nothing loaded
|
|
34085
|
+
editor3DCamera || editor3DCameraStart();
|
|
34086
|
+
editor3DGamePaused = paused;
|
|
34087
|
+
setPaused(true);
|
|
34088
|
+
setDebugOverlay(false); // out of the way of the level
|
|
34089
|
+
editor3DIsOpen = true;
|
|
34090
|
+
editor3DRestore();
|
|
34091
|
+
}
|
|
34092
|
+
else
|
|
34093
|
+
{
|
|
34094
|
+
editor3DDrag = editor3DHover = undefined;
|
|
34095
|
+
editor3DMouseOnPanel = false; // the panel hides, with no mouseleave
|
|
34096
|
+
editor3DStrokeEnd();
|
|
34097
|
+
editor3DIsOpen = false;
|
|
34098
|
+
setPaused(editor3DGamePaused);
|
|
34099
|
+
}
|
|
34100
|
+
inputCapture(editor3DViewOn());
|
|
34101
|
+
}
|
|
34102
|
+
|
|
34103
|
+
// open the editor, a new session starts at the game's camera and a return keeps the editor's
|
|
34104
|
+
function editor3DOpen()
|
|
34105
|
+
{
|
|
34106
|
+
editor3DSession || editor3DCameraStart();
|
|
34107
|
+
editor3DSession = true;
|
|
34108
|
+
editor3DSetOpen(true);
|
|
34109
|
+
}
|
|
34110
|
+
|
|
34111
|
+
// close the editor and end its session, Escape opens the debug overlay again
|
|
34112
|
+
function editor3DClose()
|
|
34113
|
+
{
|
|
34114
|
+
editor3DSession = false;
|
|
34115
|
+
editor3DSetOpen(false);
|
|
34116
|
+
}
|
|
34117
|
+
|
|
34118
|
+
// switch to playing, from a position when Play from mouse is on
|
|
34119
|
+
function editor3DPlay(pos)
|
|
34120
|
+
{
|
|
34121
|
+
editor3DSetOpen(false);
|
|
34122
|
+
editor3DPlayFromMouse && pos && levelEditor.onPlayFrom?.(pos.copy());
|
|
34123
|
+
}
|
|
34124
|
+
|
|
34125
|
+
// rebuild the level through the game's hook and play it
|
|
34126
|
+
function editor3DRestart()
|
|
34127
|
+
{
|
|
34128
|
+
if (!levelEditor.onRestart) return;
|
|
34129
|
+
editor3DSetOpen(false);
|
|
34130
|
+
levelEditor.onRestart();
|
|
34131
|
+
}
|
|
34132
|
+
|
|
34133
|
+
// the level editor's open, close and isOpen go to the 3D editor when the game has a 3D level
|
|
34134
|
+
if (debug)
|
|
34135
|
+
{
|
|
34136
|
+
const open2D = levelEditor.open.bind(levelEditor), close2D = levelEditor.close.bind(levelEditor);
|
|
34137
|
+
levelEditor.open = ()=> editor3DWanted() ? editor3DOpen() : open2D();
|
|
34138
|
+
levelEditor.close = ()=> editor3DIsOpen || editor3DSession ? editor3DClose() : close2D();
|
|
34139
|
+
Object.defineProperty(levelEditor, 'isOpen', {get: ()=> editor3DIsOpen || editorIsOpen});
|
|
34140
|
+
}
|
|
34141
|
+
|
|
34142
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
34143
|
+
// files: the level as JSON, Save, and the autosave
|
|
34144
|
+
|
|
34145
|
+
// a value as JSON on one line, with a space after each comma and colon, as a person would write it
|
|
34146
|
+
function editor3DJSON(value)
|
|
34147
|
+
{
|
|
34148
|
+
if (isArray(value))
|
|
34149
|
+
return '[' + value.map(editor3DJSON).join(', ') + ']';
|
|
34150
|
+
if (value && typeof value === 'object')
|
|
34151
|
+
return '{' + Object.entries(value).filter(([, v])=> v !== undefined)
|
|
34152
|
+
.map(([key, v])=> JSON.stringify(key) + ': ' + editor3DJSON(v)).join(', ') + '}';
|
|
34153
|
+
return JSON.stringify(value) ?? 'null';
|
|
34154
|
+
}
|
|
34155
|
+
|
|
34156
|
+
// a level as the file has it: its version, what else it holds, and its objects, one to a line
|
|
34157
|
+
function editor3DLevelJSON(level=editor3DLevel)
|
|
34158
|
+
{
|
|
34159
|
+
const {littlejs3D, objects, ...rest} = level;
|
|
34160
|
+
const lines = [`"littlejs3D": ${LEVEL3D_VERSION}`];
|
|
34161
|
+
for (const [key, value] of Object.entries(rest))
|
|
34162
|
+
lines.push(JSON.stringify(key) + ': ' + editor3DJSON(value));
|
|
34163
|
+
const list = (isArray(objects) ? objects : []).map((o)=> ' ' + editor3DJSON(o));
|
|
34164
|
+
lines.push('"objects": [' + (list.length ? '\n' + list.join(',\n') + '\n ]' : ']'));
|
|
34165
|
+
return '{\n ' + lines.join(',\n ') + '\n}\n';
|
|
34166
|
+
}
|
|
34167
|
+
|
|
34168
|
+
// every page keeps its own autosaves, one for each level by its key
|
|
34169
|
+
function editor3DSaveName() { return 'LittleJS editor 3D ' + (globalThis.location?.pathname ?? ''); }
|
|
34170
|
+
const editor3DSaves = ()=> readSaveData(editor3DSaveName(), {});
|
|
34171
|
+
|
|
34172
|
+
// if the last autosave did not fit in storage, the panel says so
|
|
34173
|
+
let editor3DSaveFailed = false;
|
|
34174
|
+
|
|
34175
|
+
// remember the level's objects, or forget them when they are back to the file
|
|
34176
|
+
function editor3DAutosave(level=editor3DLevel)
|
|
34177
|
+
{
|
|
34178
|
+
const record = editor3DRecords.get(level);
|
|
34179
|
+
if (!record || record.pending) return; // edits waiting to be applied keep their autosave
|
|
34180
|
+
const saves = editor3DSaves(), objects = isArray(level.objects) ? level.objects : [];
|
|
34181
|
+
// the level as it was loaded has nothing to keep; one a Save wrote is kept until a reload shows the file has it,
|
|
34182
|
+
// since the browser gives a picked file's name and not its folder, and a file of the same name may be a copy
|
|
34183
|
+
if (editor3DSame(objects, record.original) && !record.savedHash)
|
|
34184
|
+
delete saves[record.key];
|
|
34185
|
+
else
|
|
34186
|
+
saves[record.key] = {hash: record.hash, savedHash: record.savedHash, objects: editor3DCopy(objects)};
|
|
34187
|
+
try
|
|
34188
|
+
{
|
|
34189
|
+
localStorage.setItem(editor3DSaveName(), JSON.stringify(saves));
|
|
34190
|
+
editor3DSaveFailed = false;
|
|
34191
|
+
}
|
|
34192
|
+
catch { editor3DSaveFailed = true; }
|
|
34193
|
+
}
|
|
34194
|
+
|
|
34195
|
+
// put the autosaved edits of a file that changed into the level, as one undo
|
|
34196
|
+
function editor3DApplyPending()
|
|
34197
|
+
{
|
|
34198
|
+
const record = editor3DRecords.get(editor3DLevel), saved = record?.pending;
|
|
34199
|
+
if (!saved || !isArray(saved.objects)) return false;
|
|
34200
|
+
record.pending = undefined;
|
|
34201
|
+
editor3DFixIds(saved.objects);
|
|
34202
|
+
editor3DChange((list)=> { list.length = 0; list.push(...editor3DCopy(saved.objects)); });
|
|
34203
|
+
editor3DStrokeEnd();
|
|
34204
|
+
editor3DAutosave();
|
|
34205
|
+
}
|
|
34206
|
+
|
|
34207
|
+
// drop the autosaved edits of a file that changed
|
|
34208
|
+
function editor3DDropPending()
|
|
34209
|
+
{
|
|
34210
|
+
const record = editor3DRecords.get(editor3DLevel);
|
|
34211
|
+
if (!record?.pending) return false;
|
|
34212
|
+
record.pending = undefined;
|
|
34213
|
+
editor3DAutosave();
|
|
34214
|
+
}
|
|
34215
|
+
|
|
34216
|
+
// 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
|
|
34217
|
+
// file that changed are dropped, and a level already as its file has it is left alone
|
|
34218
|
+
function editor3DRevert()
|
|
34219
|
+
{
|
|
34220
|
+
const record = editor3DRecords.get(editor3DLevel);
|
|
34221
|
+
if (!record) return false;
|
|
34222
|
+
record.pending = undefined;
|
|
34223
|
+
if (editor3DSame(editor3DObjects(), record.original))
|
|
34224
|
+
return editor3DAutosave(), false;
|
|
34225
|
+
editor3DChange((list)=> { list.length = 0; list.push(...editor3DCopy(record.original)); });
|
|
34226
|
+
editor3DStrokeEnd();
|
|
34227
|
+
}
|
|
34228
|
+
|
|
34229
|
+
// save the level as JSON: where the browser lets a page write files, Chrome and Edge, to a file picked once and
|
|
34230
|
+
// written again on each Save after, or picked again with Save As; elsewhere as a download; a file of the level's
|
|
34231
|
+
// own name that was written is the file from then on, for the autosave; resolves to how it saved, undefined when
|
|
34232
|
+
// the picker was closed
|
|
34233
|
+
async function editor3DSave(pickAgain=false)
|
|
34234
|
+
{
|
|
34235
|
+
const level = editor3DLevel, record = editor3DRecords.get(level);
|
|
34236
|
+
if (!record) return;
|
|
34237
|
+
editor3DStrokeEnd();
|
|
34238
|
+
// what is written, kept as it is now: the level can change while the file is picked and written, and those
|
|
34239
|
+
// edits are not in the file, so they stay in the autosave; the level and record are this one's, whichever is
|
|
34240
|
+
// open by then
|
|
34241
|
+
const text = editor3DLevelJSON(level), written = editor3DCopy(JSON.parse(text).objects);
|
|
34242
|
+
const picker = /** @type {any} */ (globalThis).showSaveFilePicker;
|
|
34243
|
+
const fileKey = (globalThis.location?.pathname ?? '') + ' 3D ' + record.key;
|
|
34244
|
+
if (picker)
|
|
34245
|
+
{
|
|
34246
|
+
try
|
|
34247
|
+
{
|
|
34248
|
+
if (!pickAgain && !record.fileHandle)
|
|
34249
|
+
{
|
|
34250
|
+
const handle = await editorFileStore.get(fileKey), mode = {mode: 'readwrite'};
|
|
34251
|
+
if (handle && (await handle.queryPermission(mode) === 'granted' ||
|
|
34252
|
+
await handle.requestPermission(mode) === 'granted'))
|
|
34253
|
+
record.fileHandle = handle;
|
|
34254
|
+
}
|
|
34255
|
+
if (pickAgain || !record.fileHandle)
|
|
34256
|
+
{
|
|
34257
|
+
record.fileHandle = await picker.call(globalThis, {suggestedName: record.fileName,
|
|
34258
|
+
types: [{description: 'LittleJS 3D level', accept: {'application/json': ['.json']}}]});
|
|
34259
|
+
editorFileStore.set(fileKey, record.fileHandle);
|
|
34260
|
+
}
|
|
34261
|
+
const writable = await record.fileHandle.createWritable();
|
|
34262
|
+
await writable.write(text);
|
|
34263
|
+
await writable.close();
|
|
34264
|
+
if (record.fileHandle.name === record.fileName)
|
|
34265
|
+
{
|
|
34266
|
+
record.original = written;
|
|
34267
|
+
record.savedHash = editor3DHash(JSON.stringify(written));
|
|
34268
|
+
editor3DAutosave(level);
|
|
34269
|
+
}
|
|
34270
|
+
return 'written';
|
|
34271
|
+
}
|
|
34272
|
+
catch (error)
|
|
34273
|
+
{
|
|
34274
|
+
if (error?.name === 'AbortError') return; // the picker was closed, nothing saved
|
|
34275
|
+
record.fileHandle = undefined; // a file it could not write, a download instead, and not kept
|
|
34276
|
+
editorFileStore.set(fileKey, undefined);
|
|
34277
|
+
}
|
|
34278
|
+
}
|
|
34279
|
+
saveText(text, record.fileName, 'application/json');
|
|
34280
|
+
return 'downloaded';
|
|
34281
|
+
}
|
|
34282
|
+
|
|
34283
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
34284
|
+
// the tools: handles on the selection, dragged with the mouse
|
|
34285
|
+
|
|
34286
|
+
const EDITOR3D_HANDLE_SIZE = .15; // a handle's length as a part of the view's height
|
|
34287
|
+
const EDITOR3D_HANDLE_REACH = 10; // how many pixels from a handle still grab it
|
|
34288
|
+
const EDITOR3D_AXES = {x: vec3(1, 0, 0), y: vec3(0, 1, 0), z: vec3(0, 0, 1)};
|
|
34289
|
+
|
|
34290
|
+
let editor3DTool = 'move'; // 'select', 'move', 'rotate' or 'scale'
|
|
34291
|
+
let editor3DGrid = true; // moves, turns and sizes go in steps, Ctrl flips it for a drag
|
|
34292
|
+
let editor3DGroundSnap = true; // a body drag slides along what is under the mouse
|
|
34293
|
+
let editor3DMoveStep = 1, editor3DRotateStep = 15, editor3DScaleStep = .25; // the rotate step in degrees
|
|
34294
|
+
let editor3DHelp = false; // the keys are shown
|
|
34295
|
+
let editor3DMouseOnPanel = false;
|
|
34296
|
+
// the drag being made: {kind, ...}, a 'box' drag has from and to in screen pixels
|
|
34297
|
+
/** @type {any} */
|
|
34298
|
+
let editor3DDrag;
|
|
34299
|
+
// the handle under the mouse
|
|
34300
|
+
/** @type {any} */
|
|
34301
|
+
let editor3DHover;
|
|
34302
|
+
// what an Alt drag orbits around
|
|
34303
|
+
/** @type {Vector3|undefined} */
|
|
34304
|
+
let editor3DOrbitPivot;
|
|
34305
|
+
|
|
34306
|
+
// the middle of the selection, where the handles are
|
|
34307
|
+
function editor3DSelectionCenter()
|
|
34308
|
+
{
|
|
34309
|
+
const selected = editor3DSelected();
|
|
34310
|
+
if (selected.length)
|
|
34311
|
+
return selected.reduce((sum, o)=> sum.add(editor3DPos(o)), vec3()).scale(1 / selected.length);
|
|
34312
|
+
}
|
|
34313
|
+
|
|
34314
|
+
// the handles of the tool on the selection, each {kind, axis, center, length, direction or axes, points}: Move has
|
|
34315
|
+
// an arrow along each world axis and a square between each pair, Rotate a ring for the pitch, yaw and roll of the
|
|
34316
|
+
// first selected object, Scale a handle along each of its own axes and a box in the middle for all three
|
|
34317
|
+
function editor3DHandles()
|
|
34318
|
+
{
|
|
34319
|
+
const center = editor3DSelectionCenter(), tool = editor3DTool, handles = [];
|
|
34320
|
+
if (!center || tool === 'select') return handles;
|
|
34321
|
+
const length = editor3DScreenScale(center) * EDITOR3D_HANDLE_SIZE;
|
|
34322
|
+
const rotation = editor3DRotation(editor3DSelected()[0]).scale(PI / 180), frame = buildMatrix(vec3(), rotation);
|
|
34323
|
+
for (const axis of 'xyz')
|
|
34324
|
+
{
|
|
34325
|
+
if (tool === 'rotate')
|
|
34326
|
+
{
|
|
34327
|
+
const axes = editor3DRingAxes(rotation, axis), points = [];
|
|
34328
|
+
for (let i = 0; i <= 48; ++i)
|
|
34329
|
+
{
|
|
34330
|
+
const a = i / 48 * PI * 2;
|
|
34331
|
+
points.push(center.add(axes.across.scale(cos(a) * length)).add(axes.along.scale(sin(a) * length)));
|
|
34332
|
+
}
|
|
34333
|
+
handles.push({kind: 'ring', axis, center, length, axes, points});
|
|
34334
|
+
continue;
|
|
34335
|
+
}
|
|
34336
|
+
|
|
34337
|
+
// an arrow or scale handle starts a little out from the middle, which is left for the object's body
|
|
34338
|
+
const direction = tool === 'move' ? EDITOR3D_AXES[axis] : frame.transformDirection(EDITOR3D_AXES[axis]);
|
|
34339
|
+
handles.push({kind: tool === 'move' ? 'arrow' : 'scale', axis, center, length, direction,
|
|
34340
|
+
points: [center.add(direction.scale(length * .2)), center.add(direction.scale(length))]});
|
|
34341
|
+
if (tool === 'move')
|
|
34342
|
+
{
|
|
34343
|
+
// the square between the other two axes moves along both
|
|
34344
|
+
const [a, b] = 'xyz'.replace(axis, '');
|
|
34345
|
+
handles.push({kind: 'plane', axis: a + b, center, length, direction,
|
|
34346
|
+
points: [center.add(EDITOR3D_AXES[a].add(EDITOR3D_AXES[b]).scale(length * .35))]});
|
|
34347
|
+
}
|
|
34348
|
+
}
|
|
34349
|
+
tool === 'scale' && handles.push({kind: 'scaleAll', axis: 'xyz', center, length, points: [center]});
|
|
34350
|
+
return handles;
|
|
34351
|
+
}
|
|
34352
|
+
|
|
34353
|
+
// the handle nearest a screen position, within reach; a square or box is a point and a little easier to take
|
|
34354
|
+
function editor3DHandleAt(screenPos)
|
|
34355
|
+
{
|
|
34356
|
+
let nearest, reach = EDITOR3D_HANDLE_REACH;
|
|
34357
|
+
for (const handle of editor3DHandles())
|
|
34358
|
+
{
|
|
34359
|
+
// a ring seen edge on is a line on screen and the mouse's ray runs along its plane, it can not be dragged
|
|
34360
|
+
const {points, center} = handle, toward = center.subtract(editor3DCamera.pos).normalize();
|
|
34361
|
+
if (handle.kind === 'ring' && abs(handle.axes.axis.dot(toward)) < .15) continue;
|
|
34362
|
+
|
|
34363
|
+
// an arrow or scale handle seen end on is a dot over the object, which is left for the object's body
|
|
34364
|
+
if (points.length === 2)
|
|
34365
|
+
{
|
|
34366
|
+
const a = render3D.worldToScreen(points[0]), b = render3D.worldToScreen(points[1]);
|
|
34367
|
+
if (a && b && a.distance(b) < 12) continue;
|
|
34368
|
+
}
|
|
34369
|
+
let distance = Infinity;
|
|
34370
|
+
if (points.length === 1)
|
|
34371
|
+
{
|
|
34372
|
+
const at = render3D.worldToScreen(points[0]);
|
|
34373
|
+
at && (distance = screenPos.distance(at) - 4);
|
|
34374
|
+
}
|
|
34375
|
+
else
|
|
34376
|
+
for (let i = 1; i < points.length; ++i)
|
|
34377
|
+
distance = min(distance, editor3DSegmentDistance(screenPos, points[i-1], points[i]));
|
|
34378
|
+
if (distance < reach)
|
|
34379
|
+
reach = distance, nearest = handle;
|
|
34380
|
+
}
|
|
34381
|
+
return nearest;
|
|
34382
|
+
}
|
|
34383
|
+
|
|
34384
|
+
// 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
|
|
34385
|
+
// where it made nothing to hit, like a light or a player start; its id
|
|
34386
|
+
function editor3DPickAt(ray)
|
|
34387
|
+
{
|
|
34388
|
+
let nearest, id;
|
|
34389
|
+
for (const object of editor3DObjects())
|
|
34390
|
+
{
|
|
34391
|
+
const parts = editor3DVisible(object) ? editor3DParts(editor3DInstances.get(object.id)) : [];
|
|
34392
|
+
let distance = parts.length ? undefined : raycastBox(ray, editor3DPos(object), vec3(1));
|
|
34393
|
+
for (const part of parts)
|
|
34394
|
+
{
|
|
34395
|
+
const d = render3DRaycastObject(ray, part);
|
|
34396
|
+
if (d !== undefined && (distance === undefined || d < distance))
|
|
34397
|
+
distance = d;
|
|
34398
|
+
}
|
|
34399
|
+
if (distance !== undefined && (nearest === undefined || distance < nearest))
|
|
34400
|
+
nearest = distance, id = object.id;
|
|
34401
|
+
}
|
|
34402
|
+
return id;
|
|
34403
|
+
}
|
|
34404
|
+
|
|
34405
|
+
// what the game made for an object and the parts attached to it, its children and theirs
|
|
34406
|
+
function editor3DParts(made, parts=[])
|
|
34407
|
+
{
|
|
34408
|
+
if (made instanceof EngineObject && !made.destroyed)
|
|
34409
|
+
{
|
|
34410
|
+
parts.push(made);
|
|
34411
|
+
for (const child of made.children)
|
|
34412
|
+
editor3DParts(child, parts);
|
|
34413
|
+
}
|
|
34414
|
+
return parts;
|
|
34415
|
+
}
|
|
34416
|
+
|
|
34417
|
+
// what the game made for the selected objects, with their parts, left out when the selection looks for a surface
|
|
34418
|
+
// to stand on
|
|
34419
|
+
function editor3DSelectedInstances()
|
|
34420
|
+
{
|
|
34421
|
+
return new Set(editor3DSelected().flatMap((o)=> editor3DParts(editor3DInstances.get(o.id))));
|
|
34422
|
+
}
|
|
34423
|
+
|
|
34424
|
+
// where each selected object is, as a drag starts from
|
|
34425
|
+
function editor3DDragStart()
|
|
34426
|
+
{
|
|
34427
|
+
return new Map(editor3DSelected().map((o)=>
|
|
34428
|
+
[o.id, {pos: editor3DPos(o), rotation: editor3DRotation(o), scale: editor3DScale(o)}]));
|
|
34429
|
+
}
|
|
34430
|
+
|
|
34431
|
+
// the left button went down: on a handle it drags it, with a brush it places one, on an object it selects it and
|
|
34432
|
+
// may drag it, and on nothing it drags out a box to select with
|
|
34433
|
+
function editor3DPress(mouse, ray, shift)
|
|
34434
|
+
{
|
|
34435
|
+
const handle = editor3DHandleAt(mouse), from = mouse.copy();
|
|
34436
|
+
if (handle)
|
|
34437
|
+
{
|
|
34438
|
+
const {kind, center, direction, axes} = handle;
|
|
34439
|
+
const grab = kind === 'ring' ? editor3DRingAngle(ray, center, axes) :
|
|
34440
|
+
kind === 'plane' ? editor3DPlanePoint(ray, center, direction) :
|
|
34441
|
+
kind === 'scaleAll' ? 0 : editor3DAxisDistance(ray, center, direction);
|
|
34442
|
+
editor3DDrag = {kind, handle, from, grab, start: editor3DDragStart()};
|
|
34443
|
+
return;
|
|
34444
|
+
}
|
|
34445
|
+
if (editor3DBrush)
|
|
34446
|
+
{
|
|
34447
|
+
editor3DPlaceAt(editor3DBrush, ray);
|
|
34448
|
+
shift || (editor3DBrush = undefined);
|
|
34449
|
+
editor3DTool = 'move';
|
|
34450
|
+
return;
|
|
34451
|
+
}
|
|
34452
|
+
const id = editor3DPickAt(ray);
|
|
34453
|
+
if (id === undefined)
|
|
34454
|
+
{
|
|
34455
|
+
editor3DDrag = {kind: 'box', from, to: from.copy(), shift};
|
|
34456
|
+
return;
|
|
34457
|
+
}
|
|
34458
|
+
if (shift)
|
|
34459
|
+
{
|
|
34460
|
+
editor3DSelection.has(id) ? editor3DSelection.delete(id) : editor3DSelection.add(id);
|
|
34461
|
+
return;
|
|
34462
|
+
}
|
|
34463
|
+
if (!editor3DSelection.has(id))
|
|
34464
|
+
{
|
|
34465
|
+
editor3DSelection.clear();
|
|
34466
|
+
editor3DSelection.add(id);
|
|
34467
|
+
}
|
|
34468
|
+
|
|
34469
|
+
// a drag of its body moves it level, at its height, so it stays under the mouse; seen from the side, where the
|
|
34470
|
+
// mouse's ray runs along that plane, it moves across the view instead, in the upright plane facing it
|
|
34471
|
+
const pos = editor3DPos(editor3DObject(id)), forward = editor3DCamera.getForward();
|
|
34472
|
+
const side = abs(ray.direction.y) < .2 && vec3(forward.x, 0, forward.z).normalize();
|
|
34473
|
+
const normal = side && side.lengthSquared() ? side : EDITOR3D_UP;
|
|
34474
|
+
editor3DDrag = {kind: 'body', id, from, moving: false, ignore: editor3DSelectedInstances(), normal,
|
|
34475
|
+
start: editor3DDragStart(), level: editor3DPlanePoint(ray, pos, normal) ?? pos};
|
|
34476
|
+
}
|
|
34477
|
+
|
|
34478
|
+
// place an object of a type where a ray lands, standing on what is there, snapped, selected, as one undo
|
|
34479
|
+
function editor3DPlaceAt(type, ray)
|
|
34480
|
+
{
|
|
34481
|
+
const point = editor3DSurface(ray);
|
|
34482
|
+
if (!point) return;
|
|
34483
|
+
editor3DStrokeEnd();
|
|
34484
|
+
const id = editor3DPlace(type, point);
|
|
34485
|
+
if (id === undefined) return;
|
|
34486
|
+
const size = editor3DSize(editor3DObject(id)), ignore = new Set(editor3DParts(editor3DInstances.get(id)));
|
|
34487
|
+
const snapped = editor3DSnapPos(editor3DRest(point, size), size, editor3DGrid ? editor3DMoveStep : 0);
|
|
34488
|
+
const y = editor3DLand(snapped.x, snapped.z, point.y, size, ignore);
|
|
34489
|
+
editor3DChange((list)=> editor3DSetTransform(list.find((o)=> o.id === id), vec3(snapped.x, y, snapped.z)));
|
|
34490
|
+
editor3DStrokeEnd();
|
|
34491
|
+
}
|
|
34492
|
+
|
|
34493
|
+
// the mouse moved with a drag held: the selection goes from where the drag started to where the mouse says
|
|
34494
|
+
function editor3DDragTo(drag, mouse, ray, snap)
|
|
34495
|
+
{
|
|
34496
|
+
if (drag.kind === 'box')
|
|
34497
|
+
{
|
|
34498
|
+
drag.to = mouse.copy();
|
|
34499
|
+
return;
|
|
34500
|
+
}
|
|
34501
|
+
if (drag.kind === 'body' && !(drag.moving ||= mouse.distance(drag.from) > 4)) return;
|
|
34502
|
+
const {kind, handle, start, grab} = drag, first = editor3DObject(drag.id ?? [...start.keys()][0]);
|
|
34503
|
+
const was = first && start.get(first.id);
|
|
34504
|
+
if (!was) return;
|
|
34505
|
+
const size = editor3DSize(first), turned = editor3DTurned(first), step = snap ? editor3DMoveStep : 0;
|
|
34506
|
+
const snapTo = (to)=> editor3DSnapPos(to, size, step, turned).subtract(was.pos);
|
|
34507
|
+
let move, turn, factor;
|
|
34508
|
+
if (kind === 'arrow')
|
|
34509
|
+
{
|
|
34510
|
+
const d = editor3DAxisDistance(ray, handle.center, handle.direction) - grab;
|
|
34511
|
+
move = handle.direction.multiply(snapTo(was.pos.add(handle.direction.scale(d)))); // along its axis alone
|
|
34512
|
+
}
|
|
34513
|
+
else if (kind === 'plane')
|
|
34514
|
+
{
|
|
34515
|
+
const point = editor3DPlanePoint(ray, handle.center, handle.direction);
|
|
34516
|
+
if (!point || !grab) return;
|
|
34517
|
+
move = vec3(1).subtract(handle.direction).multiply(snapTo(was.pos.add(point.subtract(grab))));
|
|
34518
|
+
}
|
|
34519
|
+
else if (kind === 'body')
|
|
34520
|
+
{
|
|
34521
|
+
// level under the mouse, and with ground snap it lands on what is under it there, from where it started
|
|
34522
|
+
const point = editor3DPlanePoint(ray, drag.level, drag.normal);
|
|
34523
|
+
if (!point) return;
|
|
34524
|
+
const across = snapTo(was.pos.add(point.subtract(drag.level)));
|
|
34525
|
+
const y = editor3DGroundSnap ? editor3DLand(was.pos.x + across.x, was.pos.z + across.z,
|
|
34526
|
+
was.pos.y - size.y / 2, size, drag.ignore) : was.pos.y;
|
|
34527
|
+
move = vec3(across.x, y - was.pos.y, across.z);
|
|
34528
|
+
}
|
|
34529
|
+
else if (kind === 'ring')
|
|
34530
|
+
{
|
|
34531
|
+
const angle = editor3DRingAngle(ray, handle.center, handle.axes);
|
|
34532
|
+
if (angle === undefined || grab === undefined) return;
|
|
34533
|
+
turn = mod((angle - grab) * 180 / PI + 180, 360) - 180; // the short way around
|
|
34534
|
+
}
|
|
34535
|
+
else if (kind === 'scale')
|
|
34536
|
+
{
|
|
34537
|
+
if (abs(grab) < 1e-6) return;
|
|
34538
|
+
factor = vec3(1);
|
|
34539
|
+
factor[handle.axis] = max(editor3DAxisDistance(ray, handle.center, handle.direction) / grab, .01);
|
|
34540
|
+
}
|
|
34541
|
+
else // the box in the middle sizes all three, right and up is larger
|
|
34542
|
+
factor = vec3(Math.exp((mouse.x - drag.from.x - mouse.y + drag.from.y) / 200));
|
|
34543
|
+
|
|
34544
|
+
editor3DChange((list)=>
|
|
34545
|
+
{
|
|
34546
|
+
for (const object of list)
|
|
34547
|
+
{
|
|
34548
|
+
const s = start.get(object.id);
|
|
34549
|
+
if (!s) continue;
|
|
34550
|
+
if (move)
|
|
34551
|
+
editor3DSetTransform(object, s.pos.add(move));
|
|
34552
|
+
if (turn !== undefined)
|
|
34553
|
+
{
|
|
34554
|
+
const rotation = s.rotation.copy();
|
|
34555
|
+
rotation[handle.axis] = editor3DSnap(rotation[handle.axis] + turn, snap ? editor3DRotateStep : 0);
|
|
34556
|
+
editor3DSetTransform(object, undefined, rotation);
|
|
34557
|
+
}
|
|
34558
|
+
if (factor)
|
|
34559
|
+
{
|
|
34560
|
+
const size = snap ? editor3DScaleStep : 0, least = size || .01;
|
|
34561
|
+
const scale = s.scale.multiply(factor);
|
|
34562
|
+
editor3DSetTransform(object, undefined, undefined, vec3(max(editor3DSnap(scale.x, size), least),
|
|
34563
|
+
max(editor3DSnap(scale.y, size), least), max(editor3DSnap(scale.z, size), least)));
|
|
34564
|
+
}
|
|
34565
|
+
}
|
|
34566
|
+
});
|
|
34567
|
+
}
|
|
34568
|
+
|
|
34569
|
+
// the left button went up: a box selects what is inside it, by where each object's middle is on screen, and a
|
|
34570
|
+
// press that never moved clears the selection; any other drag is one undo
|
|
34571
|
+
function editor3DDragEnd(drag, mouse)
|
|
34572
|
+
{
|
|
34573
|
+
editor3DDrag = undefined;
|
|
34574
|
+
if (drag.kind !== 'box')
|
|
34575
|
+
return editor3DStrokeEnd();
|
|
34576
|
+
drag.shift || editor3DSelection.clear();
|
|
34577
|
+
const {from} = drag;
|
|
34578
|
+
if (mouse.distance(from) < 4) return;
|
|
34579
|
+
const low = vec2(min(from.x, mouse.x), min(from.y, mouse.y));
|
|
34580
|
+
const high = vec2(max(from.x, mouse.x), max(from.y, mouse.y));
|
|
34581
|
+
for (const object of editor3DObjects())
|
|
34582
|
+
{
|
|
34583
|
+
const at = render3D.worldToScreen(editor3DPos(object));
|
|
34584
|
+
at && at.x >= low.x && at.x <= high.x && at.y >= low.y && at.y <= high.y &&
|
|
34585
|
+
editor3DSelection.add(object.id);
|
|
34586
|
+
}
|
|
34587
|
+
}
|
|
34588
|
+
|
|
34589
|
+
// take the drag being made back, with nothing to undo
|
|
34590
|
+
function editor3DDragCancel()
|
|
34591
|
+
{
|
|
34592
|
+
editor3DDrag = undefined;
|
|
34593
|
+
editor3DStrokeCancel();
|
|
34594
|
+
}
|
|
34595
|
+
|
|
34596
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
34597
|
+
// the editor's camera, keys and update
|
|
34598
|
+
|
|
34599
|
+
// the surface under a screen position, under the mouse and under the middle of the view, seen through the
|
|
34600
|
+
// editor's camera
|
|
34601
|
+
function editor3DScreenPoint(screenPos)
|
|
34602
|
+
{ return editor3DWithView(()=> editor3DSurface(render3D.screenToRay(screenPos))); }
|
|
34603
|
+
function editor3DMousePoint() { return editor3DScreenPoint(mousePosScreen); }
|
|
34604
|
+
function editor3DViewPoint() { return editor3DScreenPoint(mainCanvasSize.scale(.5)); }
|
|
34605
|
+
|
|
34606
|
+
// move the camera back along its view until the selection is in the middle and fits
|
|
34607
|
+
function editor3DFrame()
|
|
34608
|
+
{
|
|
34609
|
+
const center = editor3DSelectionCenter(), camera = editor3DCamera;
|
|
34610
|
+
if (!center) return false;
|
|
34611
|
+
const radius = editor3DSelected().reduce((r, o)=>
|
|
34612
|
+
max(r, editor3DPos(o).distance(center) + editor3DSize(o).length() / 2), 0);
|
|
34613
|
+
camera.pos = center.subtract(camera.getForward().scale(max(radius * 2.5, 3)));
|
|
34614
|
+
render3D.updateMatrices();
|
|
34615
|
+
}
|
|
34616
|
+
|
|
34617
|
+
// drop the selection straight down onto what is under it, each object by itself, as one undo
|
|
34618
|
+
function editor3DDropSelection()
|
|
34619
|
+
{
|
|
34620
|
+
const ignore = editor3DSelectedInstances();
|
|
34621
|
+
if (!editor3DSelection.size) return false;
|
|
34622
|
+
editor3DStrokeEnd();
|
|
34623
|
+
editor3DChange((list)=>
|
|
34624
|
+
{
|
|
34625
|
+
for (const object of list)
|
|
34626
|
+
{
|
|
34627
|
+
const to = editor3DSelection.has(object.id) &&
|
|
34628
|
+
editor3DDrop(editor3DPos(object), editor3DSize(object), ignore);
|
|
34629
|
+
to && editor3DSetTransform(object, to);
|
|
34630
|
+
}
|
|
34631
|
+
});
|
|
34632
|
+
editor3DStrokeEnd();
|
|
34633
|
+
}
|
|
34634
|
+
|
|
34635
|
+
// paste the copied objects with their middle under the mouse, the lowest standing on what is there, snapped by
|
|
34636
|
+
// the first of them; where they were when the mouse is over nothing
|
|
34637
|
+
function editor3DPasteAtMouse()
|
|
34638
|
+
{
|
|
34639
|
+
const copied = editor3DClipboard, point = !editor3DMouseOnPanel && editor3DMousePoint();
|
|
34640
|
+
if (!copied?.length) return false;
|
|
34641
|
+
if (!point) return editor3DPaste();
|
|
34642
|
+
const center = copied.reduce((sum, o)=> sum.add(editor3DPos(o)), vec3()).scale(1 / copied.length);
|
|
34643
|
+
const bottom = copied.reduce((low, o)=> min(low, editor3DPos(o).y - editor3DSize(o).y / 2), Infinity);
|
|
34644
|
+
const first = copied[0], was = editor3DPos(first);
|
|
34645
|
+
const snapped = editor3DSnapPos(was.add(vec3(point.x - center.x, 0, point.z - center.z)), editor3DSize(first),
|
|
34646
|
+
editor3DGrid ? editor3DMoveStep : 0, editor3DTurned(first));
|
|
34647
|
+
const land = editor3DLand(snapped.x, snapped.z, point.y, editor3DSize(first)) - editor3DSize(first).y / 2;
|
|
34648
|
+
return editor3DPaste(vec3(snapped.x - was.x, land - bottom, snapped.z - was.z));
|
|
34649
|
+
}
|
|
34650
|
+
|
|
34651
|
+
// the keys by their position on the keyboard, each called with whether Shift is held; the tool keys are the ones
|
|
34652
|
+
// Unity, Unreal and Godot use
|
|
34653
|
+
/** @type {Object<string, function(boolean=): any>} */
|
|
34654
|
+
const editor3DKeys =
|
|
34655
|
+
{
|
|
34656
|
+
KeyQ: ()=> { editor3DTool = 'select'; editor3DBrush = undefined; },
|
|
34657
|
+
KeyW: ()=> { editor3DTool = 'move'; },
|
|
34658
|
+
KeyE: ()=> { editor3DTool = 'rotate'; },
|
|
34659
|
+
KeyR: ()=> { editor3DTool = 'scale'; },
|
|
34660
|
+
KeyG: ()=> { editor3DGrid = !editor3DGrid; },
|
|
34661
|
+
KeyF: ()=> editor3DFrame(),
|
|
34662
|
+
End: ()=> editor3DDropSelection(),
|
|
34663
|
+
Delete: ()=> editor3DDelete(),
|
|
34664
|
+
Backspace: ()=> editor3DDelete(),
|
|
34665
|
+
Slash: ()=> { editor3DHelp = !editor3DHelp; },
|
|
34666
|
+
};
|
|
34667
|
+
/** @type {Object<string, function(boolean=): any>} */
|
|
34668
|
+
const editor3DCtrlKeys =
|
|
34669
|
+
{
|
|
34670
|
+
KeyZ: (shift)=> editor3DUndo(shift),
|
|
34671
|
+
KeyY: ()=> editor3DUndo(true),
|
|
34672
|
+
KeyC: ()=> editor3DCopySelection(),
|
|
34673
|
+
KeyX: ()=> editor3DCut(),
|
|
34674
|
+
KeyV: ()=> editor3DPasteAtMouse(),
|
|
34675
|
+
KeyD: ()=> editor3DDuplicate(),
|
|
34676
|
+
};
|
|
34677
|
+
|
|
34678
|
+
// the camera: the right button looks and the keys fly while it is held, the middle button or Space and the left
|
|
34679
|
+
// pans, Alt and the left orbits the selection, and the wheel zooms toward what is under the mouse
|
|
34680
|
+
function editor3DCameraUpdate(seconds, shift, alt)
|
|
34681
|
+
{
|
|
34682
|
+
const camera = editor3DCamera, delta = inputCaptureDeltaScreen, wheel = inputCaptureWheel;
|
|
34683
|
+
const ahead = ()=> camera.pos.add(camera.getForward().scale(10));
|
|
34684
|
+
if (mouseIsDown(2))
|
|
34685
|
+
{
|
|
34686
|
+
editor3DFlySpeed = clamp(editor3DFlySpeed * Math.exp(-wheel * .2), .01, 10);
|
|
34687
|
+
editor3DFly(camera, editor3DFlyKeys(), delta, seconds, shift);
|
|
34688
|
+
}
|
|
34689
|
+
else if (mouseIsDown(1) || keyIsDown('Space') && mouseIsDown(0))
|
|
34690
|
+
{
|
|
34691
|
+
// the scene follows the mouse, at the depth of what is in the middle of the view
|
|
34692
|
+
const perPixel = editor3DScreenScale(editor3DViewPoint() ?? ahead()) / mainCanvasSize.y;
|
|
34693
|
+
camera.pos = camera.pos.subtract(camera.getRight().scale(delta.x * perPixel))
|
|
34694
|
+
.add(camera.getUp().scale(delta.y * perPixel));
|
|
34695
|
+
}
|
|
34696
|
+
else if (alt && mouseIsDown(0))
|
|
34697
|
+
{
|
|
34698
|
+
if (mouseWasPressed(0) || !editor3DOrbitPivot)
|
|
34699
|
+
editor3DOrbitPivot = editor3DSelectionCenter() ?? editor3DViewPoint() ?? ahead();
|
|
34700
|
+
const pivot = editor3DOrbitPivot, distance = camera.pos.distance(pivot);
|
|
34701
|
+
editor3DFly(camera, vec3(), delta, 0);
|
|
34702
|
+
camera.pos = pivot.subtract(camera.getForward().scale(distance));
|
|
34703
|
+
}
|
|
34704
|
+
else if (wheel && !editor3DMouseOnPanel)
|
|
34705
|
+
{
|
|
34706
|
+
// toward what is under the mouse, a part of the way there for each notch, and never through it
|
|
34707
|
+
render3D.updateMatrices();
|
|
34708
|
+
const ray = render3D.screenToRay(mousePosScreen), target = editor3DSurface(ray);
|
|
34709
|
+
const distance = target ? camera.pos.distance(target) : 10;
|
|
34710
|
+
camera.pos = camera.pos.add(ray.direction.scale(min(max(distance * .15, .2) * -wheel, distance - .5)));
|
|
34711
|
+
}
|
|
34712
|
+
render3D.updateMatrices(); // what follows picks and projects through the camera where it is now
|
|
34713
|
+
}
|
|
34714
|
+
|
|
34715
|
+
// the editor's update, called while it is open with the input readable and render3D looking through its camera
|
|
34716
|
+
function editor3DEditorUpdate(seconds)
|
|
34717
|
+
{
|
|
34718
|
+
const held = (...codes)=> codes.some((code)=> keyIsDown(code));
|
|
34719
|
+
const shift = held('ShiftLeft', 'ShiftRight'), alt = held('AltLeft', 'AltRight');
|
|
34720
|
+
const ctrl = held('ControlLeft', 'ControlRight', 'MetaLeft', 'MetaRight');
|
|
34721
|
+
const mouse = mousePosScreen;
|
|
34722
|
+
|
|
34723
|
+
// Escape, the debug key, puts a drag back, or else switches to playing; taken, so the debug overlay stays shut
|
|
34724
|
+
if (debugKey && keyWasPressed(debugKey))
|
|
34725
|
+
{
|
|
34726
|
+
inputClearKey(debugKey);
|
|
34727
|
+
editor3DDrag ? editor3DDragCancel() : editor3DPlay(editor3DMousePoint());
|
|
34728
|
+
return;
|
|
34729
|
+
}
|
|
34730
|
+
|
|
34731
|
+
// 0 exits the editor; cleared, so debugKeysAlways does not open it again in the same step
|
|
34732
|
+
if (keyWasPressed('Digit0'))
|
|
34733
|
+
{
|
|
34734
|
+
inputClearKey('Digit0');
|
|
34735
|
+
editor3DClose();
|
|
34736
|
+
return;
|
|
34737
|
+
}
|
|
34738
|
+
|
|
34739
|
+
editor3DCameraUpdate(seconds, shift, alt);
|
|
34740
|
+
const looking = mouseIsDown(2), cameraDrag = looking || mouseIsDown(1) || keyIsDown('Space') || alt;
|
|
34741
|
+
|
|
34742
|
+
// the keys, which wait for a drag to end, and fly while the right button is held
|
|
34743
|
+
if (!editor3DDrag && !looking)
|
|
34744
|
+
for (const [code, action] of Object.entries(ctrl ? editor3DCtrlKeys : editor3DKeys))
|
|
34745
|
+
keyWasPressed(code) && action(shift);
|
|
34746
|
+
|
|
34747
|
+
// a right press during a drag puts it back
|
|
34748
|
+
if (editor3DDrag && mouseWasPressed(2))
|
|
34749
|
+
return editor3DDragCancel();
|
|
34750
|
+
|
|
34751
|
+
const ray = render3D.screenToRay(mouse), idle = !cameraDrag && !editor3DMouseOnPanel && !editor3DDrag;
|
|
34752
|
+
editor3DHover = idle ? editor3DHandleAt(mouse) : undefined;
|
|
34753
|
+
if (idle && mouseWasPressed(0))
|
|
34754
|
+
editor3DPress(mouse, ray, shift);
|
|
34755
|
+
|
|
34756
|
+
// a quick click, down and up before this step, is a press and its release in one
|
|
34757
|
+
const drag = editor3DDrag;
|
|
34758
|
+
if (drag)
|
|
34759
|
+
mouseIsDown(0) ? editor3DDragTo(drag, mouse, ray, editor3DGrid !== ctrl) : editor3DDragEnd(drag, mouse);
|
|
34760
|
+
}
|
|
34761
|
+
|
|
34762
|
+
// Ctrl with a letter the editor uses is not the browser's while the editor is open, Ctrl+D would bookmark the page
|
|
34763
|
+
if (debug && globalThis.document?.addEventListener)
|
|
34764
|
+
document.addEventListener('keydown', (e)=>
|
|
34765
|
+
{
|
|
34766
|
+
if (editor3DIsOpen && (e.ctrlKey || e.metaKey) && /^Key[ZYCXVD]$/.test(e.code) && !editorIsTextField(e.target))
|
|
34767
|
+
e.preventDefault();
|
|
34768
|
+
});
|
|
34769
|
+
|
|
34770
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
34771
|
+
// what the panel does to the level, and what it says
|
|
34772
|
+
|
|
34773
|
+
// the help, every control
|
|
34774
|
+
const editor3DHelpLines =
|
|
34775
|
+
[
|
|
34776
|
+
'Left: select · Shift+Left: add or take away · Left drag from empty space: box select',
|
|
34777
|
+
'Q select · W move · E rotate · R scale: drag a handle, or the object itself',
|
|
34778
|
+
'G: grid snap, Ctrl flips it for a drag · End: drop to the ground',
|
|
34779
|
+
'Right button: look, and WASD and QE fly while it is held, Shift faster',
|
|
34780
|
+
'Wheel: zoom · Middle drag or Space+drag: pan · Alt+drag: orbit · F: frame the selection',
|
|
34781
|
+
'Pick a type, then click to place it, Shift+click keeps placing',
|
|
34782
|
+
'Delete · Ctrl+C / X / V: copy, cut, paste · Ctrl+D: duplicate · Ctrl+Z / Y: undo, redo',
|
|
34783
|
+
'Reset to file: the level as its file has it, Restart keeps your edits, Undo brings them back',
|
|
34784
|
+
'Esc: play and edit · 0: exit the editor · ?: keys',
|
|
34785
|
+
];
|
|
34786
|
+
|
|
34787
|
+
// the hint line, for what is held and what is selected
|
|
34788
|
+
function editor3DHint()
|
|
34789
|
+
{
|
|
34790
|
+
if (editor3DRecords.get(editor3DLevel)?.pending)
|
|
34791
|
+
return 'The level file changed: apply your edits or drop them';
|
|
34792
|
+
if (editor3DDrag)
|
|
34793
|
+
return editor3DDrag.kind === 'box' ? 'Let go to select what is inside' :
|
|
34794
|
+
'Esc or right click puts it back · Ctrl flips the snap';
|
|
34795
|
+
if (inputCaptureRead(()=> mouseIsDown(2)))
|
|
34796
|
+
return 'WASD and QE fly · Shift faster · the wheel sets the speed';
|
|
34797
|
+
if (editor3DBrush)
|
|
34798
|
+
return `Click to place a ${editor3DBrush} · Shift keeps placing · Q puts it down`;
|
|
34799
|
+
if (editor3DSelection.size)
|
|
34800
|
+
return 'Drag a handle or the object · W move · E rotate · R scale · End drops it';
|
|
34801
|
+
return 'Click to select · pick a type to place · the right button looks · ? keys';
|
|
34802
|
+
}
|
|
34803
|
+
|
|
34804
|
+
// if the properties box has an input for a default's type
|
|
34805
|
+
const editor3DPropertyEditable = (value)=> ['number', 'boolean', 'string'].includes(typeof value) ||
|
|
34806
|
+
isColor(value) || isVector2(value) || isVector3(value);
|
|
34807
|
+
|
|
34808
|
+
// set the position, rotation in degrees or scale of the one selected object, from the panel, as one undo
|
|
34809
|
+
function editor3DSetSelectedTransform(field, value)
|
|
34810
|
+
{
|
|
34811
|
+
const selected = editor3DSelected(), id = selected[0]?.id;
|
|
34812
|
+
if (selected.length !== 1 || !isVector3(value)) return false;
|
|
34813
|
+
editor3DStrokeEnd();
|
|
34814
|
+
editor3DChange((list)=> editor3DSetTransform(list.find((o)=> o.id === id), field === 'pos' ? value : undefined,
|
|
34815
|
+
field === 'rotation' ? value : undefined, field === 'scale' ? value : undefined));
|
|
34816
|
+
editor3DStrokeEnd();
|
|
34817
|
+
return true;
|
|
34818
|
+
}
|
|
34819
|
+
|
|
34820
|
+
// set a property of the one selected object, from the panel, as one undo; the value has the type of its default
|
|
34821
|
+
function editor3DSetSelectedProperty(name, value)
|
|
34822
|
+
{
|
|
34823
|
+
const selected = editor3DSelected(), id = selected[0]?.id;
|
|
34824
|
+
const defaults = selected[0] && level3DTypes.get(selected[0].type)?.defaults;
|
|
34825
|
+
const d = defaults && Object.hasOwn(defaults, name) ? defaults[name] : undefined;
|
|
34826
|
+
const sameType = isColor(d) ? isColor(value) : isVector3(d) ? isVector3(value) :
|
|
34827
|
+
isVector2(d) ? isVector2(value) : typeof value === typeof d;
|
|
34828
|
+
if (selected.length !== 1 || !editor3DPropertyEditable(d) || !sameType) return false;
|
|
34829
|
+
editor3DStrokeEnd();
|
|
34830
|
+
editor3DChange((list)=> editor3DSetProperty(list.find((o)=> o.id === id), name, value, d));
|
|
34831
|
+
editor3DStrokeEnd();
|
|
34832
|
+
return true;
|
|
34833
|
+
}
|
|
34834
|
+
|
|
34835
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
34836
|
+
// what the editor draws
|
|
34837
|
+
|
|
34838
|
+
const EDITOR3D_AXIS_COLORS = {x: hsl(0, .9, .55), y: hsl(.33, .9, .45), z: hsl(.6, .9, .6)};
|
|
34839
|
+
const EDITOR3D_SELECT_COLOR = hsl(.15, 1, .6), EDITOR3D_MARKER_COLOR = hsl(.55, 1, .7);
|
|
34840
|
+
const EDITOR3D_SOLID_COLOR = hsl(.08, 1, .55);
|
|
34841
|
+
|
|
34842
|
+
// a width in world units that shows as a number of pixels at a point
|
|
34843
|
+
function editor3DPixels(point, pixels) { return editor3DScreenScale(point) / mainCanvasSize.y * pixels; }
|
|
34844
|
+
|
|
34845
|
+
// the 12 edges of the unit box a matrix places
|
|
34846
|
+
function editor3DDrawWire(matrix, color, pixels=2)
|
|
34847
|
+
{
|
|
34848
|
+
const corner = (i)=> matrix.transformPoint(vec3(i & 1 ? .5 : -.5, i & 2 ? .5 : -.5, i & 4 ? .5 : -.5));
|
|
34849
|
+
const width = editor3DPixels(matrix.getTranslation(), pixels);
|
|
34850
|
+
for (let i = 0; i < 8; ++i)
|
|
34851
|
+
for (const bit of [1, 2, 4])
|
|
34852
|
+
i & bit || render3D.drawLine(corner(i), corner(i | bit), width, color);
|
|
34853
|
+
}
|
|
34854
|
+
|
|
34855
|
+
// a ring around a point across two axes
|
|
34856
|
+
function editor3DDrawRing(center, across, along, radius, color, pixels=2)
|
|
34857
|
+
{
|
|
34858
|
+
const points = [];
|
|
34859
|
+
for (let i = 0; i <= 32; ++i)
|
|
34860
|
+
{
|
|
34861
|
+
const a = i / 32 * PI * 2;
|
|
34862
|
+
points.push(center.add(across.scale(cos(a) * radius)).add(along.scale(sin(a) * radius)));
|
|
34863
|
+
}
|
|
34864
|
+
render3D.drawRibbon(points, editor3DPixels(center, pixels), undefined, color);
|
|
34865
|
+
}
|
|
34866
|
+
|
|
34867
|
+
// what the game made for an object, when it has something to see and click
|
|
34868
|
+
function editor3DVisible(object)
|
|
34869
|
+
{
|
|
34870
|
+
const made = editor3DInstances.get(object.id);
|
|
34871
|
+
return made instanceof EngineObject3D && !made.destroyed && (made.mesh || made.tileInfo) ? made : undefined;
|
|
34872
|
+
}
|
|
34873
|
+
|
|
34874
|
+
// 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
|
|
34875
|
+
// marker
|
|
34876
|
+
function editor3DBoxMatrix(object)
|
|
34877
|
+
{
|
|
34878
|
+
const made = editor3DVisible(object), bounds = made?.mesh && (made.mesh.bounds || made.mesh.getBounds());
|
|
34879
|
+
if (!made)
|
|
34880
|
+
return buildMatrix(editor3DPos(object));
|
|
34881
|
+
const matrix = render3DObjectMatrix(made).copy();
|
|
34882
|
+
return bounds ? matrix.multiply(buildMatrix(bounds.min.add(bounds.max).scale(.5), undefined,
|
|
34883
|
+
bounds.max.subtract(bounds.min))) : matrix.multiply(buildMatrix(vec3(), undefined, made.size3D));
|
|
34884
|
+
}
|
|
34885
|
+
|
|
34886
|
+
// the ground grid at height 0 around where the camera is, a line each unit and a stronger one each 10, fading
|
|
34887
|
+
// with distance; drawn with the depth test so the level hides it, and a hair over 0 so a floor there does not
|
|
34888
|
+
function editor3DDrawGrid()
|
|
34889
|
+
{
|
|
34890
|
+
const camera = editor3DCamera.pos, reach = 40, y = .01;
|
|
34891
|
+
const x0 = round(camera.x), z0 = round(camera.z);
|
|
34892
|
+
for (let i = -reach; i <= reach; ++i)
|
|
34893
|
+
for (const across of [false, true])
|
|
34894
|
+
{
|
|
34895
|
+
const at = (across ? z0 : x0) + i, strong = at % 10 === 0;
|
|
34896
|
+
if (!strong && abs(i) > reach / 2) continue;
|
|
34897
|
+
const fade = 1 - abs(i) / (strong ? reach : reach / 2);
|
|
34898
|
+
const a = across ? vec3(x0 - reach, y, at) : vec3(at, y, z0 - reach);
|
|
34899
|
+
const b = across ? vec3(x0 + reach, y, at) : vec3(at, y, z0 + reach);
|
|
34900
|
+
render3D.drawLine(a, b, strong ? .04 : .02, hsl(0, 0, 1, (strong ? .35 : .15) * fade));
|
|
34901
|
+
}
|
|
34902
|
+
}
|
|
34903
|
+
|
|
34904
|
+
// the cone a move arrow ends in, standing on Y, made the first time one is drawn
|
|
34905
|
+
let editor3DCone;
|
|
34906
|
+
const editor3DConeMesh = ()=> editor3DCone ||= buildCone(1, 1, 12);
|
|
34907
|
+
|
|
34908
|
+
// the editor's drawing in the 3D pass: the grid, a marker for what has nothing to see, the collision shape of
|
|
34909
|
+
// what is solid and selected, the selection, the brush where it would go, and the handles
|
|
34910
|
+
function editor3DDraw()
|
|
34911
|
+
{
|
|
34912
|
+
const r = render3D;
|
|
34913
|
+
render3DWithState({depthTest: true}, editor3DDrawGrid);
|
|
34914
|
+
for (const object of editor3DObjects())
|
|
34915
|
+
{
|
|
34916
|
+
const made = editor3DVisible(object), selected = editor3DSelection.has(object.id);
|
|
34917
|
+
made || editor3DDrawWire(editor3DBoxMatrix(object), EDITOR3D_MARKER_COLOR, 1.5);
|
|
34918
|
+
selected && editor3DDrawWire(editor3DBoxMatrix(object), EDITOR3D_SELECT_COLOR, 2.5);
|
|
34919
|
+
if (made?.collideSolidObjects && !made.parent && selected)
|
|
34920
|
+
{
|
|
34921
|
+
// what it collides as, which turns with it, where its size3D is not its mesh's
|
|
34922
|
+
const shape = render3DSolidShape(made), color = EDITOR3D_SOLID_COLOR;
|
|
34923
|
+
if (shape.size)
|
|
34924
|
+
editor3DDrawWire(buildMatrix(shape.pos, shape.axes && made.rotation3D, shape.size), color, 1.5);
|
|
34925
|
+
else
|
|
34926
|
+
for (const [across, along] of [['x', 'y'], ['y', 'z'], ['z', 'x']])
|
|
34927
|
+
editor3DDrawRing(shape.pos, EDITOR3D_AXES[across], EDITOR3D_AXES[along], shape.radius, color, 1.5);
|
|
34928
|
+
}
|
|
34929
|
+
}
|
|
34930
|
+
|
|
34931
|
+
// the brush, a box where a click would place it
|
|
34932
|
+
const point = editor3DBrush && !editor3DMouseOnPanel && !editor3DDrag && editor3DMousePoint();
|
|
34933
|
+
if (point)
|
|
34934
|
+
{
|
|
34935
|
+
const snapped = editor3DSnapPos(editor3DRest(point, vec3(1)), vec3(1), editor3DGrid ? editor3DMoveStep : 0);
|
|
34936
|
+
editor3DDrawWire(buildMatrix(vec3(snapped.x, point.y + .5, snapped.z)), hsl(0, 0, 1, .7));
|
|
34937
|
+
}
|
|
34938
|
+
|
|
34939
|
+
// the handles, the one under the mouse or being dragged in the selection's color
|
|
34940
|
+
for (const handle of editor3DHandles())
|
|
34941
|
+
{
|
|
34942
|
+
const {kind, axis, center, length, points} = handle;
|
|
34943
|
+
const taken = (editor3DDrag?.handle ?? editor3DHover);
|
|
34944
|
+
const lit = taken && taken.kind === kind && taken.axis === axis;
|
|
34945
|
+
const color = lit ? EDITOR3D_SELECT_COLOR : EDITOR3D_AXIS_COLORS[axis[0]] ?? WHITE;
|
|
34946
|
+
const width = editor3DPixels(center, lit ? 4 : 3), tip = length * .1;
|
|
34947
|
+
if (kind === 'ring')
|
|
34948
|
+
r.drawRibbon(points, width, undefined, color);
|
|
34949
|
+
else if (kind === 'plane')
|
|
34950
|
+
{
|
|
34951
|
+
// a flat square across its two axes, in the color of the axis it does not move along
|
|
34952
|
+
const size = vec3(tip * 1.6).subtract(handle.direction.scale(tip * 1.5));
|
|
34953
|
+
r.drawBox(points[0], size, lit ? color : EDITOR3D_AXIS_COLORS['xyz'.replace(axis[0], '')
|
|
34954
|
+
.replace(axis[1], '')].scale(1, .7));
|
|
34955
|
+
}
|
|
34956
|
+
else if (kind === 'scaleAll')
|
|
34957
|
+
r.drawBox(center, tip * 1.4, lit ? color : WHITE);
|
|
34958
|
+
else if (kind === 'arrow')
|
|
34959
|
+
{
|
|
34960
|
+
// a move arrow ends in a cone pointing along it, which says move this way, where scale has a box
|
|
34961
|
+
r.drawLine(points[0], points[1], width, color);
|
|
34962
|
+
const turn = axis === 'x' ? vec3(0, 0, -PI / 2) : axis === 'z' ? vec3(PI / 2, 0, 0) : undefined;
|
|
34963
|
+
const at = points[1].add(handle.direction.scale(tip));
|
|
34964
|
+
r.drawMesh(editor3DConeMesh(), buildMatrix(at, turn, vec3(tip * 1.4, tip * 2, tip * 1.4)), undefined, color);
|
|
34965
|
+
}
|
|
34966
|
+
else
|
|
34967
|
+
{
|
|
34968
|
+
r.drawLine(points[0], points[1], width, color);
|
|
34969
|
+
r.drawBox(points[1], tip, color, editor3DRotation(editor3DSelected()[0]).scale(PI / 180));
|
|
34970
|
+
}
|
|
34971
|
+
}
|
|
34972
|
+
}
|
|
34973
|
+
|
|
34974
|
+
// the editor's drawing on the 2D layer: the name of each marker, and the box being dragged out to select with
|
|
34975
|
+
function editor3DDrawLabels()
|
|
34976
|
+
{
|
|
34977
|
+
for (const object of editor3DObjects())
|
|
34978
|
+
{
|
|
34979
|
+
if (editor3DVisible(object)) continue;
|
|
34980
|
+
const at = render3D.worldToScreen(editor3DPos(object));
|
|
34981
|
+
at && drawTextScreen(object.type + '', at, 14, WHITE, 3, BLACK);
|
|
34982
|
+
}
|
|
34983
|
+
const drag = editor3DDrag;
|
|
34984
|
+
if (drag?.kind === 'box')
|
|
34985
|
+
{
|
|
34986
|
+
const {from: a, to: b} = drag, corners = [a, vec2(b.x, a.y), b, vec2(a.x, b.y)];
|
|
34987
|
+
corners.forEach((c, i)=> drawLine(c, corners[(i + 1) % 4], 2, EDITOR3D_SELECT_COLOR, undefined, 0, glEnable, true));
|
|
34988
|
+
}
|
|
34989
|
+
}
|
|
34990
|
+
|
|
34991
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
34992
|
+
// the panel, in the look of the 2D level editor's, made the first time the editor opens
|
|
34993
|
+
|
|
34994
|
+
let editor3DPanel, editor3DPanelParts;
|
|
34995
|
+
|
|
34996
|
+
function editor3DPanelInit()
|
|
34997
|
+
{
|
|
34998
|
+
const panel = editor3DPanel = editorElement('div', document.body,
|
|
34999
|
+
'position:fixed;top:8px;left:8px;width:260px;max-height:calc(100% - 16px);overflow-y:auto;' +
|
|
35000
|
+
'box-sizing:border-box;padding:8px;background:#111d;color:#eee;font:12px monospace;' +
|
|
35001
|
+
'border-radius:4px;z-index:9999;user-select:none');
|
|
35002
|
+
|
|
35003
|
+
// a click or touch on the panel is not the level's, a mouse up still goes on so a drag can let go
|
|
35004
|
+
for (const type of ['mousedown','wheel','touchstart','touchmove','touchend','touchcancel'])
|
|
35005
|
+
panel.addEventListener(type, (e)=> e.stopPropagation());
|
|
35006
|
+
panel.addEventListener('mouseenter', ()=> editor3DMouseOnPanel = true);
|
|
35007
|
+
panel.addEventListener('mouseleave', ()=> editor3DMouseOnPanel = false);
|
|
35008
|
+
|
|
35009
|
+
const box = 'margin:4px 0;padding:4px;background:#222;border-radius:3px';
|
|
35010
|
+
const row = (parent=panel)=> editorElement('div', parent, 'display:flex;gap:4px;margin:4px 0;flex-wrap:wrap');
|
|
35011
|
+
const button = (parent, text, onclick, title='')=>
|
|
35012
|
+
{
|
|
35013
|
+
const b = editorElement('button', parent, 'flex:1;padding:3px;cursor:pointer', text);
|
|
35014
|
+
b.onclick = (e)=> { onclick(e); b.blur(); }; // the keys go back to the editor
|
|
35015
|
+
b.title = title;
|
|
35016
|
+
return b;
|
|
35017
|
+
};
|
|
35018
|
+
const check = (parent, text, onchange, title='')=>
|
|
35019
|
+
{
|
|
35020
|
+
const label = editorElement('label', parent, 'display:flex;gap:6px;align-items:center;margin:2px 0');
|
|
35021
|
+
const input = editorElement('input', label);
|
|
35022
|
+
input.type = 'checkbox';
|
|
35023
|
+
input.onchange = ()=> { onchange(input.checked); input.blur(); };
|
|
35024
|
+
editorElement('span', label, '', text);
|
|
35025
|
+
label.title = title;
|
|
35026
|
+
return input;
|
|
35027
|
+
};
|
|
35028
|
+
const choice = (parent, text, values, onchange)=>
|
|
35029
|
+
{
|
|
35030
|
+
const label = editorElement('label', parent, 'display:flex;gap:3px;align-items:center;flex:1');
|
|
35031
|
+
editorElement('span', label, '', text);
|
|
35032
|
+
const select = editorElement('select', label, 'flex:1;background:#333;color:#eee');
|
|
35033
|
+
for (const value of values)
|
|
35034
|
+
editorElement('option', select, '', value + '').value = value + '';
|
|
35035
|
+
select.onchange = ()=> { onchange(parseFloat(select.value)); select.blur(); };
|
|
35036
|
+
return select;
|
|
35037
|
+
};
|
|
35038
|
+
|
|
35039
|
+
editorElement('div', panel, 'font-weight:bold', '3D Level Editor');
|
|
35040
|
+
const top = row();
|
|
35041
|
+
button(top, 'Play', ()=> editor3DPlay(editor3DViewPoint()), 'Esc, and Esc again comes back to the editor');
|
|
35042
|
+
const restart = button(top, 'Restart', editor3DRestart, 'Rebuild the level and play it');
|
|
35043
|
+
button(top, 'Exit', ()=> levelEditor.close(), '0, then Esc opens the debug overlay again');
|
|
35044
|
+
const undo = row();
|
|
35045
|
+
button(undo, 'Undo', ()=> editor3DUndo(), 'Ctrl+Z');
|
|
35046
|
+
button(undo, 'Redo', ()=> editor3DUndo(true), 'Ctrl+Y');
|
|
35047
|
+
button(undo, 'Keys', ()=> editor3DHelp = !editor3DHelp, 'Every control, ?');
|
|
35048
|
+
|
|
35049
|
+
// a file that changed under its autosave
|
|
35050
|
+
const pending = editorElement('div', panel, 'padding:4px;margin:4px 0;background:#630;border-radius:3px');
|
|
35051
|
+
editorElement('div', pending, '', 'The level file changed since your autosaved edits');
|
|
35052
|
+
const pendingRow = editorElement('div', pending, 'display:flex;gap:4px;margin-top:4px');
|
|
35053
|
+
button(pendingRow, 'Apply edits', ()=> editor3DApplyPending());
|
|
35054
|
+
button(pendingRow, 'Drop them', ()=> editor3DDropPending());
|
|
35055
|
+
|
|
35056
|
+
// the tools, and the snapping
|
|
35057
|
+
const tools = row(), toolButtons = {};
|
|
35058
|
+
for (const [tool, text, key] of [['select', 'Select', 'Q'], ['move', 'Move', 'W'], ['rotate', 'Rotate', 'E'],
|
|
35059
|
+
['scale', 'Scale', 'R']])
|
|
35060
|
+
toolButtons[tool] = button(tools, text, ()=>
|
|
35061
|
+
{
|
|
35062
|
+
editor3DTool = tool;
|
|
35063
|
+
tool === 'select' && (editor3DBrush = undefined);
|
|
35064
|
+
}, key);
|
|
35065
|
+
const snap = editorElement('div', panel, box);
|
|
35066
|
+
const grid = check(snap, 'Grid snap', (on)=> editor3DGrid = on, 'G, and Ctrl flips it for a drag');
|
|
35067
|
+
const steps = row(snap);
|
|
35068
|
+
const moveStep = choice(steps, 'Move', [1, .5, .25], (v)=> editor3DMoveStep = v);
|
|
35069
|
+
const rotateStep = choice(steps, 'Turn', [15, 45, 90], (v)=> editor3DRotateStep = v);
|
|
35070
|
+
const scaleStep = choice(steps, 'Size', [1, .5, .25], (v)=> editor3DScaleStep = v);
|
|
35071
|
+
const ground = check(snap, 'Ground snap', (on)=> editor3DGroundSnap = on,
|
|
35072
|
+
'A drag of an object slides it along what is under the mouse');
|
|
35073
|
+
|
|
35074
|
+
// the types, a click picks one up to place, made again when types are added
|
|
35075
|
+
editorElement('div', panel, 'color:#aaa;margin-top:4px', 'Place');
|
|
35076
|
+
const types = row();
|
|
35077
|
+
const properties = editorElement('div', panel, box);
|
|
35078
|
+
|
|
35079
|
+
const file = row();
|
|
35080
|
+
// Save says so for a moment when it saved, Save As shows where a page can write files
|
|
35081
|
+
const saved = (b, label)=> (result)=> result && (b.textContent = 'Saved', setTimeout(()=> b.textContent = label, 1e3));
|
|
35082
|
+
const save = button(file, 'Save', ()=> editor3DSave().then(saved(save, 'Save')),
|
|
35083
|
+
'Save, to the file picked the first time, or a download');
|
|
35084
|
+
const saveAs = button(file, 'Save As', ()=> editor3DSave(true).then(saved(saveAs, 'Save As')),
|
|
35085
|
+
'Save As, to a file picked again');
|
|
35086
|
+
button(file, 'Reset to file', ()=> editor3DRevert(),
|
|
35087
|
+
'Put the level back as its file has it, or as Save last wrote it; Undo brings the edits back');
|
|
35088
|
+
/** @type {any} */ (globalThis).showSaveFilePicker || (saveAs.style.display = 'none');
|
|
35089
|
+
const playFrom = check(panel, 'Play from mouse', (on)=> editor3DPlayFromMouse = on,
|
|
35090
|
+
'Escape starts play with the player at the mouse, Play at the middle of the view');
|
|
35091
|
+
|
|
35092
|
+
const storage = editorElement('div', panel, 'color:#f86;margin-top:4px',
|
|
35093
|
+
'Autosave failed, storage is full: Save to a file');
|
|
35094
|
+
const hint = editorElement('div', panel, 'color:#8ab;margin-top:4px');
|
|
35095
|
+
const help = editorElement('div', panel, 'color:#aaa;margin-top:4px;border-top:1px solid #444;padding-top:4px');
|
|
35096
|
+
for (const line of editor3DHelpLines)
|
|
35097
|
+
editorElement('div', help, 'margin:2px 0', line);
|
|
35098
|
+
button(help, 'Close', ()=> editor3DHelp = false, '?');
|
|
35099
|
+
|
|
35100
|
+
editor3DPanelParts = {restart, pending, toolButtons, grid, moveStep, rotateStep, scaleStep, ground, types,
|
|
35101
|
+
properties, playFrom, storage, hint, help, typeNames: ''};
|
|
35102
|
+
}
|
|
35103
|
+
|
|
35104
|
+
// show the panel as the editor is now
|
|
35105
|
+
function editor3DPanelUpdate()
|
|
35106
|
+
{
|
|
35107
|
+
if (!editor3DIsOpen)
|
|
35108
|
+
{
|
|
35109
|
+
editor3DPanel && (editor3DPanel.style.display = 'none');
|
|
35110
|
+
return;
|
|
35111
|
+
}
|
|
35112
|
+
editor3DPanel || editor3DPanelInit();
|
|
35113
|
+
editor3DPanel.style.display = '';
|
|
35114
|
+
const p = editor3DPanelParts, lit = '2px solid #4af';
|
|
35115
|
+
p.restart.style.display = levelEditor.onRestart ? '' : 'none';
|
|
35116
|
+
p.pending.style.display = editor3DRecords.get(editor3DLevel)?.pending ? '' : 'none';
|
|
35117
|
+
for (const tool in p.toolButtons)
|
|
35118
|
+
p.toolButtons[tool].style.outline = tool === editor3DTool ? lit : '';
|
|
35119
|
+
p.grid.checked = editor3DGrid;
|
|
35120
|
+
p.ground.checked = editor3DGroundSnap;
|
|
35121
|
+
p.moveStep.value = editor3DMoveStep + '';
|
|
35122
|
+
p.rotateStep.value = editor3DRotateStep + '';
|
|
35123
|
+
p.scaleStep.value = editor3DScaleStep + '';
|
|
35124
|
+
p.playFrom.checked = editor3DPlayFromMouse;
|
|
35125
|
+
p.playFrom.parentElement.style.display = levelEditor.onPlayFrom ? 'flex' : 'none';
|
|
35126
|
+
p.storage.style.display = editor3DSaveFailed ? '' : 'none';
|
|
35127
|
+
p.hint.textContent = editor3DHint();
|
|
35128
|
+
p.help.style.display = editor3DHelp ? '' : 'none';
|
|
35129
|
+
|
|
35130
|
+
// a button for each type, made again when the types changed
|
|
35131
|
+
const names = [...level3DTypes.keys()], typeNames = names.join('\n');
|
|
35132
|
+
if (p.typeNames !== typeNames)
|
|
35133
|
+
{
|
|
35134
|
+
p.typeNames = typeNames;
|
|
35135
|
+
p.types.replaceChildren(...names.map((name)=>
|
|
35136
|
+
{
|
|
35137
|
+
const b = editorElement('button', undefined, 'padding:3px 6px;cursor:pointer', name);
|
|
35138
|
+
b.onclick = ()=> { editor3DBrush = editor3DBrush === name ? undefined : name; b.blur(); };
|
|
35139
|
+
return b;
|
|
35140
|
+
}));
|
|
35141
|
+
}
|
|
35142
|
+
names.forEach((name, i)=> p.types.children[i].style.outline = name === editor3DBrush ? lit : '');
|
|
35143
|
+
editor3DPropertiesUpdate(p.properties);
|
|
35144
|
+
}
|
|
35145
|
+
|
|
35146
|
+
// the properties box: the position, rotation and scale of the one selected object and an input for each default
|
|
35147
|
+
// of its type, made again when the selection or the object changes, but not while one of its inputs is typed in
|
|
35148
|
+
function editor3DPropertiesUpdate(box)
|
|
35149
|
+
{
|
|
35150
|
+
const selected = editor3DSelected(), object = selected.length === 1 && selected[0];
|
|
35151
|
+
const type = object && level3DTypes.get(object.type);
|
|
35152
|
+
const key = object ? JSON.stringify(object) : selected.length ? selected.length + ' objects' : '';
|
|
35153
|
+
box.style.display = key ? '' : 'none';
|
|
35154
|
+
if (box.dataset.key === key || box.contains(document.activeElement)) return;
|
|
35155
|
+
box.dataset.key = key;
|
|
35156
|
+
box.replaceChildren();
|
|
35157
|
+
if (!object)
|
|
35158
|
+
{
|
|
35159
|
+
key && editorElement('div', box, 'color:#aaa', key + ' selected');
|
|
35160
|
+
return;
|
|
35161
|
+
}
|
|
35162
|
+
editorElement('div', box, 'color:#aaa;margin-bottom:2px', `${object.type} ${object.id}`);
|
|
35163
|
+
const field = 'background:#222;color:#eee';
|
|
35164
|
+
const line = (name)=>
|
|
35165
|
+
{
|
|
35166
|
+
const row = editorElement('label', box, 'display:flex;gap:4px;align-items:center;margin:2px 0');
|
|
35167
|
+
editorElement('span', row, 'flex:1', name);
|
|
35168
|
+
return row;
|
|
35169
|
+
};
|
|
35170
|
+
|
|
35171
|
+
// the numbers of a vector, set together once every one of them reads as a number
|
|
35172
|
+
const numbers = (name, value, set)=>
|
|
35173
|
+
{
|
|
35174
|
+
const row = line(name), axes = isVector3(value) ? 'xyz' : 'xy', inputs = [];
|
|
35175
|
+
for (const axis of axes)
|
|
35176
|
+
{
|
|
35177
|
+
const input = editorElement('input', row, field + ';width:' + (axes.length > 2 ? 46 : 52) + 'px');
|
|
35178
|
+
input.type = 'number';
|
|
35179
|
+
input.step = 'any';
|
|
35180
|
+
input.value = editor3DRound(value[axis]) + '';
|
|
35181
|
+
input.onchange = ()=>
|
|
35182
|
+
{
|
|
35183
|
+
const v = inputs.map((i)=> parseFloat(i.value));
|
|
35184
|
+
if (v.every((n)=> isNumber(n)))
|
|
35185
|
+
set(axes.length > 2 ? vec3(v[0], v[1], v[2]) : vec2(v[0], v[1]));
|
|
35186
|
+
else
|
|
35187
|
+
inputs.forEach((i, k)=> i.value = editor3DRound(value[axes[k]]) + '');
|
|
35188
|
+
input.blur();
|
|
35189
|
+
};
|
|
35190
|
+
inputs.push(input);
|
|
35191
|
+
}
|
|
35192
|
+
};
|
|
35193
|
+
numbers('pos', editor3DPos(object), (v)=> editor3DSetSelectedTransform('pos', v));
|
|
35194
|
+
numbers('rotation', editor3DRotation(object), (v)=> editor3DSetSelectedTransform('rotation', v));
|
|
35195
|
+
numbers('scale', editor3DScale(object), (v)=> editor3DSetSelectedTransform('scale', v));
|
|
35196
|
+
if (!type) return;
|
|
35197
|
+
|
|
35198
|
+
const values = level3DProperties(type, object);
|
|
35199
|
+
for (const [name, defaultValue] of Object.entries(type.defaults))
|
|
35200
|
+
{
|
|
35201
|
+
const value = values[name];
|
|
35202
|
+
if (isVector3(defaultValue) || isVector2(defaultValue))
|
|
35203
|
+
{
|
|
35204
|
+
numbers(name, value, (v)=> editor3DSetSelectedProperty(name, v));
|
|
35205
|
+
continue;
|
|
35206
|
+
}
|
|
35207
|
+
const input = editorElement('input', line(name), field + ';width:110px');
|
|
35208
|
+
const set = (v)=> { editor3DSetSelectedProperty(name, v); input.blur(); };
|
|
35209
|
+
if (typeof defaultValue === 'boolean')
|
|
35210
|
+
{
|
|
35211
|
+
input.type = 'checkbox';
|
|
35212
|
+
input.checked = !!value;
|
|
35213
|
+
input.onchange = ()=> set(input.checked);
|
|
35214
|
+
}
|
|
35215
|
+
else if (isColor(defaultValue))
|
|
35216
|
+
{
|
|
35217
|
+
input.type = 'color';
|
|
35218
|
+
input.value = value.toString(false);
|
|
35219
|
+
input.onchange = ()=> { const color = new Color().setHex(input.value); color.a = value.a; set(color); };
|
|
35220
|
+
}
|
|
35221
|
+
else if (typeof defaultValue === 'number')
|
|
35222
|
+
{
|
|
35223
|
+
input.type = 'number';
|
|
35224
|
+
input.step = 'any';
|
|
35225
|
+
input.value = value + '';
|
|
35226
|
+
input.onchange = ()=> { const v = parseFloat(input.value); isNumber(v) && set(v); };
|
|
35227
|
+
}
|
|
35228
|
+
else if (typeof defaultValue === 'string')
|
|
35229
|
+
{
|
|
35230
|
+
input.type = 'text';
|
|
35231
|
+
input.value = (value ?? '') + '';
|
|
35232
|
+
input.onchange = ()=> set(input.value);
|
|
35233
|
+
}
|
|
35234
|
+
else
|
|
35235
|
+
{
|
|
35236
|
+
// a type it has no input for, an array say, shown as it is
|
|
35237
|
+
input.readOnly = true;
|
|
35238
|
+
input.value = value + '';
|
|
35239
|
+
}
|
|
35240
|
+
}
|
|
35241
|
+
}
|
|
35242
|
+
|
|
35243
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
35244
|
+
// plugin
|
|
35245
|
+
|
|
35246
|
+
function editor3DUpdate()
|
|
35247
|
+
{
|
|
35248
|
+
const seconds = clamp(timeReal - editor3DTimeLast, 0, .1);
|
|
35249
|
+
editor3DTimeLast = timeReal;
|
|
35250
|
+
if (!render3D) return;
|
|
35251
|
+
if (editor3DFreeCamera)
|
|
35252
|
+
return inputCaptureRead(()=> editor3DFreeCameraUpdate(seconds));
|
|
35253
|
+
if (editor3DIsOpen)
|
|
35254
|
+
return inputCaptureRead(()=> editor3DWithView(()=> editor3DEditorUpdate(seconds)));
|
|
35255
|
+
|
|
35256
|
+
// playing in an editing session, Escape, the debug key, goes back to the editor; taken, so the debug overlay
|
|
35257
|
+
// waits till the session ends; a captured mouse the browser let go of is Escape too, Chrome keeps that key
|
|
35258
|
+
const lostLock = editor3DLostLock(pointerLockIsActive());
|
|
35259
|
+
if (editor3DSession && (lostLock || debugKey && keyWasPressed(debugKey)))
|
|
35260
|
+
{
|
|
35261
|
+
debugKey && inputClearKey(debugKey);
|
|
35262
|
+
editor3DSetOpen(true);
|
|
35263
|
+
return;
|
|
35264
|
+
}
|
|
35265
|
+
editor3DFreeCameraKey();
|
|
35266
|
+
}
|
|
35267
|
+
|
|
35268
|
+
function editor3DRender()
|
|
35269
|
+
{
|
|
35270
|
+
if (headlessMode) return;
|
|
35271
|
+
editor3DPanelUpdate();
|
|
35272
|
+
if (editor3DFreeCamera)
|
|
35273
|
+
drawTextScreen('Free camera · C or Esc to leave', vec2(mainCanvasSize.x / 2, mainCanvasSize.y - 30), 20, WHITE,
|
|
35274
|
+
4, BLACK);
|
|
35275
|
+
editor3DIsOpen && editor3DDrawLabels();
|
|
35276
|
+
}
|
|
35277
|
+
|
|
35278
|
+
debug && engineAddPlugin(editor3DUpdate, editor3DRender);
|
|
35279
|
+
debug && debugOverlayKeys.push(()=> render3D && {text: 'C: Free Camera', on: editor3DFreeCamera});
|
|
35280
|
+
|