littlejsengine 1.21.0 → 1.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -291,9 +291,11 @@ let editor3DBrush;
291
291
  // the copied objects
292
292
  /** @type {Array<Object>|undefined} */
293
293
  let editor3DClipboard;
294
- let editor3DUndoList = [], editor3DRedoList = []; // the level's, each entry its object list before and after an edit
295
- // the edit being made, a drag is one
296
- /** @type {{before: Array<Object>}|undefined} */
294
+ // the level's, each entry its object list and its parts, the scene and the blocks, before and after an edit
295
+ let editor3DUndoList = [], editor3DRedoList = [];
296
+ // the edit being made, a drag is one: the objects and the parts as they were before it, and painted when blocks
297
+ // were set in the game's map and are not in the level yet
298
+ /** @type {{before: Array<Object>, parts: Object, painted?: boolean, sculpted?: boolean}|undefined} */
297
299
  let editor3DStroke;
298
300
 
299
301
  const editor3DCopy = (value)=> JSON.parse(JSON.stringify(value));
@@ -305,6 +307,103 @@ const editor3DObjects = ()=> isArray(editor3DLevel?.objects) ? editor3DLevel.obj
305
307
  const editor3DObject = (id)=> editor3DObjects().find((o)=> o.id === id);
306
308
  const editor3DSelected = ()=> editor3DObjects().filter((o)=> editor3DSelection.has(o.id));
307
309
 
310
+ // the parts of a level beside its objects that the editor edits: its scene block and its map of blocks; each is
311
+ // undone, autosaved, reset and saved as the objects are
312
+ const editor3DLevelPartNames = ['scene', 'voxels', 'terrain'];
313
+
314
+ // the last copy made of each part, as its text and the copy: a part that has not changed since is not copied
315
+ // again, so undo steps share one copy of a terrain until it is sculpted; a copy is never changed
316
+ /** @type {Object<string, {text: string, part: Object}>} */
317
+ const editor3DPartCopies = {};
318
+
319
+ // a part of a level, undefined when it has none
320
+ function editor3DLevelPart(name, level=editor3DLevel)
321
+ {
322
+ const part = level?.[name];
323
+ return part && typeof part === 'object' && !isArray(part) ? part : undefined;
324
+ }
325
+
326
+ // a copy of every part a level has, {} for a level with none; the copies are shared, see editor3DPartCopies, so
327
+ // what is given is copied again before it is changed or made a level's own
328
+ function editor3DLevelParts(level=editor3DLevel)
329
+ {
330
+ const parts = {};
331
+ for (const name of editor3DLevelPartNames)
332
+ {
333
+ const part = editor3DLevelPart(name, level);
334
+ if (!part) continue;
335
+ const text = JSON.stringify(part);
336
+ if (editor3DPartCopies[name]?.text !== text)
337
+ editor3DPartCopies[name] = {text, part: JSON.parse(text)};
338
+ parts[name] = editor3DPartCopies[name].part;
339
+ }
340
+ return parts;
341
+ }
342
+ const editor3DScene = ()=> editor3DLevelPart('scene');
343
+
344
+ // what a level's hash covers: its objects, and the parts it has, so a level with none hashes as before
345
+ const editor3DContentHash = (objects, parts)=> editor3DHash(JSON.stringify(objects) +
346
+ editor3DLevelPartNames.map((name)=> parts[name] ? JSON.stringify(parts[name]) : '').join(''));
347
+
348
+ // the renderer's settings a scene block can set, as they are now, and put back as they were: what the game set
349
+ // itself shows again when a level's block changes or goes
350
+ function editor3DSceneState()
351
+ {
352
+ const r = render3D;
353
+ return {sky: r.sky, ambientColor: r.ambientColor.copy(), ambientGroundColor: r.ambientGroundColor?.copy(),
354
+ fogStart: r.fogStart, fogEnd: r.fogEnd, fogColor: r.fogColor?.copy(), sunDirection: r.sunDirection.copy(),
355
+ sunColor: r.sunColor.copy(), shadows: r.shadows};
356
+ }
357
+ function editor3DSceneRestore(state)
358
+ {
359
+ const r = render3D;
360
+ if (!r || !state) return;
361
+ r.sky !== state.sky && level3DSkies.has(r.sky) && r.sky.dispose(); // a dome a level made, not the game's
362
+ r.sky = state.sky;
363
+ r.ambientColor = state.ambientColor.copy();
364
+ r.ambientGroundColor = state.ambientGroundColor?.copy();
365
+ r.fogStart = state.fogStart, r.fogEnd = state.fogEnd;
366
+ r.fogColor = state.fogColor?.copy();
367
+ r.sunDirection = state.sunDirection.copy();
368
+ r.sunColor = state.sunColor.copy();
369
+ r.shadows = state.shadows;
370
+ }
371
+
372
+ // the scene on screen as a block, to start a level's scene from: a sky only when the dome's colors are known
373
+ const editor3DHex = (color)=> new Color(clamp(color.r), clamp(color.g), clamp(color.b)).toString(false);
374
+ function editor3DSceneFromView()
375
+ {
376
+ const r = render3D, scene = {}, colors = r.sky && render3DSkyColors.get(r.sky);
377
+ if (colors)
378
+ {
379
+ // how much of the top color the ambient light is
380
+ const top = max(colors[0].r, colors[0].g, colors[0].b);
381
+ const lit = max(r.ambientColor.r, r.ambientColor.g, r.ambientColor.b);
382
+ scene.sky = colors.map(editor3DHex);
383
+ scene.ambient = top ? round(clamp(lit / top) * 100) / 100 : .5;
384
+ }
385
+ const sun = r.sunDirection.normalize();
386
+ scene.sunDirection = [sun.x, sun.y, sun.z].map((v)=> round(v * 1e3) / 1e3);
387
+ scene.sunColor = editor3DHex(r.sunColor);
388
+ scene.fog = [editor3DRound(r.fogStart || 0), editor3DRound(r.fogEnd || 0)];
389
+ scene.fogColor = editor3DHex(r.fogColor || canvasClearColor);
390
+ scene.shadows = !!r.shadows;
391
+ return scene;
392
+ }
393
+
394
+ // the sun's direction as two angles in degrees, for two sliders: around, from +z toward +x, and its height over
395
+ // the horizon; and the direction of two angles, as the file keeps it
396
+ function editor3DSunAngles(direction)
397
+ {
398
+ const d = level3DVector(direction, vec3(0, 1, 0)).normalize();
399
+ return [round((atan2(d.x, d.z) * 180 / PI + 360) % 360) % 360, round(Math.asin(clamp(d.y, -1, 1)) * 180 / PI)];
400
+ }
401
+ function editor3DSunDirection(around, height)
402
+ {
403
+ const a = around * PI / 180, h = height * PI / 180;
404
+ return [sin(a) * cos(h), sin(h), cos(a) * cos(h)].map((v)=> round(v * 1e3) / 1e3 + 0);
405
+ }
406
+
308
407
  // an object's position, rotation in degrees and scale, the defaults where the level leaves them out
309
408
  const editor3DPos = (object)=> level3DVector(object.pos, vec3());
310
409
  const editor3DRotation = (object)=> level3DVector(object.rotation, vec3());
@@ -364,6 +463,14 @@ function editor3DLevelLoaded(level)
364
463
  level.objects[i] && typeof level.objects[i] === 'object' || level.objects.splice(i, 1);
365
464
  editor3DFixIds(level.objects);
366
465
  }
466
+ // its blocks as the editor writes them, so the same map always reads the same, however the file wrote its runs
467
+ const voxels = editor3DLevelPart('voxels', level), shape = level3DVoxelsShape(voxels);
468
+ if (shape)
469
+ {
470
+ const data = new Uint8Array(shape.size.x * shape.size.y * shape.size.z);
471
+ level3DVoxelsDecode(voxels.blocks, data);
472
+ voxels.blocks = level3DVoxelsEncode(data);
473
+ }
367
474
  const known = editor3DRecords.get(level);
368
475
  if (known)
369
476
  {
@@ -376,18 +483,28 @@ function editor3DLevelLoaded(level)
376
483
  // a level from a file goes by the file, one made in code by its objects as they were loaded, so each has an
377
484
  // autosave of its own
378
485
  const url = editorFetchedURLs.get(level)?.split(/[?#]/)[0];
379
- const original = editor3DCopy(editor3DObjects()), hash = editor3DHash(JSON.stringify(original));
486
+ const original = editor3DCopy(editor3DObjects()), originalParts = editor3DLevelParts();
487
+ const hash = editor3DContentHash(original, originalParts);
488
+ // sceneBase is the scene the game set, taken before the level's own block is applied
380
489
  const record = {fileName: url ? url.split('/').pop() : 'level3D.json', key: url ?? 'level #' + hash, original,
381
- hash, pending: undefined, fileHandle: undefined, undo: [], redo: []};
490
+ originalParts, sceneBase: render3D ? editor3DSceneState() : undefined, hash, pending: undefined,
491
+ fileHandle: undefined, undo: [], redo: []};
382
492
  editor3DRecords.set(level, record);
383
493
  editor3DUndoList = record.undo;
384
494
  editor3DRedoList = record.redo;
385
495
  // an autosave the file already has, from a Save, goes; one of this file, or of the file a Save wrote, comes back
386
496
  const saved = editor3DSaves()[record.key];
387
- if (saved && isArray(saved.objects) && editor3DSame(saved.objects, original))
497
+ if (saved && isArray(saved.objects) && editor3DSame(saved.objects, original) &&
498
+ editor3DSame(editor3DLevelParts(saved), originalParts))
388
499
  editor3DAutosave(level);
389
500
  else if ((saved?.hash === hash || saved?.savedHash === hash) && isArray(saved.objects))
501
+ {
502
+ // before level3DLoad makes anything, so the scene and the map are made with the edits in them
503
+ const parts = editor3DLevelParts(saved);
390
504
  level.objects = editor3DCopy(saved.objects);
505
+ for (const name of editor3DLevelPartNames)
506
+ parts[name] ? level[name] = editor3DCopy(parts[name]) : delete level[name];
507
+ }
391
508
  else if (saved)
392
509
  record.pending = saved;
393
510
  }
@@ -451,17 +568,320 @@ function editor3DChange(change)
451
568
  change(after);
452
569
  if (editor3DSame(before, after)) return false;
453
570
  editor3DSetObjects(after);
454
- editor3DStroke ||= {before};
571
+ editor3DStroke ||= {before, parts: editor3DLevelParts()};
572
+ return true;
573
+ }
574
+
575
+ // make a part the level's, or take it away, and show it: for the scene the game's own setup, then what the block
576
+ // sets; for the blocks the game's map, brought in line
577
+ function editor3DSetPart(name, part)
578
+ {
579
+ const level = editor3DLevel;
580
+ part ? level[name] = editor3DCopy(part) : delete level[name];
581
+ if (name === 'scene')
582
+ {
583
+ editor3DSceneRestore(editor3DRecords.get(level)?.sceneBase);
584
+ level3DSceneApply(level.scene);
585
+ }
586
+ else if (name === 'voxels')
587
+ editor3DVoxelShow();
588
+ else
589
+ editor3DTerrainShow();
590
+ }
591
+
592
+ // make the level's parts these, the ones that differ
593
+ function editor3DSetParts(parts)
594
+ {
595
+ for (const name of editor3DLevelPartNames)
596
+ editor3DSame(parts[name], editor3DLevelPart(name)) || editor3DSetPart(name, parts[name]);
597
+ }
598
+
599
+ // change a part of the level as part of the edit being made, as editor3DChange does its objects: change is given
600
+ // a copy of the part, undefined when the level has none, and returns the new one, or undefined for none; false
601
+ // when nothing changed
602
+ function editor3DChangePart(name, change)
603
+ {
604
+ if (!editor3DLevel || editor3DRecords.get(editor3DLevel)?.pending) return false; // its autosave waits first
605
+ const parts = editor3DLevelParts(), after = change(parts[name] && editor3DCopy(parts[name]));
606
+ if (editor3DSame(parts[name], after)) return false;
607
+ editor3DStroke ||= {before: editor3DCopy(editor3DObjects()), parts};
608
+ editor3DSetPart(name, after);
609
+ return true;
610
+ }
611
+ const editor3DChangeScene = (change)=> editor3DChangePart('scene', change);
612
+
613
+ ///////////////////////////////////////////////////////////////////////////////
614
+ // the level's map of blocks: the level's voxels block is the source of truth, the game's VoxelMap follows it;
615
+ // painting sets blocks in the game's map as it goes, and the stroke's end writes them into the level
616
+
617
+ // the game's map of the level's blocks, undefined when the level has none
618
+ function editor3DVoxelMap()
619
+ {
620
+ const map = level3DVoxelMap;
621
+ return map && !map.destroyed && editor3DLevelPart('voxels') ? map : undefined;
622
+ }
623
+
624
+ // bring the game's map in line with the level's voxels block: made, made again when its place or size changed,
625
+ // its cells set, or destroyed with the block gone
626
+ function editor3DVoxelShow()
627
+ {
628
+ const voxels = editor3DLevelPart('voxels'), shape = level3DVoxelsShape(voxels);
629
+ let map = level3DVoxelMap && !level3DVoxelMap.destroyed ? level3DVoxelMap : undefined;
630
+ const sameVector = (a, b)=> a.x === b.x && a.y === b.y && a.z === b.z;
631
+ if (map && !(shape && sameVector(map.pos3D, shape.pos) && sameVector(map.mapSize, shape.size)))
632
+ {
633
+ map.destroy();
634
+ map = level3DVoxelMap = undefined;
635
+ }
636
+ if (!shape) return;
637
+ if (!map)
638
+ {
639
+ level3DVoxelMap = level3DVoxelsMake(voxels);
640
+ return;
641
+ }
642
+ level3DVoxelsDecode(voxels.blocks, map.data);
643
+ map.rebuild();
644
+ }
645
+
646
+ // write the game's map into the level, as a stroke of painting ends
647
+ function editor3DVoxelStore()
648
+ {
649
+ const map = editor3DVoxelMap(), voxels = editor3DLevelPart('voxels');
650
+ map && voxels && (voxels.blocks = level3DVoxelsEncode(map.data));
651
+ }
652
+
653
+ // give the level an empty map of a size in cells, centered on the origin with its bottom on the ground, as one undo
654
+ function editor3DVoxelAdd(size=vec3(32, 16, 32))
655
+ {
656
+ const changed = editor3DChangePart('voxels', ()=> ({pos: [-floor(size.x / 2), 0, -floor(size.z / 2)],
657
+ size: [size.x, size.y, size.z], blocks: [size.x * size.y * size.z, 0]}));
658
+ editor3DStrokeEnd();
659
+ return changed;
660
+ }
661
+
662
+ // take the level's map away, as one undo
663
+ function editor3DVoxelRemove()
664
+ {
665
+ const changed = editor3DChangePart('voxels', ()=> undefined);
666
+ editor3DStrokeEnd();
667
+ return changed;
668
+ }
669
+
670
+ // is a cell in a map
671
+ function editor3DVoxelInside(map, cell)
672
+ {
673
+ const s = map.mapSize;
674
+ return cell.x >= 0 && cell.y >= 0 && cell.z >= 0 && cell.x < s.x && cell.y < s.y && cell.z < s.z;
675
+ }
676
+
677
+ // set a block as part of the edit being made, 0 for none: in the game's map now, in the level when the stroke
678
+ // ends; false when it is that already, or there is no such cell
679
+ function editor3DVoxelSet(cell, type)
680
+ {
681
+ const map = editor3DVoxelMap();
682
+ if (!map || editor3DRecords.get(editor3DLevel)?.pending) return false; // its autosave waits first
683
+ if (!editor3DVoxelInside(map, cell) || map.getVoxel(cell) === type) return false;
684
+ editor3DStroke ||= {before: editor3DCopy(editor3DObjects()), parts: editor3DLevelParts()};
685
+ editor3DStroke.painted = true;
686
+ map.setVoxel(cell, type);
687
+ return true;
688
+ }
689
+
690
+ // the cell a ray picks on a layer of the map: drag has the axis the layer is across, the plane the ray is met
691
+ // with, in cells from the map's corner, and the layer, the cell's place along the axis; undefined off the map
692
+ function editor3DVoxelDragCell(drag, ray)
693
+ {
694
+ const map = editor3DVoxelMap(), axis = drag.axis, along = ray.direction[axis];
695
+ if (!map || !along) return;
696
+ const distance = (map.pos3D[axis] + drag.plane - ray.origin[axis]) / along;
697
+ if (distance < 0) return;
698
+ const p = ray.getPosition(distance).subtract(map.pos3D), cell = vec3(floor(p.x), floor(p.y), floor(p.z));
699
+ cell[axis] = drag.layer;
700
+ return editor3DVoxelInside(map, cell) ? cell : undefined;
701
+ }
702
+
703
+ ///////////////////////////////////////////////////////////////////////////////
704
+ // the level's terrain: the level's terrain block is the source of truth, the game's HeightMap follows it;
705
+ // sculpting changes the game's map as it goes, and the stroke's end writes its heights into the level
706
+
707
+ // the brush: its size across in world units, and how strong it is, 0 to 1
708
+ const editor3DTerrainBrush = {size: 8, strength: .5};
709
+
710
+ // the game's map of the level's terrain, undefined when the level has none
711
+ function editor3DTerrainMap()
712
+ {
713
+ const map = level3DTerrainMap;
714
+ return map && !map.destroyed && editor3DLevelPart('terrain') ? map : undefined;
715
+ }
716
+
717
+ // bring the game's map in line with the level's terrain block: made, made again when its place or shape changed,
718
+ // its heights and color set, or destroyed with the block gone
719
+ function editor3DTerrainShow()
720
+ {
721
+ const terrain = editor3DLevelPart('terrain'), shape = level3DTerrainShape(terrain);
722
+ let map = level3DTerrainMap && !level3DTerrainMap.destroyed ? level3DTerrainMap : undefined;
723
+ const fits = map && shape && map.pos3D.distance(shape.pos) === 0 && map.mapSize.distance(shape.size) === 0 &&
724
+ map.height === shape.height && map.rows === shape.heights.length && map.columns === shape.heights[0].length;
725
+ if (map && !fits)
726
+ {
727
+ map.destroy();
728
+ map = level3DTerrainMap = undefined;
729
+ }
730
+ if (!shape) return;
731
+ if (!map)
732
+ {
733
+ level3DTerrainMap = level3DTerrainMake(terrain);
734
+ return;
735
+ }
736
+ map.heights = shape.heights;
737
+ map.color = /^#([0-9a-f]{6}|[0-9a-f]{8})$/i.test(terrain.color) ? new Color().setHex(terrain.color) : WHITE;
738
+ map.rebuild();
739
+ }
740
+
741
+ // write the game's map into the level, as a stroke of sculpting ends, its heights to a thousandth
742
+ function editor3DTerrainStore()
743
+ {
744
+ const map = editor3DTerrainMap(), terrain = editor3DLevelPart('terrain');
745
+ map && terrain && (terrain.heights = map.heights.map((row)=> row.map((v)=> round(v * 1e3) / 1e3)));
746
+ }
747
+
748
+ // give the level a flat terrain, as one undo: its size in the world, how many cells a side, and how tall a full
749
+ // height is; it starts a fifth of the way up with its surface on the ground, so it can be dug as well as raised
750
+ function editor3DTerrainAdd(size=vec2(64), cells=64, height=16)
751
+ {
752
+ const samples = clamp(floor(cells), 1, LEVEL3D_TERRAIN_SAMPLES - 1) + 1;
753
+ const changed = editor3DChangePart('terrain', ()=> ({pos: [0, editor3DRound(-height * .2), 0],
754
+ size: [size.x, size.y], height, color: '#6a9955',
755
+ heights: Array.from({length: samples}, ()=> Array(samples).fill(.2))}));
756
+ editor3DStrokeEnd();
757
+ return changed;
758
+ }
759
+
760
+ // take the level's terrain away, as one undo
761
+ function editor3DTerrainRemove()
762
+ {
763
+ const changed = editor3DChangePart('terrain', ()=> undefined);
764
+ editor3DStrokeEnd();
765
+ return changed;
766
+ }
767
+
768
+ // sculpt the ground around a point for some seconds, as part of the edit being made: raise, lower or smooth it
769
+ // under the brush, a soft circle, most in its middle and nothing at its edge; in the game's map now, in the level
770
+ // when the stroke ends; false when nothing changed
771
+ function editor3DTerrainSculpt(point, mode, seconds)
772
+ {
773
+ const map = editor3DTerrainMap();
774
+ if (!map || editor3DRecords.get(editor3DLevel)?.pending) return false; // its autosave waits first
775
+ const {size, strength} = editor3DTerrainBrush, radius = size / 2, heights = map.heights;
776
+ const rows = map.rows, columns = map.columns;
777
+ const stepX = map.mapSize.x / (columns - 1), stepZ = map.mapSize.y / (rows - 1);
778
+ const x0 = map.pos3D.x - map.mapSize.x / 2, z0 = map.pos3D.z - map.mapSize.y / 2;
779
+ const c0 = max(0, ceil((point.x - radius - x0) / stepX)), c1 = min(columns - 1, floor((point.x + radius - x0) / stepX));
780
+ const r0 = max(0, ceil((point.z - radius - z0) / stepZ)), r1 = min(rows - 1, floor((point.z + radius - z0) / stepZ));
781
+ // 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;
783
+ const rate = strength * 10 * seconds; // world units of height at full strength, 10 a second
784
+ let changed = false;
785
+ for (let r = r0; r <= r1; ++r)
786
+ for (let c = c0; c <= c1; ++c)
787
+ {
788
+ const distance = hypot(x0 + c * stepX - point.x, z0 + r * stepZ - point.z);
789
+ if (distance >= radius) continue;
790
+ const falloff = .5 + .5 * cos(PI * distance / radius), h = heights[r][c];
791
+ let v;
792
+ if (mode === 'smooth')
793
+ {
794
+ // toward the average of the samples beside it
795
+ let sum = 0, count = 0;
796
+ for (const [dr, dc] of [[-1, 0], [1, 0], [0, -1], [0, 1]])
797
+ old[r + dr]?.[c + dc] !== undefined && (sum += old[r + dr][c + dc], ++count);
798
+ v = h + (sum / count - h) * min(1, rate * falloff);
799
+ }
800
+ else
801
+ v = clamp(h + (mode === 'lower' ? -1 : 1) * rate * falloff / map.height);
802
+ if (v === h) continue;
803
+ heights[r][c] = v;
804
+ changed = true;
805
+ }
806
+ if (!changed) return false;
807
+ editor3DStroke ||= {before: editor3DCopy(editor3DObjects()), parts: editor3DLevelParts()};
808
+ editor3DStroke.sculpted = true;
809
+ map.rebuild();
455
810
  return true;
456
811
  }
457
812
 
813
+ // what the Blocks tool does to a cell: place puts the current type in an empty one, paint gives a block the
814
+ // current type, remove empties it
815
+ function editor3DVoxelApply(cell, mode)
816
+ {
817
+ const map = editor3DVoxelMap(), has = map ? map.getVoxel(cell) : 0;
818
+ return mode === 'place' ? !has && editor3DVoxelSet(cell, editor3DBlockType) :
819
+ mode === 'paint' ? !!has && editor3DVoxelSet(cell, editor3DBlockType) :
820
+ editor3DVoxelSet(cell, 0);
821
+ }
822
+
823
+ // a press of the Blocks tool: the cell under the ray is changed, and what a drag needs to go on from it on the
824
+ // same layer; undefined with no cell there
825
+ function editor3DVoxelPress(ray, mode)
826
+ {
827
+ const target = editor3DVoxelTarget(ray, mode);
828
+ if (!target) return;
829
+ const {cell, axis, plane} = target;
830
+ editor3DVoxelApply(cell, mode);
831
+ return {kind: 'blocks', mode, axis, plane, layer: cell[axis], last: cell};
832
+ }
833
+
834
+ // a drag of the Blocks tool goes on: every cell of the layer from the last one to the one under the ray
835
+ function editor3DVoxelDragTo(drag, ray)
836
+ {
837
+ const cell = editor3DVoxelDragCell(drag, ray), last = drag.last;
838
+ if (!cell) return;
839
+ const d = cell.subtract(last), steps = max(abs(d.x), abs(d.y), abs(d.z));
840
+ for (let i = 1; i <= steps; ++i)
841
+ editor3DVoxelApply(vec3(round(last.x + d.x * i / steps), round(last.y + d.y * i / steps),
842
+ round(last.z + d.z * i / steps)), drag.mode);
843
+ drag.last = cell;
844
+ }
845
+
846
+ // make the type of the block under a ray the one to place; false with no block there
847
+ function editor3DVoxelPick(ray)
848
+ {
849
+ const hit = editor3DVoxelMap()?.raycast(ray);
850
+ if (!hit) return false;
851
+ editor3DBlockType = hit.type;
852
+ }
853
+
854
+ // the cell a click is for: to place, the empty cell against the face the ray hits, or the one standing on the
855
+ // map's floor where it hits no block; otherwise the block it hits; with the axis the face is across and the plane
856
+ // of the face, for a drag to stay on; undefined with no such cell
857
+ function editor3DVoxelTarget(ray, mode)
858
+ {
859
+ const map = editor3DVoxelMap();
860
+ if (!map) return;
861
+ const place = mode === 'place', hit = map.raycast(ray);
862
+ if (!hit)
863
+ {
864
+ const cell = place ? editor3DVoxelDragCell({axis: 'y', plane: 0, layer: 0}, ray) : undefined;
865
+ return cell && {cell, axis: 'y', plane: 0};
866
+ }
867
+ const n = hit.normal, axis = n.x ? 'x' : n.y ? 'y' : 'z', cell = place ? hit.cell.add(n) : hit.cell;
868
+ 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)};
871
+ }
872
+
458
873
  // end the edit being made: one undo, and the autosave
459
874
  function editor3DStrokeEnd()
460
875
  {
461
876
  const stroke = editor3DStroke;
462
877
  editor3DStroke = undefined;
463
- if (!stroke || editor3DSame(stroke.before, editor3DObjects())) return;
464
- editor3DUndoList.push({before: stroke.before, after: editor3DCopy(editor3DObjects())});
878
+ if (!stroke) return;
879
+ stroke.painted && editor3DVoxelStore(); // the blocks painted go into the level
880
+ stroke.sculpted && editor3DTerrainStore(); // and the ground sculpted
881
+ const parts = editor3DLevelParts();
882
+ if (editor3DSame(stroke.before, editor3DObjects()) && editor3DSame(stroke.parts, parts)) return;
883
+ editor3DUndoList.push({before: stroke.before, after: editor3DCopy(editor3DObjects()),
884
+ partsBefore: stroke.parts, partsAfter: parts});
465
885
  editor3DUndoList.length > 100 && editor3DUndoList.shift();
466
886
  editor3DRedoList.length = 0;
467
887
  editor3DAutosave();
@@ -472,7 +892,11 @@ function editor3DStrokeCancel()
472
892
  {
473
893
  const stroke = editor3DStroke;
474
894
  editor3DStroke = undefined;
475
- stroke && editor3DSetObjects(stroke.before);
895
+ if (!stroke) return;
896
+ editor3DSetObjects(stroke.before);
897
+ stroke.painted && editor3DVoxelShow(); // the level never had them, the game's map goes back to it
898
+ stroke.sculpted && editor3DTerrainShow();
899
+ editor3DSetParts(stroke.parts);
476
900
  }
477
901
 
478
902
  // undo the last edit, or redo the last one undone; false with none
@@ -483,6 +907,7 @@ function editor3DUndo(redo=false)
483
907
  if (!entry) return false;
484
908
  (redo ? editor3DUndoList : editor3DRedoList).push(entry);
485
909
  editor3DSetObjects(redo ? entry.after : entry.before);
910
+ editor3DSetParts(redo ? entry.partsAfter : entry.partsBefore);
486
911
  editor3DAutosave();
487
912
  return true;
488
913
  }
@@ -705,18 +1130,20 @@ const editor3DSaves = ()=> readSaveData(editor3DSaveName(), {});
705
1130
  // if the last autosave did not fit in storage, the panel says so
706
1131
  let editor3DSaveFailed = false;
707
1132
 
708
- // remember the level's objects, or forget them when they are back to the file
1133
+ // remember the level's objects and parts, or forget them when they are back to the file
709
1134
  function editor3DAutosave(level=editor3DLevel)
710
1135
  {
711
1136
  const record = editor3DRecords.get(level);
712
1137
  if (!record || record.pending) return; // edits waiting to be applied keep their autosave
713
1138
  const saves = editor3DSaves(), objects = isArray(level.objects) ? level.objects : [];
1139
+ const parts = editor3DLevelParts(level);
714
1140
  // the level as it was loaded has nothing to keep; one a Save wrote is kept until a reload shows the file has it,
715
1141
  // since the browser gives a picked file's name and not its folder, and a file of the same name may be a copy
716
- if (editor3DSame(objects, record.original) && !record.savedHash)
1142
+ if (editor3DSame(objects, record.original) && editor3DSame(parts, record.originalParts) && !record.savedHash)
717
1143
  delete saves[record.key];
718
1144
  else
719
- saves[record.key] = {hash: record.hash, savedHash: record.savedHash, objects: editor3DCopy(objects)};
1145
+ saves[record.key] = {hash: record.hash, savedHash: record.savedHash, objects: editor3DCopy(objects),
1146
+ ...parts};
720
1147
  try
721
1148
  {
722
1149
  localStorage.setItem(editor3DSaveName(), JSON.stringify(saves));
@@ -733,6 +1160,9 @@ function editor3DApplyPending()
733
1160
  record.pending = undefined;
734
1161
  editor3DFixIds(saved.objects);
735
1162
  editor3DChange((list)=> { list.length = 0; list.push(...editor3DCopy(saved.objects)); });
1163
+ const parts = editor3DLevelParts(saved);
1164
+ for (const name of editor3DLevelPartNames)
1165
+ editor3DChangePart(name, ()=> parts[name]);
736
1166
  editor3DStrokeEnd();
737
1167
  editor3DAutosave();
738
1168
  }
@@ -753,16 +1183,18 @@ function editor3DRevert()
753
1183
  const record = editor3DRecords.get(editor3DLevel);
754
1184
  if (!record) return false;
755
1185
  record.pending = undefined;
756
- if (editor3DSame(editor3DObjects(), record.original))
1186
+ if (editor3DSame(editor3DObjects(), record.original) && editor3DSame(editor3DLevelParts(), record.originalParts))
757
1187
  return editor3DAutosave(), false;
758
1188
  editor3DChange((list)=> { list.length = 0; list.push(...editor3DCopy(record.original)); });
1189
+ for (const name of editor3DLevelPartNames)
1190
+ editor3DChangePart(name, ()=> record.originalParts[name] && editor3DCopy(record.originalParts[name]));
759
1191
  editor3DStrokeEnd();
760
1192
  }
761
1193
 
762
1194
  // save the level as JSON: where the browser lets a page write files, Chrome and Edge, to a file picked once and
763
1195
  // written again on each Save after, or picked again with Save As; elsewhere as a download; a file of the level's
764
1196
  // own name that was written is the file from then on, for the autosave; resolves to how it saved, undefined when
765
- // the picker was closed
1197
+ // the picker was closed; saves of a level run one at a time in the order asked, so the file ends with the last
766
1198
  async function editor3DSave(pickAgain=false)
767
1199
  {
768
1200
  const level = editor3DLevel, record = editor3DRecords.get(level);
@@ -771,7 +1203,16 @@ async function editor3DSave(pickAgain=false)
771
1203
  // what is written, kept as it is now: the level can change while the file is picked and written, and those
772
1204
  // edits are not in the file, so they stay in the autosave; the level and record are this one's, whichever is
773
1205
  // open by then
774
- const text = editor3DLevelJSON(level), written = editor3DCopy(JSON.parse(text).objects);
1206
+ const text = editor3DLevelJSON(level);
1207
+ const saved = (async ()=> { await record.saving; return editor3DSaveText(level, record, text, pickAgain); })();
1208
+ record.saving = saved.catch(()=> {});
1209
+ return saved;
1210
+ }
1211
+
1212
+ // write one save of a level, the one before it done
1213
+ async function editor3DSaveText(level, record, text, pickAgain)
1214
+ {
1215
+ const file = JSON.parse(text), written = editor3DCopy(file.objects), writtenParts = editor3DLevelParts(file);
775
1216
  const picker = /** @type {any} */ (globalThis).showSaveFilePicker;
776
1217
  const fileKey = (globalThis.location?.pathname ?? '') + ' 3D ' + record.key;
777
1218
  if (picker)
@@ -797,7 +1238,8 @@ async function editor3DSave(pickAgain=false)
797
1238
  if (record.fileHandle.name === record.fileName)
798
1239
  {
799
1240
  record.original = written;
800
- record.savedHash = editor3DHash(JSON.stringify(written));
1241
+ record.originalParts = writtenParts;
1242
+ record.savedHash = editor3DContentHash(written, writtenParts);
801
1243
  editor3DAutosave(level);
802
1244
  }
803
1245
  return 'written';
@@ -820,7 +1262,15 @@ const EDITOR3D_HANDLE_SIZE = .15; // a handle's length as a part of the view's h
820
1262
  const EDITOR3D_HANDLE_REACH = 10; // how many pixels from a handle still grab it
821
1263
  const EDITOR3D_AXES = {x: vec3(1, 0, 0), y: vec3(0, 1, 0), z: vec3(0, 0, 1)};
822
1264
 
823
- let editor3DTool = 'move'; // 'select', 'move', 'rotate' or 'scale'
1265
+ let editor3DTool = 'move'; // 'select', 'move', 'rotate', 'scale', 'blocks' or 'terrain'
1266
+ let editor3DBlockType = 1; // the block type the Blocks tool places
1267
+ let editor3DSeconds = 0; // how long the last step was, for a brush held down
1268
+ // where the mouse is on the terrain, for the Terrain tool's brush
1269
+ /** @type {Vector3|undefined} */
1270
+ let editor3DTerrainHover;
1271
+ // the cell the Blocks tool would change, for its outline: {cell, mode}
1272
+ /** @type {{cell: Vector3, mode: string}|undefined} */
1273
+ let editor3DBlockHover;
824
1274
  let editor3DGrid = true; // moves, turns and sizes go in steps, Ctrl flips it for a drag
825
1275
  let editor3DGroundSnap = true; // a body drag slides along what is under the mouse
826
1276
  let editor3DMoveStep = 1, editor3DRotateStep = 15, editor3DScaleStep = .25; // the rotate step in degrees
@@ -850,7 +1300,7 @@ function editor3DSelectionCenter()
850
1300
  function editor3DHandles()
851
1301
  {
852
1302
  const center = editor3DSelectionCenter(), tool = editor3DTool, handles = [];
853
- if (!center || tool === 'select') return handles;
1303
+ if (!center || tool === 'select' || tool === 'blocks' || tool === 'terrain') return handles;
854
1304
  const length = editor3DScreenScale(center) * EDITOR3D_HANDLE_SIZE;
855
1305
  const rotation = editor3DRotation(editor3DSelected()[0]).scale(PI / 180), frame = buildMatrix(vec3(), rotation);
856
1306
  for (const axis of 'xyz')
@@ -1026,6 +1476,10 @@ function editor3DPlaceAt(type, ray)
1026
1476
  // the mouse moved with a drag held: the selection goes from where the drag started to where the mouse says
1027
1477
  function editor3DDragTo(drag, mouse, ray, snap)
1028
1478
  {
1479
+ if (drag.kind === 'blocks')
1480
+ return editor3DVoxelDragTo(drag, ray);
1481
+ if (drag.kind === 'terrain')
1482
+ return editor3DTerrainHover && editor3DTerrainSculpt(editor3DTerrainHover, drag.mode, editor3DSeconds);
1029
1483
  if (drag.kind === 'box')
1030
1484
  {
1031
1485
  drag.to = mouse.copy();
@@ -1190,6 +1644,9 @@ const editor3DKeys =
1190
1644
  KeyW: ()=> { editor3DTool = 'move'; },
1191
1645
  KeyE: ()=> { editor3DTool = 'rotate'; },
1192
1646
  KeyR: ()=> { editor3DTool = 'scale'; },
1647
+ KeyB: ()=> { editor3DTool = 'blocks'; editor3DBrush = undefined; },
1648
+ KeyT: ()=> { editor3DTool = 'terrain'; editor3DBrush = undefined; },
1649
+ KeyP: ()=> editor3DTool === 'blocks' && editor3DVoxelPick(render3D.screenToRay(mousePosScreen)),
1193
1650
  KeyG: ()=> { editor3DGrid = !editor3DGrid; },
1194
1651
  KeyF: ()=> editor3DFrame(),
1195
1652
  End: ()=> editor3DDropSelection(),
@@ -1248,6 +1705,7 @@ function editor3DCameraUpdate(seconds, shift, alt)
1248
1705
  // the editor's update, called while it is open with the input readable and render3D looking through its camera
1249
1706
  function editor3DEditorUpdate(seconds)
1250
1707
  {
1708
+ editor3DSeconds = seconds;
1251
1709
  const held = (...codes)=> codes.some((code)=> keyIsDown(code));
1252
1710
  const shift = held('ShiftLeft', 'ShiftRight'), alt = held('AltLeft', 'AltRight');
1253
1711
  const ctrl = held('ControlLeft', 'ControlRight', 'MetaLeft', 'MetaRight');
@@ -1283,7 +1741,23 @@ function editor3DEditorUpdate(seconds)
1283
1741
 
1284
1742
  const ray = render3D.screenToRay(mouse), idle = !cameraDrag && !editor3DMouseOnPanel && !editor3DDrag;
1285
1743
  editor3DHover = idle ? editor3DHandleAt(mouse) : undefined;
1286
- if (idle && mouseWasPressed(0))
1744
+
1745
+ // the Blocks tool takes the mouse while the level has a map: Shift removes, Ctrl repaints
1746
+ const painting = editor3DTool === 'blocks' && !!editor3DVoxelMap();
1747
+ const mode = shift ? 'remove' : ctrl ? 'paint' : 'place';
1748
+ const target = painting && idle ? editor3DVoxelTarget(ray, mode) : undefined;
1749
+ editor3DBlockHover = target && {cell: target.cell, mode};
1750
+ // the Terrain tool takes it while the level has a terrain: held down it raises the ground, Shift lowers,
1751
+ // Ctrl smooths
1752
+ const terrain = editor3DTool === 'terrain' ? editor3DTerrainMap() : undefined;
1753
+ const reach = terrain && !cameraDrag && !editor3DMouseOnPanel ? terrain.raycast(ray) : undefined;
1754
+ editor3DTerrainHover = reach === undefined ? undefined : ray.getPosition(reach);
1755
+ if (painting)
1756
+ idle && mouseWasPressed(0) && (editor3DDrag = editor3DVoxelPress(ray, mode));
1757
+ else if (terrain)
1758
+ idle && mouseWasPressed(0) && editor3DTerrainHover &&
1759
+ (editor3DDrag = {kind: 'terrain', mode: shift ? 'lower' : ctrl ? 'smooth' : 'raise'});
1760
+ else if (idle && mouseWasPressed(0))
1287
1761
  editor3DPress(mouse, ray, shift);
1288
1762
 
1289
1763
  // a quick click, down and up before this step, is a press and its release in one
@@ -1312,6 +1786,8 @@ const editor3DHelpLines =
1312
1786
  'Right button: look, and WASD and QE fly while it is held, Shift faster',
1313
1787
  'Wheel: zoom · Middle drag or Space+drag: pan · Alt+drag: orbit · F: frame the selection',
1314
1788
  'Pick a type, then click to place it, Shift+click keeps placing',
1789
+ 'B blocks: click or drag places · Shift removes · Ctrl repaints · P picks the type under the mouse',
1790
+ 'T terrain: hold to raise the ground · Shift lowers · Ctrl smooths · the brush is in the panel',
1315
1791
  'Delete · Ctrl+C / X / V: copy, cut, paste · Ctrl+D: duplicate · Ctrl+Z / Y: undo, redo',
1316
1792
  'Reset to file: the level as its file has it, Restart keeps your edits, Undo brings them back',
1317
1793
  'Esc: play and edit · 0: exit the editor · ?: keys',
@@ -1329,6 +1805,12 @@ function editor3DHint()
1329
1805
  return 'WASD and QE fly · Shift faster · the wheel sets the speed';
1330
1806
  if (editor3DBrush)
1331
1807
  return `Click to place a ${editor3DBrush} · Shift keeps placing · Q puts it down`;
1808
+ if (editor3DTool === 'terrain')
1809
+ return editor3DTerrainMap() ? 'Hold to raise the ground · Shift lowers · Ctrl smooths' :
1810
+ 'Add a terrain in the panel to sculpt it';
1811
+ if (editor3DTool === 'blocks')
1812
+ return editor3DVoxelMap() ? 'Click or drag places · Shift removes · Ctrl repaints · P picks a type' :
1813
+ 'Add a block map in the panel to paint blocks';
1332
1814
  if (editor3DSelection.size)
1333
1815
  return 'Drag a handle or the object · W move · E rotate · R scale · End drops it';
1334
1816
  return 'Click to select · pick a type to place · the right button looks · ? keys';
@@ -1461,6 +1943,24 @@ function editor3DDraw()
1461
1943
  }
1462
1944
  }
1463
1945
 
1946
+ // the cell the Blocks tool would change: white to place, red to remove, yellow to repaint
1947
+ const map = editor3DTool === 'blocks' && !editor3DMouseOnPanel ? editor3DVoxelMap() : undefined;
1948
+ if (map && editor3DBlockHover && !editor3DDrag)
1949
+ {
1950
+ const {cell, mode} = editor3DBlockHover;
1951
+ editor3DDrawWire(buildMatrix(map.pos3D.add(cell).add(vec3(.5)), undefined, vec3(1.02)),
1952
+ mode === 'place' ? hsl(0, 0, 1, .9) : mode === 'remove' ? hsl(0, 1, .6) : hsl(.15, 1, .6), 2.5);
1953
+ }
1954
+ // the Terrain tool's brush, a ring on the ground
1955
+ if (editor3DTool === 'terrain' && editor3DTerrainHover && editor3DTerrainMap())
1956
+ editor3DDrawRing(editor3DTerrainHover.add(vec3(0, .05, 0)), EDITOR3D_AXES.x, EDITOR3D_AXES.z,
1957
+ editor3DTerrainBrush.size / 2, hsl(0, 0, 1, .9), 2.5);
1958
+
1959
+ // the map's edges, to see where it ends
1960
+ if (map)
1961
+ editor3DDrawWire(buildMatrix(map.pos3D.add(map.mapSize.scale(.5)), undefined, map.mapSize),
1962
+ hsl(.55, .6, .6, .5), 1);
1963
+
1464
1964
  // the brush, a box where a click would place it
1465
1965
  const point = editor3DBrush && !editor3DMouseOnPanel && !editor3DDrag && editor3DMousePoint();
1466
1966
  if (point)
@@ -1589,11 +2089,11 @@ function editor3DPanelInit()
1589
2089
  // the tools, and the snapping
1590
2090
  const tools = row(), toolButtons = {};
1591
2091
  for (const [tool, text, key] of [['select', 'Select', 'Q'], ['move', 'Move', 'W'], ['rotate', 'Rotate', 'E'],
1592
- ['scale', 'Scale', 'R']])
2092
+ ['scale', 'Scale', 'R'], ['blocks', 'Blocks', 'B'], ['terrain', 'Terrain', 'T']])
1593
2093
  toolButtons[tool] = button(tools, text, ()=>
1594
2094
  {
1595
2095
  editor3DTool = tool;
1596
- tool === 'select' && (editor3DBrush = undefined);
2096
+ (tool === 'select' || tool === 'blocks' || tool === 'terrain') && (editor3DBrush = undefined);
1597
2097
  }, key);
1598
2098
  const snap = editorElement('div', panel, box);
1599
2099
  const grid = check(snap, 'Grid snap', (on)=> editor3DGrid = on, 'G, and Ctrl flips it for a drag');
@@ -1609,6 +2109,21 @@ function editor3DPanelInit()
1609
2109
  const types = row();
1610
2110
  const properties = editorElement('div', panel, box);
1611
2111
 
2112
+ // the Blocks tool's box: a map to add, or the types to paint with
2113
+ const blocks = editorElement('div', panel, box);
2114
+
2115
+ // the Terrain tool's box: a terrain to add, or the brush to sculpt with
2116
+ const terrainBox = editorElement('div', panel, box);
2117
+
2118
+ // the scene the level sets: its sky, sun, fog and shadows, or none, the game's own
2119
+ const scene = editorElement('div', panel, box);
2120
+ const sceneOn = check(scene, 'Level sets the scene', (on)=>
2121
+ {
2122
+ editor3DChangeScene(()=> on ? editor3DSceneFromView() : undefined);
2123
+ editor3DStrokeEnd();
2124
+ }, 'The sky, sun, fog and shadows are saved in the level; off leaves them to the game');
2125
+ const sceneRows = editorElement('div', scene);
2126
+
1612
2127
  const file = row();
1613
2128
  // Save says so for a moment when it saved, Save As shows where a page can write files
1614
2129
  const saved = (b, label)=> (result)=> result && (b.textContent = 'Saved', setTimeout(()=> b.textContent = label, 1e3));
@@ -1631,7 +2146,7 @@ function editor3DPanelInit()
1631
2146
  button(help, 'Close', ()=> editor3DHelp = false, '?');
1632
2147
 
1633
2148
  editor3DPanelParts = {restart, pending, toolButtons, grid, moveStep, rotateStep, scaleStep, ground, types,
1634
- properties, playFrom, storage, hint, help, typeNames: ''};
2149
+ properties, blocks, terrainBox, sceneOn, sceneRows, playFrom, storage, hint, help, typeNames: ''};
1635
2150
  }
1636
2151
 
1637
2152
  // show the panel as the editor is now
@@ -1674,6 +2189,255 @@ function editor3DPanelUpdate()
1674
2189
  }
1675
2190
  names.forEach((name, i)=> p.types.children[i].style.outline = name === editor3DBrush ? lit : '');
1676
2191
  editor3DPropertiesUpdate(p.properties);
2192
+ editor3DBlocksUpdate(p.blocks);
2193
+ editor3DTerrainBoxUpdate(p.terrainBox);
2194
+ p.sceneOn.checked = !!editor3DScene();
2195
+ editor3DSceneUpdate(p.sceneRows);
2196
+ }
2197
+
2198
+ // the terrain box, shown with the Terrain tool: without a terrain, its size, cells and height and a button to add
2199
+ // it; with one, the brush's size and strength, the terrain's color, and a button to take it away
2200
+ function editor3DTerrainBoxUpdate(box)
2201
+ {
2202
+ const map = editor3DTerrainMap(), shown = editor3DTool === 'terrain';
2203
+ box.style.display = shown ? '' : 'none';
2204
+ if (!shown) return;
2205
+ const terrain = editor3DLevelPart('terrain'), key = map ? 'terrain ' + terrain.color : 'none';
2206
+ if (box.dataset.key === key || box.contains(document.activeElement)) return;
2207
+ box.dataset.key = key;
2208
+ box.replaceChildren();
2209
+ const field = 'background:#222;color:#eee';
2210
+ const line = (name, title='')=>
2211
+ {
2212
+ const row = editorElement('label', box, 'display:flex;gap:4px;align-items:center;margin:2px 0');
2213
+ editorElement('span', row, 'flex:1', name);
2214
+ row.title = title;
2215
+ return row;
2216
+ };
2217
+ const number = (name, title, value, most)=>
2218
+ {
2219
+ const input = editorElement('input', line(name, title), field + ';width:60px');
2220
+ input.type = 'number';
2221
+ input.min = '1', input.max = most + '', input.step = '1';
2222
+ input.value = value + '';
2223
+ return input;
2224
+ };
2225
+ if (!map)
2226
+ {
2227
+ const size = number('Size', 'How far across it is in the world, each way', 64, 1024);
2228
+ const cellsRow = line('Cells', 'How many cells a side, more is finer and slower to sculpt');
2229
+ const cells = editorElement('select', cellsRow, field + ';width:68px');
2230
+ for (const n of [32, 64, 128])
2231
+ editorElement('option', cells, '', n + '').value = n + '';
2232
+ cells.value = '64';
2233
+ const height = number('Height', 'How tall its highest ground can be', 16, 1024);
2234
+ const add = editorElement('button', box, 'width:100%;padding:3px;cursor:pointer', 'Add terrain');
2235
+ add.title = 'A flat terrain, centered, its surface on the ground, to raise and dig';
2236
+ add.onclick = ()=>
2237
+ {
2238
+ const read = (input, fallback)=> clamp(parseFloat(input.value) || fallback, 1, 1024);
2239
+ editor3DTerrainAdd(vec2(read(size, 64)), parseInt(cells.value), read(height, 16));
2240
+ add.blur();
2241
+ };
2242
+ return;
2243
+ }
2244
+ const slider = (name, title, min, max, step, value, set)=>
2245
+ {
2246
+ const input = editorElement('input', line(name, title), 'width:120px');
2247
+ input.type = 'range';
2248
+ input.min = min, input.max = max, input.step = step;
2249
+ input.value = value + '';
2250
+ input.oninput = ()=> set(parseFloat(input.value));
2251
+ input.onchange = ()=> input.blur();
2252
+ };
2253
+ slider('Brush size', 'How far across the brush is', 1, 32, .5, editor3DTerrainBrush.size,
2254
+ (v)=> editor3DTerrainBrush.size = v);
2255
+ slider('Strength', 'How fast the brush works', .05, 1, .05, editor3DTerrainBrush.strength,
2256
+ (v)=> editor3DTerrainBrush.strength = v);
2257
+ const color = editorElement('input', line('Color'), field + ';width:60px');
2258
+ color.type = 'color';
2259
+ color.value = /^#[0-9a-f]{6}/i.test(terrain.color) ? terrain.color.slice(0, 7) : '#ffffff';
2260
+ color.oninput = ()=> editor3DChangePart('terrain', (t)=> ({...t, color: color.value}));
2261
+ color.onchange = ()=> { color.oninput(); editor3DStrokeEnd(); color.blur(); };
2262
+ const remove = editorElement('button', box, 'margin-top:4px;padding:2px 6px;cursor:pointer', 'Remove terrain');
2263
+ remove.title = 'Take the terrain out of the level, Undo brings it back';
2264
+ remove.onclick = ()=> { editor3DTerrainRemove(); remove.blur(); };
2265
+ }
2266
+
2267
+ // the blocks box, shown with the Blocks tool: without a map, its size and a button to add it; with one, a tile
2268
+ // for each block type to paint with, and a button to take the map away; made again when what it shows changes
2269
+ function editor3DBlocksUpdate(box)
2270
+ {
2271
+ const map = editor3DVoxelMap(), shown = editor3DTool === 'blocks';
2272
+ box.style.display = shown ? '' : 'none';
2273
+ if (!shown) return;
2274
+ const first = map?.tileInfo, texture = first?.textureInfo;
2275
+ const key = map ? ['map', editor3DBlockType, texture?.size.x, map.blockTypes.length].join() : 'none';
2276
+ if (box.dataset.key === key || box.contains(document.activeElement)) return;
2277
+ box.dataset.key = key;
2278
+ box.replaceChildren();
2279
+ const field = 'background:#222;color:#eee;width:46px';
2280
+ if (!map)
2281
+ {
2282
+ const row = editorElement('label', box, 'display:flex;gap:4px;align-items:center;margin:2px 0');
2283
+ editorElement('span', row, 'flex:1', 'Map size');
2284
+ const inputs = [32, 16, 32].map((n)=>
2285
+ {
2286
+ const input = editorElement('input', row, field);
2287
+ input.type = 'number';
2288
+ input.min = '1', input.max = '256', input.step = '1';
2289
+ input.value = n + '';
2290
+ return input;
2291
+ });
2292
+ const add = editorElement('button', box, 'width:100%;padding:3px;cursor:pointer', 'Add block map');
2293
+ add.title = 'An empty map of blocks, centered, its bottom on the ground';
2294
+ add.onclick = ()=>
2295
+ {
2296
+ const [x, y, z] = inputs.map((i)=> clamp(floor(parseFloat(i.value)) || 1, 1, 256));
2297
+ editor3DVoxelAdd(vec3(x, y, z));
2298
+ add.blur();
2299
+ };
2300
+ return;
2301
+ }
2302
+
2303
+ // the tiles of the sheet, a type shows the tile of its side; a click picks the type
2304
+ const image = texture?.image, padding = first.padding || 0;
2305
+ const cell = first.size.add(vec2(padding * 2));
2306
+ const count = texture && cell.x && cell.y ? floor(texture.size.x / cell.x) * floor(texture.size.y / cell.y) : 0;
2307
+ const types = [];
2308
+ for (let type = 1; type < 256 && types.length < 96; ++type)
2309
+ (type < count || map.blockTypes[type]) && types.push(type);
2310
+ types.includes(editor3DBlockType) || types.push(editor3DBlockType);
2311
+ const grid = editorElement('div', box, 'display:flex;flex-wrap:wrap;gap:2px');
2312
+ for (const type of types)
2313
+ {
2314
+ const canvas = editorElement('canvas', grid, 'width:26px;height:26px;cursor:pointer;background:#444;' +
2315
+ 'image-rendering:pixelated;outline:' + (type === editor3DBlockType ? '2px solid #4af' : 'none'));
2316
+ canvas.width = canvas.height = 26;
2317
+ canvas.title = 'Block ' + type;
2318
+ canvas.onclick = ()=> editor3DBlockType = type;
2319
+ // the tile of its side; read without map.blockType, which would make every type one the map has
2320
+ const index = map.blockTypes[type]?.faces[0] ?? type;
2321
+ const tile = first.columns ? first.frame(index) : first.index(index);
2322
+ const context = canvas.getContext('2d');
2323
+ context.imageSmoothingEnabled = false;
2324
+ try
2325
+ {
2326
+ image && context.drawImage(image, tile.pos.x, tile.pos.y, tile.size.x, tile.size.y, 0, 0, 26, 26);
2327
+ // a tile with nothing on it is left out, unless it is the type picked, one in use or one the game set up
2328
+ const pixels = context.getImageData(0, 0, 26, 26).data;
2329
+ let blank = !!image;
2330
+ for (let i = 3; blank && i < pixels.length; i += 4)
2331
+ blank = !pixels[i];
2332
+ blank && type !== editor3DBlockType && !map.blockTypes[type] && canvas.remove();
2333
+ }
2334
+ catch {} // an image that can not be drawn or read, the title still says the type
2335
+ }
2336
+ const remove = editorElement('button', box, 'margin-top:4px;padding:2px 6px;cursor:pointer', 'Remove map');
2337
+ remove.title = 'Take the block map out of the level, Undo brings it back';
2338
+ remove.onclick = ()=> { editor3DVoxelRemove(); remove.blur(); };
2339
+ }
2340
+
2341
+ // the scene box: an input for each setting of the level's scene block, made again when the block changes, but not
2342
+ // while one of its inputs is being used; a slider or a color changes the scene as it moves, and is one undo
2343
+ function editor3DSceneUpdate(box)
2344
+ {
2345
+ const scene = editor3DScene(), key = scene ? JSON.stringify(scene) : '';
2346
+ box.style.display = scene ? '' : 'none';
2347
+ if (box.dataset.key === key || box.contains(document.activeElement)) return;
2348
+ box.dataset.key = key;
2349
+ box.replaceChildren();
2350
+ if (!scene) return;
2351
+
2352
+ // a setting the block leaves out shows as it is on screen
2353
+ const view = editor3DSceneFromView(), value = (name)=> scene[name] ?? view[name];
2354
+ const change = (name, v)=> editor3DChangeScene((s={})=> ({...s, [name]: v}));
2355
+ const field = 'background:#222;color:#eee';
2356
+ const line = (name, title='')=>
2357
+ {
2358
+ const row = editorElement('label', box, 'display:flex;gap:4px;align-items:center;margin:2px 0');
2359
+ editorElement('span', row, 'flex:1', name);
2360
+ row.title = title;
2361
+ return row;
2362
+ };
2363
+ // an input that changes the scene as it moves and ends the edit when let go
2364
+ const live = (input, read)=>
2365
+ {
2366
+ input.oninput = ()=> read();
2367
+ input.onchange = ()=> { read(); editor3DStrokeEnd(); input.blur(); };
2368
+ return input;
2369
+ };
2370
+ const slider = (name, title, min, max, step, v, set)=>
2371
+ {
2372
+ const input = editorElement('input', line(name, title), 'width:120px');
2373
+ input.type = 'range';
2374
+ input.min = min, input.max = max, input.step = step;
2375
+ input.value = v + '';
2376
+ return live(input, ()=> set(parseFloat(input.value)));
2377
+ };
2378
+ const color = (name, title, hex, set)=>
2379
+ {
2380
+ const input = editorElement('input', line(name, title), field + ';width:60px');
2381
+ input.type = 'color';
2382
+ input.value = /^#[0-9a-f]{6}/i.test(hex) ? hex.slice(0, 7) : '#ffffff';
2383
+ return live(input, ()=> set(input.value));
2384
+ };
2385
+ const number = (row, v, set)=>
2386
+ {
2387
+ const input = editorElement('input', row, field + ';width:56px');
2388
+ input.type = 'number';
2389
+ input.step = 'any';
2390
+ input.min = '0';
2391
+ input.value = v + '';
2392
+ input.onchange = ()=>
2393
+ {
2394
+ const n = parseFloat(input.value);
2395
+ isNumber(n) && n >= 0 && set(n);
2396
+ editor3DStrokeEnd();
2397
+ input.blur();
2398
+ };
2399
+ return input;
2400
+ };
2401
+ const toggle = (name, title, on, set)=>
2402
+ {
2403
+ const input = editorElement('input', line(name, title));
2404
+ input.type = 'checkbox';
2405
+ input.checked = on;
2406
+ input.onchange = ()=> { set(input.checked); editor3DStrokeEnd(); input.blur(); };
2407
+ };
2408
+
2409
+ // the sky: three colors and how much of them lights the scene, or no sky
2410
+ const sky = isArray(scene.sky) && scene.sky.length === 3 ? scene.sky : undefined;
2411
+ toggle('Sky', 'A sky dome, and light from it; off leaves the sky to the game', !!sky, (on)=>
2412
+ editor3DChangeScene((s={})=>
2413
+ {
2414
+ const {sky, ambient, ...rest} = s;
2415
+ return on ? {sky: ['#3d9be9', '#ccebff', '#ccebff'], ambient: .5, ...rest} : rest;
2416
+ }));
2417
+ if (sky)
2418
+ {
2419
+ ['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))));
2421
+ slider('Ambient', 'How much of the sky colors lights the scene', 0, 1, .05,
2422
+ isNumber(scene.ambient) ? scene.ambient : .5, (v)=> change('ambient', v));
2423
+ }
2424
+
2425
+ // the sun, as two angles
2426
+ const [around, height] = editor3DSunAngles(value('sunDirection'));
2427
+ slider('Sun around', 'Where the sun is around the scene, in degrees', 0, 359, 1, around, (v)=>
2428
+ change('sunDirection', editor3DSunDirection(v, editor3DSunAngles(value('sunDirection'))[1])));
2429
+ slider('Sun height', 'How high the sun is over the horizon, in degrees', 5, 90, 1, height, (v)=>
2430
+ change('sunDirection', editor3DSunDirection(editor3DSunAngles(value('sunDirection'))[0], v)));
2431
+ color('Sun color', '', value('sunColor'), (hex)=> change('sunColor', hex));
2432
+
2433
+ // the fog, by its distances, an end of 0 for none
2434
+ const fog = isArray(value('fog')) ? value('fog') : [0, 0], fogRow = line('Fog start, end',
2435
+ 'Where the fog starts and where it is total, an end of 0 for no fog');
2436
+ number(fogRow, fog[0], (v)=> change('fog', [v, fog[1]]));
2437
+ number(fogRow, fog[1], (v)=> change('fog', [fog[0], v]));
2438
+ color('Fog color', 'The sky\'s horizon color when the level has a sky and no fog color',
2439
+ scene.fogColor ?? (sky ? sky[1] : view.fogColor), (hex)=> change('fogColor', hex));
2440
+ toggle('Shadows', 'The sun casts shadows', !!value('shadows'), (on)=> change('shadows', on));
1677
2441
  }
1678
2442
 
1679
2443
  // the properties box: the position, rotation and scale of the one selected object and an input for each default