littlejsengine 1.22.0 → 1.23.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.
@@ -35,7 +35,7 @@ const engineName = 'LittleJS';
35
35
  * @type {string}
36
36
  * @default
37
37
  * @memberof Engine */
38
- const engineVersion = '1.22.0';
38
+ const engineVersion = '1.23.0';
39
39
 
40
40
  /** Frames per second to update
41
41
  * @type {number}
@@ -982,8 +982,19 @@ function tweak(){}
982
982
  function tweakButton(){}
983
983
  function tweakDivider(){}
984
984
  function tweakEngineDefaults(){}
985
- const levelEditor = {isOpen: false, open(){}, close(){}, onTile: undefined, onRestart: undefined, onPlayFrom: undefined,
986
- paletteTiles: undefined, use3D: undefined};
985
+ class LevelEditor
986
+ {
987
+ constructor() { this.paletteTiles = this.use3D = this.tool = undefined; }
988
+ get isOpen() { return false; }
989
+ get is3D() { return false; }
990
+ get edit2D() { return undefined; }
991
+ get edit3D() { return undefined; }
992
+ open(){} close(){} addKey(){} addButton(){} addTool(){}
993
+ onTile(){} onRestart(){} onPlayFrom(){} onOpen(){} onClose(){} onUpdate(){} onDraw(){} onPanel(){}
994
+ onSave(){ return false; }
995
+ }
996
+ let levelEditor = new LevelEditor;
997
+ function setLevelEditor(editor){ levelEditor = editor; }
987
998
  function editorMapRestore(map){ return map; }
988
999
  function editorMapLoaded(){}
989
1000
  function editorJSONFetched(){}
@@ -2884,6 +2895,15 @@ let touchGamepadEnable = false;
2884
2895
  * @memberof Settings */
2885
2896
  let touchGamepadPassthrough = false;
2886
2897
 
2898
+ /** True if a pinch of two fingers turns the mouse wheel: touchPinch is added to mouseWheel, so what zooms with
2899
+ * the wheel zooms with a pinch, the mouse is between the two fingers, and a second finger lets go of the button
2900
+ * the first pressed, since a pinch is not a drag; turn it off for a game with its own use for two fingers, the
2901
+ * first finger stays the mouse and touchPinch can still be read
2902
+ * @type {boolean}
2903
+ * @default
2904
+ * @memberof Settings */
2905
+ let touchPinchWheel = true;
2906
+
2887
2907
  /** Size of center button if touch gamepad should have start button in the center
2888
2908
  * - Prevents activating when pressed near virtual stick or face buttons
2889
2909
  * - When the game is paused, any touch will press the button
@@ -3279,6 +3299,11 @@ function setTouchInputEnable(enable) { touchInputEnable = enable; }
3279
3299
  * @memberof Settings */
3280
3300
  function setTouchGamepadEnable(enable) { touchGamepadEnable = enable; }
3281
3301
 
3302
+ /** Set if a pinch of two fingers turns the mouse wheel
3303
+ * @param {boolean} enable
3304
+ * @memberof Settings */
3305
+ function setTouchPinchWheel(enable) { touchPinchWheel = enable; }
3306
+
3282
3307
  /** Set if touches outside the gamepad controls should still drive mouse/touch input
3283
3308
  * @param {boolean} passthrough
3284
3309
  * @memberof Settings */
@@ -4585,17 +4610,29 @@ class SpriteAnimator
4585
4610
  * @return {SpriteAnimation} */
4586
4611
  get clip() { return this.clips[this.name]; }
4587
4612
 
4613
+ /** The clip showing now, once a clip that has ended has called its onEnd: the callback may switch clips, and a
4614
+ * read gives the clip it switched to, not the one that ended
4615
+ * @return {SpriteAnimation}
4616
+ * @ignore */
4617
+ get settledClip()
4618
+ {
4619
+ // each clip's own isDone calls its onEnd once; a few switches in a row at most
4620
+ for (let i = 4, clip; i-- && clip !== this.clip;)
4621
+ (clip = this.clip).isDone;
4622
+ return this.clip;
4623
+ }
4624
+
4588
4625
  /** The tile of the frame showing now
4589
4626
  * @return {TileInfo} */
4590
- get tileInfo() { return this.clip.tileInfo; }
4627
+ get tileInfo() { return this.settledClip.tileInfo; }
4591
4628
 
4592
4629
  /** The frame of the clip showing now
4593
4630
  * @return {number} */
4594
- get frame() { return this.clip.frame; }
4631
+ get frame() { return this.settledClip.frame; }
4595
4632
 
4596
4633
  /** True once the clip showing now is a play that has ended
4597
4634
  * @return {boolean} */
4598
- get isDone() { return this.clip.isDone; }
4635
+ get isDone() { return this.settledClip.isDone; }
4599
4636
  }
4600
4637
 
4601
4638
  ///////////////////////////////////////////////////////////////////////////////
@@ -6034,6 +6071,13 @@ let mouseDeltaScreen = vec2();
6034
6071
  * @memberof Input */
6035
6072
  let mouseWheel = 0;
6036
6073
 
6074
+ /** Touch pinch delta this frame, in the mouse wheel's units: two fingers moving apart are negative, as the wheel
6075
+ * turned up is, and moving together positive, 1 for every 50 pixels; added to mouseWheel while touchPinchWheel
6076
+ * is on, so a game that zooms with the wheel zooms with a pinch
6077
+ * @type {number}
6078
+ * @memberof Input */
6079
+ let touchPinch = 0;
6080
+
6037
6081
  /** True if mouse was inside the document window, set to false when mouse leaves
6038
6082
  * @type {boolean}
6039
6083
  * @memberof Input */
@@ -6122,6 +6166,7 @@ function inputClearKey(key, device=0, clearDown=true, clearPressed=true, clearRe
6122
6166
  const inputWASDToArrow = {KeyW:'ArrowUp', KeyS:'ArrowDown', KeyA:'ArrowLeft', KeyD:'ArrowRight'};
6123
6167
  const inputArrowToWASD = {ArrowUp:'KeyW', ArrowDown:'KeyS', ArrowLeft:'KeyA', ArrowRight:'KeyD'};
6124
6168
  const inputKeysHeld = new Set; // the keys physically down, since an arrow's slot is shared with its alias
6169
+ let inputPinch; // the two fingers pinching and how far apart they were, undefined with no pinch
6125
6170
  let inputWasTouching = 0, inputTouchIdentifier; // the touch driving the mouse, cleared with the input so only a new touch presses
6126
6171
  let inputLastTouchTime = -1e9; // when a touch event last came, the mouse events a browser makes from a tap follow it
6127
6172
 
@@ -6164,6 +6209,7 @@ function inputClear()
6164
6209
  if (!i || inputData[i]) inputData[i] = [];
6165
6210
  inputKeysHeld.clear();
6166
6211
  inputWasTouching = 0;
6212
+ inputPinch = undefined;
6167
6213
  // release the touch gamepad, a finger still on it has to lift before it can take a control again
6168
6214
  touchGamepadPointerRole.clear();
6169
6215
  touchGamepadButtons.length = 0;
@@ -6691,19 +6737,52 @@ function inputInit()
6691
6737
  const pressTouch = !inputWasTouching && e.type == 'touchstart' &&
6692
6738
  [...e.changedTouches].find(t=> !isGamepadTouch(t));
6693
6739
  const touch = pressTouch || gameTouches[0];
6740
+ let pos = vec2(touch.clientX, touch.clientY);
6741
+
6742
+ // two fingers pinch: how far they moved together or apart is the touch screen's wheel, a
6743
+ // click for every 50 pixels, and the mouse is between them, the place to zoom about; with
6744
+ // touchPinchWheel off the pinch is only measured, and the first finger is the mouse as ever
6745
+ const pinching = touching > 1 && touchPinchWheel;
6746
+ if (touching > 1)
6747
+ {
6748
+ const [a, b] = gameTouches, key = a.identifier + ' ' + b.identifier;
6749
+ const distance = hypot(a.clientX - b.clientX, a.clientY - b.clientY);
6750
+ if (inputPinch && inputPinch.key === key)
6751
+ {
6752
+ const delta = (inputPinch.distance - distance) / 50;
6753
+ touchPinch += delta;
6754
+ if (touchPinchWheel)
6755
+ mouseWheel += delta;
6756
+ }
6757
+ inputPinch = {key, distance}; // other fingers start it over, with no jump
6758
+ if (pinching)
6759
+ pos = vec2((a.clientX + b.clientX) / 2, (a.clientY + b.clientY) / 2);
6760
+ }
6761
+ else
6762
+ inputPinch = undefined;
6694
6763
 
6695
6764
  // set event pos and pass it along
6696
- const pos = vec2(touch.clientX, touch.clientY);
6697
6765
  const mousePosScreenLast = mousePosScreen;
6698
6766
  mousePosScreen = mouseEventToScreen(pos);
6699
- if (inputWasTouching && touch.identifier === inputTouchIdentifier)
6767
+ if (pinching)
6768
+ {
6769
+ // a pinch is not a press or a drag, the finger that pressed lets go
6770
+ if (inputData[0][button] & 1)
6771
+ inputData[0][button] = inputData[0][button] & 2 | 4;
6772
+ }
6773
+ else if (inputWasTouching && touch.identifier === inputTouchIdentifier)
6700
6774
  mouseDeltaScreen = mouseDeltaScreen.add(mousePosScreen.subtract(mousePosScreenLast));
6701
6775
  else if (pressTouch)
6702
6776
  inputData[0][button] = 3;
6703
- inputTouchIdentifier = touch.identifier;
6777
+ // the finger left after a pinch moves the mouse from where it is, with no jump and no press
6778
+ inputTouchIdentifier = pinching ? undefined : touch.identifier;
6779
+ }
6780
+ else
6781
+ {
6782
+ inputPinch = undefined;
6783
+ if (inputWasTouching && (inputData[0][button] & 1))
6784
+ inputData[0][button] = inputData[0][button] & 2 | 4; // released only if it was pressed
6704
6785
  }
6705
- else if (inputWasTouching && (inputData[0][button] & 1))
6706
- inputData[0][button] = inputData[0][button] & 2 | 4; // released only if it was pressed
6707
6786
 
6708
6787
  // set was touching
6709
6788
  inputWasTouching = touching;
@@ -6962,7 +7041,7 @@ function inputUpdatePost()
6962
7041
  for (const deviceInputData of inputData)
6963
7042
  for (const i in deviceInputData)
6964
7043
  deviceInputData[i] &= 1;
6965
- mouseWheel = 0;
7044
+ mouseWheel = touchPinch = 0;
6966
7045
  mouseDelta = vec2();
6967
7046
  mouseDeltaScreen = vec2();
6968
7047
  }
@@ -7694,10 +7773,9 @@ class Sound
7694
7773
  false&&ASSERT(isNumber(range), 'range must be a number');
7695
7774
  false&&ASSERT(isNumber(taper), 'taper must be a number');
7696
7775
 
7697
- /** @property {number} - World space max range of sound */
7698
7776
  this.range = range;
7699
7777
  /** @property {boolean} - No range was given, so it got soundDefaultRange; played in 3D such a sound is heard
7700
- * to render3D.soundDefaultRange instead, which is further */
7778
+ * to render3D.soundDefaultRange instead, which is further; setting range makes it false */
7701
7779
  this.rangeIsDefault = rangeIsDefault;
7702
7780
  /** @property {number} - At what percentage of range should it start tapering */
7703
7781
  this.taper = taper;
@@ -7753,6 +7831,15 @@ class Sound
7753
7831
  }
7754
7832
  }
7755
7833
 
7834
+ /** World space max range of sound, 0 for no limit; a range that is set is the sound's own, in 3D too
7835
+ * @type {number} */
7836
+ get range() { return this.rangeValue; }
7837
+ set range(range)
7838
+ {
7839
+ this.rangeValue = range;
7840
+ this.rangeIsDefault = false;
7841
+ }
7842
+
7756
7843
  /** Sample data for each channel
7757
7844
  * Sounds keep their samples in an audio buffer, so reading this rebuilds
7758
7845
  * the arrays from it and caches them. The copies are safe to hold onto,
@@ -13118,13 +13205,13 @@ class LightSystemPlugin
13118
13205
  // an automatic size follows the canvas, so reallocate the lightmap when
13119
13206
  // the canvas changed size, after the flush so the batch keeps its texture
13120
13207
  const size = lightSystem.textureSize;
13121
- if (lightSystem.textureSizeAuto &&
13122
- (size.x !== mainCanvasSize.x || size.y !== mainCanvasSize.y))
13208
+ const wantX = glClampTextureSize(mainCanvasSize.x), wantY = glClampTextureSize(mainCanvasSize.y);
13209
+ if (lightSystem.textureSizeAuto && (size.x !== wantX || size.y !== wantY))
13123
13210
  {
13124
- lightSystem.textureSize = mainCanvasSize.copy();
13211
+ lightSystem.textureSize = vec2(wantX, wantY);
13125
13212
  glContext.bindTexture(glContext.TEXTURE_2D, lightSystem.texture);
13126
13213
  glContext.texImage2D(glContext.TEXTURE_2D, 0, glContext.RGBA,
13127
- mainCanvasSize.x, mainCanvasSize.y, 0,
13214
+ wantX, wantY, 0,
13128
13215
  glContext.RGBA, glContext.UNSIGNED_BYTE, null);
13129
13216
  // put back the texture the engine tracks, a draw in renderLight must not sample the lightmap
13130
13217
  if (glActiveTexture)
@@ -13264,8 +13351,10 @@ class LightSystemPlugin
13264
13351
  this.shadowMapSize = glClampTextureSize(this.shadowMapSize);
13265
13352
  this.shadowTextureSize = glClampTextureSize(this.shadowTextureSize);
13266
13353
  const size = this.textureSize;
13267
- if (size)
13268
- this.textureSize = vec2(glClampTextureSize(size.x), glClampTextureSize(size.y));
13354
+ if (!size) return;
13355
+ const x = glClampTextureSize(size.x), y = glClampTextureSize(size.y);
13356
+ if (x !== size.x || y !== size.y)
13357
+ this.textureSize = vec2(x, y); // only when it is too large, this runs every frame
13269
13358
  }
13270
13359
 
13271
13360
  /** Draw a single Light's falloff blob into the currently bound lightmap.
@@ -20103,6 +20192,179 @@ function sceneUpdate() { paused || !timeScale || sceneCurrent?.update?.(); }
20103
20192
  function sceneRender() { sceneCurrent?.render?.(); }
20104
20193
  function sceneRenderPost() { sceneCurrent?.renderPost?.(); }
20105
20194
 
20195
+ /**
20196
+ * LittleJS Parallax Plugin
20197
+ * - A background layer that follows the camera by a part of its movement, so it looks far away
20198
+ * - Repeats across the view, so a level of any width has a background
20199
+ * - Draws mountains by default, or anything a game draws into it
20200
+ * @namespace Parallax
20201
+ */
20202
+
20203
+ ///////////////////////////////////////////////////////////////////////////////
20204
+
20205
+ /**
20206
+ * A background layer with parallax: an image that follows the camera by a part of its movement and repeats across
20207
+ * the view, drawn once into a canvas by a function, mountains when none is given
20208
+ * - A far layer has a parallax near 1 and a low renderOrder, a near one a smaller parallax and a higher renderOrder
20209
+ * - The image should meet itself at its left and right edges to repeat without a seam, as the mountains do
20210
+ * @memberof Parallax
20211
+ * @extends CanvasLayer
20212
+ * @example
20213
+ * // three ranges of mountains, the far ones lighter and slower
20214
+ * for (let i = 3; i--;)
20215
+ * new ParallaxLayer(vec2(0, i*2), vec2(40, 20), .9 - i*.2, -1e3 + i,
20216
+ * parallaxMountains(hsl(.6, .3, .7 - i*.2), hsl(.6, .5, .2), i));
20217
+ *
20218
+ * // or draw your own image into the layer
20219
+ * new ParallaxLayer(vec2(), vec2(40, 20), .5, -1e3, (context, size)=>
20220
+ * {
20221
+ * context.fillStyle = '#fff';
20222
+ * context.fillRect(size.x/2 - 20, size.y/2 - 20, 40, 40);
20223
+ * });
20224
+ */
20225
+ class ParallaxLayer extends CanvasLayer
20226
+ {
20227
+ /** Create a parallax layer and draw its image
20228
+ * @param {Vector2} [pos] - Where the middle of the image is in the world when the camera is there too
20229
+ * @param {Vector2} [size] - World size of one copy of the image
20230
+ * @param {number|Vector2} [parallax] - How much of the camera's movement it follows, 0 stays with the world
20231
+ * and 1 with the screen, a Vector2 to follow x and y by different amounts
20232
+ * @param {number} [renderOrder] - Low to draw behind the game, far layers lowest
20233
+ * @param {function(OffscreenCanvasRenderingContext2D, Vector2, ParallaxLayer): void} [drawFunction] - Draws
20234
+ * the image, given the canvas context, its size in pixels and the layer; mountains when not given
20235
+ * @param {Vector2} [canvasSize] - Size of the image in pixels */
20236
+ constructor(pos=vec2(), size=vec2(32, 16), parallax=.5, renderOrder=-1e3, drawFunction=parallaxMountains(),
20237
+ canvasSize=vec2(512, 256))
20238
+ {
20239
+ false&&ASSERT(isNumber(parallax) || isVector2(parallax), 'parallax must be a number or a Vector2');
20240
+ false&&ASSERT(typeof drawFunction == 'function', 'drawFunction must be a function');
20241
+ super(pos, size, 0, renderOrder, canvasSize);
20242
+
20243
+ /** @property {Vector2} - How much of the camera's movement it follows on each axis, 0 stays with the world
20244
+ * and 1 with the screen */
20245
+ this.parallax = isVector2(parallax) ? /** @type {Vector2} */ (parallax).copy() :
20246
+ vec2(/** @type {number} */ (parallax));
20247
+ /** @property {boolean} - Repeat the image across the view, left and right */
20248
+ this.wrapX = true;
20249
+ /** @property {boolean} - Repeat the image up and down the view */
20250
+ this.wrapY = false;
20251
+ /** @property {number} - How much the camera's zoom changes its size on the screen: 1 like the world, 0 not
20252
+ * at all, the same on the screen at any zoom as it is at zoomScale */
20253
+ this.zoomFollow = 1;
20254
+ /** @property {number} - The camera scale its size is given for, used when zoomFollow is under 1 */
20255
+ this.zoomScale = cameraScale;
20256
+ /** @property {function(OffscreenCanvasRenderingContext2D, Vector2, ParallaxLayer): void} - Draws the
20257
+ * image, redraw() after changing it */
20258
+ this.drawFunction = drawFunction;
20259
+ this.redraw();
20260
+ }
20261
+
20262
+ /** Draw the image again with drawFunction, after changing it or what it draws from */
20263
+ redraw()
20264
+ {
20265
+ const context = this.context;
20266
+ if (!context) return; // headless
20267
+ const size = vec2(this.canvas.width, this.canvas.height);
20268
+ context.clearRect(0, 0, size.x, size.y);
20269
+ this.drawFunction(context, size, this);
20270
+ this.updateWebGL();
20271
+ }
20272
+
20273
+ // how much smaller it is drawn than its size, from the camera's zoom and how much of it the layer follows
20274
+ zoomFactor()
20275
+ { return this.zoomFollow < 1 && cameraScale > 0 ? (this.zoomScale / cameraScale) ** (1 - this.zoomFollow) : 1; }
20276
+
20277
+ /** World size one copy of the image is drawn at now, its size unless zoomFollow is under 1
20278
+ * @return {Vector2} */
20279
+ getDrawSize() { return this.size.scale(this.zoomFactor()); }
20280
+
20281
+ /** Where each copy of the image is drawn now, in the world: one place, or with wrapping every place that
20282
+ * shows in the view
20283
+ * @return {Array<Vector2>} */
20284
+ getDrawPositions()
20285
+ {
20286
+ // its place seen from the camera is cut down by the parallax, and by the zoom it does not follow
20287
+ const zoom = this.zoomFactor(), size = this.size.scale(zoom), p = this.parallax;
20288
+ const offset = this.pos.subtract(cameraPos);
20289
+ const center = cameraPos.add(vec2(offset.x * (1 - p.x) * zoom, offset.y * (1 - p.y) * zoom));
20290
+
20291
+ // the copies that reach into the view; a turned camera sees as far as its corner each way
20292
+ const view = mainCanvasSize.scale(.5 / (cameraScale || 1));
20293
+ const reach = cameraAngle ? vec2(view.length()) : view;
20294
+ const range = (wrap, axis)=>
20295
+ {
20296
+ if (!wrap || !(size[axis] > 0)) return [0, 0];
20297
+ const from = cameraPos[axis] - reach[axis] - size[axis] / 2 - center[axis];
20298
+ const to = cameraPos[axis] + reach[axis] + size[axis] / 2 - center[axis];
20299
+ const first = ceil(from / size[axis]);
20300
+ return [first, min(floor(to / size[axis]), first + 99)]; // a tiny image does not draw without end
20301
+ };
20302
+ const [x0, x1] = range(this.wrapX, 'x'), [y0, y1] = range(this.wrapY, 'y'), positions = [];
20303
+ for (let y = y0; y <= y1; ++y)
20304
+ for (let x = x0; x <= x1; ++x)
20305
+ positions.push(vec2(center.x + x * size.x, center.y + y * size.y));
20306
+ return positions;
20307
+ }
20308
+
20309
+ // Render the layer, called automatically by the engine
20310
+ render()
20311
+ {
20312
+ const size = this.getDrawSize();
20313
+ for (const pos of this.getDrawPositions())
20314
+ this.draw(pos, size, this.color, 0, this.mirror, this.additiveColor, false);
20315
+ }
20316
+ }
20317
+
20318
+ ///////////////////////////////////////////////////////////////////////////////
20319
+
20320
+ // the ridge of a range of mountains: a height for each column of an image and one more, the last the same as the
20321
+ // first so the image repeats with no step; it wanders about the middle, turning now and then
20322
+ function parallaxRidge(width, height, random)
20323
+ {
20324
+ const pointiness = .2, levelness = .005, slopeRange = height / 256; // how often it turns, how hard it is
20325
+ const ridge = new Float32Array(width + 1), middle = height / 2; // pulled level, how steep it gets
20326
+ let y = middle, slope = random.float(-slopeRange, slopeRange);
20327
+ for (let x = 0; x <= width; ++x)
20328
+ {
20329
+ ridge[x] = y;
20330
+ y += slope -= (y - middle) * levelness;
20331
+ if (random.float() < pointiness)
20332
+ slope = random.float(-slopeRange, slopeRange);
20333
+ }
20334
+ // take out the difference between its ends, a little from each column, and keep it inside the image
20335
+ const drift = ridge[width] - ridge[0];
20336
+ for (let x = 0; x <= width; ++x)
20337
+ ridge[x] = clamp(ridge[x] - drift * x / width, 0, height);
20338
+ return ridge;
20339
+ }
20340
+
20341
+ /** A draw function for a ParallaxLayer: a range of mountains across the image, shaded from the color of their
20342
+ * peaks down to the color of their feet, that repeats with no seam
20343
+ * @param {Color} [topColor] - Color at the peaks
20344
+ * @param {Color} [bottomColor] - Color at the bottom of the image
20345
+ * @param {number} [seed] - The same seed gives the same mountains, random when not given
20346
+ * @return {function(OffscreenCanvasRenderingContext2D, Vector2): void}
20347
+ * @memberof Parallax */
20348
+ function parallaxMountains(topColor=hsl(.6, .2, .6), bottomColor=hsl(.6, .3, .25), seed)
20349
+ {
20350
+ return (context, size)=>
20351
+ {
20352
+ const w = size.x, h = size.y;
20353
+ const random = new RandomGenerator(seed === undefined ? randInt(1e9) + 1 : seed * 7919 + 1);
20354
+ const ridge = parallaxRidge(w, h, random);
20355
+
20356
+ // a strip one pixel wide with the shading, stretched down each column from its peak
20357
+ const strip = createCanvasContext(1, h);
20358
+ for (let i = h; i--;)
20359
+ {
20360
+ strip.fillStyle = topColor.lerp(bottomColor, i / h).toString();
20361
+ strip.fillRect(0, i, 1, 1);
20362
+ }
20363
+ for (let x = w; x--;)
20364
+ ridge[x] < h && context.drawImage(strip.canvas, 0, 0, 1, h, x, ridge[x], 1, h - ridge[x]);
20365
+ };
20366
+ }
20367
+
20106
20368
  /**
20107
20369
  * LittleJS PathFinder Plugin
20108
20370
  * - Grid-based A* pathfinder with two-pass smoothing for natural-looking paths
@@ -22369,12 +22631,17 @@ function render3DShadowCaster()
22369
22631
  light.intensity > 0 && light.color.a > 0 ? light : undefined;
22370
22632
  }
22371
22633
 
22634
+ // the widest cone a shadow map can look down, to each side; the light that casts the shadows lights no wider, or
22635
+ // what is past the map's edge would be lit with nothing to shade it
22636
+ const RENDER3D_SHADOW_CONE_MAX = 1.35;
22637
+
22372
22638
  // a light's cone as the four numbers the shader gets: the way it shines, scaled so that its dot with the way to
22373
22639
  // a point, less the fourth number, is 0 at the edge of the cone and 1 where its fade starts; a light with no cone
22374
22640
  // gets numbers that make it 1 every way
22375
22641
  function render3DLightCone(light)
22376
22642
  {
22377
- const angle = light.directional ? 0 : min(light.coneAngle, PI);
22643
+ const widest = light === render3DShadowCaster() ? RENDER3D_SHADOW_CONE_MAX : PI;
22644
+ const angle = light.directional ? 0 : min(light.coneAngle, widest);
22378
22645
  if (!(angle > 0)) return [0, 0, 0, -1];
22379
22646
  const outer = cos(angle), inner = cos(angle * (1 - clamp(light.coneSoftness)));
22380
22647
  const k = 1 / max(inner - outer, 1e-4), forward = light.getForward3D();
@@ -23161,8 +23428,9 @@ class Render3DPlugin
23161
23428
  // a spotlight's map looks down its cone with perspective, from just in front of it out to its radius
23162
23429
  const pos = caster.getWorldPos3D(), forward = caster.getForward3D();
23163
23430
  const up = abs(forward.y) > .99 ? vec3(0, 0, 1) : vec3(0, 1, 0);
23164
- const far = caster.radius, near = max(far / 500, .02);
23165
- const fov = min(min(caster.coneAngle, PI) * 2 + .1, 2.8);
23431
+ // the near plane stays in front of the far one however small the light
23432
+ const far = caster.radius, near = min(max(far / 500, .02), far / 2);
23433
+ const fov = min(caster.coneAngle, RENDER3D_SHADOW_CONE_MAX) * 2 + .1;
23166
23434
  this.shadowMatrix = Matrix4.perspective(fov, 1, near, far).multiply(
23167
23435
  Matrix4.lookAt(pos, pos.add(forward), up).invert());
23168
23436
  this.shadowPlanes = render3DFrustumPlanes(this.shadowMatrix);
@@ -26596,7 +26864,7 @@ class InstancedMesh3D extends EngineObject3D
26596
26864
  * - intensity multiplies the color, above 1 for a light brighter than white
26597
26865
  * - radius is a world distance, so scale3D does not change it
26598
26866
  * - An alpha, an intensity or a radius of 0 switches it off, and a light that is off takes none of those slots
26599
- * - Draws nothing itself, add a glow with drawSoftDisc or a small emissive mesh if it should be seen
26867
+ * - Draws nothing but its glow, when it has one; add a small emissive mesh if the lamp itself should be seen
26600
26868
  * @extends EngineObject3D
26601
26869
  * @memberof Render3D
26602
26870
  * @example
@@ -26645,8 +26913,10 @@ class Light3D extends EngineObject3D
26645
26913
  const r = render3D, c = this.color, pos = render3DObjectMatrix(this).getTranslation();
26646
26914
  const toCamera = r.camera.pos.subtract(pos), distance = toCamera.length();
26647
26915
  const at = distance ? pos.add(toCamera.scale(min(this.glow, distance) / 2 / distance)) : pos;
26648
- const color = rgb(c.r, c.g, c.b, c.a * min(this.intensity, 1));
26649
- r.drawBillboard(at, vec2(this.glow), engineGlowTexture(this.glowFalloff), color);
26916
+ const color = rgb(c.r, c.g, c.b, c.a * min(this.intensity, 1)), texture = engineGlowTexture(this.glowFalloff);
26917
+ // a browser that can not make the glow's texture draws no glow, not a plain square
26918
+ if (texture || !glContext)
26919
+ r.drawBillboard(at, vec2(this.glow), texture, color);
26650
26920
  }
26651
26921
  }
26652
26922
 
@@ -26656,7 +26926,7 @@ class Light3D extends EngineObject3D
26656
26926
  * - It shines from its position toward the origin, like a three.js DirectionalLight: only the direction to it
26657
26927
  * counts, so moving it or its parent swings the light around; parent it to a sun in the sky and it follows
26658
26928
  * - It cannot sit on the origin, since that leaves no direction
26659
- * - Like every Light3D it casts no shadow, only the sun, render3D.sunDirection, does
26929
+ * - It casts no shadow: the sun, render3D.sunDirection, does, or a spotlight set as render3D.shadowLight
26660
26930
  * @extends Light3D
26661
26931
  * @memberof Render3D
26662
26932
  * @example
@@ -27733,6 +28003,9 @@ class ParticleEmitter3D extends EngineObject3D
27733
28003
  /** @property {boolean} - Particles hit the level, the height maps and voxel maps, bouncing by restitution and
27734
28004
  * sliding along by friction; off by default, it tests each particle's move against the level every frame */
27735
28005
  this.collideLevel = false;
28006
+ /** @property {number} - How much a particle grips where it lands, 0 to 1: its speed along the surface is
28007
+ * cut by this much on each hit, on top of the friction, 1 stops it there */
28008
+ this.stick = 0;
27736
28009
  /** @property {Particle3DCallback|undefined} - Called with each particle as it is made
27737
28010
  * @type {Particle3DCallback|undefined} */
27738
28011
  this.particleCreateCallback = undefined;
@@ -27752,7 +28025,7 @@ class ParticleEmitter3D extends EngineObject3D
27752
28025
  /** @property {Vector3|undefined} - Where the emitter was at its last update, for when its parent is destroyed
27753
28026
  * @type {Vector3|undefined} */
27754
28027
  this.worldPos3D = undefined;
27755
- this.emitTimeBuffer = 0;
28028
+ this.emitTimeBuffer = 1; // the first particle comes at once, as the 2D emitter's does
27756
28029
  }
27757
28030
 
27758
28031
  /** Spawn new particles, move the live ones, and go away when done */
@@ -27769,9 +28042,10 @@ class ParticleEmitter3D extends EngineObject3D
27769
28042
  {
27770
28043
  // a rate of zero is an emitter fed by hand, and the global scale only quiets it,
27771
28044
  // neither is a reason to stop counting down the emit time
27772
- if (this.emitRate && particleEmitRateScale)
28045
+ const rate = this.emitRate * particleEmitRateScale;
28046
+ if (rate > 0 && rate < Infinity)
27773
28047
  {
27774
- this.emitTimeBuffer += this.emitRate * particleEmitRateScale * timeDelta;
28048
+ this.emitTimeBuffer += rate * timeDelta;
27775
28049
  for (; this.emitTimeBuffer >= 1; --this.emitTimeBuffer)
27776
28050
  this.emitParticle();
27777
28051
  }
@@ -27811,7 +28085,9 @@ class ParticleEmitter3D extends EngineObject3D
27811
28085
  const vz = data[k+5] = data[k+5] * damping + gravityZ;
27812
28086
  data[k] += vx, data[k+1] += vy, data[k+2] += vz;
27813
28087
  data[k+18] += data[k+19] *= angleDamping;
27814
- collideLevel && (this.particleCollide(k, data[k] - vx, data[k+1] - vy, data[k+2] - vz), reread());
28088
+ // true when its collide callback destroyed it, it has had its last callback but the destroy one
28089
+ const gone = collideLevel && this.particleCollide(k, data[k] - vx, data[k+1] - vy, data[k+2] - vz);
28090
+ collideLevel && reread();
27815
28091
  const t = i * trailMax * 3;
27816
28092
  if (trailMax)
27817
28093
  {
@@ -27823,7 +28099,7 @@ class ParticleEmitter3D extends EngineObject3D
27823
28099
  trail[j] = data[k], trail[j+1] = data[k+1], trail[j+2] = data[k+2];
27824
28100
  data[k+20] = n + 1;
27825
28101
  }
27826
- updateCallback && (this.particleCall(updateCallback, k), reread());
28102
+ updateCallback && !gone && (this.particleCall(updateCallback, k), reread());
27827
28103
  if ((data[k+17] += timeDelta) >= data[k+16])
27828
28104
  {
27829
28105
  // dead: the last particle takes its slot, trail and all, order does not matter
@@ -27925,13 +28201,14 @@ class ParticleEmitter3D extends EngineObject3D
27925
28201
  out[k] = view.pos.x, out[k+1] = view.pos.y, out[k+2] = view.pos.z;
27926
28202
  out[k+3] = view.velocity.x, out[k+4] = view.velocity.y, out[k+5] = view.velocity.z;
27927
28203
  if (view.destroyed)
27928
- out[k+17] = max(out[k+17], out[k+16] - timeDelta); // aged past its life this update
28204
+ out[k+17] = max(out[k+17], out[k+16]); // its life lived: a step short of it can round to just under
27929
28205
  return result;
27930
28206
  }
27931
28207
 
27932
28208
  // stop the particle at k where its last move went into the level, from where it was: back at the surface, a hair
27933
28209
  // off it, its speed into it turned around by restitution and its speed along it kept by friction, unless the
27934
- // collide callback lets it through; one that starts inside is let go, as a 2D one is
28210
+ // collide callback lets it through; one that starts inside is let go, as a 2D one is; true when the callback
28211
+ // destroyed it
27935
28212
  particleCollide(k, x, y, z)
27936
28213
  {
27937
28214
  let data = this.particleData;
@@ -27944,18 +28221,21 @@ class ParticleEmitter3D extends EngineObject3D
27944
28221
  if (h && (!hit || h.distance < hit.distance))
27945
28222
  hit = h, level = l;
27946
28223
  }
27947
- if (!hit) return;
28224
+ if (!hit) return false;
27948
28225
  const point = from.lerp(to, hit.distance);
27949
28226
  const callback = this.particleCollideCallback;
27950
28227
  if (callback && (!this.particleCall(callback, k, level, point.copy()) || this.particleView.destroyed))
27951
- return; // let through, or destroyed by the callback
28228
+ return this.particleView.destroyed; // let through, or destroyed by the callback
27952
28229
  data = this.particleData; // as the callback left it, an emit may have grown it
27953
28230
 
27954
28231
  const n = hit.normal, v = vec3(data[k+3], data[k+4], data[k+5]), into = n.scale(v.dot(n));
27955
- const restitution = max(this.restitution, level.restitution), friction = max(this.friction, level.friction);
28232
+ // the larger friction of the two, as objects take it, then the emitter's own grip, which a level can not undo
28233
+ const restitution = max(this.restitution, level.restitution);
28234
+ const friction = max(this.friction, level.friction) * (1 - clamp(this.stick));
27956
28235
  const out = v.subtract(into).scale(friction).subtract(into.scale(restitution)), p = point.add(n.scale(1e-3));
27957
28236
  data[k] = p.x, data[k+1] = p.y, data[k+2] = p.z;
27958
28237
  data[k+3] = out.x, data[k+4] = out.y, data[k+5] = out.z;
28238
+ return false;
27959
28239
  }
27960
28240
 
27961
28241
  /** Draw the particles, as flat squares or as streaks when trailTime is set
@@ -28219,6 +28499,11 @@ class LensFlare3D extends EngineObject3D
28219
28499
  * height; shape is glow, disc or ring
28220
28500
  * @type {Array<{at: number, size: number, color: Color, shape: string}>|undefined} */
28221
28501
  this.elements = undefined;
28502
+ /** @property {Light3D|undefined} - A light the flare is of in place of the sun, a lamp or a spotlight: the
28503
+ * flare is at the light and in its color, smaller from farther than the light reaches, hidden by what is
28504
+ * in front of the light, and a spotlight's shows from inside its beam only
28505
+ * @type {Light3D|undefined} */
28506
+ this.light = undefined;
28222
28507
  /** @property {boolean} - Fade out when something is between the camera and the sun */
28223
28508
  this.occlusion = true;
28224
28509
  /** @property {number} - Seconds the flare takes to fade out or in when the sun is hidden or shows again */
@@ -28257,13 +28542,30 @@ class LensFlare3D extends EngineObject3D
28257
28542
  return this.made;
28258
28543
  }
28259
28544
 
28260
- /** Where the sun is on the screen, in pixels like mousePosScreen, undefined when it is behind the camera
28545
+ // what the flare is of, seen from the camera: the way to it, how far it is, Infinity for the sun, and a point
28546
+ // to find it on the screen by; undefined with no sun direction, or a light that is gone or at the camera
28547
+ flareSource()
28548
+ {
28549
+ const camera = render3D.camera.pos, light = this.light;
28550
+ if (!light)
28551
+ {
28552
+ const sun = render3D.sunDirection;
28553
+ if (!sun.lengthSquared()) return;
28554
+ const direction = sun.normalize();
28555
+ return {direction, distance: Infinity, pos: camera.add(direction.scale(100))};
28556
+ }
28557
+ if (light.destroyed) return;
28558
+ const pos = light.getWorldPos3D(), offset = pos.subtract(camera), distance = offset.length();
28559
+ return distance ? {direction: offset.scale(1 / distance), distance, pos} : undefined;
28560
+ }
28561
+
28562
+ /** Where the sun, or the flare's light, is on the screen, in pixels like mousePosScreen, undefined when it is
28563
+ * behind the camera
28261
28564
  * @return {Vector2|undefined} */
28262
28565
  getSunScreenPos()
28263
28566
  {
28264
- const direction = render3D.sunDirection;
28265
- if (!direction.lengthSquared()) return;
28266
- return render3D.worldToScreen(render3D.camera.pos.add(direction.normalize(100)));
28567
+ const source = this.flareSource();
28568
+ return source && render3D.worldToScreen(source.pos);
28267
28569
  }
28268
28570
 
28269
28571
  /** The parts of the flare as they are drawn now: each one's place on the screen, its size in pixels and its
@@ -28277,7 +28579,19 @@ class LensFlare3D extends EngineObject3D
28277
28579
  const off = max(abs(sun.x - center.x) / center.x, abs(sun.y - center.y) / center.y);
28278
28580
  const strength = this.visible * clamp((1.3 - off) / .5) * this.intensity;
28279
28581
  if (!(strength > 0)) return [];
28280
- const tint = this.color.multiply(render3D.sunColor), height = mainCanvasSize.y * this.flareSize;
28582
+ let tint = this.color.multiply(render3D.sunColor), height = mainCanvasSize.y * this.flareSize;
28583
+ const light = this.light, source = light && this.flareSource();
28584
+ if (light)
28585
+ {
28586
+ if (!source) return [];
28587
+ // a light's flare is the light's color, as bright as the light up to 1, and only from inside its cone
28588
+ const cone = render3DLightCone(light), d = source.direction;
28589
+ const inCone = clamp(-(cone[0] * d.x + cone[1] * d.y + cone[2] * d.z) - cone[3]);
28590
+ const c = light.color, bright = c.a * clamp(light.intensity) * inCone;
28591
+ if (!(bright > 0)) return [];
28592
+ tint = this.color.multiply(rgb(c.r, c.g, c.b, bright));
28593
+ height *= min(1, light.radius / source.distance); // smaller from farther than it reaches
28594
+ }
28281
28595
  return this.getElements().map((e)=>
28282
28596
  {
28283
28597
  const c = e.color.multiply(tint);
@@ -28287,15 +28601,32 @@ class LensFlare3D extends EngineObject3D
28287
28601
  });
28288
28602
  }
28289
28603
 
28290
- /** Is something between the camera and the sun: the level, or an object that is not see through
28604
+ /** Is something between the camera and the sun, or the flare's light: the level, or an object that is not see
28605
+ * through
28291
28606
  * @return {boolean} */
28292
28607
  isSunHidden()
28293
28608
  {
28294
- const direction = render3D.sunDirection;
28295
- if (!direction.lengthSquared()) return true;
28296
- const blockers = engineObjects.filter((o)=> o !== this && o instanceof EngineObject3D && !o.transparent &&
28297
- !o.additive);
28298
- return !!render3D.pick(new Ray3D(render3D.camera.pos, direction.normalize()), blockers);
28609
+ const source = this.flareSource();
28610
+ if (!source) return true;
28611
+ const ray = new Ray3D(render3D.camera.pos, source.direction), reach = source.distance;
28612
+ const blockers = engineObjects.filter((o)=> o !== this && o instanceof EngineObject3D && !o.destroyed &&
28613
+ !o.transparent && !o.additive);
28614
+ // a voxel map says block by block what is see through: glass, water and leaves let the sun by, and the
28615
+ // ray goes on to the blocks behind them
28616
+ const maps = /** @type {Array<VoxelMap>} */ (typeof VoxelMap == 'undefined' ? [] :
28617
+ blockers.filter((o)=> o instanceof VoxelMap));
28618
+ for (const map of maps)
28619
+ if (map.raycast(ray, reach, (type)=> !map.blockType(type).seeThrough))
28620
+ return true;
28621
+ // an object is hit by its box, and a box the camera is inside, a room, a wide floor or the player's own
28622
+ // body, says nothing of what is in the way: the ray the other way hits it too, and it is left out; so is
28623
+ // one the light is inside, its lamp
28624
+ const backward = ray.direction.scale(-1), back = new Ray3D(ray.origin, backward);
28625
+ const lamp = this.light && [new Ray3D(source.pos, ray.direction), new Ray3D(source.pos, backward)];
28626
+ const hits = (r, o)=> render3DRaycastObject(r, o) !== undefined;
28627
+ const around = (o)=> !(o instanceof HeightMap) && (hits(back, o) || lamp && hits(lamp[0], o) && hits(lamp[1], o));
28628
+ const hit = render3D.pick(ray, blockers.filter((o)=> !maps.some((map)=> map === o) && !around(o)));
28629
+ return !!hit && hit.distance < reach;
28299
28630
  }
28300
28631
 
28301
28632
  /** Ease visible toward whether the sun shows, called automatically each frame */
@@ -29425,7 +29756,10 @@ function level3DAddMesh(name, mesh, tileInfo, color=WHITE)
29425
29756
  * returned, see level3DVoxelSetup for its sheet
29426
29757
  * - A level may hold a terrain, in a terrain block: pos, its center, size, its size in the world along x and z,
29427
29758
  * height, how tall a full height is, color, and heights, rows of 0 to 1 from -z to +z, each from -x to +x; it
29428
- * is made a HeightMap, returned with what else was made
29759
+ * is made a HeightMap, returned with what else was made; paint, when it has it, colors its samples: colors, a
29760
+ * list, and cells, runs of a count and a color along the rows, 0 for the terrain's own and 1 the list's first
29761
+ * - A level may hold prefabs of its own, in a prefabs block, each by its name as level3DAddPrefab takes it; they
29762
+ * are added before its objects are made, one the game added itself keeps its place
29429
29763
  * - A level may set the scene too, in a scene block beside its objects: sky, three colors for straight up, the
29430
29764
  * horizon and straight down, ambient, how much of them lights the scene, .5 when not given, sunDirection and
29431
29765
  * sunColor, fog, its start and end, fogColor, the horizon color when not given, and shadows; what the block
@@ -29438,6 +29772,7 @@ function level3DLoad(level)
29438
29772
  false&&ASSERT(!!level && typeof level === 'object', 'a level is an object, {} for a new one');
29439
29773
  false&&ASSERT(!(level.littlejs3D > LEVEL3D_VERSION), 'the level was made by a newer LittleJS');
29440
29774
  editor3DLevelLoaded(level); // debug builds: the 3D editor takes the level, its autosaved edits go in first
29775
+ level3DPrefabsAdd(level.prefabs);
29441
29776
  level3DSceneApply(level.scene);
29442
29777
  const made = [], map = level3DVoxelMap = level3DVoxelsMake(level.voxels);
29443
29778
  map && made.push(map);
@@ -29548,14 +29883,49 @@ function level3DTerrainShape(terrain)
29548
29883
  height: isNumber(terrain.height) && terrain.height > 0 ? terrain.height : 1, heights};
29549
29884
  }
29550
29885
 
29886
+ // a hex color of a level as a Color, undefined when it is not one
29887
+ function level3DHexColor(hex)
29888
+ { return /^#([0-9a-f]{6}|[0-9a-f]{8})$/i.test(hex) ? new Color().setHex(hex) : undefined; }
29889
+
29890
+ // the paint of a level's terrain block, for a terrain of so many samples: its colors as they are written and a
29891
+ // color for each sample, row after row, 0 for none and 1 the first of the colors; undefined when it has none or
29892
+ // it does not fit the terrain
29893
+ function level3DTerrainPaint(terrain, samples)
29894
+ {
29895
+ const paint = terrain?.paint, colors = paint?.colors, runs = paint?.cells;
29896
+ if (!isArray(colors) || !isArray(runs) || colors.length > 255 || !colors.every(level3DHexColor)) return;
29897
+ const cells = new Uint8Array(samples);
29898
+ let at = 0;
29899
+ for (let i = 0; i < runs.length; i += 2)
29900
+ {
29901
+ const count = runs[i], color = runs[i + 1];
29902
+ if (!(count > 0 && count % 1 === 0 && color >= 0 && color % 1 === 0) || color > colors.length ||
29903
+ at + count > samples) return;
29904
+ cells.fill(color, at, at += count);
29905
+ }
29906
+ return at === samples ? {colors: colors.slice(), cells} : undefined;
29907
+ }
29908
+
29909
+ // give a terrain's map the colors of its block: its own color over the whole of it, or with paint a color for
29910
+ // each sample, the terrain's own where it is not painted; the caller builds the map again
29911
+ function level3DTerrainSetColors(map, terrain, paint=level3DTerrainPaint(terrain, map.rows * map.columns))
29912
+ {
29913
+ const base = level3DHexColor(terrain.color) || WHITE;
29914
+ map.color = base, map.colors = undefined;
29915
+ if (!paint || !paint.cells.some((v)=> v)) return;
29916
+ const palette = [base, ...paint.colors.map(level3DHexColor)];
29917
+ map.color = WHITE;
29918
+ map.colors = map.heights.map((row, r)=> row.map((v, c)=> palette[paint.cells[r * map.columns + c]] || base));
29919
+ }
29920
+
29551
29921
  // the HeightMap of a level's terrain block, undefined when it has none or it is wrong
29552
29922
  function level3DTerrainMake(terrain)
29553
29923
  {
29554
29924
  const shape = level3DTerrainShape(terrain);
29555
29925
  if (!shape || typeof HeightMap == 'undefined') return;
29556
29926
  const map = new HeightMap(shape.heights, shape.size, shape.height, undefined, shape.pos);
29557
- if (/^#([0-9a-f]{6}|[0-9a-f]{8})$/i.test(terrain.color))
29558
- map.color = new Color().setHex(terrain.color);
29927
+ level3DTerrainSetColors(map, terrain);
29928
+ map.colors && map.rebuild();
29559
29929
  return map;
29560
29930
  }
29561
29931
 
@@ -29628,6 +29998,14 @@ function level3DProperties(type, object)
29628
29998
  // make one object of a level from the type added for its name, undefined when there is no such type or it made
29629
29999
  // nothing
29630
30000
  function level3DMake(object)
30001
+ {
30002
+ return level3DMakeAt(object, level3DVector(object.pos, vec3()),
30003
+ level3DVector(object.rotation, vec3()).scale(PI / 180), level3DVector(object.scale, vec3(1)));
30004
+ }
30005
+
30006
+ // make a level object's game object at a place, a rotation in radians and a scale, its own as level3DMake gives
30007
+ // them or the ones a prefab's instance puts it at
30008
+ function level3DMakeAt(object, pos, rotation, scale)
29631
30009
  {
29632
30010
  const type = level3DTypes.get(object.type);
29633
30011
  if (!type)
@@ -29635,7 +30013,7 @@ function level3DMake(object)
29635
30013
  debug && console.warn(`level3DLoad: no type added for ${object.type}, skipped`);
29636
30014
  return;
29637
30015
  }
29638
- const pos = level3DVector(object.pos, vec3()), properties = level3DProperties(type, object);
30016
+ const properties = level3DProperties(type, object);
29639
30017
  const {make} = type, count = engineObjects.length;
29640
30018
  let result;
29641
30019
  try { result = make.prototype ? new make(pos, properties) : make(pos, properties); }
@@ -29653,16 +30031,263 @@ function level3DMake(object)
29653
30031
  if (result instanceof EngineObject3D)
29654
30032
  {
29655
30033
  level3DBaseScale.set(result, result.scale3D.copy());
29656
- result.rotation3D = level3DVector(object.rotation, vec3()).scale(PI / 180);
29657
- result.scale3D = result.scale3D.multiply(level3DVector(object.scale, vec3(1)));
30034
+ result.rotation3D = rotation.copy();
30035
+ result.scale3D = result.scale3D.multiply(scale);
29658
30036
  }
29659
30037
 
29660
30038
  // the type's own properties are set on it, one the type has no default for could be a field of the engine's
29661
30039
  for (const key in type.defaults)
29662
30040
  result[key] = properties[key];
30041
+ // a prefab's instance makes its parts now that it is where it goes
30042
+ result instanceof Prefab3D && result.placeAt(pos);
29663
30043
  return result;
29664
30044
  }
29665
30045
 
30046
+ /** Make one object of a type added with level3DAddType, level3DAddMesh or level3DAddPrefab, from code, with no
30047
+ * level: a prefab's instance, or a plain type
30048
+ * @param {string} type - The type's name
30049
+ * @param {Vector3} [pos3D]
30050
+ * @param {Vector3} [rotation3D] - In radians, as an object has it
30051
+ * @param {Vector3} [scale3D] - Times the scale the type makes it with
30052
+ * @param {Object} [properties] - Over the type's defaults
30053
+ * @return {any} - What the type made, a Prefab3D for a prefab, undefined when there is no such type
30054
+ * @memberof Level3D
30055
+ * @example
30056
+ * level3DAddPrefab('House', await fetchJSON('house.json'));
30057
+ * level3DSpawn('House', vec3(10, 0, 0), vec3(0, PI/2, 0)); */
30058
+ function level3DSpawn(type, pos3D=vec3(), rotation3D=vec3(), scale3D=vec3(1), properties)
30059
+ {
30060
+ false&&ASSERT(isVector3(pos3D) && isVector3(rotation3D) && isVector3(scale3D), 'pos3D, rotation3D and scale3D are vec3');
30061
+ return level3DMakeAt(properties ? {type, properties} : {type}, pos3D.copy(), rotation3D, scale3D);
30062
+ }
30063
+
30064
+ ///////////////////////////////////////////////////////////////////////////////
30065
+ // prefabs: a prefab is a small level, a list of objects about its own origin, placed many times
30066
+
30067
+ // the prefabs by name: how an instance is built, its objects, fromLevel, true for one a level's own prefabs
30068
+ // block added, which one the game adds takes the place of, and source, the object it was added from, which the
30069
+ // level editor knows a level that is a prefab by
30070
+ /** @type {Map<string, {attached: boolean, objects: Array<Object>, fromLevel: boolean, source: Object}>} */
30071
+ const level3DPrefabs = new Map;
30072
+
30073
+ // how many prefabs deep an instance being made is, a prefab that holds itself is stopped; and how many of those
30074
+ // are attached: a prefab inside an attached one is attached too, or its parts would be left behind in the world
30075
+ let level3DPrefabDepth = 0, level3DPrefabAttached = 0;
30076
+
30077
+ /** Add a prefab: a small level, its objects placed about its own origin, to place many times under one name
30078
+ * - It is a type from then on: a level's object of that type, the level editor's Place list and level3DSpawn make
30079
+ * an instance, a Prefab3D; an instance is made of the prefab as it is then, so the level editor, where a prefab
30080
+ * changes, makes its instances again
30081
+ * - The prefab is a level as the level editor saves it, {objects: [...]}, so the editor is the prefab editor too;
30082
+ * only its objects are used, and they may be of other prefabs
30083
+ * - With attached true in it the parts are children of the instance and move with it as one body, without
30084
+ * collision of their own, a prefab inside it too; its handle is at the middle of the box around them and its
30085
+ * size3D is that box, so setCollision makes the body solid where it is seen; otherwise each part is an object of its own in the world and collides as one placed
30086
+ * by hand does
30087
+ * - Adding a name again replaces it
30088
+ * @param {string} name - The type its instances have in a level
30089
+ * @param {Object} prefab - {objects, attached}
30090
+ * @memberof Level3D
30091
+ * @example
30092
+ * level3DAddPrefab('Tower', {objects: [{type: 'Box', pos: [0, 1, 0], scale: [2, 2, 2]},
30093
+ * {type: 'Cylinder', pos: [0, 3, 0]}]});
30094
+ * level3DLoad({objects: [{type: 'Tower', pos: [5, 0, 5]}, {type: 'Tower', pos: [-5, 0, 5], rotation: [0, 45, 0]}]}); */
30095
+ function level3DAddPrefab(name, prefab) { level3DPrefabSet(name, prefab, false); }
30096
+
30097
+ // add a prefab, the game's or a level's own
30098
+ function level3DPrefabSet(name, prefab, fromLevel)
30099
+ {
30100
+ false&&ASSERT(!!prefab && typeof prefab === 'object', 'a prefab is an object, {objects: [...]}');
30101
+ name = String(name);
30102
+ const objects = isArray(prefab?.objects) ? prefab.objects.filter((o)=> o && typeof o === 'object') : [];
30103
+ // a copy, what the game or the editor does to its own afterwards does not change the instances made later
30104
+ level3DPrefabs.set(name, {attached: !!prefab?.attached, objects: JSON.parse(JSON.stringify(objects)), fromLevel,
30105
+ source: prefab});
30106
+ // a class of its own, so the level editor makes its instances again as it does a class's
30107
+ level3DTypes.set(name, {make: class extends Prefab3D { constructor(pos) { super(pos, name); } }, defaults: {},
30108
+ tileInfo: undefined});
30109
+ }
30110
+
30111
+ // add a level's own prefabs, each unless the game added one of that name or it is a plain type's name
30112
+ function level3DPrefabsAdd(prefabs)
30113
+ {
30114
+ if (!prefabs || typeof prefabs !== 'object' || isArray(prefabs)) return;
30115
+ for (const name in prefabs)
30116
+ {
30117
+ const known = level3DPrefabs.get(name);
30118
+ if (known ? !known.fromLevel : level3DTypes.has(name)) continue;
30119
+ prefabs[name] && typeof prefabs[name] === 'object' && level3DPrefabSet(name, prefabs[name], true);
30120
+ }
30121
+ }
30122
+
30123
+ /** Load a prefab from a file the level editor saved and add it
30124
+ * @param {string} name - The type its instances have in a level
30125
+ * @param {string} url
30126
+ * @return {Promise<void>}
30127
+ * @memberof Level3D */
30128
+ async function level3DLoadPrefab(name, url) { level3DAddPrefab(name, await fetchJSON(url)); }
30129
+
30130
+ // where an instance puts one of its prefab's objects: the object's own place, turn and size inside the instance's
30131
+ function level3DPrefabPartTransform(instance, object)
30132
+ {
30133
+ const pos = level3DVector(object.pos, vec3()), scale = level3DVector(object.scale, vec3(1));
30134
+ const rotation = level3DVector(object.rotation, vec3()).scale(PI / 180);
30135
+ // the two turns one after the other; the sizes multiply along each axis, which is exact for an instance sized
30136
+ // evenly or a part that is not turned, and as near as a box can be otherwise
30137
+ const turn = buildMatrix(vec3(), instance.rotation3D).multiply(buildMatrix(vec3(), rotation));
30138
+ return {pos: buildMatrix(instance.pos3D, instance.rotation3D, instance.scale3D).transformPoint(pos),
30139
+ rotation: turn.getRotation(), scale: scale.multiply(instance.scale3D)};
30140
+ }
30141
+
30142
+ /**
30143
+ * An instance of a prefab, what a prefab's type makes: a handle with no shape of its own, and its parts, what the
30144
+ * prefab's objects made
30145
+ * - Parts of a prefab that is not attached are objects of their own in the world: moving, turning or sizing the
30146
+ * handle puts them where it now says with its next update, or call placeParts; destroying it destroys them
30147
+ * - Parts of an attached prefab are its children and move with it as one body
30148
+ * @extends EngineObject3D
30149
+ * @memberof Level3D
30150
+ */
30151
+ class Prefab3D extends EngineObject3D
30152
+ {
30153
+ /** Create an instance of a prefab, made by its type: place one with a level or level3DSpawn
30154
+ * @param {Vector3} [pos3D]
30155
+ * @param {string} [prefabName] - A prefab added with level3DAddPrefab */
30156
+ constructor(pos3D, prefabName='')
30157
+ {
30158
+ super(pos3D);
30159
+ /** @property {string} - The prefab it is an instance of */
30160
+ this.prefabName = prefabName;
30161
+ /** @property {boolean} - Are its parts its children, moving with it as one body */
30162
+ this.attached = !!level3DPrefabs.get(prefabName)?.attached || level3DPrefabAttached > 0;
30163
+ /** @property {Array<any>} - What the prefab's objects made, in the prefab's order */
30164
+ this.parts = [];
30165
+ /** @property {Vector3} - From the prefab's origin to the handle, in the prefab's own space: nothing for
30166
+ * separate parts, and for an attached prefab the middle of the box around its parts, where its handle
30167
+ * is, as an object's place is the middle of its body */
30168
+ this.originOffset = vec3();
30169
+ this.partObjects = []; // the prefab's object of each part
30170
+ this.partsPlaced = ''; // where the handle was when its parts were last placed, undefined parts not made
30171
+ this.partsMade = false;
30172
+ }
30173
+
30174
+ /** Put the instance with its prefab's origin at a place, turned and sized as the handle is: where a level's
30175
+ * object or level3DSpawn says it goes; the handle of an attached prefab is then at the middle of its body
30176
+ * @param {Vector3} pos3D */
30177
+ placeAt(pos3D)
30178
+ {
30179
+ this.pos3D = pos3D.copy();
30180
+ this.placeParts();
30181
+ const o = this.originOffset;
30182
+ if (o.x || o.y || o.z)
30183
+ this.pos3D = buildMatrix(pos3D, this.rotation3D, this.scale3D).transformPoint(o);
30184
+ }
30185
+
30186
+ /** Put the parts where the handle is now, making them the first time; called by the handle's update when it
30187
+ * has moved, turned or changed size */
30188
+ placeParts()
30189
+ {
30190
+ if (this.destroyed) return;
30191
+ this.partsMade ||= (this.makeParts(), true);
30192
+ this.partsPlaced = this.placeKey();
30193
+ if (this.attached) return; // children follow by themselves
30194
+ this.parts.forEach((part, i)=>
30195
+ {
30196
+ if (!(part instanceof EngineObject3D)) return; // what a function made is where it was made
30197
+ const to = level3DPrefabPartTransform(this, this.partObjects[i]);
30198
+ part.rotation3D = to.rotation;
30199
+ part.scale3D = (level3DBaseScale.get(part) ?? vec3(1)).multiply(to.scale);
30200
+ // a prefab inside this one goes by its origin, which is not where an attached one's handle is
30201
+ part instanceof Prefab3D ? part.placeAt(to.pos) : part.pos3D = to.pos;
30202
+ });
30203
+ }
30204
+
30205
+ // the handle's place, turn and size as one text, to tell when it has changed
30206
+ placeKey()
30207
+ {
30208
+ const p = this.pos3D, r = this.rotation3D, s = this.scale3D;
30209
+ return [p.x, p.y, p.z, r.x, r.y, r.z, s.x, s.y, s.z].join();
30210
+ }
30211
+
30212
+ // make the parts, each where the instance puts it, or attached at its own place inside the instance
30213
+ makeParts()
30214
+ {
30215
+ const prefab = level3DPrefabs.get(this.prefabName);
30216
+ if (!prefab) return;
30217
+ if (level3DPrefabDepth >= 8)
30218
+ {
30219
+ debug && console.error(`level3DLoad: the prefab ${this.prefabName} holds itself, left out there`);
30220
+ return;
30221
+ }
30222
+ ++level3DPrefabDepth;
30223
+ this.attached && ++level3DPrefabAttached;
30224
+ try
30225
+ {
30226
+ const low = vec3(Infinity), high = vec3(-Infinity), children = [];
30227
+ for (const object of prefab.objects)
30228
+ {
30229
+ const local = {pos: level3DVector(object.pos, vec3()), scale: level3DVector(object.scale, vec3(1)),
30230
+ rotation: level3DVector(object.rotation, vec3()).scale(PI / 180)};
30231
+ const at = this.attached ? local : level3DPrefabPartTransform(this, object);
30232
+ const part = level3DMakeAt(object, at.pos, at.rotation, at.scale);
30233
+ if (!part) continue; // level3DMakeAt gives an object or nothing
30234
+ this.parts.push(part);
30235
+ this.partObjects.push(object);
30236
+ if (!this.attached || !(part instanceof EngineObject3D)) continue;
30237
+ // a child rides with its parent and has no collision of its own
30238
+ part.setCollision(false, false, false);
30239
+ children.push(part);
30240
+ // the corners of its box as it is turned; its place is its middle, a prefab inside this one too
30241
+ const box = buildMatrix(part.pos3D, part.rotation3D, part.size3D.multiply(part.scale3D));
30242
+ for (let i = 8; i--;)
30243
+ {
30244
+ const p = box.transformPoint(vec3(i & 1 ? .5 : -.5, i & 2 ? .5 : -.5, i & 4 ? .5 : -.5));
30245
+ for (const k of ['x', 'y', 'z'])
30246
+ low[k] = min(low[k], p[k]), high[k] = max(high[k], p[k]);
30247
+ }
30248
+ }
30249
+ // an attached instance is the box around its parts, its handle at the middle of it as an object's
30250
+ // place is the middle of its body, so a game that makes it solid has it solid where it is seen
30251
+ if (children.length)
30252
+ {
30253
+ this.size3D = high.subtract(low);
30254
+ this.originOffset = low.add(high).scale(.5);
30255
+ }
30256
+ for (const part of children)
30257
+ {
30258
+ part.pos3D = part.pos3D.subtract(this.originOffset);
30259
+ this.addChild(part);
30260
+ }
30261
+ }
30262
+ finally
30263
+ {
30264
+ --level3DPrefabDepth;
30265
+ this.attached && --level3DPrefabAttached;
30266
+ }
30267
+ }
30268
+
30269
+ /** Keep the parts with the handle, called automatically each frame */
30270
+ update()
30271
+ {
30272
+ super.update();
30273
+ if (!this.attached && this.partsPlaced !== this.placeKey())
30274
+ this.placeParts();
30275
+ }
30276
+
30277
+ /** Destroy the instance and its parts
30278
+ * @param {boolean} [immediate] */
30279
+ destroy(immediate)
30280
+ {
30281
+ if (this.destroyed) return;
30282
+ for (const part of this.parts)
30283
+ part.parent === this || part.destroy?.(immediate); // children go with their parent
30284
+ super.destroy(immediate);
30285
+ }
30286
+
30287
+ /** A prefab's instance has nothing of its own to draw */
30288
+ render3D() {}
30289
+ }
30290
+
29666
30291
  ///////////////////////////////////////////////////////////////////////////////
29667
30292
  // the built-in types
29668
30293
 
@@ -29876,7 +30501,9 @@ function particleEffectSanitize(raw)
29876
30501
  settings[setting.name] = value === '' || particleEffectShapes.includes(value) ? value : fallback;
29877
30502
  else if (isNumber(value))
29878
30503
  {
29879
- const clamped = clamp(value, setting.hardMin, setting.hardMax);
30504
+ // an angle past half a turn comes around, a clamp would point it the other way
30505
+ const wraps = setting.name === 'angle' && isFinite(value) && abs(value) > PI;
30506
+ const clamped = clamp(wraps ? mod(value + PI, 2*PI) - PI : value, setting.hardMin, setting.hardMax);
29880
30507
  settings[setting.name] = setting.step >= 1 ? round(clamped) : clamped;
29881
30508
  }
29882
30509
  else
@@ -29955,9 +30582,11 @@ function particleEffectsGet(name) { return particleEffectsByName.get(String(name
29955
30582
  * @param {string} text
29956
30583
  * @return {Array<Object>}
29957
30584
  * @memberof ParticleEffects */
29958
- function particleEffectsParse(text)
30585
+ function particleEffectsParse(text) { return particleEffectsFromData(JSON.parse(text)); }
30586
+
30587
+ // a library file's data, or one effect's, into sanitized effects
30588
+ function particleEffectsFromData(data)
29959
30589
  {
29960
- const data = JSON.parse(text);
29961
30590
  const list = isArray(data?.effects) ? data.effects : data?.settings ? [data] : undefined;
29962
30591
  if (!list || !list.length)
29963
30592
  throw new Error('no effects in this file');
@@ -29976,7 +30605,7 @@ function particleEffectsText(effects) { return JSON.stringify({version:1, effect
29976
30605
  * @memberof ParticleEffects */
29977
30606
  async function particleEffectsLoad(url)
29978
30607
  {
29979
- const effects = particleEffectsParse(JSON.stringify(await fetchJSON(url)));
30608
+ const effects = particleEffectsFromData(await fetchJSON(url));
29980
30609
  particleEffectsAdd(effects);
29981
30610
  return effects;
29982
30611
  }
@@ -30154,7 +30783,13 @@ function particleEffectTileInfo(s)
30154
30783
  * @memberof ParticleEffects */
30155
30784
  function particleEffectApply(emitter, effect)
30156
30785
  {
30157
- effect = particleEffectSanitize(effect); // an effect written by hand or changed since it was added
30786
+ // an effect written by hand or changed since it was added
30787
+ particleEffectSet(emitter, particleEffectSanitize(effect));
30788
+ }
30789
+
30790
+ // set a 2D emitter to an effect that is sanitized already, as a play's is
30791
+ function particleEffectSet(emitter, effect)
30792
+ {
30158
30793
  const s = effect.settings, ended = emitter.emitTime < 0; // destroyed, its last particles going
30159
30794
  for (const setting of particleEffectSettings)
30160
30795
  if (!particleEffectIndirect.includes(setting.name))
@@ -30190,15 +30825,14 @@ function particleEffectResolve(nameOrEffect, options)
30190
30825
  * - Attach it to an object with addChild to follow it
30191
30826
  * @param {string|Object} nameOrEffect - A built-in or added effect's name, or an effect
30192
30827
  * @param {Vector2} [pos]
30193
- * @param {Object} [options]
30194
- * @param {number} [options.scale] - Grows the whole effect, the built-ins fit a one unit object at 1
30195
- * @param {number} [options.hue] - Turns its colors around the color wheel, 1 is all the way
30196
- * @param {number} [options.saturation] - Multiplies its saturation, 0 is grey
30197
- * @param {number} [options.angle] - Direction, 0 is up; the effect's own angle when not given
30198
- * @param {TileInfo|TextureInfo} [options.tileInfo] - The game's own art to draw with in place of the effect's shape,
30199
- * tinted by its colors; a whole texture draws as one tile
30200
- * @param {*} [options.settings] - Any effect setting by its name, emitTime, emitRate, speed and the rest, replacing
30201
- * the effect's own for this play
30828
+ * @param {Object} [options] - What to change for this play, each left out when not wanted:
30829
+ * scale grows the whole effect, the built-ins fit a one unit object at 1;
30830
+ * hue turns its colors around the color wheel, 1 is all the way;
30831
+ * saturation multiplies its saturation, 0 is grey;
30832
+ * angle is its direction, 0 is up, the effect's own angle when not given;
30833
+ * tileInfo, a TileInfo or a TextureInfo, is the game's own art to draw with in place of the effect's shape,
30834
+ * tinted by its colors, a whole texture drawn as one tile;
30835
+ * and any effect setting by its name, emitTime, emitRate, speed and the rest, replaces the effect's own
30202
30836
  * @return {ParticleEmitter|undefined} - undefined when there is no such effect
30203
30837
  * @memberof ParticleEffects */
30204
30838
  function particleEffect(nameOrEffect, pos=vec2(), options={})
@@ -30206,7 +30840,7 @@ function particleEffect(nameOrEffect, pos=vec2(), options={})
30206
30840
  const effect = particleEffectResolve(nameOrEffect, options);
30207
30841
  if (!effect) return;
30208
30842
  const emitter = new ParticleEmitter(pos.copy());
30209
- particleEffectApply(emitter, effect);
30843
+ particleEffectSet(emitter, effect);
30210
30844
  emitter.scale = options.scale ?? 1;
30211
30845
  if (options.angle !== undefined)
30212
30846
  emitter.angle = options.angle;
@@ -30270,7 +30904,7 @@ function particleEffectFromEmitter(emitter, name='Effect')
30270
30904
  p.velocity.y += (p.pos.x - c.x) * s * .002;
30271
30905
  };
30272
30906
  b('wind').update3D = (p, s)=> p.velocity.x += s * .004 * p.scale * min(p.age / p.lifeTime, 1);
30273
- // stick has no 3D push: particleEffect3D makes it the friction a particle lands with
30907
+ // stick has no 3D push: particleEffectApply3D makes it the emitter's stick, its grip on landing
30274
30908
  }
30275
30909
 
30276
30910
  /** Set a 3D emitter to an effect, live, so a running one keeps its particles; its place, scale and flatten stay
@@ -30279,7 +30913,13 @@ function particleEffectFromEmitter(emitter, name='Effect')
30279
30913
  * @memberof ParticleEffects */
30280
30914
  function particleEffectApply3D(emitter, effect)
30281
30915
  {
30282
- effect = particleEffectSanitize(effect); // an effect written by hand or changed since it was added
30916
+ // an effect written by hand or changed since it was added
30917
+ particleEffectSet3D(emitter, particleEffectSanitize(effect));
30918
+ }
30919
+
30920
+ // set a 3D emitter to an effect that is sanitized already, as a play's is
30921
+ function particleEffectSet3D(emitter, effect)
30922
+ {
30283
30923
  const s = effect.settings, e = emitter;
30284
30924
  // a circle is a sphere and a rectangle a box as deep as it is wide
30285
30925
  e.emitSize = s.emitRect ? vec3(s.emitSize, s.emitHeight, s.emitSize) : s.emitSize;
@@ -30292,8 +30932,8 @@ function particleEffectApply3D(emitter, effect)
30292
30932
  'randomness', 'additive', 'gravityScale', 'angleSpeed', 'angleDamping', 'collideLevel', 'restitution'])
30293
30933
  e[name] = s[name];
30294
30934
  e.trailTime = s.trailScale / 60; // a stretch of speed times trailScale is a streak of that many frames
30295
- const stick = effect.behaviors.find(b=> b.name == 'stick'); // in 3D, a grip on landing is less sliding
30296
- e.friction = stick ? min(s.friction, 1 - stick.strength) : s.friction;
30935
+ e.friction = s.friction;
30936
+ e.stick = effect.behaviors.find(b=> b.name == 'stick')?.strength ?? 0; // in 3D the emitter grips on landing
30297
30937
  // a 2D emitter at angle a shoots along (sin a, cos a), a z turn r takes up to (-sin r, cos r), so r is -a
30298
30938
  e.rotation3D = vec3(0, 0, -s.angle);
30299
30939
  e.particleUpdateCallback = particleEffectUpdateCallback(effect.behaviors, true);
@@ -30303,8 +30943,8 @@ function particleEffectApply3D(emitter, effect)
30303
30943
  * - The same effect data as particleEffect, so the look carries across: a circle spawn area becomes a sphere and a
30304
30944
  * rectangle a box as deep as it is wide, both flat across the way it emits with options.flatten, a disc or a
30305
30945
  * sheet; a trail becomes a streak of the same length, and the settings the 3D emitter lacks (particleConeAngle,
30306
- * randomColorLinear, velocityInheritance, localSpace) are left out; the stick behavior becomes the friction a
30307
- * particle lands with
30946
+ * randomColorLinear, velocityInheritance, localSpace) are left out; the stick behavior becomes the emitter's
30947
+ * stick, its grip where a particle lands
30308
30948
  * @param {string|Object} nameOrEffect - A built-in or added effect's name, or an effect
30309
30949
  * @param {Vector3} [pos3D]
30310
30950
  * @param {Object} [options] - scale, hue, saturation, angle, tileInfo and any setting as particleEffect; angle turns it
@@ -30316,7 +30956,7 @@ function particleEffect3D(nameOrEffect, pos3D=vec3(), options={})
30316
30956
  const effect = particleEffectResolve(nameOrEffect, options);
30317
30957
  if (!effect) return;
30318
30958
  const e = new ParticleEmitter3D(pos3D.copy());
30319
- particleEffectApply3D(e, effect);
30959
+ particleEffectSet3D(e, effect);
30320
30960
  if (options.angle !== undefined)
30321
30961
  e.rotation3D = vec3(0, 0, -options.angle);
30322
30962
  e.scale3D = vec3(options.scale ?? 1);