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.
@@ -288,9 +288,11 @@ const editor3DSelection = new Set; // the ids of the selected objects
288
288
  // the type a click places
289
289
  /** @type {string|undefined} */
290
290
  let editor3DBrush;
291
- // the copied objects
291
+ // the copied objects, and the size and box offset each had when it was copied
292
292
  /** @type {Array<Object>|undefined} */
293
293
  let editor3DClipboard;
294
+ /** @type {Map<Object, {size: Vector3, offset: Vector3}>} */
295
+ let editor3DClipboardBoxes = new Map;
294
296
  // the level's, each entry its object list and its parts, the scene and the blocks, before and after an edit
295
297
  let editor3DUndoList = [], editor3DRedoList = [];
296
298
  // the edit being made, a drag is one: the objects and the parts as they were before it, and painted when blocks
@@ -309,7 +311,7 @@ const editor3DSelected = ()=> editor3DObjects().filter((o)=> editor3DSelection.h
309
311
 
310
312
  // the parts of a level beside its objects that the editor edits: its scene block and its map of blocks; each is
311
313
  // undone, autosaved, reset and saved as the objects are
312
- const editor3DLevelPartNames = ['scene', 'voxels', 'terrain'];
314
+ const editor3DLevelPartNames = ['scene', 'voxels', 'terrain', 'prefabs'];
313
315
 
314
316
  // the last copy made of each part, as its text and the copy: a part that has not changed since is not copied
315
317
  // again, so undo steps share one copy of a terrain until it is sculpted; a copy is never changed
@@ -413,12 +415,46 @@ const editor3DTurned = (object)=> editor3DRotation(object).lengthSquared() > 0;
413
415
  // the size of an object's box: what the game made for it says, a marker is 1 unit
414
416
  function editor3DSize(object)
415
417
  {
416
- const made = editor3DInstances.get(object.id);
418
+ const made = editor3DInstances.get(object.id), box = editor3DPrefabBox(made);
419
+ if (box) return box.size;
417
420
  if (!(made instanceof EngineObject3D) || !(made.mesh || made.tileInfo)) return vec3(1);
418
421
  const s = made.size3D, k = made.scale3D;
419
422
  return vec3(s.x * abs(k.x), s.y * abs(k.y), s.z * abs(k.z));
420
423
  }
421
424
 
425
+ // the box around a prefab's instance in the world, the boxes of its parts together, as its middle and its size;
426
+ // undefined for anything else, or an instance with nothing to see
427
+ function editor3DPrefabBox(made)
428
+ {
429
+ if (!(made instanceof Prefab3D) || made.destroyed) return;
430
+ const low = vec3(Infinity), high = vec3(-Infinity);
431
+ for (const part of editor3DParts(made))
432
+ {
433
+ if (!(part instanceof EngineObject3D) || !(part.mesh || part.tileInfo)) continue;
434
+ const bounds = part.mesh && (part.mesh.bounds || part.mesh.getBounds());
435
+ const matrix = render3DObjectMatrix(part).copy().multiply(bounds ?
436
+ buildMatrix(bounds.min.add(bounds.max).scale(.5), undefined, bounds.max.subtract(bounds.min)) :
437
+ buildMatrix(vec3(), undefined, part.size3D));
438
+ for (let i = 8; i--;)
439
+ {
440
+ const p = matrix.transformPoint(vec3(i & 1 ? .5 : -.5, i & 2 ? .5 : -.5, i & 4 ? .5 : -.5));
441
+ for (const k of ['x', 'y', 'z'])
442
+ low[k] = min(low[k], p[k]), high[k] = max(high[k], p[k]);
443
+ }
444
+ }
445
+ // to the nearest part of the file's numbers, so a box worked out through a matrix reads as it was written
446
+ const tidy = (v)=> vec3(editor3DRound(v.x), editor3DRound(v.y), editor3DRound(v.z));
447
+ return low.x <= high.x ? {pos: tidy(low.add(high).scale(.5)), size: tidy(high.subtract(low))} : undefined;
448
+ }
449
+
450
+ // from an object's place to the middle of its box: nothing for a plain object, whose place is its middle, and for
451
+ // a prefab's instance how far its origin is from the middle of its parts, as it is turned and sized now
452
+ function editor3DBoxOffset(object)
453
+ {
454
+ const box = editor3DPrefabBox(editor3DInstances.get(object.id));
455
+ return box ? box.pos.subtract(editor3DPos(object)) : vec3();
456
+ }
457
+
422
458
  // every object gets an id of its own, a whole number: one without, or with one already used, gets a new one
423
459
  function editor3DFixIds(objects)
424
460
  {
@@ -454,6 +490,12 @@ function editor3DLevelLoaded(level)
454
490
  editor3DSelection.clear();
455
491
  editor3DStroke = undefined;
456
492
  }
493
+ if (!editor3DPrefabLevels.has(level) && editor3DPrefabStack.length)
494
+ {
495
+ // the game loaded a level while a prefab was open: what was open is gone with what it was opened from
496
+ editor3DPrefabStack.length = 0;
497
+ editor3DPrefabEdits = {};
498
+ }
457
499
  editor3DLevel = level;
458
500
  editor3DInstances.clear();
459
501
  if (isArray(level.objects))
@@ -493,7 +535,8 @@ function editor3DLevelLoaded(level)
493
535
  editor3DUndoList = record.undo;
494
536
  editor3DRedoList = record.redo;
495
537
  // an autosave the file already has, from a Save, goes; one of this file, or of the file a Save wrote, comes back
496
- const saved = editor3DSaves()[record.key];
538
+ // a prefab being edited has no autosave, and one of a level with the same objects is not its
539
+ const saved = editor3DPrefabLevels.has(level) ? undefined : editor3DSaves()[record.key];
497
540
  if (saved && isArray(saved.objects) && editor3DSame(saved.objects, original) &&
498
541
  editor3DSame(editor3DLevelParts(saved), originalParts))
499
542
  editor3DAutosave(level);
@@ -523,6 +566,7 @@ function editor3DPlaceInstance(made, object)
523
566
  made.scale3D = (level3DBaseScale.get(made) ?? vec3(1)).multiply(editor3DScale(object));
524
567
  made.velocity3D = vec3();
525
568
  made.angleVelocity3D = vec3();
569
+ made instanceof Prefab3D && made.placeAt(editor3DPos(object)); // by its origin, and its parts go with it
526
570
  }
527
571
 
528
572
  // make an object's game object, again when it has one
@@ -557,6 +601,24 @@ function editor3DSetObjects(list)
557
601
  made instanceof EngineObject3D && !made.destroyed;
558
602
  onlyMoved ? editor3DPlaceInstance(made, object) : editor3DMakeInstance(object);
559
603
  }
604
+ editor3DShadowLight();
605
+ }
606
+
607
+ // the light that casts the shadows is the level's last Light that asks to, as it is after a load, whatever order
608
+ // the edits made them in; a light of the game's own is left alone while no Light of the level asks
609
+ function editor3DShadowLight()
610
+ {
611
+ if (!render3D) return;
612
+ let light;
613
+ for (const object of editor3DObjects())
614
+ {
615
+ const made = editor3DInstances.get(object.id);
616
+ if (object.type === 'Light' && object.properties?.shadows === true && made instanceof Light3D && !made.destroyed)
617
+ light = made;
618
+ }
619
+ const now = render3D.shadowLight;
620
+ if (light || !now || now.destroyed || [...editor3DInstances.values()].some((made)=> made === now))
621
+ render3D.shadowLight = light;
560
622
  }
561
623
 
562
624
  // change the level's objects as part of the edit being made: change edits a copy of the list; the changes until
@@ -585,8 +647,30 @@ function editor3DSetPart(name, part)
585
647
  }
586
648
  else if (name === 'voxels')
587
649
  editor3DVoxelShow();
588
- else
650
+ else if (name === 'terrain')
589
651
  editor3DTerrainShow();
652
+ else
653
+ editor3DPrefabsShow();
654
+ }
655
+
656
+ // bring the prefab types in line with the level's own prefabs, and make every instance again: a prefab the level
657
+ // no longer has is no longer a type, one it has is added as level3DLoad adds it, and what a prefab makes is made
658
+ // when its instance is, so each instance is made again
659
+ function editor3DPrefabsShow()
660
+ {
661
+ if (editor3DPrefabLevels.has(editor3DLevel)) return; // inside a prefab, the prefabs are the level's
662
+ const prefabs = editor3DLevelPart('prefabs') ?? {};
663
+ for (const [name, prefab] of level3DPrefabs)
664
+ {
665
+ if (!prefab.fromLevel || prefabs[name]) continue;
666
+ level3DPrefabs.delete(name);
667
+ level3DTypes.delete(name);
668
+ }
669
+ level3DPrefabsAdd(prefabs);
670
+ // every object of a prefab's type, one whose type was not there when the objects were set too
671
+ for (const object of editor3DObjects())
672
+ level3DPrefabs.has(object.type) && editor3DMakeInstance(object);
673
+ editor3DShadowLight();
590
674
  }
591
675
 
592
676
  // make the level's parts these, the ones that differ
@@ -596,6 +680,11 @@ function editor3DSetParts(parts)
596
680
  editor3DSame(parts[name], editor3DLevelPart(name)) || editor3DSetPart(name, parts[name]);
597
681
  }
598
682
 
683
+ // the stroke being made, started here with the level as it is when there is none: called before the level changes
684
+ /** @return {{before: Array<Object>, parts: Object, painted?: boolean, sculpted?: boolean}} */
685
+ function editor3DStrokeBegin()
686
+ { return editor3DStroke ||= {before: editor3DCopy(editor3DObjects()), parts: editor3DLevelParts()}; }
687
+
599
688
  // change a part of the level as part of the edit being made, as editor3DChange does its objects: change is given
600
689
  // a copy of the part, undefined when the level has none, and returns the new one, or undefined for none; false
601
690
  // when nothing changed
@@ -604,7 +693,7 @@ function editor3DChangePart(name, change)
604
693
  if (!editor3DLevel || editor3DRecords.get(editor3DLevel)?.pending) return false; // its autosave waits first
605
694
  const parts = editor3DLevelParts(), after = change(parts[name] && editor3DCopy(parts[name]));
606
695
  if (editor3DSame(parts[name], after)) return false;
607
- editor3DStroke ||= {before: editor3DCopy(editor3DObjects()), parts};
696
+ editor3DStrokeBegin();
608
697
  editor3DSetPart(name, after);
609
698
  return true;
610
699
  }
@@ -681,8 +770,7 @@ function editor3DVoxelSet(cell, type)
681
770
  const map = editor3DVoxelMap();
682
771
  if (!map || editor3DRecords.get(editor3DLevel)?.pending) return false; // its autosave waits first
683
772
  if (!editor3DVoxelInside(map, cell) || map.getVoxel(cell) === type) return false;
684
- editor3DStroke ||= {before: editor3DCopy(editor3DObjects()), parts: editor3DLevelParts()};
685
- editor3DStroke.painted = true;
773
+ editor3DStrokeBegin().painted = true;
686
774
  map.setVoxel(cell, type);
687
775
  return true;
688
776
  }
@@ -704,8 +792,85 @@ function editor3DVoxelDragCell(drag, ray)
704
792
  // the level's terrain: the level's terrain block is the source of truth, the game's HeightMap follows it;
705
793
  // sculpting changes the game's map as it goes, and the stroke's end writes its heights into the level
706
794
 
707
- // the brush: its size across in world units, and how strong it is, 0 to 1
708
- const editor3DTerrainBrush = {size: 8, strength: .5};
795
+ // the brush: its size across in world units, how strong it is, 0 to 1, which brush it is, sculpt, flatten or
796
+ // paint, and the color it paints
797
+ const editor3DTerrainBrush = {size: 8, strength: .5, mode: 'sculpt', color: '#8a6a4a'};
798
+ const editor3DTerrainBrushes = ['sculpt', 'flatten', 'paint'];
799
+
800
+ // the paint of the game's terrain map as it is being edited, {colors, cells} as level3DTerrainPaint gives it:
801
+ // from the level, and ahead of it while a stroke paints
802
+ const editor3DTerrainPaints = new WeakMap;
803
+ function editor3DTerrainPaint(map)
804
+ {
805
+ let paint = editor3DTerrainPaints.get(map);
806
+ if (!paint)
807
+ {
808
+ const samples = map.rows * map.columns;
809
+ paint = level3DTerrainPaint(editor3DLevelPart('terrain'), samples) || {colors: [], cells: new Uint8Array(samples)};
810
+ editor3DTerrainPaints.set(map, paint);
811
+ }
812
+ return paint;
813
+ }
814
+
815
+ // the next brush of the Terrain tool
816
+ function editor3DTerrainNextBrush()
817
+ {
818
+ const brushes = editor3DTerrainBrushes;
819
+ editor3DTerrainBrush.mode = brushes[(brushes.indexOf(editor3DTerrainBrush.mode) + 1) % brushes.length];
820
+ }
821
+
822
+ // the drag a press on the ground starts, by the brush and the keys held: sculpt raises, Shift lowers; flatten
823
+ // brings the ground to the height pressed; paint colors it, Shift takes the paint off; Ctrl smooths with the two
824
+ // that shape the ground
825
+ function editor3DTerrainPress(point, shift, ctrl)
826
+ {
827
+ const map = editor3DTerrainMap(), brush = editor3DTerrainBrush.mode;
828
+ if (!map) return;
829
+ if (brush === 'paint')
830
+ return {kind: 'terrain', mode: shift ? 'erase' : 'paint'};
831
+ if (ctrl)
832
+ return {kind: 'terrain', mode: 'smooth'};
833
+ if (brush === 'flatten')
834
+ return {kind: 'terrain', mode: 'flatten', level: clamp((point.y - map.pos3D.y) / map.height)};
835
+ return {kind: 'terrain', mode: shift ? 'lower' : 'raise'};
836
+ }
837
+
838
+ // the mouse held on the ground with a terrain drag
839
+ function editor3DTerrainDragTo(drag, point)
840
+ {
841
+ return drag.mode === 'paint' || drag.mode === 'erase' ? editor3DTerrainPaintAt(point, drag.mode === 'erase') :
842
+ editor3DTerrainSculpt(point, drag.mode, editor3DSeconds, drag.level);
843
+ }
844
+
845
+ // paint the ground around a point with the brush's color, or take its paint off, as part of the edit being made:
846
+ // every sample under the brush, in the game's map now and in the level when the stroke ends; false when nothing
847
+ // changed, or when the terrain has all the colors it can keep, 255
848
+ function editor3DTerrainPaintAt(point, erase=false)
849
+ {
850
+ const map = editor3DTerrainMap();
851
+ if (!map || editor3DRecords.get(editor3DLevel)?.pending) return false; // its autosave waits first
852
+ const paint = editor3DTerrainPaint(map), hex = editor3DInputColor(editor3DTerrainBrush.color);
853
+ let color = erase ? 0 : paint.colors.indexOf(hex) + 1;
854
+ if (!erase && !color && paint.colors.length >= 255) return false;
855
+ const radius = editor3DTerrainBrush.size / 2, rows = map.rows, columns = map.columns;
856
+ const stepX = map.mapSize.x / (columns - 1), stepZ = map.mapSize.y / (rows - 1);
857
+ const x0 = map.pos3D.x - map.mapSize.x / 2, z0 = map.pos3D.z - map.mapSize.y / 2;
858
+ let changed = false;
859
+ for (let r = 0; r < rows; ++r)
860
+ for (let c = 0; c < columns; ++c)
861
+ {
862
+ if (hypot(x0 + c * stepX - point.x, z0 + r * stepZ - point.z) >= radius) continue;
863
+ color ||= erase ? 0 : paint.colors.push(hex); // a new color joins the list with the first sample it paints
864
+ if (paint.cells[r * columns + c] === color) continue;
865
+ paint.cells[r * columns + c] = color;
866
+ changed = true;
867
+ }
868
+ if (!changed) return false;
869
+ editor3DStrokeBegin().sculpted = true;
870
+ level3DTerrainSetColors(map, editor3DLevelPart('terrain'), paint);
871
+ map.rebuild();
872
+ return true;
873
+ }
709
874
 
710
875
  // the game's map of the level's terrain, undefined when the level has none
711
876
  function editor3DTerrainMap()
@@ -734,15 +899,30 @@ function editor3DTerrainShow()
734
899
  return;
735
900
  }
736
901
  map.heights = shape.heights;
737
- map.color = /^#([0-9a-f]{6}|[0-9a-f]{8})$/i.test(terrain.color) ? new Color().setHex(terrain.color) : WHITE;
902
+ editor3DTerrainPaints.delete(map); // as the level has it
903
+ level3DTerrainSetColors(map, terrain);
738
904
  map.rebuild();
739
905
  }
740
906
 
741
- // write the game's map into the level, as a stroke of sculpting ends, its heights to a thousandth
907
+ // write the game's map into the level, as a stroke of sculpting ends, its heights to a thousandth, and its paint:
908
+ // the colors still in use and runs of a count and a color along the rows, or no paint when none is left
742
909
  function editor3DTerrainStore()
743
910
  {
744
911
  const map = editor3DTerrainMap(), terrain = editor3DLevelPart('terrain');
745
- map && terrain && (terrain.heights = map.heights.map((row)=> row.map((v)=> round(v * 1e3) / 1e3)));
912
+ if (!map || !terrain) return;
913
+ terrain.heights = map.heights.map((row)=> row.map((v)=> round(v * 1e3) / 1e3));
914
+ const paint = editor3DTerrainPaints.get(map);
915
+ if (!paint) return; // nothing painted since the level's was read
916
+ const used = paint.colors.map((hex, i)=> paint.cells.includes(i + 1));
917
+ const colors = paint.colors.filter((hex, i)=> used[i]), cells = [];
918
+ const index = paint.colors.map((hex, i)=> used[i] ? colors.indexOf(hex) + 1 : 0);
919
+ for (const v of paint.cells)
920
+ {
921
+ const color = v && index[v - 1];
922
+ cells[cells.length - 1] === color ? ++cells[cells.length - 2] : cells.push(1, color);
923
+ }
924
+ colors.length ? terrain.paint = {colors, cells} : delete terrain.paint;
925
+ editor3DTerrainPaints.delete(map); // read from the level again, with its colors as they are numbered now
746
926
  }
747
927
 
748
928
  // give the level a flat terrain, as one undo: its size in the world, how many cells a side, and how tall a full
@@ -766,9 +946,9 @@ function editor3DTerrainRemove()
766
946
  }
767
947
 
768
948
  // sculpt the ground around a point for some seconds, as part of the edit being made: raise, lower or smooth it
769
- // under the brush, a soft circle, most in its middle and nothing at its edge; in the game's map now, in the level
770
- // when the stroke ends; false when nothing changed
771
- function editor3DTerrainSculpt(point, mode, seconds)
949
+ // under the brush, a soft circle, most in its middle and nothing at its edge, or flatten it toward a height, level,
950
+ // 0 to 1; in the game's map now, in the level when the stroke ends; false when nothing changed
951
+ function editor3DTerrainSculpt(point, mode, seconds, level=0)
772
952
  {
773
953
  const map = editor3DTerrainMap();
774
954
  if (!map || editor3DRecords.get(editor3DLevel)?.pending) return false; // its autosave waits first
@@ -779,7 +959,7 @@ function editor3DTerrainSculpt(point, mode, seconds)
779
959
  const c0 = max(0, ceil((point.x - radius - x0) / stepX)), c1 = min(columns - 1, floor((point.x + radius - x0) / stepX));
780
960
  const r0 = max(0, ceil((point.z - radius - z0) / stepZ)), r1 = min(rows - 1, floor((point.z + radius - z0) / stepZ));
781
961
  // smoothing reads the ground as it was, so the order the samples are done in does not show
782
- const old = mode === 'smooth' ? heights.map((row)=> row.slice()) : heights;
962
+ const old = mode === 'smooth' ? heights.map((row, r)=> r >= r0 - 1 && r <= r1 + 1 ? row.slice() : row) : heights;
783
963
  const rate = strength * 10 * seconds; // world units of height at full strength, 10 a second
784
964
  let changed = false;
785
965
  for (let r = r0; r <= r1; ++r)
@@ -797,6 +977,8 @@ function editor3DTerrainSculpt(point, mode, seconds)
797
977
  old[r + dr]?.[c + dc] !== undefined && (sum += old[r + dr][c + dc], ++count);
798
978
  v = h + (sum / count - h) * min(1, rate * falloff);
799
979
  }
980
+ else if (mode === 'flatten')
981
+ v = h + (level - h) * min(1, rate * falloff);
800
982
  else
801
983
  v = clamp(h + (mode === 'lower' ? -1 : 1) * rate * falloff / map.height);
802
984
  if (v === h) continue;
@@ -804,8 +986,7 @@ function editor3DTerrainSculpt(point, mode, seconds)
804
986
  changed = true;
805
987
  }
806
988
  if (!changed) return false;
807
- editor3DStroke ||= {before: editor3DCopy(editor3DObjects()), parts: editor3DLevelParts()};
808
- editor3DStroke.sculpted = true;
989
+ editor3DStrokeBegin().sculpted = true;
809
990
  map.rebuild();
810
991
  return true;
811
992
  }
@@ -826,16 +1007,81 @@ function editor3DVoxelPress(ray, mode)
826
1007
  {
827
1008
  const target = editor3DVoxelTarget(ray, mode);
828
1009
  if (!target) return;
829
- const {cell, axis, plane} = target;
1010
+ const {cell, axis, plane, side} = target;
1011
+ if (editor3DBlockBox)
1012
+ {
1013
+ // a box grows out from the face when placing, and into the blocks when removing or repainting
1014
+ const drag = {kind: 'blocks', mode, axis, plane, layer: cell[axis], last: cell, start: cell,
1015
+ grow: mode === 'place' ? side : -side, height: max(1, floor(editor3DBlockBoxHeight)),
1016
+ changed: new Map, region: undefined};
1017
+ editor3DVoxelBoxTo(drag, cell);
1018
+ return drag;
1019
+ }
830
1020
  editor3DVoxelApply(cell, mode);
831
1021
  return {kind: 'blocks', mode, axis, plane, layer: cell[axis], last: cell};
832
1022
  }
833
1023
 
1024
+ // 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
1025
+ // cell of the same layer is changed, as many layers as its height, the part of it inside the map
1026
+ function editor3DVoxelBoxTo(drag, cell)
1027
+ {
1028
+ const map = editor3DVoxelMap();
1029
+ if (!map) return;
1030
+ for (const [at, type] of drag.changed.values())
1031
+ map.setVoxel(at, type);
1032
+ drag.changed.clear();
1033
+ const lo = vec3(min(drag.start.x, cell.x), min(drag.start.y, cell.y), min(drag.start.z, cell.z));
1034
+ const hi = vec3(max(drag.start.x, cell.x), max(drag.start.y, cell.y), max(drag.start.z, cell.z));
1035
+ const far = drag.layer + drag.grow * (drag.height - 1), axis = drag.axis, s = map.mapSize;
1036
+ lo[axis] = clamp(min(drag.layer, far), 0, s[axis] - 1);
1037
+ hi[axis] = clamp(max(drag.layer, far), 0, s[axis] - 1);
1038
+ for (let z = lo.z; z <= hi.z; ++z)
1039
+ for (let y = lo.y; y <= hi.y; ++y)
1040
+ for (let x = lo.x; x <= hi.x; ++x)
1041
+ {
1042
+ const at = vec3(x, y, z), type = map.getVoxel(at);
1043
+ editor3DVoxelApply(at, drag.mode) && drag.changed.set(x + s.x * (y + s.y * z), [at, type]);
1044
+ }
1045
+ drag.region = {lo, hi};
1046
+ drag.last = cell;
1047
+ }
1048
+
1049
+ // change the size of the level's map in cells, as one undo: it grows and shrinks about its middle across, and at
1050
+ // its top, and its blocks stay where they are in the world, the ones outside the new size gone
1051
+ function editor3DVoxelResize(size)
1052
+ {
1053
+ const changed = editor3DChangePart('voxels', (voxels)=>
1054
+ {
1055
+ const shape = level3DVoxelsShape(voxels);
1056
+ const to = vec3(floor(size.x), floor(size.y), floor(size.z));
1057
+ if (!shape || to.x < 1 || to.y < 1 || to.z < 1 || to.x * to.y * to.z > LEVEL3D_VOXEL_CELLS) return voxels;
1058
+ const from = shape.size, old = new Uint8Array(from.x * from.y * from.z);
1059
+ level3DVoxelsDecode(voxels.blocks, old);
1060
+ // how far each block moves in cells, so the corner moves the other way and the blocks stay put
1061
+ const dx = floor((to.x - from.x) / 2), dz = floor((to.z - from.z) / 2);
1062
+ const data = new Uint8Array(to.x * to.y * to.z);
1063
+ for (let z = 0; z < from.z; ++z)
1064
+ for (let y = 0; y < min(from.y, to.y); ++y)
1065
+ for (let x = 0; x < from.x; ++x)
1066
+ {
1067
+ const nx = x + dx, nz = z + dz;
1068
+ if (nx >= 0 && nz >= 0 && nx < to.x && nz < to.z)
1069
+ data[nx + to.x * (y + to.y * nz)] = old[x + from.x * (y + from.y * z)];
1070
+ }
1071
+ return {...voxels, pos: [shape.pos.x - dx, shape.pos.y, shape.pos.z - dz], size: [to.x, to.y, to.z],
1072
+ blocks: level3DVoxelsEncode(data)};
1073
+ });
1074
+ editor3DStrokeEnd();
1075
+ return changed;
1076
+ }
1077
+
834
1078
  // a drag of the Blocks tool goes on: every cell of the layer from the last one to the one under the ray
835
1079
  function editor3DVoxelDragTo(drag, ray)
836
1080
  {
837
1081
  const cell = editor3DVoxelDragCell(drag, ray), last = drag.last;
838
1082
  if (!cell) return;
1083
+ if (drag.changed)
1084
+ return editor3DVoxelBoxTo(drag, cell);
839
1085
  const d = cell.subtract(last), steps = max(abs(d.x), abs(d.y), abs(d.z));
840
1086
  for (let i = 1; i <= steps; ++i)
841
1087
  editor3DVoxelApply(vec3(round(last.x + d.x * i / steps), round(last.y + d.y * i / steps),
@@ -862,12 +1108,12 @@ function editor3DVoxelTarget(ray, mode)
862
1108
  if (!hit)
863
1109
  {
864
1110
  const cell = place ? editor3DVoxelDragCell({axis: 'y', plane: 0, layer: 0}, ray) : undefined;
865
- return cell && {cell, axis: 'y', plane: 0};
1111
+ return cell && {cell, axis: 'y', plane: 0, side: 1};
866
1112
  }
867
1113
  const n = hit.normal, axis = n.x ? 'x' : n.y ? 'y' : 'z', cell = place ? hit.cell.add(n) : hit.cell;
868
1114
  if (!editor3DVoxelInside(map, cell)) return;
869
- // the face between the block hit and the cell in front of it
870
- return {cell, axis, plane: cell[axis] + ((n[axis] > 0) === place ? 0 : 1)};
1115
+ // the face between the block hit and the cell in front of it, and side, the way that face looks along the axis
1116
+ return {cell, axis, plane: cell[axis] + ((n[axis] > 0) === place ? 0 : 1), side: n[axis]};
871
1117
  }
872
1118
 
873
1119
  // end the edit being made: one undo, and the autosave
@@ -941,6 +1187,7 @@ function editor3DSetProperty(object, name, value, defaultValue)
941
1187
  // add an object of a type at a position, selected; its id, or undefined when the level can not be edited
942
1188
  function editor3DPlace(type, pos)
943
1189
  {
1190
+ if (editor3DPrefabSelf(type)) return; // it would hold itself
944
1191
  let id;
945
1192
  const placed = editor3DChange((list)=>
946
1193
  {
@@ -954,6 +1201,302 @@ function editor3DPlace(type, pos)
954
1201
  return id;
955
1202
  }
956
1203
 
1204
+ ///////////////////////////////////////////////////////////////////////////////
1205
+ // prefabs: a selection made one, an instance made objects again, and a prefab's own settings
1206
+
1207
+ // what the panel says about the last prefab action that was refused
1208
+ let editor3DPrefabMessage = '';
1209
+
1210
+ // the box around an object in the world, turned and sized as it is, as its low and high corners
1211
+ function editor3DWorldBox(object, low=vec3(Infinity), high=vec3(-Infinity))
1212
+ {
1213
+ const matrix = editor3DBoxMatrix(object);
1214
+ for (let i = 8; i--;)
1215
+ {
1216
+ const p = matrix.transformPoint(vec3(i & 1 ? .5 : -.5, i & 2 ? .5 : -.5, i & 4 ? .5 : -.5));
1217
+ for (const k of ['x', 'y', 'z'])
1218
+ low[k] = min(low[k], p[k]), high[k] = max(high[k], p[k]);
1219
+ }
1220
+ return {low, high};
1221
+ }
1222
+
1223
+ // is a type the prefab being edited, or one that holds it: a prefab that is open, or one the game made of the
1224
+ // level being edited itself, as a prefab maker does; placed there it would hold itself
1225
+ function editor3DPrefabSelf(type)
1226
+ {
1227
+ const names = editor3DPrefabStack.map((frame)=> frame.name);
1228
+ for (const [name, prefab] of level3DPrefabs)
1229
+ prefab.source === editor3DLevel && names.push(name);
1230
+ return names.some((name)=> editor3DPrefabHolds(type, name));
1231
+ }
1232
+
1233
+ // the types the Place list has, the plain ones and then the prefabs
1234
+ function editor3DPlaceNames()
1235
+ {
1236
+ const all = [...level3DTypes.keys()].filter((name)=> !editor3DPrefabSelf(name));
1237
+ return [...all.filter((name)=> !level3DPrefabs.has(name)), ...all.filter((name)=> level3DPrefabs.has(name))];
1238
+ }
1239
+
1240
+ // does a type hold a prefab, itself or through the prefabs it holds
1241
+ function editor3DPrefabHolds(type, name, depth=0)
1242
+ {
1243
+ return type === name || depth < 8 &&
1244
+ !!level3DPrefabs.get(type)?.objects.some((o)=> editor3DPrefabHolds(o.type, name, depth + 1));
1245
+ }
1246
+
1247
+ // make the selected objects a prefab of the level's own, about the bottom centre of their box, and put one
1248
+ // instance of it where they were, as one undo; a name the level's prefabs have replaces that prefab, and every
1249
+ // instance of it changes; true, or why not, which the panel shows
1250
+ function editor3DMakePrefab(name='')
1251
+ {
1252
+ const refuse = (why)=> editor3DPrefabMessage = why;
1253
+ const selected = editor3DSelected();
1254
+ if (editor3DPrefabStack.length) return refuse('Go back to the level to make a prefab');
1255
+ if (!selected.length) return refuse('Select the objects to make a prefab of');
1256
+ if (!editor3DLevel || editor3DRecords.get(editor3DLevel)?.pending) return refuse('The level can not be edited now');
1257
+ name = String(name).trim();
1258
+ for (let i = 1; !name; ++i)
1259
+ level3DTypes.has('Prefab ' + i) || (name = 'Prefab ' + i);
1260
+ const known = level3DPrefabs.get(name);
1261
+ if (known ? !known.fromLevel : level3DTypes.has(name))
1262
+ return refuse(name + ' is a type the game added, pick another name');
1263
+ if (selected.some((o)=> editor3DPrefabHolds(o.type, name)))
1264
+ return refuse(name + ' can not hold itself');
1265
+
1266
+ // its origin is the bottom centre of the selection, so an instance stands on the ground
1267
+ const low = vec3(Infinity), high = vec3(-Infinity);
1268
+ for (const object of selected)
1269
+ editor3DWorldBox(object, low, high);
1270
+ const origin = vec3(editor3DRound((low.x + high.x) / 2), editor3DRound(low.y), editor3DRound((low.z + high.z) / 2));
1271
+ const objects = selected.map((object, i)=>
1272
+ {
1273
+ const part = {...editor3DCopy(object), id: i + 1};
1274
+ editor3DSetTransform(part, editor3DPos(object).subtract(origin));
1275
+ return part;
1276
+ });
1277
+ editor3DStrokeEnd();
1278
+ const id = editor3DObjects().reduce((next, o)=> max(next, o.id + 1), 1);
1279
+ const ids = new Set(selected.map((o)=> o.id));
1280
+ // the prefab first, so its type is there when the instance is made
1281
+ editor3DChangePart('prefabs', (prefabs={})=>
1282
+ ({...prefabs, [name]: prefabs[name]?.attached ? {attached: true, objects} : {objects}}));
1283
+ editor3DChange((list)=>
1284
+ {
1285
+ for (let i = list.length; i--;)
1286
+ ids.has(list[i].id) && list.splice(i, 1);
1287
+ const instance = {id, type: name};
1288
+ editor3DSetTransform(instance, origin);
1289
+ list.push(instance);
1290
+ });
1291
+ editor3DStrokeEnd();
1292
+ editor3DSelection.clear();
1293
+ editor3DSelection.add(id);
1294
+ editor3DPrefabMessage = '';
1295
+ return true;
1296
+ }
1297
+
1298
+ // add a prefab from the text of a file the editor saved, a level file, to the level's own prefabs under a name,
1299
+ // as one undo, and pick it to place; a name the level's prefabs have is replaced, as Make prefab does; true, or
1300
+ // why not, which the panel shows
1301
+ function editor3DPrefabLoad(name, text)
1302
+ {
1303
+ const refuse = (why)=> editor3DPrefabMessage = why;
1304
+ if (editor3DPrefabStack.length) return refuse('Go back to the level to load a prefab');
1305
+ if (!editor3DLevel || editor3DRecords.get(editor3DLevel)?.pending) return refuse('The level can not be edited now');
1306
+ name = String(name).trim();
1307
+ if (!name) return refuse('The prefab needs a name');
1308
+ let file;
1309
+ try { file = JSON.parse(text); }
1310
+ catch { return refuse(name + ' is not a JSON file'); }
1311
+ if (!file || typeof file !== 'object' || !isArray(file.objects))
1312
+ return refuse(name + ' is not a prefab, it has no objects');
1313
+ const known = level3DPrefabs.get(name);
1314
+ if (known ? !known.fromLevel : level3DTypes.has(name))
1315
+ return refuse(name + ' is a type the game added, rename the file');
1316
+ const objects = file.objects.filter((o)=> o && typeof o === 'object');
1317
+ if (objects.some((o)=> editor3DPrefabHolds(o.type, name)))
1318
+ return refuse(name + ' can not hold itself');
1319
+ editor3DStrokeEnd();
1320
+ editor3DChangePart('prefabs', (prefabs={})=> ({...prefabs, [name]: file.attached ? {attached: true, objects} : {objects}}));
1321
+ editor3DStrokeEnd();
1322
+ editor3DPickTool(editor3DTool); // a tool of the game's own is put down
1323
+ editor3DBrush = name;
1324
+ editor3DPrefabMessage = '';
1325
+ return true;
1326
+ }
1327
+
1328
+ // pick a prefab's file and load it, by its file name
1329
+ function editor3DPrefabPickFile()
1330
+ {
1331
+ const input = document.createElement('input');
1332
+ input.type = 'file';
1333
+ input.accept = '.json,application/json';
1334
+ input.onchange = async ()=>
1335
+ {
1336
+ const file = input.files?.[0];
1337
+ file && editor3DPrefabLoad(file.name.replace(/\.json$/i, ''), await file.text());
1338
+ };
1339
+ input.click();
1340
+ }
1341
+
1342
+ // turn the selected instances of prefabs into the objects they are made of, where they are, as one undo; false
1343
+ // with none selected
1344
+ function editor3DUnpack()
1345
+ {
1346
+ const instances = editor3DSelected().filter((o)=> editor3DInstances.get(o.id) instanceof Prefab3D);
1347
+ if (!instances.length) return false;
1348
+ editor3DStrokeEnd();
1349
+ const added = [];
1350
+ const changed = editor3DChange((list)=>
1351
+ {
1352
+ let id = list.reduce((next, o)=> max(next, o.id + 1), 1);
1353
+ for (const instance of instances)
1354
+ {
1355
+ const made = editor3DInstances.get(instance.id);
1356
+ // by the level's own place of it, its origin, which is not where an attached one's handle is
1357
+ const origin = {pos3D: editor3DPos(instance), rotation3D: editor3DRotation(instance).scale(PI / 180),
1358
+ scale3D: editor3DScale(instance)};
1359
+ for (const part of level3DPrefabs.get(made.prefabName)?.objects ?? [])
1360
+ {
1361
+ const at = level3DPrefabPartTransform(origin, part), object = {...editor3DCopy(part), id: id++};
1362
+ editor3DSetTransform(object, at.pos, at.rotation.scale(180 / PI), at.scale);
1363
+ list.push(object);
1364
+ added.push(object.id);
1365
+ }
1366
+ list.splice(list.findIndex((o)=> o.id === instance.id), 1);
1367
+ }
1368
+ });
1369
+ editor3DStrokeEnd();
1370
+ if (!changed) return false;
1371
+ editor3DSelection.clear();
1372
+ for (const id of added)
1373
+ editor3DSelection.add(id);
1374
+ return true;
1375
+ }
1376
+
1377
+ // make a prefab of the level's own attached, its parts the children of each instance, or not, as one undo
1378
+ function editor3DPrefabSetAttached(name, attached)
1379
+ {
1380
+ if (editor3DPrefabStack.length) return false; // a prefab is the level's, set from the level
1381
+ editor3DStrokeEnd();
1382
+ const changed = editor3DChangePart('prefabs', (prefabs)=>
1383
+ {
1384
+ if (!prefabs?.[name]) return prefabs;
1385
+ const {attached: was, ...rest} = prefabs[name];
1386
+ return {...prefabs, [name]: attached ? {attached: true, ...rest} : rest};
1387
+ });
1388
+ editor3DStrokeEnd();
1389
+ return changed;
1390
+ }
1391
+
1392
+ // a prefab as its own file has it, a level file of its objects, to load with level3DLoadPrefab
1393
+ function editor3DPrefabJSON(name)
1394
+ {
1395
+ const prefab = level3DPrefabs.get(name);
1396
+ if (!prefab) return '';
1397
+ return editor3DLevelJSON({...(prefab.attached ? {attached: true} : {}), objects: prefab.objects});
1398
+ }
1399
+
1400
+ ///////////////////////////////////////////////////////////////////////////////
1401
+ // Edit prefab: the editor on a prefab alone, the level it came from put away until Back
1402
+
1403
+ // the prefabs opened one inside the other, each with the level it was opened from, its name, the selection there
1404
+ // and the ids of that level's objects that had something made for them
1405
+ /** @type {Array<{level: Object, name: string, selection: Array<number>, made: Set<number>, entered?: string, saved?: string}>} */
1406
+ const editor3DPrefabStack = [];
1407
+ // the levels that are a prefab being edited, they have no autosave of their own
1408
+ const editor3DPrefabLevels = new WeakSet;
1409
+ // what was edited in prefabs of the level's own since the level was left, written into it on the way back
1410
+ /** @type {Object<string, Object>} */
1411
+ let editor3DPrefabEdits = {};
1412
+ // the prefabs of the game's own that were edited and are not in a file yet
1413
+ const editor3DPrefabDirty = new Set;
1414
+
1415
+ // destroy what the level being edited has in the world, its objects' and its maps'
1416
+ function editor3DPrefabClear()
1417
+ {
1418
+ for (const made of editor3DInstances.values())
1419
+ made?.destroy?.();
1420
+ editor3DInstances.clear();
1421
+ for (const map of [level3DVoxelMap, level3DTerrainMap])
1422
+ map && !map.destroyed && map.destroy();
1423
+ }
1424
+
1425
+ // open the prefab of an instance, the selected one when none is given, to edit alone: its objects about its own
1426
+ // origin, with an undo of its own, and the level it is in put away; false when it is not a prefab's instance
1427
+ function editor3DPrefabEnter(id)
1428
+ {
1429
+ const selected = editor3DSelected();
1430
+ id ??= selected.length === 1 ? selected[0].id : undefined;
1431
+ const made = editor3DInstances.get(id), from = editor3DLevel;
1432
+ const prefab = made instanceof Prefab3D && level3DPrefabs.get(made.prefabName);
1433
+ if (!prefab || editor3DRecords.get(from)?.pending) return false;
1434
+ editor3DStrokeEnd();
1435
+ editor3DDrag = editor3DHover = undefined;
1436
+ const frame = {level: from, name: made.prefabName, selection: [...editor3DSelection],
1437
+ made: new Set(editor3DInstances.keys()), entered: undefined};
1438
+ editor3DPrefabStack.push(frame);
1439
+ editor3DPrefabClear();
1440
+ const level = {littlejs3D: LEVEL3D_VERSION, ...(prefab.attached ? {attached: true} : {}),
1441
+ objects: editor3DCopy(prefab.objects)};
1442
+ editor3DPrefabLevels.add(level);
1443
+ level3DLoad(level); // the editor takes it as it does any level
1444
+ frame.entered = JSON.stringify(editor3DObjects()); // as the editor has it, an edit is what differs from this
1445
+ return true;
1446
+ }
1447
+
1448
+ // leave the prefab being edited for the level it was opened from: the prefab is what was edited, every instance
1449
+ // of it is made again, and back in the level itself what was edited in its own prefabs is one undo; false when
1450
+ // no prefab is open
1451
+ function editor3DPrefabBack()
1452
+ {
1453
+ const frame = editor3DPrefabStack.pop(), edited = editor3DLevel;
1454
+ if (!frame) return false;
1455
+ editor3DStrokeEnd();
1456
+ editor3DDrag = editor3DHover = undefined;
1457
+ const known = level3DPrefabs.get(frame.name);
1458
+ const objects = editor3DCopy(editor3DObjects()), changed = JSON.stringify(objects) !== frame.entered;
1459
+ editor3DPrefabClear();
1460
+ if (changed)
1461
+ {
1462
+ // the prefab as it is now, for whatever is made next; one of the game's own waits to be saved to its file
1463
+ const prefab = known?.attached ? {attached: true, objects} : {objects}, fromLevel = !!known?.fromLevel;
1464
+ level3DPrefabSet(frame.name, prefab, fromLevel);
1465
+ if (fromLevel)
1466
+ editor3DPrefabEdits[frame.name] = prefab;
1467
+ else if (JSON.stringify(objects) !== frame.saved)
1468
+ editor3DPrefabDirty.add(frame.name); // not what Save wrote while it was open
1469
+ }
1470
+
1471
+ // the level it was opened from is the editor's again, with its own undo
1472
+ const level = editor3DLevel = frame.level, record = editor3DRecords.get(level);
1473
+ editor3DUndoList = record.undo, editor3DRedoList = record.redo;
1474
+ editor3DStroke = undefined;
1475
+ if (!editor3DPrefabStack.length)
1476
+ {
1477
+ // back in the level: its own prefabs that were edited go into it, as one undo
1478
+ const edits = editor3DPrefabEdits;
1479
+ editor3DPrefabEdits = {};
1480
+ Object.keys(edits).length && editor3DChangePart('prefabs', (prefabs={})=> ({...prefabs, ...edits}));
1481
+ editor3DStrokeEnd();
1482
+ }
1483
+ // what the level had in the world is made again: what a class makes, and what else had something made
1484
+ for (const object of editor3DObjects())
1485
+ {
1486
+ const type = level3DTypes.get(object.type);
1487
+ type && (type.make.prototype || frame.made.has(object.id)) && editor3DMakeInstance(object);
1488
+ }
1489
+ editor3DSceneRestore(record.sceneBase);
1490
+ level3DSceneApply(level.scene);
1491
+ editor3DVoxelShow();
1492
+ editor3DTerrainShow();
1493
+ editor3DShadowLight();
1494
+ editor3DSelection.clear();
1495
+ for (const id of frame.selection)
1496
+ editor3DObject(id) && editor3DSelection.add(id);
1497
+ return true;
1498
+ }
1499
+
957
1500
  // remove the selected objects, as one undo
958
1501
  function editor3DDelete()
959
1502
  {
@@ -973,6 +1516,9 @@ function editor3DCopySelection()
973
1516
  const selected = editor3DSelected();
974
1517
  if (!selected.length) return false;
975
1518
  editor3DClipboard = editor3DCopy(selected);
1519
+ // how big each was and where its box was about its place: what is cut has nothing left to measure
1520
+ editor3DClipboardBoxes = new Map(editor3DClipboard.map((copy, i)=>
1521
+ [copy, {size: editor3DSize(selected[i]), offset: editor3DBoxOffset(selected[i])}]));
976
1522
  }
977
1523
 
978
1524
  // copy the selected objects and remove them
@@ -1024,11 +1570,17 @@ function editor3DRestore()
1024
1570
  {
1025
1571
  const type = level3DTypes.get(object.type), made = editor3DInstances.get(object.id);
1026
1572
  if (!type) continue;
1027
- if (made instanceof EngineObject3D && !made.destroyed)
1573
+ // a prefab's instance that play took a part of is made again, whole
1574
+ const broken = (p)=> p instanceof Prefab3D && p.parts.some((part)=> part?.destroyed || broken(part));
1575
+ if (made instanceof EngineObject3D && !made.destroyed && !broken(made))
1028
1576
  editor3DPlaceInstance(made, object);
1029
- else if (type.make.prototype && (!made || made.destroyed))
1577
+ else if (type.make.prototype && (!made || made.destroyed || broken(made)))
1030
1578
  editor3DMakeInstance(object);
1031
1579
  }
1580
+ // the blocks and the terrain as the level has them: what play dug, raised or destroyed is not the level's,
1581
+ // and must not be written into it by the next stroke
1582
+ editor3DVoxelShow();
1583
+ editor3DTerrainShow();
1032
1584
  }
1033
1585
 
1034
1586
  // open or close the editor, the session goes on: the game is paused under it and reads no input
@@ -1046,14 +1598,19 @@ function editor3DSetOpen(open)
1046
1598
  setDebugOverlay(false); // out of the way of the level
1047
1599
  editor3DIsOpen = true;
1048
1600
  editor3DRestore();
1601
+ editorCall('onOpen');
1602
+ editorKeyClashes();
1049
1603
  }
1050
1604
  else
1051
1605
  {
1052
1606
  editor3DDrag = editor3DHover = undefined;
1053
1607
  editor3DMouseOnPanel = false; // the panel hides, with no mouseleave
1054
1608
  editor3DStrokeEnd();
1609
+ while (editor3DPrefabBack()); // the game plays the level, not a prefab that was open
1610
+ editor3DToolHeld = false;
1055
1611
  editor3DIsOpen = false;
1056
1612
  setPaused(editor3DGamePaused);
1613
+ editorCall('onClose');
1057
1614
  }
1058
1615
  inputCapture(editor3DViewOn());
1059
1616
  }
@@ -1077,24 +1634,116 @@ function editor3DClose()
1077
1634
  function editor3DPlay(pos)
1078
1635
  {
1079
1636
  editor3DSetOpen(false);
1080
- editor3DPlayFromMouse && pos && levelEditor.onPlayFrom?.(pos.copy());
1637
+ editor3DPlayFromMouse && pos && editorHas('onPlayFrom') && levelEditor.onPlayFrom(pos.copy());
1081
1638
  }
1082
1639
 
1083
1640
  // rebuild the level through the game's hook and play it
1084
1641
  function editor3DRestart()
1085
1642
  {
1086
- if (!levelEditor.onRestart) return;
1643
+ if (!editorHas('onRestart')) return;
1087
1644
  editor3DSetOpen(false);
1088
1645
  levelEditor.onRestart();
1089
1646
  }
1090
1647
 
1091
- // the level editor's open, close and isOpen go to the 3D editor when the game has a 3D level
1648
+ // 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
1649
+ // key it is on, or its name
1650
+ const editor3DKeyMarks = {'?': 'Slash', '/': 'Slash', ',': 'Comma', '.': 'Period', ';': 'Semicolon', "'": 'Quote',
1651
+ '[': 'BracketLeft', ']': 'BracketRight', '-': 'Minus', '=': 'Equal', '\\': 'Backslash', '`': 'Backquote'};
1652
+ function editor3DKeyCode(name)
1653
+ {
1654
+ return name.length !== 1 ? name : editor3DKeyMarks[name] ??
1655
+ (name >= '0' && name <= '9' ? 'Digit' + name : 'Key' + name.toUpperCase());
1656
+ }
1657
+
1658
+ // the 3D editor's edit functions, what levelEditor.edit3D gives a game's own keys, buttons and tools
1659
+ const editor3DEdit =
1660
+ {
1661
+ get level() { return editor3DLevel; },
1662
+ get objects() { return editor3DObjects(); },
1663
+ get selection() { return editor3DSelection; },
1664
+ selected() { return editor3DSelected(); },
1665
+ made(id) { return editor3DInstances.get(id); },
1666
+ change(change) { return editor3DChange(change); },
1667
+ changePart(name, change) { return editor3DLevelPartNames.includes(name) && editor3DChangePart(name, change); },
1668
+ strokeEnd() { editor3DStrokeEnd(); },
1669
+ strokeCancel() { editor3DStrokeCancel(); },
1670
+ place(type, pos3D) { return level3DTypes.has(type) ? editor3DPlace(type, pos3D) : undefined; },
1671
+ setTransform(object, pos3D, rotation, scale3D) { editor3DSetTransform(object, pos3D, rotation, scale3D); },
1672
+ setProperty(object, name, value)
1673
+ { editor3DSetProperty(object, name, value, level3DTypes.get(object.type)?.defaults[name]); },
1674
+ pos(object) { return editor3DPos(object); },
1675
+ rotation(object) { return editor3DRotation(object); },
1676
+ scale(object) { return editor3DScale(object); },
1677
+ mousePoint() { return editor3DIsOpen && !editor3DMouseOnPanel ? editor3DWithView(editor3DMousePoint) : undefined; },
1678
+ undo(redo=false) { return editor3DUndo(redo); },
1679
+ toJSON() { return editor3DLevel ? editor3DLevelJSON() : ''; },
1680
+ };
1681
+
1682
+ // 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
1683
+ // editor's keys and edit functions through this
1092
1684
  if (debug)
1685
+ editorOther = {wanted: editor3DWanted, isOpen: ()=> editor3DIsOpen, active: ()=> editor3DIsOpen || editor3DSession,
1686
+ open: editor3DOpen, close: editor3DClose, edit: editor3DEdit,
1687
+ hasKey: (name, ctrl)=> !!(ctrl ? editor3DCtrlKeys : editor3DKeys)[editor3DKeyCode(name)]};
1688
+
1689
+ // pick a tool of the editor's own, which puts a tool of the game's down
1690
+ function editor3DPickTool(tool)
1093
1691
  {
1094
- const open2D = levelEditor.open.bind(levelEditor), close2D = levelEditor.close.bind(levelEditor);
1095
- levelEditor.open = ()=> editor3DWanted() ? editor3DOpen() : open2D();
1096
- levelEditor.close = ()=> editor3DIsOpen || editor3DSession ? editor3DClose() : close2D();
1097
- Object.defineProperty(levelEditor, 'isOpen', {get: ()=> editor3DIsOpen || editorIsOpen});
1692
+ editor3DTool = tool;
1693
+ levelEditor.tool = undefined;
1694
+ }
1695
+
1696
+ // 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,
1697
+ // since picking one of those puts the game's tool down
1698
+ let editor3DToolHeld = false, editor3DToolName, editor3DToolPicked = [];
1699
+
1700
+ // what a tool's callbacks are given: where the mouse is on the level or the ground, its ray, and the keys held
1701
+ function editor3DToolAt(ray=render3D.screenToRay(mousePosScreen), shift=false, ctrl=false)
1702
+ { return {pos: editor3DMouseOnPanel ? undefined : editor3DSurface(ray), cell: undefined, ray, shift, ctrl}; }
1703
+
1704
+ // the game's tool, when one is on: the left button is its, a press, a drag and a release one undo, the right
1705
+ // button takes a held press back; true when it has the mouse
1706
+ function editor3DToolUpdate(ray, idle, shift, ctrl)
1707
+ {
1708
+ const picked = [editor3DTool, editor3DBrush];
1709
+ if (levelEditor.tool !== editor3DToolName)
1710
+ editor3DToolName = levelEditor.tool, editor3DToolPicked = picked; // turned on, or off, by its key or button
1711
+ else if (picked.some((value, i)=> value !== editor3DToolPicked[i]))
1712
+ levelEditor.tool = editor3DToolName = undefined; // a tool or a type of the editor's was picked
1713
+ const tool = editorGameTool();
1714
+ if (!tool)
1715
+ {
1716
+ editor3DToolHeld = false;
1717
+ return false;
1718
+ }
1719
+ const at = editor3DToolAt(ray, shift, ctrl);
1720
+ editor3DHover = editor3DBlockHover = editor3DTerrainHover = undefined; // the editor's own cursors are put away
1721
+ if (editor3DToolHeld && mouseWasPressed(2))
1722
+ {
1723
+ editor3DStrokeCancel();
1724
+ editor3DToolHeld = false;
1725
+ }
1726
+ else if (idle && mouseWasPressed(0))
1727
+ editor3DToolHeld = tool.onPress?.(at) !== false;
1728
+ else if (editor3DToolHeld && mouseIsDown(0))
1729
+ tool.onDrag?.(at);
1730
+ if (editor3DToolHeld && !mouseIsDown(0))
1731
+ {
1732
+ tool.onRelease?.(at);
1733
+ editor3DStrokeEnd();
1734
+ editor3DToolHeld = false;
1735
+ }
1736
+ return true;
1737
+ }
1738
+
1739
+ // 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
1740
+ function editor3DDrawGame()
1741
+ {
1742
+ if (!editor3DIsOpen) return;
1743
+ editorCall('onDraw');
1744
+ const held = (...codes)=> inputCaptureRead(()=> codes.some((code)=> keyIsDown(code)));
1745
+ editorGameTool()?.onDraw?.(editor3DToolAt(undefined, held('ShiftLeft', 'ShiftRight'),
1746
+ held('ControlLeft', 'ControlRight', 'MetaLeft', 'MetaRight')));
1098
1747
  }
1099
1748
 
1100
1749
  ///////////////////////////////////////////////////////////////////////////////
@@ -1131,9 +1780,25 @@ const editor3DSaves = ()=> readSaveData(editor3DSaveName(), {});
1131
1780
  let editor3DSaveFailed = false;
1132
1781
 
1133
1782
  // remember the level's objects and parts, or forget them when they are back to the file
1134
- function editor3DAutosave(level=editor3DLevel)
1783
+ function editor3DAutosave(level=editor3DLevel, known)
1135
1784
  {
1136
- const record = editor3DRecords.get(level);
1785
+ // a prefab being edited is kept with the level it is in, as that level would be on going back: its own
1786
+ // prefabs with what is open, and what was edited on the way, in them
1787
+ if (editor3DPrefabLevels.has(level))
1788
+ {
1789
+ const root = editor3DPrefabStack[0]?.level, edits = {...editor3DPrefabEdits};
1790
+ editor3DPrefabStack.forEach((frame, i)=>
1791
+ {
1792
+ const open = editor3DPrefabStack[i + 1]?.level ?? editor3DLevel, known = level3DPrefabs.get(frame.name);
1793
+ const objects = isArray(open.objects) ? editor3DCopy(open.objects) : [];
1794
+ if (known?.fromLevel && JSON.stringify(objects) !== frame.entered)
1795
+ edits[frame.name] = known.attached ? {attached: true, objects} : {objects};
1796
+ });
1797
+ if (root && Object.keys(edits).length)
1798
+ editor3DAutosave({...root, prefabs: {...editor3DLevelPart('prefabs', root), ...edits}}, editor3DRecords.get(root));
1799
+ return;
1800
+ }
1801
+ const record = known ?? editor3DRecords.get(level);
1137
1802
  if (!record || record.pending) return; // edits waiting to be applied keep their autosave
1138
1803
  const saves = editor3DSaves(), objects = isArray(level.objects) ? level.objects : [];
1139
1804
  const parts = editor3DLevelParts(level);
@@ -1200,11 +1865,36 @@ async function editor3DSave(pickAgain=false)
1200
1865
  const level = editor3DLevel, record = editor3DRecords.get(level);
1201
1866
  if (!record) return;
1202
1867
  editor3DStrokeEnd();
1868
+ const open = editor3DPrefabStack[editor3DPrefabStack.length - 1];
1869
+ if (open)
1870
+ {
1871
+ // inside a prefab, Save writes the prefab as a file of its own, or the game keeps it itself
1872
+ const text = editor3DLevelJSON(level), name = open.name + '.json';
1873
+ const written = JSON.stringify(editor3DObjects()); // as the file has it
1874
+ const saved = (async ()=>
1875
+ {
1876
+ await record.saving; // in the order asked for, as a level's saves are
1877
+ const kept = await editorCall('onSave', text, name) === true;
1878
+ kept || saveText(text, name, 'application/json');
1879
+ editor3DPrefabDirty.delete(open.name);
1880
+ open.saved = written;
1881
+ return kept ? 'kept' : 'downloaded';
1882
+ })();
1883
+ record.saving = saved.catch(()=> {});
1884
+ return saved;
1885
+ }
1203
1886
  // what is written, kept as it is now: the level can change while the file is picked and written, and those
1204
1887
  // edits are not in the file, so they stay in the autosave; the level and record are this one's, whichever is
1205
1888
  // open by then
1206
1889
  const text = editor3DLevelJSON(level);
1207
- const saved = (async ()=> { await record.saving; return editor3DSaveText(level, record, text, pickAgain); })();
1890
+ // its place in line is taken now, and the game's hook is asked when its turn comes, so saves are written in
1891
+ // the order they were asked for however long a hook takes
1892
+ const saved = (async ()=>
1893
+ {
1894
+ await record.saving;
1895
+ if (await editorCall('onSave', text, record.fileName) === true) return 'kept'; // the game kept it itself
1896
+ return editor3DSaveText(level, record, text, pickAgain);
1897
+ })();
1208
1898
  record.saving = saved.catch(()=> {});
1209
1899
  return saved;
1210
1900
  }
@@ -1264,6 +1954,8 @@ const EDITOR3D_AXES = {x: vec3(1, 0, 0), y: vec3(0, 1, 0), z: vec3(0, 0, 1)};
1264
1954
 
1265
1955
  let editor3DTool = 'move'; // 'select', 'move', 'rotate', 'scale', 'blocks' or 'terrain'
1266
1956
  let editor3DBlockType = 1; // the block type the Blocks tool places
1957
+ let editor3DBlockBox = false; // the Blocks tool fills a box: the rectangle dragged, editor3DBlockBoxHeight tall
1958
+ let editor3DBlockBoxHeight = 1;
1267
1959
  let editor3DSeconds = 0; // how long the last step was, for a brush held down
1268
1960
  // where the mouse is on the terrain, for the Terrain tool's brush
1269
1961
  /** @type {Vector3|undefined} */
@@ -1273,6 +1965,7 @@ let editor3DTerrainHover;
1273
1965
  let editor3DBlockHover;
1274
1966
  let editor3DGrid = true; // moves, turns and sizes go in steps, Ctrl flips it for a drag
1275
1967
  let editor3DGroundSnap = true; // a body drag slides along what is under the mouse
1968
+ let editor3DLocalAxes = false; // the Move handles follow the object's own axes, not the world's
1276
1969
  let editor3DMoveStep = 1, editor3DRotateStep = 15, editor3DScaleStep = .25; // the rotate step in degrees
1277
1970
  let editor3DHelp = false; // the keys are shown
1278
1971
  let editor3DMouseOnPanel = false;
@@ -1300,7 +1993,7 @@ function editor3DSelectionCenter()
1300
1993
  function editor3DHandles()
1301
1994
  {
1302
1995
  const center = editor3DSelectionCenter(), tool = editor3DTool, handles = [];
1303
- if (!center || tool === 'select' || tool === 'blocks' || tool === 'terrain') return handles;
1996
+ if (!center || tool === 'select' || tool === 'blocks' || tool === 'terrain' || editorGameTool()) return handles;
1304
1997
  const length = editor3DScreenScale(center) * EDITOR3D_HANDLE_SIZE;
1305
1998
  const rotation = editor3DRotation(editor3DSelected()[0]).scale(PI / 180), frame = buildMatrix(vec3(), rotation);
1306
1999
  for (const axis of 'xyz')
@@ -1317,16 +2010,19 @@ function editor3DHandles()
1317
2010
  continue;
1318
2011
  }
1319
2012
 
1320
- // an arrow or scale handle starts a little out from the middle, which is left for the object's body
1321
- const direction = tool === 'move' ? EDITOR3D_AXES[axis] : frame.transformDirection(EDITOR3D_AXES[axis]);
1322
- handles.push({kind: tool === 'move' ? 'arrow' : 'scale', axis, center, length, direction,
2013
+ // an arrow or scale handle starts a little out from the middle, which is left for the object's body; Move
2014
+ // goes along the world's axes, or with own axes on along the object's, as Scale always does
2015
+ const local = tool !== 'move' || editor3DLocalAxes;
2016
+ const way = (k)=> local ? frame.transformDirection(EDITOR3D_AXES[k]) : EDITOR3D_AXES[k];
2017
+ const direction = way(axis), turn = tool === 'move' && local ? rotation : undefined;
2018
+ handles.push({kind: tool === 'move' ? 'arrow' : 'scale', axis, center, length, direction, turn,
1323
2019
  points: [center.add(direction.scale(length * .2)), center.add(direction.scale(length))]});
1324
2020
  if (tool === 'move')
1325
2021
  {
1326
- // the square between the other two axes moves along both
2022
+ // the square between the other two axes moves along both; across is the axis it does not move along
1327
2023
  const [a, b] = 'xyz'.replace(axis, '');
1328
- handles.push({kind: 'plane', axis: a + b, center, length, direction,
1329
- points: [center.add(EDITOR3D_AXES[a].add(EDITOR3D_AXES[b]).scale(length * .35))]});
2024
+ handles.push({kind: 'plane', axis: a + b, center, length, direction, turn, across: axis,
2025
+ ways: [way(a), way(b)], points: [center.add(way(a).add(way(b)).scale(length * .35))]});
1330
2026
  }
1331
2027
  }
1332
2028
  tool === 'scale' && handles.push({kind: 'scaleAll', axis: 'xyz', center, length, points: [center]});
@@ -1393,6 +2089,10 @@ function editor3DParts(made, parts=[])
1393
2089
  parts.push(made);
1394
2090
  for (const child of made.children)
1395
2091
  editor3DParts(child, parts);
2092
+ // a prefab's instance and its parts are one thing, the ones that are not its children too
2093
+ if (made instanceof Prefab3D)
2094
+ for (const part of made.parts)
2095
+ part?.parent === made || editor3DParts(part, parts);
1396
2096
  }
1397
2097
  return parts;
1398
2098
  }
@@ -1466,10 +2166,13 @@ function editor3DPlaceAt(type, ray)
1466
2166
  editor3DStrokeEnd();
1467
2167
  const id = editor3DPlace(type, point);
1468
2168
  if (id === undefined) return;
1469
- const size = editor3DSize(editor3DObject(id)), ignore = new Set(editor3DParts(editor3DInstances.get(id)));
2169
+ const object = editor3DObject(id), size = editor3DSize(object), offset = editor3DBoxOffset(object);
2170
+ const ignore = new Set(editor3DParts(editor3DInstances.get(id)));
1470
2171
  const snapped = editor3DSnapPos(editor3DRest(point, size), size, editor3DGrid ? editor3DMoveStep : 0);
1471
2172
  const y = editor3DLand(snapped.x, snapped.z, point.y, size, ignore);
1472
- editor3DChange((list)=> editor3DSetTransform(list.find((o)=> o.id === id), vec3(snapped.x, y, snapped.z)));
2173
+ // the box stands there, and the object's place is where that puts it
2174
+ const to = vec3(snapped.x, y, snapped.z).subtract(offset);
2175
+ editor3DChange((list)=> editor3DSetTransform(list.find((o)=> o.id === id), to));
1473
2176
  editor3DStrokeEnd();
1474
2177
  }
1475
2178
 
@@ -1479,7 +2182,7 @@ function editor3DDragTo(drag, mouse, ray, snap)
1479
2182
  if (drag.kind === 'blocks')
1480
2183
  return editor3DVoxelDragTo(drag, ray);
1481
2184
  if (drag.kind === 'terrain')
1482
- return editor3DTerrainHover && editor3DTerrainSculpt(editor3DTerrainHover, drag.mode, editor3DSeconds);
2185
+ return editor3DTerrainHover && editor3DTerrainDragTo(drag, editor3DTerrainHover);
1483
2186
  if (drag.kind === 'box')
1484
2187
  {
1485
2188
  drag.to = mouse.copy();
@@ -1490,18 +2193,23 @@ function editor3DDragTo(drag, mouse, ray, snap)
1490
2193
  const was = first && start.get(first.id);
1491
2194
  if (!was) return;
1492
2195
  const size = editor3DSize(first), turned = editor3DTurned(first), step = snap ? editor3DMoveStep : 0;
1493
- const snapTo = (to)=> editor3DSnapPos(to, size, step, turned).subtract(was.pos);
2196
+ const offset = editor3DBoxOffset(first); // snapping and landing go by its box
2197
+ const snapTo = (to)=> editor3DSnapPos(to.add(offset), size, step, turned).subtract(offset).subtract(was.pos);
1494
2198
  let move, turn, factor;
1495
2199
  if (kind === 'arrow')
1496
2200
  {
1497
2201
  const d = editor3DAxisDistance(ray, handle.center, handle.direction) - grab;
1498
- move = handle.direction.multiply(snapTo(was.pos.add(handle.direction.scale(d)))); // along its axis alone
2202
+ // an axis of its own does not follow the grid, so the move goes in steps from where it started
2203
+ move = handle.turn ? handle.direction.scale(step ? round(d / step) * step : d) :
2204
+ handle.direction.multiply(snapTo(was.pos.add(handle.direction.scale(d)))); // along its axis alone
1499
2205
  }
1500
2206
  else if (kind === 'plane')
1501
2207
  {
1502
2208
  const point = editor3DPlanePoint(ray, handle.center, handle.direction);
1503
2209
  if (!point || !grab) return;
1504
- move = vec3(1).subtract(handle.direction).multiply(snapTo(was.pos.add(point.subtract(grab))));
2210
+ const delta = point.subtract(grab), along = (w)=> step ? round(delta.dot(w) / step) * step : delta.dot(w);
2211
+ move = handle.turn ? handle.ways[0].scale(along(handle.ways[0])).add(handle.ways[1].scale(along(handle.ways[1]))) :
2212
+ vec3(1).subtract(handle.direction).multiply(snapTo(was.pos.add(delta)));
1505
2213
  }
1506
2214
  else if (kind === 'body')
1507
2215
  {
@@ -1509,8 +2217,8 @@ function editor3DDragTo(drag, mouse, ray, snap)
1509
2217
  const point = editor3DPlanePoint(ray, drag.level, drag.normal);
1510
2218
  if (!point) return;
1511
2219
  const across = snapTo(was.pos.add(point.subtract(drag.level)));
1512
- const y = editor3DGroundSnap ? editor3DLand(was.pos.x + across.x, was.pos.z + across.z,
1513
- was.pos.y - size.y / 2, size, drag.ignore) : was.pos.y;
2220
+ const y = editor3DGroundSnap ? editor3DLand(was.pos.x + offset.x + across.x, was.pos.z + offset.z + across.z,
2221
+ was.pos.y + offset.y - size.y / 2, size, drag.ignore) - offset.y : was.pos.y;
1514
2222
  move = vec3(across.x, y - was.pos.y, across.z);
1515
2223
  }
1516
2224
  else if (kind === 'ring')
@@ -1611,9 +2319,9 @@ function editor3DDropSelection()
1611
2319
  {
1612
2320
  for (const object of list)
1613
2321
  {
1614
- const to = editor3DSelection.has(object.id) &&
1615
- editor3DDrop(editor3DPos(object), editor3DSize(object), ignore);
1616
- to && editor3DSetTransform(object, to);
2322
+ const offset = editor3DBoxOffset(object), to = editor3DSelection.has(object.id) &&
2323
+ editor3DDrop(editor3DPos(object).add(offset), editor3DSize(object), ignore);
2324
+ to && editor3DSetTransform(object, to.subtract(offset));
1617
2325
  }
1618
2326
  });
1619
2327
  editor3DStrokeEnd();
@@ -1627,11 +2335,12 @@ function editor3DPasteAtMouse()
1627
2335
  if (!copied?.length) return false;
1628
2336
  if (!point) return editor3DPaste();
1629
2337
  const center = copied.reduce((sum, o)=> sum.add(editor3DPos(o)), vec3()).scale(1 / copied.length);
1630
- const bottom = copied.reduce((low, o)=> min(low, editor3DPos(o).y - editor3DSize(o).y / 2), Infinity);
1631
- const first = copied[0], was = editor3DPos(first);
1632
- const snapped = editor3DSnapPos(was.add(vec3(point.x - center.x, 0, point.z - center.z)), editor3DSize(first),
2338
+ const box = (o)=> editor3DClipboardBoxes.get(o) ?? {size: editor3DSize(o), offset: editor3DBoxOffset(o)};
2339
+ const bottom = copied.reduce((low, o)=> min(low, editor3DPos(o).y + box(o).offset.y - box(o).size.y / 2), Infinity);
2340
+ const first = copied[0], size = box(first).size, was = editor3DPos(first).add(box(first).offset); // its box's middle
2341
+ const snapped = editor3DSnapPos(was.add(vec3(point.x - center.x, 0, point.z - center.z)), size,
1633
2342
  editor3DGrid ? editor3DMoveStep : 0, editor3DTurned(first));
1634
- const land = editor3DLand(snapped.x, snapped.z, point.y, editor3DSize(first)) - editor3DSize(first).y / 2;
2343
+ const land = editor3DLand(snapped.x, snapped.z, point.y, size) - size.y / 2;
1635
2344
  return editor3DPaste(vec3(snapped.x - was.x, land - bottom, snapped.z - was.z));
1636
2345
  }
1637
2346
 
@@ -1640,18 +2349,22 @@ function editor3DPasteAtMouse()
1640
2349
  /** @type {Object<string, function(boolean=): any>} */
1641
2350
  const editor3DKeys =
1642
2351
  {
1643
- KeyQ: ()=> { editor3DTool = 'select'; editor3DBrush = undefined; },
1644
- KeyW: ()=> { editor3DTool = 'move'; },
1645
- KeyE: ()=> { editor3DTool = 'rotate'; },
1646
- KeyR: ()=> { editor3DTool = 'scale'; },
1647
- KeyB: ()=> { editor3DTool = 'blocks'; editor3DBrush = undefined; },
1648
- KeyT: ()=> { editor3DTool = 'terrain'; editor3DBrush = undefined; },
2352
+ KeyQ: ()=> { editor3DPickTool('select'); editor3DBrush = undefined; },
2353
+ KeyW: ()=> { editor3DPickTool('move'); },
2354
+ KeyE: ()=> { editor3DPickTool('rotate'); },
2355
+ KeyR: ()=> { editor3DPickTool('scale'); },
2356
+ KeyB: ()=> { editor3DPickTool('blocks'); editor3DBrush = undefined; },
2357
+ KeyT: ()=> { editor3DPickTool('terrain'); editor3DBrush = undefined; },
1649
2358
  KeyP: ()=> editor3DTool === 'blocks' && editor3DVoxelPick(render3D.screenToRay(mousePosScreen)),
2359
+ KeyX: ()=> editor3DTool === 'terrain' ? editor3DTerrainNextBrush() :
2360
+ editor3DTool === 'blocks' && (editor3DBlockBox = !editor3DBlockBox),
1650
2361
  KeyG: ()=> { editor3DGrid = !editor3DGrid; },
2362
+ KeyL: ()=> { editor3DLocalAxes = !editor3DLocalAxes; },
1651
2363
  KeyF: ()=> editor3DFrame(),
1652
2364
  End: ()=> editor3DDropSelection(),
1653
2365
  Delete: ()=> editor3DDelete(),
1654
- Backspace: ()=> editor3DDelete(),
2366
+ Backspace: ()=> editor3DSelection.size ? editor3DDelete() : editor3DPrefabBack(),
2367
+ Enter: ()=> editor3DPrefabEnter(),
1655
2368
  Slash: ()=> { editor3DHelp = !editor3DHelp; },
1656
2369
  };
1657
2370
  /** @type {Object<string, function(boolean=): any>} */
@@ -1663,6 +2376,7 @@ const editor3DCtrlKeys =
1663
2376
  KeyX: ()=> editor3DCut(),
1664
2377
  KeyV: ()=> editor3DPasteAtMouse(),
1665
2378
  KeyD: ()=> editor3DDuplicate(),
2379
+ KeyG: (shift)=> shift ? editor3DUnpack() : editor3DMakePrefab() === true,
1666
2380
  };
1667
2381
 
1668
2382
  // the camera: the right button looks and the keys fly while it is held, the middle button or Space and the left
@@ -1715,7 +2429,13 @@ function editor3DEditorUpdate(seconds)
1715
2429
  if (debugKey && keyWasPressed(debugKey))
1716
2430
  {
1717
2431
  inputClearKey(debugKey);
1718
- editor3DDrag ? editor3DDragCancel() : editor3DPlay(editor3DMousePoint());
2432
+ if (editor3DToolHeld)
2433
+ {
2434
+ editor3DStrokeCancel(); // a held press of the game's tool is taken back
2435
+ editor3DToolHeld = false;
2436
+ }
2437
+ else
2438
+ editor3DDrag ? editor3DDragCancel() : editor3DPlay(editor3DMousePoint());
1719
2439
  return;
1720
2440
  }
1721
2441
 
@@ -1731,9 +2451,20 @@ function editor3DEditorUpdate(seconds)
1731
2451
  const looking = mouseIsDown(2), cameraDrag = looking || mouseIsDown(1) || keyIsDown('Space') || alt;
1732
2452
 
1733
2453
  // the keys, which wait for a drag to end, and fly while the right button is held
1734
- if (!editor3DDrag && !looking)
2454
+ if (!editor3DDrag && !looking && !editor3DToolHeld)
2455
+ {
2456
+ // the game's own keys first, each takes the place of the editor's key of the same spelling
2457
+ const taken = new Set;
2458
+ for (const [spelled, key] of Object.entries(levelEditor.keys))
2459
+ {
2460
+ const withCtrl = spelled.startsWith('ctrl+'), code = editor3DKeyCode(spelled.slice(withCtrl ? 5 : 0));
2461
+ if (withCtrl !== ctrl) continue;
2462
+ taken.add(code);
2463
+ keyWasPressed(code) && key.action.call(levelEditor, shift);
2464
+ }
1735
2465
  for (const [code, action] of Object.entries(ctrl ? editor3DCtrlKeys : editor3DKeys))
1736
- keyWasPressed(code) && action(shift);
2466
+ taken.has(code) || keyWasPressed(code) && action(shift);
2467
+ }
1737
2468
 
1738
2469
  // a right press during a drag puts it back
1739
2470
  if (editor3DDrag && mouseWasPressed(2))
@@ -1741,22 +2472,24 @@ function editor3DEditorUpdate(seconds)
1741
2472
 
1742
2473
  const ray = render3D.screenToRay(mouse), idle = !cameraDrag && !editor3DMouseOnPanel && !editor3DDrag;
1743
2474
  editor3DHover = idle ? editor3DHandleAt(mouse) : undefined;
2475
+ editorCall('onUpdate');
2476
+ if (editor3DToolUpdate(ray, idle, shift, ctrl)) return; // a tool of the game's own has the mouse
1744
2477
 
1745
2478
  // the Blocks tool takes the mouse while the level has a map: Shift removes, Ctrl repaints
1746
- const painting = editor3DTool === 'blocks' && !!editor3DVoxelMap();
2479
+ // a type picked to place is placed, whichever tool is on
2480
+ const painting = editor3DTool === 'blocks' && !editor3DBrush && !!editor3DVoxelMap();
1747
2481
  const mode = shift ? 'remove' : ctrl ? 'paint' : 'place';
1748
2482
  const target = painting && idle ? editor3DVoxelTarget(ray, mode) : undefined;
1749
2483
  editor3DBlockHover = target && {cell: target.cell, mode};
1750
- // the Terrain tool takes it while the level has a terrain: held down it raises the ground, Shift lowers,
1751
- // Ctrl smooths
1752
- const terrain = editor3DTool === 'terrain' ? editor3DTerrainMap() : undefined;
2484
+ // the Terrain tool takes it while the level has a terrain: held down its brush works on the ground
2485
+ const terrain = editor3DTool === 'terrain' && !editor3DBrush ? editor3DTerrainMap() : undefined;
1753
2486
  const reach = terrain && !cameraDrag && !editor3DMouseOnPanel ? terrain.raycast(ray) : undefined;
1754
2487
  editor3DTerrainHover = reach === undefined ? undefined : ray.getPosition(reach);
1755
2488
  if (painting)
1756
2489
  idle && mouseWasPressed(0) && (editor3DDrag = editor3DVoxelPress(ray, mode));
1757
2490
  else if (terrain)
1758
2491
  idle && mouseWasPressed(0) && editor3DTerrainHover &&
1759
- (editor3DDrag = {kind: 'terrain', mode: shift ? 'lower' : ctrl ? 'smooth' : 'raise'});
2492
+ (editor3DDrag = editor3DTerrainPress(editor3DTerrainHover, shift, ctrl));
1760
2493
  else if (idle && mouseWasPressed(0))
1761
2494
  editor3DPress(mouse, ray, shift);
1762
2495
 
@@ -1770,7 +2503,7 @@ function editor3DEditorUpdate(seconds)
1770
2503
  if (debug && globalThis.document?.addEventListener)
1771
2504
  document.addEventListener('keydown', (e)=>
1772
2505
  {
1773
- if (editor3DIsOpen && (e.ctrlKey || e.metaKey) && /^Key[ZYCXVD]$/.test(e.code) && !editorIsTextField(e.target))
2506
+ if (editor3DIsOpen && (e.ctrlKey || e.metaKey) && /^Key[ZYCXVDG]$/.test(e.code) && !editorIsTextField(e.target))
1774
2507
  e.preventDefault();
1775
2508
  });
1776
2509
 
@@ -1782,13 +2515,16 @@ const editor3DHelpLines =
1782
2515
  [
1783
2516
  'Left: select · Shift+Left: add or take away · Left drag from empty space: box select',
1784
2517
  'Q select · W move · E rotate · R scale: drag a handle, or the object itself',
1785
- 'G: grid snap, Ctrl flips it for a drag · End: drop to the ground',
2518
+ 'G: grid snap, Ctrl flips it for a drag · L: move along its own axes · End: drop to the ground',
1786
2519
  'Right button: look, and WASD and QE fly while it is held, Shift faster',
1787
2520
  'Wheel: zoom · Middle drag or Space+drag: pan · Alt+drag: orbit · F: frame the selection',
1788
2521
  'Pick a type, then click to place it, Shift+click keeps placing',
1789
2522
  'B blocks: click or drag places · Shift removes · Ctrl repaints · P picks the type under the mouse',
1790
- 'T terrain: hold to raise the ground · Shift lowers · Ctrl smooths · the brush is in the panel',
2523
+ 'X box fill: a drag fills the rectangle dragged, as tall as the height in the panel',
2524
+ 'T terrain: hold to raise the ground · Shift lowers · Ctrl smooths · X the next brush, flatten and paint',
1791
2525
  'Delete · Ctrl+C / X / V: copy, cut, paste · Ctrl+D: duplicate · Ctrl+Z / Y: undo, redo',
2526
+ 'Ctrl+G: make the selection a prefab, one thing to place many times · Ctrl+Shift+G: unpack it into its objects',
2527
+ 'Enter: open the selected prefab to edit it alone, every instance follows · Backspace with nothing selected: back',
1792
2528
  'Reset to file: the level as its file has it, Restart keeps your edits, Undo brings them back',
1793
2529
  'Esc: play and edit · 0: exit the editor · ?: keys',
1794
2530
  ];
@@ -1798,6 +2534,7 @@ function editor3DHint()
1798
2534
  {
1799
2535
  if (editor3DRecords.get(editor3DLevel)?.pending)
1800
2536
  return 'The level file changed: apply your edits or drop them';
2537
+ if (editorGameTool()) return editorGameTool().hint || levelEditor.tool;
1801
2538
  if (editor3DDrag)
1802
2539
  return editor3DDrag.kind === 'box' ? 'Let go to select what is inside' :
1803
2540
  'Esc or right click puts it back · Ctrl flips the snap';
@@ -1806,10 +2543,14 @@ function editor3DHint()
1806
2543
  if (editor3DBrush)
1807
2544
  return `Click to place a ${editor3DBrush} · Shift keeps placing · Q puts it down`;
1808
2545
  if (editor3DTool === 'terrain')
1809
- return editor3DTerrainMap() ? 'Hold to raise the ground · Shift lowers · Ctrl smooths' :
1810
- 'Add a terrain in the panel to sculpt it';
2546
+ return !editor3DTerrainMap() ? 'Add a terrain in the panel to sculpt it' :
2547
+ {sculpt: 'Hold to raise the ground · Shift lowers · Ctrl smooths',
2548
+ flatten: 'Hold to flatten the ground to the height pressed · Ctrl smooths',
2549
+ paint: 'Hold to paint the ground · Shift takes the paint off'}[editor3DTerrainBrush.mode] +
2550
+ ' · X the next brush';
1811
2551
  if (editor3DTool === 'blocks')
1812
- return editor3DVoxelMap() ? 'Click or drag places · Shift removes · Ctrl repaints · P picks a type' :
2552
+ return editor3DVoxelMap() ? (editor3DBlockBox ? 'Drag a box to fill it' : 'Click or drag places') +
2553
+ ' · Shift removes · Ctrl repaints · P picks a type · X box' :
1813
2554
  'Add a block map in the panel to paint blocks';
1814
2555
  if (editor3DSelection.size)
1815
2556
  return 'Drag a handle or the object · W move · E rotate · R scale · End drops it';
@@ -1883,7 +2624,8 @@ function editor3DDrawRing(center, across, along, radius, color, pixels=2)
1883
2624
  function editor3DVisible(object)
1884
2625
  {
1885
2626
  const made = editor3DInstances.get(object.id);
1886
- return made instanceof EngineObject3D && !made.destroyed && (made.mesh || made.tileInfo) ? made : undefined;
2627
+ return made instanceof EngineObject3D && !made.destroyed && (made.mesh || made.tileInfo || editor3DPrefabBox(made)) ?
2628
+ made : undefined;
1887
2629
  }
1888
2630
 
1889
2631
  // 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
@@ -1893,6 +2635,9 @@ function editor3DBoxMatrix(object)
1893
2635
  const made = editor3DVisible(object), bounds = made?.mesh && (made.mesh.bounds || made.mesh.getBounds());
1894
2636
  if (!made)
1895
2637
  return buildMatrix(editor3DPos(object));
2638
+ const box = editor3DPrefabBox(made);
2639
+ if (box)
2640
+ return buildMatrix(box.pos, undefined, box.size);
1896
2641
  const matrix = render3DObjectMatrix(made).copy();
1897
2642
  return bounds ? matrix.multiply(buildMatrix(bounds.min.add(bounds.max).scale(.5), undefined,
1898
2643
  bounds.max.subtract(bounds.min))) : matrix.multiply(buildMatrix(vec3(), undefined, made.size3D));
@@ -1956,6 +2701,15 @@ function editor3DDraw()
1956
2701
  editor3DDrawRing(editor3DTerrainHover.add(vec3(0, .05, 0)), EDITOR3D_AXES.x, EDITOR3D_AXES.z,
1957
2702
  editor3DTerrainBrush.size / 2, hsl(0, 0, 1, .9), 2.5);
1958
2703
 
2704
+ // the box a box drag fills
2705
+ const region = map && editor3DDrag?.region;
2706
+ if (region)
2707
+ {
2708
+ const size = region.hi.subtract(region.lo).add(vec3(1));
2709
+ editor3DDrawWire(buildMatrix(map.pos3D.add(region.lo).add(size.scale(.5)), undefined, size.add(vec3(.02))),
2710
+ editor3DDrag.mode === 'place' ? hsl(0, 0, 1, .9) : editor3DDrag.mode === 'remove' ? hsl(0, 1, .6) :
2711
+ hsl(.15, 1, .6), 2.5);
2712
+ }
1959
2713
  // the map's edges, to see where it ends
1960
2714
  if (map)
1961
2715
  editor3DDrawWire(buildMatrix(map.pos3D.add(map.mapSize.scale(.5)), undefined, map.mapSize),
@@ -1982,9 +2736,8 @@ function editor3DDraw()
1982
2736
  else if (kind === 'plane')
1983
2737
  {
1984
2738
  // a flat square across its two axes, in the color of the axis it does not move along
1985
- const size = vec3(tip * 1.6).subtract(handle.direction.scale(tip * 1.5));
1986
- r.drawBox(points[0], size, lit ? color : EDITOR3D_AXIS_COLORS['xyz'.replace(axis[0], '')
1987
- .replace(axis[1], '')].scale(1, .7));
2739
+ const size = vec3(tip * 1.6).subtract(EDITOR3D_AXES[handle.across].scale(tip * 1.5));
2740
+ r.drawBox(points[0], size, lit ? color : EDITOR3D_AXIS_COLORS[handle.across].scale(1, .7), handle.turn);
1988
2741
  }
1989
2742
  else if (kind === 'scaleAll')
1990
2743
  r.drawBox(center, tip * 1.4, lit ? color : WHITE);
@@ -1994,7 +2747,8 @@ function editor3DDraw()
1994
2747
  r.drawLine(points[0], points[1], width, color);
1995
2748
  const turn = axis === 'x' ? vec3(0, 0, -PI / 2) : axis === 'z' ? vec3(PI / 2, 0, 0) : undefined;
1996
2749
  const at = points[1].add(handle.direction.scale(tip));
1997
- r.drawMesh(editor3DConeMesh(), buildMatrix(at, turn, vec3(tip * 1.4, tip * 2, tip * 1.4)), undefined, color);
2750
+ const cone = buildMatrix(vec3(), turn, vec3(tip * 1.4, tip * 2, tip * 1.4));
2751
+ r.drawMesh(editor3DConeMesh(), buildMatrix(at, handle.turn).multiply(cone), undefined, color);
1998
2752
  }
1999
2753
  else
2000
2754
  {
@@ -2002,6 +2756,7 @@ function editor3DDraw()
2002
2756
  r.drawBox(points[1], tip, color, editor3DRotation(editor3DSelected()[0]).scale(PI / 180));
2003
2757
  }
2004
2758
  }
2759
+ editor3DDrawGame();
2005
2760
  }
2006
2761
 
2007
2762
  // the editor's drawing on the 2D layer: the name of each marker, and the box being dragged out to select with
@@ -2092,9 +2847,11 @@ function editor3DPanelInit()
2092
2847
  ['scale', 'Scale', 'R'], ['blocks', 'Blocks', 'B'], ['terrain', 'Terrain', 'T']])
2093
2848
  toolButtons[tool] = button(tools, text, ()=>
2094
2849
  {
2095
- editor3DTool = tool;
2850
+ editor3DPickTool(tool);
2096
2851
  (tool === 'select' || tool === 'blocks' || tool === 'terrain') && (editor3DBrush = undefined);
2097
2852
  }, key);
2853
+ // the game's own tools, buttons and controls, under the editor's tools
2854
+ const game = editorElement('div', panel);
2098
2855
  const snap = editorElement('div', panel, box);
2099
2856
  const grid = check(snap, 'Grid snap', (on)=> editor3DGrid = on, 'G, and Ctrl flips it for a drag');
2100
2857
  const steps = row(snap);
@@ -2103,12 +2860,26 @@ function editor3DPanelInit()
2103
2860
  const scaleStep = choice(steps, 'Size', [1, .5, .25], (v)=> editor3DScaleStep = v);
2104
2861
  const ground = check(snap, 'Ground snap', (on)=> editor3DGroundSnap = on,
2105
2862
  'A drag of an object slides it along what is under the mouse');
2863
+ const ownAxes = check(snap, 'Own axes', (on)=> editor3DLocalAxes = on,
2864
+ 'L: the Move handles go along the object\'s own axes, as it is turned, not the world\'s');
2106
2865
 
2107
2866
  // the types, a click picks one up to place, made again when types are added
2108
2867
  editorElement('div', panel, 'color:#aaa;margin-top:4px', 'Place');
2109
2868
  const types = row();
2869
+ const prefabNote = editorElement('div', panel, 'color:#f86'); // why a prefab was not loaded
2110
2870
  const properties = editorElement('div', panel, box);
2111
2871
 
2872
+ // prefabs: the selection made one, or the selected instance's own
2873
+ const prefabBox = editorElement('div', panel, box);
2874
+
2875
+ // the prefab that is open, and the way back
2876
+ const prefabOpen = editorElement('div', panel, 'padding:4px;margin:4px 0;background:#235;border-radius:3px;' +
2877
+ 'display:flex;gap:6px;align-items:center');
2878
+ panel.insertBefore(prefabOpen, panel.firstChild);
2879
+ const prefabName = editorElement('span', prefabOpen, 'flex:1');
2880
+ button(prefabOpen, 'Back', ()=> editor3DPrefabBack(),
2881
+ 'Backspace with nothing selected: back to where the prefab was opened from, every instance of it follows');
2882
+
2112
2883
  // the Blocks tool's box: a map to add, or the types to paint with
2113
2884
  const blocks = editorElement('div', panel, box);
2114
2885
 
@@ -2139,13 +2910,15 @@ function editor3DPanelInit()
2139
2910
 
2140
2911
  const storage = editorElement('div', panel, 'color:#f86;margin-top:4px',
2141
2912
  'Autosave failed, storage is full: Save to a file');
2913
+ const prefabUnsaved = editorElement('div', panel, 'color:#fc6;margin-top:4px');
2142
2914
  const hint = editorElement('div', panel, 'color:#8ab;margin-top:4px');
2143
2915
  const help = editorElement('div', panel, 'color:#aaa;margin-top:4px;border-top:1px solid #444;padding-top:4px');
2144
2916
  for (const line of editor3DHelpLines)
2145
2917
  editorElement('div', help, 'margin:2px 0', line);
2918
+ const gameHelp = editorElement('div', help);
2146
2919
  button(help, 'Close', ()=> editor3DHelp = false, '?');
2147
2920
 
2148
- editor3DPanelParts = {restart, pending, toolButtons, grid, moveStep, rotateStep, scaleStep, ground, types,
2921
+ editor3DPanelParts = {game, gameHelp, restart, pending, toolButtons, grid, moveStep, rotateStep, scaleStep, ground, ownAxes, types, prefabBox, prefabOpen, prefabName, prefabUnsaved, prefabNote,
2149
2922
  properties, blocks, terrainBox, sceneOn, sceneRows, playFrom, storage, hint, help, typeNames: ''};
2150
2923
  }
2151
2924
 
@@ -2160,49 +2933,126 @@ function editor3DPanelUpdate()
2160
2933
  editor3DPanel || editor3DPanelInit();
2161
2934
  editor3DPanel.style.display = '';
2162
2935
  const p = editor3DPanelParts, lit = '2px solid #4af';
2163
- p.restart.style.display = levelEditor.onRestart ? '' : 'none';
2936
+ editorGamePanel(p.game);
2937
+ editorGameHelp(p.gameHelp);
2938
+ p.restart.style.display = editorHas('onRestart') ? '' : 'none';
2164
2939
  p.pending.style.display = editor3DRecords.get(editor3DLevel)?.pending ? '' : 'none';
2165
2940
  for (const tool in p.toolButtons)
2166
2941
  p.toolButtons[tool].style.outline = tool === editor3DTool ? lit : '';
2167
2942
  p.grid.checked = editor3DGrid;
2168
2943
  p.ground.checked = editor3DGroundSnap;
2944
+ p.ownAxes.checked = editor3DLocalAxes;
2169
2945
  p.moveStep.value = editor3DMoveStep + '';
2170
2946
  p.rotateStep.value = editor3DRotateStep + '';
2171
2947
  p.scaleStep.value = editor3DScaleStep + '';
2172
2948
  p.playFrom.checked = editor3DPlayFromMouse;
2173
- p.playFrom.parentElement.style.display = levelEditor.onPlayFrom ? 'flex' : 'none';
2949
+ p.playFrom.parentElement.style.display = editorHas('onPlayFrom') ? 'flex' : 'none';
2174
2950
  p.storage.style.display = editor3DSaveFailed ? '' : 'none';
2175
2951
  p.hint.textContent = editor3DHint();
2176
2952
  p.help.style.display = editor3DHelp ? '' : 'none';
2177
2953
 
2178
2954
  // a button for each type, made again when the types changed
2179
- const names = [...level3DTypes.keys()], typeNames = names.join('\n');
2955
+ const names = editor3DPlaceNames(), plain = names.filter((name)=> !level3DPrefabs.has(name));
2956
+ const prefabs = names.slice(plain.length);
2957
+ const typeNames = plain.join('\n') + '\n\n' + prefabs.join('\n');
2180
2958
  if (p.typeNames !== typeNames)
2181
2959
  {
2182
2960
  p.typeNames = typeNames;
2183
- p.types.replaceChildren(...names.map((name)=>
2961
+ p.typeButtons = names.map((name)=>
2184
2962
  {
2185
2963
  const b = editorElement('button', undefined, 'padding:3px 6px;cursor:pointer', name);
2186
2964
  b.onclick = ()=> { editor3DBrush = editor3DBrush === name ? undefined : name; b.blur(); };
2187
2965
  return b;
2188
- }));
2189
- }
2190
- names.forEach((name, i)=> p.types.children[i].style.outline = name === editor3DBrush ? lit : '');
2966
+ });
2967
+ const label = editorElement('span', undefined, 'color:#aaa;width:100%', 'Prefabs');
2968
+ const load = editorElement('button', undefined, 'padding:3px 6px;cursor:pointer', 'Load prefab…');
2969
+ load.title = 'Add a prefab from a file the editor saved, by its file name, to place in this level';
2970
+ load.onclick = ()=> { editor3DPrefabPickFile(); load.blur(); };
2971
+ p.types.replaceChildren(...p.typeButtons.slice(0, plain.length), label, ...p.typeButtons.slice(plain.length), load);
2972
+ }
2973
+ p.prefabNote.style.display = editor3DPrefabMessage && !editor3DSelection.size ? '' : 'none';
2974
+ p.prefabNote.textContent = editor3DPrefabMessage;
2975
+ names.forEach((name, i)=> p.typeButtons[i].style.outline = name === editor3DBrush ? lit : '');
2191
2976
  editor3DPropertiesUpdate(p.properties);
2977
+ editor3DPrefabBoxUpdate(p.prefabBox);
2978
+ p.prefabOpen.style.display = editor3DPrefabStack.length ? 'flex' : 'none';
2979
+ p.prefabName.textContent = 'Editing prefab ' + editor3DPrefabStack.map((frame)=> frame.name).join(' > ');
2980
+ p.prefabUnsaved.style.display = editor3DPrefabDirty.size ? '' : 'none';
2981
+ p.prefabUnsaved.textContent = 'Edited and not in its file: ' + [...editor3DPrefabDirty].join(', ') +
2982
+ '. Open it with Enter and Save';
2192
2983
  editor3DBlocksUpdate(p.blocks);
2193
2984
  editor3DTerrainBoxUpdate(p.terrainBox);
2194
2985
  p.sceneOn.checked = !!editor3DScene();
2195
2986
  editor3DSceneUpdate(p.sceneRows);
2196
2987
  }
2197
2988
 
2989
+ // the prefab box, shown with a selection: a name and a button to make it a prefab, and for one selected instance
2990
+ // its prefab's name, Unpack, Export, and for a prefab of the level's own whether it is attached
2991
+ function editor3DPrefabBoxUpdate(box)
2992
+ {
2993
+ const selected = editor3DSelected(), made = selected.length === 1 && editor3DInstances.get(selected[0].id);
2994
+ const instance = made instanceof Prefab3D ? made : undefined, prefab = instance && level3DPrefabs.get(instance.prefabName);
2995
+ const key = !selected.length ? '' : [selected.length, instance?.prefabName, prefab?.attached, prefab?.fromLevel,
2996
+ editor3DPrefabMessage, editor3DPrefabStack.length] + '';
2997
+ box.style.display = key ? '' : 'none';
2998
+ if (box.dataset.key === key || box.contains(document.activeElement)) return;
2999
+ box.dataset.key = key;
3000
+ box.replaceChildren();
3001
+ if (!key) return;
3002
+ const field = 'background:#222;color:#eee', press = 'padding:2px 6px;cursor:pointer';
3003
+ const row = ()=> editorElement('div', box, 'display:flex;gap:4px;align-items:center;margin:2px 0;flex-wrap:wrap');
3004
+ if (prefab)
3005
+ {
3006
+ const line = row();
3007
+ editorElement('span', line, 'flex:1;color:#aaa', 'Prefab ' + instance.prefabName);
3008
+ const open = editorElement('button', line, press, 'Edit');
3009
+ open.title = 'Enter: open the prefab alone to edit it, every instance of it follows';
3010
+ open.onclick = ()=> { editor3DPrefabEnter(); open.blur(); };
3011
+ const unpack = editorElement('button', line, press, 'Unpack');
3012
+ unpack.title = 'Ctrl+Shift+G: turn this instance into the objects it is made of';
3013
+ unpack.onclick = ()=> { editor3DUnpack(); unpack.blur(); };
3014
+ const save = editorElement('button', line, press, 'Export');
3015
+ save.title = 'Save the prefab as a file of its own, to load with level3DLoadPrefab';
3016
+ save.onclick = ()=>
3017
+ {
3018
+ saveText(editor3DPrefabJSON(instance.prefabName), instance.prefabName + '.json', 'application/json');
3019
+ save.blur();
3020
+ };
3021
+ if (prefab.fromLevel && !editor3DPrefabStack.length)
3022
+ {
3023
+ const label = editorElement('label', box, 'display:flex;gap:6px;align-items:center;margin:2px 0');
3024
+ label.title = 'Its parts are attached to each instance and move with it as one body, with no ' +
3025
+ 'collision of their own; off, each part is an object of its own and solid';
3026
+ const attached = editorElement('input', label);
3027
+ attached.type = 'checkbox';
3028
+ attached.checked = prefab.attached;
3029
+ attached.onchange = ()=> { editor3DPrefabSetAttached(instance.prefabName, attached.checked); attached.blur(); };
3030
+ editorElement('span', label, '', 'Attached, one body');
3031
+ }
3032
+ }
3033
+ const line = row();
3034
+ const name = editorElement('input', line, field + ';flex:1;min-width:60px');
3035
+ name.placeholder = 'prefab name';
3036
+ const make = editorElement('button', line, press, 'Make prefab');
3037
+ make.title = 'Ctrl+G: the selection becomes one thing to place many times, kept in the level';
3038
+ make.onclick = ()=> { editor3DMakePrefab(name.value); make.blur(); };
3039
+ name.onkeydown = (e)=> { e.key === 'Enter' && (editor3DMakePrefab(name.value), name.blur()); };
3040
+ editor3DPrefabMessage && editorElement('div', box, 'color:#f86', editor3DPrefabMessage);
3041
+ }
3042
+
3043
+ // a level's color as a color input takes it, six digits and no alpha, white for one it can not show
3044
+ function editor3DInputColor(hex) { return /^#[0-9a-f]{6}/i.test(hex) ? hex.slice(0, 7) : '#ffffff'; }
3045
+
2198
3046
  // the terrain box, shown with the Terrain tool: without a terrain, its size, cells and height and a button to add
2199
- // it; with one, the brush's size and strength, the terrain's color, and a button to take it away
3047
+ // it; with one, the brush, its size and strength or the color it paints, the terrain's color, and a button to
3048
+ // take it away
2200
3049
  function editor3DTerrainBoxUpdate(box)
2201
3050
  {
2202
3051
  const map = editor3DTerrainMap(), shown = editor3DTool === 'terrain';
2203
3052
  box.style.display = shown ? '' : 'none';
2204
3053
  if (!shown) return;
2205
- const terrain = editor3DLevelPart('terrain'), key = map ? 'terrain ' + terrain.color : 'none';
3054
+ const terrain = editor3DLevelPart('terrain'), brush = editor3DTerrainBrush;
3055
+ const key = map ? ['terrain', terrain.color, brush.mode] + '' : 'none';
2206
3056
  if (box.dataset.key === key || box.contains(document.activeElement)) return;
2207
3057
  box.dataset.key = key;
2208
3058
  box.replaceChildren();
@@ -2250,13 +3100,27 @@ function editor3DTerrainBoxUpdate(box)
2250
3100
  input.oninput = ()=> set(parseFloat(input.value));
2251
3101
  input.onchange = ()=> input.blur();
2252
3102
  };
2253
- slider('Brush size', 'How far across the brush is', 1, 32, .5, editor3DTerrainBrush.size,
2254
- (v)=> editor3DTerrainBrush.size = v);
2255
- slider('Strength', 'How fast the brush works', .05, 1, .05, editor3DTerrainBrush.strength,
2256
- (v)=> editor3DTerrainBrush.strength = v);
2257
- const color = editorElement('input', line('Color'), field + ';width:60px');
3103
+ const brushes = editorElement('select', line('Brush', 'X: sculpt raises and lowers the ground, flatten brings ' +
3104
+ 'it to the height pressed, paint colors it'), field + ';width:120px');
3105
+ for (const name of editor3DTerrainBrushes)
3106
+ editorElement('option', brushes, '', name[0].toUpperCase() + name.slice(1)).value = name;
3107
+ brushes.value = brush.mode;
3108
+ brushes.onchange = ()=> { brush.mode = brushes.value; brushes.blur(); };
3109
+ slider('Brush size', 'How far across the brush is', 1, 32, .5, brush.size, (v)=> brush.size = v);
3110
+ if (brush.mode === 'paint')
3111
+ {
3112
+ const paint = editorElement('input', line('Paint', 'The color the brush paints'), field + ';width:60px');
3113
+ paint.type = 'color';
3114
+ paint.value = editor3DInputColor(brush.color);
3115
+ paint.oninput = ()=> brush.color = paint.value;
3116
+ paint.onchange = ()=> { brush.color = paint.value; paint.blur(); };
3117
+ }
3118
+ else
3119
+ slider('Strength', 'How fast the brush works', .05, 1, .05, brush.strength, (v)=> brush.strength = v);
3120
+ const color = editorElement('input', line('Color', 'The color of the ground that is not painted'),
3121
+ field + ';width:60px');
2258
3122
  color.type = 'color';
2259
- color.value = /^#[0-9a-f]{6}/i.test(terrain.color) ? terrain.color.slice(0, 7) : '#ffffff';
3123
+ color.value = editor3DInputColor(terrain.color);
2260
3124
  color.oninput = ()=> editor3DChangePart('terrain', (t)=> ({...t, color: color.value}));
2261
3125
  color.onchange = ()=> { color.oninput(); editor3DStrokeEnd(); color.blur(); };
2262
3126
  const remove = editorElement('button', box, 'margin-top:4px;padding:2px 6px;cursor:pointer', 'Remove terrain');
@@ -2272,7 +3136,8 @@ function editor3DBlocksUpdate(box)
2272
3136
  box.style.display = shown ? '' : 'none';
2273
3137
  if (!shown) return;
2274
3138
  const first = map?.tileInfo, texture = first?.textureInfo;
2275
- const key = map ? ['map', editor3DBlockType, texture?.size.x, map.blockTypes.length].join() : 'none';
3139
+ const key = map ? ['map', editor3DBlockType, texture?.size.x, map.blockTypes.length, editor3DBlockBox,
3140
+ map.mapSize].join() : 'none';
2276
3141
  if (box.dataset.key === key || box.contains(document.activeElement)) return;
2277
3142
  box.dataset.key = key;
2278
3143
  box.replaceChildren();
@@ -2293,7 +3158,7 @@ function editor3DBlocksUpdate(box)
2293
3158
  add.title = 'An empty map of blocks, centered, its bottom on the ground';
2294
3159
  add.onclick = ()=>
2295
3160
  {
2296
- const [x, y, z] = inputs.map((i)=> clamp(floor(parseFloat(i.value)) || 1, 1, 256));
3161
+ const [x, y, z] = inputs.map((i, k)=> clamp(floor(parseFloat(i.value)) || [32, 16, 32][k], 1, 256));
2297
3162
  editor3DVoxelAdd(vec3(x, y, z));
2298
3163
  add.blur();
2299
3164
  };
@@ -2333,6 +3198,46 @@ function editor3DBlocksUpdate(box)
2333
3198
  }
2334
3199
  catch {} // an image that can not be drawn or read, the title still says the type
2335
3200
  }
3201
+ // box fill: a drag fills the rectangle dragged, this many layers tall
3202
+ const boxRow = editorElement('label', box, 'display:flex;gap:6px;align-items:center;margin:4px 0 2px');
3203
+ boxRow.title = 'X: a drag fills the whole rectangle from the press to the mouse, Shift clears it, Ctrl repaints';
3204
+ const boxOn = editorElement('input', boxRow);
3205
+ boxOn.type = 'checkbox';
3206
+ boxOn.checked = editor3DBlockBox;
3207
+ boxOn.onchange = ()=> { editor3DBlockBox = boxOn.checked; boxOn.blur(); };
3208
+ editorElement('span', boxRow, 'flex:1', 'Box fill, height');
3209
+ const boxHeight = editorElement('input', boxRow, field);
3210
+ boxHeight.type = 'number';
3211
+ boxHeight.min = '1', boxHeight.max = '256', boxHeight.step = '1';
3212
+ boxHeight.value = editor3DBlockBoxHeight + '';
3213
+ boxHeight.onchange = ()=>
3214
+ {
3215
+ editor3DBlockBoxHeight = clamp(floor(parseFloat(boxHeight.value)) || 1, 1, 256);
3216
+ boxHeight.value = editor3DBlockBoxHeight + '';
3217
+ boxHeight.blur();
3218
+ };
3219
+
3220
+ // the map's size, to change
3221
+ const sizeRow = editorElement('label', box, 'display:flex;gap:4px;align-items:center;margin:2px 0');
3222
+ editorElement('span', sizeRow, 'flex:1', 'Map size');
3223
+ const axes = ['x', 'y', 'z'], sizes = axes.map((axis)=>
3224
+ {
3225
+ const input = editorElement('input', sizeRow, field);
3226
+ input.type = 'number';
3227
+ input.min = '1', input.max = '256', input.step = '1';
3228
+ input.value = map.mapSize[axis] + '';
3229
+ return input;
3230
+ });
3231
+ const resize = editorElement('button', box, 'padding:2px 6px;cursor:pointer;margin-right:4px', 'Resize');
3232
+ resize.title = 'Change the map\'s size, its blocks stay where they are; Undo brings back what was cut off';
3233
+ resize.onclick = ()=>
3234
+ {
3235
+ // a field left empty keeps the size it has
3236
+ const [x, y, z] = sizes.map((i, k)=> clamp(floor(parseFloat(i.value)) || map.mapSize[axes[k]], 1, 256));
3237
+ editor3DVoxelResize(vec3(x, y, z));
3238
+ resize.blur();
3239
+ };
3240
+
2336
3241
  const remove = editorElement('button', box, 'margin-top:4px;padding:2px 6px;cursor:pointer', 'Remove map');
2337
3242
  remove.title = 'Take the block map out of the level, Undo brings it back';
2338
3243
  remove.onclick = ()=> { editor3DVoxelRemove(); remove.blur(); };
@@ -2351,7 +3256,11 @@ function editor3DSceneUpdate(box)
2351
3256
 
2352
3257
  // a setting the block leaves out shows as it is on screen
2353
3258
  const view = editor3DSceneFromView(), value = (name)=> scene[name] ?? view[name];
2354
- const change = (name, v)=> editor3DChangeScene((s={})=> ({...s, [name]: v}));
3259
+ // 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
3260
+ // made again while it has the focus, so a field edited right after another must not put the other one back;
3261
+ // set is given the setting's value now, or what shows for one the block leaves out
3262
+ const change = (name, set)=> editor3DChangeScene((s={})=>
3263
+ ({...s, [name]: typeof set === 'function' ? set(s[name] ?? view[name]) : set}));
2355
3264
  const field = 'background:#222;color:#eee';
2356
3265
  const line = (name, title='')=>
2357
3266
  {
@@ -2379,7 +3288,7 @@ function editor3DSceneUpdate(box)
2379
3288
  {
2380
3289
  const input = editorElement('input', line(name, title), field + ';width:60px');
2381
3290
  input.type = 'color';
2382
- input.value = /^#[0-9a-f]{6}/i.test(hex) ? hex.slice(0, 7) : '#ffffff';
3291
+ input.value = editor3DInputColor(hex);
2383
3292
  return live(input, ()=> set(input.value));
2384
3293
  };
2385
3294
  const number = (row, v, set)=>
@@ -2392,7 +3301,7 @@ function editor3DSceneUpdate(box)
2392
3301
  input.onchange = ()=>
2393
3302
  {
2394
3303
  const n = parseFloat(input.value);
2395
- isNumber(n) && n >= 0 && set(n);
3304
+ isNumber(n) && n >= 0 ? set(v = n) : input.value = v + ''; // one not taken shows what it has again
2396
3305
  editor3DStrokeEnd();
2397
3306
  input.blur();
2398
3307
  };
@@ -2417,7 +3326,7 @@ function editor3DSceneUpdate(box)
2417
3326
  if (sky)
2418
3327
  {
2419
3328
  ['Sky top', 'Sky horizon', 'Sky bottom'].forEach((name, i)=> color(name, '', sky[i], (hex)=>
2420
- change('sky', sky.map((c, k)=> k === i ? hex : c))));
3329
+ change('sky', (now)=> (isArray(now) && now.length === 3 ? now : sky).map((c, k)=> k === i ? hex : c))));
2421
3330
  slider('Ambient', 'How much of the sky colors lights the scene', 0, 1, .05,
2422
3331
  isNumber(scene.ambient) ? scene.ambient : .5, (v)=> change('ambient', v));
2423
3332
  }
@@ -2425,16 +3334,17 @@ function editor3DSceneUpdate(box)
2425
3334
  // the sun, as two angles
2426
3335
  const [around, height] = editor3DSunAngles(value('sunDirection'));
2427
3336
  slider('Sun around', 'Where the sun is around the scene, in degrees', 0, 359, 1, around, (v)=>
2428
- change('sunDirection', editor3DSunDirection(v, editor3DSunAngles(value('sunDirection'))[1])));
2429
- slider('Sun height', 'How high the sun is over the horizon, in degrees', 5, 90, 1, height, (v)=>
2430
- change('sunDirection', editor3DSunDirection(editor3DSunAngles(value('sunDirection'))[0], v)));
3337
+ change('sunDirection', (now)=> editor3DSunDirection(v, editor3DSunAngles(now)[1])));
3338
+ slider('Sun height', 'How high the sun is over the horizon, in degrees', 5, 89, 1, height, (v)=>
3339
+ change('sunDirection', (now)=> editor3DSunDirection(editor3DSunAngles(now)[0], v)));
2431
3340
  color('Sun color', '', value('sunColor'), (hex)=> change('sunColor', hex));
2432
3341
 
2433
3342
  // the fog, by its distances, an end of 0 for none
2434
3343
  const fog = isArray(value('fog')) ? value('fog') : [0, 0], fogRow = line('Fog start, end',
2435
3344
  'Where the fog starts and where it is total, an end of 0 for no fog');
2436
- number(fogRow, fog[0], (v)=> change('fog', [v, fog[1]]));
2437
- number(fogRow, fog[1], (v)=> change('fog', [fog[0], v]));
3345
+ const fogNow = (now)=> isArray(now) && now.length === 2 ? now : fog;
3346
+ number(fogRow, fog[0], (v)=> change('fog', (now)=> [v, fogNow(now)[1]]));
3347
+ number(fogRow, fog[1], (v)=> change('fog', (now)=> [fogNow(now)[0], v]));
2438
3348
  color('Fog color', 'The sky\'s horizon color when the level has a sky and no fog color',
2439
3349
  scene.fogColor ?? (sky ? sky[1] : view.fogColor), (hex)=> change('fogColor', hex));
2440
3350
  toggle('Shadows', 'The sun casts shadows', !!value('shadows'), (on)=> change('shadows', on));