littlejsengine 1.21.0 → 1.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/littlejs.d.ts +1258 -141
- package/dist/littlejs.esm.js +5023 -429
- package/dist/littlejs.esm.min.js +22 -9
- package/dist/littlejs.js +4977 -430
- package/dist/littlejs.min.js +21 -8
- package/dist/littlejs.release.js +2759 -313
- package/package.json +3 -2
- package/plugins/gltf.js +7 -1
- package/plugins/lightSystem.js +50 -5
- package/plugins/math3d.js +42 -20
- package/plugins/parallax.js +174 -0
- package/plugins/particleEffects.js +760 -0
- package/plugins/pluginExport.js +40 -0
- package/plugins/postProcess.js +206 -28
- package/plugins/render3d.js +495 -188
- package/plugins/render3dEditor.js +1754 -80
- package/plugins/render3dExtras.js +294 -21
- package/plugins/render3dLevel.js +463 -6
- package/plugins/sceneSystem.js +3 -2
- package/src/engine.js +1 -1
- package/src/engineAudio.js +25 -8
- package/src/engineBuild.mjs +2 -0
- package/src/engineDraw.js +59 -4
- package/src/engineEditor.js +475 -37
- package/src/engineExport.js +7 -0
- package/src/engineInput.js +48 -6
- package/src/engineParticles.js +80 -11
- package/src/engineRelease.js +13 -2
- package/src/engineSettings.js +14 -0
- package/src/engineWebGL.js +27 -10
|
@@ -288,12 +288,16 @@ const editor3DSelection = new Set; // the ids of the selected objects
|
|
|
288
288
|
// the type a click places
|
|
289
289
|
/** @type {string|undefined} */
|
|
290
290
|
let editor3DBrush;
|
|
291
|
-
// the copied objects
|
|
291
|
+
// the copied objects, and the size and box offset each had when it was copied
|
|
292
292
|
/** @type {Array<Object>|undefined} */
|
|
293
293
|
let editor3DClipboard;
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
294
|
+
/** @type {Map<Object, {size: Vector3, offset: Vector3}>} */
|
|
295
|
+
let editor3DClipboardBoxes = new Map;
|
|
296
|
+
// the level's, each entry its object list and its parts, the scene and the blocks, before and after an edit
|
|
297
|
+
let editor3DUndoList = [], editor3DRedoList = [];
|
|
298
|
+
// the edit being made, a drag is one: the objects and the parts as they were before it, and painted when blocks
|
|
299
|
+
// were set in the game's map and are not in the level yet
|
|
300
|
+
/** @type {{before: Array<Object>, parts: Object, painted?: boolean, sculpted?: boolean}|undefined} */
|
|
297
301
|
let editor3DStroke;
|
|
298
302
|
|
|
299
303
|
const editor3DCopy = (value)=> JSON.parse(JSON.stringify(value));
|
|
@@ -305,6 +309,103 @@ const editor3DObjects = ()=> isArray(editor3DLevel?.objects) ? editor3DLevel.obj
|
|
|
305
309
|
const editor3DObject = (id)=> editor3DObjects().find((o)=> o.id === id);
|
|
306
310
|
const editor3DSelected = ()=> editor3DObjects().filter((o)=> editor3DSelection.has(o.id));
|
|
307
311
|
|
|
312
|
+
// the parts of a level beside its objects that the editor edits: its scene block and its map of blocks; each is
|
|
313
|
+
// undone, autosaved, reset and saved as the objects are
|
|
314
|
+
const editor3DLevelPartNames = ['scene', 'voxels', 'terrain', 'prefabs'];
|
|
315
|
+
|
|
316
|
+
// the last copy made of each part, as its text and the copy: a part that has not changed since is not copied
|
|
317
|
+
// again, so undo steps share one copy of a terrain until it is sculpted; a copy is never changed
|
|
318
|
+
/** @type {Object<string, {text: string, part: Object}>} */
|
|
319
|
+
const editor3DPartCopies = {};
|
|
320
|
+
|
|
321
|
+
// a part of a level, undefined when it has none
|
|
322
|
+
function editor3DLevelPart(name, level=editor3DLevel)
|
|
323
|
+
{
|
|
324
|
+
const part = level?.[name];
|
|
325
|
+
return part && typeof part === 'object' && !isArray(part) ? part : undefined;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// a copy of every part a level has, {} for a level with none; the copies are shared, see editor3DPartCopies, so
|
|
329
|
+
// what is given is copied again before it is changed or made a level's own
|
|
330
|
+
function editor3DLevelParts(level=editor3DLevel)
|
|
331
|
+
{
|
|
332
|
+
const parts = {};
|
|
333
|
+
for (const name of editor3DLevelPartNames)
|
|
334
|
+
{
|
|
335
|
+
const part = editor3DLevelPart(name, level);
|
|
336
|
+
if (!part) continue;
|
|
337
|
+
const text = JSON.stringify(part);
|
|
338
|
+
if (editor3DPartCopies[name]?.text !== text)
|
|
339
|
+
editor3DPartCopies[name] = {text, part: JSON.parse(text)};
|
|
340
|
+
parts[name] = editor3DPartCopies[name].part;
|
|
341
|
+
}
|
|
342
|
+
return parts;
|
|
343
|
+
}
|
|
344
|
+
const editor3DScene = ()=> editor3DLevelPart('scene');
|
|
345
|
+
|
|
346
|
+
// what a level's hash covers: its objects, and the parts it has, so a level with none hashes as before
|
|
347
|
+
const editor3DContentHash = (objects, parts)=> editor3DHash(JSON.stringify(objects) +
|
|
348
|
+
editor3DLevelPartNames.map((name)=> parts[name] ? JSON.stringify(parts[name]) : '').join(''));
|
|
349
|
+
|
|
350
|
+
// the renderer's settings a scene block can set, as they are now, and put back as they were: what the game set
|
|
351
|
+
// itself shows again when a level's block changes or goes
|
|
352
|
+
function editor3DSceneState()
|
|
353
|
+
{
|
|
354
|
+
const r = render3D;
|
|
355
|
+
return {sky: r.sky, ambientColor: r.ambientColor.copy(), ambientGroundColor: r.ambientGroundColor?.copy(),
|
|
356
|
+
fogStart: r.fogStart, fogEnd: r.fogEnd, fogColor: r.fogColor?.copy(), sunDirection: r.sunDirection.copy(),
|
|
357
|
+
sunColor: r.sunColor.copy(), shadows: r.shadows};
|
|
358
|
+
}
|
|
359
|
+
function editor3DSceneRestore(state)
|
|
360
|
+
{
|
|
361
|
+
const r = render3D;
|
|
362
|
+
if (!r || !state) return;
|
|
363
|
+
r.sky !== state.sky && level3DSkies.has(r.sky) && r.sky.dispose(); // a dome a level made, not the game's
|
|
364
|
+
r.sky = state.sky;
|
|
365
|
+
r.ambientColor = state.ambientColor.copy();
|
|
366
|
+
r.ambientGroundColor = state.ambientGroundColor?.copy();
|
|
367
|
+
r.fogStart = state.fogStart, r.fogEnd = state.fogEnd;
|
|
368
|
+
r.fogColor = state.fogColor?.copy();
|
|
369
|
+
r.sunDirection = state.sunDirection.copy();
|
|
370
|
+
r.sunColor = state.sunColor.copy();
|
|
371
|
+
r.shadows = state.shadows;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// the scene on screen as a block, to start a level's scene from: a sky only when the dome's colors are known
|
|
375
|
+
const editor3DHex = (color)=> new Color(clamp(color.r), clamp(color.g), clamp(color.b)).toString(false);
|
|
376
|
+
function editor3DSceneFromView()
|
|
377
|
+
{
|
|
378
|
+
const r = render3D, scene = {}, colors = r.sky && render3DSkyColors.get(r.sky);
|
|
379
|
+
if (colors)
|
|
380
|
+
{
|
|
381
|
+
// how much of the top color the ambient light is
|
|
382
|
+
const top = max(colors[0].r, colors[0].g, colors[0].b);
|
|
383
|
+
const lit = max(r.ambientColor.r, r.ambientColor.g, r.ambientColor.b);
|
|
384
|
+
scene.sky = colors.map(editor3DHex);
|
|
385
|
+
scene.ambient = top ? round(clamp(lit / top) * 100) / 100 : .5;
|
|
386
|
+
}
|
|
387
|
+
const sun = r.sunDirection.normalize();
|
|
388
|
+
scene.sunDirection = [sun.x, sun.y, sun.z].map((v)=> round(v * 1e3) / 1e3);
|
|
389
|
+
scene.sunColor = editor3DHex(r.sunColor);
|
|
390
|
+
scene.fog = [editor3DRound(r.fogStart || 0), editor3DRound(r.fogEnd || 0)];
|
|
391
|
+
scene.fogColor = editor3DHex(r.fogColor || canvasClearColor);
|
|
392
|
+
scene.shadows = !!r.shadows;
|
|
393
|
+
return scene;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// the sun's direction as two angles in degrees, for two sliders: around, from +z toward +x, and its height over
|
|
397
|
+
// the horizon; and the direction of two angles, as the file keeps it
|
|
398
|
+
function editor3DSunAngles(direction)
|
|
399
|
+
{
|
|
400
|
+
const d = level3DVector(direction, vec3(0, 1, 0)).normalize();
|
|
401
|
+
return [round((atan2(d.x, d.z) * 180 / PI + 360) % 360) % 360, round(Math.asin(clamp(d.y, -1, 1)) * 180 / PI)];
|
|
402
|
+
}
|
|
403
|
+
function editor3DSunDirection(around, height)
|
|
404
|
+
{
|
|
405
|
+
const a = around * PI / 180, h = height * PI / 180;
|
|
406
|
+
return [sin(a) * cos(h), sin(h), cos(a) * cos(h)].map((v)=> round(v * 1e3) / 1e3 + 0);
|
|
407
|
+
}
|
|
408
|
+
|
|
308
409
|
// an object's position, rotation in degrees and scale, the defaults where the level leaves them out
|
|
309
410
|
const editor3DPos = (object)=> level3DVector(object.pos, vec3());
|
|
310
411
|
const editor3DRotation = (object)=> level3DVector(object.rotation, vec3());
|
|
@@ -314,12 +415,46 @@ const editor3DTurned = (object)=> editor3DRotation(object).lengthSquared() > 0;
|
|
|
314
415
|
// the size of an object's box: what the game made for it says, a marker is 1 unit
|
|
315
416
|
function editor3DSize(object)
|
|
316
417
|
{
|
|
317
|
-
const made = editor3DInstances.get(object.id);
|
|
418
|
+
const made = editor3DInstances.get(object.id), box = editor3DPrefabBox(made);
|
|
419
|
+
if (box) return box.size;
|
|
318
420
|
if (!(made instanceof EngineObject3D) || !(made.mesh || made.tileInfo)) return vec3(1);
|
|
319
421
|
const s = made.size3D, k = made.scale3D;
|
|
320
422
|
return vec3(s.x * abs(k.x), s.y * abs(k.y), s.z * abs(k.z));
|
|
321
423
|
}
|
|
322
424
|
|
|
425
|
+
// the box around a prefab's instance in the world, the boxes of its parts together, as its middle and its size;
|
|
426
|
+
// undefined for anything else, or an instance with nothing to see
|
|
427
|
+
function editor3DPrefabBox(made)
|
|
428
|
+
{
|
|
429
|
+
if (!(made instanceof Prefab3D) || made.destroyed) return;
|
|
430
|
+
const low = vec3(Infinity), high = vec3(-Infinity);
|
|
431
|
+
for (const part of editor3DParts(made))
|
|
432
|
+
{
|
|
433
|
+
if (!(part instanceof EngineObject3D) || !(part.mesh || part.tileInfo)) continue;
|
|
434
|
+
const bounds = part.mesh && (part.mesh.bounds || part.mesh.getBounds());
|
|
435
|
+
const matrix = render3DObjectMatrix(part).copy().multiply(bounds ?
|
|
436
|
+
buildMatrix(bounds.min.add(bounds.max).scale(.5), undefined, bounds.max.subtract(bounds.min)) :
|
|
437
|
+
buildMatrix(vec3(), undefined, part.size3D));
|
|
438
|
+
for (let i = 8; i--;)
|
|
439
|
+
{
|
|
440
|
+
const p = matrix.transformPoint(vec3(i & 1 ? .5 : -.5, i & 2 ? .5 : -.5, i & 4 ? .5 : -.5));
|
|
441
|
+
for (const k of ['x', 'y', 'z'])
|
|
442
|
+
low[k] = min(low[k], p[k]), high[k] = max(high[k], p[k]);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
// to the nearest part of the file's numbers, so a box worked out through a matrix reads as it was written
|
|
446
|
+
const tidy = (v)=> vec3(editor3DRound(v.x), editor3DRound(v.y), editor3DRound(v.z));
|
|
447
|
+
return low.x <= high.x ? {pos: tidy(low.add(high).scale(.5)), size: tidy(high.subtract(low))} : undefined;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// from an object's place to the middle of its box: nothing for a plain object, whose place is its middle, and for
|
|
451
|
+
// a prefab's instance how far its origin is from the middle of its parts, as it is turned and sized now
|
|
452
|
+
function editor3DBoxOffset(object)
|
|
453
|
+
{
|
|
454
|
+
const box = editor3DPrefabBox(editor3DInstances.get(object.id));
|
|
455
|
+
return box ? box.pos.subtract(editor3DPos(object)) : vec3();
|
|
456
|
+
}
|
|
457
|
+
|
|
323
458
|
// every object gets an id of its own, a whole number: one without, or with one already used, gets a new one
|
|
324
459
|
function editor3DFixIds(objects)
|
|
325
460
|
{
|
|
@@ -355,6 +490,12 @@ function editor3DLevelLoaded(level)
|
|
|
355
490
|
editor3DSelection.clear();
|
|
356
491
|
editor3DStroke = undefined;
|
|
357
492
|
}
|
|
493
|
+
if (!editor3DPrefabLevels.has(level) && editor3DPrefabStack.length)
|
|
494
|
+
{
|
|
495
|
+
// the game loaded a level while a prefab was open: what was open is gone with what it was opened from
|
|
496
|
+
editor3DPrefabStack.length = 0;
|
|
497
|
+
editor3DPrefabEdits = {};
|
|
498
|
+
}
|
|
358
499
|
editor3DLevel = level;
|
|
359
500
|
editor3DInstances.clear();
|
|
360
501
|
if (isArray(level.objects))
|
|
@@ -364,6 +505,14 @@ function editor3DLevelLoaded(level)
|
|
|
364
505
|
level.objects[i] && typeof level.objects[i] === 'object' || level.objects.splice(i, 1);
|
|
365
506
|
editor3DFixIds(level.objects);
|
|
366
507
|
}
|
|
508
|
+
// its blocks as the editor writes them, so the same map always reads the same, however the file wrote its runs
|
|
509
|
+
const voxels = editor3DLevelPart('voxels', level), shape = level3DVoxelsShape(voxels);
|
|
510
|
+
if (shape)
|
|
511
|
+
{
|
|
512
|
+
const data = new Uint8Array(shape.size.x * shape.size.y * shape.size.z);
|
|
513
|
+
level3DVoxelsDecode(voxels.blocks, data);
|
|
514
|
+
voxels.blocks = level3DVoxelsEncode(data);
|
|
515
|
+
}
|
|
367
516
|
const known = editor3DRecords.get(level);
|
|
368
517
|
if (known)
|
|
369
518
|
{
|
|
@@ -376,18 +525,29 @@ function editor3DLevelLoaded(level)
|
|
|
376
525
|
// 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
526
|
// autosave of its own
|
|
378
527
|
const url = editorFetchedURLs.get(level)?.split(/[?#]/)[0];
|
|
379
|
-
const original = editor3DCopy(editor3DObjects()),
|
|
528
|
+
const original = editor3DCopy(editor3DObjects()), originalParts = editor3DLevelParts();
|
|
529
|
+
const hash = editor3DContentHash(original, originalParts);
|
|
530
|
+
// sceneBase is the scene the game set, taken before the level's own block is applied
|
|
380
531
|
const record = {fileName: url ? url.split('/').pop() : 'level3D.json', key: url ?? 'level #' + hash, original,
|
|
381
|
-
|
|
532
|
+
originalParts, sceneBase: render3D ? editor3DSceneState() : undefined, hash, pending: undefined,
|
|
533
|
+
fileHandle: undefined, undo: [], redo: []};
|
|
382
534
|
editor3DRecords.set(level, record);
|
|
383
535
|
editor3DUndoList = record.undo;
|
|
384
536
|
editor3DRedoList = record.redo;
|
|
385
537
|
// an autosave the file already has, from a Save, goes; one of this file, or of the file a Save wrote, comes back
|
|
386
|
-
|
|
387
|
-
|
|
538
|
+
// a prefab being edited has no autosave, and one of a level with the same objects is not its
|
|
539
|
+
const saved = editor3DPrefabLevels.has(level) ? undefined : editor3DSaves()[record.key];
|
|
540
|
+
if (saved && isArray(saved.objects) && editor3DSame(saved.objects, original) &&
|
|
541
|
+
editor3DSame(editor3DLevelParts(saved), originalParts))
|
|
388
542
|
editor3DAutosave(level);
|
|
389
543
|
else if ((saved?.hash === hash || saved?.savedHash === hash) && isArray(saved.objects))
|
|
544
|
+
{
|
|
545
|
+
// before level3DLoad makes anything, so the scene and the map are made with the edits in them
|
|
546
|
+
const parts = editor3DLevelParts(saved);
|
|
390
547
|
level.objects = editor3DCopy(saved.objects);
|
|
548
|
+
for (const name of editor3DLevelPartNames)
|
|
549
|
+
parts[name] ? level[name] = editor3DCopy(parts[name]) : delete level[name];
|
|
550
|
+
}
|
|
391
551
|
else if (saved)
|
|
392
552
|
record.pending = saved;
|
|
393
553
|
}
|
|
@@ -406,6 +566,7 @@ function editor3DPlaceInstance(made, object)
|
|
|
406
566
|
made.scale3D = (level3DBaseScale.get(made) ?? vec3(1)).multiply(editor3DScale(object));
|
|
407
567
|
made.velocity3D = vec3();
|
|
408
568
|
made.angleVelocity3D = vec3();
|
|
569
|
+
made instanceof Prefab3D && made.placeAt(editor3DPos(object)); // by its origin, and its parts go with it
|
|
409
570
|
}
|
|
410
571
|
|
|
411
572
|
// make an object's game object, again when it has one
|
|
@@ -440,6 +601,24 @@ function editor3DSetObjects(list)
|
|
|
440
601
|
made instanceof EngineObject3D && !made.destroyed;
|
|
441
602
|
onlyMoved ? editor3DPlaceInstance(made, object) : editor3DMakeInstance(object);
|
|
442
603
|
}
|
|
604
|
+
editor3DShadowLight();
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// the light that casts the shadows is the level's last Light that asks to, as it is after a load, whatever order
|
|
608
|
+
// the edits made them in; a light of the game's own is left alone while no Light of the level asks
|
|
609
|
+
function editor3DShadowLight()
|
|
610
|
+
{
|
|
611
|
+
if (!render3D) return;
|
|
612
|
+
let light;
|
|
613
|
+
for (const object of editor3DObjects())
|
|
614
|
+
{
|
|
615
|
+
const made = editor3DInstances.get(object.id);
|
|
616
|
+
if (object.type === 'Light' && object.properties?.shadows === true && made instanceof Light3D && !made.destroyed)
|
|
617
|
+
light = made;
|
|
618
|
+
}
|
|
619
|
+
const now = render3D.shadowLight;
|
|
620
|
+
if (light || !now || now.destroyed || [...editor3DInstances.values()].some((made)=> made === now))
|
|
621
|
+
render3D.shadowLight = light;
|
|
443
622
|
}
|
|
444
623
|
|
|
445
624
|
// change the level's objects as part of the edit being made: change edits a copy of the list; the changes until
|
|
@@ -451,17 +630,504 @@ function editor3DChange(change)
|
|
|
451
630
|
change(after);
|
|
452
631
|
if (editor3DSame(before, after)) return false;
|
|
453
632
|
editor3DSetObjects(after);
|
|
454
|
-
editor3DStroke ||= {before};
|
|
633
|
+
editor3DStroke ||= {before, parts: editor3DLevelParts()};
|
|
634
|
+
return true;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
// 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
|
|
638
|
+
// sets; for the blocks the game's map, brought in line
|
|
639
|
+
function editor3DSetPart(name, part)
|
|
640
|
+
{
|
|
641
|
+
const level = editor3DLevel;
|
|
642
|
+
part ? level[name] = editor3DCopy(part) : delete level[name];
|
|
643
|
+
if (name === 'scene')
|
|
644
|
+
{
|
|
645
|
+
editor3DSceneRestore(editor3DRecords.get(level)?.sceneBase);
|
|
646
|
+
level3DSceneApply(level.scene);
|
|
647
|
+
}
|
|
648
|
+
else if (name === 'voxels')
|
|
649
|
+
editor3DVoxelShow();
|
|
650
|
+
else if (name === 'terrain')
|
|
651
|
+
editor3DTerrainShow();
|
|
652
|
+
else
|
|
653
|
+
editor3DPrefabsShow();
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
// bring the prefab types in line with the level's own prefabs, and make every instance again: a prefab the level
|
|
657
|
+
// no longer has is no longer a type, one it has is added as level3DLoad adds it, and what a prefab makes is made
|
|
658
|
+
// when its instance is, so each instance is made again
|
|
659
|
+
function editor3DPrefabsShow()
|
|
660
|
+
{
|
|
661
|
+
if (editor3DPrefabLevels.has(editor3DLevel)) return; // inside a prefab, the prefabs are the level's
|
|
662
|
+
const prefabs = editor3DLevelPart('prefabs') ?? {};
|
|
663
|
+
for (const [name, prefab] of level3DPrefabs)
|
|
664
|
+
{
|
|
665
|
+
if (!prefab.fromLevel || prefabs[name]) continue;
|
|
666
|
+
level3DPrefabs.delete(name);
|
|
667
|
+
level3DTypes.delete(name);
|
|
668
|
+
}
|
|
669
|
+
level3DPrefabsAdd(prefabs);
|
|
670
|
+
// every object of a prefab's type, one whose type was not there when the objects were set too
|
|
671
|
+
for (const object of editor3DObjects())
|
|
672
|
+
level3DPrefabs.has(object.type) && editor3DMakeInstance(object);
|
|
673
|
+
editor3DShadowLight();
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
// make the level's parts these, the ones that differ
|
|
677
|
+
function editor3DSetParts(parts)
|
|
678
|
+
{
|
|
679
|
+
for (const name of editor3DLevelPartNames)
|
|
680
|
+
editor3DSame(parts[name], editor3DLevelPart(name)) || editor3DSetPart(name, parts[name]);
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
// the stroke being made, started here with the level as it is when there is none: called before the level changes
|
|
684
|
+
/** @return {{before: Array<Object>, parts: Object, painted?: boolean, sculpted?: boolean}} */
|
|
685
|
+
function editor3DStrokeBegin()
|
|
686
|
+
{ return editor3DStroke ||= {before: editor3DCopy(editor3DObjects()), parts: editor3DLevelParts()}; }
|
|
687
|
+
|
|
688
|
+
// change a part of the level as part of the edit being made, as editor3DChange does its objects: change is given
|
|
689
|
+
// a copy of the part, undefined when the level has none, and returns the new one, or undefined for none; false
|
|
690
|
+
// when nothing changed
|
|
691
|
+
function editor3DChangePart(name, change)
|
|
692
|
+
{
|
|
693
|
+
if (!editor3DLevel || editor3DRecords.get(editor3DLevel)?.pending) return false; // its autosave waits first
|
|
694
|
+
const parts = editor3DLevelParts(), after = change(parts[name] && editor3DCopy(parts[name]));
|
|
695
|
+
if (editor3DSame(parts[name], after)) return false;
|
|
696
|
+
editor3DStrokeBegin();
|
|
697
|
+
editor3DSetPart(name, after);
|
|
698
|
+
return true;
|
|
699
|
+
}
|
|
700
|
+
const editor3DChangeScene = (change)=> editor3DChangePart('scene', change);
|
|
701
|
+
|
|
702
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
703
|
+
// the level's map of blocks: the level's voxels block is the source of truth, the game's VoxelMap follows it;
|
|
704
|
+
// painting sets blocks in the game's map as it goes, and the stroke's end writes them into the level
|
|
705
|
+
|
|
706
|
+
// the game's map of the level's blocks, undefined when the level has none
|
|
707
|
+
function editor3DVoxelMap()
|
|
708
|
+
{
|
|
709
|
+
const map = level3DVoxelMap;
|
|
710
|
+
return map && !map.destroyed && editor3DLevelPart('voxels') ? map : undefined;
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
// bring the game's map in line with the level's voxels block: made, made again when its place or size changed,
|
|
714
|
+
// its cells set, or destroyed with the block gone
|
|
715
|
+
function editor3DVoxelShow()
|
|
716
|
+
{
|
|
717
|
+
const voxels = editor3DLevelPart('voxels'), shape = level3DVoxelsShape(voxels);
|
|
718
|
+
let map = level3DVoxelMap && !level3DVoxelMap.destroyed ? level3DVoxelMap : undefined;
|
|
719
|
+
const sameVector = (a, b)=> a.x === b.x && a.y === b.y && a.z === b.z;
|
|
720
|
+
if (map && !(shape && sameVector(map.pos3D, shape.pos) && sameVector(map.mapSize, shape.size)))
|
|
721
|
+
{
|
|
722
|
+
map.destroy();
|
|
723
|
+
map = level3DVoxelMap = undefined;
|
|
724
|
+
}
|
|
725
|
+
if (!shape) return;
|
|
726
|
+
if (!map)
|
|
727
|
+
{
|
|
728
|
+
level3DVoxelMap = level3DVoxelsMake(voxels);
|
|
729
|
+
return;
|
|
730
|
+
}
|
|
731
|
+
level3DVoxelsDecode(voxels.blocks, map.data);
|
|
732
|
+
map.rebuild();
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
// write the game's map into the level, as a stroke of painting ends
|
|
736
|
+
function editor3DVoxelStore()
|
|
737
|
+
{
|
|
738
|
+
const map = editor3DVoxelMap(), voxels = editor3DLevelPart('voxels');
|
|
739
|
+
map && voxels && (voxels.blocks = level3DVoxelsEncode(map.data));
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
// give the level an empty map of a size in cells, centered on the origin with its bottom on the ground, as one undo
|
|
743
|
+
function editor3DVoxelAdd(size=vec3(32, 16, 32))
|
|
744
|
+
{
|
|
745
|
+
const changed = editor3DChangePart('voxels', ()=> ({pos: [-floor(size.x / 2), 0, -floor(size.z / 2)],
|
|
746
|
+
size: [size.x, size.y, size.z], blocks: [size.x * size.y * size.z, 0]}));
|
|
747
|
+
editor3DStrokeEnd();
|
|
748
|
+
return changed;
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
// take the level's map away, as one undo
|
|
752
|
+
function editor3DVoxelRemove()
|
|
753
|
+
{
|
|
754
|
+
const changed = editor3DChangePart('voxels', ()=> undefined);
|
|
755
|
+
editor3DStrokeEnd();
|
|
756
|
+
return changed;
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
// is a cell in a map
|
|
760
|
+
function editor3DVoxelInside(map, cell)
|
|
761
|
+
{
|
|
762
|
+
const s = map.mapSize;
|
|
763
|
+
return cell.x >= 0 && cell.y >= 0 && cell.z >= 0 && cell.x < s.x && cell.y < s.y && cell.z < s.z;
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
// 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
|
|
767
|
+
// ends; false when it is that already, or there is no such cell
|
|
768
|
+
function editor3DVoxelSet(cell, type)
|
|
769
|
+
{
|
|
770
|
+
const map = editor3DVoxelMap();
|
|
771
|
+
if (!map || editor3DRecords.get(editor3DLevel)?.pending) return false; // its autosave waits first
|
|
772
|
+
if (!editor3DVoxelInside(map, cell) || map.getVoxel(cell) === type) return false;
|
|
773
|
+
editor3DStrokeBegin().painted = true;
|
|
774
|
+
map.setVoxel(cell, type);
|
|
775
|
+
return true;
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
// 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
|
|
779
|
+
// with, in cells from the map's corner, and the layer, the cell's place along the axis; undefined off the map
|
|
780
|
+
function editor3DVoxelDragCell(drag, ray)
|
|
781
|
+
{
|
|
782
|
+
const map = editor3DVoxelMap(), axis = drag.axis, along = ray.direction[axis];
|
|
783
|
+
if (!map || !along) return;
|
|
784
|
+
const distance = (map.pos3D[axis] + drag.plane - ray.origin[axis]) / along;
|
|
785
|
+
if (distance < 0) return;
|
|
786
|
+
const p = ray.getPosition(distance).subtract(map.pos3D), cell = vec3(floor(p.x), floor(p.y), floor(p.z));
|
|
787
|
+
cell[axis] = drag.layer;
|
|
788
|
+
return editor3DVoxelInside(map, cell) ? cell : undefined;
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
792
|
+
// the level's terrain: the level's terrain block is the source of truth, the game's HeightMap follows it;
|
|
793
|
+
// sculpting changes the game's map as it goes, and the stroke's end writes its heights into the level
|
|
794
|
+
|
|
795
|
+
// the brush: its size across in world units, how strong it is, 0 to 1, which brush it is, sculpt, flatten or
|
|
796
|
+
// paint, and the color it paints
|
|
797
|
+
const editor3DTerrainBrush = {size: 8, strength: .5, mode: 'sculpt', color: '#8a6a4a'};
|
|
798
|
+
const editor3DTerrainBrushes = ['sculpt', 'flatten', 'paint'];
|
|
799
|
+
|
|
800
|
+
// the paint of the game's terrain map as it is being edited, {colors, cells} as level3DTerrainPaint gives it:
|
|
801
|
+
// from the level, and ahead of it while a stroke paints
|
|
802
|
+
const editor3DTerrainPaints = new WeakMap;
|
|
803
|
+
function editor3DTerrainPaint(map)
|
|
804
|
+
{
|
|
805
|
+
let paint = editor3DTerrainPaints.get(map);
|
|
806
|
+
if (!paint)
|
|
807
|
+
{
|
|
808
|
+
const samples = map.rows * map.columns;
|
|
809
|
+
paint = level3DTerrainPaint(editor3DLevelPart('terrain'), samples) || {colors: [], cells: new Uint8Array(samples)};
|
|
810
|
+
editor3DTerrainPaints.set(map, paint);
|
|
811
|
+
}
|
|
812
|
+
return paint;
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
// the next brush of the Terrain tool
|
|
816
|
+
function editor3DTerrainNextBrush()
|
|
817
|
+
{
|
|
818
|
+
const brushes = editor3DTerrainBrushes;
|
|
819
|
+
editor3DTerrainBrush.mode = brushes[(brushes.indexOf(editor3DTerrainBrush.mode) + 1) % brushes.length];
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
// the drag a press on the ground starts, by the brush and the keys held: sculpt raises, Shift lowers; flatten
|
|
823
|
+
// brings the ground to the height pressed; paint colors it, Shift takes the paint off; Ctrl smooths with the two
|
|
824
|
+
// that shape the ground
|
|
825
|
+
function editor3DTerrainPress(point, shift, ctrl)
|
|
826
|
+
{
|
|
827
|
+
const map = editor3DTerrainMap(), brush = editor3DTerrainBrush.mode;
|
|
828
|
+
if (!map) return;
|
|
829
|
+
if (brush === 'paint')
|
|
830
|
+
return {kind: 'terrain', mode: shift ? 'erase' : 'paint'};
|
|
831
|
+
if (ctrl)
|
|
832
|
+
return {kind: 'terrain', mode: 'smooth'};
|
|
833
|
+
if (brush === 'flatten')
|
|
834
|
+
return {kind: 'terrain', mode: 'flatten', level: clamp((point.y - map.pos3D.y) / map.height)};
|
|
835
|
+
return {kind: 'terrain', mode: shift ? 'lower' : 'raise'};
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
// the mouse held on the ground with a terrain drag
|
|
839
|
+
function editor3DTerrainDragTo(drag, point)
|
|
840
|
+
{
|
|
841
|
+
return drag.mode === 'paint' || drag.mode === 'erase' ? editor3DTerrainPaintAt(point, drag.mode === 'erase') :
|
|
842
|
+
editor3DTerrainSculpt(point, drag.mode, editor3DSeconds, drag.level);
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
// paint the ground around a point with the brush's color, or take its paint off, as part of the edit being made:
|
|
846
|
+
// every sample under the brush, in the game's map now and in the level when the stroke ends; false when nothing
|
|
847
|
+
// changed, or when the terrain has all the colors it can keep, 255
|
|
848
|
+
function editor3DTerrainPaintAt(point, erase=false)
|
|
849
|
+
{
|
|
850
|
+
const map = editor3DTerrainMap();
|
|
851
|
+
if (!map || editor3DRecords.get(editor3DLevel)?.pending) return false; // its autosave waits first
|
|
852
|
+
const paint = editor3DTerrainPaint(map), hex = editor3DInputColor(editor3DTerrainBrush.color);
|
|
853
|
+
let color = erase ? 0 : paint.colors.indexOf(hex) + 1;
|
|
854
|
+
if (!erase && !color && paint.colors.length >= 255) return false;
|
|
855
|
+
const radius = editor3DTerrainBrush.size / 2, rows = map.rows, columns = map.columns;
|
|
856
|
+
const stepX = map.mapSize.x / (columns - 1), stepZ = map.mapSize.y / (rows - 1);
|
|
857
|
+
const x0 = map.pos3D.x - map.mapSize.x / 2, z0 = map.pos3D.z - map.mapSize.y / 2;
|
|
858
|
+
let changed = false;
|
|
859
|
+
for (let r = 0; r < rows; ++r)
|
|
860
|
+
for (let c = 0; c < columns; ++c)
|
|
861
|
+
{
|
|
862
|
+
if (hypot(x0 + c * stepX - point.x, z0 + r * stepZ - point.z) >= radius) continue;
|
|
863
|
+
color ||= erase ? 0 : paint.colors.push(hex); // a new color joins the list with the first sample it paints
|
|
864
|
+
if (paint.cells[r * columns + c] === color) continue;
|
|
865
|
+
paint.cells[r * columns + c] = color;
|
|
866
|
+
changed = true;
|
|
867
|
+
}
|
|
868
|
+
if (!changed) return false;
|
|
869
|
+
editor3DStrokeBegin().sculpted = true;
|
|
870
|
+
level3DTerrainSetColors(map, editor3DLevelPart('terrain'), paint);
|
|
871
|
+
map.rebuild();
|
|
872
|
+
return true;
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
// the game's map of the level's terrain, undefined when the level has none
|
|
876
|
+
function editor3DTerrainMap()
|
|
877
|
+
{
|
|
878
|
+
const map = level3DTerrainMap;
|
|
879
|
+
return map && !map.destroyed && editor3DLevelPart('terrain') ? map : undefined;
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
// bring the game's map in line with the level's terrain block: made, made again when its place or shape changed,
|
|
883
|
+
// its heights and color set, or destroyed with the block gone
|
|
884
|
+
function editor3DTerrainShow()
|
|
885
|
+
{
|
|
886
|
+
const terrain = editor3DLevelPart('terrain'), shape = level3DTerrainShape(terrain);
|
|
887
|
+
let map = level3DTerrainMap && !level3DTerrainMap.destroyed ? level3DTerrainMap : undefined;
|
|
888
|
+
const fits = map && shape && map.pos3D.distance(shape.pos) === 0 && map.mapSize.distance(shape.size) === 0 &&
|
|
889
|
+
map.height === shape.height && map.rows === shape.heights.length && map.columns === shape.heights[0].length;
|
|
890
|
+
if (map && !fits)
|
|
891
|
+
{
|
|
892
|
+
map.destroy();
|
|
893
|
+
map = level3DTerrainMap = undefined;
|
|
894
|
+
}
|
|
895
|
+
if (!shape) return;
|
|
896
|
+
if (!map)
|
|
897
|
+
{
|
|
898
|
+
level3DTerrainMap = level3DTerrainMake(terrain);
|
|
899
|
+
return;
|
|
900
|
+
}
|
|
901
|
+
map.heights = shape.heights;
|
|
902
|
+
editor3DTerrainPaints.delete(map); // as the level has it
|
|
903
|
+
level3DTerrainSetColors(map, terrain);
|
|
904
|
+
map.rebuild();
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
// write the game's map into the level, as a stroke of sculpting ends, its heights to a thousandth, and its paint:
|
|
908
|
+
// the colors still in use and runs of a count and a color along the rows, or no paint when none is left
|
|
909
|
+
function editor3DTerrainStore()
|
|
910
|
+
{
|
|
911
|
+
const map = editor3DTerrainMap(), terrain = editor3DLevelPart('terrain');
|
|
912
|
+
if (!map || !terrain) return;
|
|
913
|
+
terrain.heights = map.heights.map((row)=> row.map((v)=> round(v * 1e3) / 1e3));
|
|
914
|
+
const paint = editor3DTerrainPaints.get(map);
|
|
915
|
+
if (!paint) return; // nothing painted since the level's was read
|
|
916
|
+
const used = paint.colors.map((hex, i)=> paint.cells.includes(i + 1));
|
|
917
|
+
const colors = paint.colors.filter((hex, i)=> used[i]), cells = [];
|
|
918
|
+
const index = paint.colors.map((hex, i)=> used[i] ? colors.indexOf(hex) + 1 : 0);
|
|
919
|
+
for (const v of paint.cells)
|
|
920
|
+
{
|
|
921
|
+
const color = v && index[v - 1];
|
|
922
|
+
cells[cells.length - 1] === color ? ++cells[cells.length - 2] : cells.push(1, color);
|
|
923
|
+
}
|
|
924
|
+
colors.length ? terrain.paint = {colors, cells} : delete terrain.paint;
|
|
925
|
+
editor3DTerrainPaints.delete(map); // read from the level again, with its colors as they are numbered now
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
// give the level a flat terrain, as one undo: its size in the world, how many cells a side, and how tall a full
|
|
929
|
+
// 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
|
|
930
|
+
function editor3DTerrainAdd(size=vec2(64), cells=64, height=16)
|
|
931
|
+
{
|
|
932
|
+
const samples = clamp(floor(cells), 1, LEVEL3D_TERRAIN_SAMPLES - 1) + 1;
|
|
933
|
+
const changed = editor3DChangePart('terrain', ()=> ({pos: [0, editor3DRound(-height * .2), 0],
|
|
934
|
+
size: [size.x, size.y], height, color: '#6a9955',
|
|
935
|
+
heights: Array.from({length: samples}, ()=> Array(samples).fill(.2))}));
|
|
936
|
+
editor3DStrokeEnd();
|
|
937
|
+
return changed;
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
// take the level's terrain away, as one undo
|
|
941
|
+
function editor3DTerrainRemove()
|
|
942
|
+
{
|
|
943
|
+
const changed = editor3DChangePart('terrain', ()=> undefined);
|
|
944
|
+
editor3DStrokeEnd();
|
|
945
|
+
return changed;
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
// sculpt the ground around a point for some seconds, as part of the edit being made: raise, lower or smooth it
|
|
949
|
+
// under the brush, a soft circle, most in its middle and nothing at its edge, or flatten it toward a height, level,
|
|
950
|
+
// 0 to 1; in the game's map now, in the level when the stroke ends; false when nothing changed
|
|
951
|
+
function editor3DTerrainSculpt(point, mode, seconds, level=0)
|
|
952
|
+
{
|
|
953
|
+
const map = editor3DTerrainMap();
|
|
954
|
+
if (!map || editor3DRecords.get(editor3DLevel)?.pending) return false; // its autosave waits first
|
|
955
|
+
const {size, strength} = editor3DTerrainBrush, radius = size / 2, heights = map.heights;
|
|
956
|
+
const rows = map.rows, columns = map.columns;
|
|
957
|
+
const stepX = map.mapSize.x / (columns - 1), stepZ = map.mapSize.y / (rows - 1);
|
|
958
|
+
const x0 = map.pos3D.x - map.mapSize.x / 2, z0 = map.pos3D.z - map.mapSize.y / 2;
|
|
959
|
+
const c0 = max(0, ceil((point.x - radius - x0) / stepX)), c1 = min(columns - 1, floor((point.x + radius - x0) / stepX));
|
|
960
|
+
const r0 = max(0, ceil((point.z - radius - z0) / stepZ)), r1 = min(rows - 1, floor((point.z + radius - z0) / stepZ));
|
|
961
|
+
// smoothing reads the ground as it was, so the order the samples are done in does not show
|
|
962
|
+
const old = mode === 'smooth' ? heights.map((row, r)=> r >= r0 - 1 && r <= r1 + 1 ? row.slice() : row) : heights;
|
|
963
|
+
const rate = strength * 10 * seconds; // world units of height at full strength, 10 a second
|
|
964
|
+
let changed = false;
|
|
965
|
+
for (let r = r0; r <= r1; ++r)
|
|
966
|
+
for (let c = c0; c <= c1; ++c)
|
|
967
|
+
{
|
|
968
|
+
const distance = hypot(x0 + c * stepX - point.x, z0 + r * stepZ - point.z);
|
|
969
|
+
if (distance >= radius) continue;
|
|
970
|
+
const falloff = .5 + .5 * cos(PI * distance / radius), h = heights[r][c];
|
|
971
|
+
let v;
|
|
972
|
+
if (mode === 'smooth')
|
|
973
|
+
{
|
|
974
|
+
// toward the average of the samples beside it
|
|
975
|
+
let sum = 0, count = 0;
|
|
976
|
+
for (const [dr, dc] of [[-1, 0], [1, 0], [0, -1], [0, 1]])
|
|
977
|
+
old[r + dr]?.[c + dc] !== undefined && (sum += old[r + dr][c + dc], ++count);
|
|
978
|
+
v = h + (sum / count - h) * min(1, rate * falloff);
|
|
979
|
+
}
|
|
980
|
+
else if (mode === 'flatten')
|
|
981
|
+
v = h + (level - h) * min(1, rate * falloff);
|
|
982
|
+
else
|
|
983
|
+
v = clamp(h + (mode === 'lower' ? -1 : 1) * rate * falloff / map.height);
|
|
984
|
+
if (v === h) continue;
|
|
985
|
+
heights[r][c] = v;
|
|
986
|
+
changed = true;
|
|
987
|
+
}
|
|
988
|
+
if (!changed) return false;
|
|
989
|
+
editor3DStrokeBegin().sculpted = true;
|
|
990
|
+
map.rebuild();
|
|
455
991
|
return true;
|
|
456
992
|
}
|
|
457
993
|
|
|
994
|
+
// what the Blocks tool does to a cell: place puts the current type in an empty one, paint gives a block the
|
|
995
|
+
// current type, remove empties it
|
|
996
|
+
function editor3DVoxelApply(cell, mode)
|
|
997
|
+
{
|
|
998
|
+
const map = editor3DVoxelMap(), has = map ? map.getVoxel(cell) : 0;
|
|
999
|
+
return mode === 'place' ? !has && editor3DVoxelSet(cell, editor3DBlockType) :
|
|
1000
|
+
mode === 'paint' ? !!has && editor3DVoxelSet(cell, editor3DBlockType) :
|
|
1001
|
+
editor3DVoxelSet(cell, 0);
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
// 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
|
|
1005
|
+
// same layer; undefined with no cell there
|
|
1006
|
+
function editor3DVoxelPress(ray, mode)
|
|
1007
|
+
{
|
|
1008
|
+
const target = editor3DVoxelTarget(ray, mode);
|
|
1009
|
+
if (!target) return;
|
|
1010
|
+
const {cell, axis, plane, side} = target;
|
|
1011
|
+
if (editor3DBlockBox)
|
|
1012
|
+
{
|
|
1013
|
+
// a box grows out from the face when placing, and into the blocks when removing or repainting
|
|
1014
|
+
const drag = {kind: 'blocks', mode, axis, plane, layer: cell[axis], last: cell, start: cell,
|
|
1015
|
+
grow: mode === 'place' ? side : -side, height: max(1, floor(editor3DBlockBoxHeight)),
|
|
1016
|
+
changed: new Map, region: undefined};
|
|
1017
|
+
editor3DVoxelBoxTo(drag, cell);
|
|
1018
|
+
return drag;
|
|
1019
|
+
}
|
|
1020
|
+
editor3DVoxelApply(cell, mode);
|
|
1021
|
+
return {kind: 'blocks', mode, axis, plane, layer: cell[axis], last: cell};
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
// a box drag goes on: what it changed so far is put back, then every cell of the box from where it started to a
|
|
1025
|
+
// cell of the same layer is changed, as many layers as its height, the part of it inside the map
|
|
1026
|
+
function editor3DVoxelBoxTo(drag, cell)
|
|
1027
|
+
{
|
|
1028
|
+
const map = editor3DVoxelMap();
|
|
1029
|
+
if (!map) return;
|
|
1030
|
+
for (const [at, type] of drag.changed.values())
|
|
1031
|
+
map.setVoxel(at, type);
|
|
1032
|
+
drag.changed.clear();
|
|
1033
|
+
const lo = vec3(min(drag.start.x, cell.x), min(drag.start.y, cell.y), min(drag.start.z, cell.z));
|
|
1034
|
+
const hi = vec3(max(drag.start.x, cell.x), max(drag.start.y, cell.y), max(drag.start.z, cell.z));
|
|
1035
|
+
const far = drag.layer + drag.grow * (drag.height - 1), axis = drag.axis, s = map.mapSize;
|
|
1036
|
+
lo[axis] = clamp(min(drag.layer, far), 0, s[axis] - 1);
|
|
1037
|
+
hi[axis] = clamp(max(drag.layer, far), 0, s[axis] - 1);
|
|
1038
|
+
for (let z = lo.z; z <= hi.z; ++z)
|
|
1039
|
+
for (let y = lo.y; y <= hi.y; ++y)
|
|
1040
|
+
for (let x = lo.x; x <= hi.x; ++x)
|
|
1041
|
+
{
|
|
1042
|
+
const at = vec3(x, y, z), type = map.getVoxel(at);
|
|
1043
|
+
editor3DVoxelApply(at, drag.mode) && drag.changed.set(x + s.x * (y + s.y * z), [at, type]);
|
|
1044
|
+
}
|
|
1045
|
+
drag.region = {lo, hi};
|
|
1046
|
+
drag.last = cell;
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
// change the size of the level's map in cells, as one undo: it grows and shrinks about its middle across, and at
|
|
1050
|
+
// its top, and its blocks stay where they are in the world, the ones outside the new size gone
|
|
1051
|
+
function editor3DVoxelResize(size)
|
|
1052
|
+
{
|
|
1053
|
+
const changed = editor3DChangePart('voxels', (voxels)=>
|
|
1054
|
+
{
|
|
1055
|
+
const shape = level3DVoxelsShape(voxels);
|
|
1056
|
+
const to = vec3(floor(size.x), floor(size.y), floor(size.z));
|
|
1057
|
+
if (!shape || to.x < 1 || to.y < 1 || to.z < 1 || to.x * to.y * to.z > LEVEL3D_VOXEL_CELLS) return voxels;
|
|
1058
|
+
const from = shape.size, old = new Uint8Array(from.x * from.y * from.z);
|
|
1059
|
+
level3DVoxelsDecode(voxels.blocks, old);
|
|
1060
|
+
// how far each block moves in cells, so the corner moves the other way and the blocks stay put
|
|
1061
|
+
const dx = floor((to.x - from.x) / 2), dz = floor((to.z - from.z) / 2);
|
|
1062
|
+
const data = new Uint8Array(to.x * to.y * to.z);
|
|
1063
|
+
for (let z = 0; z < from.z; ++z)
|
|
1064
|
+
for (let y = 0; y < min(from.y, to.y); ++y)
|
|
1065
|
+
for (let x = 0; x < from.x; ++x)
|
|
1066
|
+
{
|
|
1067
|
+
const nx = x + dx, nz = z + dz;
|
|
1068
|
+
if (nx >= 0 && nz >= 0 && nx < to.x && nz < to.z)
|
|
1069
|
+
data[nx + to.x * (y + to.y * nz)] = old[x + from.x * (y + from.y * z)];
|
|
1070
|
+
}
|
|
1071
|
+
return {...voxels, pos: [shape.pos.x - dx, shape.pos.y, shape.pos.z - dz], size: [to.x, to.y, to.z],
|
|
1072
|
+
blocks: level3DVoxelsEncode(data)};
|
|
1073
|
+
});
|
|
1074
|
+
editor3DStrokeEnd();
|
|
1075
|
+
return changed;
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
// a drag of the Blocks tool goes on: every cell of the layer from the last one to the one under the ray
|
|
1079
|
+
function editor3DVoxelDragTo(drag, ray)
|
|
1080
|
+
{
|
|
1081
|
+
const cell = editor3DVoxelDragCell(drag, ray), last = drag.last;
|
|
1082
|
+
if (!cell) return;
|
|
1083
|
+
if (drag.changed)
|
|
1084
|
+
return editor3DVoxelBoxTo(drag, cell);
|
|
1085
|
+
const d = cell.subtract(last), steps = max(abs(d.x), abs(d.y), abs(d.z));
|
|
1086
|
+
for (let i = 1; i <= steps; ++i)
|
|
1087
|
+
editor3DVoxelApply(vec3(round(last.x + d.x * i / steps), round(last.y + d.y * i / steps),
|
|
1088
|
+
round(last.z + d.z * i / steps)), drag.mode);
|
|
1089
|
+
drag.last = cell;
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
// make the type of the block under a ray the one to place; false with no block there
|
|
1093
|
+
function editor3DVoxelPick(ray)
|
|
1094
|
+
{
|
|
1095
|
+
const hit = editor3DVoxelMap()?.raycast(ray);
|
|
1096
|
+
if (!hit) return false;
|
|
1097
|
+
editor3DBlockType = hit.type;
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
// the cell a click is for: to place, the empty cell against the face the ray hits, or the one standing on the
|
|
1101
|
+
// map's floor where it hits no block; otherwise the block it hits; with the axis the face is across and the plane
|
|
1102
|
+
// of the face, for a drag to stay on; undefined with no such cell
|
|
1103
|
+
function editor3DVoxelTarget(ray, mode)
|
|
1104
|
+
{
|
|
1105
|
+
const map = editor3DVoxelMap();
|
|
1106
|
+
if (!map) return;
|
|
1107
|
+
const place = mode === 'place', hit = map.raycast(ray);
|
|
1108
|
+
if (!hit)
|
|
1109
|
+
{
|
|
1110
|
+
const cell = place ? editor3DVoxelDragCell({axis: 'y', plane: 0, layer: 0}, ray) : undefined;
|
|
1111
|
+
return cell && {cell, axis: 'y', plane: 0, side: 1};
|
|
1112
|
+
}
|
|
1113
|
+
const n = hit.normal, axis = n.x ? 'x' : n.y ? 'y' : 'z', cell = place ? hit.cell.add(n) : hit.cell;
|
|
1114
|
+
if (!editor3DVoxelInside(map, cell)) return;
|
|
1115
|
+
// the face between the block hit and the cell in front of it, and side, the way that face looks along the axis
|
|
1116
|
+
return {cell, axis, plane: cell[axis] + ((n[axis] > 0) === place ? 0 : 1), side: n[axis]};
|
|
1117
|
+
}
|
|
1118
|
+
|
|
458
1119
|
// end the edit being made: one undo, and the autosave
|
|
459
1120
|
function editor3DStrokeEnd()
|
|
460
1121
|
{
|
|
461
1122
|
const stroke = editor3DStroke;
|
|
462
1123
|
editor3DStroke = undefined;
|
|
463
|
-
if (!stroke
|
|
464
|
-
|
|
1124
|
+
if (!stroke) return;
|
|
1125
|
+
stroke.painted && editor3DVoxelStore(); // the blocks painted go into the level
|
|
1126
|
+
stroke.sculpted && editor3DTerrainStore(); // and the ground sculpted
|
|
1127
|
+
const parts = editor3DLevelParts();
|
|
1128
|
+
if (editor3DSame(stroke.before, editor3DObjects()) && editor3DSame(stroke.parts, parts)) return;
|
|
1129
|
+
editor3DUndoList.push({before: stroke.before, after: editor3DCopy(editor3DObjects()),
|
|
1130
|
+
partsBefore: stroke.parts, partsAfter: parts});
|
|
465
1131
|
editor3DUndoList.length > 100 && editor3DUndoList.shift();
|
|
466
1132
|
editor3DRedoList.length = 0;
|
|
467
1133
|
editor3DAutosave();
|
|
@@ -472,7 +1138,11 @@ function editor3DStrokeCancel()
|
|
|
472
1138
|
{
|
|
473
1139
|
const stroke = editor3DStroke;
|
|
474
1140
|
editor3DStroke = undefined;
|
|
475
|
-
|
|
1141
|
+
if (!stroke) return;
|
|
1142
|
+
editor3DSetObjects(stroke.before);
|
|
1143
|
+
stroke.painted && editor3DVoxelShow(); // the level never had them, the game's map goes back to it
|
|
1144
|
+
stroke.sculpted && editor3DTerrainShow();
|
|
1145
|
+
editor3DSetParts(stroke.parts);
|
|
476
1146
|
}
|
|
477
1147
|
|
|
478
1148
|
// undo the last edit, or redo the last one undone; false with none
|
|
@@ -483,6 +1153,7 @@ function editor3DUndo(redo=false)
|
|
|
483
1153
|
if (!entry) return false;
|
|
484
1154
|
(redo ? editor3DUndoList : editor3DRedoList).push(entry);
|
|
485
1155
|
editor3DSetObjects(redo ? entry.after : entry.before);
|
|
1156
|
+
editor3DSetParts(redo ? entry.partsAfter : entry.partsBefore);
|
|
486
1157
|
editor3DAutosave();
|
|
487
1158
|
return true;
|
|
488
1159
|
}
|
|
@@ -516,6 +1187,7 @@ function editor3DSetProperty(object, name, value, defaultValue)
|
|
|
516
1187
|
// add an object of a type at a position, selected; its id, or undefined when the level can not be edited
|
|
517
1188
|
function editor3DPlace(type, pos)
|
|
518
1189
|
{
|
|
1190
|
+
if (editor3DPrefabSelf(type)) return; // it would hold itself
|
|
519
1191
|
let id;
|
|
520
1192
|
const placed = editor3DChange((list)=>
|
|
521
1193
|
{
|
|
@@ -529,6 +1201,302 @@ function editor3DPlace(type, pos)
|
|
|
529
1201
|
return id;
|
|
530
1202
|
}
|
|
531
1203
|
|
|
1204
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
1205
|
+
// prefabs: a selection made one, an instance made objects again, and a prefab's own settings
|
|
1206
|
+
|
|
1207
|
+
// what the panel says about the last prefab action that was refused
|
|
1208
|
+
let editor3DPrefabMessage = '';
|
|
1209
|
+
|
|
1210
|
+
// the box around an object in the world, turned and sized as it is, as its low and high corners
|
|
1211
|
+
function editor3DWorldBox(object, low=vec3(Infinity), high=vec3(-Infinity))
|
|
1212
|
+
{
|
|
1213
|
+
const matrix = editor3DBoxMatrix(object);
|
|
1214
|
+
for (let i = 8; i--;)
|
|
1215
|
+
{
|
|
1216
|
+
const p = matrix.transformPoint(vec3(i & 1 ? .5 : -.5, i & 2 ? .5 : -.5, i & 4 ? .5 : -.5));
|
|
1217
|
+
for (const k of ['x', 'y', 'z'])
|
|
1218
|
+
low[k] = min(low[k], p[k]), high[k] = max(high[k], p[k]);
|
|
1219
|
+
}
|
|
1220
|
+
return {low, high};
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
// is a type the prefab being edited, or one that holds it: a prefab that is open, or one the game made of the
|
|
1224
|
+
// level being edited itself, as a prefab maker does; placed there it would hold itself
|
|
1225
|
+
function editor3DPrefabSelf(type)
|
|
1226
|
+
{
|
|
1227
|
+
const names = editor3DPrefabStack.map((frame)=> frame.name);
|
|
1228
|
+
for (const [name, prefab] of level3DPrefabs)
|
|
1229
|
+
prefab.source === editor3DLevel && names.push(name);
|
|
1230
|
+
return names.some((name)=> editor3DPrefabHolds(type, name));
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
// the types the Place list has, the plain ones and then the prefabs
|
|
1234
|
+
function editor3DPlaceNames()
|
|
1235
|
+
{
|
|
1236
|
+
const all = [...level3DTypes.keys()].filter((name)=> !editor3DPrefabSelf(name));
|
|
1237
|
+
return [...all.filter((name)=> !level3DPrefabs.has(name)), ...all.filter((name)=> level3DPrefabs.has(name))];
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
// does a type hold a prefab, itself or through the prefabs it holds
|
|
1241
|
+
function editor3DPrefabHolds(type, name, depth=0)
|
|
1242
|
+
{
|
|
1243
|
+
return type === name || depth < 8 &&
|
|
1244
|
+
!!level3DPrefabs.get(type)?.objects.some((o)=> editor3DPrefabHolds(o.type, name, depth + 1));
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
// make the selected objects a prefab of the level's own, about the bottom centre of their box, and put one
|
|
1248
|
+
// instance of it where they were, as one undo; a name the level's prefabs have replaces that prefab, and every
|
|
1249
|
+
// instance of it changes; true, or why not, which the panel shows
|
|
1250
|
+
function editor3DMakePrefab(name='')
|
|
1251
|
+
{
|
|
1252
|
+
const refuse = (why)=> editor3DPrefabMessage = why;
|
|
1253
|
+
const selected = editor3DSelected();
|
|
1254
|
+
if (editor3DPrefabStack.length) return refuse('Go back to the level to make a prefab');
|
|
1255
|
+
if (!selected.length) return refuse('Select the objects to make a prefab of');
|
|
1256
|
+
if (!editor3DLevel || editor3DRecords.get(editor3DLevel)?.pending) return refuse('The level can not be edited now');
|
|
1257
|
+
name = String(name).trim();
|
|
1258
|
+
for (let i = 1; !name; ++i)
|
|
1259
|
+
level3DTypes.has('Prefab ' + i) || (name = 'Prefab ' + i);
|
|
1260
|
+
const known = level3DPrefabs.get(name);
|
|
1261
|
+
if (known ? !known.fromLevel : level3DTypes.has(name))
|
|
1262
|
+
return refuse(name + ' is a type the game added, pick another name');
|
|
1263
|
+
if (selected.some((o)=> editor3DPrefabHolds(o.type, name)))
|
|
1264
|
+
return refuse(name + ' can not hold itself');
|
|
1265
|
+
|
|
1266
|
+
// its origin is the bottom centre of the selection, so an instance stands on the ground
|
|
1267
|
+
const low = vec3(Infinity), high = vec3(-Infinity);
|
|
1268
|
+
for (const object of selected)
|
|
1269
|
+
editor3DWorldBox(object, low, high);
|
|
1270
|
+
const origin = vec3(editor3DRound((low.x + high.x) / 2), editor3DRound(low.y), editor3DRound((low.z + high.z) / 2));
|
|
1271
|
+
const objects = selected.map((object, i)=>
|
|
1272
|
+
{
|
|
1273
|
+
const part = {...editor3DCopy(object), id: i + 1};
|
|
1274
|
+
editor3DSetTransform(part, editor3DPos(object).subtract(origin));
|
|
1275
|
+
return part;
|
|
1276
|
+
});
|
|
1277
|
+
editor3DStrokeEnd();
|
|
1278
|
+
const id = editor3DObjects().reduce((next, o)=> max(next, o.id + 1), 1);
|
|
1279
|
+
const ids = new Set(selected.map((o)=> o.id));
|
|
1280
|
+
// the prefab first, so its type is there when the instance is made
|
|
1281
|
+
editor3DChangePart('prefabs', (prefabs={})=>
|
|
1282
|
+
({...prefabs, [name]: prefabs[name]?.attached ? {attached: true, objects} : {objects}}));
|
|
1283
|
+
editor3DChange((list)=>
|
|
1284
|
+
{
|
|
1285
|
+
for (let i = list.length; i--;)
|
|
1286
|
+
ids.has(list[i].id) && list.splice(i, 1);
|
|
1287
|
+
const instance = {id, type: name};
|
|
1288
|
+
editor3DSetTransform(instance, origin);
|
|
1289
|
+
list.push(instance);
|
|
1290
|
+
});
|
|
1291
|
+
editor3DStrokeEnd();
|
|
1292
|
+
editor3DSelection.clear();
|
|
1293
|
+
editor3DSelection.add(id);
|
|
1294
|
+
editor3DPrefabMessage = '';
|
|
1295
|
+
return true;
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
// add a prefab from the text of a file the editor saved, a level file, to the level's own prefabs under a name,
|
|
1299
|
+
// as one undo, and pick it to place; a name the level's prefabs have is replaced, as Make prefab does; true, or
|
|
1300
|
+
// why not, which the panel shows
|
|
1301
|
+
function editor3DPrefabLoad(name, text)
|
|
1302
|
+
{
|
|
1303
|
+
const refuse = (why)=> editor3DPrefabMessage = why;
|
|
1304
|
+
if (editor3DPrefabStack.length) return refuse('Go back to the level to load a prefab');
|
|
1305
|
+
if (!editor3DLevel || editor3DRecords.get(editor3DLevel)?.pending) return refuse('The level can not be edited now');
|
|
1306
|
+
name = String(name).trim();
|
|
1307
|
+
if (!name) return refuse('The prefab needs a name');
|
|
1308
|
+
let file;
|
|
1309
|
+
try { file = JSON.parse(text); }
|
|
1310
|
+
catch { return refuse(name + ' is not a JSON file'); }
|
|
1311
|
+
if (!file || typeof file !== 'object' || !isArray(file.objects))
|
|
1312
|
+
return refuse(name + ' is not a prefab, it has no objects');
|
|
1313
|
+
const known = level3DPrefabs.get(name);
|
|
1314
|
+
if (known ? !known.fromLevel : level3DTypes.has(name))
|
|
1315
|
+
return refuse(name + ' is a type the game added, rename the file');
|
|
1316
|
+
const objects = file.objects.filter((o)=> o && typeof o === 'object');
|
|
1317
|
+
if (objects.some((o)=> editor3DPrefabHolds(o.type, name)))
|
|
1318
|
+
return refuse(name + ' can not hold itself');
|
|
1319
|
+
editor3DStrokeEnd();
|
|
1320
|
+
editor3DChangePart('prefabs', (prefabs={})=> ({...prefabs, [name]: file.attached ? {attached: true, objects} : {objects}}));
|
|
1321
|
+
editor3DStrokeEnd();
|
|
1322
|
+
editor3DPickTool(editor3DTool); // a tool of the game's own is put down
|
|
1323
|
+
editor3DBrush = name;
|
|
1324
|
+
editor3DPrefabMessage = '';
|
|
1325
|
+
return true;
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
// pick a prefab's file and load it, by its file name
|
|
1329
|
+
function editor3DPrefabPickFile()
|
|
1330
|
+
{
|
|
1331
|
+
const input = document.createElement('input');
|
|
1332
|
+
input.type = 'file';
|
|
1333
|
+
input.accept = '.json,application/json';
|
|
1334
|
+
input.onchange = async ()=>
|
|
1335
|
+
{
|
|
1336
|
+
const file = input.files?.[0];
|
|
1337
|
+
file && editor3DPrefabLoad(file.name.replace(/\.json$/i, ''), await file.text());
|
|
1338
|
+
};
|
|
1339
|
+
input.click();
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
// turn the selected instances of prefabs into the objects they are made of, where they are, as one undo; false
|
|
1343
|
+
// with none selected
|
|
1344
|
+
function editor3DUnpack()
|
|
1345
|
+
{
|
|
1346
|
+
const instances = editor3DSelected().filter((o)=> editor3DInstances.get(o.id) instanceof Prefab3D);
|
|
1347
|
+
if (!instances.length) return false;
|
|
1348
|
+
editor3DStrokeEnd();
|
|
1349
|
+
const added = [];
|
|
1350
|
+
const changed = editor3DChange((list)=>
|
|
1351
|
+
{
|
|
1352
|
+
let id = list.reduce((next, o)=> max(next, o.id + 1), 1);
|
|
1353
|
+
for (const instance of instances)
|
|
1354
|
+
{
|
|
1355
|
+
const made = editor3DInstances.get(instance.id);
|
|
1356
|
+
// by the level's own place of it, its origin, which is not where an attached one's handle is
|
|
1357
|
+
const origin = {pos3D: editor3DPos(instance), rotation3D: editor3DRotation(instance).scale(PI / 180),
|
|
1358
|
+
scale3D: editor3DScale(instance)};
|
|
1359
|
+
for (const part of level3DPrefabs.get(made.prefabName)?.objects ?? [])
|
|
1360
|
+
{
|
|
1361
|
+
const at = level3DPrefabPartTransform(origin, part), object = {...editor3DCopy(part), id: id++};
|
|
1362
|
+
editor3DSetTransform(object, at.pos, at.rotation.scale(180 / PI), at.scale);
|
|
1363
|
+
list.push(object);
|
|
1364
|
+
added.push(object.id);
|
|
1365
|
+
}
|
|
1366
|
+
list.splice(list.findIndex((o)=> o.id === instance.id), 1);
|
|
1367
|
+
}
|
|
1368
|
+
});
|
|
1369
|
+
editor3DStrokeEnd();
|
|
1370
|
+
if (!changed) return false;
|
|
1371
|
+
editor3DSelection.clear();
|
|
1372
|
+
for (const id of added)
|
|
1373
|
+
editor3DSelection.add(id);
|
|
1374
|
+
return true;
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
// make a prefab of the level's own attached, its parts the children of each instance, or not, as one undo
|
|
1378
|
+
function editor3DPrefabSetAttached(name, attached)
|
|
1379
|
+
{
|
|
1380
|
+
if (editor3DPrefabStack.length) return false; // a prefab is the level's, set from the level
|
|
1381
|
+
editor3DStrokeEnd();
|
|
1382
|
+
const changed = editor3DChangePart('prefabs', (prefabs)=>
|
|
1383
|
+
{
|
|
1384
|
+
if (!prefabs?.[name]) return prefabs;
|
|
1385
|
+
const {attached: was, ...rest} = prefabs[name];
|
|
1386
|
+
return {...prefabs, [name]: attached ? {attached: true, ...rest} : rest};
|
|
1387
|
+
});
|
|
1388
|
+
editor3DStrokeEnd();
|
|
1389
|
+
return changed;
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
// a prefab as its own file has it, a level file of its objects, to load with level3DLoadPrefab
|
|
1393
|
+
function editor3DPrefabJSON(name)
|
|
1394
|
+
{
|
|
1395
|
+
const prefab = level3DPrefabs.get(name);
|
|
1396
|
+
if (!prefab) return '';
|
|
1397
|
+
return editor3DLevelJSON({...(prefab.attached ? {attached: true} : {}), objects: prefab.objects});
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
1401
|
+
// Edit prefab: the editor on a prefab alone, the level it came from put away until Back
|
|
1402
|
+
|
|
1403
|
+
// the prefabs opened one inside the other, each with the level it was opened from, its name, the selection there
|
|
1404
|
+
// and the ids of that level's objects that had something made for them
|
|
1405
|
+
/** @type {Array<{level: Object, name: string, selection: Array<number>, made: Set<number>, entered?: string, saved?: string}>} */
|
|
1406
|
+
const editor3DPrefabStack = [];
|
|
1407
|
+
// the levels that are a prefab being edited, they have no autosave of their own
|
|
1408
|
+
const editor3DPrefabLevels = new WeakSet;
|
|
1409
|
+
// what was edited in prefabs of the level's own since the level was left, written into it on the way back
|
|
1410
|
+
/** @type {Object<string, Object>} */
|
|
1411
|
+
let editor3DPrefabEdits = {};
|
|
1412
|
+
// the prefabs of the game's own that were edited and are not in a file yet
|
|
1413
|
+
const editor3DPrefabDirty = new Set;
|
|
1414
|
+
|
|
1415
|
+
// destroy what the level being edited has in the world, its objects' and its maps'
|
|
1416
|
+
function editor3DPrefabClear()
|
|
1417
|
+
{
|
|
1418
|
+
for (const made of editor3DInstances.values())
|
|
1419
|
+
made?.destroy?.();
|
|
1420
|
+
editor3DInstances.clear();
|
|
1421
|
+
for (const map of [level3DVoxelMap, level3DTerrainMap])
|
|
1422
|
+
map && !map.destroyed && map.destroy();
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
// open the prefab of an instance, the selected one when none is given, to edit alone: its objects about its own
|
|
1426
|
+
// origin, with an undo of its own, and the level it is in put away; false when it is not a prefab's instance
|
|
1427
|
+
function editor3DPrefabEnter(id)
|
|
1428
|
+
{
|
|
1429
|
+
const selected = editor3DSelected();
|
|
1430
|
+
id ??= selected.length === 1 ? selected[0].id : undefined;
|
|
1431
|
+
const made = editor3DInstances.get(id), from = editor3DLevel;
|
|
1432
|
+
const prefab = made instanceof Prefab3D && level3DPrefabs.get(made.prefabName);
|
|
1433
|
+
if (!prefab || editor3DRecords.get(from)?.pending) return false;
|
|
1434
|
+
editor3DStrokeEnd();
|
|
1435
|
+
editor3DDrag = editor3DHover = undefined;
|
|
1436
|
+
const frame = {level: from, name: made.prefabName, selection: [...editor3DSelection],
|
|
1437
|
+
made: new Set(editor3DInstances.keys()), entered: undefined};
|
|
1438
|
+
editor3DPrefabStack.push(frame);
|
|
1439
|
+
editor3DPrefabClear();
|
|
1440
|
+
const level = {littlejs3D: LEVEL3D_VERSION, ...(prefab.attached ? {attached: true} : {}),
|
|
1441
|
+
objects: editor3DCopy(prefab.objects)};
|
|
1442
|
+
editor3DPrefabLevels.add(level);
|
|
1443
|
+
level3DLoad(level); // the editor takes it as it does any level
|
|
1444
|
+
frame.entered = JSON.stringify(editor3DObjects()); // as the editor has it, an edit is what differs from this
|
|
1445
|
+
return true;
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
// leave the prefab being edited for the level it was opened from: the prefab is what was edited, every instance
|
|
1449
|
+
// of it is made again, and back in the level itself what was edited in its own prefabs is one undo; false when
|
|
1450
|
+
// no prefab is open
|
|
1451
|
+
function editor3DPrefabBack()
|
|
1452
|
+
{
|
|
1453
|
+
const frame = editor3DPrefabStack.pop(), edited = editor3DLevel;
|
|
1454
|
+
if (!frame) return false;
|
|
1455
|
+
editor3DStrokeEnd();
|
|
1456
|
+
editor3DDrag = editor3DHover = undefined;
|
|
1457
|
+
const known = level3DPrefabs.get(frame.name);
|
|
1458
|
+
const objects = editor3DCopy(editor3DObjects()), changed = JSON.stringify(objects) !== frame.entered;
|
|
1459
|
+
editor3DPrefabClear();
|
|
1460
|
+
if (changed)
|
|
1461
|
+
{
|
|
1462
|
+
// the prefab as it is now, for whatever is made next; one of the game's own waits to be saved to its file
|
|
1463
|
+
const prefab = known?.attached ? {attached: true, objects} : {objects}, fromLevel = !!known?.fromLevel;
|
|
1464
|
+
level3DPrefabSet(frame.name, prefab, fromLevel);
|
|
1465
|
+
if (fromLevel)
|
|
1466
|
+
editor3DPrefabEdits[frame.name] = prefab;
|
|
1467
|
+
else if (JSON.stringify(objects) !== frame.saved)
|
|
1468
|
+
editor3DPrefabDirty.add(frame.name); // not what Save wrote while it was open
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1471
|
+
// the level it was opened from is the editor's again, with its own undo
|
|
1472
|
+
const level = editor3DLevel = frame.level, record = editor3DRecords.get(level);
|
|
1473
|
+
editor3DUndoList = record.undo, editor3DRedoList = record.redo;
|
|
1474
|
+
editor3DStroke = undefined;
|
|
1475
|
+
if (!editor3DPrefabStack.length)
|
|
1476
|
+
{
|
|
1477
|
+
// back in the level: its own prefabs that were edited go into it, as one undo
|
|
1478
|
+
const edits = editor3DPrefabEdits;
|
|
1479
|
+
editor3DPrefabEdits = {};
|
|
1480
|
+
Object.keys(edits).length && editor3DChangePart('prefabs', (prefabs={})=> ({...prefabs, ...edits}));
|
|
1481
|
+
editor3DStrokeEnd();
|
|
1482
|
+
}
|
|
1483
|
+
// what the level had in the world is made again: what a class makes, and what else had something made
|
|
1484
|
+
for (const object of editor3DObjects())
|
|
1485
|
+
{
|
|
1486
|
+
const type = level3DTypes.get(object.type);
|
|
1487
|
+
type && (type.make.prototype || frame.made.has(object.id)) && editor3DMakeInstance(object);
|
|
1488
|
+
}
|
|
1489
|
+
editor3DSceneRestore(record.sceneBase);
|
|
1490
|
+
level3DSceneApply(level.scene);
|
|
1491
|
+
editor3DVoxelShow();
|
|
1492
|
+
editor3DTerrainShow();
|
|
1493
|
+
editor3DShadowLight();
|
|
1494
|
+
editor3DSelection.clear();
|
|
1495
|
+
for (const id of frame.selection)
|
|
1496
|
+
editor3DObject(id) && editor3DSelection.add(id);
|
|
1497
|
+
return true;
|
|
1498
|
+
}
|
|
1499
|
+
|
|
532
1500
|
// remove the selected objects, as one undo
|
|
533
1501
|
function editor3DDelete()
|
|
534
1502
|
{
|
|
@@ -548,6 +1516,9 @@ function editor3DCopySelection()
|
|
|
548
1516
|
const selected = editor3DSelected();
|
|
549
1517
|
if (!selected.length) return false;
|
|
550
1518
|
editor3DClipboard = editor3DCopy(selected);
|
|
1519
|
+
// how big each was and where its box was about its place: what is cut has nothing left to measure
|
|
1520
|
+
editor3DClipboardBoxes = new Map(editor3DClipboard.map((copy, i)=>
|
|
1521
|
+
[copy, {size: editor3DSize(selected[i]), offset: editor3DBoxOffset(selected[i])}]));
|
|
551
1522
|
}
|
|
552
1523
|
|
|
553
1524
|
// copy the selected objects and remove them
|
|
@@ -599,11 +1570,17 @@ function editor3DRestore()
|
|
|
599
1570
|
{
|
|
600
1571
|
const type = level3DTypes.get(object.type), made = editor3DInstances.get(object.id);
|
|
601
1572
|
if (!type) continue;
|
|
602
|
-
|
|
1573
|
+
// a prefab's instance that play took a part of is made again, whole
|
|
1574
|
+
const broken = (p)=> p instanceof Prefab3D && p.parts.some((part)=> part?.destroyed || broken(part));
|
|
1575
|
+
if (made instanceof EngineObject3D && !made.destroyed && !broken(made))
|
|
603
1576
|
editor3DPlaceInstance(made, object);
|
|
604
|
-
else if (type.make.prototype && (!made || made.destroyed))
|
|
1577
|
+
else if (type.make.prototype && (!made || made.destroyed || broken(made)))
|
|
605
1578
|
editor3DMakeInstance(object);
|
|
606
1579
|
}
|
|
1580
|
+
// the blocks and the terrain as the level has them: what play dug, raised or destroyed is not the level's,
|
|
1581
|
+
// and must not be written into it by the next stroke
|
|
1582
|
+
editor3DVoxelShow();
|
|
1583
|
+
editor3DTerrainShow();
|
|
607
1584
|
}
|
|
608
1585
|
|
|
609
1586
|
// open or close the editor, the session goes on: the game is paused under it and reads no input
|
|
@@ -621,14 +1598,19 @@ function editor3DSetOpen(open)
|
|
|
621
1598
|
setDebugOverlay(false); // out of the way of the level
|
|
622
1599
|
editor3DIsOpen = true;
|
|
623
1600
|
editor3DRestore();
|
|
1601
|
+
editorCall('onOpen');
|
|
1602
|
+
editorKeyClashes();
|
|
624
1603
|
}
|
|
625
1604
|
else
|
|
626
1605
|
{
|
|
627
1606
|
editor3DDrag = editor3DHover = undefined;
|
|
628
1607
|
editor3DMouseOnPanel = false; // the panel hides, with no mouseleave
|
|
629
1608
|
editor3DStrokeEnd();
|
|
1609
|
+
while (editor3DPrefabBack()); // the game plays the level, not a prefab that was open
|
|
1610
|
+
editor3DToolHeld = false;
|
|
630
1611
|
editor3DIsOpen = false;
|
|
631
1612
|
setPaused(editor3DGamePaused);
|
|
1613
|
+
editorCall('onClose');
|
|
632
1614
|
}
|
|
633
1615
|
inputCapture(editor3DViewOn());
|
|
634
1616
|
}
|
|
@@ -652,24 +1634,116 @@ function editor3DClose()
|
|
|
652
1634
|
function editor3DPlay(pos)
|
|
653
1635
|
{
|
|
654
1636
|
editor3DSetOpen(false);
|
|
655
|
-
editor3DPlayFromMouse && pos && levelEditor.onPlayFrom
|
|
1637
|
+
editor3DPlayFromMouse && pos && editorHas('onPlayFrom') && levelEditor.onPlayFrom(pos.copy());
|
|
656
1638
|
}
|
|
657
1639
|
|
|
658
1640
|
// rebuild the level through the game's hook and play it
|
|
659
1641
|
function editor3DRestart()
|
|
660
1642
|
{
|
|
661
|
-
if (!
|
|
1643
|
+
if (!editorHas('onRestart')) return;
|
|
662
1644
|
editor3DSetOpen(false);
|
|
663
1645
|
levelEditor.onRestart();
|
|
664
1646
|
}
|
|
665
1647
|
|
|
666
|
-
// the
|
|
1648
|
+
// a key as addKey spells it, as the 3D editor's tables have it, by its position: a letter, a digit, a mark by the
|
|
1649
|
+
// key it is on, or its name
|
|
1650
|
+
const editor3DKeyMarks = {'?': 'Slash', '/': 'Slash', ',': 'Comma', '.': 'Period', ';': 'Semicolon', "'": 'Quote',
|
|
1651
|
+
'[': 'BracketLeft', ']': 'BracketRight', '-': 'Minus', '=': 'Equal', '\\': 'Backslash', '`': 'Backquote'};
|
|
1652
|
+
function editor3DKeyCode(name)
|
|
1653
|
+
{
|
|
1654
|
+
return name.length !== 1 ? name : editor3DKeyMarks[name] ??
|
|
1655
|
+
(name >= '0' && name <= '9' ? 'Digit' + name : 'Key' + name.toUpperCase());
|
|
1656
|
+
}
|
|
1657
|
+
|
|
1658
|
+
// the 3D editor's edit functions, what levelEditor.edit3D gives a game's own keys, buttons and tools
|
|
1659
|
+
const editor3DEdit =
|
|
1660
|
+
{
|
|
1661
|
+
get level() { return editor3DLevel; },
|
|
1662
|
+
get objects() { return editor3DObjects(); },
|
|
1663
|
+
get selection() { return editor3DSelection; },
|
|
1664
|
+
selected() { return editor3DSelected(); },
|
|
1665
|
+
made(id) { return editor3DInstances.get(id); },
|
|
1666
|
+
change(change) { return editor3DChange(change); },
|
|
1667
|
+
changePart(name, change) { return editor3DLevelPartNames.includes(name) && editor3DChangePart(name, change); },
|
|
1668
|
+
strokeEnd() { editor3DStrokeEnd(); },
|
|
1669
|
+
strokeCancel() { editor3DStrokeCancel(); },
|
|
1670
|
+
place(type, pos3D) { return level3DTypes.has(type) ? editor3DPlace(type, pos3D) : undefined; },
|
|
1671
|
+
setTransform(object, pos3D, rotation, scale3D) { editor3DSetTransform(object, pos3D, rotation, scale3D); },
|
|
1672
|
+
setProperty(object, name, value)
|
|
1673
|
+
{ editor3DSetProperty(object, name, value, level3DTypes.get(object.type)?.defaults[name]); },
|
|
1674
|
+
pos(object) { return editor3DPos(object); },
|
|
1675
|
+
rotation(object) { return editor3DRotation(object); },
|
|
1676
|
+
scale(object) { return editor3DScale(object); },
|
|
1677
|
+
mousePoint() { return editor3DIsOpen && !editor3DMouseOnPanel ? editor3DWithView(editor3DMousePoint) : undefined; },
|
|
1678
|
+
undo(redo=false) { return editor3DUndo(redo); },
|
|
1679
|
+
toJSON() { return editor3DLevel ? editor3DLevelJSON() : ''; },
|
|
1680
|
+
};
|
|
1681
|
+
|
|
1682
|
+
// the level editor's open, close and isOpen go to the 3D editor when the game has a 3D level, and it has the 3D
|
|
1683
|
+
// editor's keys and edit functions through this
|
|
667
1684
|
if (debug)
|
|
1685
|
+
editorOther = {wanted: editor3DWanted, isOpen: ()=> editor3DIsOpen, active: ()=> editor3DIsOpen || editor3DSession,
|
|
1686
|
+
open: editor3DOpen, close: editor3DClose, edit: editor3DEdit,
|
|
1687
|
+
hasKey: (name, ctrl)=> !!(ctrl ? editor3DCtrlKeys : editor3DKeys)[editor3DKeyCode(name)]};
|
|
1688
|
+
|
|
1689
|
+
// pick a tool of the editor's own, which puts a tool of the game's down
|
|
1690
|
+
function editor3DPickTool(tool)
|
|
1691
|
+
{
|
|
1692
|
+
editor3DTool = tool;
|
|
1693
|
+
levelEditor.tool = undefined;
|
|
1694
|
+
}
|
|
1695
|
+
|
|
1696
|
+
// a tool of the game's own: if its press is held, and the editor's tool and type to place when it was turned on,
|
|
1697
|
+
// since picking one of those puts the game's tool down
|
|
1698
|
+
let editor3DToolHeld = false, editor3DToolName, editor3DToolPicked = [];
|
|
1699
|
+
|
|
1700
|
+
// what a tool's callbacks are given: where the mouse is on the level or the ground, its ray, and the keys held
|
|
1701
|
+
function editor3DToolAt(ray=render3D.screenToRay(mousePosScreen), shift=false, ctrl=false)
|
|
1702
|
+
{ return {pos: editor3DMouseOnPanel ? undefined : editor3DSurface(ray), cell: undefined, ray, shift, ctrl}; }
|
|
1703
|
+
|
|
1704
|
+
// the game's tool, when one is on: the left button is its, a press, a drag and a release one undo, the right
|
|
1705
|
+
// button takes a held press back; true when it has the mouse
|
|
1706
|
+
function editor3DToolUpdate(ray, idle, shift, ctrl)
|
|
1707
|
+
{
|
|
1708
|
+
const picked = [editor3DTool, editor3DBrush];
|
|
1709
|
+
if (levelEditor.tool !== editor3DToolName)
|
|
1710
|
+
editor3DToolName = levelEditor.tool, editor3DToolPicked = picked; // turned on, or off, by its key or button
|
|
1711
|
+
else if (picked.some((value, i)=> value !== editor3DToolPicked[i]))
|
|
1712
|
+
levelEditor.tool = editor3DToolName = undefined; // a tool or a type of the editor's was picked
|
|
1713
|
+
const tool = editorGameTool();
|
|
1714
|
+
if (!tool)
|
|
1715
|
+
{
|
|
1716
|
+
editor3DToolHeld = false;
|
|
1717
|
+
return false;
|
|
1718
|
+
}
|
|
1719
|
+
const at = editor3DToolAt(ray, shift, ctrl);
|
|
1720
|
+
editor3DHover = editor3DBlockHover = editor3DTerrainHover = undefined; // the editor's own cursors are put away
|
|
1721
|
+
if (editor3DToolHeld && mouseWasPressed(2))
|
|
1722
|
+
{
|
|
1723
|
+
editor3DStrokeCancel();
|
|
1724
|
+
editor3DToolHeld = false;
|
|
1725
|
+
}
|
|
1726
|
+
else if (idle && mouseWasPressed(0))
|
|
1727
|
+
editor3DToolHeld = tool.onPress?.(at) !== false;
|
|
1728
|
+
else if (editor3DToolHeld && mouseIsDown(0))
|
|
1729
|
+
tool.onDrag?.(at);
|
|
1730
|
+
if (editor3DToolHeld && !mouseIsDown(0))
|
|
1731
|
+
{
|
|
1732
|
+
tool.onRelease?.(at);
|
|
1733
|
+
editor3DStrokeEnd();
|
|
1734
|
+
editor3DToolHeld = false;
|
|
1735
|
+
}
|
|
1736
|
+
return true;
|
|
1737
|
+
}
|
|
1738
|
+
|
|
1739
|
+
// the game's own drawing in the level, in the editor's 3D pass: the editor's onDraw, and its tool's while one is on
|
|
1740
|
+
function editor3DDrawGame()
|
|
668
1741
|
{
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
1742
|
+
if (!editor3DIsOpen) return;
|
|
1743
|
+
editorCall('onDraw');
|
|
1744
|
+
const held = (...codes)=> inputCaptureRead(()=> codes.some((code)=> keyIsDown(code)));
|
|
1745
|
+
editorGameTool()?.onDraw?.(editor3DToolAt(undefined, held('ShiftLeft', 'ShiftRight'),
|
|
1746
|
+
held('ControlLeft', 'ControlRight', 'MetaLeft', 'MetaRight')));
|
|
673
1747
|
}
|
|
674
1748
|
|
|
675
1749
|
///////////////////////////////////////////////////////////////////////////////
|
|
@@ -705,18 +1779,36 @@ const editor3DSaves = ()=> readSaveData(editor3DSaveName(), {});
|
|
|
705
1779
|
// if the last autosave did not fit in storage, the panel says so
|
|
706
1780
|
let editor3DSaveFailed = false;
|
|
707
1781
|
|
|
708
|
-
// remember the level's objects, or forget them when they are back to the file
|
|
709
|
-
function editor3DAutosave(level=editor3DLevel)
|
|
1782
|
+
// remember the level's objects and parts, or forget them when they are back to the file
|
|
1783
|
+
function editor3DAutosave(level=editor3DLevel, known)
|
|
710
1784
|
{
|
|
711
|
-
|
|
1785
|
+
// a prefab being edited is kept with the level it is in, as that level would be on going back: its own
|
|
1786
|
+
// prefabs with what is open, and what was edited on the way, in them
|
|
1787
|
+
if (editor3DPrefabLevels.has(level))
|
|
1788
|
+
{
|
|
1789
|
+
const root = editor3DPrefabStack[0]?.level, edits = {...editor3DPrefabEdits};
|
|
1790
|
+
editor3DPrefabStack.forEach((frame, i)=>
|
|
1791
|
+
{
|
|
1792
|
+
const open = editor3DPrefabStack[i + 1]?.level ?? editor3DLevel, known = level3DPrefabs.get(frame.name);
|
|
1793
|
+
const objects = isArray(open.objects) ? editor3DCopy(open.objects) : [];
|
|
1794
|
+
if (known?.fromLevel && JSON.stringify(objects) !== frame.entered)
|
|
1795
|
+
edits[frame.name] = known.attached ? {attached: true, objects} : {objects};
|
|
1796
|
+
});
|
|
1797
|
+
if (root && Object.keys(edits).length)
|
|
1798
|
+
editor3DAutosave({...root, prefabs: {...editor3DLevelPart('prefabs', root), ...edits}}, editor3DRecords.get(root));
|
|
1799
|
+
return;
|
|
1800
|
+
}
|
|
1801
|
+
const record = known ?? editor3DRecords.get(level);
|
|
712
1802
|
if (!record || record.pending) return; // edits waiting to be applied keep their autosave
|
|
713
1803
|
const saves = editor3DSaves(), objects = isArray(level.objects) ? level.objects : [];
|
|
1804
|
+
const parts = editor3DLevelParts(level);
|
|
714
1805
|
// 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
1806
|
// 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)
|
|
1807
|
+
if (editor3DSame(objects, record.original) && editor3DSame(parts, record.originalParts) && !record.savedHash)
|
|
717
1808
|
delete saves[record.key];
|
|
718
1809
|
else
|
|
719
|
-
saves[record.key] = {hash: record.hash, savedHash: record.savedHash, objects: editor3DCopy(objects)
|
|
1810
|
+
saves[record.key] = {hash: record.hash, savedHash: record.savedHash, objects: editor3DCopy(objects),
|
|
1811
|
+
...parts};
|
|
720
1812
|
try
|
|
721
1813
|
{
|
|
722
1814
|
localStorage.setItem(editor3DSaveName(), JSON.stringify(saves));
|
|
@@ -733,6 +1825,9 @@ function editor3DApplyPending()
|
|
|
733
1825
|
record.pending = undefined;
|
|
734
1826
|
editor3DFixIds(saved.objects);
|
|
735
1827
|
editor3DChange((list)=> { list.length = 0; list.push(...editor3DCopy(saved.objects)); });
|
|
1828
|
+
const parts = editor3DLevelParts(saved);
|
|
1829
|
+
for (const name of editor3DLevelPartNames)
|
|
1830
|
+
editor3DChangePart(name, ()=> parts[name]);
|
|
736
1831
|
editor3DStrokeEnd();
|
|
737
1832
|
editor3DAutosave();
|
|
738
1833
|
}
|
|
@@ -753,25 +1848,61 @@ function editor3DRevert()
|
|
|
753
1848
|
const record = editor3DRecords.get(editor3DLevel);
|
|
754
1849
|
if (!record) return false;
|
|
755
1850
|
record.pending = undefined;
|
|
756
|
-
if (editor3DSame(editor3DObjects(), record.original))
|
|
1851
|
+
if (editor3DSame(editor3DObjects(), record.original) && editor3DSame(editor3DLevelParts(), record.originalParts))
|
|
757
1852
|
return editor3DAutosave(), false;
|
|
758
1853
|
editor3DChange((list)=> { list.length = 0; list.push(...editor3DCopy(record.original)); });
|
|
1854
|
+
for (const name of editor3DLevelPartNames)
|
|
1855
|
+
editor3DChangePart(name, ()=> record.originalParts[name] && editor3DCopy(record.originalParts[name]));
|
|
759
1856
|
editor3DStrokeEnd();
|
|
760
1857
|
}
|
|
761
1858
|
|
|
762
1859
|
// save the level as JSON: where the browser lets a page write files, Chrome and Edge, to a file picked once and
|
|
763
1860
|
// written again on each Save after, or picked again with Save As; elsewhere as a download; a file of the level's
|
|
764
1861
|
// 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
|
|
1862
|
+
// 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
1863
|
async function editor3DSave(pickAgain=false)
|
|
767
1864
|
{
|
|
768
1865
|
const level = editor3DLevel, record = editor3DRecords.get(level);
|
|
769
1866
|
if (!record) return;
|
|
770
1867
|
editor3DStrokeEnd();
|
|
1868
|
+
const open = editor3DPrefabStack[editor3DPrefabStack.length - 1];
|
|
1869
|
+
if (open)
|
|
1870
|
+
{
|
|
1871
|
+
// inside a prefab, Save writes the prefab as a file of its own, or the game keeps it itself
|
|
1872
|
+
const text = editor3DLevelJSON(level), name = open.name + '.json';
|
|
1873
|
+
const written = JSON.stringify(editor3DObjects()); // as the file has it
|
|
1874
|
+
const saved = (async ()=>
|
|
1875
|
+
{
|
|
1876
|
+
await record.saving; // in the order asked for, as a level's saves are
|
|
1877
|
+
const kept = await editorCall('onSave', text, name) === true;
|
|
1878
|
+
kept || saveText(text, name, 'application/json');
|
|
1879
|
+
editor3DPrefabDirty.delete(open.name);
|
|
1880
|
+
open.saved = written;
|
|
1881
|
+
return kept ? 'kept' : 'downloaded';
|
|
1882
|
+
})();
|
|
1883
|
+
record.saving = saved.catch(()=> {});
|
|
1884
|
+
return saved;
|
|
1885
|
+
}
|
|
771
1886
|
// what is written, kept as it is now: the level can change while the file is picked and written, and those
|
|
772
1887
|
// edits are not in the file, so they stay in the autosave; the level and record are this one's, whichever is
|
|
773
1888
|
// open by then
|
|
774
|
-
const text = editor3DLevelJSON(level)
|
|
1889
|
+
const text = editor3DLevelJSON(level);
|
|
1890
|
+
// its place in line is taken now, and the game's hook is asked when its turn comes, so saves are written in
|
|
1891
|
+
// the order they were asked for however long a hook takes
|
|
1892
|
+
const saved = (async ()=>
|
|
1893
|
+
{
|
|
1894
|
+
await record.saving;
|
|
1895
|
+
if (await editorCall('onSave', text, record.fileName) === true) return 'kept'; // the game kept it itself
|
|
1896
|
+
return editor3DSaveText(level, record, text, pickAgain);
|
|
1897
|
+
})();
|
|
1898
|
+
record.saving = saved.catch(()=> {});
|
|
1899
|
+
return saved;
|
|
1900
|
+
}
|
|
1901
|
+
|
|
1902
|
+
// write one save of a level, the one before it done
|
|
1903
|
+
async function editor3DSaveText(level, record, text, pickAgain)
|
|
1904
|
+
{
|
|
1905
|
+
const file = JSON.parse(text), written = editor3DCopy(file.objects), writtenParts = editor3DLevelParts(file);
|
|
775
1906
|
const picker = /** @type {any} */ (globalThis).showSaveFilePicker;
|
|
776
1907
|
const fileKey = (globalThis.location?.pathname ?? '') + ' 3D ' + record.key;
|
|
777
1908
|
if (picker)
|
|
@@ -797,7 +1928,8 @@ async function editor3DSave(pickAgain=false)
|
|
|
797
1928
|
if (record.fileHandle.name === record.fileName)
|
|
798
1929
|
{
|
|
799
1930
|
record.original = written;
|
|
800
|
-
record.
|
|
1931
|
+
record.originalParts = writtenParts;
|
|
1932
|
+
record.savedHash = editor3DContentHash(written, writtenParts);
|
|
801
1933
|
editor3DAutosave(level);
|
|
802
1934
|
}
|
|
803
1935
|
return 'written';
|
|
@@ -820,9 +1952,20 @@ const EDITOR3D_HANDLE_SIZE = .15; // a handle's length as a part of the view's h
|
|
|
820
1952
|
const EDITOR3D_HANDLE_REACH = 10; // how many pixels from a handle still grab it
|
|
821
1953
|
const EDITOR3D_AXES = {x: vec3(1, 0, 0), y: vec3(0, 1, 0), z: vec3(0, 0, 1)};
|
|
822
1954
|
|
|
823
|
-
let editor3DTool = 'move'; // 'select', 'move', 'rotate' or '
|
|
1955
|
+
let editor3DTool = 'move'; // 'select', 'move', 'rotate', 'scale', 'blocks' or 'terrain'
|
|
1956
|
+
let editor3DBlockType = 1; // the block type the Blocks tool places
|
|
1957
|
+
let editor3DBlockBox = false; // the Blocks tool fills a box: the rectangle dragged, editor3DBlockBoxHeight tall
|
|
1958
|
+
let editor3DBlockBoxHeight = 1;
|
|
1959
|
+
let editor3DSeconds = 0; // how long the last step was, for a brush held down
|
|
1960
|
+
// where the mouse is on the terrain, for the Terrain tool's brush
|
|
1961
|
+
/** @type {Vector3|undefined} */
|
|
1962
|
+
let editor3DTerrainHover;
|
|
1963
|
+
// the cell the Blocks tool would change, for its outline: {cell, mode}
|
|
1964
|
+
/** @type {{cell: Vector3, mode: string}|undefined} */
|
|
1965
|
+
let editor3DBlockHover;
|
|
824
1966
|
let editor3DGrid = true; // moves, turns and sizes go in steps, Ctrl flips it for a drag
|
|
825
1967
|
let editor3DGroundSnap = true; // a body drag slides along what is under the mouse
|
|
1968
|
+
let editor3DLocalAxes = false; // the Move handles follow the object's own axes, not the world's
|
|
826
1969
|
let editor3DMoveStep = 1, editor3DRotateStep = 15, editor3DScaleStep = .25; // the rotate step in degrees
|
|
827
1970
|
let editor3DHelp = false; // the keys are shown
|
|
828
1971
|
let editor3DMouseOnPanel = false;
|
|
@@ -850,7 +1993,7 @@ function editor3DSelectionCenter()
|
|
|
850
1993
|
function editor3DHandles()
|
|
851
1994
|
{
|
|
852
1995
|
const center = editor3DSelectionCenter(), tool = editor3DTool, handles = [];
|
|
853
|
-
if (!center || tool === 'select') return handles;
|
|
1996
|
+
if (!center || tool === 'select' || tool === 'blocks' || tool === 'terrain' || editorGameTool()) return handles;
|
|
854
1997
|
const length = editor3DScreenScale(center) * EDITOR3D_HANDLE_SIZE;
|
|
855
1998
|
const rotation = editor3DRotation(editor3DSelected()[0]).scale(PI / 180), frame = buildMatrix(vec3(), rotation);
|
|
856
1999
|
for (const axis of 'xyz')
|
|
@@ -867,16 +2010,19 @@ function editor3DHandles()
|
|
|
867
2010
|
continue;
|
|
868
2011
|
}
|
|
869
2012
|
|
|
870
|
-
// an arrow or scale handle starts a little out from the middle, which is left for the object's body
|
|
871
|
-
|
|
872
|
-
|
|
2013
|
+
// an arrow or scale handle starts a little out from the middle, which is left for the object's body; Move
|
|
2014
|
+
// goes along the world's axes, or with own axes on along the object's, as Scale always does
|
|
2015
|
+
const local = tool !== 'move' || editor3DLocalAxes;
|
|
2016
|
+
const way = (k)=> local ? frame.transformDirection(EDITOR3D_AXES[k]) : EDITOR3D_AXES[k];
|
|
2017
|
+
const direction = way(axis), turn = tool === 'move' && local ? rotation : undefined;
|
|
2018
|
+
handles.push({kind: tool === 'move' ? 'arrow' : 'scale', axis, center, length, direction, turn,
|
|
873
2019
|
points: [center.add(direction.scale(length * .2)), center.add(direction.scale(length))]});
|
|
874
2020
|
if (tool === 'move')
|
|
875
2021
|
{
|
|
876
|
-
// the square between the other two axes moves along both
|
|
2022
|
+
// the square between the other two axes moves along both; across is the axis it does not move along
|
|
877
2023
|
const [a, b] = 'xyz'.replace(axis, '');
|
|
878
|
-
handles.push({kind: 'plane', axis: a + b, center, length, direction,
|
|
879
|
-
points: [center.add(
|
|
2024
|
+
handles.push({kind: 'plane', axis: a + b, center, length, direction, turn, across: axis,
|
|
2025
|
+
ways: [way(a), way(b)], points: [center.add(way(a).add(way(b)).scale(length * .35))]});
|
|
880
2026
|
}
|
|
881
2027
|
}
|
|
882
2028
|
tool === 'scale' && handles.push({kind: 'scaleAll', axis: 'xyz', center, length, points: [center]});
|
|
@@ -943,6 +2089,10 @@ function editor3DParts(made, parts=[])
|
|
|
943
2089
|
parts.push(made);
|
|
944
2090
|
for (const child of made.children)
|
|
945
2091
|
editor3DParts(child, parts);
|
|
2092
|
+
// a prefab's instance and its parts are one thing, the ones that are not its children too
|
|
2093
|
+
if (made instanceof Prefab3D)
|
|
2094
|
+
for (const part of made.parts)
|
|
2095
|
+
part?.parent === made || editor3DParts(part, parts);
|
|
946
2096
|
}
|
|
947
2097
|
return parts;
|
|
948
2098
|
}
|
|
@@ -1016,16 +2166,23 @@ function editor3DPlaceAt(type, ray)
|
|
|
1016
2166
|
editor3DStrokeEnd();
|
|
1017
2167
|
const id = editor3DPlace(type, point);
|
|
1018
2168
|
if (id === undefined) return;
|
|
1019
|
-
const
|
|
2169
|
+
const object = editor3DObject(id), size = editor3DSize(object), offset = editor3DBoxOffset(object);
|
|
2170
|
+
const ignore = new Set(editor3DParts(editor3DInstances.get(id)));
|
|
1020
2171
|
const snapped = editor3DSnapPos(editor3DRest(point, size), size, editor3DGrid ? editor3DMoveStep : 0);
|
|
1021
2172
|
const y = editor3DLand(snapped.x, snapped.z, point.y, size, ignore);
|
|
1022
|
-
|
|
2173
|
+
// the box stands there, and the object's place is where that puts it
|
|
2174
|
+
const to = vec3(snapped.x, y, snapped.z).subtract(offset);
|
|
2175
|
+
editor3DChange((list)=> editor3DSetTransform(list.find((o)=> o.id === id), to));
|
|
1023
2176
|
editor3DStrokeEnd();
|
|
1024
2177
|
}
|
|
1025
2178
|
|
|
1026
2179
|
// the mouse moved with a drag held: the selection goes from where the drag started to where the mouse says
|
|
1027
2180
|
function editor3DDragTo(drag, mouse, ray, snap)
|
|
1028
2181
|
{
|
|
2182
|
+
if (drag.kind === 'blocks')
|
|
2183
|
+
return editor3DVoxelDragTo(drag, ray);
|
|
2184
|
+
if (drag.kind === 'terrain')
|
|
2185
|
+
return editor3DTerrainHover && editor3DTerrainDragTo(drag, editor3DTerrainHover);
|
|
1029
2186
|
if (drag.kind === 'box')
|
|
1030
2187
|
{
|
|
1031
2188
|
drag.to = mouse.copy();
|
|
@@ -1036,18 +2193,23 @@ function editor3DDragTo(drag, mouse, ray, snap)
|
|
|
1036
2193
|
const was = first && start.get(first.id);
|
|
1037
2194
|
if (!was) return;
|
|
1038
2195
|
const size = editor3DSize(first), turned = editor3DTurned(first), step = snap ? editor3DMoveStep : 0;
|
|
1039
|
-
const
|
|
2196
|
+
const offset = editor3DBoxOffset(first); // snapping and landing go by its box
|
|
2197
|
+
const snapTo = (to)=> editor3DSnapPos(to.add(offset), size, step, turned).subtract(offset).subtract(was.pos);
|
|
1040
2198
|
let move, turn, factor;
|
|
1041
2199
|
if (kind === 'arrow')
|
|
1042
2200
|
{
|
|
1043
2201
|
const d = editor3DAxisDistance(ray, handle.center, handle.direction) - grab;
|
|
1044
|
-
move
|
|
2202
|
+
// an axis of its own does not follow the grid, so the move goes in steps from where it started
|
|
2203
|
+
move = handle.turn ? handle.direction.scale(step ? round(d / step) * step : d) :
|
|
2204
|
+
handle.direction.multiply(snapTo(was.pos.add(handle.direction.scale(d)))); // along its axis alone
|
|
1045
2205
|
}
|
|
1046
2206
|
else if (kind === 'plane')
|
|
1047
2207
|
{
|
|
1048
2208
|
const point = editor3DPlanePoint(ray, handle.center, handle.direction);
|
|
1049
2209
|
if (!point || !grab) return;
|
|
1050
|
-
|
|
2210
|
+
const delta = point.subtract(grab), along = (w)=> step ? round(delta.dot(w) / step) * step : delta.dot(w);
|
|
2211
|
+
move = handle.turn ? handle.ways[0].scale(along(handle.ways[0])).add(handle.ways[1].scale(along(handle.ways[1]))) :
|
|
2212
|
+
vec3(1).subtract(handle.direction).multiply(snapTo(was.pos.add(delta)));
|
|
1051
2213
|
}
|
|
1052
2214
|
else if (kind === 'body')
|
|
1053
2215
|
{
|
|
@@ -1055,8 +2217,8 @@ function editor3DDragTo(drag, mouse, ray, snap)
|
|
|
1055
2217
|
const point = editor3DPlanePoint(ray, drag.level, drag.normal);
|
|
1056
2218
|
if (!point) return;
|
|
1057
2219
|
const across = snapTo(was.pos.add(point.subtract(drag.level)));
|
|
1058
|
-
const y = editor3DGroundSnap ? editor3DLand(was.pos.x + across.x, was.pos.z + across.z,
|
|
1059
|
-
was.pos.y - size.y / 2, size, drag.ignore) : was.pos.y;
|
|
2220
|
+
const y = editor3DGroundSnap ? editor3DLand(was.pos.x + offset.x + across.x, was.pos.z + offset.z + across.z,
|
|
2221
|
+
was.pos.y + offset.y - size.y / 2, size, drag.ignore) - offset.y : was.pos.y;
|
|
1060
2222
|
move = vec3(across.x, y - was.pos.y, across.z);
|
|
1061
2223
|
}
|
|
1062
2224
|
else if (kind === 'ring')
|
|
@@ -1157,9 +2319,9 @@ function editor3DDropSelection()
|
|
|
1157
2319
|
{
|
|
1158
2320
|
for (const object of list)
|
|
1159
2321
|
{
|
|
1160
|
-
const to = editor3DSelection.has(object.id) &&
|
|
1161
|
-
editor3DDrop(editor3DPos(object), editor3DSize(object), ignore);
|
|
1162
|
-
to && editor3DSetTransform(object, to);
|
|
2322
|
+
const offset = editor3DBoxOffset(object), to = editor3DSelection.has(object.id) &&
|
|
2323
|
+
editor3DDrop(editor3DPos(object).add(offset), editor3DSize(object), ignore);
|
|
2324
|
+
to && editor3DSetTransform(object, to.subtract(offset));
|
|
1163
2325
|
}
|
|
1164
2326
|
});
|
|
1165
2327
|
editor3DStrokeEnd();
|
|
@@ -1173,11 +2335,12 @@ function editor3DPasteAtMouse()
|
|
|
1173
2335
|
if (!copied?.length) return false;
|
|
1174
2336
|
if (!point) return editor3DPaste();
|
|
1175
2337
|
const center = copied.reduce((sum, o)=> sum.add(editor3DPos(o)), vec3()).scale(1 / copied.length);
|
|
1176
|
-
const
|
|
1177
|
-
const
|
|
1178
|
-
const
|
|
2338
|
+
const box = (o)=> editor3DClipboardBoxes.get(o) ?? {size: editor3DSize(o), offset: editor3DBoxOffset(o)};
|
|
2339
|
+
const bottom = copied.reduce((low, o)=> min(low, editor3DPos(o).y + box(o).offset.y - box(o).size.y / 2), Infinity);
|
|
2340
|
+
const first = copied[0], size = box(first).size, was = editor3DPos(first).add(box(first).offset); // its box's middle
|
|
2341
|
+
const snapped = editor3DSnapPos(was.add(vec3(point.x - center.x, 0, point.z - center.z)), size,
|
|
1179
2342
|
editor3DGrid ? editor3DMoveStep : 0, editor3DTurned(first));
|
|
1180
|
-
const land = editor3DLand(snapped.x, snapped.z, point.y,
|
|
2343
|
+
const land = editor3DLand(snapped.x, snapped.z, point.y, size) - size.y / 2;
|
|
1181
2344
|
return editor3DPaste(vec3(snapped.x - was.x, land - bottom, snapped.z - was.z));
|
|
1182
2345
|
}
|
|
1183
2346
|
|
|
@@ -1186,15 +2349,22 @@ function editor3DPasteAtMouse()
|
|
|
1186
2349
|
/** @type {Object<string, function(boolean=): any>} */
|
|
1187
2350
|
const editor3DKeys =
|
|
1188
2351
|
{
|
|
1189
|
-
KeyQ: ()=> {
|
|
1190
|
-
KeyW: ()=> {
|
|
1191
|
-
KeyE: ()=> {
|
|
1192
|
-
KeyR: ()=> {
|
|
2352
|
+
KeyQ: ()=> { editor3DPickTool('select'); editor3DBrush = undefined; },
|
|
2353
|
+
KeyW: ()=> { editor3DPickTool('move'); },
|
|
2354
|
+
KeyE: ()=> { editor3DPickTool('rotate'); },
|
|
2355
|
+
KeyR: ()=> { editor3DPickTool('scale'); },
|
|
2356
|
+
KeyB: ()=> { editor3DPickTool('blocks'); editor3DBrush = undefined; },
|
|
2357
|
+
KeyT: ()=> { editor3DPickTool('terrain'); editor3DBrush = undefined; },
|
|
2358
|
+
KeyP: ()=> editor3DTool === 'blocks' && editor3DVoxelPick(render3D.screenToRay(mousePosScreen)),
|
|
2359
|
+
KeyX: ()=> editor3DTool === 'terrain' ? editor3DTerrainNextBrush() :
|
|
2360
|
+
editor3DTool === 'blocks' && (editor3DBlockBox = !editor3DBlockBox),
|
|
1193
2361
|
KeyG: ()=> { editor3DGrid = !editor3DGrid; },
|
|
2362
|
+
KeyL: ()=> { editor3DLocalAxes = !editor3DLocalAxes; },
|
|
1194
2363
|
KeyF: ()=> editor3DFrame(),
|
|
1195
2364
|
End: ()=> editor3DDropSelection(),
|
|
1196
2365
|
Delete: ()=> editor3DDelete(),
|
|
1197
|
-
Backspace: ()=> editor3DDelete(),
|
|
2366
|
+
Backspace: ()=> editor3DSelection.size ? editor3DDelete() : editor3DPrefabBack(),
|
|
2367
|
+
Enter: ()=> editor3DPrefabEnter(),
|
|
1198
2368
|
Slash: ()=> { editor3DHelp = !editor3DHelp; },
|
|
1199
2369
|
};
|
|
1200
2370
|
/** @type {Object<string, function(boolean=): any>} */
|
|
@@ -1206,6 +2376,7 @@ const editor3DCtrlKeys =
|
|
|
1206
2376
|
KeyX: ()=> editor3DCut(),
|
|
1207
2377
|
KeyV: ()=> editor3DPasteAtMouse(),
|
|
1208
2378
|
KeyD: ()=> editor3DDuplicate(),
|
|
2379
|
+
KeyG: (shift)=> shift ? editor3DUnpack() : editor3DMakePrefab() === true,
|
|
1209
2380
|
};
|
|
1210
2381
|
|
|
1211
2382
|
// the camera: the right button looks and the keys fly while it is held, the middle button or Space and the left
|
|
@@ -1248,6 +2419,7 @@ function editor3DCameraUpdate(seconds, shift, alt)
|
|
|
1248
2419
|
// the editor's update, called while it is open with the input readable and render3D looking through its camera
|
|
1249
2420
|
function editor3DEditorUpdate(seconds)
|
|
1250
2421
|
{
|
|
2422
|
+
editor3DSeconds = seconds;
|
|
1251
2423
|
const held = (...codes)=> codes.some((code)=> keyIsDown(code));
|
|
1252
2424
|
const shift = held('ShiftLeft', 'ShiftRight'), alt = held('AltLeft', 'AltRight');
|
|
1253
2425
|
const ctrl = held('ControlLeft', 'ControlRight', 'MetaLeft', 'MetaRight');
|
|
@@ -1257,7 +2429,13 @@ function editor3DEditorUpdate(seconds)
|
|
|
1257
2429
|
if (debugKey && keyWasPressed(debugKey))
|
|
1258
2430
|
{
|
|
1259
2431
|
inputClearKey(debugKey);
|
|
1260
|
-
|
|
2432
|
+
if (editor3DToolHeld)
|
|
2433
|
+
{
|
|
2434
|
+
editor3DStrokeCancel(); // a held press of the game's tool is taken back
|
|
2435
|
+
editor3DToolHeld = false;
|
|
2436
|
+
}
|
|
2437
|
+
else
|
|
2438
|
+
editor3DDrag ? editor3DDragCancel() : editor3DPlay(editor3DMousePoint());
|
|
1261
2439
|
return;
|
|
1262
2440
|
}
|
|
1263
2441
|
|
|
@@ -1273,9 +2451,20 @@ function editor3DEditorUpdate(seconds)
|
|
|
1273
2451
|
const looking = mouseIsDown(2), cameraDrag = looking || mouseIsDown(1) || keyIsDown('Space') || alt;
|
|
1274
2452
|
|
|
1275
2453
|
// the keys, which wait for a drag to end, and fly while the right button is held
|
|
1276
|
-
if (!editor3DDrag && !looking)
|
|
2454
|
+
if (!editor3DDrag && !looking && !editor3DToolHeld)
|
|
2455
|
+
{
|
|
2456
|
+
// the game's own keys first, each takes the place of the editor's key of the same spelling
|
|
2457
|
+
const taken = new Set;
|
|
2458
|
+
for (const [spelled, key] of Object.entries(levelEditor.keys))
|
|
2459
|
+
{
|
|
2460
|
+
const withCtrl = spelled.startsWith('ctrl+'), code = editor3DKeyCode(spelled.slice(withCtrl ? 5 : 0));
|
|
2461
|
+
if (withCtrl !== ctrl) continue;
|
|
2462
|
+
taken.add(code);
|
|
2463
|
+
keyWasPressed(code) && key.action.call(levelEditor, shift);
|
|
2464
|
+
}
|
|
1277
2465
|
for (const [code, action] of Object.entries(ctrl ? editor3DCtrlKeys : editor3DKeys))
|
|
1278
|
-
keyWasPressed(code) && action(shift);
|
|
2466
|
+
taken.has(code) || keyWasPressed(code) && action(shift);
|
|
2467
|
+
}
|
|
1279
2468
|
|
|
1280
2469
|
// a right press during a drag puts it back
|
|
1281
2470
|
if (editor3DDrag && mouseWasPressed(2))
|
|
@@ -1283,7 +2472,25 @@ function editor3DEditorUpdate(seconds)
|
|
|
1283
2472
|
|
|
1284
2473
|
const ray = render3D.screenToRay(mouse), idle = !cameraDrag && !editor3DMouseOnPanel && !editor3DDrag;
|
|
1285
2474
|
editor3DHover = idle ? editor3DHandleAt(mouse) : undefined;
|
|
1286
|
-
|
|
2475
|
+
editorCall('onUpdate');
|
|
2476
|
+
if (editor3DToolUpdate(ray, idle, shift, ctrl)) return; // a tool of the game's own has the mouse
|
|
2477
|
+
|
|
2478
|
+
// the Blocks tool takes the mouse while the level has a map: Shift removes, Ctrl repaints
|
|
2479
|
+
// a type picked to place is placed, whichever tool is on
|
|
2480
|
+
const painting = editor3DTool === 'blocks' && !editor3DBrush && !!editor3DVoxelMap();
|
|
2481
|
+
const mode = shift ? 'remove' : ctrl ? 'paint' : 'place';
|
|
2482
|
+
const target = painting && idle ? editor3DVoxelTarget(ray, mode) : undefined;
|
|
2483
|
+
editor3DBlockHover = target && {cell: target.cell, mode};
|
|
2484
|
+
// the Terrain tool takes it while the level has a terrain: held down its brush works on the ground
|
|
2485
|
+
const terrain = editor3DTool === 'terrain' && !editor3DBrush ? editor3DTerrainMap() : undefined;
|
|
2486
|
+
const reach = terrain && !cameraDrag && !editor3DMouseOnPanel ? terrain.raycast(ray) : undefined;
|
|
2487
|
+
editor3DTerrainHover = reach === undefined ? undefined : ray.getPosition(reach);
|
|
2488
|
+
if (painting)
|
|
2489
|
+
idle && mouseWasPressed(0) && (editor3DDrag = editor3DVoxelPress(ray, mode));
|
|
2490
|
+
else if (terrain)
|
|
2491
|
+
idle && mouseWasPressed(0) && editor3DTerrainHover &&
|
|
2492
|
+
(editor3DDrag = editor3DTerrainPress(editor3DTerrainHover, shift, ctrl));
|
|
2493
|
+
else if (idle && mouseWasPressed(0))
|
|
1287
2494
|
editor3DPress(mouse, ray, shift);
|
|
1288
2495
|
|
|
1289
2496
|
// a quick click, down and up before this step, is a press and its release in one
|
|
@@ -1296,7 +2503,7 @@ function editor3DEditorUpdate(seconds)
|
|
|
1296
2503
|
if (debug && globalThis.document?.addEventListener)
|
|
1297
2504
|
document.addEventListener('keydown', (e)=>
|
|
1298
2505
|
{
|
|
1299
|
-
if (editor3DIsOpen && (e.ctrlKey || e.metaKey) && /^Key[
|
|
2506
|
+
if (editor3DIsOpen && (e.ctrlKey || e.metaKey) && /^Key[ZYCXVDG]$/.test(e.code) && !editorIsTextField(e.target))
|
|
1300
2507
|
e.preventDefault();
|
|
1301
2508
|
});
|
|
1302
2509
|
|
|
@@ -1308,11 +2515,16 @@ const editor3DHelpLines =
|
|
|
1308
2515
|
[
|
|
1309
2516
|
'Left: select · Shift+Left: add or take away · Left drag from empty space: box select',
|
|
1310
2517
|
'Q select · W move · E rotate · R scale: drag a handle, or the object itself',
|
|
1311
|
-
'G: grid snap, Ctrl flips it for a drag · End: drop to the ground',
|
|
2518
|
+
'G: grid snap, Ctrl flips it for a drag · L: move along its own axes · End: drop to the ground',
|
|
1312
2519
|
'Right button: look, and WASD and QE fly while it is held, Shift faster',
|
|
1313
2520
|
'Wheel: zoom · Middle drag or Space+drag: pan · Alt+drag: orbit · F: frame the selection',
|
|
1314
2521
|
'Pick a type, then click to place it, Shift+click keeps placing',
|
|
2522
|
+
'B blocks: click or drag places · Shift removes · Ctrl repaints · P picks the type under the mouse',
|
|
2523
|
+
'X box fill: a drag fills the rectangle dragged, as tall as the height in the panel',
|
|
2524
|
+
'T terrain: hold to raise the ground · Shift lowers · Ctrl smooths · X the next brush, flatten and paint',
|
|
1315
2525
|
'Delete · Ctrl+C / X / V: copy, cut, paste · Ctrl+D: duplicate · Ctrl+Z / Y: undo, redo',
|
|
2526
|
+
'Ctrl+G: make the selection a prefab, one thing to place many times · Ctrl+Shift+G: unpack it into its objects',
|
|
2527
|
+
'Enter: open the selected prefab to edit it alone, every instance follows · Backspace with nothing selected: back',
|
|
1316
2528
|
'Reset to file: the level as its file has it, Restart keeps your edits, Undo brings them back',
|
|
1317
2529
|
'Esc: play and edit · 0: exit the editor · ?: keys',
|
|
1318
2530
|
];
|
|
@@ -1322,6 +2534,7 @@ function editor3DHint()
|
|
|
1322
2534
|
{
|
|
1323
2535
|
if (editor3DRecords.get(editor3DLevel)?.pending)
|
|
1324
2536
|
return 'The level file changed: apply your edits or drop them';
|
|
2537
|
+
if (editorGameTool()) return editorGameTool().hint || levelEditor.tool;
|
|
1325
2538
|
if (editor3DDrag)
|
|
1326
2539
|
return editor3DDrag.kind === 'box' ? 'Let go to select what is inside' :
|
|
1327
2540
|
'Esc or right click puts it back · Ctrl flips the snap';
|
|
@@ -1329,6 +2542,16 @@ function editor3DHint()
|
|
|
1329
2542
|
return 'WASD and QE fly · Shift faster · the wheel sets the speed';
|
|
1330
2543
|
if (editor3DBrush)
|
|
1331
2544
|
return `Click to place a ${editor3DBrush} · Shift keeps placing · Q puts it down`;
|
|
2545
|
+
if (editor3DTool === 'terrain')
|
|
2546
|
+
return !editor3DTerrainMap() ? 'Add a terrain in the panel to sculpt it' :
|
|
2547
|
+
{sculpt: 'Hold to raise the ground · Shift lowers · Ctrl smooths',
|
|
2548
|
+
flatten: 'Hold to flatten the ground to the height pressed · Ctrl smooths',
|
|
2549
|
+
paint: 'Hold to paint the ground · Shift takes the paint off'}[editor3DTerrainBrush.mode] +
|
|
2550
|
+
' · X the next brush';
|
|
2551
|
+
if (editor3DTool === 'blocks')
|
|
2552
|
+
return editor3DVoxelMap() ? (editor3DBlockBox ? 'Drag a box to fill it' : 'Click or drag places') +
|
|
2553
|
+
' · Shift removes · Ctrl repaints · P picks a type · X box' :
|
|
2554
|
+
'Add a block map in the panel to paint blocks';
|
|
1332
2555
|
if (editor3DSelection.size)
|
|
1333
2556
|
return 'Drag a handle or the object · W move · E rotate · R scale · End drops it';
|
|
1334
2557
|
return 'Click to select · pick a type to place · the right button looks · ? keys';
|
|
@@ -1401,7 +2624,8 @@ function editor3DDrawRing(center, across, along, radius, color, pixels=2)
|
|
|
1401
2624
|
function editor3DVisible(object)
|
|
1402
2625
|
{
|
|
1403
2626
|
const made = editor3DInstances.get(object.id);
|
|
1404
|
-
return made instanceof EngineObject3D && !made.destroyed && (made.mesh || made.tileInfo
|
|
2627
|
+
return made instanceof EngineObject3D && !made.destroyed && (made.mesh || made.tileInfo || editor3DPrefabBox(made)) ?
|
|
2628
|
+
made : undefined;
|
|
1405
2629
|
}
|
|
1406
2630
|
|
|
1407
2631
|
// the box around an object as a matrix: around its mesh where it is, turned and sized as it is, a unit box for a
|
|
@@ -1411,6 +2635,9 @@ function editor3DBoxMatrix(object)
|
|
|
1411
2635
|
const made = editor3DVisible(object), bounds = made?.mesh && (made.mesh.bounds || made.mesh.getBounds());
|
|
1412
2636
|
if (!made)
|
|
1413
2637
|
return buildMatrix(editor3DPos(object));
|
|
2638
|
+
const box = editor3DPrefabBox(made);
|
|
2639
|
+
if (box)
|
|
2640
|
+
return buildMatrix(box.pos, undefined, box.size);
|
|
1414
2641
|
const matrix = render3DObjectMatrix(made).copy();
|
|
1415
2642
|
return bounds ? matrix.multiply(buildMatrix(bounds.min.add(bounds.max).scale(.5), undefined,
|
|
1416
2643
|
bounds.max.subtract(bounds.min))) : matrix.multiply(buildMatrix(vec3(), undefined, made.size3D));
|
|
@@ -1461,6 +2688,33 @@ function editor3DDraw()
|
|
|
1461
2688
|
}
|
|
1462
2689
|
}
|
|
1463
2690
|
|
|
2691
|
+
// the cell the Blocks tool would change: white to place, red to remove, yellow to repaint
|
|
2692
|
+
const map = editor3DTool === 'blocks' && !editor3DMouseOnPanel ? editor3DVoxelMap() : undefined;
|
|
2693
|
+
if (map && editor3DBlockHover && !editor3DDrag)
|
|
2694
|
+
{
|
|
2695
|
+
const {cell, mode} = editor3DBlockHover;
|
|
2696
|
+
editor3DDrawWire(buildMatrix(map.pos3D.add(cell).add(vec3(.5)), undefined, vec3(1.02)),
|
|
2697
|
+
mode === 'place' ? hsl(0, 0, 1, .9) : mode === 'remove' ? hsl(0, 1, .6) : hsl(.15, 1, .6), 2.5);
|
|
2698
|
+
}
|
|
2699
|
+
// the Terrain tool's brush, a ring on the ground
|
|
2700
|
+
if (editor3DTool === 'terrain' && editor3DTerrainHover && editor3DTerrainMap())
|
|
2701
|
+
editor3DDrawRing(editor3DTerrainHover.add(vec3(0, .05, 0)), EDITOR3D_AXES.x, EDITOR3D_AXES.z,
|
|
2702
|
+
editor3DTerrainBrush.size / 2, hsl(0, 0, 1, .9), 2.5);
|
|
2703
|
+
|
|
2704
|
+
// the box a box drag fills
|
|
2705
|
+
const region = map && editor3DDrag?.region;
|
|
2706
|
+
if (region)
|
|
2707
|
+
{
|
|
2708
|
+
const size = region.hi.subtract(region.lo).add(vec3(1));
|
|
2709
|
+
editor3DDrawWire(buildMatrix(map.pos3D.add(region.lo).add(size.scale(.5)), undefined, size.add(vec3(.02))),
|
|
2710
|
+
editor3DDrag.mode === 'place' ? hsl(0, 0, 1, .9) : editor3DDrag.mode === 'remove' ? hsl(0, 1, .6) :
|
|
2711
|
+
hsl(.15, 1, .6), 2.5);
|
|
2712
|
+
}
|
|
2713
|
+
// the map's edges, to see where it ends
|
|
2714
|
+
if (map)
|
|
2715
|
+
editor3DDrawWire(buildMatrix(map.pos3D.add(map.mapSize.scale(.5)), undefined, map.mapSize),
|
|
2716
|
+
hsl(.55, .6, .6, .5), 1);
|
|
2717
|
+
|
|
1464
2718
|
// the brush, a box where a click would place it
|
|
1465
2719
|
const point = editor3DBrush && !editor3DMouseOnPanel && !editor3DDrag && editor3DMousePoint();
|
|
1466
2720
|
if (point)
|
|
@@ -1482,9 +2736,8 @@ function editor3DDraw()
|
|
|
1482
2736
|
else if (kind === 'plane')
|
|
1483
2737
|
{
|
|
1484
2738
|
// a flat square across its two axes, in the color of the axis it does not move along
|
|
1485
|
-
const size = vec3(tip * 1.6).subtract(handle.
|
|
1486
|
-
r.drawBox(points[0], size, lit ? color : EDITOR3D_AXIS_COLORS[
|
|
1487
|
-
.replace(axis[1], '')].scale(1, .7));
|
|
2739
|
+
const size = vec3(tip * 1.6).subtract(EDITOR3D_AXES[handle.across].scale(tip * 1.5));
|
|
2740
|
+
r.drawBox(points[0], size, lit ? color : EDITOR3D_AXIS_COLORS[handle.across].scale(1, .7), handle.turn);
|
|
1488
2741
|
}
|
|
1489
2742
|
else if (kind === 'scaleAll')
|
|
1490
2743
|
r.drawBox(center, tip * 1.4, lit ? color : WHITE);
|
|
@@ -1494,7 +2747,8 @@ function editor3DDraw()
|
|
|
1494
2747
|
r.drawLine(points[0], points[1], width, color);
|
|
1495
2748
|
const turn = axis === 'x' ? vec3(0, 0, -PI / 2) : axis === 'z' ? vec3(PI / 2, 0, 0) : undefined;
|
|
1496
2749
|
const at = points[1].add(handle.direction.scale(tip));
|
|
1497
|
-
|
|
2750
|
+
const cone = buildMatrix(vec3(), turn, vec3(tip * 1.4, tip * 2, tip * 1.4));
|
|
2751
|
+
r.drawMesh(editor3DConeMesh(), buildMatrix(at, handle.turn).multiply(cone), undefined, color);
|
|
1498
2752
|
}
|
|
1499
2753
|
else
|
|
1500
2754
|
{
|
|
@@ -1502,6 +2756,7 @@ function editor3DDraw()
|
|
|
1502
2756
|
r.drawBox(points[1], tip, color, editor3DRotation(editor3DSelected()[0]).scale(PI / 180));
|
|
1503
2757
|
}
|
|
1504
2758
|
}
|
|
2759
|
+
editor3DDrawGame();
|
|
1505
2760
|
}
|
|
1506
2761
|
|
|
1507
2762
|
// the editor's drawing on the 2D layer: the name of each marker, and the box being dragged out to select with
|
|
@@ -1589,12 +2844,14 @@ function editor3DPanelInit()
|
|
|
1589
2844
|
// the tools, and the snapping
|
|
1590
2845
|
const tools = row(), toolButtons = {};
|
|
1591
2846
|
for (const [tool, text, key] of [['select', 'Select', 'Q'], ['move', 'Move', 'W'], ['rotate', 'Rotate', 'E'],
|
|
1592
|
-
['scale', 'Scale', 'R']])
|
|
2847
|
+
['scale', 'Scale', 'R'], ['blocks', 'Blocks', 'B'], ['terrain', 'Terrain', 'T']])
|
|
1593
2848
|
toolButtons[tool] = button(tools, text, ()=>
|
|
1594
2849
|
{
|
|
1595
|
-
|
|
1596
|
-
tool === 'select' && (editor3DBrush = undefined);
|
|
2850
|
+
editor3DPickTool(tool);
|
|
2851
|
+
(tool === 'select' || tool === 'blocks' || tool === 'terrain') && (editor3DBrush = undefined);
|
|
1597
2852
|
}, key);
|
|
2853
|
+
// the game's own tools, buttons and controls, under the editor's tools
|
|
2854
|
+
const game = editorElement('div', panel);
|
|
1598
2855
|
const snap = editorElement('div', panel, box);
|
|
1599
2856
|
const grid = check(snap, 'Grid snap', (on)=> editor3DGrid = on, 'G, and Ctrl flips it for a drag');
|
|
1600
2857
|
const steps = row(snap);
|
|
@@ -1603,12 +2860,41 @@ function editor3DPanelInit()
|
|
|
1603
2860
|
const scaleStep = choice(steps, 'Size', [1, .5, .25], (v)=> editor3DScaleStep = v);
|
|
1604
2861
|
const ground = check(snap, 'Ground snap', (on)=> editor3DGroundSnap = on,
|
|
1605
2862
|
'A drag of an object slides it along what is under the mouse');
|
|
2863
|
+
const ownAxes = check(snap, 'Own axes', (on)=> editor3DLocalAxes = on,
|
|
2864
|
+
'L: the Move handles go along the object\'s own axes, as it is turned, not the world\'s');
|
|
1606
2865
|
|
|
1607
2866
|
// the types, a click picks one up to place, made again when types are added
|
|
1608
2867
|
editorElement('div', panel, 'color:#aaa;margin-top:4px', 'Place');
|
|
1609
2868
|
const types = row();
|
|
2869
|
+
const prefabNote = editorElement('div', panel, 'color:#f86'); // why a prefab was not loaded
|
|
1610
2870
|
const properties = editorElement('div', panel, box);
|
|
1611
2871
|
|
|
2872
|
+
// prefabs: the selection made one, or the selected instance's own
|
|
2873
|
+
const prefabBox = editorElement('div', panel, box);
|
|
2874
|
+
|
|
2875
|
+
// the prefab that is open, and the way back
|
|
2876
|
+
const prefabOpen = editorElement('div', panel, 'padding:4px;margin:4px 0;background:#235;border-radius:3px;' +
|
|
2877
|
+
'display:flex;gap:6px;align-items:center');
|
|
2878
|
+
panel.insertBefore(prefabOpen, panel.firstChild);
|
|
2879
|
+
const prefabName = editorElement('span', prefabOpen, 'flex:1');
|
|
2880
|
+
button(prefabOpen, 'Back', ()=> editor3DPrefabBack(),
|
|
2881
|
+
'Backspace with nothing selected: back to where the prefab was opened from, every instance of it follows');
|
|
2882
|
+
|
|
2883
|
+
// the Blocks tool's box: a map to add, or the types to paint with
|
|
2884
|
+
const blocks = editorElement('div', panel, box);
|
|
2885
|
+
|
|
2886
|
+
// the Terrain tool's box: a terrain to add, or the brush to sculpt with
|
|
2887
|
+
const terrainBox = editorElement('div', panel, box);
|
|
2888
|
+
|
|
2889
|
+
// the scene the level sets: its sky, sun, fog and shadows, or none, the game's own
|
|
2890
|
+
const scene = editorElement('div', panel, box);
|
|
2891
|
+
const sceneOn = check(scene, 'Level sets the scene', (on)=>
|
|
2892
|
+
{
|
|
2893
|
+
editor3DChangeScene(()=> on ? editor3DSceneFromView() : undefined);
|
|
2894
|
+
editor3DStrokeEnd();
|
|
2895
|
+
}, 'The sky, sun, fog and shadows are saved in the level; off leaves them to the game');
|
|
2896
|
+
const sceneRows = editorElement('div', scene);
|
|
2897
|
+
|
|
1612
2898
|
const file = row();
|
|
1613
2899
|
// Save says so for a moment when it saved, Save As shows where a page can write files
|
|
1614
2900
|
const saved = (b, label)=> (result)=> result && (b.textContent = 'Saved', setTimeout(()=> b.textContent = label, 1e3));
|
|
@@ -1624,14 +2910,16 @@ function editor3DPanelInit()
|
|
|
1624
2910
|
|
|
1625
2911
|
const storage = editorElement('div', panel, 'color:#f86;margin-top:4px',
|
|
1626
2912
|
'Autosave failed, storage is full: Save to a file');
|
|
2913
|
+
const prefabUnsaved = editorElement('div', panel, 'color:#fc6;margin-top:4px');
|
|
1627
2914
|
const hint = editorElement('div', panel, 'color:#8ab;margin-top:4px');
|
|
1628
2915
|
const help = editorElement('div', panel, 'color:#aaa;margin-top:4px;border-top:1px solid #444;padding-top:4px');
|
|
1629
2916
|
for (const line of editor3DHelpLines)
|
|
1630
2917
|
editorElement('div', help, 'margin:2px 0', line);
|
|
2918
|
+
const gameHelp = editorElement('div', help);
|
|
1631
2919
|
button(help, 'Close', ()=> editor3DHelp = false, '?');
|
|
1632
2920
|
|
|
1633
|
-
editor3DPanelParts = {restart, pending, toolButtons, grid, moveStep, rotateStep, scaleStep, ground, types,
|
|
1634
|
-
properties, playFrom, storage, hint, help, typeNames: ''};
|
|
2921
|
+
editor3DPanelParts = {game, gameHelp, restart, pending, toolButtons, grid, moveStep, rotateStep, scaleStep, ground, ownAxes, types, prefabBox, prefabOpen, prefabName, prefabUnsaved, prefabNote,
|
|
2922
|
+
properties, blocks, terrainBox, sceneOn, sceneRows, playFrom, storage, hint, help, typeNames: ''};
|
|
1635
2923
|
}
|
|
1636
2924
|
|
|
1637
2925
|
// show the panel as the editor is now
|
|
@@ -1645,35 +2933,421 @@ function editor3DPanelUpdate()
|
|
|
1645
2933
|
editor3DPanel || editor3DPanelInit();
|
|
1646
2934
|
editor3DPanel.style.display = '';
|
|
1647
2935
|
const p = editor3DPanelParts, lit = '2px solid #4af';
|
|
1648
|
-
p.
|
|
2936
|
+
editorGamePanel(p.game);
|
|
2937
|
+
editorGameHelp(p.gameHelp);
|
|
2938
|
+
p.restart.style.display = editorHas('onRestart') ? '' : 'none';
|
|
1649
2939
|
p.pending.style.display = editor3DRecords.get(editor3DLevel)?.pending ? '' : 'none';
|
|
1650
2940
|
for (const tool in p.toolButtons)
|
|
1651
2941
|
p.toolButtons[tool].style.outline = tool === editor3DTool ? lit : '';
|
|
1652
2942
|
p.grid.checked = editor3DGrid;
|
|
1653
2943
|
p.ground.checked = editor3DGroundSnap;
|
|
2944
|
+
p.ownAxes.checked = editor3DLocalAxes;
|
|
1654
2945
|
p.moveStep.value = editor3DMoveStep + '';
|
|
1655
2946
|
p.rotateStep.value = editor3DRotateStep + '';
|
|
1656
2947
|
p.scaleStep.value = editor3DScaleStep + '';
|
|
1657
2948
|
p.playFrom.checked = editor3DPlayFromMouse;
|
|
1658
|
-
p.playFrom.parentElement.style.display =
|
|
2949
|
+
p.playFrom.parentElement.style.display = editorHas('onPlayFrom') ? 'flex' : 'none';
|
|
1659
2950
|
p.storage.style.display = editor3DSaveFailed ? '' : 'none';
|
|
1660
2951
|
p.hint.textContent = editor3DHint();
|
|
1661
2952
|
p.help.style.display = editor3DHelp ? '' : 'none';
|
|
1662
2953
|
|
|
1663
2954
|
// a button for each type, made again when the types changed
|
|
1664
|
-
const names =
|
|
2955
|
+
const names = editor3DPlaceNames(), plain = names.filter((name)=> !level3DPrefabs.has(name));
|
|
2956
|
+
const prefabs = names.slice(plain.length);
|
|
2957
|
+
const typeNames = plain.join('\n') + '\n\n' + prefabs.join('\n');
|
|
1665
2958
|
if (p.typeNames !== typeNames)
|
|
1666
2959
|
{
|
|
1667
2960
|
p.typeNames = typeNames;
|
|
1668
|
-
p.
|
|
2961
|
+
p.typeButtons = names.map((name)=>
|
|
1669
2962
|
{
|
|
1670
2963
|
const b = editorElement('button', undefined, 'padding:3px 6px;cursor:pointer', name);
|
|
1671
2964
|
b.onclick = ()=> { editor3DBrush = editor3DBrush === name ? undefined : name; b.blur(); };
|
|
1672
2965
|
return b;
|
|
1673
|
-
})
|
|
2966
|
+
});
|
|
2967
|
+
const label = editorElement('span', undefined, 'color:#aaa;width:100%', 'Prefabs');
|
|
2968
|
+
const load = editorElement('button', undefined, 'padding:3px 6px;cursor:pointer', 'Load prefab…');
|
|
2969
|
+
load.title = 'Add a prefab from a file the editor saved, by its file name, to place in this level';
|
|
2970
|
+
load.onclick = ()=> { editor3DPrefabPickFile(); load.blur(); };
|
|
2971
|
+
p.types.replaceChildren(...p.typeButtons.slice(0, plain.length), label, ...p.typeButtons.slice(plain.length), load);
|
|
1674
2972
|
}
|
|
1675
|
-
|
|
2973
|
+
p.prefabNote.style.display = editor3DPrefabMessage && !editor3DSelection.size ? '' : 'none';
|
|
2974
|
+
p.prefabNote.textContent = editor3DPrefabMessage;
|
|
2975
|
+
names.forEach((name, i)=> p.typeButtons[i].style.outline = name === editor3DBrush ? lit : '');
|
|
1676
2976
|
editor3DPropertiesUpdate(p.properties);
|
|
2977
|
+
editor3DPrefabBoxUpdate(p.prefabBox);
|
|
2978
|
+
p.prefabOpen.style.display = editor3DPrefabStack.length ? 'flex' : 'none';
|
|
2979
|
+
p.prefabName.textContent = 'Editing prefab ' + editor3DPrefabStack.map((frame)=> frame.name).join(' > ');
|
|
2980
|
+
p.prefabUnsaved.style.display = editor3DPrefabDirty.size ? '' : 'none';
|
|
2981
|
+
p.prefabUnsaved.textContent = 'Edited and not in its file: ' + [...editor3DPrefabDirty].join(', ') +
|
|
2982
|
+
'. Open it with Enter and Save';
|
|
2983
|
+
editor3DBlocksUpdate(p.blocks);
|
|
2984
|
+
editor3DTerrainBoxUpdate(p.terrainBox);
|
|
2985
|
+
p.sceneOn.checked = !!editor3DScene();
|
|
2986
|
+
editor3DSceneUpdate(p.sceneRows);
|
|
2987
|
+
}
|
|
2988
|
+
|
|
2989
|
+
// the prefab box, shown with a selection: a name and a button to make it a prefab, and for one selected instance
|
|
2990
|
+
// its prefab's name, Unpack, Export, and for a prefab of the level's own whether it is attached
|
|
2991
|
+
function editor3DPrefabBoxUpdate(box)
|
|
2992
|
+
{
|
|
2993
|
+
const selected = editor3DSelected(), made = selected.length === 1 && editor3DInstances.get(selected[0].id);
|
|
2994
|
+
const instance = made instanceof Prefab3D ? made : undefined, prefab = instance && level3DPrefabs.get(instance.prefabName);
|
|
2995
|
+
const key = !selected.length ? '' : [selected.length, instance?.prefabName, prefab?.attached, prefab?.fromLevel,
|
|
2996
|
+
editor3DPrefabMessage, editor3DPrefabStack.length] + '';
|
|
2997
|
+
box.style.display = key ? '' : 'none';
|
|
2998
|
+
if (box.dataset.key === key || box.contains(document.activeElement)) return;
|
|
2999
|
+
box.dataset.key = key;
|
|
3000
|
+
box.replaceChildren();
|
|
3001
|
+
if (!key) return;
|
|
3002
|
+
const field = 'background:#222;color:#eee', press = 'padding:2px 6px;cursor:pointer';
|
|
3003
|
+
const row = ()=> editorElement('div', box, 'display:flex;gap:4px;align-items:center;margin:2px 0;flex-wrap:wrap');
|
|
3004
|
+
if (prefab)
|
|
3005
|
+
{
|
|
3006
|
+
const line = row();
|
|
3007
|
+
editorElement('span', line, 'flex:1;color:#aaa', 'Prefab ' + instance.prefabName);
|
|
3008
|
+
const open = editorElement('button', line, press, 'Edit');
|
|
3009
|
+
open.title = 'Enter: open the prefab alone to edit it, every instance of it follows';
|
|
3010
|
+
open.onclick = ()=> { editor3DPrefabEnter(); open.blur(); };
|
|
3011
|
+
const unpack = editorElement('button', line, press, 'Unpack');
|
|
3012
|
+
unpack.title = 'Ctrl+Shift+G: turn this instance into the objects it is made of';
|
|
3013
|
+
unpack.onclick = ()=> { editor3DUnpack(); unpack.blur(); };
|
|
3014
|
+
const save = editorElement('button', line, press, 'Export');
|
|
3015
|
+
save.title = 'Save the prefab as a file of its own, to load with level3DLoadPrefab';
|
|
3016
|
+
save.onclick = ()=>
|
|
3017
|
+
{
|
|
3018
|
+
saveText(editor3DPrefabJSON(instance.prefabName), instance.prefabName + '.json', 'application/json');
|
|
3019
|
+
save.blur();
|
|
3020
|
+
};
|
|
3021
|
+
if (prefab.fromLevel && !editor3DPrefabStack.length)
|
|
3022
|
+
{
|
|
3023
|
+
const label = editorElement('label', box, 'display:flex;gap:6px;align-items:center;margin:2px 0');
|
|
3024
|
+
label.title = 'Its parts are attached to each instance and move with it as one body, with no ' +
|
|
3025
|
+
'collision of their own; off, each part is an object of its own and solid';
|
|
3026
|
+
const attached = editorElement('input', label);
|
|
3027
|
+
attached.type = 'checkbox';
|
|
3028
|
+
attached.checked = prefab.attached;
|
|
3029
|
+
attached.onchange = ()=> { editor3DPrefabSetAttached(instance.prefabName, attached.checked); attached.blur(); };
|
|
3030
|
+
editorElement('span', label, '', 'Attached, one body');
|
|
3031
|
+
}
|
|
3032
|
+
}
|
|
3033
|
+
const line = row();
|
|
3034
|
+
const name = editorElement('input', line, field + ';flex:1;min-width:60px');
|
|
3035
|
+
name.placeholder = 'prefab name';
|
|
3036
|
+
const make = editorElement('button', line, press, 'Make prefab');
|
|
3037
|
+
make.title = 'Ctrl+G: the selection becomes one thing to place many times, kept in the level';
|
|
3038
|
+
make.onclick = ()=> { editor3DMakePrefab(name.value); make.blur(); };
|
|
3039
|
+
name.onkeydown = (e)=> { e.key === 'Enter' && (editor3DMakePrefab(name.value), name.blur()); };
|
|
3040
|
+
editor3DPrefabMessage && editorElement('div', box, 'color:#f86', editor3DPrefabMessage);
|
|
3041
|
+
}
|
|
3042
|
+
|
|
3043
|
+
// a level's color as a color input takes it, six digits and no alpha, white for one it can not show
|
|
3044
|
+
function editor3DInputColor(hex) { return /^#[0-9a-f]{6}/i.test(hex) ? hex.slice(0, 7) : '#ffffff'; }
|
|
3045
|
+
|
|
3046
|
+
// the terrain box, shown with the Terrain tool: without a terrain, its size, cells and height and a button to add
|
|
3047
|
+
// it; with one, the brush, its size and strength or the color it paints, the terrain's color, and a button to
|
|
3048
|
+
// take it away
|
|
3049
|
+
function editor3DTerrainBoxUpdate(box)
|
|
3050
|
+
{
|
|
3051
|
+
const map = editor3DTerrainMap(), shown = editor3DTool === 'terrain';
|
|
3052
|
+
box.style.display = shown ? '' : 'none';
|
|
3053
|
+
if (!shown) return;
|
|
3054
|
+
const terrain = editor3DLevelPart('terrain'), brush = editor3DTerrainBrush;
|
|
3055
|
+
const key = map ? ['terrain', terrain.color, brush.mode] + '' : 'none';
|
|
3056
|
+
if (box.dataset.key === key || box.contains(document.activeElement)) return;
|
|
3057
|
+
box.dataset.key = key;
|
|
3058
|
+
box.replaceChildren();
|
|
3059
|
+
const field = 'background:#222;color:#eee';
|
|
3060
|
+
const line = (name, title='')=>
|
|
3061
|
+
{
|
|
3062
|
+
const row = editorElement('label', box, 'display:flex;gap:4px;align-items:center;margin:2px 0');
|
|
3063
|
+
editorElement('span', row, 'flex:1', name);
|
|
3064
|
+
row.title = title;
|
|
3065
|
+
return row;
|
|
3066
|
+
};
|
|
3067
|
+
const number = (name, title, value, most)=>
|
|
3068
|
+
{
|
|
3069
|
+
const input = editorElement('input', line(name, title), field + ';width:60px');
|
|
3070
|
+
input.type = 'number';
|
|
3071
|
+
input.min = '1', input.max = most + '', input.step = '1';
|
|
3072
|
+
input.value = value + '';
|
|
3073
|
+
return input;
|
|
3074
|
+
};
|
|
3075
|
+
if (!map)
|
|
3076
|
+
{
|
|
3077
|
+
const size = number('Size', 'How far across it is in the world, each way', 64, 1024);
|
|
3078
|
+
const cellsRow = line('Cells', 'How many cells a side, more is finer and slower to sculpt');
|
|
3079
|
+
const cells = editorElement('select', cellsRow, field + ';width:68px');
|
|
3080
|
+
for (const n of [32, 64, 128])
|
|
3081
|
+
editorElement('option', cells, '', n + '').value = n + '';
|
|
3082
|
+
cells.value = '64';
|
|
3083
|
+
const height = number('Height', 'How tall its highest ground can be', 16, 1024);
|
|
3084
|
+
const add = editorElement('button', box, 'width:100%;padding:3px;cursor:pointer', 'Add terrain');
|
|
3085
|
+
add.title = 'A flat terrain, centered, its surface on the ground, to raise and dig';
|
|
3086
|
+
add.onclick = ()=>
|
|
3087
|
+
{
|
|
3088
|
+
const read = (input, fallback)=> clamp(parseFloat(input.value) || fallback, 1, 1024);
|
|
3089
|
+
editor3DTerrainAdd(vec2(read(size, 64)), parseInt(cells.value), read(height, 16));
|
|
3090
|
+
add.blur();
|
|
3091
|
+
};
|
|
3092
|
+
return;
|
|
3093
|
+
}
|
|
3094
|
+
const slider = (name, title, min, max, step, value, set)=>
|
|
3095
|
+
{
|
|
3096
|
+
const input = editorElement('input', line(name, title), 'width:120px');
|
|
3097
|
+
input.type = 'range';
|
|
3098
|
+
input.min = min, input.max = max, input.step = step;
|
|
3099
|
+
input.value = value + '';
|
|
3100
|
+
input.oninput = ()=> set(parseFloat(input.value));
|
|
3101
|
+
input.onchange = ()=> input.blur();
|
|
3102
|
+
};
|
|
3103
|
+
const brushes = editorElement('select', line('Brush', 'X: sculpt raises and lowers the ground, flatten brings ' +
|
|
3104
|
+
'it to the height pressed, paint colors it'), field + ';width:120px');
|
|
3105
|
+
for (const name of editor3DTerrainBrushes)
|
|
3106
|
+
editorElement('option', brushes, '', name[0].toUpperCase() + name.slice(1)).value = name;
|
|
3107
|
+
brushes.value = brush.mode;
|
|
3108
|
+
brushes.onchange = ()=> { brush.mode = brushes.value; brushes.blur(); };
|
|
3109
|
+
slider('Brush size', 'How far across the brush is', 1, 32, .5, brush.size, (v)=> brush.size = v);
|
|
3110
|
+
if (brush.mode === 'paint')
|
|
3111
|
+
{
|
|
3112
|
+
const paint = editorElement('input', line('Paint', 'The color the brush paints'), field + ';width:60px');
|
|
3113
|
+
paint.type = 'color';
|
|
3114
|
+
paint.value = editor3DInputColor(brush.color);
|
|
3115
|
+
paint.oninput = ()=> brush.color = paint.value;
|
|
3116
|
+
paint.onchange = ()=> { brush.color = paint.value; paint.blur(); };
|
|
3117
|
+
}
|
|
3118
|
+
else
|
|
3119
|
+
slider('Strength', 'How fast the brush works', .05, 1, .05, brush.strength, (v)=> brush.strength = v);
|
|
3120
|
+
const color = editorElement('input', line('Color', 'The color of the ground that is not painted'),
|
|
3121
|
+
field + ';width:60px');
|
|
3122
|
+
color.type = 'color';
|
|
3123
|
+
color.value = editor3DInputColor(terrain.color);
|
|
3124
|
+
color.oninput = ()=> editor3DChangePart('terrain', (t)=> ({...t, color: color.value}));
|
|
3125
|
+
color.onchange = ()=> { color.oninput(); editor3DStrokeEnd(); color.blur(); };
|
|
3126
|
+
const remove = editorElement('button', box, 'margin-top:4px;padding:2px 6px;cursor:pointer', 'Remove terrain');
|
|
3127
|
+
remove.title = 'Take the terrain out of the level, Undo brings it back';
|
|
3128
|
+
remove.onclick = ()=> { editor3DTerrainRemove(); remove.blur(); };
|
|
3129
|
+
}
|
|
3130
|
+
|
|
3131
|
+
// the blocks box, shown with the Blocks tool: without a map, its size and a button to add it; with one, a tile
|
|
3132
|
+
// for each block type to paint with, and a button to take the map away; made again when what it shows changes
|
|
3133
|
+
function editor3DBlocksUpdate(box)
|
|
3134
|
+
{
|
|
3135
|
+
const map = editor3DVoxelMap(), shown = editor3DTool === 'blocks';
|
|
3136
|
+
box.style.display = shown ? '' : 'none';
|
|
3137
|
+
if (!shown) return;
|
|
3138
|
+
const first = map?.tileInfo, texture = first?.textureInfo;
|
|
3139
|
+
const key = map ? ['map', editor3DBlockType, texture?.size.x, map.blockTypes.length, editor3DBlockBox,
|
|
3140
|
+
map.mapSize].join() : 'none';
|
|
3141
|
+
if (box.dataset.key === key || box.contains(document.activeElement)) return;
|
|
3142
|
+
box.dataset.key = key;
|
|
3143
|
+
box.replaceChildren();
|
|
3144
|
+
const field = 'background:#222;color:#eee;width:46px';
|
|
3145
|
+
if (!map)
|
|
3146
|
+
{
|
|
3147
|
+
const row = editorElement('label', box, 'display:flex;gap:4px;align-items:center;margin:2px 0');
|
|
3148
|
+
editorElement('span', row, 'flex:1', 'Map size');
|
|
3149
|
+
const inputs = [32, 16, 32].map((n)=>
|
|
3150
|
+
{
|
|
3151
|
+
const input = editorElement('input', row, field);
|
|
3152
|
+
input.type = 'number';
|
|
3153
|
+
input.min = '1', input.max = '256', input.step = '1';
|
|
3154
|
+
input.value = n + '';
|
|
3155
|
+
return input;
|
|
3156
|
+
});
|
|
3157
|
+
const add = editorElement('button', box, 'width:100%;padding:3px;cursor:pointer', 'Add block map');
|
|
3158
|
+
add.title = 'An empty map of blocks, centered, its bottom on the ground';
|
|
3159
|
+
add.onclick = ()=>
|
|
3160
|
+
{
|
|
3161
|
+
const [x, y, z] = inputs.map((i, k)=> clamp(floor(parseFloat(i.value)) || [32, 16, 32][k], 1, 256));
|
|
3162
|
+
editor3DVoxelAdd(vec3(x, y, z));
|
|
3163
|
+
add.blur();
|
|
3164
|
+
};
|
|
3165
|
+
return;
|
|
3166
|
+
}
|
|
3167
|
+
|
|
3168
|
+
// the tiles of the sheet, a type shows the tile of its side; a click picks the type
|
|
3169
|
+
const image = texture?.image, padding = first.padding || 0;
|
|
3170
|
+
const cell = first.size.add(vec2(padding * 2));
|
|
3171
|
+
const count = texture && cell.x && cell.y ? floor(texture.size.x / cell.x) * floor(texture.size.y / cell.y) : 0;
|
|
3172
|
+
const types = [];
|
|
3173
|
+
for (let type = 1; type < 256 && types.length < 96; ++type)
|
|
3174
|
+
(type < count || map.blockTypes[type]) && types.push(type);
|
|
3175
|
+
types.includes(editor3DBlockType) || types.push(editor3DBlockType);
|
|
3176
|
+
const grid = editorElement('div', box, 'display:flex;flex-wrap:wrap;gap:2px');
|
|
3177
|
+
for (const type of types)
|
|
3178
|
+
{
|
|
3179
|
+
const canvas = editorElement('canvas', grid, 'width:26px;height:26px;cursor:pointer;background:#444;' +
|
|
3180
|
+
'image-rendering:pixelated;outline:' + (type === editor3DBlockType ? '2px solid #4af' : 'none'));
|
|
3181
|
+
canvas.width = canvas.height = 26;
|
|
3182
|
+
canvas.title = 'Block ' + type;
|
|
3183
|
+
canvas.onclick = ()=> editor3DBlockType = type;
|
|
3184
|
+
// the tile of its side; read without map.blockType, which would make every type one the map has
|
|
3185
|
+
const index = map.blockTypes[type]?.faces[0] ?? type;
|
|
3186
|
+
const tile = first.columns ? first.frame(index) : first.index(index);
|
|
3187
|
+
const context = canvas.getContext('2d');
|
|
3188
|
+
context.imageSmoothingEnabled = false;
|
|
3189
|
+
try
|
|
3190
|
+
{
|
|
3191
|
+
image && context.drawImage(image, tile.pos.x, tile.pos.y, tile.size.x, tile.size.y, 0, 0, 26, 26);
|
|
3192
|
+
// a tile with nothing on it is left out, unless it is the type picked, one in use or one the game set up
|
|
3193
|
+
const pixels = context.getImageData(0, 0, 26, 26).data;
|
|
3194
|
+
let blank = !!image;
|
|
3195
|
+
for (let i = 3; blank && i < pixels.length; i += 4)
|
|
3196
|
+
blank = !pixels[i];
|
|
3197
|
+
blank && type !== editor3DBlockType && !map.blockTypes[type] && canvas.remove();
|
|
3198
|
+
}
|
|
3199
|
+
catch {} // an image that can not be drawn or read, the title still says the type
|
|
3200
|
+
}
|
|
3201
|
+
// box fill: a drag fills the rectangle dragged, this many layers tall
|
|
3202
|
+
const boxRow = editorElement('label', box, 'display:flex;gap:6px;align-items:center;margin:4px 0 2px');
|
|
3203
|
+
boxRow.title = 'X: a drag fills the whole rectangle from the press to the mouse, Shift clears it, Ctrl repaints';
|
|
3204
|
+
const boxOn = editorElement('input', boxRow);
|
|
3205
|
+
boxOn.type = 'checkbox';
|
|
3206
|
+
boxOn.checked = editor3DBlockBox;
|
|
3207
|
+
boxOn.onchange = ()=> { editor3DBlockBox = boxOn.checked; boxOn.blur(); };
|
|
3208
|
+
editorElement('span', boxRow, 'flex:1', 'Box fill, height');
|
|
3209
|
+
const boxHeight = editorElement('input', boxRow, field);
|
|
3210
|
+
boxHeight.type = 'number';
|
|
3211
|
+
boxHeight.min = '1', boxHeight.max = '256', boxHeight.step = '1';
|
|
3212
|
+
boxHeight.value = editor3DBlockBoxHeight + '';
|
|
3213
|
+
boxHeight.onchange = ()=>
|
|
3214
|
+
{
|
|
3215
|
+
editor3DBlockBoxHeight = clamp(floor(parseFloat(boxHeight.value)) || 1, 1, 256);
|
|
3216
|
+
boxHeight.value = editor3DBlockBoxHeight + '';
|
|
3217
|
+
boxHeight.blur();
|
|
3218
|
+
};
|
|
3219
|
+
|
|
3220
|
+
// the map's size, to change
|
|
3221
|
+
const sizeRow = editorElement('label', box, 'display:flex;gap:4px;align-items:center;margin:2px 0');
|
|
3222
|
+
editorElement('span', sizeRow, 'flex:1', 'Map size');
|
|
3223
|
+
const axes = ['x', 'y', 'z'], sizes = axes.map((axis)=>
|
|
3224
|
+
{
|
|
3225
|
+
const input = editorElement('input', sizeRow, field);
|
|
3226
|
+
input.type = 'number';
|
|
3227
|
+
input.min = '1', input.max = '256', input.step = '1';
|
|
3228
|
+
input.value = map.mapSize[axis] + '';
|
|
3229
|
+
return input;
|
|
3230
|
+
});
|
|
3231
|
+
const resize = editorElement('button', box, 'padding:2px 6px;cursor:pointer;margin-right:4px', 'Resize');
|
|
3232
|
+
resize.title = 'Change the map\'s size, its blocks stay where they are; Undo brings back what was cut off';
|
|
3233
|
+
resize.onclick = ()=>
|
|
3234
|
+
{
|
|
3235
|
+
// a field left empty keeps the size it has
|
|
3236
|
+
const [x, y, z] = sizes.map((i, k)=> clamp(floor(parseFloat(i.value)) || map.mapSize[axes[k]], 1, 256));
|
|
3237
|
+
editor3DVoxelResize(vec3(x, y, z));
|
|
3238
|
+
resize.blur();
|
|
3239
|
+
};
|
|
3240
|
+
|
|
3241
|
+
const remove = editorElement('button', box, 'margin-top:4px;padding:2px 6px;cursor:pointer', 'Remove map');
|
|
3242
|
+
remove.title = 'Take the block map out of the level, Undo brings it back';
|
|
3243
|
+
remove.onclick = ()=> { editor3DVoxelRemove(); remove.blur(); };
|
|
3244
|
+
}
|
|
3245
|
+
|
|
3246
|
+
// the scene box: an input for each setting of the level's scene block, made again when the block changes, but not
|
|
3247
|
+
// while one of its inputs is being used; a slider or a color changes the scene as it moves, and is one undo
|
|
3248
|
+
function editor3DSceneUpdate(box)
|
|
3249
|
+
{
|
|
3250
|
+
const scene = editor3DScene(), key = scene ? JSON.stringify(scene) : '';
|
|
3251
|
+
box.style.display = scene ? '' : 'none';
|
|
3252
|
+
if (box.dataset.key === key || box.contains(document.activeElement)) return;
|
|
3253
|
+
box.dataset.key = key;
|
|
3254
|
+
box.replaceChildren();
|
|
3255
|
+
if (!scene) return;
|
|
3256
|
+
|
|
3257
|
+
// a setting the block leaves out shows as it is on screen
|
|
3258
|
+
const view = editor3DSceneFromView(), value = (name)=> scene[name] ?? view[name];
|
|
3259
|
+
// an input changes the scene as it is when it is used, not as it was when the box was made: the box is not
|
|
3260
|
+
// made again while it has the focus, so a field edited right after another must not put the other one back;
|
|
3261
|
+
// set is given the setting's value now, or what shows for one the block leaves out
|
|
3262
|
+
const change = (name, set)=> editor3DChangeScene((s={})=>
|
|
3263
|
+
({...s, [name]: typeof set === 'function' ? set(s[name] ?? view[name]) : set}));
|
|
3264
|
+
const field = 'background:#222;color:#eee';
|
|
3265
|
+
const line = (name, title='')=>
|
|
3266
|
+
{
|
|
3267
|
+
const row = editorElement('label', box, 'display:flex;gap:4px;align-items:center;margin:2px 0');
|
|
3268
|
+
editorElement('span', row, 'flex:1', name);
|
|
3269
|
+
row.title = title;
|
|
3270
|
+
return row;
|
|
3271
|
+
};
|
|
3272
|
+
// an input that changes the scene as it moves and ends the edit when let go
|
|
3273
|
+
const live = (input, read)=>
|
|
3274
|
+
{
|
|
3275
|
+
input.oninput = ()=> read();
|
|
3276
|
+
input.onchange = ()=> { read(); editor3DStrokeEnd(); input.blur(); };
|
|
3277
|
+
return input;
|
|
3278
|
+
};
|
|
3279
|
+
const slider = (name, title, min, max, step, v, set)=>
|
|
3280
|
+
{
|
|
3281
|
+
const input = editorElement('input', line(name, title), 'width:120px');
|
|
3282
|
+
input.type = 'range';
|
|
3283
|
+
input.min = min, input.max = max, input.step = step;
|
|
3284
|
+
input.value = v + '';
|
|
3285
|
+
return live(input, ()=> set(parseFloat(input.value)));
|
|
3286
|
+
};
|
|
3287
|
+
const color = (name, title, hex, set)=>
|
|
3288
|
+
{
|
|
3289
|
+
const input = editorElement('input', line(name, title), field + ';width:60px');
|
|
3290
|
+
input.type = 'color';
|
|
3291
|
+
input.value = editor3DInputColor(hex);
|
|
3292
|
+
return live(input, ()=> set(input.value));
|
|
3293
|
+
};
|
|
3294
|
+
const number = (row, v, set)=>
|
|
3295
|
+
{
|
|
3296
|
+
const input = editorElement('input', row, field + ';width:56px');
|
|
3297
|
+
input.type = 'number';
|
|
3298
|
+
input.step = 'any';
|
|
3299
|
+
input.min = '0';
|
|
3300
|
+
input.value = v + '';
|
|
3301
|
+
input.onchange = ()=>
|
|
3302
|
+
{
|
|
3303
|
+
const n = parseFloat(input.value);
|
|
3304
|
+
isNumber(n) && n >= 0 ? set(v = n) : input.value = v + ''; // one not taken shows what it has again
|
|
3305
|
+
editor3DStrokeEnd();
|
|
3306
|
+
input.blur();
|
|
3307
|
+
};
|
|
3308
|
+
return input;
|
|
3309
|
+
};
|
|
3310
|
+
const toggle = (name, title, on, set)=>
|
|
3311
|
+
{
|
|
3312
|
+
const input = editorElement('input', line(name, title));
|
|
3313
|
+
input.type = 'checkbox';
|
|
3314
|
+
input.checked = on;
|
|
3315
|
+
input.onchange = ()=> { set(input.checked); editor3DStrokeEnd(); input.blur(); };
|
|
3316
|
+
};
|
|
3317
|
+
|
|
3318
|
+
// the sky: three colors and how much of them lights the scene, or no sky
|
|
3319
|
+
const sky = isArray(scene.sky) && scene.sky.length === 3 ? scene.sky : undefined;
|
|
3320
|
+
toggle('Sky', 'A sky dome, and light from it; off leaves the sky to the game', !!sky, (on)=>
|
|
3321
|
+
editor3DChangeScene((s={})=>
|
|
3322
|
+
{
|
|
3323
|
+
const {sky, ambient, ...rest} = s;
|
|
3324
|
+
return on ? {sky: ['#3d9be9', '#ccebff', '#ccebff'], ambient: .5, ...rest} : rest;
|
|
3325
|
+
}));
|
|
3326
|
+
if (sky)
|
|
3327
|
+
{
|
|
3328
|
+
['Sky top', 'Sky horizon', 'Sky bottom'].forEach((name, i)=> color(name, '', sky[i], (hex)=>
|
|
3329
|
+
change('sky', (now)=> (isArray(now) && now.length === 3 ? now : sky).map((c, k)=> k === i ? hex : c))));
|
|
3330
|
+
slider('Ambient', 'How much of the sky colors lights the scene', 0, 1, .05,
|
|
3331
|
+
isNumber(scene.ambient) ? scene.ambient : .5, (v)=> change('ambient', v));
|
|
3332
|
+
}
|
|
3333
|
+
|
|
3334
|
+
// the sun, as two angles
|
|
3335
|
+
const [around, height] = editor3DSunAngles(value('sunDirection'));
|
|
3336
|
+
slider('Sun around', 'Where the sun is around the scene, in degrees', 0, 359, 1, around, (v)=>
|
|
3337
|
+
change('sunDirection', (now)=> editor3DSunDirection(v, editor3DSunAngles(now)[1])));
|
|
3338
|
+
slider('Sun height', 'How high the sun is over the horizon, in degrees', 5, 89, 1, height, (v)=>
|
|
3339
|
+
change('sunDirection', (now)=> editor3DSunDirection(editor3DSunAngles(now)[0], v)));
|
|
3340
|
+
color('Sun color', '', value('sunColor'), (hex)=> change('sunColor', hex));
|
|
3341
|
+
|
|
3342
|
+
// the fog, by its distances, an end of 0 for none
|
|
3343
|
+
const fog = isArray(value('fog')) ? value('fog') : [0, 0], fogRow = line('Fog start, end',
|
|
3344
|
+
'Where the fog starts and where it is total, an end of 0 for no fog');
|
|
3345
|
+
const fogNow = (now)=> isArray(now) && now.length === 2 ? now : fog;
|
|
3346
|
+
number(fogRow, fog[0], (v)=> change('fog', (now)=> [v, fogNow(now)[1]]));
|
|
3347
|
+
number(fogRow, fog[1], (v)=> change('fog', (now)=> [fogNow(now)[0], v]));
|
|
3348
|
+
color('Fog color', 'The sky\'s horizon color when the level has a sky and no fog color',
|
|
3349
|
+
scene.fogColor ?? (sky ? sky[1] : view.fogColor), (hex)=> change('fogColor', hex));
|
|
3350
|
+
toggle('Shadows', 'The sun casts shadows', !!value('shadows'), (on)=> change('shadows', on));
|
|
1677
3351
|
}
|
|
1678
3352
|
|
|
1679
3353
|
// the properties box: the position, rotation and scale of the one selected object and an input for each default
|