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}
@@ -3772,6 +3772,15 @@ let touchGamepadEnable = false;
3772
3772
  * @memberof Settings */
3773
3773
  let touchGamepadPassthrough = false;
3774
3774
 
3775
+ /** True if a pinch of two fingers turns the mouse wheel: touchPinch is added to mouseWheel, so what zooms with
3776
+ * the wheel zooms with a pinch, the mouse is between the two fingers, and a second finger lets go of the button
3777
+ * the first pressed, since a pinch is not a drag; turn it off for a game with its own use for two fingers, the
3778
+ * first finger stays the mouse and touchPinch can still be read
3779
+ * @type {boolean}
3780
+ * @default
3781
+ * @memberof Settings */
3782
+ let touchPinchWheel = true;
3783
+
3775
3784
  /** Size of center button if touch gamepad should have start button in the center
3776
3785
  * - Prevents activating when pressed near virtual stick or face buttons
3777
3786
  * - When the game is paused, any touch will press the button
@@ -4167,6 +4176,11 @@ function setTouchInputEnable(enable) { touchInputEnable = enable; }
4167
4176
  * @memberof Settings */
4168
4177
  function setTouchGamepadEnable(enable) { touchGamepadEnable = enable; }
4169
4178
 
4179
+ /** Set if a pinch of two fingers turns the mouse wheel
4180
+ * @param {boolean} enable
4181
+ * @memberof Settings */
4182
+ function setTouchPinchWheel(enable) { touchPinchWheel = enable; }
4183
+
4170
4184
  /** Set if touches outside the gamepad controls should still drive mouse/touch input
4171
4185
  * @param {boolean} passthrough
4172
4186
  * @memberof Settings */
@@ -5473,17 +5487,29 @@ class SpriteAnimator
5473
5487
  * @return {SpriteAnimation} */
5474
5488
  get clip() { return this.clips[this.name]; }
5475
5489
 
5490
+ /** The clip showing now, once a clip that has ended has called its onEnd: the callback may switch clips, and a
5491
+ * read gives the clip it switched to, not the one that ended
5492
+ * @return {SpriteAnimation}
5493
+ * @ignore */
5494
+ get settledClip()
5495
+ {
5496
+ // each clip's own isDone calls its onEnd once; a few switches in a row at most
5497
+ for (let i = 4, clip; i-- && clip !== this.clip;)
5498
+ (clip = this.clip).isDone;
5499
+ return this.clip;
5500
+ }
5501
+
5476
5502
  /** The tile of the frame showing now
5477
5503
  * @return {TileInfo} */
5478
- get tileInfo() { return this.clip.tileInfo; }
5504
+ get tileInfo() { return this.settledClip.tileInfo; }
5479
5505
 
5480
5506
  /** The frame of the clip showing now
5481
5507
  * @return {number} */
5482
- get frame() { return this.clip.frame; }
5508
+ get frame() { return this.settledClip.frame; }
5483
5509
 
5484
5510
  /** True once the clip showing now is a play that has ended
5485
5511
  * @return {boolean} */
5486
- get isDone() { return this.clip.isDone; }
5512
+ get isDone() { return this.settledClip.isDone; }
5487
5513
  }
5488
5514
 
5489
5515
  ///////////////////////////////////////////////////////////////////////////////
@@ -6922,6 +6948,13 @@ let mouseDeltaScreen = vec2();
6922
6948
  * @memberof Input */
6923
6949
  let mouseWheel = 0;
6924
6950
 
6951
+ /** Touch pinch delta this frame, in the mouse wheel's units: two fingers moving apart are negative, as the wheel
6952
+ * turned up is, and moving together positive, 1 for every 50 pixels; added to mouseWheel while touchPinchWheel
6953
+ * is on, so a game that zooms with the wheel zooms with a pinch
6954
+ * @type {number}
6955
+ * @memberof Input */
6956
+ let touchPinch = 0;
6957
+
6925
6958
  /** True if mouse was inside the document window, set to false when mouse leaves
6926
6959
  * @type {boolean}
6927
6960
  * @memberof Input */
@@ -7010,6 +7043,7 @@ function inputClearKey(key, device=0, clearDown=true, clearPressed=true, clearRe
7010
7043
  const inputWASDToArrow = {KeyW:'ArrowUp', KeyS:'ArrowDown', KeyA:'ArrowLeft', KeyD:'ArrowRight'};
7011
7044
  const inputArrowToWASD = {ArrowUp:'KeyW', ArrowDown:'KeyS', ArrowLeft:'KeyA', ArrowRight:'KeyD'};
7012
7045
  const inputKeysHeld = new Set; // the keys physically down, since an arrow's slot is shared with its alias
7046
+ let inputPinch; // the two fingers pinching and how far apart they were, undefined with no pinch
7013
7047
  let inputWasTouching = 0, inputTouchIdentifier; // the touch driving the mouse, cleared with the input so only a new touch presses
7014
7048
  let inputLastTouchTime = -1e9; // when a touch event last came, the mouse events a browser makes from a tap follow it
7015
7049
 
@@ -7052,6 +7086,7 @@ function inputClear()
7052
7086
  if (!i || inputData[i]) inputData[i] = [];
7053
7087
  inputKeysHeld.clear();
7054
7088
  inputWasTouching = 0;
7089
+ inputPinch = undefined;
7055
7090
  // release the touch gamepad, a finger still on it has to lift before it can take a control again
7056
7091
  touchGamepadPointerRole.clear();
7057
7092
  touchGamepadButtons.length = 0;
@@ -7579,19 +7614,52 @@ function inputInit()
7579
7614
  const pressTouch = !inputWasTouching && e.type == 'touchstart' &&
7580
7615
  [...e.changedTouches].find(t=> !isGamepadTouch(t));
7581
7616
  const touch = pressTouch || gameTouches[0];
7617
+ let pos = vec2(touch.clientX, touch.clientY);
7618
+
7619
+ // two fingers pinch: how far they moved together or apart is the touch screen's wheel, a
7620
+ // click for every 50 pixels, and the mouse is between them, the place to zoom about; with
7621
+ // touchPinchWheel off the pinch is only measured, and the first finger is the mouse as ever
7622
+ const pinching = touching > 1 && touchPinchWheel;
7623
+ if (touching > 1)
7624
+ {
7625
+ const [a, b] = gameTouches, key = a.identifier + ' ' + b.identifier;
7626
+ const distance = hypot(a.clientX - b.clientX, a.clientY - b.clientY);
7627
+ if (inputPinch && inputPinch.key === key)
7628
+ {
7629
+ const delta = (inputPinch.distance - distance) / 50;
7630
+ touchPinch += delta;
7631
+ if (touchPinchWheel)
7632
+ mouseWheel += delta;
7633
+ }
7634
+ inputPinch = {key, distance}; // other fingers start it over, with no jump
7635
+ if (pinching)
7636
+ pos = vec2((a.clientX + b.clientX) / 2, (a.clientY + b.clientY) / 2);
7637
+ }
7638
+ else
7639
+ inputPinch = undefined;
7582
7640
 
7583
7641
  // set event pos and pass it along
7584
- const pos = vec2(touch.clientX, touch.clientY);
7585
7642
  const mousePosScreenLast = mousePosScreen;
7586
7643
  mousePosScreen = mouseEventToScreen(pos);
7587
- if (inputWasTouching && touch.identifier === inputTouchIdentifier)
7644
+ if (pinching)
7645
+ {
7646
+ // a pinch is not a press or a drag, the finger that pressed lets go
7647
+ if (inputData[0][button] & 1)
7648
+ inputData[0][button] = inputData[0][button] & 2 | 4;
7649
+ }
7650
+ else if (inputWasTouching && touch.identifier === inputTouchIdentifier)
7588
7651
  mouseDeltaScreen = mouseDeltaScreen.add(mousePosScreen.subtract(mousePosScreenLast));
7589
7652
  else if (pressTouch)
7590
7653
  inputData[0][button] = 3;
7591
- inputTouchIdentifier = touch.identifier;
7654
+ // the finger left after a pinch moves the mouse from where it is, with no jump and no press
7655
+ inputTouchIdentifier = pinching ? undefined : touch.identifier;
7656
+ }
7657
+ else
7658
+ {
7659
+ inputPinch = undefined;
7660
+ if (inputWasTouching && (inputData[0][button] & 1))
7661
+ inputData[0][button] = inputData[0][button] & 2 | 4; // released only if it was pressed
7592
7662
  }
7593
- else if (inputWasTouching && (inputData[0][button] & 1))
7594
- inputData[0][button] = inputData[0][button] & 2 | 4; // released only if it was pressed
7595
7663
 
7596
7664
  // set was touching
7597
7665
  inputWasTouching = touching;
@@ -7850,7 +7918,7 @@ function inputUpdatePost()
7850
7918
  for (const deviceInputData of inputData)
7851
7919
  for (const i in deviceInputData)
7852
7920
  deviceInputData[i] &= 1;
7853
- mouseWheel = 0;
7921
+ mouseWheel = touchPinch = 0;
7854
7922
  mouseDelta = vec2();
7855
7923
  mouseDeltaScreen = vec2();
7856
7924
  }
@@ -8582,10 +8650,9 @@ class Sound
8582
8650
  ASSERT(isNumber(range), 'range must be a number');
8583
8651
  ASSERT(isNumber(taper), 'taper must be a number');
8584
8652
 
8585
- /** @property {number} - World space max range of sound */
8586
8653
  this.range = range;
8587
8654
  /** @property {boolean} - No range was given, so it got soundDefaultRange; played in 3D such a sound is heard
8588
- * to render3D.soundDefaultRange instead, which is further */
8655
+ * to render3D.soundDefaultRange instead, which is further; setting range makes it false */
8589
8656
  this.rangeIsDefault = rangeIsDefault;
8590
8657
  /** @property {number} - At what percentage of range should it start tapering */
8591
8658
  this.taper = taper;
@@ -8641,6 +8708,15 @@ class Sound
8641
8708
  }
8642
8709
  }
8643
8710
 
8711
+ /** World space max range of sound, 0 for no limit; a range that is set is the sound's own, in 3D too
8712
+ * @type {number} */
8713
+ get range() { return this.rangeValue; }
8714
+ set range(range)
8715
+ {
8716
+ this.rangeValue = range;
8717
+ this.rangeIsDefault = false;
8718
+ }
8719
+
8644
8720
  /** Sample data for each channel
8645
8721
  * Sounds keep their samples in an audio buffer, so reading this rebuilds
8646
8722
  * the arrays from it and caches them. The copies are safe to hold onto,
@@ -14006,13 +14082,13 @@ class LightSystemPlugin
14006
14082
  // an automatic size follows the canvas, so reallocate the lightmap when
14007
14083
  // the canvas changed size, after the flush so the batch keeps its texture
14008
14084
  const size = lightSystem.textureSize;
14009
- if (lightSystem.textureSizeAuto &&
14010
- (size.x !== mainCanvasSize.x || size.y !== mainCanvasSize.y))
14085
+ const wantX = glClampTextureSize(mainCanvasSize.x), wantY = glClampTextureSize(mainCanvasSize.y);
14086
+ if (lightSystem.textureSizeAuto && (size.x !== wantX || size.y !== wantY))
14011
14087
  {
14012
- lightSystem.textureSize = mainCanvasSize.copy();
14088
+ lightSystem.textureSize = vec2(wantX, wantY);
14013
14089
  glContext.bindTexture(glContext.TEXTURE_2D, lightSystem.texture);
14014
14090
  glContext.texImage2D(glContext.TEXTURE_2D, 0, glContext.RGBA,
14015
- mainCanvasSize.x, mainCanvasSize.y, 0,
14091
+ wantX, wantY, 0,
14016
14092
  glContext.RGBA, glContext.UNSIGNED_BYTE, null);
14017
14093
  // put back the texture the engine tracks, a draw in renderLight must not sample the lightmap
14018
14094
  if (glActiveTexture)
@@ -14152,8 +14228,10 @@ class LightSystemPlugin
14152
14228
  this.shadowMapSize = glClampTextureSize(this.shadowMapSize);
14153
14229
  this.shadowTextureSize = glClampTextureSize(this.shadowTextureSize);
14154
14230
  const size = this.textureSize;
14155
- if (size)
14156
- this.textureSize = vec2(glClampTextureSize(size.x), glClampTextureSize(size.y));
14231
+ if (!size) return;
14232
+ const x = glClampTextureSize(size.x), y = glClampTextureSize(size.y);
14233
+ if (x !== size.x || y !== size.y)
14234
+ this.textureSize = vec2(x, y); // only when it is too large, this runs every frame
14157
14235
  }
14158
14236
 
14159
14237
  /** Draw a single Light's falloff blob into the currently bound lightmap.
@@ -20991,6 +21069,179 @@ function sceneUpdate() { paused || !timeScale || sceneCurrent?.update?.(); }
20991
21069
  function sceneRender() { sceneCurrent?.render?.(); }
20992
21070
  function sceneRenderPost() { sceneCurrent?.renderPost?.(); }
20993
21071
 
21072
+ /**
21073
+ * LittleJS Parallax Plugin
21074
+ * - A background layer that follows the camera by a part of its movement, so it looks far away
21075
+ * - Repeats across the view, so a level of any width has a background
21076
+ * - Draws mountains by default, or anything a game draws into it
21077
+ * @namespace Parallax
21078
+ */
21079
+
21080
+ ///////////////////////////////////////////////////////////////////////////////
21081
+
21082
+ /**
21083
+ * A background layer with parallax: an image that follows the camera by a part of its movement and repeats across
21084
+ * the view, drawn once into a canvas by a function, mountains when none is given
21085
+ * - A far layer has a parallax near 1 and a low renderOrder, a near one a smaller parallax and a higher renderOrder
21086
+ * - The image should meet itself at its left and right edges to repeat without a seam, as the mountains do
21087
+ * @memberof Parallax
21088
+ * @extends CanvasLayer
21089
+ * @example
21090
+ * // three ranges of mountains, the far ones lighter and slower
21091
+ * for (let i = 3; i--;)
21092
+ * new ParallaxLayer(vec2(0, i*2), vec2(40, 20), .9 - i*.2, -1e3 + i,
21093
+ * parallaxMountains(hsl(.6, .3, .7 - i*.2), hsl(.6, .5, .2), i));
21094
+ *
21095
+ * // or draw your own image into the layer
21096
+ * new ParallaxLayer(vec2(), vec2(40, 20), .5, -1e3, (context, size)=>
21097
+ * {
21098
+ * context.fillStyle = '#fff';
21099
+ * context.fillRect(size.x/2 - 20, size.y/2 - 20, 40, 40);
21100
+ * });
21101
+ */
21102
+ class ParallaxLayer extends CanvasLayer
21103
+ {
21104
+ /** Create a parallax layer and draw its image
21105
+ * @param {Vector2} [pos] - Where the middle of the image is in the world when the camera is there too
21106
+ * @param {Vector2} [size] - World size of one copy of the image
21107
+ * @param {number|Vector2} [parallax] - How much of the camera's movement it follows, 0 stays with the world
21108
+ * and 1 with the screen, a Vector2 to follow x and y by different amounts
21109
+ * @param {number} [renderOrder] - Low to draw behind the game, far layers lowest
21110
+ * @param {function(OffscreenCanvasRenderingContext2D, Vector2, ParallaxLayer): void} [drawFunction] - Draws
21111
+ * the image, given the canvas context, its size in pixels and the layer; mountains when not given
21112
+ * @param {Vector2} [canvasSize] - Size of the image in pixels */
21113
+ constructor(pos=vec2(), size=vec2(32, 16), parallax=.5, renderOrder=-1e3, drawFunction=parallaxMountains(),
21114
+ canvasSize=vec2(512, 256))
21115
+ {
21116
+ ASSERT(isNumber(parallax) || isVector2(parallax), 'parallax must be a number or a Vector2');
21117
+ ASSERT(typeof drawFunction == 'function', 'drawFunction must be a function');
21118
+ super(pos, size, 0, renderOrder, canvasSize);
21119
+
21120
+ /** @property {Vector2} - How much of the camera's movement it follows on each axis, 0 stays with the world
21121
+ * and 1 with the screen */
21122
+ this.parallax = isVector2(parallax) ? /** @type {Vector2} */ (parallax).copy() :
21123
+ vec2(/** @type {number} */ (parallax));
21124
+ /** @property {boolean} - Repeat the image across the view, left and right */
21125
+ this.wrapX = true;
21126
+ /** @property {boolean} - Repeat the image up and down the view */
21127
+ this.wrapY = false;
21128
+ /** @property {number} - How much the camera's zoom changes its size on the screen: 1 like the world, 0 not
21129
+ * at all, the same on the screen at any zoom as it is at zoomScale */
21130
+ this.zoomFollow = 1;
21131
+ /** @property {number} - The camera scale its size is given for, used when zoomFollow is under 1 */
21132
+ this.zoomScale = cameraScale;
21133
+ /** @property {function(OffscreenCanvasRenderingContext2D, Vector2, ParallaxLayer): void} - Draws the
21134
+ * image, redraw() after changing it */
21135
+ this.drawFunction = drawFunction;
21136
+ this.redraw();
21137
+ }
21138
+
21139
+ /** Draw the image again with drawFunction, after changing it or what it draws from */
21140
+ redraw()
21141
+ {
21142
+ const context = this.context;
21143
+ if (!context) return; // headless
21144
+ const size = vec2(this.canvas.width, this.canvas.height);
21145
+ context.clearRect(0, 0, size.x, size.y);
21146
+ this.drawFunction(context, size, this);
21147
+ this.updateWebGL();
21148
+ }
21149
+
21150
+ // how much smaller it is drawn than its size, from the camera's zoom and how much of it the layer follows
21151
+ zoomFactor()
21152
+ { return this.zoomFollow < 1 && cameraScale > 0 ? (this.zoomScale / cameraScale) ** (1 - this.zoomFollow) : 1; }
21153
+
21154
+ /** World size one copy of the image is drawn at now, its size unless zoomFollow is under 1
21155
+ * @return {Vector2} */
21156
+ getDrawSize() { return this.size.scale(this.zoomFactor()); }
21157
+
21158
+ /** Where each copy of the image is drawn now, in the world: one place, or with wrapping every place that
21159
+ * shows in the view
21160
+ * @return {Array<Vector2>} */
21161
+ getDrawPositions()
21162
+ {
21163
+ // its place seen from the camera is cut down by the parallax, and by the zoom it does not follow
21164
+ const zoom = this.zoomFactor(), size = this.size.scale(zoom), p = this.parallax;
21165
+ const offset = this.pos.subtract(cameraPos);
21166
+ const center = cameraPos.add(vec2(offset.x * (1 - p.x) * zoom, offset.y * (1 - p.y) * zoom));
21167
+
21168
+ // the copies that reach into the view; a turned camera sees as far as its corner each way
21169
+ const view = mainCanvasSize.scale(.5 / (cameraScale || 1));
21170
+ const reach = cameraAngle ? vec2(view.length()) : view;
21171
+ const range = (wrap, axis)=>
21172
+ {
21173
+ if (!wrap || !(size[axis] > 0)) return [0, 0];
21174
+ const from = cameraPos[axis] - reach[axis] - size[axis] / 2 - center[axis];
21175
+ const to = cameraPos[axis] + reach[axis] + size[axis] / 2 - center[axis];
21176
+ const first = ceil(from / size[axis]);
21177
+ return [first, min(floor(to / size[axis]), first + 99)]; // a tiny image does not draw without end
21178
+ };
21179
+ const [x0, x1] = range(this.wrapX, 'x'), [y0, y1] = range(this.wrapY, 'y'), positions = [];
21180
+ for (let y = y0; y <= y1; ++y)
21181
+ for (let x = x0; x <= x1; ++x)
21182
+ positions.push(vec2(center.x + x * size.x, center.y + y * size.y));
21183
+ return positions;
21184
+ }
21185
+
21186
+ // Render the layer, called automatically by the engine
21187
+ render()
21188
+ {
21189
+ const size = this.getDrawSize();
21190
+ for (const pos of this.getDrawPositions())
21191
+ this.draw(pos, size, this.color, 0, this.mirror, this.additiveColor, false);
21192
+ }
21193
+ }
21194
+
21195
+ ///////////////////////////////////////////////////////////////////////////////
21196
+
21197
+ // the ridge of a range of mountains: a height for each column of an image and one more, the last the same as the
21198
+ // first so the image repeats with no step; it wanders about the middle, turning now and then
21199
+ function parallaxRidge(width, height, random)
21200
+ {
21201
+ const pointiness = .2, levelness = .005, slopeRange = height / 256; // how often it turns, how hard it is
21202
+ const ridge = new Float32Array(width + 1), middle = height / 2; // pulled level, how steep it gets
21203
+ let y = middle, slope = random.float(-slopeRange, slopeRange);
21204
+ for (let x = 0; x <= width; ++x)
21205
+ {
21206
+ ridge[x] = y;
21207
+ y += slope -= (y - middle) * levelness;
21208
+ if (random.float() < pointiness)
21209
+ slope = random.float(-slopeRange, slopeRange);
21210
+ }
21211
+ // take out the difference between its ends, a little from each column, and keep it inside the image
21212
+ const drift = ridge[width] - ridge[0];
21213
+ for (let x = 0; x <= width; ++x)
21214
+ ridge[x] = clamp(ridge[x] - drift * x / width, 0, height);
21215
+ return ridge;
21216
+ }
21217
+
21218
+ /** A draw function for a ParallaxLayer: a range of mountains across the image, shaded from the color of their
21219
+ * peaks down to the color of their feet, that repeats with no seam
21220
+ * @param {Color} [topColor] - Color at the peaks
21221
+ * @param {Color} [bottomColor] - Color at the bottom of the image
21222
+ * @param {number} [seed] - The same seed gives the same mountains, random when not given
21223
+ * @return {function(OffscreenCanvasRenderingContext2D, Vector2): void}
21224
+ * @memberof Parallax */
21225
+ function parallaxMountains(topColor=hsl(.6, .2, .6), bottomColor=hsl(.6, .3, .25), seed)
21226
+ {
21227
+ return (context, size)=>
21228
+ {
21229
+ const w = size.x, h = size.y;
21230
+ const random = new RandomGenerator(seed === undefined ? randInt(1e9) + 1 : seed * 7919 + 1);
21231
+ const ridge = parallaxRidge(w, h, random);
21232
+
21233
+ // a strip one pixel wide with the shading, stretched down each column from its peak
21234
+ const strip = createCanvasContext(1, h);
21235
+ for (let i = h; i--;)
21236
+ {
21237
+ strip.fillStyle = topColor.lerp(bottomColor, i / h).toString();
21238
+ strip.fillRect(0, i, 1, 1);
21239
+ }
21240
+ for (let x = w; x--;)
21241
+ ridge[x] < h && context.drawImage(strip.canvas, 0, 0, 1, h, x, ridge[x], 1, h - ridge[x]);
21242
+ };
21243
+ }
21244
+
20994
21245
  /**
20995
21246
  * LittleJS PathFinder Plugin
20996
21247
  * - Grid-based A* pathfinder with two-pass smoothing for natural-looking paths
@@ -23257,12 +23508,17 @@ function render3DShadowCaster()
23257
23508
  light.intensity > 0 && light.color.a > 0 ? light : undefined;
23258
23509
  }
23259
23510
 
23511
+ // the widest cone a shadow map can look down, to each side; the light that casts the shadows lights no wider, or
23512
+ // what is past the map's edge would be lit with nothing to shade it
23513
+ const RENDER3D_SHADOW_CONE_MAX = 1.35;
23514
+
23260
23515
  // a light's cone as the four numbers the shader gets: the way it shines, scaled so that its dot with the way to
23261
23516
  // 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
23262
23517
  // gets numbers that make it 1 every way
23263
23518
  function render3DLightCone(light)
23264
23519
  {
23265
- const angle = light.directional ? 0 : min(light.coneAngle, PI);
23520
+ const widest = light === render3DShadowCaster() ? RENDER3D_SHADOW_CONE_MAX : PI;
23521
+ const angle = light.directional ? 0 : min(light.coneAngle, widest);
23266
23522
  if (!(angle > 0)) return [0, 0, 0, -1];
23267
23523
  const outer = cos(angle), inner = cos(angle * (1 - clamp(light.coneSoftness)));
23268
23524
  const k = 1 / max(inner - outer, 1e-4), forward = light.getForward3D();
@@ -24049,8 +24305,9 @@ class Render3DPlugin
24049
24305
  // a spotlight's map looks down its cone with perspective, from just in front of it out to its radius
24050
24306
  const pos = caster.getWorldPos3D(), forward = caster.getForward3D();
24051
24307
  const up = abs(forward.y) > .99 ? vec3(0, 0, 1) : vec3(0, 1, 0);
24052
- const far = caster.radius, near = max(far / 500, .02);
24053
- const fov = min(min(caster.coneAngle, PI) * 2 + .1, 2.8);
24308
+ // the near plane stays in front of the far one however small the light
24309
+ const far = caster.radius, near = min(max(far / 500, .02), far / 2);
24310
+ const fov = min(caster.coneAngle, RENDER3D_SHADOW_CONE_MAX) * 2 + .1;
24054
24311
  this.shadowMatrix = Matrix4.perspective(fov, 1, near, far).multiply(
24055
24312
  Matrix4.lookAt(pos, pos.add(forward), up).invert());
24056
24313
  this.shadowPlanes = render3DFrustumPlanes(this.shadowMatrix);
@@ -27484,7 +27741,7 @@ class InstancedMesh3D extends EngineObject3D
27484
27741
  * - intensity multiplies the color, above 1 for a light brighter than white
27485
27742
  * - radius is a world distance, so scale3D does not change it
27486
27743
  * - An alpha, an intensity or a radius of 0 switches it off, and a light that is off takes none of those slots
27487
- * - Draws nothing itself, add a glow with drawSoftDisc or a small emissive mesh if it should be seen
27744
+ * - Draws nothing but its glow, when it has one; add a small emissive mesh if the lamp itself should be seen
27488
27745
  * @extends EngineObject3D
27489
27746
  * @memberof Render3D
27490
27747
  * @example
@@ -27533,8 +27790,10 @@ class Light3D extends EngineObject3D
27533
27790
  const r = render3D, c = this.color, pos = render3DObjectMatrix(this).getTranslation();
27534
27791
  const toCamera = r.camera.pos.subtract(pos), distance = toCamera.length();
27535
27792
  const at = distance ? pos.add(toCamera.scale(min(this.glow, distance) / 2 / distance)) : pos;
27536
- const color = rgb(c.r, c.g, c.b, c.a * min(this.intensity, 1));
27537
- r.drawBillboard(at, vec2(this.glow), engineGlowTexture(this.glowFalloff), color);
27793
+ const color = rgb(c.r, c.g, c.b, c.a * min(this.intensity, 1)), texture = engineGlowTexture(this.glowFalloff);
27794
+ // a browser that can not make the glow's texture draws no glow, not a plain square
27795
+ if (texture || !glContext)
27796
+ r.drawBillboard(at, vec2(this.glow), texture, color);
27538
27797
  }
27539
27798
  }
27540
27799
 
@@ -27544,7 +27803,7 @@ class Light3D extends EngineObject3D
27544
27803
  * - It shines from its position toward the origin, like a three.js DirectionalLight: only the direction to it
27545
27804
  * counts, so moving it or its parent swings the light around; parent it to a sun in the sky and it follows
27546
27805
  * - It cannot sit on the origin, since that leaves no direction
27547
- * - Like every Light3D it casts no shadow, only the sun, render3D.sunDirection, does
27806
+ * - It casts no shadow: the sun, render3D.sunDirection, does, or a spotlight set as render3D.shadowLight
27548
27807
  * @extends Light3D
27549
27808
  * @memberof Render3D
27550
27809
  * @example
@@ -28621,6 +28880,9 @@ class ParticleEmitter3D extends EngineObject3D
28621
28880
  /** @property {boolean} - Particles hit the level, the height maps and voxel maps, bouncing by restitution and
28622
28881
  * sliding along by friction; off by default, it tests each particle's move against the level every frame */
28623
28882
  this.collideLevel = false;
28883
+ /** @property {number} - How much a particle grips where it lands, 0 to 1: its speed along the surface is
28884
+ * cut by this much on each hit, on top of the friction, 1 stops it there */
28885
+ this.stick = 0;
28624
28886
  /** @property {Particle3DCallback|undefined} - Called with each particle as it is made
28625
28887
  * @type {Particle3DCallback|undefined} */
28626
28888
  this.particleCreateCallback = undefined;
@@ -28640,7 +28902,7 @@ class ParticleEmitter3D extends EngineObject3D
28640
28902
  /** @property {Vector3|undefined} - Where the emitter was at its last update, for when its parent is destroyed
28641
28903
  * @type {Vector3|undefined} */
28642
28904
  this.worldPos3D = undefined;
28643
- this.emitTimeBuffer = 0;
28905
+ this.emitTimeBuffer = 1; // the first particle comes at once, as the 2D emitter's does
28644
28906
  }
28645
28907
 
28646
28908
  /** Spawn new particles, move the live ones, and go away when done */
@@ -28657,9 +28919,10 @@ class ParticleEmitter3D extends EngineObject3D
28657
28919
  {
28658
28920
  // a rate of zero is an emitter fed by hand, and the global scale only quiets it,
28659
28921
  // neither is a reason to stop counting down the emit time
28660
- if (this.emitRate && particleEmitRateScale)
28922
+ const rate = this.emitRate * particleEmitRateScale;
28923
+ if (rate > 0 && rate < Infinity)
28661
28924
  {
28662
- this.emitTimeBuffer += this.emitRate * particleEmitRateScale * timeDelta;
28925
+ this.emitTimeBuffer += rate * timeDelta;
28663
28926
  for (; this.emitTimeBuffer >= 1; --this.emitTimeBuffer)
28664
28927
  this.emitParticle();
28665
28928
  }
@@ -28699,7 +28962,9 @@ class ParticleEmitter3D extends EngineObject3D
28699
28962
  const vz = data[k+5] = data[k+5] * damping + gravityZ;
28700
28963
  data[k] += vx, data[k+1] += vy, data[k+2] += vz;
28701
28964
  data[k+18] += data[k+19] *= angleDamping;
28702
- collideLevel && (this.particleCollide(k, data[k] - vx, data[k+1] - vy, data[k+2] - vz), reread());
28965
+ // true when its collide callback destroyed it, it has had its last callback but the destroy one
28966
+ const gone = collideLevel && this.particleCollide(k, data[k] - vx, data[k+1] - vy, data[k+2] - vz);
28967
+ collideLevel && reread();
28703
28968
  const t = i * trailMax * 3;
28704
28969
  if (trailMax)
28705
28970
  {
@@ -28711,7 +28976,7 @@ class ParticleEmitter3D extends EngineObject3D
28711
28976
  trail[j] = data[k], trail[j+1] = data[k+1], trail[j+2] = data[k+2];
28712
28977
  data[k+20] = n + 1;
28713
28978
  }
28714
- updateCallback && (this.particleCall(updateCallback, k), reread());
28979
+ updateCallback && !gone && (this.particleCall(updateCallback, k), reread());
28715
28980
  if ((data[k+17] += timeDelta) >= data[k+16])
28716
28981
  {
28717
28982
  // dead: the last particle takes its slot, trail and all, order does not matter
@@ -28813,13 +29078,14 @@ class ParticleEmitter3D extends EngineObject3D
28813
29078
  out[k] = view.pos.x, out[k+1] = view.pos.y, out[k+2] = view.pos.z;
28814
29079
  out[k+3] = view.velocity.x, out[k+4] = view.velocity.y, out[k+5] = view.velocity.z;
28815
29080
  if (view.destroyed)
28816
- out[k+17] = max(out[k+17], out[k+16] - timeDelta); // aged past its life this update
29081
+ out[k+17] = max(out[k+17], out[k+16]); // its life lived: a step short of it can round to just under
28817
29082
  return result;
28818
29083
  }
28819
29084
 
28820
29085
  // stop the particle at k where its last move went into the level, from where it was: back at the surface, a hair
28821
29086
  // off it, its speed into it turned around by restitution and its speed along it kept by friction, unless the
28822
- // collide callback lets it through; one that starts inside is let go, as a 2D one is
29087
+ // collide callback lets it through; one that starts inside is let go, as a 2D one is; true when the callback
29088
+ // destroyed it
28823
29089
  particleCollide(k, x, y, z)
28824
29090
  {
28825
29091
  let data = this.particleData;
@@ -28832,18 +29098,21 @@ class ParticleEmitter3D extends EngineObject3D
28832
29098
  if (h && (!hit || h.distance < hit.distance))
28833
29099
  hit = h, level = l;
28834
29100
  }
28835
- if (!hit) return;
29101
+ if (!hit) return false;
28836
29102
  const point = from.lerp(to, hit.distance);
28837
29103
  const callback = this.particleCollideCallback;
28838
29104
  if (callback && (!this.particleCall(callback, k, level, point.copy()) || this.particleView.destroyed))
28839
- return; // let through, or destroyed by the callback
29105
+ return this.particleView.destroyed; // let through, or destroyed by the callback
28840
29106
  data = this.particleData; // as the callback left it, an emit may have grown it
28841
29107
 
28842
29108
  const n = hit.normal, v = vec3(data[k+3], data[k+4], data[k+5]), into = n.scale(v.dot(n));
28843
- const restitution = max(this.restitution, level.restitution), friction = max(this.friction, level.friction);
29109
+ // the larger friction of the two, as objects take it, then the emitter's own grip, which a level can not undo
29110
+ const restitution = max(this.restitution, level.restitution);
29111
+ const friction = max(this.friction, level.friction) * (1 - clamp(this.stick));
28844
29112
  const out = v.subtract(into).scale(friction).subtract(into.scale(restitution)), p = point.add(n.scale(1e-3));
28845
29113
  data[k] = p.x, data[k+1] = p.y, data[k+2] = p.z;
28846
29114
  data[k+3] = out.x, data[k+4] = out.y, data[k+5] = out.z;
29115
+ return false;
28847
29116
  }
28848
29117
 
28849
29118
  /** Draw the particles, as flat squares or as streaks when trailTime is set
@@ -29107,6 +29376,11 @@ class LensFlare3D extends EngineObject3D
29107
29376
  * height; shape is glow, disc or ring
29108
29377
  * @type {Array<{at: number, size: number, color: Color, shape: string}>|undefined} */
29109
29378
  this.elements = undefined;
29379
+ /** @property {Light3D|undefined} - A light the flare is of in place of the sun, a lamp or a spotlight: the
29380
+ * flare is at the light and in its color, smaller from farther than the light reaches, hidden by what is
29381
+ * in front of the light, and a spotlight's shows from inside its beam only
29382
+ * @type {Light3D|undefined} */
29383
+ this.light = undefined;
29110
29384
  /** @property {boolean} - Fade out when something is between the camera and the sun */
29111
29385
  this.occlusion = true;
29112
29386
  /** @property {number} - Seconds the flare takes to fade out or in when the sun is hidden or shows again */
@@ -29145,13 +29419,30 @@ class LensFlare3D extends EngineObject3D
29145
29419
  return this.made;
29146
29420
  }
29147
29421
 
29148
- /** Where the sun is on the screen, in pixels like mousePosScreen, undefined when it is behind the camera
29422
+ // what the flare is of, seen from the camera: the way to it, how far it is, Infinity for the sun, and a point
29423
+ // to find it on the screen by; undefined with no sun direction, or a light that is gone or at the camera
29424
+ flareSource()
29425
+ {
29426
+ const camera = render3D.camera.pos, light = this.light;
29427
+ if (!light)
29428
+ {
29429
+ const sun = render3D.sunDirection;
29430
+ if (!sun.lengthSquared()) return;
29431
+ const direction = sun.normalize();
29432
+ return {direction, distance: Infinity, pos: camera.add(direction.scale(100))};
29433
+ }
29434
+ if (light.destroyed) return;
29435
+ const pos = light.getWorldPos3D(), offset = pos.subtract(camera), distance = offset.length();
29436
+ return distance ? {direction: offset.scale(1 / distance), distance, pos} : undefined;
29437
+ }
29438
+
29439
+ /** Where the sun, or the flare's light, is on the screen, in pixels like mousePosScreen, undefined when it is
29440
+ * behind the camera
29149
29441
  * @return {Vector2|undefined} */
29150
29442
  getSunScreenPos()
29151
29443
  {
29152
- const direction = render3D.sunDirection;
29153
- if (!direction.lengthSquared()) return;
29154
- return render3D.worldToScreen(render3D.camera.pos.add(direction.normalize(100)));
29444
+ const source = this.flareSource();
29445
+ return source && render3D.worldToScreen(source.pos);
29155
29446
  }
29156
29447
 
29157
29448
  /** The parts of the flare as they are drawn now: each one's place on the screen, its size in pixels and its
@@ -29165,7 +29456,19 @@ class LensFlare3D extends EngineObject3D
29165
29456
  const off = max(abs(sun.x - center.x) / center.x, abs(sun.y - center.y) / center.y);
29166
29457
  const strength = this.visible * clamp((1.3 - off) / .5) * this.intensity;
29167
29458
  if (!(strength > 0)) return [];
29168
- const tint = this.color.multiply(render3D.sunColor), height = mainCanvasSize.y * this.flareSize;
29459
+ let tint = this.color.multiply(render3D.sunColor), height = mainCanvasSize.y * this.flareSize;
29460
+ const light = this.light, source = light && this.flareSource();
29461
+ if (light)
29462
+ {
29463
+ if (!source) return [];
29464
+ // a light's flare is the light's color, as bright as the light up to 1, and only from inside its cone
29465
+ const cone = render3DLightCone(light), d = source.direction;
29466
+ const inCone = clamp(-(cone[0] * d.x + cone[1] * d.y + cone[2] * d.z) - cone[3]);
29467
+ const c = light.color, bright = c.a * clamp(light.intensity) * inCone;
29468
+ if (!(bright > 0)) return [];
29469
+ tint = this.color.multiply(rgb(c.r, c.g, c.b, bright));
29470
+ height *= min(1, light.radius / source.distance); // smaller from farther than it reaches
29471
+ }
29169
29472
  return this.getElements().map((e)=>
29170
29473
  {
29171
29474
  const c = e.color.multiply(tint);
@@ -29175,15 +29478,32 @@ class LensFlare3D extends EngineObject3D
29175
29478
  });
29176
29479
  }
29177
29480
 
29178
- /** Is something between the camera and the sun: the level, or an object that is not see through
29481
+ /** Is something between the camera and the sun, or the flare's light: the level, or an object that is not see
29482
+ * through
29179
29483
  * @return {boolean} */
29180
29484
  isSunHidden()
29181
29485
  {
29182
- const direction = render3D.sunDirection;
29183
- if (!direction.lengthSquared()) return true;
29184
- const blockers = engineObjects.filter((o)=> o !== this && o instanceof EngineObject3D && !o.transparent &&
29185
- !o.additive);
29186
- return !!render3D.pick(new Ray3D(render3D.camera.pos, direction.normalize()), blockers);
29486
+ const source = this.flareSource();
29487
+ if (!source) return true;
29488
+ const ray = new Ray3D(render3D.camera.pos, source.direction), reach = source.distance;
29489
+ const blockers = engineObjects.filter((o)=> o !== this && o instanceof EngineObject3D && !o.destroyed &&
29490
+ !o.transparent && !o.additive);
29491
+ // a voxel map says block by block what is see through: glass, water and leaves let the sun by, and the
29492
+ // ray goes on to the blocks behind them
29493
+ const maps = /** @type {Array<VoxelMap>} */ (typeof VoxelMap == 'undefined' ? [] :
29494
+ blockers.filter((o)=> o instanceof VoxelMap));
29495
+ for (const map of maps)
29496
+ if (map.raycast(ray, reach, (type)=> !map.blockType(type).seeThrough))
29497
+ return true;
29498
+ // an object is hit by its box, and a box the camera is inside, a room, a wide floor or the player's own
29499
+ // body, says nothing of what is in the way: the ray the other way hits it too, and it is left out; so is
29500
+ // one the light is inside, its lamp
29501
+ const backward = ray.direction.scale(-1), back = new Ray3D(ray.origin, backward);
29502
+ const lamp = this.light && [new Ray3D(source.pos, ray.direction), new Ray3D(source.pos, backward)];
29503
+ const hits = (r, o)=> render3DRaycastObject(r, o) !== undefined;
29504
+ const around = (o)=> !(o instanceof HeightMap) && (hits(back, o) || lamp && hits(lamp[0], o) && hits(lamp[1], o));
29505
+ const hit = render3D.pick(ray, blockers.filter((o)=> !maps.some((map)=> map === o) && !around(o)));
29506
+ return !!hit && hit.distance < reach;
29187
29507
  }
29188
29508
 
29189
29509
  /** Ease visible toward whether the sun shows, called automatically each frame */
@@ -30313,7 +30633,10 @@ function level3DAddMesh(name, mesh, tileInfo, color=WHITE)
30313
30633
  * returned, see level3DVoxelSetup for its sheet
30314
30634
  * - A level may hold a terrain, in a terrain block: pos, its center, size, its size in the world along x and z,
30315
30635
  * 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
30316
- * is made a HeightMap, returned with what else was made
30636
+ * is made a HeightMap, returned with what else was made; paint, when it has it, colors its samples: colors, a
30637
+ * 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
30638
+ * - A level may hold prefabs of its own, in a prefabs block, each by its name as level3DAddPrefab takes it; they
30639
+ * are added before its objects are made, one the game added itself keeps its place
30317
30640
  * - A level may set the scene too, in a scene block beside its objects: sky, three colors for straight up, the
30318
30641
  * horizon and straight down, ambient, how much of them lights the scene, .5 when not given, sunDirection and
30319
30642
  * sunColor, fog, its start and end, fogColor, the horizon color when not given, and shadows; what the block
@@ -30326,6 +30649,7 @@ function level3DLoad(level)
30326
30649
  ASSERT(!!level && typeof level === 'object', 'a level is an object, {} for a new one');
30327
30650
  ASSERT(!(level.littlejs3D > LEVEL3D_VERSION), 'the level was made by a newer LittleJS');
30328
30651
  editor3DLevelLoaded(level); // debug builds: the 3D editor takes the level, its autosaved edits go in first
30652
+ level3DPrefabsAdd(level.prefabs);
30329
30653
  level3DSceneApply(level.scene);
30330
30654
  const made = [], map = level3DVoxelMap = level3DVoxelsMake(level.voxels);
30331
30655
  map && made.push(map);
@@ -30436,14 +30760,49 @@ function level3DTerrainShape(terrain)
30436
30760
  height: isNumber(terrain.height) && terrain.height > 0 ? terrain.height : 1, heights};
30437
30761
  }
30438
30762
 
30763
+ // a hex color of a level as a Color, undefined when it is not one
30764
+ function level3DHexColor(hex)
30765
+ { return /^#([0-9a-f]{6}|[0-9a-f]{8})$/i.test(hex) ? new Color().setHex(hex) : undefined; }
30766
+
30767
+ // the paint of a level's terrain block, for a terrain of so many samples: its colors as they are written and a
30768
+ // color for each sample, row after row, 0 for none and 1 the first of the colors; undefined when it has none or
30769
+ // it does not fit the terrain
30770
+ function level3DTerrainPaint(terrain, samples)
30771
+ {
30772
+ const paint = terrain?.paint, colors = paint?.colors, runs = paint?.cells;
30773
+ if (!isArray(colors) || !isArray(runs) || colors.length > 255 || !colors.every(level3DHexColor)) return;
30774
+ const cells = new Uint8Array(samples);
30775
+ let at = 0;
30776
+ for (let i = 0; i < runs.length; i += 2)
30777
+ {
30778
+ const count = runs[i], color = runs[i + 1];
30779
+ if (!(count > 0 && count % 1 === 0 && color >= 0 && color % 1 === 0) || color > colors.length ||
30780
+ at + count > samples) return;
30781
+ cells.fill(color, at, at += count);
30782
+ }
30783
+ return at === samples ? {colors: colors.slice(), cells} : undefined;
30784
+ }
30785
+
30786
+ // give a terrain's map the colors of its block: its own color over the whole of it, or with paint a color for
30787
+ // each sample, the terrain's own where it is not painted; the caller builds the map again
30788
+ function level3DTerrainSetColors(map, terrain, paint=level3DTerrainPaint(terrain, map.rows * map.columns))
30789
+ {
30790
+ const base = level3DHexColor(terrain.color) || WHITE;
30791
+ map.color = base, map.colors = undefined;
30792
+ if (!paint || !paint.cells.some((v)=> v)) return;
30793
+ const palette = [base, ...paint.colors.map(level3DHexColor)];
30794
+ map.color = WHITE;
30795
+ map.colors = map.heights.map((row, r)=> row.map((v, c)=> palette[paint.cells[r * map.columns + c]] || base));
30796
+ }
30797
+
30439
30798
  // the HeightMap of a level's terrain block, undefined when it has none or it is wrong
30440
30799
  function level3DTerrainMake(terrain)
30441
30800
  {
30442
30801
  const shape = level3DTerrainShape(terrain);
30443
30802
  if (!shape || typeof HeightMap == 'undefined') return;
30444
30803
  const map = new HeightMap(shape.heights, shape.size, shape.height, undefined, shape.pos);
30445
- if (/^#([0-9a-f]{6}|[0-9a-f]{8})$/i.test(terrain.color))
30446
- map.color = new Color().setHex(terrain.color);
30804
+ level3DTerrainSetColors(map, terrain);
30805
+ map.colors && map.rebuild();
30447
30806
  return map;
30448
30807
  }
30449
30808
 
@@ -30516,6 +30875,14 @@ function level3DProperties(type, object)
30516
30875
  // make one object of a level from the type added for its name, undefined when there is no such type or it made
30517
30876
  // nothing
30518
30877
  function level3DMake(object)
30878
+ {
30879
+ return level3DMakeAt(object, level3DVector(object.pos, vec3()),
30880
+ level3DVector(object.rotation, vec3()).scale(PI / 180), level3DVector(object.scale, vec3(1)));
30881
+ }
30882
+
30883
+ // make a level object's game object at a place, a rotation in radians and a scale, its own as level3DMake gives
30884
+ // them or the ones a prefab's instance puts it at
30885
+ function level3DMakeAt(object, pos, rotation, scale)
30519
30886
  {
30520
30887
  const type = level3DTypes.get(object.type);
30521
30888
  if (!type)
@@ -30523,7 +30890,7 @@ function level3DMake(object)
30523
30890
  debug && console.warn(`level3DLoad: no type added for ${object.type}, skipped`);
30524
30891
  return;
30525
30892
  }
30526
- const pos = level3DVector(object.pos, vec3()), properties = level3DProperties(type, object);
30893
+ const properties = level3DProperties(type, object);
30527
30894
  const {make} = type, count = engineObjects.length;
30528
30895
  let result;
30529
30896
  try { result = make.prototype ? new make(pos, properties) : make(pos, properties); }
@@ -30541,16 +30908,263 @@ function level3DMake(object)
30541
30908
  if (result instanceof EngineObject3D)
30542
30909
  {
30543
30910
  level3DBaseScale.set(result, result.scale3D.copy());
30544
- result.rotation3D = level3DVector(object.rotation, vec3()).scale(PI / 180);
30545
- result.scale3D = result.scale3D.multiply(level3DVector(object.scale, vec3(1)));
30911
+ result.rotation3D = rotation.copy();
30912
+ result.scale3D = result.scale3D.multiply(scale);
30546
30913
  }
30547
30914
 
30548
30915
  // the type's own properties are set on it, one the type has no default for could be a field of the engine's
30549
30916
  for (const key in type.defaults)
30550
30917
  result[key] = properties[key];
30918
+ // a prefab's instance makes its parts now that it is where it goes
30919
+ result instanceof Prefab3D && result.placeAt(pos);
30551
30920
  return result;
30552
30921
  }
30553
30922
 
30923
+ /** Make one object of a type added with level3DAddType, level3DAddMesh or level3DAddPrefab, from code, with no
30924
+ * level: a prefab's instance, or a plain type
30925
+ * @param {string} type - The type's name
30926
+ * @param {Vector3} [pos3D]
30927
+ * @param {Vector3} [rotation3D] - In radians, as an object has it
30928
+ * @param {Vector3} [scale3D] - Times the scale the type makes it with
30929
+ * @param {Object} [properties] - Over the type's defaults
30930
+ * @return {any} - What the type made, a Prefab3D for a prefab, undefined when there is no such type
30931
+ * @memberof Level3D
30932
+ * @example
30933
+ * level3DAddPrefab('House', await fetchJSON('house.json'));
30934
+ * level3DSpawn('House', vec3(10, 0, 0), vec3(0, PI/2, 0)); */
30935
+ function level3DSpawn(type, pos3D=vec3(), rotation3D=vec3(), scale3D=vec3(1), properties)
30936
+ {
30937
+ ASSERT(isVector3(pos3D) && isVector3(rotation3D) && isVector3(scale3D), 'pos3D, rotation3D and scale3D are vec3');
30938
+ return level3DMakeAt(properties ? {type, properties} : {type}, pos3D.copy(), rotation3D, scale3D);
30939
+ }
30940
+
30941
+ ///////////////////////////////////////////////////////////////////////////////
30942
+ // prefabs: a prefab is a small level, a list of objects about its own origin, placed many times
30943
+
30944
+ // the prefabs by name: how an instance is built, its objects, fromLevel, true for one a level's own prefabs
30945
+ // block added, which one the game adds takes the place of, and source, the object it was added from, which the
30946
+ // level editor knows a level that is a prefab by
30947
+ /** @type {Map<string, {attached: boolean, objects: Array<Object>, fromLevel: boolean, source: Object}>} */
30948
+ const level3DPrefabs = new Map;
30949
+
30950
+ // how many prefabs deep an instance being made is, a prefab that holds itself is stopped; and how many of those
30951
+ // are attached: a prefab inside an attached one is attached too, or its parts would be left behind in the world
30952
+ let level3DPrefabDepth = 0, level3DPrefabAttached = 0;
30953
+
30954
+ /** Add a prefab: a small level, its objects placed about its own origin, to place many times under one name
30955
+ * - It is a type from then on: a level's object of that type, the level editor's Place list and level3DSpawn make
30956
+ * an instance, a Prefab3D; an instance is made of the prefab as it is then, so the level editor, where a prefab
30957
+ * changes, makes its instances again
30958
+ * - The prefab is a level as the level editor saves it, {objects: [...]}, so the editor is the prefab editor too;
30959
+ * only its objects are used, and they may be of other prefabs
30960
+ * - With attached true in it the parts are children of the instance and move with it as one body, without
30961
+ * collision of their own, a prefab inside it too; its handle is at the middle of the box around them and its
30962
+ * 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
30963
+ * by hand does
30964
+ * - Adding a name again replaces it
30965
+ * @param {string} name - The type its instances have in a level
30966
+ * @param {Object} prefab - {objects, attached}
30967
+ * @memberof Level3D
30968
+ * @example
30969
+ * level3DAddPrefab('Tower', {objects: [{type: 'Box', pos: [0, 1, 0], scale: [2, 2, 2]},
30970
+ * {type: 'Cylinder', pos: [0, 3, 0]}]});
30971
+ * level3DLoad({objects: [{type: 'Tower', pos: [5, 0, 5]}, {type: 'Tower', pos: [-5, 0, 5], rotation: [0, 45, 0]}]}); */
30972
+ function level3DAddPrefab(name, prefab) { level3DPrefabSet(name, prefab, false); }
30973
+
30974
+ // add a prefab, the game's or a level's own
30975
+ function level3DPrefabSet(name, prefab, fromLevel)
30976
+ {
30977
+ ASSERT(!!prefab && typeof prefab === 'object', 'a prefab is an object, {objects: [...]}');
30978
+ name = String(name);
30979
+ const objects = isArray(prefab?.objects) ? prefab.objects.filter((o)=> o && typeof o === 'object') : [];
30980
+ // a copy, what the game or the editor does to its own afterwards does not change the instances made later
30981
+ level3DPrefabs.set(name, {attached: !!prefab?.attached, objects: JSON.parse(JSON.stringify(objects)), fromLevel,
30982
+ source: prefab});
30983
+ // a class of its own, so the level editor makes its instances again as it does a class's
30984
+ level3DTypes.set(name, {make: class extends Prefab3D { constructor(pos) { super(pos, name); } }, defaults: {},
30985
+ tileInfo: undefined});
30986
+ }
30987
+
30988
+ // add a level's own prefabs, each unless the game added one of that name or it is a plain type's name
30989
+ function level3DPrefabsAdd(prefabs)
30990
+ {
30991
+ if (!prefabs || typeof prefabs !== 'object' || isArray(prefabs)) return;
30992
+ for (const name in prefabs)
30993
+ {
30994
+ const known = level3DPrefabs.get(name);
30995
+ if (known ? !known.fromLevel : level3DTypes.has(name)) continue;
30996
+ prefabs[name] && typeof prefabs[name] === 'object' && level3DPrefabSet(name, prefabs[name], true);
30997
+ }
30998
+ }
30999
+
31000
+ /** Load a prefab from a file the level editor saved and add it
31001
+ * @param {string} name - The type its instances have in a level
31002
+ * @param {string} url
31003
+ * @return {Promise<void>}
31004
+ * @memberof Level3D */
31005
+ async function level3DLoadPrefab(name, url) { level3DAddPrefab(name, await fetchJSON(url)); }
31006
+
31007
+ // where an instance puts one of its prefab's objects: the object's own place, turn and size inside the instance's
31008
+ function level3DPrefabPartTransform(instance, object)
31009
+ {
31010
+ const pos = level3DVector(object.pos, vec3()), scale = level3DVector(object.scale, vec3(1));
31011
+ const rotation = level3DVector(object.rotation, vec3()).scale(PI / 180);
31012
+ // the two turns one after the other; the sizes multiply along each axis, which is exact for an instance sized
31013
+ // evenly or a part that is not turned, and as near as a box can be otherwise
31014
+ const turn = buildMatrix(vec3(), instance.rotation3D).multiply(buildMatrix(vec3(), rotation));
31015
+ return {pos: buildMatrix(instance.pos3D, instance.rotation3D, instance.scale3D).transformPoint(pos),
31016
+ rotation: turn.getRotation(), scale: scale.multiply(instance.scale3D)};
31017
+ }
31018
+
31019
+ /**
31020
+ * An instance of a prefab, what a prefab's type makes: a handle with no shape of its own, and its parts, what the
31021
+ * prefab's objects made
31022
+ * - Parts of a prefab that is not attached are objects of their own in the world: moving, turning or sizing the
31023
+ * handle puts them where it now says with its next update, or call placeParts; destroying it destroys them
31024
+ * - Parts of an attached prefab are its children and move with it as one body
31025
+ * @extends EngineObject3D
31026
+ * @memberof Level3D
31027
+ */
31028
+ class Prefab3D extends EngineObject3D
31029
+ {
31030
+ /** Create an instance of a prefab, made by its type: place one with a level or level3DSpawn
31031
+ * @param {Vector3} [pos3D]
31032
+ * @param {string} [prefabName] - A prefab added with level3DAddPrefab */
31033
+ constructor(pos3D, prefabName='')
31034
+ {
31035
+ super(pos3D);
31036
+ /** @property {string} - The prefab it is an instance of */
31037
+ this.prefabName = prefabName;
31038
+ /** @property {boolean} - Are its parts its children, moving with it as one body */
31039
+ this.attached = !!level3DPrefabs.get(prefabName)?.attached || level3DPrefabAttached > 0;
31040
+ /** @property {Array<any>} - What the prefab's objects made, in the prefab's order */
31041
+ this.parts = [];
31042
+ /** @property {Vector3} - From the prefab's origin to the handle, in the prefab's own space: nothing for
31043
+ * separate parts, and for an attached prefab the middle of the box around its parts, where its handle
31044
+ * is, as an object's place is the middle of its body */
31045
+ this.originOffset = vec3();
31046
+ this.partObjects = []; // the prefab's object of each part
31047
+ this.partsPlaced = ''; // where the handle was when its parts were last placed, undefined parts not made
31048
+ this.partsMade = false;
31049
+ }
31050
+
31051
+ /** Put the instance with its prefab's origin at a place, turned and sized as the handle is: where a level's
31052
+ * object or level3DSpawn says it goes; the handle of an attached prefab is then at the middle of its body
31053
+ * @param {Vector3} pos3D */
31054
+ placeAt(pos3D)
31055
+ {
31056
+ this.pos3D = pos3D.copy();
31057
+ this.placeParts();
31058
+ const o = this.originOffset;
31059
+ if (o.x || o.y || o.z)
31060
+ this.pos3D = buildMatrix(pos3D, this.rotation3D, this.scale3D).transformPoint(o);
31061
+ }
31062
+
31063
+ /** Put the parts where the handle is now, making them the first time; called by the handle's update when it
31064
+ * has moved, turned or changed size */
31065
+ placeParts()
31066
+ {
31067
+ if (this.destroyed) return;
31068
+ this.partsMade ||= (this.makeParts(), true);
31069
+ this.partsPlaced = this.placeKey();
31070
+ if (this.attached) return; // children follow by themselves
31071
+ this.parts.forEach((part, i)=>
31072
+ {
31073
+ if (!(part instanceof EngineObject3D)) return; // what a function made is where it was made
31074
+ const to = level3DPrefabPartTransform(this, this.partObjects[i]);
31075
+ part.rotation3D = to.rotation;
31076
+ part.scale3D = (level3DBaseScale.get(part) ?? vec3(1)).multiply(to.scale);
31077
+ // a prefab inside this one goes by its origin, which is not where an attached one's handle is
31078
+ part instanceof Prefab3D ? part.placeAt(to.pos) : part.pos3D = to.pos;
31079
+ });
31080
+ }
31081
+
31082
+ // the handle's place, turn and size as one text, to tell when it has changed
31083
+ placeKey()
31084
+ {
31085
+ const p = this.pos3D, r = this.rotation3D, s = this.scale3D;
31086
+ return [p.x, p.y, p.z, r.x, r.y, r.z, s.x, s.y, s.z].join();
31087
+ }
31088
+
31089
+ // make the parts, each where the instance puts it, or attached at its own place inside the instance
31090
+ makeParts()
31091
+ {
31092
+ const prefab = level3DPrefabs.get(this.prefabName);
31093
+ if (!prefab) return;
31094
+ if (level3DPrefabDepth >= 8)
31095
+ {
31096
+ debug && console.error(`level3DLoad: the prefab ${this.prefabName} holds itself, left out there`);
31097
+ return;
31098
+ }
31099
+ ++level3DPrefabDepth;
31100
+ this.attached && ++level3DPrefabAttached;
31101
+ try
31102
+ {
31103
+ const low = vec3(Infinity), high = vec3(-Infinity), children = [];
31104
+ for (const object of prefab.objects)
31105
+ {
31106
+ const local = {pos: level3DVector(object.pos, vec3()), scale: level3DVector(object.scale, vec3(1)),
31107
+ rotation: level3DVector(object.rotation, vec3()).scale(PI / 180)};
31108
+ const at = this.attached ? local : level3DPrefabPartTransform(this, object);
31109
+ const part = level3DMakeAt(object, at.pos, at.rotation, at.scale);
31110
+ if (!part) continue; // level3DMakeAt gives an object or nothing
31111
+ this.parts.push(part);
31112
+ this.partObjects.push(object);
31113
+ if (!this.attached || !(part instanceof EngineObject3D)) continue;
31114
+ // a child rides with its parent and has no collision of its own
31115
+ part.setCollision(false, false, false);
31116
+ children.push(part);
31117
+ // the corners of its box as it is turned; its place is its middle, a prefab inside this one too
31118
+ const box = buildMatrix(part.pos3D, part.rotation3D, part.size3D.multiply(part.scale3D));
31119
+ for (let i = 8; i--;)
31120
+ {
31121
+ const p = box.transformPoint(vec3(i & 1 ? .5 : -.5, i & 2 ? .5 : -.5, i & 4 ? .5 : -.5));
31122
+ for (const k of ['x', 'y', 'z'])
31123
+ low[k] = min(low[k], p[k]), high[k] = max(high[k], p[k]);
31124
+ }
31125
+ }
31126
+ // an attached instance is the box around its parts, its handle at the middle of it as an object's
31127
+ // place is the middle of its body, so a game that makes it solid has it solid where it is seen
31128
+ if (children.length)
31129
+ {
31130
+ this.size3D = high.subtract(low);
31131
+ this.originOffset = low.add(high).scale(.5);
31132
+ }
31133
+ for (const part of children)
31134
+ {
31135
+ part.pos3D = part.pos3D.subtract(this.originOffset);
31136
+ this.addChild(part);
31137
+ }
31138
+ }
31139
+ finally
31140
+ {
31141
+ --level3DPrefabDepth;
31142
+ this.attached && --level3DPrefabAttached;
31143
+ }
31144
+ }
31145
+
31146
+ /** Keep the parts with the handle, called automatically each frame */
31147
+ update()
31148
+ {
31149
+ super.update();
31150
+ if (!this.attached && this.partsPlaced !== this.placeKey())
31151
+ this.placeParts();
31152
+ }
31153
+
31154
+ /** Destroy the instance and its parts
31155
+ * @param {boolean} [immediate] */
31156
+ destroy(immediate)
31157
+ {
31158
+ if (this.destroyed) return;
31159
+ for (const part of this.parts)
31160
+ part.parent === this || part.destroy?.(immediate); // children go with their parent
31161
+ super.destroy(immediate);
31162
+ }
31163
+
31164
+ /** A prefab's instance has nothing of its own to draw */
31165
+ render3D() {}
31166
+ }
31167
+
30554
31168
  ///////////////////////////////////////////////////////////////////////////////
30555
31169
  // the built-in types
30556
31170
 
@@ -30764,7 +31378,9 @@ function particleEffectSanitize(raw)
30764
31378
  settings[setting.name] = value === '' || particleEffectShapes.includes(value) ? value : fallback;
30765
31379
  else if (isNumber(value))
30766
31380
  {
30767
- const clamped = clamp(value, setting.hardMin, setting.hardMax);
31381
+ // an angle past half a turn comes around, a clamp would point it the other way
31382
+ const wraps = setting.name === 'angle' && isFinite(value) && abs(value) > PI;
31383
+ const clamped = clamp(wraps ? mod(value + PI, 2*PI) - PI : value, setting.hardMin, setting.hardMax);
30768
31384
  settings[setting.name] = setting.step >= 1 ? round(clamped) : clamped;
30769
31385
  }
30770
31386
  else
@@ -30843,9 +31459,11 @@ function particleEffectsGet(name) { return particleEffectsByName.get(String(name
30843
31459
  * @param {string} text
30844
31460
  * @return {Array<Object>}
30845
31461
  * @memberof ParticleEffects */
30846
- function particleEffectsParse(text)
31462
+ function particleEffectsParse(text) { return particleEffectsFromData(JSON.parse(text)); }
31463
+
31464
+ // a library file's data, or one effect's, into sanitized effects
31465
+ function particleEffectsFromData(data)
30847
31466
  {
30848
- const data = JSON.parse(text);
30849
31467
  const list = isArray(data?.effects) ? data.effects : data?.settings ? [data] : undefined;
30850
31468
  if (!list || !list.length)
30851
31469
  throw new Error('no effects in this file');
@@ -30864,7 +31482,7 @@ function particleEffectsText(effects) { return JSON.stringify({version:1, effect
30864
31482
  * @memberof ParticleEffects */
30865
31483
  async function particleEffectsLoad(url)
30866
31484
  {
30867
- const effects = particleEffectsParse(JSON.stringify(await fetchJSON(url)));
31485
+ const effects = particleEffectsFromData(await fetchJSON(url));
30868
31486
  particleEffectsAdd(effects);
30869
31487
  return effects;
30870
31488
  }
@@ -31042,7 +31660,13 @@ function particleEffectTileInfo(s)
31042
31660
  * @memberof ParticleEffects */
31043
31661
  function particleEffectApply(emitter, effect)
31044
31662
  {
31045
- effect = particleEffectSanitize(effect); // an effect written by hand or changed since it was added
31663
+ // an effect written by hand or changed since it was added
31664
+ particleEffectSet(emitter, particleEffectSanitize(effect));
31665
+ }
31666
+
31667
+ // set a 2D emitter to an effect that is sanitized already, as a play's is
31668
+ function particleEffectSet(emitter, effect)
31669
+ {
31046
31670
  const s = effect.settings, ended = emitter.emitTime < 0; // destroyed, its last particles going
31047
31671
  for (const setting of particleEffectSettings)
31048
31672
  if (!particleEffectIndirect.includes(setting.name))
@@ -31078,15 +31702,14 @@ function particleEffectResolve(nameOrEffect, options)
31078
31702
  * - Attach it to an object with addChild to follow it
31079
31703
  * @param {string|Object} nameOrEffect - A built-in or added effect's name, or an effect
31080
31704
  * @param {Vector2} [pos]
31081
- * @param {Object} [options]
31082
- * @param {number} [options.scale] - Grows the whole effect, the built-ins fit a one unit object at 1
31083
- * @param {number} [options.hue] - Turns its colors around the color wheel, 1 is all the way
31084
- * @param {number} [options.saturation] - Multiplies its saturation, 0 is grey
31085
- * @param {number} [options.angle] - Direction, 0 is up; the effect's own angle when not given
31086
- * @param {TileInfo|TextureInfo} [options.tileInfo] - The game's own art to draw with in place of the effect's shape,
31087
- * tinted by its colors; a whole texture draws as one tile
31088
- * @param {*} [options.settings] - Any effect setting by its name, emitTime, emitRate, speed and the rest, replacing
31089
- * the effect's own for this play
31705
+ * @param {Object} [options] - What to change for this play, each left out when not wanted:
31706
+ * scale grows the whole effect, the built-ins fit a one unit object at 1;
31707
+ * hue turns its colors around the color wheel, 1 is all the way;
31708
+ * saturation multiplies its saturation, 0 is grey;
31709
+ * angle is its direction, 0 is up, the effect's own angle when not given;
31710
+ * tileInfo, a TileInfo or a TextureInfo, is the game's own art to draw with in place of the effect's shape,
31711
+ * tinted by its colors, a whole texture drawn as one tile;
31712
+ * and any effect setting by its name, emitTime, emitRate, speed and the rest, replaces the effect's own
31090
31713
  * @return {ParticleEmitter|undefined} - undefined when there is no such effect
31091
31714
  * @memberof ParticleEffects */
31092
31715
  function particleEffect(nameOrEffect, pos=vec2(), options={})
@@ -31094,7 +31717,7 @@ function particleEffect(nameOrEffect, pos=vec2(), options={})
31094
31717
  const effect = particleEffectResolve(nameOrEffect, options);
31095
31718
  if (!effect) return;
31096
31719
  const emitter = new ParticleEmitter(pos.copy());
31097
- particleEffectApply(emitter, effect);
31720
+ particleEffectSet(emitter, effect);
31098
31721
  emitter.scale = options.scale ?? 1;
31099
31722
  if (options.angle !== undefined)
31100
31723
  emitter.angle = options.angle;
@@ -31158,7 +31781,7 @@ function particleEffectFromEmitter(emitter, name='Effect')
31158
31781
  p.velocity.y += (p.pos.x - c.x) * s * .002;
31159
31782
  };
31160
31783
  b('wind').update3D = (p, s)=> p.velocity.x += s * .004 * p.scale * min(p.age / p.lifeTime, 1);
31161
- // stick has no 3D push: particleEffect3D makes it the friction a particle lands with
31784
+ // stick has no 3D push: particleEffectApply3D makes it the emitter's stick, its grip on landing
31162
31785
  }
31163
31786
 
31164
31787
  /** Set a 3D emitter to an effect, live, so a running one keeps its particles; its place, scale and flatten stay
@@ -31167,7 +31790,13 @@ function particleEffectFromEmitter(emitter, name='Effect')
31167
31790
  * @memberof ParticleEffects */
31168
31791
  function particleEffectApply3D(emitter, effect)
31169
31792
  {
31170
- effect = particleEffectSanitize(effect); // an effect written by hand or changed since it was added
31793
+ // an effect written by hand or changed since it was added
31794
+ particleEffectSet3D(emitter, particleEffectSanitize(effect));
31795
+ }
31796
+
31797
+ // set a 3D emitter to an effect that is sanitized already, as a play's is
31798
+ function particleEffectSet3D(emitter, effect)
31799
+ {
31171
31800
  const s = effect.settings, e = emitter;
31172
31801
  // a circle is a sphere and a rectangle a box as deep as it is wide
31173
31802
  e.emitSize = s.emitRect ? vec3(s.emitSize, s.emitHeight, s.emitSize) : s.emitSize;
@@ -31180,8 +31809,8 @@ function particleEffectApply3D(emitter, effect)
31180
31809
  'randomness', 'additive', 'gravityScale', 'angleSpeed', 'angleDamping', 'collideLevel', 'restitution'])
31181
31810
  e[name] = s[name];
31182
31811
  e.trailTime = s.trailScale / 60; // a stretch of speed times trailScale is a streak of that many frames
31183
- const stick = effect.behaviors.find(b=> b.name == 'stick'); // in 3D, a grip on landing is less sliding
31184
- e.friction = stick ? min(s.friction, 1 - stick.strength) : s.friction;
31812
+ e.friction = s.friction;
31813
+ e.stick = effect.behaviors.find(b=> b.name == 'stick')?.strength ?? 0; // in 3D the emitter grips on landing
31185
31814
  // 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
31186
31815
  e.rotation3D = vec3(0, 0, -s.angle);
31187
31816
  e.particleUpdateCallback = particleEffectUpdateCallback(effect.behaviors, true);
@@ -31191,8 +31820,8 @@ function particleEffectApply3D(emitter, effect)
31191
31820
  * - The same effect data as particleEffect, so the look carries across: a circle spawn area becomes a sphere and a
31192
31821
  * rectangle a box as deep as it is wide, both flat across the way it emits with options.flatten, a disc or a
31193
31822
  * sheet; a trail becomes a streak of the same length, and the settings the 3D emitter lacks (particleConeAngle,
31194
- * randomColorLinear, velocityInheritance, localSpace) are left out; the stick behavior becomes the friction a
31195
- * particle lands with
31823
+ * randomColorLinear, velocityInheritance, localSpace) are left out; the stick behavior becomes the emitter's
31824
+ * stick, its grip where a particle lands
31196
31825
  * @param {string|Object} nameOrEffect - A built-in or added effect's name, or an effect
31197
31826
  * @param {Vector3} [pos3D]
31198
31827
  * @param {Object} [options] - scale, hue, saturation, angle, tileInfo and any setting as particleEffect; angle turns it
@@ -31204,7 +31833,7 @@ function particleEffect3D(nameOrEffect, pos3D=vec3(), options={})
31204
31833
  const effect = particleEffectResolve(nameOrEffect, options);
31205
31834
  if (!effect) return;
31206
31835
  const e = new ParticleEmitter3D(pos3D.copy());
31207
- particleEffectApply3D(e, effect);
31836
+ particleEffectSet3D(e, effect);
31208
31837
  if (options.angle !== undefined)
31209
31838
  e.rotation3D = vec3(0, 0, -options.angle);
31210
31839
  e.scale3D = vec3(options.scale ?? 1);
@@ -32853,32 +33482,101 @@ function debugPoint3D(pos, color=WHITE, time=0, size=.2)
32853
33482
  * @memberof Editor
32854
33483
  */
32855
33484
 
33485
+ /**
33486
+ * @typedef {Object} EditorToolAt - What the level editor gives the callbacks of a tool of the game's own
33487
+ * @property {any} pos - Where the mouse is in the level: a Vector2 in the 2D editor; in the 3D one a Vector3 on
33488
+ * the level or the ground, undefined when the mouse is over the panel
33489
+ * @property {Vector2|undefined} cell - 2D: the tile cell under the mouse on the selected tile layer
33490
+ * @property {any} ray - 3D: the mouse's Ray3D
33491
+ * @property {boolean} shift - Is Shift held
33492
+ * @property {boolean} ctrl - Is Ctrl held
33493
+ * @memberof Editor
33494
+ */
33495
+
33496
+ /**
33497
+ * @typedef {Object} EditorTool - A tool of the game's own for the level editor, see LevelEditor.addTool
33498
+ * @property {string} [key] - The key that picks it, as addKey spells one
33499
+ * @property {string} [hint] - The hint line while it is on
33500
+ * @property {function(EditorToolAt): any} [onPress] - The left button went down in the level; returning false
33501
+ * says the press was not the tool's
33502
+ * @property {function(EditorToolAt): any} [onDrag] - Each frame it is held
33503
+ * @property {function(EditorToolAt): any} [onRelease] - It was let go
33504
+ * @property {function(EditorToolAt): any} [onDraw] - Each frame the tool is on, to draw its cursor or preview
33505
+ * @memberof Editor
33506
+ */
33507
+
33508
+ /**
33509
+ * @typedef {Object} EditorEdit2D - The 2D level editor's edit functions, levelEditor.edit2D
33510
+ * @property {Object|undefined} map - The Tiled map of the selected layer
33511
+ * @property {TileLayer|undefined} layer - The selected tile layer, undefined on an object layer
33512
+ * @property {Array<Object>} objects - A copy of the selected object layer's objects, as Tiled has them
33513
+ * @property {Vector2|undefined} hover - The cell under the mouse on the selected tile layer
33514
+ * @property {Set<number>} selection - The selected objects' ids
33515
+ * @property {function(Vector2, number, ...any): boolean} paint - Set a cell of the selected tile layer
33516
+ * to a tile index, -1 erases, with a direction 0 to 3 and a mirror; false off the layer or with none
33517
+ * @property {function(function(Array<Object>): void): boolean} changeObjects - Edit a copy of the selected object
33518
+ * layer's objects; false when nothing changed
33519
+ * @property {function(): void} strokeEnd - End the edit: one undo step, and the autosave
33520
+ * @property {function(): void} strokeCancel - Take the edit being made back
33521
+ * @property {function(function(): void): void} bulk - Many paints with one redraw a layer
33522
+ * @property {function(...any): any} undo - Undo, or redo with true
33523
+ * @property {function(): string} toJSON - The map as the text Save writes
33524
+ * @memberof Editor
33525
+ */
33526
+
33527
+ /**
33528
+ * @typedef {Object} EditorEdit3D - The 3D level editor's edit functions, levelEditor.edit3D; a position, a
33529
+ * rotation and a scale are Vector3, the rotation in degrees
33530
+ * @property {Object|undefined} level - The level object being edited
33531
+ * @property {Array<Object>} objects - Its objects, the list in the level: read it, edit through change
33532
+ * @property {Set<number>} selection - The selected objects' ids
33533
+ * @property {function(): Array<Object>} selected - The selected objects
33534
+ * @property {function(number): any} made - What the game made for an object's id
33535
+ * @property {function(function(Array<Object>): void): boolean} change - Edit a copy of the object list; false
33536
+ * when nothing changed
33537
+ * @property {function(string, function(any): any): boolean} changePart - Edit the level's scene, voxels, terrain
33538
+ * or prefabs block
33539
+ * @property {function(): void} strokeEnd - End the edit: one undo step, and the autosave
33540
+ * @property {function(): void} strokeCancel - Take the edit being made back
33541
+ * @property {function(string, any): number|undefined} place - Add an object of a type at a position, selected;
33542
+ * its id, undefined when there is no such type or the level can not be edited
33543
+ * @property {function(Object, ...any): void} setTransform - Write a place, a rotation and a scale on an
33544
+ * object of the list given to change; one left out is kept
33545
+ * @property {function(Object, string, any): void} setProperty - Write a property on an object of the list given
33546
+ * to change, left out of the file when it is the type's default
33547
+ * @property {function(Object): any} pos - An object's position
33548
+ * @property {function(Object): any} rotation - Its rotation, in degrees
33549
+ * @property {function(Object): any} scale - Its scale
33550
+ * @property {function(): any} mousePoint - Where the mouse is on the level or the ground, undefined over the panel
33551
+ * @property {function(...any): boolean} undo - Undo, or redo with true
33552
+ * @property {function(): string} toJSON - The level as the text Save writes
33553
+ * @memberof Editor
33554
+ */
33555
+
32856
33556
  /**
32857
33557
  * The level editor, open it to pause the game and edit its level, close it to play on with the changes
32858
33558
  * - One of it, levelEditor, 0 on the debug overlay opens and closes it too
32859
- * - In release builds levelEditor is a stub that never opens, and its hooks are never called
33559
+ * - A game makes it its own: it sets the hooks, adds keys, panel buttons and tools, or extends this class and
33560
+ * gives its own with setLevelEditor; the same calls work in the 2D editor and the 3D one, see EDITOR.md
33561
+ * - In release builds LevelEditor is a stub that never opens: what a game adds is taken and never called
32860
33562
  * @memberof Editor
32861
33563
  * @example
32862
33564
  * levelEditor.onRestart = ()=> loadLevel(); // adds a Restart button that rebuilds the level
32863
33565
  * levelEditor.onTile = (layer, pos, tile)=> layer.setCollisionData(pos, tile === ladderTile ? -1 : tile ? 1 : 0);
33566
+ * levelEditor.addKey('k', ()=> clearEnemies(), 'K: clear the enemies');
33567
+ *
33568
+ * // or as a class of the game's own
33569
+ * class MyEditor extends LevelEditor
33570
+ * {
33571
+ * onRestart() { loadLevel(); }
33572
+ * onDraw() { drawSpawnZones(); }
33573
+ * }
33574
+ * setLevelEditor(new MyEditor);
32864
33575
  */
32865
33576
  class LevelEditor
32866
33577
  {
32867
33578
  constructor()
32868
33579
  {
32869
- /** @property {EditorTileCallback|undefined} - What the game does when the editor paints a tile, like
32870
- * setting its collision or its look the way the game does when it loads the level; without one, the
32871
- * collision layer gets collision 1 where there is a tile
32872
- * @type {EditorTileCallback|undefined} */
32873
- this.onTile = undefined;
32874
- /** @property {Function|undefined} - Rebuild the level from the map the editor changed, a Restart button
32875
- * calls it after switching to play; without one there is no Restart button
32876
- * @type {EditorRestartCallback|undefined} */
32877
- this.onRestart = undefined;
32878
- /** @property {Function|undefined} - Put the player at a world position; with it, the editor's Advanced
32879
- * section has Play from mouse, which starts play there, Escape at the mouse and Play at the view center
32880
- * @type {EditorPlayFromCallback|undefined} */
32881
- this.onPlayFrom = undefined;
32882
33580
  /** @property {Array<number>|undefined} - The tiles the palette shows, in its order, for a sheet that also holds
32883
33581
  * sprites and art that are not level tiles; undefined shows every tile of the sheet
32884
33582
  * @type {Array<number>|undefined} */
@@ -32887,15 +33585,133 @@ class LevelEditor
32887
33585
  * 3D one when a level was loaded with level3DLoad and there is a Render3DPlugin
32888
33586
  * @type {boolean|undefined} */
32889
33587
  this.use3D = undefined;
32890
- }
33588
+ /** @property {string|undefined} - The tool of the game's own that is on, one added with addTool, undefined
33589
+ * while a tool of the editor's is
33590
+ * @type {string|undefined} */
33591
+ this.tool = undefined;
33592
+ // what the game added: its keys by how they are spelled, its panel buttons and its tools by name
33593
+ /** @type {Object<string, {action: function(boolean): any, help: string|undefined, warned: boolean}>} */
33594
+ this.keys = {};
33595
+ /** @type {Array<{label: string, onClick: Function, title: string}>} */
33596
+ this.buttons = [];
33597
+ /** @type {Object<string, EditorTool>} */
33598
+ this.tools = {};
33599
+ }
33600
+
33601
+ /** What the game does when the 2D editor paints a tile, like setting its collision or its look the way the
33602
+ * game does when it loads the level; set it or override it, without one the collision layer gets collision 1
33603
+ * where there is a tile
33604
+ * @param {TileLayer} layer - The layer painted
33605
+ * @param {Vector2} pos - The cell's position in the layer
33606
+ * @param {number|undefined} tile - The tile painted, undefined when erased */
33607
+ onTile(layer, pos, tile) {}
33608
+
33609
+ /** Rebuild the level from what the editor changed; set it or override it and the editor has a Restart button,
33610
+ * which calls it after switching to play */
33611
+ onRestart() {}
33612
+
33613
+ /** Put the player at a position, a Vector2 in the 2D editor and a Vector3 in the 3D one; set it or override it
33614
+ * and the editor has Play from mouse, which starts play there
33615
+ * @param {any} pos */
33616
+ onPlayFrom(pos) {}
33617
+
33618
+ /** Called when the editor opens, to set or override */
33619
+ onOpen() {}
33620
+
33621
+ /** Called when the editor closes and the game plays on, to set or override */
33622
+ onClose() {}
33623
+
33624
+ /** Called each frame while the editor is open, to set or override */
33625
+ onUpdate() {}
33626
+
33627
+ /** Called while the editor draws the level, to draw overlays of the game's own in it, to set or override */
33628
+ onDraw() {}
33629
+
33630
+ /** Called once when the editor's panel is made, with a box in it for the game's own controls, to set or override
33631
+ * @param {HTMLElement} element */
33632
+ onPanel(element) {}
33633
+
33634
+ /** Called by Save with the text of the file and its name; return true when the game kept it itself, and the
33635
+ * editor writes no file, to set or override
33636
+ * @param {string} text
33637
+ * @param {string} fileName
33638
+ * @return {boolean|void} */
33639
+ onSave(text, fileName) { return false; }
32891
33640
 
32892
33641
  /** True while the editor is open, the game is paused under it
32893
33642
  * @return {boolean} */
32894
- get isOpen() { return editorIsOpen; }
33643
+ get isOpen() { return editorIsOpen || !!editorOther?.isOpen(); }
33644
+
33645
+ /** True when the editor in use is the 3D one, as use3D says or since a 3D level was loaded
33646
+ * @return {boolean} */
33647
+ get is3D() { return !!editorOther?.wanted(); }
33648
+
33649
+ /** The 2D editor's edit functions, for a key, a button or a tool of the game's own: map, the Tiled map; layer,
33650
+ * the selected TileLayer; objects, a copy of the selected object layer's; hover, the cell under the mouse;
33651
+ * selection, the selected objects' ids; paint(cell, tile, direction, mirror), a tile index, -1 erases;
33652
+ * changeObjects((list)=> ...); strokeEnd() and strokeCancel(); bulk(()=> ...); undo(redo); toJSON()
33653
+ * @return {EditorEdit2D} */
33654
+ get edit2D() { return editorEdit2D; }
33655
+
33656
+ /** The 3D editor's edit functions, undefined without the 3D plugins: level; objects; selection, a Set of ids;
33657
+ * selected(); made(id); change((list)=> ...); changePart(name, (part)=> ...); strokeEnd() and strokeCancel();
33658
+ * place(type, pos3D); setTransform(object, pos3D, rotationDegrees, scale3D); setProperty(object, name, value);
33659
+ * pos(object), rotation(object) and scale(object); mousePoint(); undo(redo); toJSON()
33660
+ * @return {EditorEdit3D|undefined} */
33661
+ get edit3D() { return editorOther?.edit; }
33662
+
33663
+ /** Add a key of the game's own to the editor, the 2D and the 3D one
33664
+ * - Escape and 0 are the editor's own, to play and to exit, and Ctrl is the one modifier: Shift is given to
33665
+ * the action
33666
+ * @param {string} key - A letter or digit, 'k', or a key's name in any case, 'Delete', 'F2', 'Enter', 'Space';
33667
+ * 'ctrl+k' with Ctrl or Cmd
33668
+ * @param {function(boolean): any} action - Called with whether Shift is held; returning false says it did
33669
+ * nothing, and the key is left to the browser
33670
+ * @param {string} [helpLine] - A line for the editor's help */
33671
+ addKey(key, action, helpLine)
33672
+ {
33673
+ ASSERT(typeof action == 'function', 'a key needs an action');
33674
+ const spelled = editorKeySpelling(key), name = spelled.replace('ctrl+', '');
33675
+ if (name === 'Escape' || name === '0' || name.length > 1 && name.includes('+'))
33676
+ {
33677
+ console.warn(`levelEditor.addKey: ${key} is not taken, ` + (name.includes('+') ?
33678
+ 'Ctrl is the one modifier, Shift is given to the action' : 'Escape and 0 are the editor\'s own'));
33679
+ return;
33680
+ }
33681
+ this.keys[spelled] = {action, help: helpLine, warned: false};
33682
+ this === levelEditor && editorKeyClashes();
33683
+ }
33684
+
33685
+ /** Add a button of the game's own to the editor's panel
33686
+ * @param {string} label
33687
+ * @param {Function} onClick
33688
+ * @param {string} [title] - What it says when the mouse is over it */
33689
+ addButton(label, onClick, title='') { this.buttons.push({label, onClick, title}); }
33690
+
33691
+ /** Add a tool of the game's own: a button in the panel by its name, and while it is on the left button in the
33692
+ * level is its, not the editor's own tools'
33693
+ * - Each callback is given {pos, cell, ray, shift, ctrl}: pos is where the mouse is in the level, a Vector2 in
33694
+ * 2D and a Vector3 on the level or the ground in 3D; cell is the tile under it in 2D; ray is the mouse's
33695
+ * in 3D
33696
+ * - What the callbacks change through edit2D or edit3D is one undo: the editor ends the stroke at the release,
33697
+ * and takes it back when the right button or Escape ends the press
33698
+ * @param {string} name
33699
+ * @param {EditorTool} tool - {key, hint, onPress, onDrag, onRelease, onDraw}, each optional; onPress returning
33700
+ * false says the press was not the tool's */
33701
+ addTool(name, tool)
33702
+ {
33703
+ ASSERT(!!tool && typeof tool == 'object', 'a tool is an object of callbacks');
33704
+ const before = this.tools[name]; // added again, it takes the place of the first, its key too
33705
+ before?.key && delete this.keys[editorKeySpelling(before.key)];
33706
+ this.tools[name] = tool;
33707
+ tool.key && this.addKey(tool.key, ()=> { this.tool = this.tool === name ? undefined : name; });
33708
+ }
32895
33709
 
32896
33710
  /** Open the editor, pausing the game; until close(), Escape (the debug key) switches between playing and editing */
32897
33711
  open()
32898
33712
  {
33713
+ if (editorOther?.wanted())
33714
+ return editorOther.open();
32899
33715
  if (!editorSession)
32900
33716
  editorCameraScale = cameraScale; // a new session starts at the game's zoom, a return keeps the editor's
32901
33717
  editorSession = true;
@@ -32906,6 +33722,8 @@ class LevelEditor
32906
33722
  * overlay again */
32907
33723
  close()
32908
33724
  {
33725
+ if (editorOther?.active())
33726
+ return editorOther.close();
32909
33727
  editorSession = false;
32910
33728
  editorSetOpen(false);
32911
33729
  }
@@ -32914,7 +33732,118 @@ class LevelEditor
32914
33732
  /** The level editor, levelEditor.open() to edit the level, levelEditor.close() to play on with the changes
32915
33733
  * @type {LevelEditor}
32916
33734
  * @memberof Editor */
32917
- const levelEditor = new LevelEditor;
33735
+ let levelEditor = new LevelEditor;
33736
+
33737
+ /** Make a level editor of the game's own the one in use: an instance of a class that extends LevelEditor, with
33738
+ * the game's hooks as its methods; set it before the editor opens
33739
+ * @param {LevelEditor} editor
33740
+ * @memberof Editor */
33741
+ function setLevelEditor(editor)
33742
+ {
33743
+ ASSERT(editor instanceof LevelEditor, 'a level editor extends LevelEditor');
33744
+ levelEditor.isOpen && console.warn('setLevelEditor: the editor is open, close it first');
33745
+ levelEditor = editor;
33746
+ }
33747
+
33748
+ // the other editor, the 3D one, when its plugin is there: it says if it is the one wanted and open, opens and
33749
+ // closes, and has its edit functions; the 2D editor is this file
33750
+ /** @type {{wanted: function(): boolean, isOpen: function(): boolean, active: function(): boolean, open: Function,
33751
+ * close: Function, hasKey: function(string, boolean): boolean, edit: EditorEdit3D}|undefined} */
33752
+ let editorOther;
33753
+
33754
+ // if the game gave a hook: set on the editor, or a method of its own class, not the empty one of LevelEditor
33755
+ function editorHas(name)
33756
+ {
33757
+ const hook = levelEditor[name];
33758
+ return typeof hook == 'function' && hook !== LevelEditor.prototype[name];
33759
+ }
33760
+
33761
+ // a hook or a moment of the game's, called when it is one: a game may set one to undefined to take it away
33762
+ function editorCall(name, ...args)
33763
+ {
33764
+ const hook = levelEditor[name];
33765
+ return typeof hook == 'function' ? hook.apply(levelEditor, args) : undefined;
33766
+ }
33767
+
33768
+ // the names keys have, by their lower case, so a game may spell one in any case
33769
+ const editorKeyNames = {};
33770
+ for (const name of ['Delete', 'Backspace', 'Enter', 'Tab', 'Escape', 'Space', 'Home', 'End', 'PageUp', 'PageDown',
33771
+ 'Insert', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', ...[...Array(12)].map((v, i)=> 'F' + (i + 1))])
33772
+ editorKeyNames[name.toLowerCase()] = name;
33773
+
33774
+ // a key as addKey keeps it: a letter in lower case, a name as keys are named, 'ctrl+' in front with Ctrl
33775
+ function editorKeySpelling(key)
33776
+ {
33777
+ const text = String(key), ctrl = text.trim().toLowerCase().startsWith('ctrl+');
33778
+ let name = ctrl ? text.trim().slice(5) : text;
33779
+ name = name === ' ' ? 'Space' : name.trim();
33780
+ name = name.length === 1 ? name.toLowerCase() : editorKeyNames[name.toLowerCase()] ?? name;
33781
+ return (ctrl ? 'ctrl+' : '') + name;
33782
+ }
33783
+
33784
+ // say once, for each key of the game's own that is one of the open editor's too, that the game's takes its place:
33785
+ // when the editor opens, or when the key is added while it is open, since the 2D and 3D editors' keys differ
33786
+ function editorKeyClashes()
33787
+ {
33788
+ if (!levelEditor.isOpen) return;
33789
+ for (const [spelled, key] of Object.entries(levelEditor.keys))
33790
+ {
33791
+ const ctrl = spelled.startsWith('ctrl+'), name = spelled.slice(ctrl ? 5 : 0);
33792
+ // in 2D the digits 1 to 9 pick a layer
33793
+ const clash = levelEditor.is3D ? editorOther.hasKey(name, ctrl) :
33794
+ !!(ctrl ? editorCtrlKeys : editorKeys)[name] || !ctrl && name.length === 1 && name >= '1' && name <= '9';
33795
+ if (key.warned || !clash) continue;
33796
+ key.warned = true;
33797
+ console.warn(`levelEditor.addKey: ${spelled} is a key of the editor's, the game's takes its place`);
33798
+ }
33799
+ }
33800
+
33801
+ // the help lines of the game's own keys
33802
+ function editorGameHelpLines()
33803
+ { return Object.values(levelEditor.keys).map((key)=> key.help).filter((line)=> !!line); }
33804
+
33805
+ // the tool of the game's own that is on, undefined when there is none
33806
+ function editorGameTool() { return levelEditor.tool !== undefined ? levelEditor.tools[levelEditor.tool] : undefined; }
33807
+
33808
+ // the game's box of an editor's panel: a row of its tools and its buttons, made again when one was added, the
33809
+ // tool that is on lit; and, the first time for a level editor, onPanel is given the box for controls of its own
33810
+ const editorGamePanels = new WeakMap;
33811
+ function editorGamePanel(box)
33812
+ {
33813
+ let state = editorGamePanels.get(box);
33814
+ if (state?.owner !== levelEditor)
33815
+ {
33816
+ box.replaceChildren();
33817
+ state = {owner: levelEditor, row: editorElement('div', box, 'display:flex;gap:4px;margin:4px 0;flex-wrap:wrap'),
33818
+ key: '', buttons: {}};
33819
+ editorGamePanels.set(box, state);
33820
+ editorCall('onPanel', box);
33821
+ }
33822
+ const tools = Object.keys(levelEditor.tools);
33823
+ const key = tools.map((name)=> name + '\t' + (levelEditor.tools[name].hint || '')).join('\n') + '\n\n' +
33824
+ levelEditor.buttons.length;
33825
+ if (state.key !== key)
33826
+ {
33827
+ state.key = key;
33828
+ state.buttons = {};
33829
+ const press = 'padding:3px 6px;cursor:pointer';
33830
+ state.row.replaceChildren(...tools.map((name)=>
33831
+ {
33832
+ const b = state.buttons[name] = editorElement('button', undefined, press, name);
33833
+ b.title = levelEditor.tools[name].hint || '';
33834
+ b.onclick = ()=> { levelEditor.tool = levelEditor.tool === name ? undefined : name; b.blur?.(); };
33835
+ return b;
33836
+ }), ...levelEditor.buttons.map((button)=>
33837
+ {
33838
+ const b = editorElement('button', undefined, press, button.label);
33839
+ b.title = button.title;
33840
+ b.onclick = ()=> { button.onClick.call(levelEditor); b.blur?.(); };
33841
+ return b;
33842
+ }));
33843
+ }
33844
+ for (const name of tools)
33845
+ state.buttons[name].style.outline = name === levelEditor.tool ? '2px solid #4af' : '';
33846
+ }
32918
33847
 
32919
33848
  // if the editor is open, and if it was opened and not exited, while Escape switches between playing and editing
32920
33849
  let editorIsOpen = false, editorSession = false;
@@ -32926,7 +33855,7 @@ let editorPlayFromMouse = false, editorAdvanced = false;
32926
33855
  function editorPlay(pos)
32927
33856
  {
32928
33857
  editorSetOpen(false);
32929
- editorPlayFromMouse && pos && levelEditor.onPlayFrom?.(pos.copy());
33858
+ editorPlayFromMouse && pos && editorHas('onPlayFrom') && levelEditor.onPlayFrom(pos.copy());
32930
33859
  }
32931
33860
 
32932
33861
  // the game's pause and camera from before the editor opened, handed back when it closes
@@ -32978,10 +33907,13 @@ function editorSetOpen(open)
32978
33907
  layers.filter((layer)=> !layer.isObjects).at(-1));
32979
33908
  layers.includes(editorObjectLayer) || (editorObjectLayer = editorLayer ? undefined :
32980
33909
  layers.find((layer)=> layer.isObjects)); // a level of objects alone
33910
+ editorCall('onOpen');
33911
+ editorKeyClashes();
32981
33912
  }
32982
33913
  else
32983
33914
  {
32984
33915
  editorStrokeEnd();
33916
+ editorToolHeld = false;
32985
33917
  editorSelection = editorSelectionDrag = editorRightPress = editorObjectDrag = editorObjectBox = undefined;
32986
33918
  editorObjectSelection.clear();
32987
33919
  editorMouseOnPanel = false; // the panel hides, with no mouseleave
@@ -32990,6 +33922,7 @@ function editorSetOpen(open)
32990
33922
  setCameraPos(state.cameraPos);
32991
33923
  setCameraScale(state.cameraScale);
32992
33924
  setCameraAngle(state.cameraAngle);
33925
+ editorCall('onClose');
32993
33926
  }
32994
33927
  }
32995
33928
 
@@ -33260,7 +34193,14 @@ async function editorSave(record, pickAgain=false)
33260
34193
  if (!record) return;
33261
34194
  editorStrokeEnd();
33262
34195
  const text = editorMapJSON(record); // the map as it is now, later edits wait for a later save
33263
- const saved = (async ()=> { await record.saving; return editorSaveText(record, text, pickAgain); })();
34196
+ // its place in line is taken now, and the game's hook is asked when its turn comes, so saves are written in
34197
+ // the order they were asked for however long a hook takes
34198
+ const saved = (async ()=>
34199
+ {
34200
+ await record.saving;
34201
+ if (await editorCall('onSave', text, record.fileName) === true) return 'kept'; // the game kept it itself
34202
+ return editorSaveText(record, text, pickAgain);
34203
+ })();
33264
34204
  record.saving = saved.catch(()=> {});
33265
34205
  return saved;
33266
34206
  }
@@ -33384,7 +34324,7 @@ function editorPaintData(record, data, objects, width=record.map.width, height=r
33384
34324
  editorStrokeEnd();
33385
34325
  if (width !== record.map.width || height !== record.map.height)
33386
34326
  {
33387
- if (!levelEditor.onRestart) return;
34327
+ if (!editorHas('onRestart')) return;
33388
34328
  const before = editorMapSnapshot(record);
33389
34329
  editorSetMapSnapshot(record, {width, height, layers: data, objects});
33390
34330
  editorMapChanged(record, before);
@@ -33429,7 +34369,7 @@ function editorPendingFits(record, saved)
33429
34369
  {
33430
34370
  const map = record.map, data = editorTileLayerData(map.layers), groups = editorObjectGroups(map.layers).length;
33431
34371
  const width = saved.width ?? map.width, height = saved.height ?? map.height;
33432
- return (width === map.width && height === map.height || !!levelEditor.onRestart) &&
34372
+ return (width === map.width && height === map.height || editorHas('onRestart')) &&
33433
34373
  saved.layers?.length === data.length && saved.layers.every((layer)=> layer.length === width * height) &&
33434
34374
  (saved.objects ?? []).every((objects, i)=> i < groups || !objects.length);
33435
34375
  }
@@ -33533,7 +34473,7 @@ function editorMapChanged(record, before)
33533
34473
  function editorResize(record, width, height)
33534
34474
  {
33535
34475
  const map = record?.map;
33536
- if (!map || record.synthetic || record.pending || !levelEditor.onRestart) return false;
34476
+ if (!map || record.synthetic || record.pending || !editorHas('onRestart')) return false;
33537
34477
  if (width === map.width && height === map.height) return false;
33538
34478
  editorStrokeEnd();
33539
34479
  const before = editorMapSnapshot(record);
@@ -33587,7 +34527,7 @@ function editorSetCell(layer, pos, gid)
33587
34527
  }
33588
34528
  const t = editorGidToTile(gid);
33589
34529
  live.setData(pos, t ? new TileLayerData(t.tile, t.direction, t.mirror, color) : new TileLayerData, true);
33590
- if (levelEditor.onTile)
34530
+ if (editorHas('onTile'))
33591
34531
  levelEditor.onTile(live, pos.copy(), t?.tile);
33592
34532
  else if (live instanceof TileCollisionLayer && live.isSolid)
33593
34533
  live.setCollisionData(pos, gid ? 1 : 0); // as tileLayersLoad gives it
@@ -33656,7 +34596,7 @@ function editorChanged(stroke)
33656
34596
  editorRedraw(stroke);
33657
34597
  for (const record of new Set(stroke.map(editorEntryRecord)))
33658
34598
  editorAutosave(record);
33659
- stroke.some((entry)=> entry.resize) && levelEditor.onRestart?.();
34599
+ stroke.some((entry)=> entry.resize) && editorHas('onRestart') && levelEditor.onRestart();
33660
34600
  }
33661
34601
 
33662
34602
  // the tile layers a stroke touched draw again whole
@@ -33849,6 +34789,7 @@ let editorObjectDrag, editorObjectBox;
33849
34789
  function editorSelectLayer(layer)
33850
34790
  {
33851
34791
  editorStrokeEnd();
34792
+ levelEditor.tool = undefined; // a tool of the game's own is put down
33852
34793
  editorLastCell = editorObjectDrag = editorRightPress = editorObjectBox = undefined; // presses stay on their layer
33853
34794
  editorSelectionDrag = undefined; // a move so far is kept, the stroke ended
33854
34795
  editorObjectSelection.clear();
@@ -34476,6 +35417,7 @@ const editorHelpLines =
34476
35417
  function editorHint()
34477
35418
  {
34478
35419
  if (keyIsDown('Space')) return 'Drag to pan';
35420
+ if (editorGameTool()) return editorGameTool().hint || levelEditor.tool;
34479
35421
  if (editorObjectLayer)
34480
35422
  return editorObjectSelection.size ? 'Selection: drag moves · Delete removes · Ctrl+C copy · Ctrl+X cut · click to clear' :
34481
35423
  'Left place / select · drag moves · Right pick / drag select · Delete removes';
@@ -34544,6 +35486,8 @@ function editorPanelInit()
34544
35486
  button(undo, 'Undo', ()=> editorUndo(), 'Ctrl+Z');
34545
35487
  button(undo, 'Redo', ()=> editorUndo(true), 'Ctrl+Y');
34546
35488
  button(undo, 'Keys', ()=> editorHelp = !editorHelp, 'Every control, ?');
35489
+ // the game's own tools, buttons and controls, near the top where the tools are
35490
+ const game = editorElement('div', editorPanel);
34547
35491
 
34548
35492
  // a file that changed under its autosave
34549
35493
  const pending = editorElement('div', editorPanel, 'padding:4px;margin:4px 0;background:#630;border-radius:3px');
@@ -34630,9 +35574,10 @@ function editorPanelInit()
34630
35574
  const help = editorElement('div', editorPanel, 'color:#aaa;margin-top:4px;border-top:1px solid #444;padding-top:4px');
34631
35575
  for (const line of editorHelpLines)
34632
35576
  editorElement('div', help, 'margin:2px 0', line);
35577
+ const gameHelp = editorElement('div', help);
34633
35578
  button(help, 'Close', ()=> editorHelp = false, '?');
34634
35579
 
34635
- editorPanelParts = {pendingUnfit, reset, advancedToggle, advanced, playFromLabel, playFrom, sizeRow, sizeX, sizeY, resize, restart, pending, layerRow, allLayers, turns, palette, brush, properties, status, storage, hint, help, layers: undefined};
35580
+ editorPanelParts = {game, gameHelp, pendingUnfit, reset, advancedToggle, advanced, playFromLabel, playFrom, sizeRow, sizeX, sizeY, resize, restart, pending, layerRow, allLayers, turns, palette, brush, properties, status, storage, hint, help, layers: undefined};
34636
35581
  }
34637
35582
 
34638
35583
  // the palette's cell size in pixels and how many to a row
@@ -34791,15 +35736,15 @@ function editorPanelUpdate()
34791
35736
  p.hint.textContent = editorHint();
34792
35737
  p.help.style.display = editorHelp ? '' : 'none';
34793
35738
  p.storage.style.display = editorSaveFailed ? '' : 'none';
34794
- p.restart.style.display = levelEditor.onRestart ? '' : 'none';
35739
+ p.restart.style.display = editorHas('onRestart') ? '' : 'none';
34795
35740
  p.advancedToggle.textContent = editorAdvanced ? 'Advanced ▾' : 'Advanced ▸';
34796
35741
  p.advanced.style.display = editorAdvanced ? '' : 'none';
34797
- p.playFromLabel.style.display = levelEditor.onPlayFrom ? 'flex' : 'none';
35742
+ p.playFromLabel.style.display = editorHas('onPlayFrom') ? 'flex' : 'none';
34798
35743
  p.playFrom.checked = editorPlayFromMouse;
34799
35744
 
34800
35745
  // the level's size, shown until a field is being typed in; without the Restart hook it can not change
34801
35746
  const record = editorRecord(), map = record?.map;
34802
- const canResize = !!levelEditor.onRestart && !record?.synthetic && !record?.pending;
35747
+ const canResize = editorHas('onRestart') && !record?.synthetic && !record?.pending;
34803
35748
  p.sizeRow.style.display = map ? 'flex' : 'none';
34804
35749
  if (map && !p.sizeRow.contains(document.activeElement))
34805
35750
  {
@@ -34869,6 +35814,7 @@ function editorIsTextField(target)
34869
35814
  function editorOnKeyDown(e)
34870
35815
  {
34871
35816
  if (!editorIsOpen || e.repeat || e.altKey || editorIsTextField(e.target)) return;
35817
+ if (editorToolHeld) return; // the keys wait for a tool of the game's own to let go
34872
35818
  if (editorSelectionDrag && !(debugKey && e.code === debugKey))
34873
35819
  {
34874
35820
  // a key ends a selection drag where it is, an undo of its own, so Delete or undo acts on what is there;
@@ -34876,10 +35822,12 @@ function editorOnKeyDown(e)
34876
35822
  editorStrokeEnd();
34877
35823
  editorSelectionDrag = undefined;
34878
35824
  }
34879
- let key = e.key.length === 1 ? e.key.toLowerCase() : e.key;
34880
- if (key.length === 1 && !/[a-z?]/.test(key))
35825
+ let key = e.key === ' ' ? 'Space' : e.key.length === 1 ? e.key.toLowerCase() : e.key;
35826
+ if (key.length === 1 && !/[a-z?]/.test(key) && !levelEditor.keys[key])
34881
35827
  key = e.code?.match(/^Key([A-Z])$/)?.[1].toLowerCase() ?? key;
34882
- const action = (e.ctrlKey || e.metaKey ? editorCtrlKeys : editorKeys)[key];
35828
+ // a key of the game's own first, it takes the place of one of the editor's
35829
+ const ctrl = e.ctrlKey || e.metaKey, own = levelEditor.keys[(ctrl ? 'ctrl+' : '') + key];
35830
+ const action = own ? (shift)=> own.action.call(levelEditor, shift) : (ctrl ? editorCtrlKeys : editorKeys)[key];
34883
35831
  if (action && action(e.shiftKey) !== false)
34884
35832
  e.preventDefault();
34885
35833
  }
@@ -35073,6 +36021,15 @@ function editorUpdate()
35073
36021
  return;
35074
36022
  }
35075
36023
 
36024
+ // Escape while a tool of the game's own is held takes its press back, and stays in the editor
36025
+ if (editorToolHeld && debugKey && keyWasPressed(debugKey))
36026
+ {
36027
+ inputClearKey(debugKey);
36028
+ editorStrokeCancel();
36029
+ editorToolHeld = false;
36030
+ return;
36031
+ }
36032
+
35076
36033
  // in an editing session Escape, the debug key, switches between playing and editing; taken, so the debug
35077
36034
  // overlay waits till the session ends
35078
36035
  if (editorSession && debugKey && keyWasPressed(debugKey))
@@ -35103,7 +36060,7 @@ function editorUpdate()
35103
36060
  // keeps them while it is open
35104
36061
  for (let i = 1; !debugOverlay && i <= 9; ++i)
35105
36062
  {
35106
- if (!keyWasPressed('Digit' + i)) continue;
36063
+ if (!keyWasPressed('Digit' + i) || levelEditor.keys[i]) continue; // a digit of the game's own is its
35107
36064
  inputClearKey('Digit' + i);
35108
36065
  const layer = editorLayers()[i - 1];
35109
36066
  layer && layer !== (editorObjectLayer ?? editorLayer) && editorSelectLayer(layer); // a drag does not carry across
@@ -35116,6 +36073,8 @@ function editorUpdate()
35116
36073
  editorApplyCamera();
35117
36074
 
35118
36075
  editorMouseOnPanel || (editorMouseScreen = mousePosScreen.copy());
36076
+ editorCall('onUpdate');
36077
+ if (editorToolUpdate(space)) return; // a tool of the game's own has the mouse
35119
36078
  if (editorObjectLayer)
35120
36079
  return editorUpdateObjects(space);
35121
36080
  const layer = editorLayer?.live.destroyed ? undefined : editorLayer, mouse = screenToWorld(editorMouseScreen);
@@ -35185,10 +36144,87 @@ function editorUpdate()
35185
36144
  }
35186
36145
  }
35187
36146
 
36147
+ // a tool of the game's own: if its press is held, and what was picked in the editor when it was turned on, since
36148
+ // picking a layer, a tile or an object type puts the game's tool down
36149
+ let editorToolHeld = false, editorToolName, editorToolPicked = [];
36150
+
36151
+ // what a tool's callbacks are given: where the mouse is in the level, the cell under it on the selected tile
36152
+ // layer, and the keys held
36153
+ function editorToolAt()
36154
+ {
36155
+ const layer = editorObjectLayer || editorLayer?.live.destroyed ? undefined : editorLayer;
36156
+ const pos = screenToWorld(editorMouseScreen);
36157
+ return {pos, cell: layer && !editorMouseOnPanel ? editorCellAt(layer.live, pos) || undefined : undefined,
36158
+ ray: undefined, shift: keyIsDown('ShiftLeft') || keyIsDown('ShiftRight'),
36159
+ ctrl: keyIsDown('ControlLeft') || keyIsDown('ControlRight')};
36160
+ }
36161
+
36162
+ // the game's tool, when one is on: the left button is its, a press, a drag and a release one undo, the right
36163
+ // button takes a held press back; true when it has the mouse
36164
+ function editorToolUpdate(space)
36165
+ {
36166
+ const picked = [editorLayer, editorObjectLayer, editorBrush, editorObjectBrush];
36167
+ if (levelEditor.tool !== editorToolName)
36168
+ editorToolName = levelEditor.tool, editorToolPicked = picked; // turned on, or off, by its key or button
36169
+ else if (picked.some((value, i)=> value !== editorToolPicked[i]))
36170
+ levelEditor.tool = editorToolName = undefined; // something of the editor's was picked
36171
+ const tool = editorGameTool();
36172
+ if (!tool)
36173
+ {
36174
+ editorToolHeld = false;
36175
+ return false;
36176
+ }
36177
+ const at = editorToolAt();
36178
+ editorHover = at.cell;
36179
+ editorRightPress = undefined; // a right press from before the tool was on is not the editor's to finish
36180
+ if (editorToolHeld && mouseWasPressed(2))
36181
+ {
36182
+ inputClearKey(2);
36183
+ editorStrokeCancel();
36184
+ editorToolHeld = false;
36185
+ }
36186
+ else if (mouseWasPressed(0) && !space && !editorMouseOnPanel)
36187
+ editorToolHeld = tool.onPress?.(at) !== false;
36188
+ else if (editorToolHeld && mouseIsDown(0))
36189
+ tool.onDrag?.(at);
36190
+ if (editorToolHeld && !mouseIsDown(0))
36191
+ {
36192
+ tool.onRelease?.(at);
36193
+ editorStrokeEnd();
36194
+ editorToolHeld = false;
36195
+ }
36196
+ return true;
36197
+ }
36198
+
36199
+ // the 2D editor's edit functions, what levelEditor.edit2D gives a game's own keys, buttons and tools
36200
+ const editorEdit2D =
36201
+ {
36202
+ get map() { return editorRecord()?.map; },
36203
+ get layer() { return editorObjectLayer ? undefined : editorLayer?.live; },
36204
+ get objects() { return editorObjectLayer ? editorObjectsCopy(editorObjectList(editorObjectLayer)) : []; },
36205
+ get hover() { return editorHover && !editorObjectLayer ? editorHover.copy() : undefined; },
36206
+ get selection() { return editorObjectSelection; },
36207
+ paint(cell, tile, direction=0, mirror=false)
36208
+ {
36209
+ const layer = editorObjectLayer ? undefined : editorLayer, size = layer?.live.size;
36210
+ if (!layer || layer.live.destroyed || layer.record.pending || !isVector2(cell)) return false;
36211
+ const x = floor(cell.x), y = floor(cell.y);
36212
+ if (x < 0 || y < 0 || x >= size.x || y >= size.y) return false;
36213
+ editorPaint(layer, vec2(x, y), tile < 0 ? 0 : editorTileToGid(tile, direction & 3, mirror));
36214
+ return true;
36215
+ },
36216
+ changeObjects(change) { return editorChangeObjects(editorObjectLayer, change); },
36217
+ strokeEnd() { editorStrokeEnd(); },
36218
+ strokeCancel() { editorStrokeCancel(); },
36219
+ bulk(edit) { editorBulkEdit(edit); },
36220
+ undo(redo=false) { return editorUndo(redo); },
36221
+ toJSON() { const record = editorRecord(); return record ? editorMapJSON(record) : ''; },
36222
+ };
36223
+
35188
36224
  // switch to play and have the game rebuild its level from the changed map, when it has a hook for that
35189
36225
  function editorRestart()
35190
36226
  {
35191
- if (!levelEditor.onRestart) return;
36227
+ if (!editorHas('onRestart')) return;
35192
36228
  editorSetOpen(false); // ends a held stroke, and hands back the game's pause and camera, Escape comes back
35193
36229
  levelEditor.onRestart();
35194
36230
  }
@@ -35202,6 +36238,11 @@ function editorPreRender() { editorIsOpen && editorApplyCamera(); }
35202
36238
  function editorRender()
35203
36239
  {
35204
36240
  headlessMode || editorPanelUpdate();
36241
+ if (!headlessMode && editorIsOpen && editorPanelParts)
36242
+ {
36243
+ editorGamePanel(editorPanelParts.game);
36244
+ editorGameHelp(editorPanelParts.gameHelp);
36245
+ }
35205
36246
  if (!editorIsOpen || headlessMode) return;
35206
36247
  const layer = editorLayer?.live.destroyed ? undefined : editorLayer;
35207
36248
  if (!layer && !editorObjectLayer) return;
@@ -35250,12 +36291,12 @@ function editorRender()
35250
36291
  if (editorSelection && !editorObjectLayer)
35251
36292
  outline(editorSelection.min, editorSelection.max.add(vec2(1)), hsl(.15, 1, .6), thin * 3);
35252
36293
  const last = editorLastPlaced, shift = keyIsDown('ShiftLeft') || keyIsDown('ShiftRight');
35253
- if (shift && editorHover && last?.layer === layer && !mouseIsDown(0))
36294
+ if (shift && editorHover && last?.layer === layer && !mouseIsDown(0) && !editorGameTool())
35254
36295
  drawLine(live.pos.add(last.pos).add(vec2(.5)), live.pos.add(editorHover).add(vec2(.5)), thin * 2,
35255
36296
  hsl(.55, 1, .6, .6), undefined, 0, glEnable, false);
35256
36297
 
35257
- // the brush where it would paint, the Erase brush's cells in red
35258
- if (editorHover)
36298
+ // the brush where it would paint, the Erase brush's cells in red; a tool of the game's own draws its own
36299
+ if (editorHover && !editorGameTool())
35259
36300
  {
35260
36301
  const grid = editorStampGrid(layer, editorBrush), {width: w, height: h} = editorBrush;
35261
36302
  for (let y = h; y--;)
@@ -35273,7 +36314,24 @@ function editorRender()
35273
36314
  editorObjectLayer && editorRenderObjects(thin);
35274
36315
  }
35275
36316
 
35276
- debug && engineAddPlugin(editorUpdate, editorRender);
36317
+ // the game's own drawing in the level, over the editor's: the editor's onDraw, and its tool's while one is on
36318
+ function editorRenderGame()
36319
+ {
36320
+ if (!editorIsOpen) return;
36321
+ editorCall('onDraw');
36322
+ editorGameTool()?.onDraw?.(editorToolAt());
36323
+ }
36324
+
36325
+ // the lines of the game's own keys in a panel's help, made again when they change
36326
+ function editorGameHelp(element)
36327
+ {
36328
+ const lines = editorGameHelpLines(), key = lines.join('\n');
36329
+ if (element.dataset.key === key) return;
36330
+ element.dataset.key = key;
36331
+ element.replaceChildren(...lines.map((line)=> editorElement('div', undefined, 'margin:2px 0', line)));
36332
+ }
36333
+
36334
+ debug && engineAddPlugin(editorUpdate, ()=> { editorRender(); editorRenderGame(); });
35277
36335
 
35278
36336
  // the editor's own listeners for letter shortcuts and the wheel, the engine's input reads keys by position
35279
36337
  if (debug && globalThis.document?.addEventListener)
@@ -35570,9 +36628,11 @@ const editor3DSelection = new Set; // the ids of the selected objects
35570
36628
  // the type a click places
35571
36629
  /** @type {string|undefined} */
35572
36630
  let editor3DBrush;
35573
- // the copied objects
36631
+ // the copied objects, and the size and box offset each had when it was copied
35574
36632
  /** @type {Array<Object>|undefined} */
35575
36633
  let editor3DClipboard;
36634
+ /** @type {Map<Object, {size: Vector3, offset: Vector3}>} */
36635
+ let editor3DClipboardBoxes = new Map;
35576
36636
  // the level's, each entry its object list and its parts, the scene and the blocks, before and after an edit
35577
36637
  let editor3DUndoList = [], editor3DRedoList = [];
35578
36638
  // the edit being made, a drag is one: the objects and the parts as they were before it, and painted when blocks
@@ -35591,7 +36651,7 @@ const editor3DSelected = ()=> editor3DObjects().filter((o)=> editor3DSelection.h
35591
36651
 
35592
36652
  // the parts of a level beside its objects that the editor edits: its scene block and its map of blocks; each is
35593
36653
  // undone, autosaved, reset and saved as the objects are
35594
- const editor3DLevelPartNames = ['scene', 'voxels', 'terrain'];
36654
+ const editor3DLevelPartNames = ['scene', 'voxels', 'terrain', 'prefabs'];
35595
36655
 
35596
36656
  // the last copy made of each part, as its text and the copy: a part that has not changed since is not copied
35597
36657
  // again, so undo steps share one copy of a terrain until it is sculpted; a copy is never changed
@@ -35695,12 +36755,46 @@ const editor3DTurned = (object)=> editor3DRotation(object).lengthSquared() > 0;
35695
36755
  // the size of an object's box: what the game made for it says, a marker is 1 unit
35696
36756
  function editor3DSize(object)
35697
36757
  {
35698
- const made = editor3DInstances.get(object.id);
36758
+ const made = editor3DInstances.get(object.id), box = editor3DPrefabBox(made);
36759
+ if (box) return box.size;
35699
36760
  if (!(made instanceof EngineObject3D) || !(made.mesh || made.tileInfo)) return vec3(1);
35700
36761
  const s = made.size3D, k = made.scale3D;
35701
36762
  return vec3(s.x * abs(k.x), s.y * abs(k.y), s.z * abs(k.z));
35702
36763
  }
35703
36764
 
36765
+ // the box around a prefab's instance in the world, the boxes of its parts together, as its middle and its size;
36766
+ // undefined for anything else, or an instance with nothing to see
36767
+ function editor3DPrefabBox(made)
36768
+ {
36769
+ if (!(made instanceof Prefab3D) || made.destroyed) return;
36770
+ const low = vec3(Infinity), high = vec3(-Infinity);
36771
+ for (const part of editor3DParts(made))
36772
+ {
36773
+ if (!(part instanceof EngineObject3D) || !(part.mesh || part.tileInfo)) continue;
36774
+ const bounds = part.mesh && (part.mesh.bounds || part.mesh.getBounds());
36775
+ const matrix = render3DObjectMatrix(part).copy().multiply(bounds ?
36776
+ buildMatrix(bounds.min.add(bounds.max).scale(.5), undefined, bounds.max.subtract(bounds.min)) :
36777
+ buildMatrix(vec3(), undefined, part.size3D));
36778
+ for (let i = 8; i--;)
36779
+ {
36780
+ const p = matrix.transformPoint(vec3(i & 1 ? .5 : -.5, i & 2 ? .5 : -.5, i & 4 ? .5 : -.5));
36781
+ for (const k of ['x', 'y', 'z'])
36782
+ low[k] = min(low[k], p[k]), high[k] = max(high[k], p[k]);
36783
+ }
36784
+ }
36785
+ // to the nearest part of the file's numbers, so a box worked out through a matrix reads as it was written
36786
+ const tidy = (v)=> vec3(editor3DRound(v.x), editor3DRound(v.y), editor3DRound(v.z));
36787
+ return low.x <= high.x ? {pos: tidy(low.add(high).scale(.5)), size: tidy(high.subtract(low))} : undefined;
36788
+ }
36789
+
36790
+ // from an object's place to the middle of its box: nothing for a plain object, whose place is its middle, and for
36791
+ // a prefab's instance how far its origin is from the middle of its parts, as it is turned and sized now
36792
+ function editor3DBoxOffset(object)
36793
+ {
36794
+ const box = editor3DPrefabBox(editor3DInstances.get(object.id));
36795
+ return box ? box.pos.subtract(editor3DPos(object)) : vec3();
36796
+ }
36797
+
35704
36798
  // every object gets an id of its own, a whole number: one without, or with one already used, gets a new one
35705
36799
  function editor3DFixIds(objects)
35706
36800
  {
@@ -35736,6 +36830,12 @@ function editor3DLevelLoaded(level)
35736
36830
  editor3DSelection.clear();
35737
36831
  editor3DStroke = undefined;
35738
36832
  }
36833
+ if (!editor3DPrefabLevels.has(level) && editor3DPrefabStack.length)
36834
+ {
36835
+ // the game loaded a level while a prefab was open: what was open is gone with what it was opened from
36836
+ editor3DPrefabStack.length = 0;
36837
+ editor3DPrefabEdits = {};
36838
+ }
35739
36839
  editor3DLevel = level;
35740
36840
  editor3DInstances.clear();
35741
36841
  if (isArray(level.objects))
@@ -35775,7 +36875,8 @@ function editor3DLevelLoaded(level)
35775
36875
  editor3DUndoList = record.undo;
35776
36876
  editor3DRedoList = record.redo;
35777
36877
  // an autosave the file already has, from a Save, goes; one of this file, or of the file a Save wrote, comes back
35778
- const saved = editor3DSaves()[record.key];
36878
+ // a prefab being edited has no autosave, and one of a level with the same objects is not its
36879
+ const saved = editor3DPrefabLevels.has(level) ? undefined : editor3DSaves()[record.key];
35779
36880
  if (saved && isArray(saved.objects) && editor3DSame(saved.objects, original) &&
35780
36881
  editor3DSame(editor3DLevelParts(saved), originalParts))
35781
36882
  editor3DAutosave(level);
@@ -35805,6 +36906,7 @@ function editor3DPlaceInstance(made, object)
35805
36906
  made.scale3D = (level3DBaseScale.get(made) ?? vec3(1)).multiply(editor3DScale(object));
35806
36907
  made.velocity3D = vec3();
35807
36908
  made.angleVelocity3D = vec3();
36909
+ made instanceof Prefab3D && made.placeAt(editor3DPos(object)); // by its origin, and its parts go with it
35808
36910
  }
35809
36911
 
35810
36912
  // make an object's game object, again when it has one
@@ -35839,6 +36941,24 @@ function editor3DSetObjects(list)
35839
36941
  made instanceof EngineObject3D && !made.destroyed;
35840
36942
  onlyMoved ? editor3DPlaceInstance(made, object) : editor3DMakeInstance(object);
35841
36943
  }
36944
+ editor3DShadowLight();
36945
+ }
36946
+
36947
+ // the light that casts the shadows is the level's last Light that asks to, as it is after a load, whatever order
36948
+ // the edits made them in; a light of the game's own is left alone while no Light of the level asks
36949
+ function editor3DShadowLight()
36950
+ {
36951
+ if (!render3D) return;
36952
+ let light;
36953
+ for (const object of editor3DObjects())
36954
+ {
36955
+ const made = editor3DInstances.get(object.id);
36956
+ if (object.type === 'Light' && object.properties?.shadows === true && made instanceof Light3D && !made.destroyed)
36957
+ light = made;
36958
+ }
36959
+ const now = render3D.shadowLight;
36960
+ if (light || !now || now.destroyed || [...editor3DInstances.values()].some((made)=> made === now))
36961
+ render3D.shadowLight = light;
35842
36962
  }
35843
36963
 
35844
36964
  // change the level's objects as part of the edit being made: change edits a copy of the list; the changes until
@@ -35867,8 +36987,30 @@ function editor3DSetPart(name, part)
35867
36987
  }
35868
36988
  else if (name === 'voxels')
35869
36989
  editor3DVoxelShow();
35870
- else
36990
+ else if (name === 'terrain')
35871
36991
  editor3DTerrainShow();
36992
+ else
36993
+ editor3DPrefabsShow();
36994
+ }
36995
+
36996
+ // bring the prefab types in line with the level's own prefabs, and make every instance again: a prefab the level
36997
+ // no longer has is no longer a type, one it has is added as level3DLoad adds it, and what a prefab makes is made
36998
+ // when its instance is, so each instance is made again
36999
+ function editor3DPrefabsShow()
37000
+ {
37001
+ if (editor3DPrefabLevels.has(editor3DLevel)) return; // inside a prefab, the prefabs are the level's
37002
+ const prefabs = editor3DLevelPart('prefabs') ?? {};
37003
+ for (const [name, prefab] of level3DPrefabs)
37004
+ {
37005
+ if (!prefab.fromLevel || prefabs[name]) continue;
37006
+ level3DPrefabs.delete(name);
37007
+ level3DTypes.delete(name);
37008
+ }
37009
+ level3DPrefabsAdd(prefabs);
37010
+ // every object of a prefab's type, one whose type was not there when the objects were set too
37011
+ for (const object of editor3DObjects())
37012
+ level3DPrefabs.has(object.type) && editor3DMakeInstance(object);
37013
+ editor3DShadowLight();
35872
37014
  }
35873
37015
 
35874
37016
  // make the level's parts these, the ones that differ
@@ -35878,6 +37020,11 @@ function editor3DSetParts(parts)
35878
37020
  editor3DSame(parts[name], editor3DLevelPart(name)) || editor3DSetPart(name, parts[name]);
35879
37021
  }
35880
37022
 
37023
+ // the stroke being made, started here with the level as it is when there is none: called before the level changes
37024
+ /** @return {{before: Array<Object>, parts: Object, painted?: boolean, sculpted?: boolean}} */
37025
+ function editor3DStrokeBegin()
37026
+ { return editor3DStroke ||= {before: editor3DCopy(editor3DObjects()), parts: editor3DLevelParts()}; }
37027
+
35881
37028
  // change a part of the level as part of the edit being made, as editor3DChange does its objects: change is given
35882
37029
  // a copy of the part, undefined when the level has none, and returns the new one, or undefined for none; false
35883
37030
  // when nothing changed
@@ -35886,7 +37033,7 @@ function editor3DChangePart(name, change)
35886
37033
  if (!editor3DLevel || editor3DRecords.get(editor3DLevel)?.pending) return false; // its autosave waits first
35887
37034
  const parts = editor3DLevelParts(), after = change(parts[name] && editor3DCopy(parts[name]));
35888
37035
  if (editor3DSame(parts[name], after)) return false;
35889
- editor3DStroke ||= {before: editor3DCopy(editor3DObjects()), parts};
37036
+ editor3DStrokeBegin();
35890
37037
  editor3DSetPart(name, after);
35891
37038
  return true;
35892
37039
  }
@@ -35963,8 +37110,7 @@ function editor3DVoxelSet(cell, type)
35963
37110
  const map = editor3DVoxelMap();
35964
37111
  if (!map || editor3DRecords.get(editor3DLevel)?.pending) return false; // its autosave waits first
35965
37112
  if (!editor3DVoxelInside(map, cell) || map.getVoxel(cell) === type) return false;
35966
- editor3DStroke ||= {before: editor3DCopy(editor3DObjects()), parts: editor3DLevelParts()};
35967
- editor3DStroke.painted = true;
37113
+ editor3DStrokeBegin().painted = true;
35968
37114
  map.setVoxel(cell, type);
35969
37115
  return true;
35970
37116
  }
@@ -35986,8 +37132,85 @@ function editor3DVoxelDragCell(drag, ray)
35986
37132
  // the level's terrain: the level's terrain block is the source of truth, the game's HeightMap follows it;
35987
37133
  // sculpting changes the game's map as it goes, and the stroke's end writes its heights into the level
35988
37134
 
35989
- // the brush: its size across in world units, and how strong it is, 0 to 1
35990
- const editor3DTerrainBrush = {size: 8, strength: .5};
37135
+ // the brush: its size across in world units, how strong it is, 0 to 1, which brush it is, sculpt, flatten or
37136
+ // paint, and the color it paints
37137
+ const editor3DTerrainBrush = {size: 8, strength: .5, mode: 'sculpt', color: '#8a6a4a'};
37138
+ const editor3DTerrainBrushes = ['sculpt', 'flatten', 'paint'];
37139
+
37140
+ // the paint of the game's terrain map as it is being edited, {colors, cells} as level3DTerrainPaint gives it:
37141
+ // from the level, and ahead of it while a stroke paints
37142
+ const editor3DTerrainPaints = new WeakMap;
37143
+ function editor3DTerrainPaint(map)
37144
+ {
37145
+ let paint = editor3DTerrainPaints.get(map);
37146
+ if (!paint)
37147
+ {
37148
+ const samples = map.rows * map.columns;
37149
+ paint = level3DTerrainPaint(editor3DLevelPart('terrain'), samples) || {colors: [], cells: new Uint8Array(samples)};
37150
+ editor3DTerrainPaints.set(map, paint);
37151
+ }
37152
+ return paint;
37153
+ }
37154
+
37155
+ // the next brush of the Terrain tool
37156
+ function editor3DTerrainNextBrush()
37157
+ {
37158
+ const brushes = editor3DTerrainBrushes;
37159
+ editor3DTerrainBrush.mode = brushes[(brushes.indexOf(editor3DTerrainBrush.mode) + 1) % brushes.length];
37160
+ }
37161
+
37162
+ // the drag a press on the ground starts, by the brush and the keys held: sculpt raises, Shift lowers; flatten
37163
+ // brings the ground to the height pressed; paint colors it, Shift takes the paint off; Ctrl smooths with the two
37164
+ // that shape the ground
37165
+ function editor3DTerrainPress(point, shift, ctrl)
37166
+ {
37167
+ const map = editor3DTerrainMap(), brush = editor3DTerrainBrush.mode;
37168
+ if (!map) return;
37169
+ if (brush === 'paint')
37170
+ return {kind: 'terrain', mode: shift ? 'erase' : 'paint'};
37171
+ if (ctrl)
37172
+ return {kind: 'terrain', mode: 'smooth'};
37173
+ if (brush === 'flatten')
37174
+ return {kind: 'terrain', mode: 'flatten', level: clamp((point.y - map.pos3D.y) / map.height)};
37175
+ return {kind: 'terrain', mode: shift ? 'lower' : 'raise'};
37176
+ }
37177
+
37178
+ // the mouse held on the ground with a terrain drag
37179
+ function editor3DTerrainDragTo(drag, point)
37180
+ {
37181
+ return drag.mode === 'paint' || drag.mode === 'erase' ? editor3DTerrainPaintAt(point, drag.mode === 'erase') :
37182
+ editor3DTerrainSculpt(point, drag.mode, editor3DSeconds, drag.level);
37183
+ }
37184
+
37185
+ // paint the ground around a point with the brush's color, or take its paint off, as part of the edit being made:
37186
+ // every sample under the brush, in the game's map now and in the level when the stroke ends; false when nothing
37187
+ // changed, or when the terrain has all the colors it can keep, 255
37188
+ function editor3DTerrainPaintAt(point, erase=false)
37189
+ {
37190
+ const map = editor3DTerrainMap();
37191
+ if (!map || editor3DRecords.get(editor3DLevel)?.pending) return false; // its autosave waits first
37192
+ const paint = editor3DTerrainPaint(map), hex = editor3DInputColor(editor3DTerrainBrush.color);
37193
+ let color = erase ? 0 : paint.colors.indexOf(hex) + 1;
37194
+ if (!erase && !color && paint.colors.length >= 255) return false;
37195
+ const radius = editor3DTerrainBrush.size / 2, rows = map.rows, columns = map.columns;
37196
+ const stepX = map.mapSize.x / (columns - 1), stepZ = map.mapSize.y / (rows - 1);
37197
+ const x0 = map.pos3D.x - map.mapSize.x / 2, z0 = map.pos3D.z - map.mapSize.y / 2;
37198
+ let changed = false;
37199
+ for (let r = 0; r < rows; ++r)
37200
+ for (let c = 0; c < columns; ++c)
37201
+ {
37202
+ if (hypot(x0 + c * stepX - point.x, z0 + r * stepZ - point.z) >= radius) continue;
37203
+ color ||= erase ? 0 : paint.colors.push(hex); // a new color joins the list with the first sample it paints
37204
+ if (paint.cells[r * columns + c] === color) continue;
37205
+ paint.cells[r * columns + c] = color;
37206
+ changed = true;
37207
+ }
37208
+ if (!changed) return false;
37209
+ editor3DStrokeBegin().sculpted = true;
37210
+ level3DTerrainSetColors(map, editor3DLevelPart('terrain'), paint);
37211
+ map.rebuild();
37212
+ return true;
37213
+ }
35991
37214
 
35992
37215
  // the game's map of the level's terrain, undefined when the level has none
35993
37216
  function editor3DTerrainMap()
@@ -36016,15 +37239,30 @@ function editor3DTerrainShow()
36016
37239
  return;
36017
37240
  }
36018
37241
  map.heights = shape.heights;
36019
- map.color = /^#([0-9a-f]{6}|[0-9a-f]{8})$/i.test(terrain.color) ? new Color().setHex(terrain.color) : WHITE;
37242
+ editor3DTerrainPaints.delete(map); // as the level has it
37243
+ level3DTerrainSetColors(map, terrain);
36020
37244
  map.rebuild();
36021
37245
  }
36022
37246
 
36023
- // write the game's map into the level, as a stroke of sculpting ends, its heights to a thousandth
37247
+ // write the game's map into the level, as a stroke of sculpting ends, its heights to a thousandth, and its paint:
37248
+ // the colors still in use and runs of a count and a color along the rows, or no paint when none is left
36024
37249
  function editor3DTerrainStore()
36025
37250
  {
36026
37251
  const map = editor3DTerrainMap(), terrain = editor3DLevelPart('terrain');
36027
- map && terrain && (terrain.heights = map.heights.map((row)=> row.map((v)=> round(v * 1e3) / 1e3)));
37252
+ if (!map || !terrain) return;
37253
+ terrain.heights = map.heights.map((row)=> row.map((v)=> round(v * 1e3) / 1e3));
37254
+ const paint = editor3DTerrainPaints.get(map);
37255
+ if (!paint) return; // nothing painted since the level's was read
37256
+ const used = paint.colors.map((hex, i)=> paint.cells.includes(i + 1));
37257
+ const colors = paint.colors.filter((hex, i)=> used[i]), cells = [];
37258
+ const index = paint.colors.map((hex, i)=> used[i] ? colors.indexOf(hex) + 1 : 0);
37259
+ for (const v of paint.cells)
37260
+ {
37261
+ const color = v && index[v - 1];
37262
+ cells[cells.length - 1] === color ? ++cells[cells.length - 2] : cells.push(1, color);
37263
+ }
37264
+ colors.length ? terrain.paint = {colors, cells} : delete terrain.paint;
37265
+ editor3DTerrainPaints.delete(map); // read from the level again, with its colors as they are numbered now
36028
37266
  }
36029
37267
 
36030
37268
  // give the level a flat terrain, as one undo: its size in the world, how many cells a side, and how tall a full
@@ -36048,9 +37286,9 @@ function editor3DTerrainRemove()
36048
37286
  }
36049
37287
 
36050
37288
  // sculpt the ground around a point for some seconds, as part of the edit being made: raise, lower or smooth it
36051
- // under the brush, a soft circle, most in its middle and nothing at its edge; in the game's map now, in the level
36052
- // when the stroke ends; false when nothing changed
36053
- function editor3DTerrainSculpt(point, mode, seconds)
37289
+ // under the brush, a soft circle, most in its middle and nothing at its edge, or flatten it toward a height, level,
37290
+ // 0 to 1; in the game's map now, in the level when the stroke ends; false when nothing changed
37291
+ function editor3DTerrainSculpt(point, mode, seconds, level=0)
36054
37292
  {
36055
37293
  const map = editor3DTerrainMap();
36056
37294
  if (!map || editor3DRecords.get(editor3DLevel)?.pending) return false; // its autosave waits first
@@ -36061,7 +37299,7 @@ function editor3DTerrainSculpt(point, mode, seconds)
36061
37299
  const c0 = max(0, ceil((point.x - radius - x0) / stepX)), c1 = min(columns - 1, floor((point.x + radius - x0) / stepX));
36062
37300
  const r0 = max(0, ceil((point.z - radius - z0) / stepZ)), r1 = min(rows - 1, floor((point.z + radius - z0) / stepZ));
36063
37301
  // smoothing reads the ground as it was, so the order the samples are done in does not show
36064
- const old = mode === 'smooth' ? heights.map((row)=> row.slice()) : heights;
37302
+ const old = mode === 'smooth' ? heights.map((row, r)=> r >= r0 - 1 && r <= r1 + 1 ? row.slice() : row) : heights;
36065
37303
  const rate = strength * 10 * seconds; // world units of height at full strength, 10 a second
36066
37304
  let changed = false;
36067
37305
  for (let r = r0; r <= r1; ++r)
@@ -36079,6 +37317,8 @@ function editor3DTerrainSculpt(point, mode, seconds)
36079
37317
  old[r + dr]?.[c + dc] !== undefined && (sum += old[r + dr][c + dc], ++count);
36080
37318
  v = h + (sum / count - h) * min(1, rate * falloff);
36081
37319
  }
37320
+ else if (mode === 'flatten')
37321
+ v = h + (level - h) * min(1, rate * falloff);
36082
37322
  else
36083
37323
  v = clamp(h + (mode === 'lower' ? -1 : 1) * rate * falloff / map.height);
36084
37324
  if (v === h) continue;
@@ -36086,8 +37326,7 @@ function editor3DTerrainSculpt(point, mode, seconds)
36086
37326
  changed = true;
36087
37327
  }
36088
37328
  if (!changed) return false;
36089
- editor3DStroke ||= {before: editor3DCopy(editor3DObjects()), parts: editor3DLevelParts()};
36090
- editor3DStroke.sculpted = true;
37329
+ editor3DStrokeBegin().sculpted = true;
36091
37330
  map.rebuild();
36092
37331
  return true;
36093
37332
  }
@@ -36108,16 +37347,81 @@ function editor3DVoxelPress(ray, mode)
36108
37347
  {
36109
37348
  const target = editor3DVoxelTarget(ray, mode);
36110
37349
  if (!target) return;
36111
- const {cell, axis, plane} = target;
37350
+ const {cell, axis, plane, side} = target;
37351
+ if (editor3DBlockBox)
37352
+ {
37353
+ // a box grows out from the face when placing, and into the blocks when removing or repainting
37354
+ const drag = {kind: 'blocks', mode, axis, plane, layer: cell[axis], last: cell, start: cell,
37355
+ grow: mode === 'place' ? side : -side, height: max(1, floor(editor3DBlockBoxHeight)),
37356
+ changed: new Map, region: undefined};
37357
+ editor3DVoxelBoxTo(drag, cell);
37358
+ return drag;
37359
+ }
36112
37360
  editor3DVoxelApply(cell, mode);
36113
37361
  return {kind: 'blocks', mode, axis, plane, layer: cell[axis], last: cell};
36114
37362
  }
36115
37363
 
37364
+ // a box drag goes on: what it changed so far is put back, then every cell of the box from where it started to a
37365
+ // cell of the same layer is changed, as many layers as its height, the part of it inside the map
37366
+ function editor3DVoxelBoxTo(drag, cell)
37367
+ {
37368
+ const map = editor3DVoxelMap();
37369
+ if (!map) return;
37370
+ for (const [at, type] of drag.changed.values())
37371
+ map.setVoxel(at, type);
37372
+ drag.changed.clear();
37373
+ const lo = vec3(min(drag.start.x, cell.x), min(drag.start.y, cell.y), min(drag.start.z, cell.z));
37374
+ const hi = vec3(max(drag.start.x, cell.x), max(drag.start.y, cell.y), max(drag.start.z, cell.z));
37375
+ const far = drag.layer + drag.grow * (drag.height - 1), axis = drag.axis, s = map.mapSize;
37376
+ lo[axis] = clamp(min(drag.layer, far), 0, s[axis] - 1);
37377
+ hi[axis] = clamp(max(drag.layer, far), 0, s[axis] - 1);
37378
+ for (let z = lo.z; z <= hi.z; ++z)
37379
+ for (let y = lo.y; y <= hi.y; ++y)
37380
+ for (let x = lo.x; x <= hi.x; ++x)
37381
+ {
37382
+ const at = vec3(x, y, z), type = map.getVoxel(at);
37383
+ editor3DVoxelApply(at, drag.mode) && drag.changed.set(x + s.x * (y + s.y * z), [at, type]);
37384
+ }
37385
+ drag.region = {lo, hi};
37386
+ drag.last = cell;
37387
+ }
37388
+
37389
+ // change the size of the level's map in cells, as one undo: it grows and shrinks about its middle across, and at
37390
+ // its top, and its blocks stay where they are in the world, the ones outside the new size gone
37391
+ function editor3DVoxelResize(size)
37392
+ {
37393
+ const changed = editor3DChangePart('voxels', (voxels)=>
37394
+ {
37395
+ const shape = level3DVoxelsShape(voxels);
37396
+ const to = vec3(floor(size.x), floor(size.y), floor(size.z));
37397
+ if (!shape || to.x < 1 || to.y < 1 || to.z < 1 || to.x * to.y * to.z > LEVEL3D_VOXEL_CELLS) return voxels;
37398
+ const from = shape.size, old = new Uint8Array(from.x * from.y * from.z);
37399
+ level3DVoxelsDecode(voxels.blocks, old);
37400
+ // how far each block moves in cells, so the corner moves the other way and the blocks stay put
37401
+ const dx = floor((to.x - from.x) / 2), dz = floor((to.z - from.z) / 2);
37402
+ const data = new Uint8Array(to.x * to.y * to.z);
37403
+ for (let z = 0; z < from.z; ++z)
37404
+ for (let y = 0; y < min(from.y, to.y); ++y)
37405
+ for (let x = 0; x < from.x; ++x)
37406
+ {
37407
+ const nx = x + dx, nz = z + dz;
37408
+ if (nx >= 0 && nz >= 0 && nx < to.x && nz < to.z)
37409
+ data[nx + to.x * (y + to.y * nz)] = old[x + from.x * (y + from.y * z)];
37410
+ }
37411
+ return {...voxels, pos: [shape.pos.x - dx, shape.pos.y, shape.pos.z - dz], size: [to.x, to.y, to.z],
37412
+ blocks: level3DVoxelsEncode(data)};
37413
+ });
37414
+ editor3DStrokeEnd();
37415
+ return changed;
37416
+ }
37417
+
36116
37418
  // a drag of the Blocks tool goes on: every cell of the layer from the last one to the one under the ray
36117
37419
  function editor3DVoxelDragTo(drag, ray)
36118
37420
  {
36119
37421
  const cell = editor3DVoxelDragCell(drag, ray), last = drag.last;
36120
37422
  if (!cell) return;
37423
+ if (drag.changed)
37424
+ return editor3DVoxelBoxTo(drag, cell);
36121
37425
  const d = cell.subtract(last), steps = max(abs(d.x), abs(d.y), abs(d.z));
36122
37426
  for (let i = 1; i <= steps; ++i)
36123
37427
  editor3DVoxelApply(vec3(round(last.x + d.x * i / steps), round(last.y + d.y * i / steps),
@@ -36144,12 +37448,12 @@ function editor3DVoxelTarget(ray, mode)
36144
37448
  if (!hit)
36145
37449
  {
36146
37450
  const cell = place ? editor3DVoxelDragCell({axis: 'y', plane: 0, layer: 0}, ray) : undefined;
36147
- return cell && {cell, axis: 'y', plane: 0};
37451
+ return cell && {cell, axis: 'y', plane: 0, side: 1};
36148
37452
  }
36149
37453
  const n = hit.normal, axis = n.x ? 'x' : n.y ? 'y' : 'z', cell = place ? hit.cell.add(n) : hit.cell;
36150
37454
  if (!editor3DVoxelInside(map, cell)) return;
36151
- // the face between the block hit and the cell in front of it
36152
- return {cell, axis, plane: cell[axis] + ((n[axis] > 0) === place ? 0 : 1)};
37455
+ // the face between the block hit and the cell in front of it, and side, the way that face looks along the axis
37456
+ return {cell, axis, plane: cell[axis] + ((n[axis] > 0) === place ? 0 : 1), side: n[axis]};
36153
37457
  }
36154
37458
 
36155
37459
  // end the edit being made: one undo, and the autosave
@@ -36223,6 +37527,7 @@ function editor3DSetProperty(object, name, value, defaultValue)
36223
37527
  // add an object of a type at a position, selected; its id, or undefined when the level can not be edited
36224
37528
  function editor3DPlace(type, pos)
36225
37529
  {
37530
+ if (editor3DPrefabSelf(type)) return; // it would hold itself
36226
37531
  let id;
36227
37532
  const placed = editor3DChange((list)=>
36228
37533
  {
@@ -36236,6 +37541,302 @@ function editor3DPlace(type, pos)
36236
37541
  return id;
36237
37542
  }
36238
37543
 
37544
+ ///////////////////////////////////////////////////////////////////////////////
37545
+ // prefabs: a selection made one, an instance made objects again, and a prefab's own settings
37546
+
37547
+ // what the panel says about the last prefab action that was refused
37548
+ let editor3DPrefabMessage = '';
37549
+
37550
+ // the box around an object in the world, turned and sized as it is, as its low and high corners
37551
+ function editor3DWorldBox(object, low=vec3(Infinity), high=vec3(-Infinity))
37552
+ {
37553
+ const matrix = editor3DBoxMatrix(object);
37554
+ for (let i = 8; i--;)
37555
+ {
37556
+ const p = matrix.transformPoint(vec3(i & 1 ? .5 : -.5, i & 2 ? .5 : -.5, i & 4 ? .5 : -.5));
37557
+ for (const k of ['x', 'y', 'z'])
37558
+ low[k] = min(low[k], p[k]), high[k] = max(high[k], p[k]);
37559
+ }
37560
+ return {low, high};
37561
+ }
37562
+
37563
+ // is a type the prefab being edited, or one that holds it: a prefab that is open, or one the game made of the
37564
+ // level being edited itself, as a prefab maker does; placed there it would hold itself
37565
+ function editor3DPrefabSelf(type)
37566
+ {
37567
+ const names = editor3DPrefabStack.map((frame)=> frame.name);
37568
+ for (const [name, prefab] of level3DPrefabs)
37569
+ prefab.source === editor3DLevel && names.push(name);
37570
+ return names.some((name)=> editor3DPrefabHolds(type, name));
37571
+ }
37572
+
37573
+ // the types the Place list has, the plain ones and then the prefabs
37574
+ function editor3DPlaceNames()
37575
+ {
37576
+ const all = [...level3DTypes.keys()].filter((name)=> !editor3DPrefabSelf(name));
37577
+ return [...all.filter((name)=> !level3DPrefabs.has(name)), ...all.filter((name)=> level3DPrefabs.has(name))];
37578
+ }
37579
+
37580
+ // does a type hold a prefab, itself or through the prefabs it holds
37581
+ function editor3DPrefabHolds(type, name, depth=0)
37582
+ {
37583
+ return type === name || depth < 8 &&
37584
+ !!level3DPrefabs.get(type)?.objects.some((o)=> editor3DPrefabHolds(o.type, name, depth + 1));
37585
+ }
37586
+
37587
+ // make the selected objects a prefab of the level's own, about the bottom centre of their box, and put one
37588
+ // instance of it where they were, as one undo; a name the level's prefabs have replaces that prefab, and every
37589
+ // instance of it changes; true, or why not, which the panel shows
37590
+ function editor3DMakePrefab(name='')
37591
+ {
37592
+ const refuse = (why)=> editor3DPrefabMessage = why;
37593
+ const selected = editor3DSelected();
37594
+ if (editor3DPrefabStack.length) return refuse('Go back to the level to make a prefab');
37595
+ if (!selected.length) return refuse('Select the objects to make a prefab of');
37596
+ if (!editor3DLevel || editor3DRecords.get(editor3DLevel)?.pending) return refuse('The level can not be edited now');
37597
+ name = String(name).trim();
37598
+ for (let i = 1; !name; ++i)
37599
+ level3DTypes.has('Prefab ' + i) || (name = 'Prefab ' + i);
37600
+ const known = level3DPrefabs.get(name);
37601
+ if (known ? !known.fromLevel : level3DTypes.has(name))
37602
+ return refuse(name + ' is a type the game added, pick another name');
37603
+ if (selected.some((o)=> editor3DPrefabHolds(o.type, name)))
37604
+ return refuse(name + ' can not hold itself');
37605
+
37606
+ // its origin is the bottom centre of the selection, so an instance stands on the ground
37607
+ const low = vec3(Infinity), high = vec3(-Infinity);
37608
+ for (const object of selected)
37609
+ editor3DWorldBox(object, low, high);
37610
+ const origin = vec3(editor3DRound((low.x + high.x) / 2), editor3DRound(low.y), editor3DRound((low.z + high.z) / 2));
37611
+ const objects = selected.map((object, i)=>
37612
+ {
37613
+ const part = {...editor3DCopy(object), id: i + 1};
37614
+ editor3DSetTransform(part, editor3DPos(object).subtract(origin));
37615
+ return part;
37616
+ });
37617
+ editor3DStrokeEnd();
37618
+ const id = editor3DObjects().reduce((next, o)=> max(next, o.id + 1), 1);
37619
+ const ids = new Set(selected.map((o)=> o.id));
37620
+ // the prefab first, so its type is there when the instance is made
37621
+ editor3DChangePart('prefabs', (prefabs={})=>
37622
+ ({...prefabs, [name]: prefabs[name]?.attached ? {attached: true, objects} : {objects}}));
37623
+ editor3DChange((list)=>
37624
+ {
37625
+ for (let i = list.length; i--;)
37626
+ ids.has(list[i].id) && list.splice(i, 1);
37627
+ const instance = {id, type: name};
37628
+ editor3DSetTransform(instance, origin);
37629
+ list.push(instance);
37630
+ });
37631
+ editor3DStrokeEnd();
37632
+ editor3DSelection.clear();
37633
+ editor3DSelection.add(id);
37634
+ editor3DPrefabMessage = '';
37635
+ return true;
37636
+ }
37637
+
37638
+ // add a prefab from the text of a file the editor saved, a level file, to the level's own prefabs under a name,
37639
+ // as one undo, and pick it to place; a name the level's prefabs have is replaced, as Make prefab does; true, or
37640
+ // why not, which the panel shows
37641
+ function editor3DPrefabLoad(name, text)
37642
+ {
37643
+ const refuse = (why)=> editor3DPrefabMessage = why;
37644
+ if (editor3DPrefabStack.length) return refuse('Go back to the level to load a prefab');
37645
+ if (!editor3DLevel || editor3DRecords.get(editor3DLevel)?.pending) return refuse('The level can not be edited now');
37646
+ name = String(name).trim();
37647
+ if (!name) return refuse('The prefab needs a name');
37648
+ let file;
37649
+ try { file = JSON.parse(text); }
37650
+ catch { return refuse(name + ' is not a JSON file'); }
37651
+ if (!file || typeof file !== 'object' || !isArray(file.objects))
37652
+ return refuse(name + ' is not a prefab, it has no objects');
37653
+ const known = level3DPrefabs.get(name);
37654
+ if (known ? !known.fromLevel : level3DTypes.has(name))
37655
+ return refuse(name + ' is a type the game added, rename the file');
37656
+ const objects = file.objects.filter((o)=> o && typeof o === 'object');
37657
+ if (objects.some((o)=> editor3DPrefabHolds(o.type, name)))
37658
+ return refuse(name + ' can not hold itself');
37659
+ editor3DStrokeEnd();
37660
+ editor3DChangePart('prefabs', (prefabs={})=> ({...prefabs, [name]: file.attached ? {attached: true, objects} : {objects}}));
37661
+ editor3DStrokeEnd();
37662
+ editor3DPickTool(editor3DTool); // a tool of the game's own is put down
37663
+ editor3DBrush = name;
37664
+ editor3DPrefabMessage = '';
37665
+ return true;
37666
+ }
37667
+
37668
+ // pick a prefab's file and load it, by its file name
37669
+ function editor3DPrefabPickFile()
37670
+ {
37671
+ const input = document.createElement('input');
37672
+ input.type = 'file';
37673
+ input.accept = '.json,application/json';
37674
+ input.onchange = async ()=>
37675
+ {
37676
+ const file = input.files?.[0];
37677
+ file && editor3DPrefabLoad(file.name.replace(/\.json$/i, ''), await file.text());
37678
+ };
37679
+ input.click();
37680
+ }
37681
+
37682
+ // turn the selected instances of prefabs into the objects they are made of, where they are, as one undo; false
37683
+ // with none selected
37684
+ function editor3DUnpack()
37685
+ {
37686
+ const instances = editor3DSelected().filter((o)=> editor3DInstances.get(o.id) instanceof Prefab3D);
37687
+ if (!instances.length) return false;
37688
+ editor3DStrokeEnd();
37689
+ const added = [];
37690
+ const changed = editor3DChange((list)=>
37691
+ {
37692
+ let id = list.reduce((next, o)=> max(next, o.id + 1), 1);
37693
+ for (const instance of instances)
37694
+ {
37695
+ const made = editor3DInstances.get(instance.id);
37696
+ // by the level's own place of it, its origin, which is not where an attached one's handle is
37697
+ const origin = {pos3D: editor3DPos(instance), rotation3D: editor3DRotation(instance).scale(PI / 180),
37698
+ scale3D: editor3DScale(instance)};
37699
+ for (const part of level3DPrefabs.get(made.prefabName)?.objects ?? [])
37700
+ {
37701
+ const at = level3DPrefabPartTransform(origin, part), object = {...editor3DCopy(part), id: id++};
37702
+ editor3DSetTransform(object, at.pos, at.rotation.scale(180 / PI), at.scale);
37703
+ list.push(object);
37704
+ added.push(object.id);
37705
+ }
37706
+ list.splice(list.findIndex((o)=> o.id === instance.id), 1);
37707
+ }
37708
+ });
37709
+ editor3DStrokeEnd();
37710
+ if (!changed) return false;
37711
+ editor3DSelection.clear();
37712
+ for (const id of added)
37713
+ editor3DSelection.add(id);
37714
+ return true;
37715
+ }
37716
+
37717
+ // make a prefab of the level's own attached, its parts the children of each instance, or not, as one undo
37718
+ function editor3DPrefabSetAttached(name, attached)
37719
+ {
37720
+ if (editor3DPrefabStack.length) return false; // a prefab is the level's, set from the level
37721
+ editor3DStrokeEnd();
37722
+ const changed = editor3DChangePart('prefabs', (prefabs)=>
37723
+ {
37724
+ if (!prefabs?.[name]) return prefabs;
37725
+ const {attached: was, ...rest} = prefabs[name];
37726
+ return {...prefabs, [name]: attached ? {attached: true, ...rest} : rest};
37727
+ });
37728
+ editor3DStrokeEnd();
37729
+ return changed;
37730
+ }
37731
+
37732
+ // a prefab as its own file has it, a level file of its objects, to load with level3DLoadPrefab
37733
+ function editor3DPrefabJSON(name)
37734
+ {
37735
+ const prefab = level3DPrefabs.get(name);
37736
+ if (!prefab) return '';
37737
+ return editor3DLevelJSON({...(prefab.attached ? {attached: true} : {}), objects: prefab.objects});
37738
+ }
37739
+
37740
+ ///////////////////////////////////////////////////////////////////////////////
37741
+ // Edit prefab: the editor on a prefab alone, the level it came from put away until Back
37742
+
37743
+ // the prefabs opened one inside the other, each with the level it was opened from, its name, the selection there
37744
+ // and the ids of that level's objects that had something made for them
37745
+ /** @type {Array<{level: Object, name: string, selection: Array<number>, made: Set<number>, entered?: string, saved?: string}>} */
37746
+ const editor3DPrefabStack = [];
37747
+ // the levels that are a prefab being edited, they have no autosave of their own
37748
+ const editor3DPrefabLevels = new WeakSet;
37749
+ // what was edited in prefabs of the level's own since the level was left, written into it on the way back
37750
+ /** @type {Object<string, Object>} */
37751
+ let editor3DPrefabEdits = {};
37752
+ // the prefabs of the game's own that were edited and are not in a file yet
37753
+ const editor3DPrefabDirty = new Set;
37754
+
37755
+ // destroy what the level being edited has in the world, its objects' and its maps'
37756
+ function editor3DPrefabClear()
37757
+ {
37758
+ for (const made of editor3DInstances.values())
37759
+ made?.destroy?.();
37760
+ editor3DInstances.clear();
37761
+ for (const map of [level3DVoxelMap, level3DTerrainMap])
37762
+ map && !map.destroyed && map.destroy();
37763
+ }
37764
+
37765
+ // open the prefab of an instance, the selected one when none is given, to edit alone: its objects about its own
37766
+ // origin, with an undo of its own, and the level it is in put away; false when it is not a prefab's instance
37767
+ function editor3DPrefabEnter(id)
37768
+ {
37769
+ const selected = editor3DSelected();
37770
+ id ??= selected.length === 1 ? selected[0].id : undefined;
37771
+ const made = editor3DInstances.get(id), from = editor3DLevel;
37772
+ const prefab = made instanceof Prefab3D && level3DPrefabs.get(made.prefabName);
37773
+ if (!prefab || editor3DRecords.get(from)?.pending) return false;
37774
+ editor3DStrokeEnd();
37775
+ editor3DDrag = editor3DHover = undefined;
37776
+ const frame = {level: from, name: made.prefabName, selection: [...editor3DSelection],
37777
+ made: new Set(editor3DInstances.keys()), entered: undefined};
37778
+ editor3DPrefabStack.push(frame);
37779
+ editor3DPrefabClear();
37780
+ const level = {littlejs3D: LEVEL3D_VERSION, ...(prefab.attached ? {attached: true} : {}),
37781
+ objects: editor3DCopy(prefab.objects)};
37782
+ editor3DPrefabLevels.add(level);
37783
+ level3DLoad(level); // the editor takes it as it does any level
37784
+ frame.entered = JSON.stringify(editor3DObjects()); // as the editor has it, an edit is what differs from this
37785
+ return true;
37786
+ }
37787
+
37788
+ // leave the prefab being edited for the level it was opened from: the prefab is what was edited, every instance
37789
+ // of it is made again, and back in the level itself what was edited in its own prefabs is one undo; false when
37790
+ // no prefab is open
37791
+ function editor3DPrefabBack()
37792
+ {
37793
+ const frame = editor3DPrefabStack.pop(), edited = editor3DLevel;
37794
+ if (!frame) return false;
37795
+ editor3DStrokeEnd();
37796
+ editor3DDrag = editor3DHover = undefined;
37797
+ const known = level3DPrefabs.get(frame.name);
37798
+ const objects = editor3DCopy(editor3DObjects()), changed = JSON.stringify(objects) !== frame.entered;
37799
+ editor3DPrefabClear();
37800
+ if (changed)
37801
+ {
37802
+ // the prefab as it is now, for whatever is made next; one of the game's own waits to be saved to its file
37803
+ const prefab = known?.attached ? {attached: true, objects} : {objects}, fromLevel = !!known?.fromLevel;
37804
+ level3DPrefabSet(frame.name, prefab, fromLevel);
37805
+ if (fromLevel)
37806
+ editor3DPrefabEdits[frame.name] = prefab;
37807
+ else if (JSON.stringify(objects) !== frame.saved)
37808
+ editor3DPrefabDirty.add(frame.name); // not what Save wrote while it was open
37809
+ }
37810
+
37811
+ // the level it was opened from is the editor's again, with its own undo
37812
+ const level = editor3DLevel = frame.level, record = editor3DRecords.get(level);
37813
+ editor3DUndoList = record.undo, editor3DRedoList = record.redo;
37814
+ editor3DStroke = undefined;
37815
+ if (!editor3DPrefabStack.length)
37816
+ {
37817
+ // back in the level: its own prefabs that were edited go into it, as one undo
37818
+ const edits = editor3DPrefabEdits;
37819
+ editor3DPrefabEdits = {};
37820
+ Object.keys(edits).length && editor3DChangePart('prefabs', (prefabs={})=> ({...prefabs, ...edits}));
37821
+ editor3DStrokeEnd();
37822
+ }
37823
+ // what the level had in the world is made again: what a class makes, and what else had something made
37824
+ for (const object of editor3DObjects())
37825
+ {
37826
+ const type = level3DTypes.get(object.type);
37827
+ type && (type.make.prototype || frame.made.has(object.id)) && editor3DMakeInstance(object);
37828
+ }
37829
+ editor3DSceneRestore(record.sceneBase);
37830
+ level3DSceneApply(level.scene);
37831
+ editor3DVoxelShow();
37832
+ editor3DTerrainShow();
37833
+ editor3DShadowLight();
37834
+ editor3DSelection.clear();
37835
+ for (const id of frame.selection)
37836
+ editor3DObject(id) && editor3DSelection.add(id);
37837
+ return true;
37838
+ }
37839
+
36239
37840
  // remove the selected objects, as one undo
36240
37841
  function editor3DDelete()
36241
37842
  {
@@ -36255,6 +37856,9 @@ function editor3DCopySelection()
36255
37856
  const selected = editor3DSelected();
36256
37857
  if (!selected.length) return false;
36257
37858
  editor3DClipboard = editor3DCopy(selected);
37859
+ // how big each was and where its box was about its place: what is cut has nothing left to measure
37860
+ editor3DClipboardBoxes = new Map(editor3DClipboard.map((copy, i)=>
37861
+ [copy, {size: editor3DSize(selected[i]), offset: editor3DBoxOffset(selected[i])}]));
36258
37862
  }
36259
37863
 
36260
37864
  // copy the selected objects and remove them
@@ -36306,11 +37910,17 @@ function editor3DRestore()
36306
37910
  {
36307
37911
  const type = level3DTypes.get(object.type), made = editor3DInstances.get(object.id);
36308
37912
  if (!type) continue;
36309
- if (made instanceof EngineObject3D && !made.destroyed)
37913
+ // a prefab's instance that play took a part of is made again, whole
37914
+ const broken = (p)=> p instanceof Prefab3D && p.parts.some((part)=> part?.destroyed || broken(part));
37915
+ if (made instanceof EngineObject3D && !made.destroyed && !broken(made))
36310
37916
  editor3DPlaceInstance(made, object);
36311
- else if (type.make.prototype && (!made || made.destroyed))
37917
+ else if (type.make.prototype && (!made || made.destroyed || broken(made)))
36312
37918
  editor3DMakeInstance(object);
36313
37919
  }
37920
+ // the blocks and the terrain as the level has them: what play dug, raised or destroyed is not the level's,
37921
+ // and must not be written into it by the next stroke
37922
+ editor3DVoxelShow();
37923
+ editor3DTerrainShow();
36314
37924
  }
36315
37925
 
36316
37926
  // open or close the editor, the session goes on: the game is paused under it and reads no input
@@ -36328,14 +37938,19 @@ function editor3DSetOpen(open)
36328
37938
  setDebugOverlay(false); // out of the way of the level
36329
37939
  editor3DIsOpen = true;
36330
37940
  editor3DRestore();
37941
+ editorCall('onOpen');
37942
+ editorKeyClashes();
36331
37943
  }
36332
37944
  else
36333
37945
  {
36334
37946
  editor3DDrag = editor3DHover = undefined;
36335
37947
  editor3DMouseOnPanel = false; // the panel hides, with no mouseleave
36336
37948
  editor3DStrokeEnd();
37949
+ while (editor3DPrefabBack()); // the game plays the level, not a prefab that was open
37950
+ editor3DToolHeld = false;
36337
37951
  editor3DIsOpen = false;
36338
37952
  setPaused(editor3DGamePaused);
37953
+ editorCall('onClose');
36339
37954
  }
36340
37955
  inputCapture(editor3DViewOn());
36341
37956
  }
@@ -36359,24 +37974,116 @@ function editor3DClose()
36359
37974
  function editor3DPlay(pos)
36360
37975
  {
36361
37976
  editor3DSetOpen(false);
36362
- editor3DPlayFromMouse && pos && levelEditor.onPlayFrom?.(pos.copy());
37977
+ editor3DPlayFromMouse && pos && editorHas('onPlayFrom') && levelEditor.onPlayFrom(pos.copy());
36363
37978
  }
36364
37979
 
36365
37980
  // rebuild the level through the game's hook and play it
36366
37981
  function editor3DRestart()
36367
37982
  {
36368
- if (!levelEditor.onRestart) return;
37983
+ if (!editorHas('onRestart')) return;
36369
37984
  editor3DSetOpen(false);
36370
37985
  levelEditor.onRestart();
36371
37986
  }
36372
37987
 
36373
- // the level editor's open, close and isOpen go to the 3D editor when the game has a 3D level
37988
+ // a key as addKey spells it, as the 3D editor's tables have it, by its position: a letter, a digit, a mark by the
37989
+ // key it is on, or its name
37990
+ const editor3DKeyMarks = {'?': 'Slash', '/': 'Slash', ',': 'Comma', '.': 'Period', ';': 'Semicolon', "'": 'Quote',
37991
+ '[': 'BracketLeft', ']': 'BracketRight', '-': 'Minus', '=': 'Equal', '\\': 'Backslash', '`': 'Backquote'};
37992
+ function editor3DKeyCode(name)
37993
+ {
37994
+ return name.length !== 1 ? name : editor3DKeyMarks[name] ??
37995
+ (name >= '0' && name <= '9' ? 'Digit' + name : 'Key' + name.toUpperCase());
37996
+ }
37997
+
37998
+ // the 3D editor's edit functions, what levelEditor.edit3D gives a game's own keys, buttons and tools
37999
+ const editor3DEdit =
38000
+ {
38001
+ get level() { return editor3DLevel; },
38002
+ get objects() { return editor3DObjects(); },
38003
+ get selection() { return editor3DSelection; },
38004
+ selected() { return editor3DSelected(); },
38005
+ made(id) { return editor3DInstances.get(id); },
38006
+ change(change) { return editor3DChange(change); },
38007
+ changePart(name, change) { return editor3DLevelPartNames.includes(name) && editor3DChangePart(name, change); },
38008
+ strokeEnd() { editor3DStrokeEnd(); },
38009
+ strokeCancel() { editor3DStrokeCancel(); },
38010
+ place(type, pos3D) { return level3DTypes.has(type) ? editor3DPlace(type, pos3D) : undefined; },
38011
+ setTransform(object, pos3D, rotation, scale3D) { editor3DSetTransform(object, pos3D, rotation, scale3D); },
38012
+ setProperty(object, name, value)
38013
+ { editor3DSetProperty(object, name, value, level3DTypes.get(object.type)?.defaults[name]); },
38014
+ pos(object) { return editor3DPos(object); },
38015
+ rotation(object) { return editor3DRotation(object); },
38016
+ scale(object) { return editor3DScale(object); },
38017
+ mousePoint() { return editor3DIsOpen && !editor3DMouseOnPanel ? editor3DWithView(editor3DMousePoint) : undefined; },
38018
+ undo(redo=false) { return editor3DUndo(redo); },
38019
+ toJSON() { return editor3DLevel ? editor3DLevelJSON() : ''; },
38020
+ };
38021
+
38022
+ // the level editor's open, close and isOpen go to the 3D editor when the game has a 3D level, and it has the 3D
38023
+ // editor's keys and edit functions through this
36374
38024
  if (debug)
38025
+ editorOther = {wanted: editor3DWanted, isOpen: ()=> editor3DIsOpen, active: ()=> editor3DIsOpen || editor3DSession,
38026
+ open: editor3DOpen, close: editor3DClose, edit: editor3DEdit,
38027
+ hasKey: (name, ctrl)=> !!(ctrl ? editor3DCtrlKeys : editor3DKeys)[editor3DKeyCode(name)]};
38028
+
38029
+ // pick a tool of the editor's own, which puts a tool of the game's down
38030
+ function editor3DPickTool(tool)
38031
+ {
38032
+ editor3DTool = tool;
38033
+ levelEditor.tool = undefined;
38034
+ }
38035
+
38036
+ // a tool of the game's own: if its press is held, and the editor's tool and type to place when it was turned on,
38037
+ // since picking one of those puts the game's tool down
38038
+ let editor3DToolHeld = false, editor3DToolName, editor3DToolPicked = [];
38039
+
38040
+ // what a tool's callbacks are given: where the mouse is on the level or the ground, its ray, and the keys held
38041
+ function editor3DToolAt(ray=render3D.screenToRay(mousePosScreen), shift=false, ctrl=false)
38042
+ { return {pos: editor3DMouseOnPanel ? undefined : editor3DSurface(ray), cell: undefined, ray, shift, ctrl}; }
38043
+
38044
+ // the game's tool, when one is on: the left button is its, a press, a drag and a release one undo, the right
38045
+ // button takes a held press back; true when it has the mouse
38046
+ function editor3DToolUpdate(ray, idle, shift, ctrl)
38047
+ {
38048
+ const picked = [editor3DTool, editor3DBrush];
38049
+ if (levelEditor.tool !== editor3DToolName)
38050
+ editor3DToolName = levelEditor.tool, editor3DToolPicked = picked; // turned on, or off, by its key or button
38051
+ else if (picked.some((value, i)=> value !== editor3DToolPicked[i]))
38052
+ levelEditor.tool = editor3DToolName = undefined; // a tool or a type of the editor's was picked
38053
+ const tool = editorGameTool();
38054
+ if (!tool)
38055
+ {
38056
+ editor3DToolHeld = false;
38057
+ return false;
38058
+ }
38059
+ const at = editor3DToolAt(ray, shift, ctrl);
38060
+ editor3DHover = editor3DBlockHover = editor3DTerrainHover = undefined; // the editor's own cursors are put away
38061
+ if (editor3DToolHeld && mouseWasPressed(2))
38062
+ {
38063
+ editor3DStrokeCancel();
38064
+ editor3DToolHeld = false;
38065
+ }
38066
+ else if (idle && mouseWasPressed(0))
38067
+ editor3DToolHeld = tool.onPress?.(at) !== false;
38068
+ else if (editor3DToolHeld && mouseIsDown(0))
38069
+ tool.onDrag?.(at);
38070
+ if (editor3DToolHeld && !mouseIsDown(0))
38071
+ {
38072
+ tool.onRelease?.(at);
38073
+ editor3DStrokeEnd();
38074
+ editor3DToolHeld = false;
38075
+ }
38076
+ return true;
38077
+ }
38078
+
38079
+ // the game's own drawing in the level, in the editor's 3D pass: the editor's onDraw, and its tool's while one is on
38080
+ function editor3DDrawGame()
36375
38081
  {
36376
- const open2D = levelEditor.open.bind(levelEditor), close2D = levelEditor.close.bind(levelEditor);
36377
- levelEditor.open = ()=> editor3DWanted() ? editor3DOpen() : open2D();
36378
- levelEditor.close = ()=> editor3DIsOpen || editor3DSession ? editor3DClose() : close2D();
36379
- Object.defineProperty(levelEditor, 'isOpen', {get: ()=> editor3DIsOpen || editorIsOpen});
38082
+ if (!editor3DIsOpen) return;
38083
+ editorCall('onDraw');
38084
+ const held = (...codes)=> inputCaptureRead(()=> codes.some((code)=> keyIsDown(code)));
38085
+ editorGameTool()?.onDraw?.(editor3DToolAt(undefined, held('ShiftLeft', 'ShiftRight'),
38086
+ held('ControlLeft', 'ControlRight', 'MetaLeft', 'MetaRight')));
36380
38087
  }
36381
38088
 
36382
38089
  ///////////////////////////////////////////////////////////////////////////////
@@ -36413,9 +38120,25 @@ const editor3DSaves = ()=> readSaveData(editor3DSaveName(), {});
36413
38120
  let editor3DSaveFailed = false;
36414
38121
 
36415
38122
  // remember the level's objects and parts, or forget them when they are back to the file
36416
- function editor3DAutosave(level=editor3DLevel)
38123
+ function editor3DAutosave(level=editor3DLevel, known)
36417
38124
  {
36418
- const record = editor3DRecords.get(level);
38125
+ // a prefab being edited is kept with the level it is in, as that level would be on going back: its own
38126
+ // prefabs with what is open, and what was edited on the way, in them
38127
+ if (editor3DPrefabLevels.has(level))
38128
+ {
38129
+ const root = editor3DPrefabStack[0]?.level, edits = {...editor3DPrefabEdits};
38130
+ editor3DPrefabStack.forEach((frame, i)=>
38131
+ {
38132
+ const open = editor3DPrefabStack[i + 1]?.level ?? editor3DLevel, known = level3DPrefabs.get(frame.name);
38133
+ const objects = isArray(open.objects) ? editor3DCopy(open.objects) : [];
38134
+ if (known?.fromLevel && JSON.stringify(objects) !== frame.entered)
38135
+ edits[frame.name] = known.attached ? {attached: true, objects} : {objects};
38136
+ });
38137
+ if (root && Object.keys(edits).length)
38138
+ editor3DAutosave({...root, prefabs: {...editor3DLevelPart('prefabs', root), ...edits}}, editor3DRecords.get(root));
38139
+ return;
38140
+ }
38141
+ const record = known ?? editor3DRecords.get(level);
36419
38142
  if (!record || record.pending) return; // edits waiting to be applied keep their autosave
36420
38143
  const saves = editor3DSaves(), objects = isArray(level.objects) ? level.objects : [];
36421
38144
  const parts = editor3DLevelParts(level);
@@ -36482,11 +38205,36 @@ async function editor3DSave(pickAgain=false)
36482
38205
  const level = editor3DLevel, record = editor3DRecords.get(level);
36483
38206
  if (!record) return;
36484
38207
  editor3DStrokeEnd();
38208
+ const open = editor3DPrefabStack[editor3DPrefabStack.length - 1];
38209
+ if (open)
38210
+ {
38211
+ // inside a prefab, Save writes the prefab as a file of its own, or the game keeps it itself
38212
+ const text = editor3DLevelJSON(level), name = open.name + '.json';
38213
+ const written = JSON.stringify(editor3DObjects()); // as the file has it
38214
+ const saved = (async ()=>
38215
+ {
38216
+ await record.saving; // in the order asked for, as a level's saves are
38217
+ const kept = await editorCall('onSave', text, name) === true;
38218
+ kept || saveText(text, name, 'application/json');
38219
+ editor3DPrefabDirty.delete(open.name);
38220
+ open.saved = written;
38221
+ return kept ? 'kept' : 'downloaded';
38222
+ })();
38223
+ record.saving = saved.catch(()=> {});
38224
+ return saved;
38225
+ }
36485
38226
  // what is written, kept as it is now: the level can change while the file is picked and written, and those
36486
38227
  // edits are not in the file, so they stay in the autosave; the level and record are this one's, whichever is
36487
38228
  // open by then
36488
38229
  const text = editor3DLevelJSON(level);
36489
- const saved = (async ()=> { await record.saving; return editor3DSaveText(level, record, text, pickAgain); })();
38230
+ // its place in line is taken now, and the game's hook is asked when its turn comes, so saves are written in
38231
+ // the order they were asked for however long a hook takes
38232
+ const saved = (async ()=>
38233
+ {
38234
+ await record.saving;
38235
+ if (await editorCall('onSave', text, record.fileName) === true) return 'kept'; // the game kept it itself
38236
+ return editor3DSaveText(level, record, text, pickAgain);
38237
+ })();
36490
38238
  record.saving = saved.catch(()=> {});
36491
38239
  return saved;
36492
38240
  }
@@ -36546,6 +38294,8 @@ const EDITOR3D_AXES = {x: vec3(1, 0, 0), y: vec3(0, 1, 0), z: vec3(0, 0, 1)};
36546
38294
 
36547
38295
  let editor3DTool = 'move'; // 'select', 'move', 'rotate', 'scale', 'blocks' or 'terrain'
36548
38296
  let editor3DBlockType = 1; // the block type the Blocks tool places
38297
+ let editor3DBlockBox = false; // the Blocks tool fills a box: the rectangle dragged, editor3DBlockBoxHeight tall
38298
+ let editor3DBlockBoxHeight = 1;
36549
38299
  let editor3DSeconds = 0; // how long the last step was, for a brush held down
36550
38300
  // where the mouse is on the terrain, for the Terrain tool's brush
36551
38301
  /** @type {Vector3|undefined} */
@@ -36555,6 +38305,7 @@ let editor3DTerrainHover;
36555
38305
  let editor3DBlockHover;
36556
38306
  let editor3DGrid = true; // moves, turns and sizes go in steps, Ctrl flips it for a drag
36557
38307
  let editor3DGroundSnap = true; // a body drag slides along what is under the mouse
38308
+ let editor3DLocalAxes = false; // the Move handles follow the object's own axes, not the world's
36558
38309
  let editor3DMoveStep = 1, editor3DRotateStep = 15, editor3DScaleStep = .25; // the rotate step in degrees
36559
38310
  let editor3DHelp = false; // the keys are shown
36560
38311
  let editor3DMouseOnPanel = false;
@@ -36582,7 +38333,7 @@ function editor3DSelectionCenter()
36582
38333
  function editor3DHandles()
36583
38334
  {
36584
38335
  const center = editor3DSelectionCenter(), tool = editor3DTool, handles = [];
36585
- if (!center || tool === 'select' || tool === 'blocks' || tool === 'terrain') return handles;
38336
+ if (!center || tool === 'select' || tool === 'blocks' || tool === 'terrain' || editorGameTool()) return handles;
36586
38337
  const length = editor3DScreenScale(center) * EDITOR3D_HANDLE_SIZE;
36587
38338
  const rotation = editor3DRotation(editor3DSelected()[0]).scale(PI / 180), frame = buildMatrix(vec3(), rotation);
36588
38339
  for (const axis of 'xyz')
@@ -36599,16 +38350,19 @@ function editor3DHandles()
36599
38350
  continue;
36600
38351
  }
36601
38352
 
36602
- // an arrow or scale handle starts a little out from the middle, which is left for the object's body
36603
- const direction = tool === 'move' ? EDITOR3D_AXES[axis] : frame.transformDirection(EDITOR3D_AXES[axis]);
36604
- handles.push({kind: tool === 'move' ? 'arrow' : 'scale', axis, center, length, direction,
38353
+ // an arrow or scale handle starts a little out from the middle, which is left for the object's body; Move
38354
+ // goes along the world's axes, or with own axes on along the object's, as Scale always does
38355
+ const local = tool !== 'move' || editor3DLocalAxes;
38356
+ const way = (k)=> local ? frame.transformDirection(EDITOR3D_AXES[k]) : EDITOR3D_AXES[k];
38357
+ const direction = way(axis), turn = tool === 'move' && local ? rotation : undefined;
38358
+ handles.push({kind: tool === 'move' ? 'arrow' : 'scale', axis, center, length, direction, turn,
36605
38359
  points: [center.add(direction.scale(length * .2)), center.add(direction.scale(length))]});
36606
38360
  if (tool === 'move')
36607
38361
  {
36608
- // the square between the other two axes moves along both
38362
+ // the square between the other two axes moves along both; across is the axis it does not move along
36609
38363
  const [a, b] = 'xyz'.replace(axis, '');
36610
- handles.push({kind: 'plane', axis: a + b, center, length, direction,
36611
- points: [center.add(EDITOR3D_AXES[a].add(EDITOR3D_AXES[b]).scale(length * .35))]});
38364
+ handles.push({kind: 'plane', axis: a + b, center, length, direction, turn, across: axis,
38365
+ ways: [way(a), way(b)], points: [center.add(way(a).add(way(b)).scale(length * .35))]});
36612
38366
  }
36613
38367
  }
36614
38368
  tool === 'scale' && handles.push({kind: 'scaleAll', axis: 'xyz', center, length, points: [center]});
@@ -36675,6 +38429,10 @@ function editor3DParts(made, parts=[])
36675
38429
  parts.push(made);
36676
38430
  for (const child of made.children)
36677
38431
  editor3DParts(child, parts);
38432
+ // a prefab's instance and its parts are one thing, the ones that are not its children too
38433
+ if (made instanceof Prefab3D)
38434
+ for (const part of made.parts)
38435
+ part?.parent === made || editor3DParts(part, parts);
36678
38436
  }
36679
38437
  return parts;
36680
38438
  }
@@ -36748,10 +38506,13 @@ function editor3DPlaceAt(type, ray)
36748
38506
  editor3DStrokeEnd();
36749
38507
  const id = editor3DPlace(type, point);
36750
38508
  if (id === undefined) return;
36751
- const size = editor3DSize(editor3DObject(id)), ignore = new Set(editor3DParts(editor3DInstances.get(id)));
38509
+ const object = editor3DObject(id), size = editor3DSize(object), offset = editor3DBoxOffset(object);
38510
+ const ignore = new Set(editor3DParts(editor3DInstances.get(id)));
36752
38511
  const snapped = editor3DSnapPos(editor3DRest(point, size), size, editor3DGrid ? editor3DMoveStep : 0);
36753
38512
  const y = editor3DLand(snapped.x, snapped.z, point.y, size, ignore);
36754
- editor3DChange((list)=> editor3DSetTransform(list.find((o)=> o.id === id), vec3(snapped.x, y, snapped.z)));
38513
+ // the box stands there, and the object's place is where that puts it
38514
+ const to = vec3(snapped.x, y, snapped.z).subtract(offset);
38515
+ editor3DChange((list)=> editor3DSetTransform(list.find((o)=> o.id === id), to));
36755
38516
  editor3DStrokeEnd();
36756
38517
  }
36757
38518
 
@@ -36761,7 +38522,7 @@ function editor3DDragTo(drag, mouse, ray, snap)
36761
38522
  if (drag.kind === 'blocks')
36762
38523
  return editor3DVoxelDragTo(drag, ray);
36763
38524
  if (drag.kind === 'terrain')
36764
- return editor3DTerrainHover && editor3DTerrainSculpt(editor3DTerrainHover, drag.mode, editor3DSeconds);
38525
+ return editor3DTerrainHover && editor3DTerrainDragTo(drag, editor3DTerrainHover);
36765
38526
  if (drag.kind === 'box')
36766
38527
  {
36767
38528
  drag.to = mouse.copy();
@@ -36772,18 +38533,23 @@ function editor3DDragTo(drag, mouse, ray, snap)
36772
38533
  const was = first && start.get(first.id);
36773
38534
  if (!was) return;
36774
38535
  const size = editor3DSize(first), turned = editor3DTurned(first), step = snap ? editor3DMoveStep : 0;
36775
- const snapTo = (to)=> editor3DSnapPos(to, size, step, turned).subtract(was.pos);
38536
+ const offset = editor3DBoxOffset(first); // snapping and landing go by its box
38537
+ const snapTo = (to)=> editor3DSnapPos(to.add(offset), size, step, turned).subtract(offset).subtract(was.pos);
36776
38538
  let move, turn, factor;
36777
38539
  if (kind === 'arrow')
36778
38540
  {
36779
38541
  const d = editor3DAxisDistance(ray, handle.center, handle.direction) - grab;
36780
- move = handle.direction.multiply(snapTo(was.pos.add(handle.direction.scale(d)))); // along its axis alone
38542
+ // an axis of its own does not follow the grid, so the move goes in steps from where it started
38543
+ move = handle.turn ? handle.direction.scale(step ? round(d / step) * step : d) :
38544
+ handle.direction.multiply(snapTo(was.pos.add(handle.direction.scale(d)))); // along its axis alone
36781
38545
  }
36782
38546
  else if (kind === 'plane')
36783
38547
  {
36784
38548
  const point = editor3DPlanePoint(ray, handle.center, handle.direction);
36785
38549
  if (!point || !grab) return;
36786
- move = vec3(1).subtract(handle.direction).multiply(snapTo(was.pos.add(point.subtract(grab))));
38550
+ const delta = point.subtract(grab), along = (w)=> step ? round(delta.dot(w) / step) * step : delta.dot(w);
38551
+ move = handle.turn ? handle.ways[0].scale(along(handle.ways[0])).add(handle.ways[1].scale(along(handle.ways[1]))) :
38552
+ vec3(1).subtract(handle.direction).multiply(snapTo(was.pos.add(delta)));
36787
38553
  }
36788
38554
  else if (kind === 'body')
36789
38555
  {
@@ -36791,8 +38557,8 @@ function editor3DDragTo(drag, mouse, ray, snap)
36791
38557
  const point = editor3DPlanePoint(ray, drag.level, drag.normal);
36792
38558
  if (!point) return;
36793
38559
  const across = snapTo(was.pos.add(point.subtract(drag.level)));
36794
- const y = editor3DGroundSnap ? editor3DLand(was.pos.x + across.x, was.pos.z + across.z,
36795
- was.pos.y - size.y / 2, size, drag.ignore) : was.pos.y;
38560
+ const y = editor3DGroundSnap ? editor3DLand(was.pos.x + offset.x + across.x, was.pos.z + offset.z + across.z,
38561
+ was.pos.y + offset.y - size.y / 2, size, drag.ignore) - offset.y : was.pos.y;
36796
38562
  move = vec3(across.x, y - was.pos.y, across.z);
36797
38563
  }
36798
38564
  else if (kind === 'ring')
@@ -36893,9 +38659,9 @@ function editor3DDropSelection()
36893
38659
  {
36894
38660
  for (const object of list)
36895
38661
  {
36896
- const to = editor3DSelection.has(object.id) &&
36897
- editor3DDrop(editor3DPos(object), editor3DSize(object), ignore);
36898
- to && editor3DSetTransform(object, to);
38662
+ const offset = editor3DBoxOffset(object), to = editor3DSelection.has(object.id) &&
38663
+ editor3DDrop(editor3DPos(object).add(offset), editor3DSize(object), ignore);
38664
+ to && editor3DSetTransform(object, to.subtract(offset));
36899
38665
  }
36900
38666
  });
36901
38667
  editor3DStrokeEnd();
@@ -36909,11 +38675,12 @@ function editor3DPasteAtMouse()
36909
38675
  if (!copied?.length) return false;
36910
38676
  if (!point) return editor3DPaste();
36911
38677
  const center = copied.reduce((sum, o)=> sum.add(editor3DPos(o)), vec3()).scale(1 / copied.length);
36912
- const bottom = copied.reduce((low, o)=> min(low, editor3DPos(o).y - editor3DSize(o).y / 2), Infinity);
36913
- const first = copied[0], was = editor3DPos(first);
36914
- const snapped = editor3DSnapPos(was.add(vec3(point.x - center.x, 0, point.z - center.z)), editor3DSize(first),
38678
+ const box = (o)=> editor3DClipboardBoxes.get(o) ?? {size: editor3DSize(o), offset: editor3DBoxOffset(o)};
38679
+ const bottom = copied.reduce((low, o)=> min(low, editor3DPos(o).y + box(o).offset.y - box(o).size.y / 2), Infinity);
38680
+ const first = copied[0], size = box(first).size, was = editor3DPos(first).add(box(first).offset); // its box's middle
38681
+ const snapped = editor3DSnapPos(was.add(vec3(point.x - center.x, 0, point.z - center.z)), size,
36915
38682
  editor3DGrid ? editor3DMoveStep : 0, editor3DTurned(first));
36916
- const land = editor3DLand(snapped.x, snapped.z, point.y, editor3DSize(first)) - editor3DSize(first).y / 2;
38683
+ const land = editor3DLand(snapped.x, snapped.z, point.y, size) - size.y / 2;
36917
38684
  return editor3DPaste(vec3(snapped.x - was.x, land - bottom, snapped.z - was.z));
36918
38685
  }
36919
38686
 
@@ -36922,18 +38689,22 @@ function editor3DPasteAtMouse()
36922
38689
  /** @type {Object<string, function(boolean=): any>} */
36923
38690
  const editor3DKeys =
36924
38691
  {
36925
- KeyQ: ()=> { editor3DTool = 'select'; editor3DBrush = undefined; },
36926
- KeyW: ()=> { editor3DTool = 'move'; },
36927
- KeyE: ()=> { editor3DTool = 'rotate'; },
36928
- KeyR: ()=> { editor3DTool = 'scale'; },
36929
- KeyB: ()=> { editor3DTool = 'blocks'; editor3DBrush = undefined; },
36930
- KeyT: ()=> { editor3DTool = 'terrain'; editor3DBrush = undefined; },
38692
+ KeyQ: ()=> { editor3DPickTool('select'); editor3DBrush = undefined; },
38693
+ KeyW: ()=> { editor3DPickTool('move'); },
38694
+ KeyE: ()=> { editor3DPickTool('rotate'); },
38695
+ KeyR: ()=> { editor3DPickTool('scale'); },
38696
+ KeyB: ()=> { editor3DPickTool('blocks'); editor3DBrush = undefined; },
38697
+ KeyT: ()=> { editor3DPickTool('terrain'); editor3DBrush = undefined; },
36931
38698
  KeyP: ()=> editor3DTool === 'blocks' && editor3DVoxelPick(render3D.screenToRay(mousePosScreen)),
38699
+ KeyX: ()=> editor3DTool === 'terrain' ? editor3DTerrainNextBrush() :
38700
+ editor3DTool === 'blocks' && (editor3DBlockBox = !editor3DBlockBox),
36932
38701
  KeyG: ()=> { editor3DGrid = !editor3DGrid; },
38702
+ KeyL: ()=> { editor3DLocalAxes = !editor3DLocalAxes; },
36933
38703
  KeyF: ()=> editor3DFrame(),
36934
38704
  End: ()=> editor3DDropSelection(),
36935
38705
  Delete: ()=> editor3DDelete(),
36936
- Backspace: ()=> editor3DDelete(),
38706
+ Backspace: ()=> editor3DSelection.size ? editor3DDelete() : editor3DPrefabBack(),
38707
+ Enter: ()=> editor3DPrefabEnter(),
36937
38708
  Slash: ()=> { editor3DHelp = !editor3DHelp; },
36938
38709
  };
36939
38710
  /** @type {Object<string, function(boolean=): any>} */
@@ -36945,6 +38716,7 @@ const editor3DCtrlKeys =
36945
38716
  KeyX: ()=> editor3DCut(),
36946
38717
  KeyV: ()=> editor3DPasteAtMouse(),
36947
38718
  KeyD: ()=> editor3DDuplicate(),
38719
+ KeyG: (shift)=> shift ? editor3DUnpack() : editor3DMakePrefab() === true,
36948
38720
  };
36949
38721
 
36950
38722
  // the camera: the right button looks and the keys fly while it is held, the middle button or Space and the left
@@ -36997,7 +38769,13 @@ function editor3DEditorUpdate(seconds)
36997
38769
  if (debugKey && keyWasPressed(debugKey))
36998
38770
  {
36999
38771
  inputClearKey(debugKey);
37000
- editor3DDrag ? editor3DDragCancel() : editor3DPlay(editor3DMousePoint());
38772
+ if (editor3DToolHeld)
38773
+ {
38774
+ editor3DStrokeCancel(); // a held press of the game's tool is taken back
38775
+ editor3DToolHeld = false;
38776
+ }
38777
+ else
38778
+ editor3DDrag ? editor3DDragCancel() : editor3DPlay(editor3DMousePoint());
37001
38779
  return;
37002
38780
  }
37003
38781
 
@@ -37013,9 +38791,20 @@ function editor3DEditorUpdate(seconds)
37013
38791
  const looking = mouseIsDown(2), cameraDrag = looking || mouseIsDown(1) || keyIsDown('Space') || alt;
37014
38792
 
37015
38793
  // the keys, which wait for a drag to end, and fly while the right button is held
37016
- if (!editor3DDrag && !looking)
38794
+ if (!editor3DDrag && !looking && !editor3DToolHeld)
38795
+ {
38796
+ // the game's own keys first, each takes the place of the editor's key of the same spelling
38797
+ const taken = new Set;
38798
+ for (const [spelled, key] of Object.entries(levelEditor.keys))
38799
+ {
38800
+ const withCtrl = spelled.startsWith('ctrl+'), code = editor3DKeyCode(spelled.slice(withCtrl ? 5 : 0));
38801
+ if (withCtrl !== ctrl) continue;
38802
+ taken.add(code);
38803
+ keyWasPressed(code) && key.action.call(levelEditor, shift);
38804
+ }
37017
38805
  for (const [code, action] of Object.entries(ctrl ? editor3DCtrlKeys : editor3DKeys))
37018
- keyWasPressed(code) && action(shift);
38806
+ taken.has(code) || keyWasPressed(code) && action(shift);
38807
+ }
37019
38808
 
37020
38809
  // a right press during a drag puts it back
37021
38810
  if (editor3DDrag && mouseWasPressed(2))
@@ -37023,22 +38812,24 @@ function editor3DEditorUpdate(seconds)
37023
38812
 
37024
38813
  const ray = render3D.screenToRay(mouse), idle = !cameraDrag && !editor3DMouseOnPanel && !editor3DDrag;
37025
38814
  editor3DHover = idle ? editor3DHandleAt(mouse) : undefined;
38815
+ editorCall('onUpdate');
38816
+ if (editor3DToolUpdate(ray, idle, shift, ctrl)) return; // a tool of the game's own has the mouse
37026
38817
 
37027
38818
  // the Blocks tool takes the mouse while the level has a map: Shift removes, Ctrl repaints
37028
- const painting = editor3DTool === 'blocks' && !!editor3DVoxelMap();
38819
+ // a type picked to place is placed, whichever tool is on
38820
+ const painting = editor3DTool === 'blocks' && !editor3DBrush && !!editor3DVoxelMap();
37029
38821
  const mode = shift ? 'remove' : ctrl ? 'paint' : 'place';
37030
38822
  const target = painting && idle ? editor3DVoxelTarget(ray, mode) : undefined;
37031
38823
  editor3DBlockHover = target && {cell: target.cell, mode};
37032
- // the Terrain tool takes it while the level has a terrain: held down it raises the ground, Shift lowers,
37033
- // Ctrl smooths
37034
- const terrain = editor3DTool === 'terrain' ? editor3DTerrainMap() : undefined;
38824
+ // the Terrain tool takes it while the level has a terrain: held down its brush works on the ground
38825
+ const terrain = editor3DTool === 'terrain' && !editor3DBrush ? editor3DTerrainMap() : undefined;
37035
38826
  const reach = terrain && !cameraDrag && !editor3DMouseOnPanel ? terrain.raycast(ray) : undefined;
37036
38827
  editor3DTerrainHover = reach === undefined ? undefined : ray.getPosition(reach);
37037
38828
  if (painting)
37038
38829
  idle && mouseWasPressed(0) && (editor3DDrag = editor3DVoxelPress(ray, mode));
37039
38830
  else if (terrain)
37040
38831
  idle && mouseWasPressed(0) && editor3DTerrainHover &&
37041
- (editor3DDrag = {kind: 'terrain', mode: shift ? 'lower' : ctrl ? 'smooth' : 'raise'});
38832
+ (editor3DDrag = editor3DTerrainPress(editor3DTerrainHover, shift, ctrl));
37042
38833
  else if (idle && mouseWasPressed(0))
37043
38834
  editor3DPress(mouse, ray, shift);
37044
38835
 
@@ -37052,7 +38843,7 @@ function editor3DEditorUpdate(seconds)
37052
38843
  if (debug && globalThis.document?.addEventListener)
37053
38844
  document.addEventListener('keydown', (e)=>
37054
38845
  {
37055
- if (editor3DIsOpen && (e.ctrlKey || e.metaKey) && /^Key[ZYCXVD]$/.test(e.code) && !editorIsTextField(e.target))
38846
+ if (editor3DIsOpen && (e.ctrlKey || e.metaKey) && /^Key[ZYCXVDG]$/.test(e.code) && !editorIsTextField(e.target))
37056
38847
  e.preventDefault();
37057
38848
  });
37058
38849
 
@@ -37064,13 +38855,16 @@ const editor3DHelpLines =
37064
38855
  [
37065
38856
  'Left: select · Shift+Left: add or take away · Left drag from empty space: box select',
37066
38857
  'Q select · W move · E rotate · R scale: drag a handle, or the object itself',
37067
- 'G: grid snap, Ctrl flips it for a drag · End: drop to the ground',
38858
+ 'G: grid snap, Ctrl flips it for a drag · L: move along its own axes · End: drop to the ground',
37068
38859
  'Right button: look, and WASD and QE fly while it is held, Shift faster',
37069
38860
  'Wheel: zoom · Middle drag or Space+drag: pan · Alt+drag: orbit · F: frame the selection',
37070
38861
  'Pick a type, then click to place it, Shift+click keeps placing',
37071
38862
  'B blocks: click or drag places · Shift removes · Ctrl repaints · P picks the type under the mouse',
37072
- 'T terrain: hold to raise the ground · Shift lowers · Ctrl smooths · the brush is in the panel',
38863
+ 'X box fill: a drag fills the rectangle dragged, as tall as the height in the panel',
38864
+ 'T terrain: hold to raise the ground · Shift lowers · Ctrl smooths · X the next brush, flatten and paint',
37073
38865
  'Delete · Ctrl+C / X / V: copy, cut, paste · Ctrl+D: duplicate · Ctrl+Z / Y: undo, redo',
38866
+ 'Ctrl+G: make the selection a prefab, one thing to place many times · Ctrl+Shift+G: unpack it into its objects',
38867
+ 'Enter: open the selected prefab to edit it alone, every instance follows · Backspace with nothing selected: back',
37074
38868
  'Reset to file: the level as its file has it, Restart keeps your edits, Undo brings them back',
37075
38869
  'Esc: play and edit · 0: exit the editor · ?: keys',
37076
38870
  ];
@@ -37080,6 +38874,7 @@ function editor3DHint()
37080
38874
  {
37081
38875
  if (editor3DRecords.get(editor3DLevel)?.pending)
37082
38876
  return 'The level file changed: apply your edits or drop them';
38877
+ if (editorGameTool()) return editorGameTool().hint || levelEditor.tool;
37083
38878
  if (editor3DDrag)
37084
38879
  return editor3DDrag.kind === 'box' ? 'Let go to select what is inside' :
37085
38880
  'Esc or right click puts it back · Ctrl flips the snap';
@@ -37088,10 +38883,14 @@ function editor3DHint()
37088
38883
  if (editor3DBrush)
37089
38884
  return `Click to place a ${editor3DBrush} · Shift keeps placing · Q puts it down`;
37090
38885
  if (editor3DTool === 'terrain')
37091
- return editor3DTerrainMap() ? 'Hold to raise the ground · Shift lowers · Ctrl smooths' :
37092
- 'Add a terrain in the panel to sculpt it';
38886
+ return !editor3DTerrainMap() ? 'Add a terrain in the panel to sculpt it' :
38887
+ {sculpt: 'Hold to raise the ground · Shift lowers · Ctrl smooths',
38888
+ flatten: 'Hold to flatten the ground to the height pressed · Ctrl smooths',
38889
+ paint: 'Hold to paint the ground · Shift takes the paint off'}[editor3DTerrainBrush.mode] +
38890
+ ' · X the next brush';
37093
38891
  if (editor3DTool === 'blocks')
37094
- return editor3DVoxelMap() ? 'Click or drag places · Shift removes · Ctrl repaints · P picks a type' :
38892
+ return editor3DVoxelMap() ? (editor3DBlockBox ? 'Drag a box to fill it' : 'Click or drag places') +
38893
+ ' · Shift removes · Ctrl repaints · P picks a type · X box' :
37095
38894
  'Add a block map in the panel to paint blocks';
37096
38895
  if (editor3DSelection.size)
37097
38896
  return 'Drag a handle or the object · W move · E rotate · R scale · End drops it';
@@ -37165,7 +38964,8 @@ function editor3DDrawRing(center, across, along, radius, color, pixels=2)
37165
38964
  function editor3DVisible(object)
37166
38965
  {
37167
38966
  const made = editor3DInstances.get(object.id);
37168
- return made instanceof EngineObject3D && !made.destroyed && (made.mesh || made.tileInfo) ? made : undefined;
38967
+ return made instanceof EngineObject3D && !made.destroyed && (made.mesh || made.tileInfo || editor3DPrefabBox(made)) ?
38968
+ made : undefined;
37169
38969
  }
37170
38970
 
37171
38971
  // 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
@@ -37175,6 +38975,9 @@ function editor3DBoxMatrix(object)
37175
38975
  const made = editor3DVisible(object), bounds = made?.mesh && (made.mesh.bounds || made.mesh.getBounds());
37176
38976
  if (!made)
37177
38977
  return buildMatrix(editor3DPos(object));
38978
+ const box = editor3DPrefabBox(made);
38979
+ if (box)
38980
+ return buildMatrix(box.pos, undefined, box.size);
37178
38981
  const matrix = render3DObjectMatrix(made).copy();
37179
38982
  return bounds ? matrix.multiply(buildMatrix(bounds.min.add(bounds.max).scale(.5), undefined,
37180
38983
  bounds.max.subtract(bounds.min))) : matrix.multiply(buildMatrix(vec3(), undefined, made.size3D));
@@ -37238,6 +39041,15 @@ function editor3DDraw()
37238
39041
  editor3DDrawRing(editor3DTerrainHover.add(vec3(0, .05, 0)), EDITOR3D_AXES.x, EDITOR3D_AXES.z,
37239
39042
  editor3DTerrainBrush.size / 2, hsl(0, 0, 1, .9), 2.5);
37240
39043
 
39044
+ // the box a box drag fills
39045
+ const region = map && editor3DDrag?.region;
39046
+ if (region)
39047
+ {
39048
+ const size = region.hi.subtract(region.lo).add(vec3(1));
39049
+ editor3DDrawWire(buildMatrix(map.pos3D.add(region.lo).add(size.scale(.5)), undefined, size.add(vec3(.02))),
39050
+ editor3DDrag.mode === 'place' ? hsl(0, 0, 1, .9) : editor3DDrag.mode === 'remove' ? hsl(0, 1, .6) :
39051
+ hsl(.15, 1, .6), 2.5);
39052
+ }
37241
39053
  // the map's edges, to see where it ends
37242
39054
  if (map)
37243
39055
  editor3DDrawWire(buildMatrix(map.pos3D.add(map.mapSize.scale(.5)), undefined, map.mapSize),
@@ -37264,9 +39076,8 @@ function editor3DDraw()
37264
39076
  else if (kind === 'plane')
37265
39077
  {
37266
39078
  // a flat square across its two axes, in the color of the axis it does not move along
37267
- const size = vec3(tip * 1.6).subtract(handle.direction.scale(tip * 1.5));
37268
- r.drawBox(points[0], size, lit ? color : EDITOR3D_AXIS_COLORS['xyz'.replace(axis[0], '')
37269
- .replace(axis[1], '')].scale(1, .7));
39079
+ const size = vec3(tip * 1.6).subtract(EDITOR3D_AXES[handle.across].scale(tip * 1.5));
39080
+ r.drawBox(points[0], size, lit ? color : EDITOR3D_AXIS_COLORS[handle.across].scale(1, .7), handle.turn);
37270
39081
  }
37271
39082
  else if (kind === 'scaleAll')
37272
39083
  r.drawBox(center, tip * 1.4, lit ? color : WHITE);
@@ -37276,7 +39087,8 @@ function editor3DDraw()
37276
39087
  r.drawLine(points[0], points[1], width, color);
37277
39088
  const turn = axis === 'x' ? vec3(0, 0, -PI / 2) : axis === 'z' ? vec3(PI / 2, 0, 0) : undefined;
37278
39089
  const at = points[1].add(handle.direction.scale(tip));
37279
- r.drawMesh(editor3DConeMesh(), buildMatrix(at, turn, vec3(tip * 1.4, tip * 2, tip * 1.4)), undefined, color);
39090
+ const cone = buildMatrix(vec3(), turn, vec3(tip * 1.4, tip * 2, tip * 1.4));
39091
+ r.drawMesh(editor3DConeMesh(), buildMatrix(at, handle.turn).multiply(cone), undefined, color);
37280
39092
  }
37281
39093
  else
37282
39094
  {
@@ -37284,6 +39096,7 @@ function editor3DDraw()
37284
39096
  r.drawBox(points[1], tip, color, editor3DRotation(editor3DSelected()[0]).scale(PI / 180));
37285
39097
  }
37286
39098
  }
39099
+ editor3DDrawGame();
37287
39100
  }
37288
39101
 
37289
39102
  // the editor's drawing on the 2D layer: the name of each marker, and the box being dragged out to select with
@@ -37374,9 +39187,11 @@ function editor3DPanelInit()
37374
39187
  ['scale', 'Scale', 'R'], ['blocks', 'Blocks', 'B'], ['terrain', 'Terrain', 'T']])
37375
39188
  toolButtons[tool] = button(tools, text, ()=>
37376
39189
  {
37377
- editor3DTool = tool;
39190
+ editor3DPickTool(tool);
37378
39191
  (tool === 'select' || tool === 'blocks' || tool === 'terrain') && (editor3DBrush = undefined);
37379
39192
  }, key);
39193
+ // the game's own tools, buttons and controls, under the editor's tools
39194
+ const game = editorElement('div', panel);
37380
39195
  const snap = editorElement('div', panel, box);
37381
39196
  const grid = check(snap, 'Grid snap', (on)=> editor3DGrid = on, 'G, and Ctrl flips it for a drag');
37382
39197
  const steps = row(snap);
@@ -37385,12 +39200,26 @@ function editor3DPanelInit()
37385
39200
  const scaleStep = choice(steps, 'Size', [1, .5, .25], (v)=> editor3DScaleStep = v);
37386
39201
  const ground = check(snap, 'Ground snap', (on)=> editor3DGroundSnap = on,
37387
39202
  'A drag of an object slides it along what is under the mouse');
39203
+ const ownAxes = check(snap, 'Own axes', (on)=> editor3DLocalAxes = on,
39204
+ 'L: the Move handles go along the object\'s own axes, as it is turned, not the world\'s');
37388
39205
 
37389
39206
  // the types, a click picks one up to place, made again when types are added
37390
39207
  editorElement('div', panel, 'color:#aaa;margin-top:4px', 'Place');
37391
39208
  const types = row();
39209
+ const prefabNote = editorElement('div', panel, 'color:#f86'); // why a prefab was not loaded
37392
39210
  const properties = editorElement('div', panel, box);
37393
39211
 
39212
+ // prefabs: the selection made one, or the selected instance's own
39213
+ const prefabBox = editorElement('div', panel, box);
39214
+
39215
+ // the prefab that is open, and the way back
39216
+ const prefabOpen = editorElement('div', panel, 'padding:4px;margin:4px 0;background:#235;border-radius:3px;' +
39217
+ 'display:flex;gap:6px;align-items:center');
39218
+ panel.insertBefore(prefabOpen, panel.firstChild);
39219
+ const prefabName = editorElement('span', prefabOpen, 'flex:1');
39220
+ button(prefabOpen, 'Back', ()=> editor3DPrefabBack(),
39221
+ 'Backspace with nothing selected: back to where the prefab was opened from, every instance of it follows');
39222
+
37394
39223
  // the Blocks tool's box: a map to add, or the types to paint with
37395
39224
  const blocks = editorElement('div', panel, box);
37396
39225
 
@@ -37421,13 +39250,15 @@ function editor3DPanelInit()
37421
39250
 
37422
39251
  const storage = editorElement('div', panel, 'color:#f86;margin-top:4px',
37423
39252
  'Autosave failed, storage is full: Save to a file');
39253
+ const prefabUnsaved = editorElement('div', panel, 'color:#fc6;margin-top:4px');
37424
39254
  const hint = editorElement('div', panel, 'color:#8ab;margin-top:4px');
37425
39255
  const help = editorElement('div', panel, 'color:#aaa;margin-top:4px;border-top:1px solid #444;padding-top:4px');
37426
39256
  for (const line of editor3DHelpLines)
37427
39257
  editorElement('div', help, 'margin:2px 0', line);
39258
+ const gameHelp = editorElement('div', help);
37428
39259
  button(help, 'Close', ()=> editor3DHelp = false, '?');
37429
39260
 
37430
- editor3DPanelParts = {restart, pending, toolButtons, grid, moveStep, rotateStep, scaleStep, ground, types,
39261
+ editor3DPanelParts = {game, gameHelp, restart, pending, toolButtons, grid, moveStep, rotateStep, scaleStep, ground, ownAxes, types, prefabBox, prefabOpen, prefabName, prefabUnsaved, prefabNote,
37431
39262
  properties, blocks, terrainBox, sceneOn, sceneRows, playFrom, storage, hint, help, typeNames: ''};
37432
39263
  }
37433
39264
 
@@ -37442,49 +39273,126 @@ function editor3DPanelUpdate()
37442
39273
  editor3DPanel || editor3DPanelInit();
37443
39274
  editor3DPanel.style.display = '';
37444
39275
  const p = editor3DPanelParts, lit = '2px solid #4af';
37445
- p.restart.style.display = levelEditor.onRestart ? '' : 'none';
39276
+ editorGamePanel(p.game);
39277
+ editorGameHelp(p.gameHelp);
39278
+ p.restart.style.display = editorHas('onRestart') ? '' : 'none';
37446
39279
  p.pending.style.display = editor3DRecords.get(editor3DLevel)?.pending ? '' : 'none';
37447
39280
  for (const tool in p.toolButtons)
37448
39281
  p.toolButtons[tool].style.outline = tool === editor3DTool ? lit : '';
37449
39282
  p.grid.checked = editor3DGrid;
37450
39283
  p.ground.checked = editor3DGroundSnap;
39284
+ p.ownAxes.checked = editor3DLocalAxes;
37451
39285
  p.moveStep.value = editor3DMoveStep + '';
37452
39286
  p.rotateStep.value = editor3DRotateStep + '';
37453
39287
  p.scaleStep.value = editor3DScaleStep + '';
37454
39288
  p.playFrom.checked = editor3DPlayFromMouse;
37455
- p.playFrom.parentElement.style.display = levelEditor.onPlayFrom ? 'flex' : 'none';
39289
+ p.playFrom.parentElement.style.display = editorHas('onPlayFrom') ? 'flex' : 'none';
37456
39290
  p.storage.style.display = editor3DSaveFailed ? '' : 'none';
37457
39291
  p.hint.textContent = editor3DHint();
37458
39292
  p.help.style.display = editor3DHelp ? '' : 'none';
37459
39293
 
37460
39294
  // a button for each type, made again when the types changed
37461
- const names = [...level3DTypes.keys()], typeNames = names.join('\n');
39295
+ const names = editor3DPlaceNames(), plain = names.filter((name)=> !level3DPrefabs.has(name));
39296
+ const prefabs = names.slice(plain.length);
39297
+ const typeNames = plain.join('\n') + '\n\n' + prefabs.join('\n');
37462
39298
  if (p.typeNames !== typeNames)
37463
39299
  {
37464
39300
  p.typeNames = typeNames;
37465
- p.types.replaceChildren(...names.map((name)=>
39301
+ p.typeButtons = names.map((name)=>
37466
39302
  {
37467
39303
  const b = editorElement('button', undefined, 'padding:3px 6px;cursor:pointer', name);
37468
39304
  b.onclick = ()=> { editor3DBrush = editor3DBrush === name ? undefined : name; b.blur(); };
37469
39305
  return b;
37470
- }));
37471
- }
37472
- names.forEach((name, i)=> p.types.children[i].style.outline = name === editor3DBrush ? lit : '');
39306
+ });
39307
+ const label = editorElement('span', undefined, 'color:#aaa;width:100%', 'Prefabs');
39308
+ const load = editorElement('button', undefined, 'padding:3px 6px;cursor:pointer', 'Load prefab…');
39309
+ load.title = 'Add a prefab from a file the editor saved, by its file name, to place in this level';
39310
+ load.onclick = ()=> { editor3DPrefabPickFile(); load.blur(); };
39311
+ p.types.replaceChildren(...p.typeButtons.slice(0, plain.length), label, ...p.typeButtons.slice(plain.length), load);
39312
+ }
39313
+ p.prefabNote.style.display = editor3DPrefabMessage && !editor3DSelection.size ? '' : 'none';
39314
+ p.prefabNote.textContent = editor3DPrefabMessage;
39315
+ names.forEach((name, i)=> p.typeButtons[i].style.outline = name === editor3DBrush ? lit : '');
37473
39316
  editor3DPropertiesUpdate(p.properties);
39317
+ editor3DPrefabBoxUpdate(p.prefabBox);
39318
+ p.prefabOpen.style.display = editor3DPrefabStack.length ? 'flex' : 'none';
39319
+ p.prefabName.textContent = 'Editing prefab ' + editor3DPrefabStack.map((frame)=> frame.name).join(' > ');
39320
+ p.prefabUnsaved.style.display = editor3DPrefabDirty.size ? '' : 'none';
39321
+ p.prefabUnsaved.textContent = 'Edited and not in its file: ' + [...editor3DPrefabDirty].join(', ') +
39322
+ '. Open it with Enter and Save';
37474
39323
  editor3DBlocksUpdate(p.blocks);
37475
39324
  editor3DTerrainBoxUpdate(p.terrainBox);
37476
39325
  p.sceneOn.checked = !!editor3DScene();
37477
39326
  editor3DSceneUpdate(p.sceneRows);
37478
39327
  }
37479
39328
 
39329
+ // the prefab box, shown with a selection: a name and a button to make it a prefab, and for one selected instance
39330
+ // its prefab's name, Unpack, Export, and for a prefab of the level's own whether it is attached
39331
+ function editor3DPrefabBoxUpdate(box)
39332
+ {
39333
+ const selected = editor3DSelected(), made = selected.length === 1 && editor3DInstances.get(selected[0].id);
39334
+ const instance = made instanceof Prefab3D ? made : undefined, prefab = instance && level3DPrefabs.get(instance.prefabName);
39335
+ const key = !selected.length ? '' : [selected.length, instance?.prefabName, prefab?.attached, prefab?.fromLevel,
39336
+ editor3DPrefabMessage, editor3DPrefabStack.length] + '';
39337
+ box.style.display = key ? '' : 'none';
39338
+ if (box.dataset.key === key || box.contains(document.activeElement)) return;
39339
+ box.dataset.key = key;
39340
+ box.replaceChildren();
39341
+ if (!key) return;
39342
+ const field = 'background:#222;color:#eee', press = 'padding:2px 6px;cursor:pointer';
39343
+ const row = ()=> editorElement('div', box, 'display:flex;gap:4px;align-items:center;margin:2px 0;flex-wrap:wrap');
39344
+ if (prefab)
39345
+ {
39346
+ const line = row();
39347
+ editorElement('span', line, 'flex:1;color:#aaa', 'Prefab ' + instance.prefabName);
39348
+ const open = editorElement('button', line, press, 'Edit');
39349
+ open.title = 'Enter: open the prefab alone to edit it, every instance of it follows';
39350
+ open.onclick = ()=> { editor3DPrefabEnter(); open.blur(); };
39351
+ const unpack = editorElement('button', line, press, 'Unpack');
39352
+ unpack.title = 'Ctrl+Shift+G: turn this instance into the objects it is made of';
39353
+ unpack.onclick = ()=> { editor3DUnpack(); unpack.blur(); };
39354
+ const save = editorElement('button', line, press, 'Export');
39355
+ save.title = 'Save the prefab as a file of its own, to load with level3DLoadPrefab';
39356
+ save.onclick = ()=>
39357
+ {
39358
+ saveText(editor3DPrefabJSON(instance.prefabName), instance.prefabName + '.json', 'application/json');
39359
+ save.blur();
39360
+ };
39361
+ if (prefab.fromLevel && !editor3DPrefabStack.length)
39362
+ {
39363
+ const label = editorElement('label', box, 'display:flex;gap:6px;align-items:center;margin:2px 0');
39364
+ label.title = 'Its parts are attached to each instance and move with it as one body, with no ' +
39365
+ 'collision of their own; off, each part is an object of its own and solid';
39366
+ const attached = editorElement('input', label);
39367
+ attached.type = 'checkbox';
39368
+ attached.checked = prefab.attached;
39369
+ attached.onchange = ()=> { editor3DPrefabSetAttached(instance.prefabName, attached.checked); attached.blur(); };
39370
+ editorElement('span', label, '', 'Attached, one body');
39371
+ }
39372
+ }
39373
+ const line = row();
39374
+ const name = editorElement('input', line, field + ';flex:1;min-width:60px');
39375
+ name.placeholder = 'prefab name';
39376
+ const make = editorElement('button', line, press, 'Make prefab');
39377
+ make.title = 'Ctrl+G: the selection becomes one thing to place many times, kept in the level';
39378
+ make.onclick = ()=> { editor3DMakePrefab(name.value); make.blur(); };
39379
+ name.onkeydown = (e)=> { e.key === 'Enter' && (editor3DMakePrefab(name.value), name.blur()); };
39380
+ editor3DPrefabMessage && editorElement('div', box, 'color:#f86', editor3DPrefabMessage);
39381
+ }
39382
+
39383
+ // a level's color as a color input takes it, six digits and no alpha, white for one it can not show
39384
+ function editor3DInputColor(hex) { return /^#[0-9a-f]{6}/i.test(hex) ? hex.slice(0, 7) : '#ffffff'; }
39385
+
37480
39386
  // the terrain box, shown with the Terrain tool: without a terrain, its size, cells and height and a button to add
37481
- // it; with one, the brush's size and strength, the terrain's color, and a button to take it away
39387
+ // it; with one, the brush, its size and strength or the color it paints, the terrain's color, and a button to
39388
+ // take it away
37482
39389
  function editor3DTerrainBoxUpdate(box)
37483
39390
  {
37484
39391
  const map = editor3DTerrainMap(), shown = editor3DTool === 'terrain';
37485
39392
  box.style.display = shown ? '' : 'none';
37486
39393
  if (!shown) return;
37487
- const terrain = editor3DLevelPart('terrain'), key = map ? 'terrain ' + terrain.color : 'none';
39394
+ const terrain = editor3DLevelPart('terrain'), brush = editor3DTerrainBrush;
39395
+ const key = map ? ['terrain', terrain.color, brush.mode] + '' : 'none';
37488
39396
  if (box.dataset.key === key || box.contains(document.activeElement)) return;
37489
39397
  box.dataset.key = key;
37490
39398
  box.replaceChildren();
@@ -37532,13 +39440,27 @@ function editor3DTerrainBoxUpdate(box)
37532
39440
  input.oninput = ()=> set(parseFloat(input.value));
37533
39441
  input.onchange = ()=> input.blur();
37534
39442
  };
37535
- slider('Brush size', 'How far across the brush is', 1, 32, .5, editor3DTerrainBrush.size,
37536
- (v)=> editor3DTerrainBrush.size = v);
37537
- slider('Strength', 'How fast the brush works', .05, 1, .05, editor3DTerrainBrush.strength,
37538
- (v)=> editor3DTerrainBrush.strength = v);
37539
- const color = editorElement('input', line('Color'), field + ';width:60px');
39443
+ const brushes = editorElement('select', line('Brush', 'X: sculpt raises and lowers the ground, flatten brings ' +
39444
+ 'it to the height pressed, paint colors it'), field + ';width:120px');
39445
+ for (const name of editor3DTerrainBrushes)
39446
+ editorElement('option', brushes, '', name[0].toUpperCase() + name.slice(1)).value = name;
39447
+ brushes.value = brush.mode;
39448
+ brushes.onchange = ()=> { brush.mode = brushes.value; brushes.blur(); };
39449
+ slider('Brush size', 'How far across the brush is', 1, 32, .5, brush.size, (v)=> brush.size = v);
39450
+ if (brush.mode === 'paint')
39451
+ {
39452
+ const paint = editorElement('input', line('Paint', 'The color the brush paints'), field + ';width:60px');
39453
+ paint.type = 'color';
39454
+ paint.value = editor3DInputColor(brush.color);
39455
+ paint.oninput = ()=> brush.color = paint.value;
39456
+ paint.onchange = ()=> { brush.color = paint.value; paint.blur(); };
39457
+ }
39458
+ else
39459
+ slider('Strength', 'How fast the brush works', .05, 1, .05, brush.strength, (v)=> brush.strength = v);
39460
+ const color = editorElement('input', line('Color', 'The color of the ground that is not painted'),
39461
+ field + ';width:60px');
37540
39462
  color.type = 'color';
37541
- color.value = /^#[0-9a-f]{6}/i.test(terrain.color) ? terrain.color.slice(0, 7) : '#ffffff';
39463
+ color.value = editor3DInputColor(terrain.color);
37542
39464
  color.oninput = ()=> editor3DChangePart('terrain', (t)=> ({...t, color: color.value}));
37543
39465
  color.onchange = ()=> { color.oninput(); editor3DStrokeEnd(); color.blur(); };
37544
39466
  const remove = editorElement('button', box, 'margin-top:4px;padding:2px 6px;cursor:pointer', 'Remove terrain');
@@ -37554,7 +39476,8 @@ function editor3DBlocksUpdate(box)
37554
39476
  box.style.display = shown ? '' : 'none';
37555
39477
  if (!shown) return;
37556
39478
  const first = map?.tileInfo, texture = first?.textureInfo;
37557
- const key = map ? ['map', editor3DBlockType, texture?.size.x, map.blockTypes.length].join() : 'none';
39479
+ const key = map ? ['map', editor3DBlockType, texture?.size.x, map.blockTypes.length, editor3DBlockBox,
39480
+ map.mapSize].join() : 'none';
37558
39481
  if (box.dataset.key === key || box.contains(document.activeElement)) return;
37559
39482
  box.dataset.key = key;
37560
39483
  box.replaceChildren();
@@ -37575,7 +39498,7 @@ function editor3DBlocksUpdate(box)
37575
39498
  add.title = 'An empty map of blocks, centered, its bottom on the ground';
37576
39499
  add.onclick = ()=>
37577
39500
  {
37578
- const [x, y, z] = inputs.map((i)=> clamp(floor(parseFloat(i.value)) || 1, 1, 256));
39501
+ const [x, y, z] = inputs.map((i, k)=> clamp(floor(parseFloat(i.value)) || [32, 16, 32][k], 1, 256));
37579
39502
  editor3DVoxelAdd(vec3(x, y, z));
37580
39503
  add.blur();
37581
39504
  };
@@ -37615,6 +39538,46 @@ function editor3DBlocksUpdate(box)
37615
39538
  }
37616
39539
  catch {} // an image that can not be drawn or read, the title still says the type
37617
39540
  }
39541
+ // box fill: a drag fills the rectangle dragged, this many layers tall
39542
+ const boxRow = editorElement('label', box, 'display:flex;gap:6px;align-items:center;margin:4px 0 2px');
39543
+ boxRow.title = 'X: a drag fills the whole rectangle from the press to the mouse, Shift clears it, Ctrl repaints';
39544
+ const boxOn = editorElement('input', boxRow);
39545
+ boxOn.type = 'checkbox';
39546
+ boxOn.checked = editor3DBlockBox;
39547
+ boxOn.onchange = ()=> { editor3DBlockBox = boxOn.checked; boxOn.blur(); };
39548
+ editorElement('span', boxRow, 'flex:1', 'Box fill, height');
39549
+ const boxHeight = editorElement('input', boxRow, field);
39550
+ boxHeight.type = 'number';
39551
+ boxHeight.min = '1', boxHeight.max = '256', boxHeight.step = '1';
39552
+ boxHeight.value = editor3DBlockBoxHeight + '';
39553
+ boxHeight.onchange = ()=>
39554
+ {
39555
+ editor3DBlockBoxHeight = clamp(floor(parseFloat(boxHeight.value)) || 1, 1, 256);
39556
+ boxHeight.value = editor3DBlockBoxHeight + '';
39557
+ boxHeight.blur();
39558
+ };
39559
+
39560
+ // the map's size, to change
39561
+ const sizeRow = editorElement('label', box, 'display:flex;gap:4px;align-items:center;margin:2px 0');
39562
+ editorElement('span', sizeRow, 'flex:1', 'Map size');
39563
+ const axes = ['x', 'y', 'z'], sizes = axes.map((axis)=>
39564
+ {
39565
+ const input = editorElement('input', sizeRow, field);
39566
+ input.type = 'number';
39567
+ input.min = '1', input.max = '256', input.step = '1';
39568
+ input.value = map.mapSize[axis] + '';
39569
+ return input;
39570
+ });
39571
+ const resize = editorElement('button', box, 'padding:2px 6px;cursor:pointer;margin-right:4px', 'Resize');
39572
+ resize.title = 'Change the map\'s size, its blocks stay where they are; Undo brings back what was cut off';
39573
+ resize.onclick = ()=>
39574
+ {
39575
+ // a field left empty keeps the size it has
39576
+ const [x, y, z] = sizes.map((i, k)=> clamp(floor(parseFloat(i.value)) || map.mapSize[axes[k]], 1, 256));
39577
+ editor3DVoxelResize(vec3(x, y, z));
39578
+ resize.blur();
39579
+ };
39580
+
37618
39581
  const remove = editorElement('button', box, 'margin-top:4px;padding:2px 6px;cursor:pointer', 'Remove map');
37619
39582
  remove.title = 'Take the block map out of the level, Undo brings it back';
37620
39583
  remove.onclick = ()=> { editor3DVoxelRemove(); remove.blur(); };
@@ -37633,7 +39596,11 @@ function editor3DSceneUpdate(box)
37633
39596
 
37634
39597
  // a setting the block leaves out shows as it is on screen
37635
39598
  const view = editor3DSceneFromView(), value = (name)=> scene[name] ?? view[name];
37636
- const change = (name, v)=> editor3DChangeScene((s={})=> ({...s, [name]: v}));
39599
+ // an input changes the scene as it is when it is used, not as it was when the box was made: the box is not
39600
+ // made again while it has the focus, so a field edited right after another must not put the other one back;
39601
+ // set is given the setting's value now, or what shows for one the block leaves out
39602
+ const change = (name, set)=> editor3DChangeScene((s={})=>
39603
+ ({...s, [name]: typeof set === 'function' ? set(s[name] ?? view[name]) : set}));
37637
39604
  const field = 'background:#222;color:#eee';
37638
39605
  const line = (name, title='')=>
37639
39606
  {
@@ -37661,7 +39628,7 @@ function editor3DSceneUpdate(box)
37661
39628
  {
37662
39629
  const input = editorElement('input', line(name, title), field + ';width:60px');
37663
39630
  input.type = 'color';
37664
- input.value = /^#[0-9a-f]{6}/i.test(hex) ? hex.slice(0, 7) : '#ffffff';
39631
+ input.value = editor3DInputColor(hex);
37665
39632
  return live(input, ()=> set(input.value));
37666
39633
  };
37667
39634
  const number = (row, v, set)=>
@@ -37674,7 +39641,7 @@ function editor3DSceneUpdate(box)
37674
39641
  input.onchange = ()=>
37675
39642
  {
37676
39643
  const n = parseFloat(input.value);
37677
- isNumber(n) && n >= 0 && set(n);
39644
+ isNumber(n) && n >= 0 ? set(v = n) : input.value = v + ''; // one not taken shows what it has again
37678
39645
  editor3DStrokeEnd();
37679
39646
  input.blur();
37680
39647
  };
@@ -37699,7 +39666,7 @@ function editor3DSceneUpdate(box)
37699
39666
  if (sky)
37700
39667
  {
37701
39668
  ['Sky top', 'Sky horizon', 'Sky bottom'].forEach((name, i)=> color(name, '', sky[i], (hex)=>
37702
- change('sky', sky.map((c, k)=> k === i ? hex : c))));
39669
+ change('sky', (now)=> (isArray(now) && now.length === 3 ? now : sky).map((c, k)=> k === i ? hex : c))));
37703
39670
  slider('Ambient', 'How much of the sky colors lights the scene', 0, 1, .05,
37704
39671
  isNumber(scene.ambient) ? scene.ambient : .5, (v)=> change('ambient', v));
37705
39672
  }
@@ -37707,16 +39674,17 @@ function editor3DSceneUpdate(box)
37707
39674
  // the sun, as two angles
37708
39675
  const [around, height] = editor3DSunAngles(value('sunDirection'));
37709
39676
  slider('Sun around', 'Where the sun is around the scene, in degrees', 0, 359, 1, around, (v)=>
37710
- change('sunDirection', editor3DSunDirection(v, editor3DSunAngles(value('sunDirection'))[1])));
37711
- slider('Sun height', 'How high the sun is over the horizon, in degrees', 5, 90, 1, height, (v)=>
37712
- change('sunDirection', editor3DSunDirection(editor3DSunAngles(value('sunDirection'))[0], v)));
39677
+ change('sunDirection', (now)=> editor3DSunDirection(v, editor3DSunAngles(now)[1])));
39678
+ slider('Sun height', 'How high the sun is over the horizon, in degrees', 5, 89, 1, height, (v)=>
39679
+ change('sunDirection', (now)=> editor3DSunDirection(editor3DSunAngles(now)[0], v)));
37713
39680
  color('Sun color', '', value('sunColor'), (hex)=> change('sunColor', hex));
37714
39681
 
37715
39682
  // the fog, by its distances, an end of 0 for none
37716
39683
  const fog = isArray(value('fog')) ? value('fog') : [0, 0], fogRow = line('Fog start, end',
37717
39684
  'Where the fog starts and where it is total, an end of 0 for no fog');
37718
- number(fogRow, fog[0], (v)=> change('fog', [v, fog[1]]));
37719
- number(fogRow, fog[1], (v)=> change('fog', [fog[0], v]));
39685
+ const fogNow = (now)=> isArray(now) && now.length === 2 ? now : fog;
39686
+ number(fogRow, fog[0], (v)=> change('fog', (now)=> [v, fogNow(now)[1]]));
39687
+ number(fogRow, fog[1], (v)=> change('fog', (now)=> [fogNow(now)[0], v]));
37720
39688
  color('Fog color', 'The sky\'s horizon color when the level has a sky and no fog color',
37721
39689
  scene.fogColor ?? (sky ? sky[1] : view.fogColor), (hex)=> change('fogColor', hex));
37722
39690
  toggle('Shadows', 'The sun casts shadows', !!value('shadows'), (on)=> change('shadows', on));
@@ -37896,6 +39864,8 @@ export
37896
39864
  debugKeysAlways,
37897
39865
  debugTweakables,
37898
39866
  levelEditor,
39867
+ LevelEditor,
39868
+ setLevelEditor,
37899
39869
 
37900
39870
  // Debug
37901
39871
  ASSERT,
@@ -37957,6 +39927,7 @@ export
37957
39927
  touchInputEnable,
37958
39928
  touchGamepadEnable,
37959
39929
  touchGamepadPassthrough,
39930
+ touchPinchWheel,
37960
39931
  touchGamepadCenterButtonSize,
37961
39932
  touchGamepadButtonCount,
37962
39933
  touchGamepadLeftStick,
@@ -38018,6 +39989,7 @@ export
38018
39989
  setInputWASDEmulateDirection,
38019
39990
  setTouchGamepadEnable,
38020
39991
  setTouchGamepadPassthrough,
39992
+ setTouchPinchWheel,
38021
39993
  setTouchGamepadCenterButtonSize,
38022
39994
  setTouchGamepadButtonCount,
38023
39995
  setTouchGamepadLeftStick,
@@ -38221,6 +40193,7 @@ export
38221
40193
  mouseDelta,
38222
40194
  mouseDeltaScreen,
38223
40195
  mouseWheel,
40196
+ touchPinch,
38224
40197
  mouseInWindow,
38225
40198
  isUsingGamepad,
38226
40199
  lastInputDevice,
@@ -38404,6 +40377,10 @@ export
38404
40377
  setScene,
38405
40378
  getScene,
38406
40379
 
40380
+ // Parallax
40381
+ ParallaxLayer,
40382
+ parallaxMountains,
40383
+
38407
40384
  // Path Finding
38408
40385
  PathFinder,
38409
40386
  PathFinderNode,
@@ -38484,6 +40461,10 @@ export
38484
40461
  level3DAddType,
38485
40462
  level3DAddMesh,
38486
40463
  level3DLoad,
40464
+ level3DAddPrefab,
40465
+ level3DLoadPrefab,
40466
+ level3DSpawn,
40467
+ Prefab3D,
38487
40468
  level3DVoxelSetup,
38488
40469
 
38489
40470
  // Three.js