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.js CHANGED
@@ -35,7 +35,7 @@ const engineName = 'LittleJS';
35
35
  * @type {string}
36
36
  * @default
37
37
  * @memberof Engine */
38
- const engineVersion = '1.20.0';
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 each frame lasts, engine uses a fixed time step
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
- const timeDelta = 1/frameRate;
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
- // paused or a time scale of 0 is frozen: it ticks on unscaled time, so the update rate stays fixed instead of
255
- // following however fast the display refreshes, and gameUpdatePost and input still run to leave it
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
- // force at least one update each frame since it is waiting for refresh
265
- deltaSmooth = frameTimeBufferMS;
266
- frameTimeBufferMS = 0;
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
- // read again each tick, so a pause set by the game stops the rest of this frame's catch-up ticks
273
- const frozenTick = paused || !(timeScale * debugScale);
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
- // increment frame and update time, frozen does not advance time
276
- if (!frozenTick)
277
- time = frame++ / frameRate;
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
- // update game and objects, when frozen update everything except them
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
- // wait for all the promises to finish
446
- await Promise.all(promises);
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
- async function startEngine()
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
- // wait for gameInit to load
452
- await gameInit();
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
- if (this.clampSpeed)
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() { return this.restart('once'); }
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.mode == 'once' && this.heldFrame === undefined && abs(this.elapsedFrames) >= this.frameCount; }
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
- { document.exitPointerLock?.(); }
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
- '#version 300 es\n' + // specify GLSL ES version
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
- if (!t.active || t.activePass === pass) continue; // stopped, or started again by a callback this update
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 === pass)
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 an axis aligned box, boundary is inclusive
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 axis aligned boxes are overlapping, touching edges do not overlap
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 an axis aligned box, or undefined
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 an axis aligned box, or undefined
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 is fixed */
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 sampler2D tex;' +
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.),16.)*step(0.,ln);' +
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.),16.)*lightColor.a*step(0.,nl)*s*(1.-e)+sp*lightColor.a*(1.-e);' +
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
- function render3DBindTexture(tileInfo, state=render3D)
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
- return gl.bindSampler(0, null); // the texture's own filtering, as in 2D; the white texel needs no mipmaps
23602
- // or anisotropy, and filtering it that way costs every untextured fragment
23603
- gl.bindSampler(0, render3DSampler(textureInfo?.wrap, state.pixelated));
23604
- glUpdateMipmaps(texture); // drawn into since its mipmaps were made
23605
- if (!state.pixelated && !glMipmappedTextures.has(texture)) // a hard edged draw never reads them
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
- glMipmappedTextures.add(texture); // the core makes them again when the texture changes
23608
- gl.generateMipmap(gl.TEXTURE_2D);
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
- gl.activeTexture(gl.TEXTURE0);
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
- render3DWriteVertex(floats, ints, j * RENDER3D_VERTEX_FLOATS, p.x, p.y, p.z,
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
- this.computeRadius();
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, along with render3D.gravity and damping once it has a mass
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, angleDamping,
25131
- * additiveColor, drawSize, mirror, clampSpeed, friction and groundObject
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 is axis aligned in the world, rotation3D is ignored like angle is in 2D, so give a turned wall a
25137
- * size3D along the world axes
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, angleDamping is 2D only */
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 is fixed */
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
- oldPos && render3DCollideLevel(this, oldPos);
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 are axis aligned in the world, rotation3D is ignored
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 and slowing by its damping
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
- // damped first and gravity added after, the order EngineObject.updatePhysics uses,
25487
- // so the same mass, damping and gravity fall the same way in both
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
- function render3DCollideLevel(o, oldPos)
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
- return {pos: o.pos3D.copy(), size: vec3(s.x * kx, s.y * ky, s.z * kz)};
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 ? collideSphereBox(a.pos, a.radius, b.pos, 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 = collideSphereBox(b.pos, b.radius, a.pos, a.size);
26361
+ const push = sphereBox(b, a);
25540
26362
  return push && push.scale(-1);
25541
26363
  }
25542
- return collideBoxBox3D(a.pos, a.size, b.pos, b.size);
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 push = render3DSolidPush(shapeA, render3DSolidShape(b));
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
- * - Boxes are axis aligned around the world position, rotation3D is ignored; lights, emitters and trails have no size
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
- /** Lights draw nothing */
25947
- render3D() {}
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 or voxel map it collides with, see collideLevel */
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
- const trailMax = this.trailTime ? max(1, round(this.trailTime / timeDelta)) : 0;
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
- const F = RENDER3D_PARTICLE_FLOATS, data = this.particleData, trail = this.trailData;
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.map(p=> p.textureInfo)))
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; only the base color
28626
- // textures are drawn with, so the normal, roughness and other maps are not loaded at all
28627
- const baseColorTextures = new Set((json.materials || []).map(m=> m.pbrMetallicRoughness?.baseColorTexture?.index));
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' || !baseColorTextures.has(index)) return;
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
- const bitmap = opaque ? await createImageBitmap(blob, {premultiplyAlpha: 'none'}).then(gltfOpaqueImage) : await createImageBitmap(blob);
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.map(p=> p.textureInfo));
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, the autosave keeps only the
30075
- // edits since, and a reload of it has nothing to apply; a download can not say it replaced the file, so it does not,
30076
- // and neither does a file of another name, a copy, since the game still loads the one it came from
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, hash: editorMapHash(data, 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
- if (sameSize && editorSameData(data, record.original) && editorSameData(kept, original))
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, layers: data, objects,
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 tile n - 1, keeping a one tile brush's turn; on
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
- editorBrush = editorStampTile(slot ? editorTileToGid(slot - 1, t?.direction, t?.mirror) : 0);
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 tiles in order, as the tile infos it draws them with, up to the image's edge and without the blank
31464
- // ones at the end, a tile of one flat color; found once for each layer
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(t.pos.x, t.pos.y, t.size.x, t.size.y).data.buffer);
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((t, i)=>
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, t.pos.x, t.pos.y, t.size.x, t.size.y, x + 2, y + 2, cell - 4, cell - 4);
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, selected = erase ? 0 : t ? t.tile + 1 : -1;
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
+