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.
- package/dist/littlejs.d.ts +649 -71
- package/dist/littlejs.esm.js +2919 -306
- package/dist/littlejs.esm.min.js +22 -9
- package/dist/littlejs.js +2885 -306
- package/dist/littlejs.min.js +21 -8
- package/dist/littlejs.release.js +2087 -281
- package/package.json +3 -2
- package/plugins/gltf.js +7 -1
- package/plugins/lightSystem.js +44 -1
- package/plugins/math3d.js +42 -20
- package/plugins/particleEffects.js +745 -0
- package/plugins/pluginExport.js +32 -0
- package/plugins/postProcess.js +206 -28
- package/plugins/render3d.js +484 -185
- package/plugins/render3dEditor.js +787 -23
- package/plugins/render3dExtras.js +223 -11
- package/plugins/render3dLevel.js +166 -3
- package/plugins/sceneSystem.js +3 -2
- package/src/engine.js +1 -1
- package/src/engineAudio.js +16 -7
- package/src/engineBuild.mjs +1 -0
- package/src/engineDraw.js +44 -1
- package/src/engineEditor.js +11 -2
- package/src/engineExport.js +2 -0
- package/src/engineParticles.js +80 -11
- package/src/engineWebGL.js +27 -10
|
@@ -291,9 +291,11 @@ let editor3DBrush;
|
|
|
291
291
|
// the copied objects
|
|
292
292
|
/** @type {Array<Object>|undefined} */
|
|
293
293
|
let editor3DClipboard;
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
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()),
|
|
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
|
-
|
|
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
|
|
464
|
-
|
|
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
|
-
|
|
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)
|
|
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.
|
|
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 '
|
|
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
|
-
|
|
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
|