littlejsengine 1.22.0 → 1.23.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/COPYRIGHT.txt +65 -38
- package/EDITOR.md +359 -0
- package/README.md +12 -7
- package/REFERENCE.md +2314 -0
- package/dist/littlejs.d.ts +640 -100
- package/dist/littlejs.esm.js +3596 -1444
- package/dist/littlejs.esm.min.js +3 -3
- package/dist/littlejs.js +3583 -1444
- package/dist/littlejs.min.js +2 -2
- package/dist/littlejs.release.js +2106 -1355
- package/package.json +4 -3
- package/plugins/gltf.js +29 -13
- package/plugins/lightSystem.js +8 -6
- package/plugins/parallax.js +174 -0
- package/plugins/particleEffects.js +37 -22
- package/plugins/pluginExport.js +8 -0
- package/plugins/postProcess.js +5 -2
- package/plugins/render3d.js +59 -21
- package/plugins/render3dEditor.js +1058 -118
- package/plugins/render3dExtras.js +82 -21
- package/plugins/render3dLevel.js +328 -8
- package/plugins/textureSheet.js +1 -0
- package/plugins/tweenSystem.js +39 -34
- package/plugins/uiSystem.js +7 -3
- package/plugins/zzfxm.js +161 -159
- package/src/engine.js +1 -1
- package/src/engineAudio.js +17 -3
- package/src/engineBuild.mjs +18 -2
- package/src/engineDraw.js +26 -10
- package/src/engineEditor.js +515 -52
- package/src/engineExport.js +5 -0
- package/src/engineInput.js +48 -6
- package/src/engineObject.js +675 -675
- package/src/engineRelease.js +17 -2
- package/src/engineSettings.js +14 -0
- package/src/engineTileLayer.js +2 -1
- package/src/engineUtilities.js +291 -291
- package/src/engineWebGL.js +12 -3
|
@@ -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))
|
|
@@ -486,14 +528,15 @@ function editor3DLevelLoaded(level)
|
|
|
486
528
|
const original = editor3DCopy(editor3DObjects()), originalParts = editor3DLevelParts();
|
|
487
529
|
const hash = editor3DContentHash(original, originalParts);
|
|
488
530
|
// sceneBase is the scene the game set, taken before the level's own block is applied
|
|
489
|
-
const record = {fileName: url
|
|
531
|
+
const record = {fileName: editorFileName(url, 'level3D.json'), key: url ?? 'level #' + hash, original,
|
|
490
532
|
originalParts, sceneBase: render3D ? editor3DSceneState() : undefined, hash, pending: undefined,
|
|
491
533
|
fileHandle: undefined, undo: [], redo: []};
|
|
492
534
|
editor3DRecords.set(level, record);
|
|
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
|
-
|
|
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
|
|
@@ -567,6 +629,7 @@ function editor3DChange(change)
|
|
|
567
629
|
const before = editor3DCopy(editor3DObjects()), after = editor3DCopy(before);
|
|
568
630
|
change(after);
|
|
569
631
|
if (editor3DSame(before, after)) return false;
|
|
632
|
+
editor3DFixIds(after); // an object added with no id, or with one in use, gets one of its own
|
|
570
633
|
editor3DSetObjects(after);
|
|
571
634
|
editor3DStroke ||= {before, parts: editor3DLevelParts()};
|
|
572
635
|
return true;
|
|
@@ -585,8 +648,30 @@ function editor3DSetPart(name, part)
|
|
|
585
648
|
}
|
|
586
649
|
else if (name === 'voxels')
|
|
587
650
|
editor3DVoxelShow();
|
|
588
|
-
else
|
|
651
|
+
else if (name === 'terrain')
|
|
589
652
|
editor3DTerrainShow();
|
|
653
|
+
else
|
|
654
|
+
editor3DPrefabsShow();
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
// bring the prefab types in line with the level's own prefabs, and make every instance again: a prefab the level
|
|
658
|
+
// no longer has is no longer a type, one it has is added as level3DLoad adds it, and what a prefab makes is made
|
|
659
|
+
// when its instance is, so each instance is made again
|
|
660
|
+
function editor3DPrefabsShow()
|
|
661
|
+
{
|
|
662
|
+
if (editor3DPrefabLevels.has(editor3DLevel)) return; // inside a prefab, the prefabs are the level's
|
|
663
|
+
const prefabs = editor3DLevelPart('prefabs') ?? {};
|
|
664
|
+
for (const [name, prefab] of level3DPrefabs)
|
|
665
|
+
{
|
|
666
|
+
if (!prefab.fromLevel || prefabs[name]) continue;
|
|
667
|
+
level3DPrefabs.delete(name);
|
|
668
|
+
level3DTypes.delete(name);
|
|
669
|
+
}
|
|
670
|
+
level3DPrefabsAdd(prefabs);
|
|
671
|
+
// every object of a prefab's type, one whose type was not there when the objects were set too
|
|
672
|
+
for (const object of editor3DObjects())
|
|
673
|
+
level3DPrefabs.has(object.type) && editor3DMakeInstance(object);
|
|
674
|
+
editor3DShadowLight();
|
|
590
675
|
}
|
|
591
676
|
|
|
592
677
|
// make the level's parts these, the ones that differ
|
|
@@ -596,6 +681,11 @@ function editor3DSetParts(parts)
|
|
|
596
681
|
editor3DSame(parts[name], editor3DLevelPart(name)) || editor3DSetPart(name, parts[name]);
|
|
597
682
|
}
|
|
598
683
|
|
|
684
|
+
// the stroke being made, started here with the level as it is when there is none: called before the level changes
|
|
685
|
+
/** @return {{before: Array<Object>, parts: Object, painted?: boolean, sculpted?: boolean}} */
|
|
686
|
+
function editor3DStrokeBegin()
|
|
687
|
+
{ return editor3DStroke ||= {before: editor3DCopy(editor3DObjects()), parts: editor3DLevelParts()}; }
|
|
688
|
+
|
|
599
689
|
// change a part of the level as part of the edit being made, as editor3DChange does its objects: change is given
|
|
600
690
|
// a copy of the part, undefined when the level has none, and returns the new one, or undefined for none; false
|
|
601
691
|
// when nothing changed
|
|
@@ -604,7 +694,7 @@ function editor3DChangePart(name, change)
|
|
|
604
694
|
if (!editor3DLevel || editor3DRecords.get(editor3DLevel)?.pending) return false; // its autosave waits first
|
|
605
695
|
const parts = editor3DLevelParts(), after = change(parts[name] && editor3DCopy(parts[name]));
|
|
606
696
|
if (editor3DSame(parts[name], after)) return false;
|
|
607
|
-
|
|
697
|
+
editor3DStrokeBegin();
|
|
608
698
|
editor3DSetPart(name, after);
|
|
609
699
|
return true;
|
|
610
700
|
}
|
|
@@ -681,8 +771,7 @@ function editor3DVoxelSet(cell, type)
|
|
|
681
771
|
const map = editor3DVoxelMap();
|
|
682
772
|
if (!map || editor3DRecords.get(editor3DLevel)?.pending) return false; // its autosave waits first
|
|
683
773
|
if (!editor3DVoxelInside(map, cell) || map.getVoxel(cell) === type) return false;
|
|
684
|
-
|
|
685
|
-
editor3DStroke.painted = true;
|
|
774
|
+
editor3DStrokeBegin().painted = true;
|
|
686
775
|
map.setVoxel(cell, type);
|
|
687
776
|
return true;
|
|
688
777
|
}
|
|
@@ -704,8 +793,85 @@ function editor3DVoxelDragCell(drag, ray)
|
|
|
704
793
|
// the level's terrain: the level's terrain block is the source of truth, the game's HeightMap follows it;
|
|
705
794
|
// sculpting changes the game's map as it goes, and the stroke's end writes its heights into the level
|
|
706
795
|
|
|
707
|
-
// the brush: its size across in world units,
|
|
708
|
-
|
|
796
|
+
// the brush: its size across in world units, how strong it is, 0 to 1, which brush it is, sculpt, flatten or
|
|
797
|
+
// paint, and the color it paints
|
|
798
|
+
const editor3DTerrainBrush = {size: 8, strength: .5, mode: 'sculpt', color: '#8a6a4a'};
|
|
799
|
+
const editor3DTerrainBrushes = ['sculpt', 'flatten', 'paint'];
|
|
800
|
+
|
|
801
|
+
// the paint of the game's terrain map as it is being edited, {colors, cells} as level3DTerrainPaint gives it:
|
|
802
|
+
// from the level, and ahead of it while a stroke paints
|
|
803
|
+
const editor3DTerrainPaints = new WeakMap;
|
|
804
|
+
function editor3DTerrainPaint(map)
|
|
805
|
+
{
|
|
806
|
+
let paint = editor3DTerrainPaints.get(map);
|
|
807
|
+
if (!paint)
|
|
808
|
+
{
|
|
809
|
+
const samples = map.rows * map.columns;
|
|
810
|
+
paint = level3DTerrainPaint(editor3DLevelPart('terrain'), samples) || {colors: [], cells: new Uint8Array(samples)};
|
|
811
|
+
editor3DTerrainPaints.set(map, paint);
|
|
812
|
+
}
|
|
813
|
+
return paint;
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
// the next brush of the Terrain tool
|
|
817
|
+
function editor3DTerrainNextBrush()
|
|
818
|
+
{
|
|
819
|
+
const brushes = editor3DTerrainBrushes;
|
|
820
|
+
editor3DTerrainBrush.mode = brushes[(brushes.indexOf(editor3DTerrainBrush.mode) + 1) % brushes.length];
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
// the drag a press on the ground starts, by the brush and the keys held: sculpt raises, Shift lowers; flatten
|
|
824
|
+
// brings the ground to the height pressed; paint colors it, Shift takes the paint off; Ctrl smooths with the two
|
|
825
|
+
// that shape the ground
|
|
826
|
+
function editor3DTerrainPress(point, shift, ctrl)
|
|
827
|
+
{
|
|
828
|
+
const map = editor3DTerrainMap(), brush = editor3DTerrainBrush.mode;
|
|
829
|
+
if (!map) return;
|
|
830
|
+
if (brush === 'paint')
|
|
831
|
+
return {kind: 'terrain', mode: shift ? 'erase' : 'paint'};
|
|
832
|
+
if (ctrl)
|
|
833
|
+
return {kind: 'terrain', mode: 'smooth'};
|
|
834
|
+
if (brush === 'flatten')
|
|
835
|
+
return {kind: 'terrain', mode: 'flatten', level: clamp((point.y - map.pos3D.y) / map.height)};
|
|
836
|
+
return {kind: 'terrain', mode: shift ? 'lower' : 'raise'};
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
// the mouse held on the ground with a terrain drag
|
|
840
|
+
function editor3DTerrainDragTo(drag, point)
|
|
841
|
+
{
|
|
842
|
+
return drag.mode === 'paint' || drag.mode === 'erase' ? editor3DTerrainPaintAt(point, drag.mode === 'erase') :
|
|
843
|
+
editor3DTerrainSculpt(point, drag.mode, editor3DSeconds, drag.level);
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
// paint the ground around a point with the brush's color, or take its paint off, as part of the edit being made:
|
|
847
|
+
// every sample under the brush, in the game's map now and in the level when the stroke ends; false when nothing
|
|
848
|
+
// changed, or when the terrain has all the colors it can keep, 255
|
|
849
|
+
function editor3DTerrainPaintAt(point, erase=false)
|
|
850
|
+
{
|
|
851
|
+
const map = editor3DTerrainMap();
|
|
852
|
+
if (!map || editor3DRecords.get(editor3DLevel)?.pending) return false; // its autosave waits first
|
|
853
|
+
const paint = editor3DTerrainPaint(map), hex = editor3DInputColor(editor3DTerrainBrush.color);
|
|
854
|
+
let color = erase ? 0 : paint.colors.indexOf(hex) + 1;
|
|
855
|
+
if (!erase && !color && paint.colors.length >= 255) return false;
|
|
856
|
+
const radius = editor3DTerrainBrush.size / 2, rows = map.rows, columns = map.columns;
|
|
857
|
+
const stepX = map.mapSize.x / (columns - 1), stepZ = map.mapSize.y / (rows - 1);
|
|
858
|
+
const x0 = map.pos3D.x - map.mapSize.x / 2, z0 = map.pos3D.z - map.mapSize.y / 2;
|
|
859
|
+
let changed = false;
|
|
860
|
+
for (let r = 0; r < rows; ++r)
|
|
861
|
+
for (let c = 0; c < columns; ++c)
|
|
862
|
+
{
|
|
863
|
+
if (hypot(x0 + c * stepX - point.x, z0 + r * stepZ - point.z) >= radius) continue;
|
|
864
|
+
color ||= erase ? 0 : paint.colors.push(hex); // a new color joins the list with the first sample it paints
|
|
865
|
+
if (paint.cells[r * columns + c] === color) continue;
|
|
866
|
+
paint.cells[r * columns + c] = color;
|
|
867
|
+
changed = true;
|
|
868
|
+
}
|
|
869
|
+
if (!changed) return false;
|
|
870
|
+
editor3DStrokeBegin().sculpted = true;
|
|
871
|
+
level3DTerrainSetColors(map, editor3DLevelPart('terrain'), paint);
|
|
872
|
+
map.rebuild();
|
|
873
|
+
return true;
|
|
874
|
+
}
|
|
709
875
|
|
|
710
876
|
// the game's map of the level's terrain, undefined when the level has none
|
|
711
877
|
function editor3DTerrainMap()
|
|
@@ -734,15 +900,30 @@ function editor3DTerrainShow()
|
|
|
734
900
|
return;
|
|
735
901
|
}
|
|
736
902
|
map.heights = shape.heights;
|
|
737
|
-
|
|
903
|
+
editor3DTerrainPaints.delete(map); // as the level has it
|
|
904
|
+
level3DTerrainSetColors(map, terrain);
|
|
738
905
|
map.rebuild();
|
|
739
906
|
}
|
|
740
907
|
|
|
741
|
-
// write the game's map into the level, as a stroke of sculpting ends, its heights to a thousandth
|
|
908
|
+
// write the game's map into the level, as a stroke of sculpting ends, its heights to a thousandth, and its paint:
|
|
909
|
+
// the colors still in use and runs of a count and a color along the rows, or no paint when none is left
|
|
742
910
|
function editor3DTerrainStore()
|
|
743
911
|
{
|
|
744
912
|
const map = editor3DTerrainMap(), terrain = editor3DLevelPart('terrain');
|
|
745
|
-
|
|
913
|
+
if (!map || !terrain) return;
|
|
914
|
+
terrain.heights = map.heights.map((row)=> row.map((v)=> round(v * 1e3) / 1e3));
|
|
915
|
+
const paint = editor3DTerrainPaints.get(map);
|
|
916
|
+
if (!paint) return; // nothing painted since the level's was read
|
|
917
|
+
const used = paint.colors.map((hex, i)=> paint.cells.includes(i + 1));
|
|
918
|
+
const colors = paint.colors.filter((hex, i)=> used[i]), cells = [];
|
|
919
|
+
const index = paint.colors.map((hex, i)=> used[i] ? colors.indexOf(hex) + 1 : 0);
|
|
920
|
+
for (const v of paint.cells)
|
|
921
|
+
{
|
|
922
|
+
const color = v && index[v - 1];
|
|
923
|
+
cells[cells.length - 1] === color ? ++cells[cells.length - 2] : cells.push(1, color);
|
|
924
|
+
}
|
|
925
|
+
colors.length ? terrain.paint = {colors, cells} : delete terrain.paint;
|
|
926
|
+
editor3DTerrainPaints.delete(map); // read from the level again, with its colors as they are numbered now
|
|
746
927
|
}
|
|
747
928
|
|
|
748
929
|
// 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 +947,9 @@ function editor3DTerrainRemove()
|
|
|
766
947
|
}
|
|
767
948
|
|
|
768
949
|
// 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
|
|
770
|
-
// when the stroke ends; false when nothing changed
|
|
771
|
-
function editor3DTerrainSculpt(point, mode, seconds)
|
|
950
|
+
// under the brush, a soft circle, most in its middle and nothing at its edge, or flatten it toward a height, level,
|
|
951
|
+
// 0 to 1; in the game's map now, in the level when the stroke ends; false when nothing changed
|
|
952
|
+
function editor3DTerrainSculpt(point, mode, seconds, level=0)
|
|
772
953
|
{
|
|
773
954
|
const map = editor3DTerrainMap();
|
|
774
955
|
if (!map || editor3DRecords.get(editor3DLevel)?.pending) return false; // its autosave waits first
|
|
@@ -779,7 +960,7 @@ function editor3DTerrainSculpt(point, mode, seconds)
|
|
|
779
960
|
const c0 = max(0, ceil((point.x - radius - x0) / stepX)), c1 = min(columns - 1, floor((point.x + radius - x0) / stepX));
|
|
780
961
|
const r0 = max(0, ceil((point.z - radius - z0) / stepZ)), r1 = min(rows - 1, floor((point.z + radius - z0) / stepZ));
|
|
781
962
|
// 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;
|
|
963
|
+
const old = mode === 'smooth' ? heights.map((row, r)=> r >= r0 - 1 && r <= r1 + 1 ? row.slice() : row) : heights;
|
|
783
964
|
const rate = strength * 10 * seconds; // world units of height at full strength, 10 a second
|
|
784
965
|
let changed = false;
|
|
785
966
|
for (let r = r0; r <= r1; ++r)
|
|
@@ -797,6 +978,8 @@ function editor3DTerrainSculpt(point, mode, seconds)
|
|
|
797
978
|
old[r + dr]?.[c + dc] !== undefined && (sum += old[r + dr][c + dc], ++count);
|
|
798
979
|
v = h + (sum / count - h) * min(1, rate * falloff);
|
|
799
980
|
}
|
|
981
|
+
else if (mode === 'flatten')
|
|
982
|
+
v = h + (level - h) * min(1, rate * falloff);
|
|
800
983
|
else
|
|
801
984
|
v = clamp(h + (mode === 'lower' ? -1 : 1) * rate * falloff / map.height);
|
|
802
985
|
if (v === h) continue;
|
|
@@ -804,8 +987,7 @@ function editor3DTerrainSculpt(point, mode, seconds)
|
|
|
804
987
|
changed = true;
|
|
805
988
|
}
|
|
806
989
|
if (!changed) return false;
|
|
807
|
-
|
|
808
|
-
editor3DStroke.sculpted = true;
|
|
990
|
+
editor3DStrokeBegin().sculpted = true;
|
|
809
991
|
map.rebuild();
|
|
810
992
|
return true;
|
|
811
993
|
}
|
|
@@ -826,16 +1008,81 @@ function editor3DVoxelPress(ray, mode)
|
|
|
826
1008
|
{
|
|
827
1009
|
const target = editor3DVoxelTarget(ray, mode);
|
|
828
1010
|
if (!target) return;
|
|
829
|
-
const {cell, axis, plane} = target;
|
|
1011
|
+
const {cell, axis, plane, side} = target;
|
|
1012
|
+
if (editor3DBlockBox)
|
|
1013
|
+
{
|
|
1014
|
+
// a box grows out from the face when placing, and into the blocks when removing or repainting
|
|
1015
|
+
const drag = {kind: 'blocks', mode, axis, plane, layer: cell[axis], last: cell, start: cell,
|
|
1016
|
+
grow: mode === 'place' ? side : -side, height: max(1, floor(editor3DBlockBoxHeight)),
|
|
1017
|
+
changed: new Map, region: undefined};
|
|
1018
|
+
editor3DVoxelBoxTo(drag, cell);
|
|
1019
|
+
return drag;
|
|
1020
|
+
}
|
|
830
1021
|
editor3DVoxelApply(cell, mode);
|
|
831
1022
|
return {kind: 'blocks', mode, axis, plane, layer: cell[axis], last: cell};
|
|
832
1023
|
}
|
|
833
1024
|
|
|
1025
|
+
// 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
|
|
1026
|
+
// cell of the same layer is changed, as many layers as its height, the part of it inside the map
|
|
1027
|
+
function editor3DVoxelBoxTo(drag, cell)
|
|
1028
|
+
{
|
|
1029
|
+
const map = editor3DVoxelMap();
|
|
1030
|
+
if (!map) return;
|
|
1031
|
+
for (const [at, type] of drag.changed.values())
|
|
1032
|
+
map.setVoxel(at, type);
|
|
1033
|
+
drag.changed.clear();
|
|
1034
|
+
const lo = vec3(min(drag.start.x, cell.x), min(drag.start.y, cell.y), min(drag.start.z, cell.z));
|
|
1035
|
+
const hi = vec3(max(drag.start.x, cell.x), max(drag.start.y, cell.y), max(drag.start.z, cell.z));
|
|
1036
|
+
const far = drag.layer + drag.grow * (drag.height - 1), axis = drag.axis, s = map.mapSize;
|
|
1037
|
+
lo[axis] = clamp(min(drag.layer, far), 0, s[axis] - 1);
|
|
1038
|
+
hi[axis] = clamp(max(drag.layer, far), 0, s[axis] - 1);
|
|
1039
|
+
for (let z = lo.z; z <= hi.z; ++z)
|
|
1040
|
+
for (let y = lo.y; y <= hi.y; ++y)
|
|
1041
|
+
for (let x = lo.x; x <= hi.x; ++x)
|
|
1042
|
+
{
|
|
1043
|
+
const at = vec3(x, y, z), type = map.getVoxel(at);
|
|
1044
|
+
editor3DVoxelApply(at, drag.mode) && drag.changed.set(x + s.x * (y + s.y * z), [at, type]);
|
|
1045
|
+
}
|
|
1046
|
+
drag.region = {lo, hi};
|
|
1047
|
+
drag.last = cell;
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
// change the size of the level's map in cells, as one undo: it grows and shrinks about its middle across, and at
|
|
1051
|
+
// its top, and its blocks stay where they are in the world, the ones outside the new size gone
|
|
1052
|
+
function editor3DVoxelResize(size)
|
|
1053
|
+
{
|
|
1054
|
+
const changed = editor3DChangePart('voxels', (voxels)=>
|
|
1055
|
+
{
|
|
1056
|
+
const shape = level3DVoxelsShape(voxels);
|
|
1057
|
+
const to = vec3(floor(size.x), floor(size.y), floor(size.z));
|
|
1058
|
+
if (!shape || to.x < 1 || to.y < 1 || to.z < 1 || to.x * to.y * to.z > LEVEL3D_VOXEL_CELLS) return voxels;
|
|
1059
|
+
const from = shape.size, old = new Uint8Array(from.x * from.y * from.z);
|
|
1060
|
+
level3DVoxelsDecode(voxels.blocks, old);
|
|
1061
|
+
// how far each block moves in cells, so the corner moves the other way and the blocks stay put
|
|
1062
|
+
const dx = floor((to.x - from.x) / 2), dz = floor((to.z - from.z) / 2);
|
|
1063
|
+
const data = new Uint8Array(to.x * to.y * to.z);
|
|
1064
|
+
for (let z = 0; z < from.z; ++z)
|
|
1065
|
+
for (let y = 0; y < min(from.y, to.y); ++y)
|
|
1066
|
+
for (let x = 0; x < from.x; ++x)
|
|
1067
|
+
{
|
|
1068
|
+
const nx = x + dx, nz = z + dz;
|
|
1069
|
+
if (nx >= 0 && nz >= 0 && nx < to.x && nz < to.z)
|
|
1070
|
+
data[nx + to.x * (y + to.y * nz)] = old[x + from.x * (y + from.y * z)];
|
|
1071
|
+
}
|
|
1072
|
+
return {...voxels, pos: [shape.pos.x - dx, shape.pos.y, shape.pos.z - dz], size: [to.x, to.y, to.z],
|
|
1073
|
+
blocks: level3DVoxelsEncode(data)};
|
|
1074
|
+
});
|
|
1075
|
+
editor3DStrokeEnd();
|
|
1076
|
+
return changed;
|
|
1077
|
+
}
|
|
1078
|
+
|
|
834
1079
|
// a drag of the Blocks tool goes on: every cell of the layer from the last one to the one under the ray
|
|
835
1080
|
function editor3DVoxelDragTo(drag, ray)
|
|
836
1081
|
{
|
|
837
1082
|
const cell = editor3DVoxelDragCell(drag, ray), last = drag.last;
|
|
838
1083
|
if (!cell) return;
|
|
1084
|
+
if (drag.changed)
|
|
1085
|
+
return editor3DVoxelBoxTo(drag, cell);
|
|
839
1086
|
const d = cell.subtract(last), steps = max(abs(d.x), abs(d.y), abs(d.z));
|
|
840
1087
|
for (let i = 1; i <= steps; ++i)
|
|
841
1088
|
editor3DVoxelApply(vec3(round(last.x + d.x * i / steps), round(last.y + d.y * i / steps),
|
|
@@ -862,12 +1109,12 @@ function editor3DVoxelTarget(ray, mode)
|
|
|
862
1109
|
if (!hit)
|
|
863
1110
|
{
|
|
864
1111
|
const cell = place ? editor3DVoxelDragCell({axis: 'y', plane: 0, layer: 0}, ray) : undefined;
|
|
865
|
-
return cell && {cell, axis: 'y', plane: 0};
|
|
1112
|
+
return cell && {cell, axis: 'y', plane: 0, side: 1};
|
|
866
1113
|
}
|
|
867
1114
|
const n = hit.normal, axis = n.x ? 'x' : n.y ? 'y' : 'z', cell = place ? hit.cell.add(n) : hit.cell;
|
|
868
1115
|
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)};
|
|
1116
|
+
// the face between the block hit and the cell in front of it, and side, the way that face looks along the axis
|
|
1117
|
+
return {cell, axis, plane: cell[axis] + ((n[axis] > 0) === place ? 0 : 1), side: n[axis]};
|
|
871
1118
|
}
|
|
872
1119
|
|
|
873
1120
|
// end the edit being made: one undo, and the autosave
|
|
@@ -941,6 +1188,7 @@ function editor3DSetProperty(object, name, value, defaultValue)
|
|
|
941
1188
|
// add an object of a type at a position, selected; its id, or undefined when the level can not be edited
|
|
942
1189
|
function editor3DPlace(type, pos)
|
|
943
1190
|
{
|
|
1191
|
+
if (editor3DPrefabSelf(type)) return; // it would hold itself
|
|
944
1192
|
let id;
|
|
945
1193
|
const placed = editor3DChange((list)=>
|
|
946
1194
|
{
|
|
@@ -954,6 +1202,325 @@ function editor3DPlace(type, pos)
|
|
|
954
1202
|
return id;
|
|
955
1203
|
}
|
|
956
1204
|
|
|
1205
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
1206
|
+
// prefabs: a selection made one, an instance made objects again, and a prefab's own settings
|
|
1207
|
+
|
|
1208
|
+
// what the panel says about the last prefab action that was refused
|
|
1209
|
+
let editor3DPrefabMessage = '';
|
|
1210
|
+
|
|
1211
|
+
// the box around an object in the world, turned and sized as it is, as its low and high corners
|
|
1212
|
+
function editor3DWorldBox(object, low=vec3(Infinity), high=vec3(-Infinity))
|
|
1213
|
+
{
|
|
1214
|
+
const matrix = editor3DBoxMatrix(object);
|
|
1215
|
+
for (let i = 8; i--;)
|
|
1216
|
+
{
|
|
1217
|
+
const p = matrix.transformPoint(vec3(i & 1 ? .5 : -.5, i & 2 ? .5 : -.5, i & 4 ? .5 : -.5));
|
|
1218
|
+
for (const k of ['x', 'y', 'z'])
|
|
1219
|
+
low[k] = min(low[k], p[k]), high[k] = max(high[k], p[k]);
|
|
1220
|
+
}
|
|
1221
|
+
return {low, high};
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
// is a type the prefab being edited, or one that holds it: a prefab that is open, or one the game made of the
|
|
1225
|
+
// level being edited itself, as a prefab maker does; placed there it would hold itself
|
|
1226
|
+
function editor3DPrefabSelf(type)
|
|
1227
|
+
{
|
|
1228
|
+
const names = editor3DPrefabStack.map((frame)=> frame.name);
|
|
1229
|
+
for (const [name, prefab] of level3DPrefabs)
|
|
1230
|
+
prefab.source === editor3DLevel && names.push(name);
|
|
1231
|
+
return names.some((name)=> editor3DPrefabHolds(type, name));
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
// the types the Place list has, the plain ones and then the prefabs
|
|
1235
|
+
function editor3DPlaceNames()
|
|
1236
|
+
{
|
|
1237
|
+
const all = [...level3DTypes.keys()].filter((name)=> !editor3DPrefabSelf(name));
|
|
1238
|
+
return [...all.filter((name)=> !level3DPrefabs.has(name)), ...all.filter((name)=> level3DPrefabs.has(name))];
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
// does a type hold a prefab, itself or through the prefabs it holds
|
|
1242
|
+
function editor3DPrefabHolds(type, name, depth=0)
|
|
1243
|
+
{
|
|
1244
|
+
return type === name || depth < 8 &&
|
|
1245
|
+
!!level3DPrefabs.get(type)?.objects.some((o)=> editor3DPrefabHolds(o.type, name, depth + 1));
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
// make the selected objects a prefab of the level's own, about the bottom centre of their box, and put one
|
|
1249
|
+
// instance of it where they were, as one undo; a name the level's prefabs have replaces that prefab, and every
|
|
1250
|
+
// instance of it changes; true, or why not, which the panel shows
|
|
1251
|
+
function editor3DMakePrefab(name='')
|
|
1252
|
+
{
|
|
1253
|
+
const refuse = (why)=> editor3DPrefabMessage = why;
|
|
1254
|
+
const selected = editor3DSelected();
|
|
1255
|
+
if (editor3DPrefabStack.length) return refuse('Go back to the level to make a prefab');
|
|
1256
|
+
if (!selected.length) return refuse('Select the objects to make a prefab of');
|
|
1257
|
+
if (!editor3DLevel || editor3DRecords.get(editor3DLevel)?.pending) return refuse('The level can not be edited now');
|
|
1258
|
+
name = String(name).trim();
|
|
1259
|
+
for (let i = 1; !name; ++i)
|
|
1260
|
+
level3DTypes.has('Prefab ' + i) || (name = 'Prefab ' + i);
|
|
1261
|
+
const known = level3DPrefabs.get(name);
|
|
1262
|
+
if (known ? !known.fromLevel : level3DTypes.has(name))
|
|
1263
|
+
return refuse(name + ' is a type the game added, pick another name');
|
|
1264
|
+
if (selected.some((o)=> editor3DPrefabHolds(o.type, name)))
|
|
1265
|
+
return refuse(name + ' can not hold itself');
|
|
1266
|
+
|
|
1267
|
+
// its origin is the bottom centre of the selection, so an instance stands on the ground
|
|
1268
|
+
const low = vec3(Infinity), high = vec3(-Infinity);
|
|
1269
|
+
for (const object of selected)
|
|
1270
|
+
editor3DWorldBox(object, low, high);
|
|
1271
|
+
const origin = vec3(editor3DRound((low.x + high.x) / 2), editor3DRound(low.y), editor3DRound((low.z + high.z) / 2));
|
|
1272
|
+
const objects = selected.map((object, i)=>
|
|
1273
|
+
{
|
|
1274
|
+
const part = {...editor3DCopy(object), id: i + 1};
|
|
1275
|
+
editor3DSetTransform(part, editor3DPos(object).subtract(origin));
|
|
1276
|
+
return part;
|
|
1277
|
+
});
|
|
1278
|
+
editor3DStrokeEnd();
|
|
1279
|
+
const id = editor3DObjects().reduce((next, o)=> max(next, o.id + 1), 1);
|
|
1280
|
+
const ids = new Set(selected.map((o)=> o.id));
|
|
1281
|
+
// the prefab first, so its type is there when the instance is made
|
|
1282
|
+
editor3DChangePart('prefabs', (prefabs={})=>
|
|
1283
|
+
({...prefabs, [name]: prefabs[name]?.attached ? {attached: true, objects} : {objects}}));
|
|
1284
|
+
editor3DChange((list)=>
|
|
1285
|
+
{
|
|
1286
|
+
for (let i = list.length; i--;)
|
|
1287
|
+
ids.has(list[i].id) && list.splice(i, 1);
|
|
1288
|
+
const instance = {id, type: name};
|
|
1289
|
+
editor3DSetTransform(instance, origin);
|
|
1290
|
+
list.push(instance);
|
|
1291
|
+
});
|
|
1292
|
+
editor3DStrokeEnd();
|
|
1293
|
+
editor3DSelection.clear();
|
|
1294
|
+
editor3DSelection.add(id);
|
|
1295
|
+
editor3DPrefabMessage = '';
|
|
1296
|
+
return true;
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
// add a prefab from the text of a file the editor saved, a level file, to the level's own prefabs under a name,
|
|
1300
|
+
// as one undo, and pick it to place; a name the level's prefabs have is replaced, as Make prefab does; true, or
|
|
1301
|
+
// why not, which the panel shows
|
|
1302
|
+
function editor3DPrefabLoad(name, text)
|
|
1303
|
+
{
|
|
1304
|
+
const refuse = (why)=> editor3DPrefabMessage = why;
|
|
1305
|
+
if (editor3DPrefabStack.length) return refuse('Go back to the level to load a prefab');
|
|
1306
|
+
if (!editor3DLevel || editor3DRecords.get(editor3DLevel)?.pending) return refuse('The level can not be edited now');
|
|
1307
|
+
name = String(name).trim();
|
|
1308
|
+
if (!name) return refuse('The prefab needs a name');
|
|
1309
|
+
let file;
|
|
1310
|
+
try { file = JSON.parse(text); }
|
|
1311
|
+
catch { return refuse(name + ' is not a JSON file'); }
|
|
1312
|
+
if (!file || typeof file !== 'object' || !isArray(file.objects))
|
|
1313
|
+
return refuse(name + ' is not a prefab, it has no objects');
|
|
1314
|
+
const known = level3DPrefabs.get(name);
|
|
1315
|
+
if (known ? !known.fromLevel : level3DTypes.has(name))
|
|
1316
|
+
return refuse(name + ' is a type the game added, rename the file');
|
|
1317
|
+
const objects = file.objects.filter((o)=> o && typeof o === 'object');
|
|
1318
|
+
if (objects.some((o)=> editor3DPrefabHolds(o.type, name)))
|
|
1319
|
+
return refuse(name + ' can not hold itself');
|
|
1320
|
+
editor3DStrokeEnd();
|
|
1321
|
+
editor3DChangePart('prefabs', (prefabs={})=> ({...prefabs, [name]: file.attached ? {attached: true, objects} : {objects}}));
|
|
1322
|
+
editor3DStrokeEnd();
|
|
1323
|
+
editor3DPickTool(editor3DTool); // a tool of the game's own is put down
|
|
1324
|
+
editor3DBrush = name;
|
|
1325
|
+
editor3DPrefabMessage = '';
|
|
1326
|
+
return true;
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
// pick a prefab's file and load it, by its file name
|
|
1330
|
+
function editor3DPrefabPickFile()
|
|
1331
|
+
{
|
|
1332
|
+
const input = document.createElement('input');
|
|
1333
|
+
input.type = 'file';
|
|
1334
|
+
input.accept = '.json,application/json';
|
|
1335
|
+
input.onchange = async ()=>
|
|
1336
|
+
{
|
|
1337
|
+
const file = input.files?.[0];
|
|
1338
|
+
file && editor3DPrefabLoad(file.name.replace(/\.json$/i, ''), await file.text());
|
|
1339
|
+
};
|
|
1340
|
+
input.click();
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
// turn the selected instances of prefabs into the objects they are made of, where they are, as one undo; false
|
|
1344
|
+
// with none selected
|
|
1345
|
+
function editor3DUnpack()
|
|
1346
|
+
{
|
|
1347
|
+
const instances = editor3DSelected().filter((o)=> editor3DInstances.get(o.id) instanceof Prefab3D);
|
|
1348
|
+
if (!instances.length) return false;
|
|
1349
|
+
editor3DStrokeEnd();
|
|
1350
|
+
const added = [];
|
|
1351
|
+
const changed = editor3DChange((list)=>
|
|
1352
|
+
{
|
|
1353
|
+
let id = list.reduce((next, o)=> max(next, o.id + 1), 1);
|
|
1354
|
+
for (const instance of instances)
|
|
1355
|
+
{
|
|
1356
|
+
const made = editor3DInstances.get(instance.id);
|
|
1357
|
+
// by the level's own place of it, its origin, which is not where an attached one's handle is
|
|
1358
|
+
const origin = {pos3D: editor3DPos(instance), rotation3D: editor3DRotation(instance).scale(PI / 180),
|
|
1359
|
+
scale3D: editor3DScale(instance)};
|
|
1360
|
+
for (const part of level3DPrefabs.get(made.prefabName)?.objects ?? [])
|
|
1361
|
+
{
|
|
1362
|
+
const at = level3DPrefabPartTransform(origin, part), object = {...editor3DCopy(part), id: id++};
|
|
1363
|
+
editor3DSetTransform(object, at.pos, at.rotation.scale(180 / PI), at.scale);
|
|
1364
|
+
list.push(object);
|
|
1365
|
+
added.push(object.id);
|
|
1366
|
+
}
|
|
1367
|
+
list.splice(list.findIndex((o)=> o.id === instance.id), 1);
|
|
1368
|
+
}
|
|
1369
|
+
});
|
|
1370
|
+
editor3DStrokeEnd();
|
|
1371
|
+
if (!changed) return false;
|
|
1372
|
+
editor3DSelection.clear();
|
|
1373
|
+
for (const id of added)
|
|
1374
|
+
editor3DSelection.add(id);
|
|
1375
|
+
return true;
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
// make a prefab of the level's own attached, its parts the children of each instance, or not, as one undo
|
|
1379
|
+
function editor3DPrefabSetAttached(name, attached)
|
|
1380
|
+
{
|
|
1381
|
+
if (editor3DPrefabStack.length) return false; // a prefab is the level's, set from the level
|
|
1382
|
+
editor3DStrokeEnd();
|
|
1383
|
+
const changed = editor3DChangePart('prefabs', (prefabs)=>
|
|
1384
|
+
{
|
|
1385
|
+
if (!prefabs?.[name]) return prefabs;
|
|
1386
|
+
const {attached: was, ...rest} = prefabs[name];
|
|
1387
|
+
return {...prefabs, [name]: attached ? {attached: true, ...rest} : rest};
|
|
1388
|
+
});
|
|
1389
|
+
editor3DStrokeEnd();
|
|
1390
|
+
return changed;
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1393
|
+
// a prefab as its own file has it, a level file of its objects, to load with level3DLoadPrefab
|
|
1394
|
+
function editor3DPrefabJSON(name)
|
|
1395
|
+
{
|
|
1396
|
+
const prefab = level3DPrefabs.get(name);
|
|
1397
|
+
if (!prefab) return '';
|
|
1398
|
+
return editor3DLevelJSON({...(prefab.attached ? {attached: true} : {}), objects: prefab.objects});
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
1402
|
+
// Edit prefab: the editor on a prefab alone, the level it came from put away until Back
|
|
1403
|
+
|
|
1404
|
+
// the prefabs opened one inside the other, each with the level it was opened from, its name, the selection there
|
|
1405
|
+
// and the ids of that level's objects that had something made for them
|
|
1406
|
+
/** @type {Array<{level: Object, name: string, selection: Array<number>, made: Set<number>, entered?: string}>} */
|
|
1407
|
+
const editor3DPrefabStack = [];
|
|
1408
|
+
// the levels that are a prefab being edited, they have no autosave of their own
|
|
1409
|
+
const editor3DPrefabLevels = new WeakSet;
|
|
1410
|
+
// what was edited in prefabs of the level's own since the level was left, written into it on the way back
|
|
1411
|
+
/** @type {Object<string, Object>} */
|
|
1412
|
+
let editor3DPrefabEdits = {};
|
|
1413
|
+
// the prefabs of the game's own that were edited and are not in a file yet
|
|
1414
|
+
const editor3DPrefabDirty = new Set;
|
|
1415
|
+
// what is known of each prefab's file, by its name, so it outlasts the prefab being closed and opened again: the
|
|
1416
|
+
// save being written, for the next to wait on, what the file has since a Save, and what the prefab holds now
|
|
1417
|
+
/** @type {Map<string, {saving?: Promise<any>, saved?: string, content?: string}>} */
|
|
1418
|
+
const editor3DPrefabFiles = new Map;
|
|
1419
|
+
function editor3DPrefabFile(name)
|
|
1420
|
+
{
|
|
1421
|
+
editor3DPrefabFiles.has(name) || editor3DPrefabFiles.set(name, {});
|
|
1422
|
+
return editor3DPrefabFiles.get(name);
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
// say if a prefab of the game's own is in its file, now that it holds this: once a Save has written it, it is
|
|
1426
|
+
// when what it holds is what was written; before any Save it is not once it was edited
|
|
1427
|
+
function editor3DPrefabFileCheck(name, content, edited=false)
|
|
1428
|
+
{
|
|
1429
|
+
const file = editor3DPrefabFile(name);
|
|
1430
|
+
file.content = content;
|
|
1431
|
+
if (level3DPrefabs.get(name)?.fromLevel) return; // one of the level's own is saved with the level
|
|
1432
|
+
if (file.saved !== undefined)
|
|
1433
|
+
content === file.saved ? editor3DPrefabDirty.delete(name) : editor3DPrefabDirty.add(name);
|
|
1434
|
+
else if (edited)
|
|
1435
|
+
editor3DPrefabDirty.add(name);
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1438
|
+
// destroy what the level being edited has in the world, its objects' and its maps'
|
|
1439
|
+
function editor3DPrefabClear()
|
|
1440
|
+
{
|
|
1441
|
+
for (const made of editor3DInstances.values())
|
|
1442
|
+
made?.destroy?.();
|
|
1443
|
+
editor3DInstances.clear();
|
|
1444
|
+
for (const map of [level3DVoxelMap, level3DTerrainMap])
|
|
1445
|
+
map && !map.destroyed && map.destroy();
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
// open the prefab of an instance, the selected one when none is given, to edit alone: its objects about its own
|
|
1449
|
+
// origin, with an undo of its own, and the level it is in put away; false when it is not a prefab's instance
|
|
1450
|
+
function editor3DPrefabEnter(id)
|
|
1451
|
+
{
|
|
1452
|
+
const selected = editor3DSelected();
|
|
1453
|
+
id ??= selected.length === 1 ? selected[0].id : undefined;
|
|
1454
|
+
const made = editor3DInstances.get(id), from = editor3DLevel;
|
|
1455
|
+
const prefab = made instanceof Prefab3D && level3DPrefabs.get(made.prefabName);
|
|
1456
|
+
if (!prefab || editor3DRecords.get(from)?.pending) return false;
|
|
1457
|
+
editor3DStrokeEnd();
|
|
1458
|
+
editor3DDrag = editor3DHover = undefined;
|
|
1459
|
+
const frame = {level: from, name: made.prefabName, selection: [...editor3DSelection],
|
|
1460
|
+
made: new Set(editor3DInstances.keys()), entered: undefined};
|
|
1461
|
+
editor3DPrefabStack.push(frame);
|
|
1462
|
+
editor3DPrefabClear();
|
|
1463
|
+
const level = {littlejs3D: LEVEL3D_VERSION, ...(prefab.attached ? {attached: true} : {}),
|
|
1464
|
+
objects: editor3DCopy(prefab.objects)};
|
|
1465
|
+
editor3DPrefabLevels.add(level);
|
|
1466
|
+
level3DLoad(level); // the editor takes it as it does any level
|
|
1467
|
+
frame.entered = JSON.stringify(editor3DObjects()); // as the editor has it, an edit is what differs from this
|
|
1468
|
+
return true;
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1471
|
+
// leave the prefab being edited for the level it was opened from: the prefab is what was edited, every instance
|
|
1472
|
+
// of it is made again, and back in the level itself what was edited in its own prefabs is one undo; false when
|
|
1473
|
+
// no prefab is open
|
|
1474
|
+
function editor3DPrefabBack()
|
|
1475
|
+
{
|
|
1476
|
+
const frame = editor3DPrefabStack.pop(), edited = editor3DLevel;
|
|
1477
|
+
if (!frame) return false;
|
|
1478
|
+
editor3DStrokeEnd();
|
|
1479
|
+
editor3DDrag = editor3DHover = undefined;
|
|
1480
|
+
const known = level3DPrefabs.get(frame.name);
|
|
1481
|
+
const objects = editor3DCopy(editor3DObjects()), changed = JSON.stringify(objects) !== frame.entered;
|
|
1482
|
+
editor3DPrefabClear();
|
|
1483
|
+
if (changed)
|
|
1484
|
+
{
|
|
1485
|
+
// the prefab as it is now, for whatever is made next; one of the game's own waits to be saved to its file
|
|
1486
|
+
const prefab = known?.attached ? {attached: true, objects} : {objects}, fromLevel = !!known?.fromLevel;
|
|
1487
|
+
level3DPrefabSet(frame.name, prefab, fromLevel);
|
|
1488
|
+
if (fromLevel)
|
|
1489
|
+
editor3DPrefabEdits[frame.name] = prefab;
|
|
1490
|
+
}
|
|
1491
|
+
// one of the game's own is in its file or not by what it holds now, edited here or not: undone back to how it
|
|
1492
|
+
// was opened after a Save, it is not what the file has
|
|
1493
|
+
editor3DPrefabFileCheck(frame.name, JSON.stringify(objects), changed);
|
|
1494
|
+
|
|
1495
|
+
// the level it was opened from is the editor's again, with its own undo
|
|
1496
|
+
const level = editor3DLevel = frame.level, record = editor3DRecords.get(level);
|
|
1497
|
+
editor3DUndoList = record.undo, editor3DRedoList = record.redo;
|
|
1498
|
+
editor3DStroke = undefined;
|
|
1499
|
+
if (!editor3DPrefabStack.length)
|
|
1500
|
+
{
|
|
1501
|
+
// back in the level: its own prefabs that were edited go into it, as one undo
|
|
1502
|
+
const edits = editor3DPrefabEdits;
|
|
1503
|
+
editor3DPrefabEdits = {};
|
|
1504
|
+
Object.keys(edits).length && editor3DChangePart('prefabs', (prefabs={})=> ({...prefabs, ...edits}));
|
|
1505
|
+
editor3DStrokeEnd();
|
|
1506
|
+
}
|
|
1507
|
+
// what the level had in the world is made again: what a class makes, and what else had something made
|
|
1508
|
+
for (const object of editor3DObjects())
|
|
1509
|
+
{
|
|
1510
|
+
const type = level3DTypes.get(object.type);
|
|
1511
|
+
type && (type.make.prototype || frame.made.has(object.id)) && editor3DMakeInstance(object);
|
|
1512
|
+
}
|
|
1513
|
+
editor3DSceneRestore(record.sceneBase);
|
|
1514
|
+
level3DSceneApply(level.scene);
|
|
1515
|
+
editor3DVoxelShow();
|
|
1516
|
+
editor3DTerrainShow();
|
|
1517
|
+
editor3DShadowLight();
|
|
1518
|
+
editor3DSelection.clear();
|
|
1519
|
+
for (const id of frame.selection)
|
|
1520
|
+
editor3DObject(id) && editor3DSelection.add(id);
|
|
1521
|
+
return true;
|
|
1522
|
+
}
|
|
1523
|
+
|
|
957
1524
|
// remove the selected objects, as one undo
|
|
958
1525
|
function editor3DDelete()
|
|
959
1526
|
{
|
|
@@ -973,6 +1540,9 @@ function editor3DCopySelection()
|
|
|
973
1540
|
const selected = editor3DSelected();
|
|
974
1541
|
if (!selected.length) return false;
|
|
975
1542
|
editor3DClipboard = editor3DCopy(selected);
|
|
1543
|
+
// how big each was and where its box was about its place: what is cut has nothing left to measure
|
|
1544
|
+
editor3DClipboardBoxes = new Map(editor3DClipboard.map((copy, i)=>
|
|
1545
|
+
[copy, {size: editor3DSize(selected[i]), offset: editor3DBoxOffset(selected[i])}]));
|
|
976
1546
|
}
|
|
977
1547
|
|
|
978
1548
|
// copy the selected objects and remove them
|
|
@@ -1024,11 +1594,17 @@ function editor3DRestore()
|
|
|
1024
1594
|
{
|
|
1025
1595
|
const type = level3DTypes.get(object.type), made = editor3DInstances.get(object.id);
|
|
1026
1596
|
if (!type) continue;
|
|
1027
|
-
|
|
1597
|
+
// a prefab's instance that play took a part of is made again, whole
|
|
1598
|
+
const broken = (p)=> p instanceof Prefab3D && p.parts.some((part)=> part?.destroyed || broken(part));
|
|
1599
|
+
if (made instanceof EngineObject3D && !made.destroyed && !broken(made))
|
|
1028
1600
|
editor3DPlaceInstance(made, object);
|
|
1029
|
-
else if (type.make.prototype && (!made || made.destroyed))
|
|
1601
|
+
else if (type.make.prototype && (!made || made.destroyed || broken(made)))
|
|
1030
1602
|
editor3DMakeInstance(object);
|
|
1031
1603
|
}
|
|
1604
|
+
// the blocks and the terrain as the level has them: what play dug, raised or destroyed is not the level's,
|
|
1605
|
+
// and must not be written into it by the next stroke
|
|
1606
|
+
editor3DVoxelShow();
|
|
1607
|
+
editor3DTerrainShow();
|
|
1032
1608
|
}
|
|
1033
1609
|
|
|
1034
1610
|
// open or close the editor, the session goes on: the game is paused under it and reads no input
|
|
@@ -1046,14 +1622,19 @@ function editor3DSetOpen(open)
|
|
|
1046
1622
|
setDebugOverlay(false); // out of the way of the level
|
|
1047
1623
|
editor3DIsOpen = true;
|
|
1048
1624
|
editor3DRestore();
|
|
1625
|
+
editorCall('onOpen');
|
|
1626
|
+
editorKeyClashes();
|
|
1049
1627
|
}
|
|
1050
1628
|
else
|
|
1051
1629
|
{
|
|
1052
1630
|
editor3DDrag = editor3DHover = undefined;
|
|
1053
1631
|
editor3DMouseOnPanel = false; // the panel hides, with no mouseleave
|
|
1054
1632
|
editor3DStrokeEnd();
|
|
1633
|
+
while (editor3DPrefabBack()); // the game plays the level, not a prefab that was open
|
|
1634
|
+
editor3DToolHeld = false;
|
|
1055
1635
|
editor3DIsOpen = false;
|
|
1056
1636
|
setPaused(editor3DGamePaused);
|
|
1637
|
+
editorCall('onClose');
|
|
1057
1638
|
}
|
|
1058
1639
|
inputCapture(editor3DViewOn());
|
|
1059
1640
|
}
|
|
@@ -1077,24 +1658,116 @@ function editor3DClose()
|
|
|
1077
1658
|
function editor3DPlay(pos)
|
|
1078
1659
|
{
|
|
1079
1660
|
editor3DSetOpen(false);
|
|
1080
|
-
editor3DPlayFromMouse && pos && levelEditor.onPlayFrom
|
|
1661
|
+
editor3DPlayFromMouse && pos && editorHas('onPlayFrom') && levelEditor.onPlayFrom(pos.copy());
|
|
1081
1662
|
}
|
|
1082
1663
|
|
|
1083
1664
|
// rebuild the level through the game's hook and play it
|
|
1084
1665
|
function editor3DRestart()
|
|
1085
1666
|
{
|
|
1086
|
-
if (!
|
|
1667
|
+
if (!editorHas('onRestart')) return;
|
|
1087
1668
|
editor3DSetOpen(false);
|
|
1088
1669
|
levelEditor.onRestart();
|
|
1089
1670
|
}
|
|
1090
1671
|
|
|
1091
|
-
// the
|
|
1672
|
+
// 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
|
|
1673
|
+
// key it is on, or its name
|
|
1674
|
+
const editor3DKeyMarks = {'?': 'Slash', '/': 'Slash', ',': 'Comma', '.': 'Period', ';': 'Semicolon', "'": 'Quote',
|
|
1675
|
+
'[': 'BracketLeft', ']': 'BracketRight', '-': 'Minus', '=': 'Equal', '\\': 'Backslash', '`': 'Backquote'};
|
|
1676
|
+
function editor3DKeyCode(name)
|
|
1677
|
+
{
|
|
1678
|
+
return name.length !== 1 ? name : editor3DKeyMarks[name] ??
|
|
1679
|
+
(name >= '0' && name <= '9' ? 'Digit' + name : 'Key' + name.toUpperCase());
|
|
1680
|
+
}
|
|
1681
|
+
|
|
1682
|
+
// the 3D editor's edit functions, what levelEditor.edit3D gives a game's own keys, buttons and tools
|
|
1683
|
+
const editor3DEdit =
|
|
1684
|
+
{
|
|
1685
|
+
get level() { return editor3DLevel; },
|
|
1686
|
+
get objects() { return editor3DObjects(); },
|
|
1687
|
+
get selection() { return editor3DSelection; },
|
|
1688
|
+
selected() { return editor3DSelected(); },
|
|
1689
|
+
made(id) { return editor3DInstances.get(id); },
|
|
1690
|
+
change(change) { return editor3DChange(change); },
|
|
1691
|
+
changePart(name, change) { return editor3DLevelPartNames.includes(name) && editor3DChangePart(name, change); },
|
|
1692
|
+
strokeEnd() { editor3DStrokeEnd(); },
|
|
1693
|
+
strokeCancel() { editor3DStrokeCancel(); },
|
|
1694
|
+
place(type, pos3D) { return level3DTypes.has(type) ? editor3DPlace(type, pos3D) : undefined; },
|
|
1695
|
+
setTransform(object, pos3D, rotation, scale3D) { editor3DSetTransform(object, pos3D, rotation, scale3D); },
|
|
1696
|
+
setProperty(object, name, value)
|
|
1697
|
+
{ editor3DSetProperty(object, name, value, level3DTypes.get(object.type)?.defaults[name]); },
|
|
1698
|
+
pos(object) { return editor3DPos(object); },
|
|
1699
|
+
rotation(object) { return editor3DRotation(object); },
|
|
1700
|
+
scale(object) { return editor3DScale(object); },
|
|
1701
|
+
mousePoint() { return editor3DIsOpen && !editor3DMouseOnPanel ? editor3DWithView(editor3DMousePoint) : undefined; },
|
|
1702
|
+
undo(redo=false) { return editor3DUndo(redo); },
|
|
1703
|
+
toJSON() { return editor3DLevel ? editor3DLevelJSON() : ''; },
|
|
1704
|
+
};
|
|
1705
|
+
|
|
1706
|
+
// 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
|
|
1707
|
+
// editor's keys and edit functions through this
|
|
1092
1708
|
if (debug)
|
|
1709
|
+
editorOther = {wanted: editor3DWanted, isOpen: ()=> editor3DIsOpen, active: ()=> editor3DIsOpen || editor3DSession,
|
|
1710
|
+
open: editor3DOpen, close: editor3DClose, edit: editor3DEdit,
|
|
1711
|
+
hasKey: (name, ctrl)=> !!(ctrl ? editor3DCtrlKeys : editor3DKeys)[editor3DKeyCode(name)]};
|
|
1712
|
+
|
|
1713
|
+
// pick a tool of the editor's own, which puts a tool of the game's down
|
|
1714
|
+
function editor3DPickTool(tool)
|
|
1715
|
+
{
|
|
1716
|
+
editor3DTool = tool;
|
|
1717
|
+
levelEditor.tool = undefined;
|
|
1718
|
+
}
|
|
1719
|
+
|
|
1720
|
+
// 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,
|
|
1721
|
+
// since picking one of those puts the game's tool down
|
|
1722
|
+
let editor3DToolHeld = false, editor3DToolName, editor3DToolPicked = [];
|
|
1723
|
+
|
|
1724
|
+
// what a tool's callbacks are given: where the mouse is on the level or the ground, its ray, and the keys held
|
|
1725
|
+
function editor3DToolAt(ray=render3D.screenToRay(mousePosScreen), shift=false, ctrl=false)
|
|
1726
|
+
{ return {pos: editor3DMouseOnPanel ? undefined : editor3DSurface(ray), cell: undefined, ray, shift, ctrl}; }
|
|
1727
|
+
|
|
1728
|
+
// the game's tool, when one is on: the left button is its, a press, a drag and a release one undo, the right
|
|
1729
|
+
// button takes a held press back; true when it has the mouse
|
|
1730
|
+
function editor3DToolUpdate(ray, idle, shift, ctrl)
|
|
1731
|
+
{
|
|
1732
|
+
const picked = [editor3DTool, editor3DBrush];
|
|
1733
|
+
if (levelEditor.tool !== editor3DToolName)
|
|
1734
|
+
editor3DToolName = levelEditor.tool, editor3DToolPicked = picked; // turned on, or off, by its key or button
|
|
1735
|
+
else if (picked.some((value, i)=> value !== editor3DToolPicked[i]))
|
|
1736
|
+
levelEditor.tool = editor3DToolName = undefined; // a tool or a type of the editor's was picked
|
|
1737
|
+
const tool = editorGameTool();
|
|
1738
|
+
if (!tool)
|
|
1739
|
+
{
|
|
1740
|
+
editor3DToolHeld = false;
|
|
1741
|
+
return false;
|
|
1742
|
+
}
|
|
1743
|
+
const at = editor3DToolAt(ray, shift, ctrl);
|
|
1744
|
+
editor3DHover = editor3DBlockHover = editor3DTerrainHover = undefined; // the editor's own cursors are put away
|
|
1745
|
+
if (editor3DToolHeld && mouseWasPressed(2))
|
|
1746
|
+
{
|
|
1747
|
+
editor3DStrokeCancel();
|
|
1748
|
+
editor3DToolHeld = false;
|
|
1749
|
+
}
|
|
1750
|
+
else if (idle && mouseWasPressed(0))
|
|
1751
|
+
editor3DToolHeld = tool.onPress?.(at) !== false;
|
|
1752
|
+
else if (editor3DToolHeld && mouseIsDown(0))
|
|
1753
|
+
tool.onDrag?.(at);
|
|
1754
|
+
if (editor3DToolHeld && !mouseIsDown(0))
|
|
1755
|
+
{
|
|
1756
|
+
tool.onRelease?.(at);
|
|
1757
|
+
editor3DStrokeEnd();
|
|
1758
|
+
editor3DToolHeld = false;
|
|
1759
|
+
}
|
|
1760
|
+
return true;
|
|
1761
|
+
}
|
|
1762
|
+
|
|
1763
|
+
// 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
|
|
1764
|
+
function editor3DDrawGame()
|
|
1093
1765
|
{
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1766
|
+
if (!editor3DIsOpen) return;
|
|
1767
|
+
editorCall('onDraw');
|
|
1768
|
+
const held = (...codes)=> inputCaptureRead(()=> codes.some((code)=> keyIsDown(code)));
|
|
1769
|
+
editorGameTool()?.onDraw?.(editor3DToolAt(undefined, held('ShiftLeft', 'ShiftRight'),
|
|
1770
|
+
held('ControlLeft', 'ControlRight', 'MetaLeft', 'MetaRight')));
|
|
1098
1771
|
}
|
|
1099
1772
|
|
|
1100
1773
|
///////////////////////////////////////////////////////////////////////////////
|
|
@@ -1131,9 +1804,27 @@ const editor3DSaves = ()=> readSaveData(editor3DSaveName(), {});
|
|
|
1131
1804
|
let editor3DSaveFailed = false;
|
|
1132
1805
|
|
|
1133
1806
|
// remember the level's objects and parts, or forget them when they are back to the file
|
|
1134
|
-
function editor3DAutosave(level=editor3DLevel)
|
|
1807
|
+
function editor3DAutosave(level=editor3DLevel, known)
|
|
1135
1808
|
{
|
|
1136
|
-
|
|
1809
|
+
// a prefab being edited is kept with the level it is in, as that level would be on going back: its own
|
|
1810
|
+
// prefabs with what is open, and what was edited on the way, in them
|
|
1811
|
+
if (editor3DPrefabLevels.has(level))
|
|
1812
|
+
{
|
|
1813
|
+
const root = editor3DPrefabStack[0]?.level, edits = {...editor3DPrefabEdits};
|
|
1814
|
+
editor3DPrefabStack.forEach((frame, i)=>
|
|
1815
|
+
{
|
|
1816
|
+
const open = editor3DPrefabStack[i + 1]?.level ?? editor3DLevel, known = level3DPrefabs.get(frame.name);
|
|
1817
|
+
const objects = isArray(open.objects) ? editor3DCopy(open.objects) : [];
|
|
1818
|
+
if (known?.fromLevel && JSON.stringify(objects) !== frame.entered)
|
|
1819
|
+
edits[frame.name] = known.attached ? {attached: true, objects} : {objects};
|
|
1820
|
+
});
|
|
1821
|
+
// with nothing edited any more, an undo back to how the prefab was, the level's autosave is the level's own
|
|
1822
|
+
// again, which takes away one of an edit that was undone and keeps the level's other edits
|
|
1823
|
+
const prefabs = {...editor3DLevelPart('prefabs', root), ...edits};
|
|
1824
|
+
root && editor3DAutosave(Object.keys(prefabs).length ? {...root, prefabs} : root, editor3DRecords.get(root));
|
|
1825
|
+
return;
|
|
1826
|
+
}
|
|
1827
|
+
const record = known ?? editor3DRecords.get(level);
|
|
1137
1828
|
if (!record || record.pending) return; // edits waiting to be applied keep their autosave
|
|
1138
1829
|
const saves = editor3DSaves(), objects = isArray(level.objects) ? level.objects : [];
|
|
1139
1830
|
const parts = editor3DLevelParts(level);
|
|
@@ -1144,12 +1835,9 @@ function editor3DAutosave(level=editor3DLevel)
|
|
|
1144
1835
|
else
|
|
1145
1836
|
saves[record.key] = {hash: record.hash, savedHash: record.savedHash, objects: editor3DCopy(objects),
|
|
1146
1837
|
...parts};
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
editor3DSaveFailed = false;
|
|
1151
|
-
}
|
|
1152
|
-
catch { editor3DSaveFailed = true; }
|
|
1838
|
+
const failed = editorSaveFailed; // the 2D editor's own flag is its own
|
|
1839
|
+
editor3DSaveFailed = editorWriteSaves(saves, record.key, editor3DSaveName());
|
|
1840
|
+
editorSaveFailed = failed;
|
|
1153
1841
|
}
|
|
1154
1842
|
|
|
1155
1843
|
// put the autosaved edits of a file that changed into the level, as one undo
|
|
@@ -1200,11 +1888,43 @@ async function editor3DSave(pickAgain=false)
|
|
|
1200
1888
|
const level = editor3DLevel, record = editor3DRecords.get(level);
|
|
1201
1889
|
if (!record) return;
|
|
1202
1890
|
editor3DStrokeEnd();
|
|
1891
|
+
const open = editor3DPrefabStack[editor3DPrefabStack.length - 1];
|
|
1892
|
+
if (open)
|
|
1893
|
+
{
|
|
1894
|
+
// inside a prefab, Save writes the prefab as a file of its own, or the game keeps it itself
|
|
1895
|
+
const text = editor3DLevelJSON(level), name = open.name + '.json';
|
|
1896
|
+
const written = JSON.stringify(editor3DObjects()); // as the file has it
|
|
1897
|
+
// its place in line is the prefab's, by its name: closed and opened again it is a new level here, and its
|
|
1898
|
+
// saves still go in the order they were asked for
|
|
1899
|
+
const file = editor3DPrefabFile(open.name), before = file.saving;
|
|
1900
|
+
const saved = (async ()=>
|
|
1901
|
+
{
|
|
1902
|
+
await before;
|
|
1903
|
+
const kept = await editorCall('onSave', text, name) === true;
|
|
1904
|
+
kept || saveText(text, name, 'application/json');
|
|
1905
|
+
file.saved = written;
|
|
1906
|
+
// it is in its file when what it holds now is what was written: edited again while the save was
|
|
1907
|
+
// kept, or gone back from with more edits, it still waits
|
|
1908
|
+
const at = editor3DPrefabStack.indexOf(open);
|
|
1909
|
+
const now = at < 0 ? file.content : JSON.stringify((editor3DPrefabStack[at + 1]?.level ?? editor3DLevel).objects);
|
|
1910
|
+
editor3DPrefabFileCheck(open.name, now ?? written);
|
|
1911
|
+
return kept ? 'kept' : 'downloaded';
|
|
1912
|
+
})();
|
|
1913
|
+
file.saving = saved.catch(()=> {});
|
|
1914
|
+
return saved;
|
|
1915
|
+
}
|
|
1203
1916
|
// what is written, kept as it is now: the level can change while the file is picked and written, and those
|
|
1204
1917
|
// edits are not in the file, so they stay in the autosave; the level and record are this one's, whichever is
|
|
1205
1918
|
// open by then
|
|
1206
1919
|
const text = editor3DLevelJSON(level);
|
|
1207
|
-
|
|
1920
|
+
// its place in line is taken now, and the game's hook is asked when its turn comes, so saves are written in
|
|
1921
|
+
// the order they were asked for however long a hook takes
|
|
1922
|
+
const saved = (async ()=>
|
|
1923
|
+
{
|
|
1924
|
+
await record.saving;
|
|
1925
|
+
if (await editorCall('onSave', text, record.fileName) === true) return 'kept'; // the game kept it itself
|
|
1926
|
+
return editor3DSaveText(level, record, text, pickAgain);
|
|
1927
|
+
})();
|
|
1208
1928
|
record.saving = saved.catch(()=> {});
|
|
1209
1929
|
return saved;
|
|
1210
1930
|
}
|
|
@@ -1264,6 +1984,8 @@ const EDITOR3D_AXES = {x: vec3(1, 0, 0), y: vec3(0, 1, 0), z: vec3(0, 0, 1)};
|
|
|
1264
1984
|
|
|
1265
1985
|
let editor3DTool = 'move'; // 'select', 'move', 'rotate', 'scale', 'blocks' or 'terrain'
|
|
1266
1986
|
let editor3DBlockType = 1; // the block type the Blocks tool places
|
|
1987
|
+
let editor3DBlockBox = false; // the Blocks tool fills a box: the rectangle dragged, editor3DBlockBoxHeight tall
|
|
1988
|
+
let editor3DBlockBoxHeight = 1;
|
|
1267
1989
|
let editor3DSeconds = 0; // how long the last step was, for a brush held down
|
|
1268
1990
|
// where the mouse is on the terrain, for the Terrain tool's brush
|
|
1269
1991
|
/** @type {Vector3|undefined} */
|
|
@@ -1273,6 +1995,7 @@ let editor3DTerrainHover;
|
|
|
1273
1995
|
let editor3DBlockHover;
|
|
1274
1996
|
let editor3DGrid = true; // moves, turns and sizes go in steps, Ctrl flips it for a drag
|
|
1275
1997
|
let editor3DGroundSnap = true; // a body drag slides along what is under the mouse
|
|
1998
|
+
let editor3DLocalAxes = false; // the Move handles follow the object's own axes, not the world's
|
|
1276
1999
|
let editor3DMoveStep = 1, editor3DRotateStep = 15, editor3DScaleStep = .25; // the rotate step in degrees
|
|
1277
2000
|
let editor3DHelp = false; // the keys are shown
|
|
1278
2001
|
let editor3DMouseOnPanel = false;
|
|
@@ -1300,7 +2023,7 @@ function editor3DSelectionCenter()
|
|
|
1300
2023
|
function editor3DHandles()
|
|
1301
2024
|
{
|
|
1302
2025
|
const center = editor3DSelectionCenter(), tool = editor3DTool, handles = [];
|
|
1303
|
-
if (!center || tool === 'select' || tool === 'blocks' || tool === 'terrain') return handles;
|
|
2026
|
+
if (!center || tool === 'select' || tool === 'blocks' || tool === 'terrain' || editorGameTool()) return handles;
|
|
1304
2027
|
const length = editor3DScreenScale(center) * EDITOR3D_HANDLE_SIZE;
|
|
1305
2028
|
const rotation = editor3DRotation(editor3DSelected()[0]).scale(PI / 180), frame = buildMatrix(vec3(), rotation);
|
|
1306
2029
|
for (const axis of 'xyz')
|
|
@@ -1317,16 +2040,19 @@ function editor3DHandles()
|
|
|
1317
2040
|
continue;
|
|
1318
2041
|
}
|
|
1319
2042
|
|
|
1320
|
-
// an arrow or scale handle starts a little out from the middle, which is left for the object's body
|
|
1321
|
-
|
|
1322
|
-
|
|
2043
|
+
// an arrow or scale handle starts a little out from the middle, which is left for the object's body; Move
|
|
2044
|
+
// goes along the world's axes, or with own axes on along the object's, as Scale always does
|
|
2045
|
+
const local = tool !== 'move' || editor3DLocalAxes;
|
|
2046
|
+
const way = (k)=> local ? frame.transformDirection(EDITOR3D_AXES[k]) : EDITOR3D_AXES[k];
|
|
2047
|
+
const direction = way(axis), turn = tool === 'move' && local ? rotation : undefined;
|
|
2048
|
+
handles.push({kind: tool === 'move' ? 'arrow' : 'scale', axis, center, length, direction, turn,
|
|
1323
2049
|
points: [center.add(direction.scale(length * .2)), center.add(direction.scale(length))]});
|
|
1324
2050
|
if (tool === 'move')
|
|
1325
2051
|
{
|
|
1326
|
-
// the square between the other two axes moves along both
|
|
2052
|
+
// the square between the other two axes moves along both; across is the axis it does not move along
|
|
1327
2053
|
const [a, b] = 'xyz'.replace(axis, '');
|
|
1328
|
-
handles.push({kind: 'plane', axis: a + b, center, length, direction,
|
|
1329
|
-
points: [center.add(
|
|
2054
|
+
handles.push({kind: 'plane', axis: a + b, center, length, direction, turn, across: axis,
|
|
2055
|
+
ways: [way(a), way(b)], points: [center.add(way(a).add(way(b)).scale(length * .35))]});
|
|
1330
2056
|
}
|
|
1331
2057
|
}
|
|
1332
2058
|
tool === 'scale' && handles.push({kind: 'scaleAll', axis: 'xyz', center, length, points: [center]});
|
|
@@ -1393,6 +2119,10 @@ function editor3DParts(made, parts=[])
|
|
|
1393
2119
|
parts.push(made);
|
|
1394
2120
|
for (const child of made.children)
|
|
1395
2121
|
editor3DParts(child, parts);
|
|
2122
|
+
// a prefab's instance and its parts are one thing, the ones that are not its children too
|
|
2123
|
+
if (made instanceof Prefab3D)
|
|
2124
|
+
for (const part of made.parts)
|
|
2125
|
+
part?.parent === made || editor3DParts(part, parts);
|
|
1396
2126
|
}
|
|
1397
2127
|
return parts;
|
|
1398
2128
|
}
|
|
@@ -1466,10 +2196,13 @@ function editor3DPlaceAt(type, ray)
|
|
|
1466
2196
|
editor3DStrokeEnd();
|
|
1467
2197
|
const id = editor3DPlace(type, point);
|
|
1468
2198
|
if (id === undefined) return;
|
|
1469
|
-
const
|
|
2199
|
+
const object = editor3DObject(id), size = editor3DSize(object), offset = editor3DBoxOffset(object);
|
|
2200
|
+
const ignore = new Set(editor3DParts(editor3DInstances.get(id)));
|
|
1470
2201
|
const snapped = editor3DSnapPos(editor3DRest(point, size), size, editor3DGrid ? editor3DMoveStep : 0);
|
|
1471
2202
|
const y = editor3DLand(snapped.x, snapped.z, point.y, size, ignore);
|
|
1472
|
-
|
|
2203
|
+
// the box stands there, and the object's place is where that puts it
|
|
2204
|
+
const to = vec3(snapped.x, y, snapped.z).subtract(offset);
|
|
2205
|
+
editor3DChange((list)=> editor3DSetTransform(list.find((o)=> o.id === id), to));
|
|
1473
2206
|
editor3DStrokeEnd();
|
|
1474
2207
|
}
|
|
1475
2208
|
|
|
@@ -1479,7 +2212,7 @@ function editor3DDragTo(drag, mouse, ray, snap)
|
|
|
1479
2212
|
if (drag.kind === 'blocks')
|
|
1480
2213
|
return editor3DVoxelDragTo(drag, ray);
|
|
1481
2214
|
if (drag.kind === 'terrain')
|
|
1482
|
-
return editor3DTerrainHover &&
|
|
2215
|
+
return editor3DTerrainHover && editor3DTerrainDragTo(drag, editor3DTerrainHover);
|
|
1483
2216
|
if (drag.kind === 'box')
|
|
1484
2217
|
{
|
|
1485
2218
|
drag.to = mouse.copy();
|
|
@@ -1490,18 +2223,23 @@ function editor3DDragTo(drag, mouse, ray, snap)
|
|
|
1490
2223
|
const was = first && start.get(first.id);
|
|
1491
2224
|
if (!was) return;
|
|
1492
2225
|
const size = editor3DSize(first), turned = editor3DTurned(first), step = snap ? editor3DMoveStep : 0;
|
|
1493
|
-
const
|
|
2226
|
+
const offset = editor3DBoxOffset(first); // snapping and landing go by its box
|
|
2227
|
+
const snapTo = (to)=> editor3DSnapPos(to.add(offset), size, step, turned).subtract(offset).subtract(was.pos);
|
|
1494
2228
|
let move, turn, factor;
|
|
1495
2229
|
if (kind === 'arrow')
|
|
1496
2230
|
{
|
|
1497
2231
|
const d = editor3DAxisDistance(ray, handle.center, handle.direction) - grab;
|
|
1498
|
-
move
|
|
2232
|
+
// an axis of its own does not follow the grid, so the move goes in steps from where it started
|
|
2233
|
+
move = handle.turn ? handle.direction.scale(step ? round(d / step) * step : d) :
|
|
2234
|
+
handle.direction.multiply(snapTo(was.pos.add(handle.direction.scale(d)))); // along its axis alone
|
|
1499
2235
|
}
|
|
1500
2236
|
else if (kind === 'plane')
|
|
1501
2237
|
{
|
|
1502
2238
|
const point = editor3DPlanePoint(ray, handle.center, handle.direction);
|
|
1503
2239
|
if (!point || !grab) return;
|
|
1504
|
-
|
|
2240
|
+
const delta = point.subtract(grab), along = (w)=> step ? round(delta.dot(w) / step) * step : delta.dot(w);
|
|
2241
|
+
move = handle.turn ? handle.ways[0].scale(along(handle.ways[0])).add(handle.ways[1].scale(along(handle.ways[1]))) :
|
|
2242
|
+
vec3(1).subtract(handle.direction).multiply(snapTo(was.pos.add(delta)));
|
|
1505
2243
|
}
|
|
1506
2244
|
else if (kind === 'body')
|
|
1507
2245
|
{
|
|
@@ -1509,8 +2247,8 @@ function editor3DDragTo(drag, mouse, ray, snap)
|
|
|
1509
2247
|
const point = editor3DPlanePoint(ray, drag.level, drag.normal);
|
|
1510
2248
|
if (!point) return;
|
|
1511
2249
|
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;
|
|
2250
|
+
const y = editor3DGroundSnap ? editor3DLand(was.pos.x + offset.x + across.x, was.pos.z + offset.z + across.z,
|
|
2251
|
+
was.pos.y + offset.y - size.y / 2, size, drag.ignore) - offset.y : was.pos.y;
|
|
1514
2252
|
move = vec3(across.x, y - was.pos.y, across.z);
|
|
1515
2253
|
}
|
|
1516
2254
|
else if (kind === 'ring')
|
|
@@ -1611,9 +2349,9 @@ function editor3DDropSelection()
|
|
|
1611
2349
|
{
|
|
1612
2350
|
for (const object of list)
|
|
1613
2351
|
{
|
|
1614
|
-
const to = editor3DSelection.has(object.id) &&
|
|
1615
|
-
editor3DDrop(editor3DPos(object), editor3DSize(object), ignore);
|
|
1616
|
-
to && editor3DSetTransform(object, to);
|
|
2352
|
+
const offset = editor3DBoxOffset(object), to = editor3DSelection.has(object.id) &&
|
|
2353
|
+
editor3DDrop(editor3DPos(object).add(offset), editor3DSize(object), ignore);
|
|
2354
|
+
to && editor3DSetTransform(object, to.subtract(offset));
|
|
1617
2355
|
}
|
|
1618
2356
|
});
|
|
1619
2357
|
editor3DStrokeEnd();
|
|
@@ -1627,11 +2365,12 @@ function editor3DPasteAtMouse()
|
|
|
1627
2365
|
if (!copied?.length) return false;
|
|
1628
2366
|
if (!point) return editor3DPaste();
|
|
1629
2367
|
const center = copied.reduce((sum, o)=> sum.add(editor3DPos(o)), vec3()).scale(1 / copied.length);
|
|
1630
|
-
const
|
|
1631
|
-
const
|
|
1632
|
-
const
|
|
2368
|
+
const box = (o)=> editor3DClipboardBoxes.get(o) ?? {size: editor3DSize(o), offset: editor3DBoxOffset(o)};
|
|
2369
|
+
const bottom = copied.reduce((low, o)=> min(low, editor3DPos(o).y + box(o).offset.y - box(o).size.y / 2), Infinity);
|
|
2370
|
+
const first = copied[0], size = box(first).size, was = editor3DPos(first).add(box(first).offset); // its box's middle
|
|
2371
|
+
const snapped = editor3DSnapPos(was.add(vec3(point.x - center.x, 0, point.z - center.z)), size,
|
|
1633
2372
|
editor3DGrid ? editor3DMoveStep : 0, editor3DTurned(first));
|
|
1634
|
-
const land = editor3DLand(snapped.x, snapped.z, point.y,
|
|
2373
|
+
const land = editor3DLand(snapped.x, snapped.z, point.y, size) - size.y / 2;
|
|
1635
2374
|
return editor3DPaste(vec3(snapped.x - was.x, land - bottom, snapped.z - was.z));
|
|
1636
2375
|
}
|
|
1637
2376
|
|
|
@@ -1640,18 +2379,22 @@ function editor3DPasteAtMouse()
|
|
|
1640
2379
|
/** @type {Object<string, function(boolean=): any>} */
|
|
1641
2380
|
const editor3DKeys =
|
|
1642
2381
|
{
|
|
1643
|
-
KeyQ: ()=> {
|
|
1644
|
-
KeyW: ()=> {
|
|
1645
|
-
KeyE: ()=> {
|
|
1646
|
-
KeyR: ()=> {
|
|
1647
|
-
KeyB: ()=> {
|
|
1648
|
-
KeyT: ()=> {
|
|
2382
|
+
KeyQ: ()=> { editor3DPickTool('select'); editor3DBrush = undefined; },
|
|
2383
|
+
KeyW: ()=> { editor3DPickTool('move'); },
|
|
2384
|
+
KeyE: ()=> { editor3DPickTool('rotate'); },
|
|
2385
|
+
KeyR: ()=> { editor3DPickTool('scale'); },
|
|
2386
|
+
KeyB: ()=> { editor3DPickTool('blocks'); editor3DBrush = undefined; },
|
|
2387
|
+
KeyT: ()=> { editor3DPickTool('terrain'); editor3DBrush = undefined; },
|
|
1649
2388
|
KeyP: ()=> editor3DTool === 'blocks' && editor3DVoxelPick(render3D.screenToRay(mousePosScreen)),
|
|
2389
|
+
KeyX: ()=> editor3DTool === 'terrain' ? editor3DTerrainNextBrush() :
|
|
2390
|
+
editor3DTool === 'blocks' && (editor3DBlockBox = !editor3DBlockBox),
|
|
1650
2391
|
KeyG: ()=> { editor3DGrid = !editor3DGrid; },
|
|
2392
|
+
KeyL: ()=> { editor3DLocalAxes = !editor3DLocalAxes; },
|
|
1651
2393
|
KeyF: ()=> editor3DFrame(),
|
|
1652
2394
|
End: ()=> editor3DDropSelection(),
|
|
1653
2395
|
Delete: ()=> editor3DDelete(),
|
|
1654
|
-
Backspace: ()=> editor3DDelete(),
|
|
2396
|
+
Backspace: ()=> editor3DSelection.size ? editor3DDelete() : editor3DPrefabBack(),
|
|
2397
|
+
Enter: ()=> editor3DPrefabEnter(),
|
|
1655
2398
|
Slash: ()=> { editor3DHelp = !editor3DHelp; },
|
|
1656
2399
|
};
|
|
1657
2400
|
/** @type {Object<string, function(boolean=): any>} */
|
|
@@ -1663,6 +2406,7 @@ const editor3DCtrlKeys =
|
|
|
1663
2406
|
KeyX: ()=> editor3DCut(),
|
|
1664
2407
|
KeyV: ()=> editor3DPasteAtMouse(),
|
|
1665
2408
|
KeyD: ()=> editor3DDuplicate(),
|
|
2409
|
+
KeyG: (shift)=> shift ? editor3DUnpack() : editor3DMakePrefab() === true,
|
|
1666
2410
|
};
|
|
1667
2411
|
|
|
1668
2412
|
// the camera: the right button looks and the keys fly while it is held, the middle button or Space and the left
|
|
@@ -1715,7 +2459,13 @@ function editor3DEditorUpdate(seconds)
|
|
|
1715
2459
|
if (debugKey && keyWasPressed(debugKey))
|
|
1716
2460
|
{
|
|
1717
2461
|
inputClearKey(debugKey);
|
|
1718
|
-
|
|
2462
|
+
if (editor3DToolHeld)
|
|
2463
|
+
{
|
|
2464
|
+
editor3DStrokeCancel(); // a held press of the game's tool is taken back
|
|
2465
|
+
editor3DToolHeld = false;
|
|
2466
|
+
}
|
|
2467
|
+
else
|
|
2468
|
+
editor3DDrag ? editor3DDragCancel() : editor3DPlay(editor3DMousePoint());
|
|
1719
2469
|
return;
|
|
1720
2470
|
}
|
|
1721
2471
|
|
|
@@ -1731,9 +2481,20 @@ function editor3DEditorUpdate(seconds)
|
|
|
1731
2481
|
const looking = mouseIsDown(2), cameraDrag = looking || mouseIsDown(1) || keyIsDown('Space') || alt;
|
|
1732
2482
|
|
|
1733
2483
|
// the keys, which wait for a drag to end, and fly while the right button is held
|
|
1734
|
-
if (!editor3DDrag && !looking)
|
|
2484
|
+
if (!editor3DDrag && !looking && !editor3DToolHeld)
|
|
2485
|
+
{
|
|
2486
|
+
// the game's own keys first, each takes the place of the editor's key of the same spelling
|
|
2487
|
+
const taken = new Set;
|
|
2488
|
+
for (const [spelled, key] of Object.entries(levelEditor.keys))
|
|
2489
|
+
{
|
|
2490
|
+
const withCtrl = spelled.startsWith('ctrl+'), code = editor3DKeyCode(spelled.slice(withCtrl ? 5 : 0));
|
|
2491
|
+
if (withCtrl !== ctrl) continue;
|
|
2492
|
+
taken.add(code);
|
|
2493
|
+
keyWasPressed(code) && key.action.call(levelEditor, shift);
|
|
2494
|
+
}
|
|
1735
2495
|
for (const [code, action] of Object.entries(ctrl ? editor3DCtrlKeys : editor3DKeys))
|
|
1736
|
-
keyWasPressed(code) && action(shift);
|
|
2496
|
+
taken.has(code) || keyWasPressed(code) && action(shift);
|
|
2497
|
+
}
|
|
1737
2498
|
|
|
1738
2499
|
// a right press during a drag puts it back
|
|
1739
2500
|
if (editor3DDrag && mouseWasPressed(2))
|
|
@@ -1741,22 +2502,24 @@ function editor3DEditorUpdate(seconds)
|
|
|
1741
2502
|
|
|
1742
2503
|
const ray = render3D.screenToRay(mouse), idle = !cameraDrag && !editor3DMouseOnPanel && !editor3DDrag;
|
|
1743
2504
|
editor3DHover = idle ? editor3DHandleAt(mouse) : undefined;
|
|
2505
|
+
editorCall('onUpdate');
|
|
2506
|
+
if (editor3DToolUpdate(ray, idle, shift, ctrl)) return; // a tool of the game's own has the mouse
|
|
1744
2507
|
|
|
1745
2508
|
// the Blocks tool takes the mouse while the level has a map: Shift removes, Ctrl repaints
|
|
1746
|
-
|
|
2509
|
+
// a type picked to place is placed, whichever tool is on
|
|
2510
|
+
const painting = editor3DTool === 'blocks' && !editor3DBrush && !!editor3DVoxelMap();
|
|
1747
2511
|
const mode = shift ? 'remove' : ctrl ? 'paint' : 'place';
|
|
1748
2512
|
const target = painting && idle ? editor3DVoxelTarget(ray, mode) : undefined;
|
|
1749
2513
|
editor3DBlockHover = target && {cell: target.cell, mode};
|
|
1750
|
-
// the Terrain tool takes it while the level has a terrain: held down
|
|
1751
|
-
|
|
1752
|
-
const terrain = editor3DTool === 'terrain' ? editor3DTerrainMap() : undefined;
|
|
2514
|
+
// the Terrain tool takes it while the level has a terrain: held down its brush works on the ground
|
|
2515
|
+
const terrain = editor3DTool === 'terrain' && !editor3DBrush ? editor3DTerrainMap() : undefined;
|
|
1753
2516
|
const reach = terrain && !cameraDrag && !editor3DMouseOnPanel ? terrain.raycast(ray) : undefined;
|
|
1754
2517
|
editor3DTerrainHover = reach === undefined ? undefined : ray.getPosition(reach);
|
|
1755
2518
|
if (painting)
|
|
1756
2519
|
idle && mouseWasPressed(0) && (editor3DDrag = editor3DVoxelPress(ray, mode));
|
|
1757
2520
|
else if (terrain)
|
|
1758
2521
|
idle && mouseWasPressed(0) && editor3DTerrainHover &&
|
|
1759
|
-
(editor3DDrag =
|
|
2522
|
+
(editor3DDrag = editor3DTerrainPress(editor3DTerrainHover, shift, ctrl));
|
|
1760
2523
|
else if (idle && mouseWasPressed(0))
|
|
1761
2524
|
editor3DPress(mouse, ray, shift);
|
|
1762
2525
|
|
|
@@ -1770,7 +2533,7 @@ function editor3DEditorUpdate(seconds)
|
|
|
1770
2533
|
if (debug && globalThis.document?.addEventListener)
|
|
1771
2534
|
document.addEventListener('keydown', (e)=>
|
|
1772
2535
|
{
|
|
1773
|
-
if (editor3DIsOpen && (e.ctrlKey || e.metaKey) && /^Key[
|
|
2536
|
+
if (editor3DIsOpen && (e.ctrlKey || e.metaKey) && /^Key[ZYCXVDG]$/.test(e.code) && !editorIsTextField(e.target))
|
|
1774
2537
|
e.preventDefault();
|
|
1775
2538
|
});
|
|
1776
2539
|
|
|
@@ -1782,13 +2545,16 @@ const editor3DHelpLines =
|
|
|
1782
2545
|
[
|
|
1783
2546
|
'Left: select · Shift+Left: add or take away · Left drag from empty space: box select',
|
|
1784
2547
|
'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',
|
|
2548
|
+
'G: grid snap, Ctrl flips it for a drag · L: move along its own axes · End: drop to the ground',
|
|
1786
2549
|
'Right button: look, and WASD and QE fly while it is held, Shift faster',
|
|
1787
2550
|
'Wheel: zoom · Middle drag or Space+drag: pan · Alt+drag: orbit · F: frame the selection',
|
|
1788
2551
|
'Pick a type, then click to place it, Shift+click keeps placing',
|
|
1789
2552
|
'B blocks: click or drag places · Shift removes · Ctrl repaints · P picks the type under the mouse',
|
|
1790
|
-
'
|
|
2553
|
+
'X box fill: a drag fills the rectangle dragged, as tall as the height in the panel',
|
|
2554
|
+
'T terrain: hold to raise the ground · Shift lowers · Ctrl smooths · X the next brush, flatten and paint',
|
|
1791
2555
|
'Delete · Ctrl+C / X / V: copy, cut, paste · Ctrl+D: duplicate · Ctrl+Z / Y: undo, redo',
|
|
2556
|
+
'Ctrl+G: make the selection a prefab, one thing to place many times · Ctrl+Shift+G: unpack it into its objects',
|
|
2557
|
+
'Enter: open the selected prefab to edit it alone, every instance follows · Backspace with nothing selected: back',
|
|
1792
2558
|
'Reset to file: the level as its file has it, Restart keeps your edits, Undo brings them back',
|
|
1793
2559
|
'Esc: play and edit · 0: exit the editor · ?: keys',
|
|
1794
2560
|
];
|
|
@@ -1798,6 +2564,7 @@ function editor3DHint()
|
|
|
1798
2564
|
{
|
|
1799
2565
|
if (editor3DRecords.get(editor3DLevel)?.pending)
|
|
1800
2566
|
return 'The level file changed: apply your edits or drop them';
|
|
2567
|
+
if (editorGameTool()) return editorGameTool().hint || levelEditor.tool;
|
|
1801
2568
|
if (editor3DDrag)
|
|
1802
2569
|
return editor3DDrag.kind === 'box' ? 'Let go to select what is inside' :
|
|
1803
2570
|
'Esc or right click puts it back · Ctrl flips the snap';
|
|
@@ -1806,10 +2573,14 @@ function editor3DHint()
|
|
|
1806
2573
|
if (editor3DBrush)
|
|
1807
2574
|
return `Click to place a ${editor3DBrush} · Shift keeps placing · Q puts it down`;
|
|
1808
2575
|
if (editor3DTool === 'terrain')
|
|
1809
|
-
return editor3DTerrainMap() ? '
|
|
1810
|
-
'
|
|
2576
|
+
return !editor3DTerrainMap() ? 'Add a terrain in the panel to sculpt it' :
|
|
2577
|
+
{sculpt: 'Hold to raise the ground · Shift lowers · Ctrl smooths',
|
|
2578
|
+
flatten: 'Hold to flatten the ground to the height pressed · Ctrl smooths',
|
|
2579
|
+
paint: 'Hold to paint the ground · Shift takes the paint off'}[editor3DTerrainBrush.mode] +
|
|
2580
|
+
' · X the next brush';
|
|
1811
2581
|
if (editor3DTool === 'blocks')
|
|
1812
|
-
return editor3DVoxelMap() ?
|
|
2582
|
+
return editor3DVoxelMap() ? (editor3DBlockBox ? 'Drag a box to fill it' : 'Click or drag places') +
|
|
2583
|
+
' · Shift removes · Ctrl repaints · P picks a type · X box' :
|
|
1813
2584
|
'Add a block map in the panel to paint blocks';
|
|
1814
2585
|
if (editor3DSelection.size)
|
|
1815
2586
|
return 'Drag a handle or the object · W move · E rotate · R scale · End drops it';
|
|
@@ -1883,7 +2654,8 @@ function editor3DDrawRing(center, across, along, radius, color, pixels=2)
|
|
|
1883
2654
|
function editor3DVisible(object)
|
|
1884
2655
|
{
|
|
1885
2656
|
const made = editor3DInstances.get(object.id);
|
|
1886
|
-
return made instanceof EngineObject3D && !made.destroyed && (made.mesh || made.tileInfo
|
|
2657
|
+
return made instanceof EngineObject3D && !made.destroyed && (made.mesh || made.tileInfo || editor3DPrefabBox(made)) ?
|
|
2658
|
+
made : undefined;
|
|
1887
2659
|
}
|
|
1888
2660
|
|
|
1889
2661
|
// 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 +2665,9 @@ function editor3DBoxMatrix(object)
|
|
|
1893
2665
|
const made = editor3DVisible(object), bounds = made?.mesh && (made.mesh.bounds || made.mesh.getBounds());
|
|
1894
2666
|
if (!made)
|
|
1895
2667
|
return buildMatrix(editor3DPos(object));
|
|
2668
|
+
const box = editor3DPrefabBox(made);
|
|
2669
|
+
if (box)
|
|
2670
|
+
return buildMatrix(box.pos, undefined, box.size);
|
|
1896
2671
|
const matrix = render3DObjectMatrix(made).copy();
|
|
1897
2672
|
return bounds ? matrix.multiply(buildMatrix(bounds.min.add(bounds.max).scale(.5), undefined,
|
|
1898
2673
|
bounds.max.subtract(bounds.min))) : matrix.multiply(buildMatrix(vec3(), undefined, made.size3D));
|
|
@@ -1956,6 +2731,15 @@ function editor3DDraw()
|
|
|
1956
2731
|
editor3DDrawRing(editor3DTerrainHover.add(vec3(0, .05, 0)), EDITOR3D_AXES.x, EDITOR3D_AXES.z,
|
|
1957
2732
|
editor3DTerrainBrush.size / 2, hsl(0, 0, 1, .9), 2.5);
|
|
1958
2733
|
|
|
2734
|
+
// the box a box drag fills
|
|
2735
|
+
const region = map && editor3DDrag?.region;
|
|
2736
|
+
if (region)
|
|
2737
|
+
{
|
|
2738
|
+
const size = region.hi.subtract(region.lo).add(vec3(1));
|
|
2739
|
+
editor3DDrawWire(buildMatrix(map.pos3D.add(region.lo).add(size.scale(.5)), undefined, size.add(vec3(.02))),
|
|
2740
|
+
editor3DDrag.mode === 'place' ? hsl(0, 0, 1, .9) : editor3DDrag.mode === 'remove' ? hsl(0, 1, .6) :
|
|
2741
|
+
hsl(.15, 1, .6), 2.5);
|
|
2742
|
+
}
|
|
1959
2743
|
// the map's edges, to see where it ends
|
|
1960
2744
|
if (map)
|
|
1961
2745
|
editor3DDrawWire(buildMatrix(map.pos3D.add(map.mapSize.scale(.5)), undefined, map.mapSize),
|
|
@@ -1982,9 +2766,8 @@ function editor3DDraw()
|
|
|
1982
2766
|
else if (kind === 'plane')
|
|
1983
2767
|
{
|
|
1984
2768
|
// 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.
|
|
1986
|
-
r.drawBox(points[0], size, lit ? color : EDITOR3D_AXIS_COLORS[
|
|
1987
|
-
.replace(axis[1], '')].scale(1, .7));
|
|
2769
|
+
const size = vec3(tip * 1.6).subtract(EDITOR3D_AXES[handle.across].scale(tip * 1.5));
|
|
2770
|
+
r.drawBox(points[0], size, lit ? color : EDITOR3D_AXIS_COLORS[handle.across].scale(1, .7), handle.turn);
|
|
1988
2771
|
}
|
|
1989
2772
|
else if (kind === 'scaleAll')
|
|
1990
2773
|
r.drawBox(center, tip * 1.4, lit ? color : WHITE);
|
|
@@ -1994,7 +2777,8 @@ function editor3DDraw()
|
|
|
1994
2777
|
r.drawLine(points[0], points[1], width, color);
|
|
1995
2778
|
const turn = axis === 'x' ? vec3(0, 0, -PI / 2) : axis === 'z' ? vec3(PI / 2, 0, 0) : undefined;
|
|
1996
2779
|
const at = points[1].add(handle.direction.scale(tip));
|
|
1997
|
-
|
|
2780
|
+
const cone = buildMatrix(vec3(), turn, vec3(tip * 1.4, tip * 2, tip * 1.4));
|
|
2781
|
+
r.drawMesh(editor3DConeMesh(), buildMatrix(at, handle.turn).multiply(cone), undefined, color);
|
|
1998
2782
|
}
|
|
1999
2783
|
else
|
|
2000
2784
|
{
|
|
@@ -2002,6 +2786,7 @@ function editor3DDraw()
|
|
|
2002
2786
|
r.drawBox(points[1], tip, color, editor3DRotation(editor3DSelected()[0]).scale(PI / 180));
|
|
2003
2787
|
}
|
|
2004
2788
|
}
|
|
2789
|
+
editor3DDrawGame();
|
|
2005
2790
|
}
|
|
2006
2791
|
|
|
2007
2792
|
// the editor's drawing on the 2D layer: the name of each marker, and the box being dragged out to select with
|
|
@@ -2092,9 +2877,11 @@ function editor3DPanelInit()
|
|
|
2092
2877
|
['scale', 'Scale', 'R'], ['blocks', 'Blocks', 'B'], ['terrain', 'Terrain', 'T']])
|
|
2093
2878
|
toolButtons[tool] = button(tools, text, ()=>
|
|
2094
2879
|
{
|
|
2095
|
-
|
|
2880
|
+
editor3DPickTool(tool);
|
|
2096
2881
|
(tool === 'select' || tool === 'blocks' || tool === 'terrain') && (editor3DBrush = undefined);
|
|
2097
2882
|
}, key);
|
|
2883
|
+
// the game's own tools, buttons and controls, under the editor's tools
|
|
2884
|
+
const game = editorElement('div', panel);
|
|
2098
2885
|
const snap = editorElement('div', panel, box);
|
|
2099
2886
|
const grid = check(snap, 'Grid snap', (on)=> editor3DGrid = on, 'G, and Ctrl flips it for a drag');
|
|
2100
2887
|
const steps = row(snap);
|
|
@@ -2103,12 +2890,26 @@ function editor3DPanelInit()
|
|
|
2103
2890
|
const scaleStep = choice(steps, 'Size', [1, .5, .25], (v)=> editor3DScaleStep = v);
|
|
2104
2891
|
const ground = check(snap, 'Ground snap', (on)=> editor3DGroundSnap = on,
|
|
2105
2892
|
'A drag of an object slides it along what is under the mouse');
|
|
2893
|
+
const ownAxes = check(snap, 'Own axes', (on)=> editor3DLocalAxes = on,
|
|
2894
|
+
'L: the Move handles go along the object\'s own axes, as it is turned, not the world\'s');
|
|
2106
2895
|
|
|
2107
2896
|
// the types, a click picks one up to place, made again when types are added
|
|
2108
2897
|
editorElement('div', panel, 'color:#aaa;margin-top:4px', 'Place');
|
|
2109
2898
|
const types = row();
|
|
2899
|
+
const prefabNote = editorElement('div', panel, 'color:#f86'); // why a prefab was not loaded
|
|
2110
2900
|
const properties = editorElement('div', panel, box);
|
|
2111
2901
|
|
|
2902
|
+
// prefabs: the selection made one, or the selected instance's own
|
|
2903
|
+
const prefabBox = editorElement('div', panel, box);
|
|
2904
|
+
|
|
2905
|
+
// the prefab that is open, and the way back
|
|
2906
|
+
const prefabOpen = editorElement('div', panel, 'padding:4px;margin:4px 0;background:#235;border-radius:3px;' +
|
|
2907
|
+
'display:flex;gap:6px;align-items:center');
|
|
2908
|
+
panel.insertBefore(prefabOpen, panel.firstChild);
|
|
2909
|
+
const prefabName = editorElement('span', prefabOpen, 'flex:1');
|
|
2910
|
+
button(prefabOpen, 'Back', ()=> editor3DPrefabBack(),
|
|
2911
|
+
'Backspace with nothing selected: back to where the prefab was opened from, every instance of it follows');
|
|
2912
|
+
|
|
2112
2913
|
// the Blocks tool's box: a map to add, or the types to paint with
|
|
2113
2914
|
const blocks = editorElement('div', panel, box);
|
|
2114
2915
|
|
|
@@ -2139,13 +2940,15 @@ function editor3DPanelInit()
|
|
|
2139
2940
|
|
|
2140
2941
|
const storage = editorElement('div', panel, 'color:#f86;margin-top:4px',
|
|
2141
2942
|
'Autosave failed, storage is full: Save to a file');
|
|
2943
|
+
const prefabUnsaved = editorElement('div', panel, 'color:#fc6;margin-top:4px');
|
|
2142
2944
|
const hint = editorElement('div', panel, 'color:#8ab;margin-top:4px');
|
|
2143
2945
|
const help = editorElement('div', panel, 'color:#aaa;margin-top:4px;border-top:1px solid #444;padding-top:4px');
|
|
2144
2946
|
for (const line of editor3DHelpLines)
|
|
2145
2947
|
editorElement('div', help, 'margin:2px 0', line);
|
|
2948
|
+
const gameHelp = editorElement('div', help);
|
|
2146
2949
|
button(help, 'Close', ()=> editor3DHelp = false, '?');
|
|
2147
2950
|
|
|
2148
|
-
editor3DPanelParts = {restart, pending, toolButtons, grid, moveStep, rotateStep, scaleStep, ground, types,
|
|
2951
|
+
editor3DPanelParts = {game, gameHelp, restart, pending, toolButtons, grid, moveStep, rotateStep, scaleStep, ground, ownAxes, types, prefabBox, prefabOpen, prefabName, prefabUnsaved, prefabNote,
|
|
2149
2952
|
properties, blocks, terrainBox, sceneOn, sceneRows, playFrom, storage, hint, help, typeNames: ''};
|
|
2150
2953
|
}
|
|
2151
2954
|
|
|
@@ -2160,49 +2963,126 @@ function editor3DPanelUpdate()
|
|
|
2160
2963
|
editor3DPanel || editor3DPanelInit();
|
|
2161
2964
|
editor3DPanel.style.display = '';
|
|
2162
2965
|
const p = editor3DPanelParts, lit = '2px solid #4af';
|
|
2163
|
-
p.
|
|
2966
|
+
editorGamePanel(p.game);
|
|
2967
|
+
editorGameHelp(p.gameHelp);
|
|
2968
|
+
p.restart.style.display = editorHas('onRestart') ? '' : 'none';
|
|
2164
2969
|
p.pending.style.display = editor3DRecords.get(editor3DLevel)?.pending ? '' : 'none';
|
|
2165
2970
|
for (const tool in p.toolButtons)
|
|
2166
2971
|
p.toolButtons[tool].style.outline = tool === editor3DTool ? lit : '';
|
|
2167
2972
|
p.grid.checked = editor3DGrid;
|
|
2168
2973
|
p.ground.checked = editor3DGroundSnap;
|
|
2974
|
+
p.ownAxes.checked = editor3DLocalAxes;
|
|
2169
2975
|
p.moveStep.value = editor3DMoveStep + '';
|
|
2170
2976
|
p.rotateStep.value = editor3DRotateStep + '';
|
|
2171
2977
|
p.scaleStep.value = editor3DScaleStep + '';
|
|
2172
2978
|
p.playFrom.checked = editor3DPlayFromMouse;
|
|
2173
|
-
p.playFrom.parentElement.style.display =
|
|
2979
|
+
p.playFrom.parentElement.style.display = editorHas('onPlayFrom') ? 'flex' : 'none';
|
|
2174
2980
|
p.storage.style.display = editor3DSaveFailed ? '' : 'none';
|
|
2175
2981
|
p.hint.textContent = editor3DHint();
|
|
2176
2982
|
p.help.style.display = editor3DHelp ? '' : 'none';
|
|
2177
2983
|
|
|
2178
2984
|
// a button for each type, made again when the types changed
|
|
2179
|
-
const names =
|
|
2985
|
+
const names = editor3DPlaceNames(), plain = names.filter((name)=> !level3DPrefabs.has(name));
|
|
2986
|
+
const prefabs = names.slice(plain.length);
|
|
2987
|
+
const typeNames = plain.join('\n') + '\n\n' + prefabs.join('\n');
|
|
2180
2988
|
if (p.typeNames !== typeNames)
|
|
2181
2989
|
{
|
|
2182
2990
|
p.typeNames = typeNames;
|
|
2183
|
-
p.
|
|
2991
|
+
p.typeButtons = names.map((name)=>
|
|
2184
2992
|
{
|
|
2185
2993
|
const b = editorElement('button', undefined, 'padding:3px 6px;cursor:pointer', name);
|
|
2186
2994
|
b.onclick = ()=> { editor3DBrush = editor3DBrush === name ? undefined : name; b.blur(); };
|
|
2187
2995
|
return b;
|
|
2188
|
-
})
|
|
2189
|
-
|
|
2190
|
-
|
|
2996
|
+
});
|
|
2997
|
+
const label = editorElement('span', undefined, 'color:#aaa;width:100%', 'Prefabs');
|
|
2998
|
+
const load = editorElement('button', undefined, 'padding:3px 6px;cursor:pointer', 'Load prefab…');
|
|
2999
|
+
load.title = 'Add a prefab from a file the editor saved, by its file name, to place in this level';
|
|
3000
|
+
load.onclick = ()=> { editor3DPrefabPickFile(); load.blur(); };
|
|
3001
|
+
p.types.replaceChildren(...p.typeButtons.slice(0, plain.length), label, ...p.typeButtons.slice(plain.length), load);
|
|
3002
|
+
}
|
|
3003
|
+
p.prefabNote.style.display = editor3DPrefabMessage && !editor3DSelection.size ? '' : 'none';
|
|
3004
|
+
p.prefabNote.textContent = editor3DPrefabMessage;
|
|
3005
|
+
names.forEach((name, i)=> p.typeButtons[i].style.outline = name === editor3DBrush ? lit : '');
|
|
2191
3006
|
editor3DPropertiesUpdate(p.properties);
|
|
3007
|
+
editor3DPrefabBoxUpdate(p.prefabBox);
|
|
3008
|
+
p.prefabOpen.style.display = editor3DPrefabStack.length ? 'flex' : 'none';
|
|
3009
|
+
p.prefabName.textContent = 'Editing prefab ' + editor3DPrefabStack.map((frame)=> frame.name).join(' > ');
|
|
3010
|
+
p.prefabUnsaved.style.display = editor3DPrefabDirty.size ? '' : 'none';
|
|
3011
|
+
p.prefabUnsaved.textContent = 'Edited and not in its file: ' + [...editor3DPrefabDirty].join(', ') +
|
|
3012
|
+
'. Open it with Enter and Save';
|
|
2192
3013
|
editor3DBlocksUpdate(p.blocks);
|
|
2193
3014
|
editor3DTerrainBoxUpdate(p.terrainBox);
|
|
2194
3015
|
p.sceneOn.checked = !!editor3DScene();
|
|
2195
3016
|
editor3DSceneUpdate(p.sceneRows);
|
|
2196
3017
|
}
|
|
2197
3018
|
|
|
3019
|
+
// the prefab box, shown with a selection: a name and a button to make it a prefab, and for one selected instance
|
|
3020
|
+
// its prefab's name, Unpack, Export, and for a prefab of the level's own whether it is attached
|
|
3021
|
+
function editor3DPrefabBoxUpdate(box)
|
|
3022
|
+
{
|
|
3023
|
+
const selected = editor3DSelected(), made = selected.length === 1 && editor3DInstances.get(selected[0].id);
|
|
3024
|
+
const instance = made instanceof Prefab3D ? made : undefined, prefab = instance && level3DPrefabs.get(instance.prefabName);
|
|
3025
|
+
const key = !selected.length ? '' : [selected.length, instance?.prefabName, prefab?.attached, prefab?.fromLevel,
|
|
3026
|
+
editor3DPrefabMessage, editor3DPrefabStack.length] + '';
|
|
3027
|
+
box.style.display = key ? '' : 'none';
|
|
3028
|
+
if (box.dataset.key === key || box.contains(document.activeElement)) return;
|
|
3029
|
+
box.dataset.key = key;
|
|
3030
|
+
box.replaceChildren();
|
|
3031
|
+
if (!key) return;
|
|
3032
|
+
const field = 'background:#222;color:#eee', press = 'padding:2px 6px;cursor:pointer';
|
|
3033
|
+
const row = ()=> editorElement('div', box, 'display:flex;gap:4px;align-items:center;margin:2px 0;flex-wrap:wrap');
|
|
3034
|
+
if (prefab)
|
|
3035
|
+
{
|
|
3036
|
+
const line = row();
|
|
3037
|
+
editorElement('span', line, 'flex:1;color:#aaa', 'Prefab ' + instance.prefabName);
|
|
3038
|
+
const open = editorElement('button', line, press, 'Edit');
|
|
3039
|
+
open.title = 'Enter: open the prefab alone to edit it, every instance of it follows';
|
|
3040
|
+
open.onclick = ()=> { editor3DPrefabEnter(); open.blur(); };
|
|
3041
|
+
const unpack = editorElement('button', line, press, 'Unpack');
|
|
3042
|
+
unpack.title = 'Ctrl+Shift+G: turn this instance into the objects it is made of';
|
|
3043
|
+
unpack.onclick = ()=> { editor3DUnpack(); unpack.blur(); };
|
|
3044
|
+
const save = editorElement('button', line, press, 'Export');
|
|
3045
|
+
save.title = 'Save the prefab as a file of its own, to load with level3DLoadPrefab';
|
|
3046
|
+
save.onclick = ()=>
|
|
3047
|
+
{
|
|
3048
|
+
saveText(editor3DPrefabJSON(instance.prefabName), instance.prefabName + '.json', 'application/json');
|
|
3049
|
+
save.blur();
|
|
3050
|
+
};
|
|
3051
|
+
if (prefab.fromLevel && !editor3DPrefabStack.length)
|
|
3052
|
+
{
|
|
3053
|
+
const label = editorElement('label', box, 'display:flex;gap:6px;align-items:center;margin:2px 0');
|
|
3054
|
+
label.title = 'Its parts are attached to each instance and move with it as one body, with no ' +
|
|
3055
|
+
'collision of their own; off, each part is an object of its own and solid';
|
|
3056
|
+
const attached = editorElement('input', label);
|
|
3057
|
+
attached.type = 'checkbox';
|
|
3058
|
+
attached.checked = prefab.attached;
|
|
3059
|
+
attached.onchange = ()=> { editor3DPrefabSetAttached(instance.prefabName, attached.checked); attached.blur(); };
|
|
3060
|
+
editorElement('span', label, '', 'Attached, one body');
|
|
3061
|
+
}
|
|
3062
|
+
}
|
|
3063
|
+
const line = row();
|
|
3064
|
+
const name = editorElement('input', line, field + ';flex:1;min-width:60px');
|
|
3065
|
+
name.placeholder = 'prefab name';
|
|
3066
|
+
const make = editorElement('button', line, press, 'Make prefab');
|
|
3067
|
+
make.title = 'Ctrl+G: the selection becomes one thing to place many times, kept in the level';
|
|
3068
|
+
make.onclick = ()=> { editor3DMakePrefab(name.value); make.blur(); };
|
|
3069
|
+
name.onkeydown = (e)=> { e.key === 'Enter' && (editor3DMakePrefab(name.value), name.blur()); };
|
|
3070
|
+
editor3DPrefabMessage && editorElement('div', box, 'color:#f86', editor3DPrefabMessage);
|
|
3071
|
+
}
|
|
3072
|
+
|
|
3073
|
+
// a level's color as a color input takes it, six digits and no alpha, white for one it can not show
|
|
3074
|
+
function editor3DInputColor(hex) { return /^#[0-9a-f]{6}/i.test(hex) ? hex.slice(0, 7) : '#ffffff'; }
|
|
3075
|
+
|
|
2198
3076
|
// 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
|
|
3077
|
+
// it; with one, the brush, its size and strength or the color it paints, the terrain's color, and a button to
|
|
3078
|
+
// take it away
|
|
2200
3079
|
function editor3DTerrainBoxUpdate(box)
|
|
2201
3080
|
{
|
|
2202
3081
|
const map = editor3DTerrainMap(), shown = editor3DTool === 'terrain';
|
|
2203
3082
|
box.style.display = shown ? '' : 'none';
|
|
2204
3083
|
if (!shown) return;
|
|
2205
|
-
const terrain = editor3DLevelPart('terrain'),
|
|
3084
|
+
const terrain = editor3DLevelPart('terrain'), brush = editor3DTerrainBrush;
|
|
3085
|
+
const key = map ? ['terrain', terrain.color, brush.mode] + '' : 'none';
|
|
2206
3086
|
if (box.dataset.key === key || box.contains(document.activeElement)) return;
|
|
2207
3087
|
box.dataset.key = key;
|
|
2208
3088
|
box.replaceChildren();
|
|
@@ -2250,13 +3130,27 @@ function editor3DTerrainBoxUpdate(box)
|
|
|
2250
3130
|
input.oninput = ()=> set(parseFloat(input.value));
|
|
2251
3131
|
input.onchange = ()=> input.blur();
|
|
2252
3132
|
};
|
|
2253
|
-
|
|
2254
|
-
|
|
2255
|
-
|
|
2256
|
-
(
|
|
2257
|
-
|
|
3133
|
+
const brushes = editorElement('select', line('Brush', 'X: sculpt raises and lowers the ground, flatten brings ' +
|
|
3134
|
+
'it to the height pressed, paint colors it'), field + ';width:120px');
|
|
3135
|
+
for (const name of editor3DTerrainBrushes)
|
|
3136
|
+
editorElement('option', brushes, '', name[0].toUpperCase() + name.slice(1)).value = name;
|
|
3137
|
+
brushes.value = brush.mode;
|
|
3138
|
+
brushes.onchange = ()=> { brush.mode = brushes.value; brushes.blur(); };
|
|
3139
|
+
slider('Brush size', 'How far across the brush is', 1, 32, .5, brush.size, (v)=> brush.size = v);
|
|
3140
|
+
if (brush.mode === 'paint')
|
|
3141
|
+
{
|
|
3142
|
+
const paint = editorElement('input', line('Paint', 'The color the brush paints'), field + ';width:60px');
|
|
3143
|
+
paint.type = 'color';
|
|
3144
|
+
paint.value = editor3DInputColor(brush.color);
|
|
3145
|
+
paint.oninput = ()=> brush.color = paint.value;
|
|
3146
|
+
paint.onchange = ()=> { brush.color = paint.value; paint.blur(); };
|
|
3147
|
+
}
|
|
3148
|
+
else
|
|
3149
|
+
slider('Strength', 'How fast the brush works', .05, 1, .05, brush.strength, (v)=> brush.strength = v);
|
|
3150
|
+
const color = editorElement('input', line('Color', 'The color of the ground that is not painted'),
|
|
3151
|
+
field + ';width:60px');
|
|
2258
3152
|
color.type = 'color';
|
|
2259
|
-
color.value =
|
|
3153
|
+
color.value = editor3DInputColor(terrain.color);
|
|
2260
3154
|
color.oninput = ()=> editor3DChangePart('terrain', (t)=> ({...t, color: color.value}));
|
|
2261
3155
|
color.onchange = ()=> { color.oninput(); editor3DStrokeEnd(); color.blur(); };
|
|
2262
3156
|
const remove = editorElement('button', box, 'margin-top:4px;padding:2px 6px;cursor:pointer', 'Remove terrain');
|
|
@@ -2272,7 +3166,8 @@ function editor3DBlocksUpdate(box)
|
|
|
2272
3166
|
box.style.display = shown ? '' : 'none';
|
|
2273
3167
|
if (!shown) return;
|
|
2274
3168
|
const first = map?.tileInfo, texture = first?.textureInfo;
|
|
2275
|
-
const key = map ? ['map', editor3DBlockType, texture?.size.x, map.blockTypes.length
|
|
3169
|
+
const key = map ? ['map', editor3DBlockType, texture?.size.x, map.blockTypes.length, editor3DBlockBox,
|
|
3170
|
+
map.mapSize].join() : 'none';
|
|
2276
3171
|
if (box.dataset.key === key || box.contains(document.activeElement)) return;
|
|
2277
3172
|
box.dataset.key = key;
|
|
2278
3173
|
box.replaceChildren();
|
|
@@ -2293,7 +3188,7 @@ function editor3DBlocksUpdate(box)
|
|
|
2293
3188
|
add.title = 'An empty map of blocks, centered, its bottom on the ground';
|
|
2294
3189
|
add.onclick = ()=>
|
|
2295
3190
|
{
|
|
2296
|
-
const [x, y, z] = inputs.map((i)=> clamp(floor(parseFloat(i.value)) ||
|
|
3191
|
+
const [x, y, z] = inputs.map((i, k)=> clamp(floor(parseFloat(i.value)) || [32, 16, 32][k], 1, 256));
|
|
2297
3192
|
editor3DVoxelAdd(vec3(x, y, z));
|
|
2298
3193
|
add.blur();
|
|
2299
3194
|
};
|
|
@@ -2333,6 +3228,46 @@ function editor3DBlocksUpdate(box)
|
|
|
2333
3228
|
}
|
|
2334
3229
|
catch {} // an image that can not be drawn or read, the title still says the type
|
|
2335
3230
|
}
|
|
3231
|
+
// box fill: a drag fills the rectangle dragged, this many layers tall
|
|
3232
|
+
const boxRow = editorElement('label', box, 'display:flex;gap:6px;align-items:center;margin:4px 0 2px');
|
|
3233
|
+
boxRow.title = 'X: a drag fills the whole rectangle from the press to the mouse, Shift clears it, Ctrl repaints';
|
|
3234
|
+
const boxOn = editorElement('input', boxRow);
|
|
3235
|
+
boxOn.type = 'checkbox';
|
|
3236
|
+
boxOn.checked = editor3DBlockBox;
|
|
3237
|
+
boxOn.onchange = ()=> { editor3DBlockBox = boxOn.checked; boxOn.blur(); };
|
|
3238
|
+
editorElement('span', boxRow, 'flex:1', 'Box fill, height');
|
|
3239
|
+
const boxHeight = editorElement('input', boxRow, field);
|
|
3240
|
+
boxHeight.type = 'number';
|
|
3241
|
+
boxHeight.min = '1', boxHeight.max = '256', boxHeight.step = '1';
|
|
3242
|
+
boxHeight.value = editor3DBlockBoxHeight + '';
|
|
3243
|
+
boxHeight.onchange = ()=>
|
|
3244
|
+
{
|
|
3245
|
+
editor3DBlockBoxHeight = clamp(floor(parseFloat(boxHeight.value)) || 1, 1, 256);
|
|
3246
|
+
boxHeight.value = editor3DBlockBoxHeight + '';
|
|
3247
|
+
boxHeight.blur();
|
|
3248
|
+
};
|
|
3249
|
+
|
|
3250
|
+
// the map's size, to change
|
|
3251
|
+
const sizeRow = editorElement('label', box, 'display:flex;gap:4px;align-items:center;margin:2px 0');
|
|
3252
|
+
editorElement('span', sizeRow, 'flex:1', 'Map size');
|
|
3253
|
+
const axes = ['x', 'y', 'z'], sizes = axes.map((axis)=>
|
|
3254
|
+
{
|
|
3255
|
+
const input = editorElement('input', sizeRow, field);
|
|
3256
|
+
input.type = 'number';
|
|
3257
|
+
input.min = '1', input.max = '256', input.step = '1';
|
|
3258
|
+
input.value = map.mapSize[axis] + '';
|
|
3259
|
+
return input;
|
|
3260
|
+
});
|
|
3261
|
+
const resize = editorElement('button', box, 'padding:2px 6px;cursor:pointer;margin-right:4px', 'Resize');
|
|
3262
|
+
resize.title = 'Change the map\'s size, its blocks stay where they are; Undo brings back what was cut off';
|
|
3263
|
+
resize.onclick = ()=>
|
|
3264
|
+
{
|
|
3265
|
+
// a field left empty keeps the size it has
|
|
3266
|
+
const [x, y, z] = sizes.map((i, k)=> clamp(floor(parseFloat(i.value)) || map.mapSize[axes[k]], 1, 256));
|
|
3267
|
+
editor3DVoxelResize(vec3(x, y, z));
|
|
3268
|
+
resize.blur();
|
|
3269
|
+
};
|
|
3270
|
+
|
|
2336
3271
|
const remove = editorElement('button', box, 'margin-top:4px;padding:2px 6px;cursor:pointer', 'Remove map');
|
|
2337
3272
|
remove.title = 'Take the block map out of the level, Undo brings it back';
|
|
2338
3273
|
remove.onclick = ()=> { editor3DVoxelRemove(); remove.blur(); };
|
|
@@ -2351,7 +3286,11 @@ function editor3DSceneUpdate(box)
|
|
|
2351
3286
|
|
|
2352
3287
|
// a setting the block leaves out shows as it is on screen
|
|
2353
3288
|
const view = editor3DSceneFromView(), value = (name)=> scene[name] ?? view[name];
|
|
2354
|
-
|
|
3289
|
+
// 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
|
|
3290
|
+
// made again while it has the focus, so a field edited right after another must not put the other one back;
|
|
3291
|
+
// set is given the setting's value now, or what shows for one the block leaves out
|
|
3292
|
+
const change = (name, set)=> editor3DChangeScene((s={})=>
|
|
3293
|
+
({...s, [name]: typeof set === 'function' ? set(s[name] ?? view[name]) : set}));
|
|
2355
3294
|
const field = 'background:#222;color:#eee';
|
|
2356
3295
|
const line = (name, title='')=>
|
|
2357
3296
|
{
|
|
@@ -2379,7 +3318,7 @@ function editor3DSceneUpdate(box)
|
|
|
2379
3318
|
{
|
|
2380
3319
|
const input = editorElement('input', line(name, title), field + ';width:60px');
|
|
2381
3320
|
input.type = 'color';
|
|
2382
|
-
input.value =
|
|
3321
|
+
input.value = editor3DInputColor(hex);
|
|
2383
3322
|
return live(input, ()=> set(input.value));
|
|
2384
3323
|
};
|
|
2385
3324
|
const number = (row, v, set)=>
|
|
@@ -2392,7 +3331,7 @@ function editor3DSceneUpdate(box)
|
|
|
2392
3331
|
input.onchange = ()=>
|
|
2393
3332
|
{
|
|
2394
3333
|
const n = parseFloat(input.value);
|
|
2395
|
-
isNumber(n) && n >= 0
|
|
3334
|
+
isNumber(n) && n >= 0 ? set(v = n) : input.value = v + ''; // one not taken shows what it has again
|
|
2396
3335
|
editor3DStrokeEnd();
|
|
2397
3336
|
input.blur();
|
|
2398
3337
|
};
|
|
@@ -2417,7 +3356,7 @@ function editor3DSceneUpdate(box)
|
|
|
2417
3356
|
if (sky)
|
|
2418
3357
|
{
|
|
2419
3358
|
['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))));
|
|
3359
|
+
change('sky', (now)=> (isArray(now) && now.length === 3 ? now : sky).map((c, k)=> k === i ? hex : c))));
|
|
2421
3360
|
slider('Ambient', 'How much of the sky colors lights the scene', 0, 1, .05,
|
|
2422
3361
|
isNumber(scene.ambient) ? scene.ambient : .5, (v)=> change('ambient', v));
|
|
2423
3362
|
}
|
|
@@ -2425,16 +3364,17 @@ function editor3DSceneUpdate(box)
|
|
|
2425
3364
|
// the sun, as two angles
|
|
2426
3365
|
const [around, height] = editor3DSunAngles(value('sunDirection'));
|
|
2427
3366
|
slider('Sun around', 'Where the sun is around the scene, in degrees', 0, 359, 1, around, (v)=>
|
|
2428
|
-
change('sunDirection', editor3DSunDirection(v, editor3DSunAngles(
|
|
2429
|
-
slider('Sun height', 'How high the sun is over the horizon, in degrees', 5,
|
|
2430
|
-
change('sunDirection', editor3DSunDirection(editor3DSunAngles(
|
|
3367
|
+
change('sunDirection', (now)=> editor3DSunDirection(v, editor3DSunAngles(now)[1])));
|
|
3368
|
+
slider('Sun height', 'How high the sun is over the horizon, in degrees', 5, 89, 1, height, (v)=>
|
|
3369
|
+
change('sunDirection', (now)=> editor3DSunDirection(editor3DSunAngles(now)[0], v)));
|
|
2431
3370
|
color('Sun color', '', value('sunColor'), (hex)=> change('sunColor', hex));
|
|
2432
3371
|
|
|
2433
3372
|
// the fog, by its distances, an end of 0 for none
|
|
2434
3373
|
const fog = isArray(value('fog')) ? value('fog') : [0, 0], fogRow = line('Fog start, end',
|
|
2435
3374
|
'Where the fog starts and where it is total, an end of 0 for no fog');
|
|
2436
|
-
|
|
2437
|
-
number(fogRow, fog[
|
|
3375
|
+
const fogNow = (now)=> isArray(now) && now.length === 2 ? now : fog;
|
|
3376
|
+
number(fogRow, fog[0], (v)=> change('fog', (now)=> [v, fogNow(now)[1]]));
|
|
3377
|
+
number(fogRow, fog[1], (v)=> change('fog', (now)=> [fogNow(now)[0], v]));
|
|
2438
3378
|
color('Fog color', 'The sky\'s horizon color when the level has a sky and no fog color',
|
|
2439
3379
|
scene.fogColor ?? (sky ? sky[1] : view.fogColor), (hex)=> change('fogColor', hex));
|
|
2440
3380
|
toggle('Shadows', 'The sun casts shadows', !!value('shadows'), (on)=> change('shadows', on));
|