plugins_render3dLevel.js

/**
 * LittleJS 3D Level Plugin
 * - A 3D level is a list of objects, each a type name, a position, and a rotation, scale and properties where
 *   they are not the default, kept as plain JSON that can be written by hand
 * - level3DAddType names the types a game makes them from, as objectLayersAddType does in 2D
 * - level3DLoad makes every object of a level
 * - Box, Sphere, Cylinder and Light are built in, to block out and light a level with no code
 * - The 3D level editor, in debug builds, edits the level a game loaded
 * @namespace Level3D
 */

'use strict';

///////////////////////////////////////////////////////////////////////////////

const LEVEL3D_VERSION = 1; // the format a level's littlejs3D names

// the types a level's objects are made from, by name
const level3DTypes = new Map;

// the scale each object had when it was made, the level's scale multiplies it
const level3DBaseScale = new WeakMap;

/** Add a type of object, so level3DLoad makes one wherever a level has an object of that type
 *  - The name is a string because minified builds rename classes
 *  - A class, or any function with a prototype, is made with new make(pos3D, properties); an arrow function is
 *    called as make(pos3D, properties), for what is not an object, like a player start
 *  - properties is the defaults with the object's own values over them, and each of the type's own is also set on
 *    what was made; a property the type has no default for is in properties and is not set
 *  - Give a class a constructor of its own that takes the position: one that hands every argument on to
 *    EngineObject3D would hand it the properties as its mesh
 *  - An EngineObject3D then gets the object's rotation, and its scale times the scale it was made with
 *  - Adding a name again replaces it, Box, Sphere, Cylinder and Light too
 *  @param {string} name - The type the objects have in the level
 *  @param {Function} make - A class made at each object's position, or a function called with it
 *  @param {Object} [defaults] - Properties of each one made, the level editor shows inputs for them
 *  @param {TileInfo} [tileInfo] - An icon for the level editor
 *  @memberof Level3D
 *  @example
 *  level3DAddType('Crate', Crate, {health: 3});
 *  level3DAddType('PlayerStart', (pos)=> playerStart = pos); */
function level3DAddType(name, make, defaults={}, tileInfo)
{
    ASSERT(isStringLike(name), 'object type name must be a string');
    ASSERT(typeof make === 'function', 'make must be a class or function');
    ASSERT(!!defaults && typeof defaults === 'object', 'defaults must be an object');
    level3DTypes.set(String(name), {make, defaults, tileInfo});
}

/** Add a type that is a mesh and nothing more, a static prop with no class to write, for a built mesh or a model
 *  - Each object has a color and a solid property, solid collides as the box around the mesh
 *  @param {string} name - The type the objects have in the level
 *  @param {Mesh} mesh - Shared by every object of the type
 *  @param {TileInfo} [tileInfo] - Its texture
 *  @param {Color} [color] - Its color, an object's own color property goes over it
 *  @memberof Level3D
 *  @example
 *  level3DAddMesh('Tree', treeMesh, tile(4)); */
function level3DAddMesh(name, mesh, tileInfo, color=WHITE)
{
    ASSERT(mesh instanceof Mesh, 'mesh must be a Mesh');
    ASSERT(isColor(color), 'color must be a color');
    level3DAddType(name, function(pos, properties) { return level3DMakeShape(pos, properties, mesh, tileInfo); },
        {color, solid: false}, tileInfo);
}

/** Make the objects of a level, each from the type added for its name
 *  - The level is an object: {littlejs3D: 1, objects: [{id, type, pos: [x, y, z]}, ...]}, and {} is a new one
 *  - rotation is pitch, yaw and roll in degrees, scale is a number for each axis, both left out when they are
 *    the default, and properties holds what differs from the type's defaults
 *  - A Color property is a #rrggbb or #rrggbbaa string, a Vector2 or Vector3 an array, as the default says
 *  - An object whose type was not added is skipped, with a warning in debug builds
 *  - What a file written by hand gets wrong uses the default: a value that is not of its default's type
 *  - An object its type can not make is skipped with an error in debug builds, where asserts throw, and the rest
 *    of the level is made
 *  @param {Object} level - The level, the level editor edits this same object
 *  @return {Array<any>} - What each object's type made, a function that made nothing is left out
 *  @memberof Level3D */
function level3DLoad(level)
{
    ASSERT(!!level && typeof level === 'object', 'a level is an object, {} for a new one');
    ASSERT(!(level.littlejs3D > LEVEL3D_VERSION), 'the level was made by a newer LittleJS');
    editor3DLevelLoaded(level); // debug builds: the 3D editor takes the level, its autosaved edits go in first
    const made = [];
    for (const object of isArray(level.objects) ? level.objects : [])
    {
        if (!object || typeof object !== 'object') continue;
        const result = level3DMake(object);
        editor3DObjectMade(object, result); // debug builds link it for the 3D editor
        result && made.push(result);
    }
    return made;
}

// a vec3 of an array of three numbers, as the file has them, or the fallback
function level3DVector(value, fallback)
{
    return isArray(value) && value.length === 3 && value.every((v)=> isNumber(v)) ?
        vec3(value[0], value[1], value[2]) : fallback;
}

// the defaults of an object's type, a Color or vector copied for each object, then its own properties over them,
// each read as its default is; one the file got wrong, a value not of its default's type, keeps the default, and
// one the type has no default for is kept as it is
function level3DProperties(type, object)
{
    const properties = {}, own = object.properties;
    for (const [key, value] of Object.entries(type.defaults))
        properties[key] = value?.copy ? value.copy() : value;
    for (const [key, value] of Object.entries(own && typeof own === 'object' ? own : {}))
    {
        const d = type.defaults[key];
        if (isColor(d))
            /^#([0-9a-f]{6}|[0-9a-f]{8})$/i.test(value) && (properties[key] = new Color().setHex(value));
        else if (isVector3(d))
            properties[key] = level3DVector(value, properties[key]);
        else if (isVector2(d))
            isArray(value) && value.length === 2 && value.every((v)=> isNumber(v)) &&
                (properties[key] = vec2(value[0], value[1]));
        else if (d === undefined || typeof value === typeof d)
            properties[key] = value;
    }
    return properties;
}

// make one object of a level from the type added for its name, undefined when there is no such type or it made
// nothing
function level3DMake(object)
{
    const type = level3DTypes.get(object.type);
    if (!type)
    {
        debug && console.warn(`level3DLoad: no type added for ${object.type}, skipped`);
        return;
    }
    const pos = level3DVector(object.pos, vec3()), properties = level3DProperties(type, object);
    const {make} = type, count = engineObjects.length;
    let result;
    try { result = make.prototype ? new make(pos, properties) : make(pos, properties); }
    catch (error)
    {
        // a value the type can not be made with: in debug builds, where asserts throw, the object is skipped with
        // what it made so far, so the rest of the level loads and the level editor can put the value right
        if (!debug) throw error;
        console.error(`level3DLoad: ${object.type} ${object.id} could not be made, skipped:`, error);
        for (const o of engineObjects.slice(count))
            o.destroy();
        return;
    }
    if (!result || typeof result !== 'object') return;
    if (result instanceof EngineObject3D)
    {
        level3DBaseScale.set(result, result.scale3D.copy());
        result.rotation3D = level3DVector(object.rotation, vec3()).scale(PI / 180);
        result.scale3D = result.scale3D.multiply(level3DVector(object.scale, vec3(1)));
    }

    // the type's own properties are set on it, one the type has no default for could be a field of the engine's
    for (const key in type.defaults)
        result[key] = properties[key];
    return result;
}

///////////////////////////////////////////////////////////////////////////////
// the built-in types

// a static object of a mesh, with the color, tile and solid properties the built-in types have
function level3DMakeShape(pos, properties, mesh, tileInfo, asSphere=false)
{
    const o = new EngineObject3D(pos, mesh, properties.tile >= 0 ? tile(properties.tile) : tileInfo, properties.color);
    o.collideAsSphere3D = asSphere;
    properties.solid && o.setCollision();
    return o;
}

// the cylinder all Cylinder objects share, built the first time one is made
let level3DCylinderMesh;

level3DAddType('Box', function(pos, properties) { return level3DMakeShape(pos, properties, render3D.boxMesh); },
    {color: WHITE, tile: -1, solid: true});
level3DAddType('Sphere', function(pos, properties)
    { return level3DMakeShape(pos, properties, render3D.sphereMesh, undefined, true); },
    {color: WHITE, tile: -1, solid: true});
level3DAddType('Cylinder', function(pos, properties)
    { return level3DMakeShape(pos, properties, level3DCylinderMesh ||= buildCylinder()); },
    {color: WHITE, tile: -1, solid: true});
level3DAddType('Light', function(pos, properties)
    { return new Light3D(pos, properties.radius, properties.color, properties.intensity); },
    {color: WHITE, radius: 5, intensity: 1});