// LittleJS Engine - MIT License - Copyright 2021 Frank Force // https://github.com/KilledByAPixel/LittleJS 'use strict'; /** * LittleJS - The Tiny Fast JavaScript Game Engine * MIT License - Copyright 2021 Frank Force * * Engine Features * - Object oriented system with EngineObject base class * - Automatic object lifecycle (update, physics, collision, rendering) * - Engine helper classes: Vector2, Color, Timer, RandomGenerator * - Hybrid rendering with WebGL batching and Canvas2D fallback * - Audio system with wave, mp3, or ZzFX sound effects * - Input system with keyboard, mouse, gamepad, and touch support * - Tile layer rendering and collision detection * - Particle effect system with emitters * - Medal/achievement system with local storage * - Comprehensive debug tools and visualizations * - Fixed 60 FPS timestep with configurable time scale * - Raycast and spatial query utilities * - Plugin system for extending engine functionality * - Start with engineInit() and provide your game callbacks * @namespace Engine */ /** Name of engine * @type {string} * @default * @memberof Engine */ const engineName = 'LittleJS'; /** Version of engine * @type {string} * @default * @memberof Engine */ const engineVersion = '1.27.2'; /** Frames per second to update * @type {number} * @default * @memberof Engine */ const frameRate = 60; /** How many seconds the update covers: 1/60 with the fixed time step, or with engineVariableStep the time of the * display frame it runs on, times timeScale; while paused it keeps the last update's * @type {number} * @default 1/60 * @memberof Engine */ let timeDelta = 1/frameRate; /** Array containing all engine objects * @type {Array} * @memberof Engine */ let engineObjects = []; /** Array with only objects set to collide with other objects this frame (for optimization) * @type {Array} * @memberof Engine */ let engineObjectsCollide = []; // the same objects with the static ones last, the order 2D physics checks them in let engineObjectsCollideStaticLast = []; /** Current update frame, used to calculate time * @type {number} * @memberof Engine */ let frame = 0; /** Current engine time since start in seconds * - Below 20 FPS it falls behind the clock, since a frame catches up at most 50 ms of updates, so a timer in * real seconds, one that must keep up on a slow device, goes by timeReal * @type {number} * @memberof Engine */ let time = 0; /** Actual clock time since start in seconds (not affected by pause, timescale, or frame rate clamping; the debug speed keys scale it in debug builds) * @type {number} * @memberof Engine */ let timeReal = 0; /** Is the game paused? Causes time and objects to not be updated * @type {boolean} * @default false * @memberof Engine */ let paused = false; /** Get if game is paused * @return {boolean} * @memberof Engine */ function getPaused() { return paused; } /** Set if game is paused * @param {boolean} [isPaused] * @memberof Engine */ function setPaused(isPaused=true) { paused = isPaused; } /** Frames drawn per second, smoothed over the last few seconds, in release builds too * @type {number} * @memberof Engine */ let averageFPS = 0; // Engine internal variables let frameTimeLastMS = 0, frameTimeBufferMS = 0; // delta smoothing, after Time Delta Smoothing by Frank Force (2013): a frame is on screen for whole display frames, // so each delta is rounded to them and the rest is carried to the next, keeping the total real time; the frame // length is estimated from recent frames and kept internal, since the browser does not say what it is const frameDeltaHistory = []; let frameIntervalMS = 1e3 / 60, frameDeltaCarryMS = 0, frameDeltaCarryAverageMS = 0, frameDeltaSmoothing = true; function engineSmoothDelta(deltaMS) { if (!(deltaMS > 0)) return 0; if (deltaMS < 250) // a gap from a hidden tab is not a display frame { frameDeltaHistory.push(deltaMS); frameDeltaHistory.length > 64 && frameDeltaHistory.shift(); // the frames each delta held are counted in the last estimate or in the lower quartile of the last 16, // whichever the deltas fit better: the quartile is one frame even when a busy game misses most of them, and // it takes over when the window moves to a display with another refresh rate, or the estimate went wrong const recent = frameDeltaHistory.slice(-16).sort((a, b)=> a - b); const [lastMS, lastOff] = engineFrameFit(frameIntervalMS); const [quartileMS, quartileOff] = engineFrameFit(recent[recent.length >> 2]); frameIntervalMS = lastOff <= quartileOff ? lastMS : quartileMS; // deltas far from whole frames mean the display has no fixed refresh, with a margin so it does not flip frameDeltaSmoothing = min(lastOff, quartileOff) < (frameDeltaSmoothing ? .2 : .12); } if (!frameDeltaSmoothing) { // deltas that are not whole frames of one length, like a variable refresh display, are the frame times // as they are, with any carry paid, and smoothing starts over once they fit again const rawMS = deltaMS + frameDeltaCarryMS; frameDeltaCarryMS = min(rawMS, 0); frameDeltaCarryAverageMS = 0; return max(rawMS, 0); } // whole frames, 0 for one that came early or 2 after one was missed, the carry stays within half a frame; a // little of its slow average is paid each frame, so jitter can not hold it at half a frame and flip every frame const pullMS = frameDeltaCarryAverageMS / 32; frameDeltaCarryMS += deltaMS; const frames = round((frameDeltaCarryMS - pullMS) / frameIntervalMS); if (frames < 1) return 0; // the carry keeps it const smoothMS = frames * frameIntervalMS + pullMS; frameDeltaCarryMS -= smoothMS; frameDeltaCarryAverageMS += (frameDeltaCarryMS - frameDeltaCarryAverageMS) / 32; return smoothMS; } // fit the delta history as whole frames of about unitMS: the frame length is the least squares slope of time over // frames held, so jitter and whole ms timestamps average out, with how far the deltas are from whole frames function engineFrameFit(unitMS) { let timeMS = 0, frames = 0, offMS = 0, sumF = 0, sumT = 0, sumFF = 0, sumFT = 0; const count = frameDeltaHistory.length; for (const d of frameDeltaHistory) { const held = max(1, round(d / unitMS)); timeMS += d; frames += held; offMS += abs(d - held * unitMS); sumF += frames, sumT += timeMS, sumFF += frames * frames, sumFT += frames * timeMS; } const spread = count * sumFF - sumF * sumF; return [spread ? (count * sumFT - sumF * sumT) / spread : unitMS, offMS / count / unitMS]; } // where the fixed step's time counts from, moved when the variable step hands back so time goes on from there let timeFixedStart = 0, frameFixedStart = 0; let windowWidthLast = 0, windowHeightLast = 0, windowPixelRatioLast = 0; let engineUpdateInternal; // assigned by engineInit so engineStep can drive it let engineFrameScheduled = false; // a frame of the loop is asked for and has not run yet let engineFrameErrorLast; // the last error a release build's loop went on past, as text // the pairs of objects asked about a collision this update, so the other's own physics does not ask again: each // asker's others, with true for a pair both said to resolve, and false for one left overlapping, ignored or only // nudged apart; a map for each asker so the lookup stays quick when many objects pile up on one spot const engineObjectsCollidePairs = new Map; function engineObjectsCollidePairAnswer(asker, other) { return engineObjectsCollidePairs.get(asker)?.get(other); } function engineObjectsCollidePairAdd(asker, other, resolve=false) { let others = engineObjectsCollidePairs.get(asker); others || engineObjectsCollidePairs.set(asker, others = new Map); others.set(other, resolve); } // with this many solids or more, a mover finds what is near it through a grid of cells rather than checking every // solid, a big game's cost going from every pair to what is close; it resolves the same contacts in the same order, // so a game plays the same either way, and a few solids are quicker checked all let engineCollideGridMin = 64; // the grid for this update, {list, built}: the list of solids, and the grid made from it the first time a 2D mover // walks it, so a frame with no 2D mover, as 3D solids alone, makes none; built is each cell's solids, each solid's // cells, and each solid's place in the list, which the contacts are taken in; solids too big for cells are near // everything let engineCollideGrid; // where each object that collides with solids was before this frame's moves, kept only when a one way solid is among // them, so a one way test goes by where both were whichever of the two updates first let engineObjectsOneWayStart; // the grid of a list of solids, its cells about twice a typical solid so most are in one to four function engineCollideGridBuild(list) { let extent = 0; for (const o of list) extent += min(max(abs(o.size.x), abs(o.size.y)), 16) || 0; // a negative size is a mirrored one const index = new Map; const grid = {size: max(2 * extent / list.length, .5), cells: new Map, at: new Map, index, big: new Set, seen: new Map, stamp: 0, byIndex: (a, b)=> index.get(a) - index.get(b)}; // what a query found, and its order list.forEach((o, i)=> { index.set(o, i); engineCollideGridPlace(grid, o); }); return grid; } // the cells an object's box covers, first and last along x and y, in one kept array to read at once; a negative size // is a mirrored sprite of that size const engineCollideGridBox = [0, 0, 0, 0]; function engineCollideGridCells(grid, o) { const s = grid.size, w = abs(o.size.x) / 2, h = abs(o.size.y) / 2, box = engineCollideGridBox; box[0] = floor((o.pos.x - w) / s), box[1] = floor((o.pos.y - h) / s); box[2] = floor((o.pos.x + w) / s), box[3] = floor((o.pos.y + h) / s); return box; } // whether a box is kept in its cells: not over 1024 of them, and within 2^31 cells of the origin, past which a cell // loop would not end, as ++ stops changing a number past 2^53, and the keys would not be exact; one with no finite // box does not fit either, and is near every mover const engineCollideGridFits = (x0, y0, x1, y1)=> (x1 - x0 + 1) * (y1 - y0 + 1) <= 1024 && abs(x0) < 2**31 && abs(y0) < 2**31 && abs(x1) < 2**31 && abs(y1) < 2**31; // put a solid in the cells its box covers now, out of those it was in; a box on a cell's edge is in both cells, so // solids that touch share one function engineCollideGridPlace(grid, o) { if (!grid.index.has(o)) return; const [x0, y0, x1, y1] = engineCollideGridCells(grid, o); const big = !engineCollideGridFits(x0, y0, x1, y1); const was = grid.at.get(o); if (was && was[0] === x0 && was[1] === y0 && was[2] === x1 && was[3] === y1 && was[4] === big) return; if (was && !was[4]) for (let x = was[0]; x <= was[2]; ++x) for (let y = was[1]; y <= was[3]; ++y) { const cell = grid.cells.get(x * 1048576 + y); cell.splice(cell.indexOf(o), 1); } grid.big.delete(o); if (big) grid.big.add(o); else for (let x = x0; x <= x1; ++x) for (let y = y0; y <= y1; ++y) { const key = x * 1048576 + y; const cell = grid.cells.get(key); cell ? cell.push(o) : grid.cells.set(key, [o]); } grid.at.set(o, [x0, y0, x1, y1, big]); } // the solids a mover is near, after a place in the list, in list order, filled into near; each found once, by the // query's stamp function engineCollideGridNear(grid, o, after, near) { const [x0, y0, x1, y1] = engineCollideGridCells(grid, o); const stamp = ++grid.stamp; near.length = 0; for (const other of grid.big) engineCollideGridAdd(grid, other, after, stamp, near); if (engineCollideGridFits(x0, y0, x1, y1)) { for (let x = x0; x <= x1; ++x) for (let y = y0; y <= y1; ++y) { const cell = grid.cells.get(x * 1048576 + y); if (cell) for (const other of cell) engineCollideGridAdd(grid, other, after, stamp, near); } } else for (const other of grid.index.keys()) // a mover too big for cells is near everything engineCollideGridAdd(grid, other, after, stamp, near); near.sort(grid.byIndex); } // add a solid to what a query found, once, when it is after the place in the list function engineCollideGridAdd(grid, other, after, stamp, near) { if (grid.seen.get(other) === stamp) return; grid.seen.set(other, stamp); grid.index.get(other) > after && near.push(other); } // the solids a mover checks, as checking every one would reach them: in list order, and found again from where it is // whenever its box has moved, as a push does, so a solid it is pushed into later in the list is still checked function* engineCollideGridWalk(o) { const grid = engineCollideGrid.built ||= engineCollideGridBuild(engineCollideGrid.list); let after = -1, x, y, w, h, k = 0; const near = []; for (;;) { if (o.pos.x !== x || o.pos.y !== y || o.size.x !== w || o.size.y !== h) { x = o.pos.x, y = o.pos.y, w = o.size.x, h = o.size.y; engineCollideGridNear(grid, o, after, near); k = 0; } if (k >= near.length) return; const other = near[k++]; after = grid.index.get(other); yield other; } } let engineInitialized = false; // engineInit ran, with or without a canvas let engineInitDone; // the promise the first engineInit hands back, which a second call hands back too // the loads startup waits for, each counted for the loading screen, and how many are done; undefined once the game // loop starts let engineLoads, engineLoadsDone = 0; let engineObjectsUpdateCount = 0; // passes of engineObjectsUpdate so far, how a child knows it moved this pass const engineChildStack = []; // the children being updated, taken off the live lists so one leaving does not skip the next let showEngineVersion = true; /////////////////////////////////////////////////////////////////////////////// // plugin hooks const pluginList = []; class EnginePlugin { constructor(update, render, glContextLost, glContextRestored, preRender) { this.update = update; this.render = render; this.glContextLost = glContextLost; this.glContextRestored = glContextRestored; this.preRender = preRender; } } /** * @callback PluginCallback - Update or render function for a plugin * @memberof Engine */ /** Add a new update function for a plugin * - update runs on every fixed tick, paused and timeScale 0 included; a plugin that simulates should skip those * @param {PluginCallback} [update] * @param {PluginCallback} [render] * @param {PluginCallback} [glContextLost] * @param {PluginCallback} [glContextRestored] * @param {PluginCallback} [preRender] - Called after the canvas is cleared and before gameRender * @memberof Engine */ function engineAddPlugin(update, render, glContextLost, glContextRestored, preRender) { // make sure plugin functions are unique ASSERT(!pluginList.find(p=> p.update === update && p.render === render && p.glContextLost === glContextLost && p.glContextRestored === glContextRestored && p.preRender === preRender), 'engineAddPlugin: this plugin was already added'); const plugin = new EnginePlugin(update, render, glContextLost, glContextRestored, preRender); pluginList.push(plugin); } /////////////////////////////////////////////////////////////////////////////// // Main Engine Functions /** Add something the game loads to what startup waits for: while engineInit and gameInit run, the game loop starts * once it is done, and the loading screen counts it; images from loadTexture and sounds from files are added on * their own, and a load that fails counts as done; after startup it does nothing * @param {Promise} promise * @return {Promise} - The same promise * @example * async function gameInit() { level = await engineAddLoad(fetchJSON('level.json')); } * @memberof Engine */ function engineAddLoad(promise) { if (engineLoads) { engineLoads.push(promise); const done = ()=> { ++engineLoadsDone; }; promise.then(done, done); } return promise; } // wait for every load, the ones added while waiting too, drawing the loading screen each frame meanwhile async function engineWaitForLoads() { let waiting = true; const start = performance.now(); const drawFrame = ()=> { if (!waiting) return; engineLoadingScreenDraw((performance.now() - start) / 1e3); setTimeout(drawFrame, 16); }; headlessMode || drawFrame(); try { for (let count; count !== engineLoads.length;) { count = engineLoads.length; await Promise.allSettled(engineLoads); } } finally { waiting = false; } } // one frame of the loading screen, once loading has taken half a second, so a fast load never shows it; input // while it shows is dropped, as it is under the splash function engineLoadingScreenDraw(elapsed) { if (headlessMode || !loadingScreen || elapsed < .5) return; inputClear(); engineUpdateCanvas(); loadingScreen(engineLoadsDone / engineLoads.length); } /** * @callback GameInitCallback - Called after the engine starts, can be async * @return {void|Promise} * @memberof Engine */ /** * @callback LoadingScreenCallback - Draws the loading screen on mainContext, each frame while the game loads * @param {number} progress - The part of the loads done, 0 to 1 * @memberof Engine */ /** * @callback GameCallback - Update or render function for the game * @memberof Engine */ /** Startup LittleJS engine with your callback functions * @param {GameInitCallback} [gameInit] - Called once after the engine starts up, can be async for loading * @param {GameCallback} [gameUpdate] - Called every frame before objects are updated (60fps), use for game logic * @param {GameCallback} [gameUpdatePost] - Called after physics and objects are updated, even when paused, use for UI updates * @param {GameCallback} [gameRender] - Called before objects are rendered, use for drawing backgrounds/world elements * @param {GameCallback} [gameRenderPost] - Called after objects are rendered, use for drawing UI/overlays * @param {Array|string} [imageSources=[]] - List of image file paths to preload (e.g., ['player.png', 'tiles.png']), or one path * @param {HTMLElement} [rootElement] - Root DOM element to attach canvas to, defaults to document.body; it is * styled for a game (no scroll bars or selection, touch-action none), * where its own inline style does not say otherwise * It keeps its own inline styles and the canvas centers inside it, but the canvas is still sized from the window, * so set canvasFixedSize or canvasMaxSize to fit a smaller element * @example * // Basic engine startup * engineInit( * ()=> { LOG('Game initialized!'); }, // gameInit * ()=> { updateGameLogic(); }, // gameUpdate * ()=> { updateUI(); }, // gameUpdatePost * ()=> { drawBackground(); }, // gameRender * ()=> { drawHUD(); }, // gameRenderPost * ['tiles.png', 'tilesLevel.png'] // images to load * ); * @memberof Engine * @return {Promise} - Done when the images have loaded and gameInit has run */ async function engineInit(gameInit, gameUpdate, gameUpdatePost, gameRender, gameRenderPost, imageSources=[], rootElement) { ASSERT(!engineInitialized, 'engine already initialized'); // runtime guard so release builds (where the assert is stripped) don't // double-register listeners / double-add canvases on a second call, which is done when the first is if (engineInitialized) return engineInitDone; engineInitialized = true; showEngineVersion && console.log(`${engineName} Engine v${engineVersion}`); engineLoads = [], engineLoadsDone = 0; if (typeof imageSources === 'string') imageSources = [imageSources]; // one image given alone ASSERT(isArray(imageSources), 'pass in images as array'); // allow passing in empty functions gameInit ||= ()=>{}; gameUpdate ||= ()=>{}; gameUpdatePost ||= ()=>{}; gameRender ||= ()=>{}; gameRenderPost ||= ()=>{}; // Called automatically by engine to setup render system function enginePreRender() { // the level editor's own view while it is open, in debug builds, before the camera goes to WebGL editorPreRender(); // disable smoothing for pixel art mainContext.imageSmoothingEnabled = !tilesPixelated; // setup gl rendering if enabled glPreRender(); setShader(); // a shader left set last frame does not carry into this one // plugins that draw underneath the 2D layer pluginList.forEach(plugin=>plugin.preRender?.()); } // internal update loop for engine function engineUpdate(frameTimeMS=0) { const manualStepAtStart = engineManualStep; // update time keeping let frameTimeDeltaMS = frameTimeMS - frameTimeLastMS; // skip delta on the very first frame so timeReal doesn't jump // by ~page-load-time when RAF starts handing real timestamps if (!frameTimeLastMS) frameTimeDeltaMS = 0; frameTimeLastMS = frameTimeMS; // the first frame's rate seeds the average, so it does not start out climbing from 0; a gap of over a // second, a hidden tab coming back, is not a frame if (frameTimeDeltaMS && frameTimeDeltaMS < 1e3) averageFPS = averageFPS ? lerp(averageFPS, 1e3/frameTimeDeltaMS, .05) : 1e3/frameTimeDeltaMS; // the time the frame will be on screen, in whole display frames; engineStep's steps are exact already if (!manualStepAtStart) frameTimeDeltaMS = engineSmoothDelta(frameTimeDeltaMS); audioUpdateVolume(); // the time keys work while the debug overlay is open, or always when debugKeysAlways is set const debugKeys = debug && (debugOverlay || debugKeysAlways); const debugSpeedUp = debugKeys && keyIsDown('Equal'); // + const debugSpeedDown = debugKeys && keyIsDown('Minus'); // - const debugScale = debugSpeedUp ? 10 : debugSpeedDown ? .1 : 1; // apply time deltas const frameTimeDeltaUnscaledMS = frameTimeDeltaMS; timeReal += frameTimeDeltaMS * debugScale / 1e3; const combinedScale = timeScale * debugScale; frameTimeDeltaMS *= combinedScale; let wasUpdated = false; if (engineVariableStep) { // one update for the frame with timeDelta the time it covers, per-frame values are the game's to scale; // engineStep's frames are exactly 1/60, its first too, which has no frame before it to take a delta from if (frameTimeDeltaUnscaledMS > 0 || manualStepAtStart) { // frozen stands still as in the fixed step, and timeDelta keeps the last update's const frozenTick = paused || !combinedScale; if (!frozenTick) { timeDelta = manualStepAtStart ? combinedScale / frameRate : min(frameTimeDeltaUnscaledMS, 50) * combinedScale / 1e3; // clamp min framerate time += timeDelta; ++frame; } engineTick(frozenTick); } } else { // paused or a time scale of 0 is frozen: it ticks on unscaled time, so the update rate stays fixed instead // of following however fast the display refreshes, and gameUpdatePost and input still run to leave it const frozen = paused || !combinedScale; frameTimeBufferMS += frozen ? frameTimeDeltaUnscaledMS : frameTimeDeltaMS; frameTimeBufferMS = min(frameTimeBufferMS, 50 * (frozen ? 1 : max(1, combinedScale))); // clamp min framerate // apply time delta smoothing, improves smoothness of framerate in some browsers let deltaSmooth = 0; if (frameTimeBufferMS < 0 && frameTimeBufferMS > -9) { // force at least one update each frame since it is waiting for refresh deltaSmooth = frameTimeBufferMS; frameTimeBufferMS = 0; } // update multiple frames if necessary in case of slow framerate; a tick that throws still spends its time // and the smoothing still comes back, so a release build going on past an error each tick keeps its rate try { while (frameTimeBufferMS >= 0) { try { // read again each tick, so a pause set by the game stops the rest of this frame's catch-up ticks const frozenTick = paused || !(timeScale * debugScale); // increment frame and update time, frozen does not advance time if (!frozenTick) time = timeFixedStart + (frame++ - frameFixedStart) / frameRate; engineTick(frozenTick); } finally { frameTimeBufferMS -= 1e3 / frameRate; } } } finally { // add the time smoothing back in frameTimeBufferMS += deltaSmooth; } } // one tick of the loop: update game and objects, when frozen update everything except them function engineTick(frozenTick) { wasUpdated = true; engineUpdateCanvas(); inputUpdate(); if (!frozenTick) gameUpdate(); pluginList.forEach(plugin=>plugin.update?.()); if (frozenTick) { // update object transforms even when paused for (const o of engineObjects) o.parent || o.updateTransforms(); // objects made and destroyed while paused, like a menu's effects, still leave the list engineObjects.some(o=>o.destroyed) && (engineObjects = engineObjects.filter(o=>!o.destroyed)); } else engineObjectsUpdate(); // do post update debugUpdate(); gameUpdatePost(); inputUpdatePost(); } // manual step turned on by this frame's updates, set the buffer the loop and smoothing just moved again if (engineManualStep && !manualStepAtStart) frameTimeBufferMS = -.5e3 / frameRate; // check if the window changed so a resize is picked up even when // the game is not updating, for example when timeScale is 0 let windowChanged = false; if (!headlessMode) { const dpr = devicePixelRatio; windowChanged = windowWidthLast !== innerWidth || windowHeightLast !== innerHeight || windowPixelRatioLast !== dpr; windowWidthLast = innerWidth; windowHeightLast = innerHeight; windowPixelRatioLast = dpr; } // render only when something changed, displays that refresh faster // than the fixed update rate would otherwise redraw identical frames if (wasUpdated || windowChanged) renderFrame(); engineManualStep || engineScheduleFrame(); function renderFrame() { if (headlessMode) return; // canvas must be updated before rendering if (!wasUpdated) engineUpdateCanvas(); // render the game and objects enginePreRender(); gameRender(); engineObjectsSort(); for (const o of engineObjects) { if (o.destroyed) continue; setShader(o.shader); // each object draws with its own shader, or none o.render(); } setShader(); // back to the engine's for gameRenderPost // post rendering gameRenderPost(); setShader(); // plugin, input and debug draws start from the engine's state setAdditiveBlendMode(false); pluginList.forEach(plugin=>plugin.render?.()); inputRender(); debugRender(); glFlush(); drawCount = 0; primitiveCount = 0; } } // skip setup if headless if (headlessMode) return engineInitDone = startEngine([]); // ensure body exists for minimal HTML where the script runs before is parsed if (!document.body) document.documentElement.appendChild(document.createElement('body')); rootElement ||= document.body; // setup webgl glInit(rootElement); // setup html let styleRoot = 'margin:0;' + // fill the window 'overflow:hidden;' + // no scroll bars 'background:#000;' + // set background color 'user-select:none;' + // prevent hold to select '-webkit-user-select:none;' + // compatibility for ios 'touch-action:none;' + // prevent mobile pinch to resize '-webkit-touch-callout:none;'; // compatibility for ios // the canvases center on a root element with a height of its own, not the page; one sized only by its // children has none, since the canvases are placed apart from it, and would clip them all away if (rootElement !== document.body && rootElement.clientHeight && getComputedStyle(rootElement).position === 'static') styleRoot += 'position:relative;'; rootElement.style.cssText = styleRoot + rootElement.style.cssText; // its own inline styles come after and win mainCanvas = rootElement.appendChild(document.createElement('canvas')); drawContext = mainContext = mainCanvas.getContext('2d'); // init stuff and start engine inputInit(); audioInit(); debugInit(); // setup canvases // transform way is still more reliable than flexbox or grid const styleCanvas = 'position:absolute;'+ // allow canvases to overlap 'top:50%;left:50%;transform:translate(-50%,-50%)'; // center on screen mainCanvas.style.cssText = styleCanvas; if (glCanvas) glCanvas.style.cssText = styleCanvas; setCanvasPixelated(canvasPixelated); engineUpdateCanvas(); glPreRender(); // create offscreen canvases for image processing workContext = createCanvasContext(64); workCanvas = workContext.canvas; workReadContext = createCanvasContext(64, 64, true); workReadCanvas = workReadContext.canvas; // create promises for loading images /** @type {Array>} */ const promises = imageSources.map((src, i)=> loadTexture(i, src)); // no images to load if (!imageSources.length) promises.push(loadTexture(0)); // load engine font image promises.push(imageFontInit()); if (showSplashScreen) { // draw splash screen /** @type {Promise} */ const splash = new Promise(resolve => { let t = 0; updateSplash(); function updateSplash() { inputClear(); drawEngineLogo(t+=.01); t>1 ? resolve() : setTimeout(updateSplash, 16); } }); promises.push(splash); } // kept before anything is awaited, so a second call has it return engineInitDone = startEngine(promises); // the splash first, the images load under it, then gameInit runs once they are in, and the game loop starts once // it and everything loaded while it ran are done, the loading screen showing in the meantime; an error in gameInit // reaches the caller async function startEngine(images) { showSplashScreen && !headlessMode && await images[images.length - 1]; // headless has no splash const init = (async ()=> { await Promise.all(images); await gameInit(); })(); engineAddLoad(init); await engineWaitForLoads(); engineLoads = undefined; await init; engineUpdateInternal = engineUpdate; // engineStep only runs once the game is set up if (engineManualStep) return; if (debug) return engineUpdate(); // a debug build stops at an error, the first frame's as any try { engineUpdate(); } catch (error) { engineFrameFailed(error); } // a release build goes on past it, as on any later frame } } // Resize the canvas to fit the window and prepare it for a new frame // Called automatically each frame and by the splash screen before the loop starts // mainCanvasSize is css pixels and the backing store is that scaled by the // pixel ratio, so the ratio only changes sharpness, never how big things look function engineUpdateCanvas() { if (headlessMode) return; // the backing store is scaled by this, every size below is css pixels const dpr = getCanvasPixelRatio(); if (canvasFixedSize.x) { // set canvas fixed size mainCanvasSize.set(canvasFixedSize.x, canvasFixedSize.y); // fit to window using css width and height const innerAspect = innerWidth / innerHeight; const fixedAspect = canvasFixedSize.x / canvasFixedSize.y; const w = innerAspect < fixedAspect ? '100%' : ''; const h = innerAspect < fixedAspect ? '' : '100%'; mainCanvas.style.width = w; mainCanvas.style.height = h; if (glCanvas) { glCanvas.style.width = w; glCanvas.style.height = h; } } else { // get main canvas size based on window size, in css pixels so // canvasMaxSize caps how big the canvas looks, not its resolution mainCanvasSize.x = min(innerWidth, canvasMaxSize.x) | 0; mainCanvasSize.y = min(innerHeight, canvasMaxSize.y) | 0; // responsive aspect ratio, of the size after canvasMaxSize, which can change its shape const innerAspect = mainCanvasSize.x / mainCanvasSize.y; ASSERT(!canvasMaxAspect || canvasMinAspect <= canvasMaxAspect, 'canvasMinAspect must not be above canvasMaxAspect', canvasMinAspect, canvasMaxAspect); if (canvasMaxAspect && innerAspect > canvasMaxAspect) { // full height const w = mainCanvasSize.y * canvasMaxAspect | 0; mainCanvasSize.x = min(w, canvasMaxSize.x); } else if (innerAspect < canvasMinAspect) { // full width const h = mainCanvasSize.x / canvasMinAspect | 0; mainCanvasSize.y = min(h, canvasMaxSize.y); } // css size is the canvas size, the backing store is scaled up below mainCanvas.style.width = mainCanvasSize.x + 'px'; mainCanvas.style.height = mainCanvasSize.y + 'px'; if (glCanvas) { glCanvas.style.width = mainCanvasSize.x + 'px'; glCanvas.style.height = mainCanvasSize.y + 'px'; } } // clear main canvas and set size // only set the size when it changes, setting it invalidates the canvas // frame which makes the browser rebuild the display list for the page const bufferSizeX = mainCanvasSize.x * dpr | 0; const bufferSizeY = mainCanvasSize.y * dpr | 0; if (mainCanvas.width !== bufferSizeX || mainCanvas.height !== bufferSizeY) { mainCanvas.width = bufferSizeX; mainCanvas.height = bufferSizeY; } else { // setting the size also resets the context state, match that, what a game may have left on it too mainContext.setTransform(1, 0, 0, 1, 0, 0); mainContext.globalCompositeOperation = 'source-over'; mainContext.globalAlpha = 1; mainContext.filter = 'none'; mainContext.shadowColor = 'rgba(0,0,0,0)'; mainContext.clearRect(0, 0, bufferSizeX, bufferSizeY); } // scale the context so 2d drawing is in css pixels mainContext.setTransform(dpr, 0, 0, dpr, 0, 0); // apply the clear color to main canvas if (canvasClearColor.a > 0 && !glEnable) { mainContext.fillStyle = canvasClearColor.toString(); mainContext.fillRect(0, 0, mainCanvasSize.x, mainCanvasSize.y); mainContext.fillStyle = BLACK.toString(); } // set default line join and cap, round on purpose: it looks better, suits text and keeps sharp corners from // spiking far out; WebGL outlines are square and mitered for speed, the two are not meant to match mainContext.lineJoin = 'round'; mainContext.lineCap = 'round'; } // a release build goes on past an error in a frame, a frozen game is the worst a player can get; an error is logged // when it is not the last one again, one every frame would flood the console function engineFrameFailed(error) { const text = String(error); text === engineFrameErrorLast || console.error(error); engineFrameErrorLast = text; // the frame's input is cleared as its tick would have, or a key press that threw would be pressed again inputUpdatePost(); engineScheduleFrame(); } // ask for the next frame of the loop, once however often it is called before that frame, and skip it if manual // step was turned on since, so turning it off and on again within a frame can not start a second loop function engineScheduleFrame() { if (engineFrameScheduled) return; engineFrameScheduled = true; const next = (frameTimeMS)=> { engineFrameScheduled = false; if (engineManualStep) return; if (debug) return engineUpdateInternal(frameTimeMS); // a debug build stops at an error, where it shows it try { engineUpdateInternal(frameTimeMS); } catch (error) { engineFrameFailed(error); } }; if (typeof requestAnimationFrame === 'function') requestAnimationFrame(next); else // a headless server in Node has no display to wait for, a timer keeps the pace setTimeout(()=> next(performance.now()), 1e3 / frameRate); } // max frames engineStep can advance in one call, 10 minutes at 60fps // large counts block until they finish, so this catches runaway values const engineStepMaxFrames = 36000; /** Advance the engine by a number of frames * Requires setEngineManualStep(true), before engineInit or while running; it stops early if an update turns it off * Respects paused exactly as the normal update loop does * @param {number} [frames] - frames of 1/60 of a second to advance, max 36000; timeScale sets how many fixed * updates they run, as in the normal loop, one each at timeScale 1 * @example * setHeadlessMode(true); * setEngineManualStep(true); * await engineInit(gameInit, gameUpdate, gameUpdatePost, gameRender, gameRenderPost); * engineStep(600); // 600 updates of 1/60 second; the first is at time 0, so time is then 599/60 * @memberof Engine */ function engineStep(frames=1) { ASSERT(engineManualStep, 'engineStep requires setEngineManualStep(true)'); ASSERT(engineUpdateInternal, 'engineStep requires engineInit to complete'); // runtime guard so release builds (where the asserts are stripped) can't // start a second requestAnimationFrame chain or call an undefined update if (!engineManualStep || !engineUpdateInternal) return; ASSERT(Number.isInteger(frames) && frames >= 0 && frames <= engineStepMaxFrames, 'engineStep requires a whole frame count from 0 to ' + engineStepMaxFrames); frames = min(frames, engineStepMaxFrames); // release has no asserts, don't freeze for (let i = frames; i > 0 && engineManualStep; --i) // an update that turns manual step off hands back the loop engineUpdateInternal(frameTimeLastMS + 1e3 / frameRate); } /** Update each engine object and remove destroyed objects; time and frame are advanced by the engine loop, not here * - Can be called manually if objects need to be updated outside of main loop * @memberof Engine */ function engineObjectsUpdate() { ++engineObjectsUpdateCount; engineObjectsCollidePairs.clear(); // objects update in render order, which rendering keeps them in, so a headless run or a frame that rendered // nothing updates them the same way engineObjectsSort(); // get list of solid objects for physics optimization, in update order, which 3D collision pairs by; // 2D checks the static ones last, so a contact with a moving object can not leave something back inside a static // solid it was already pushed out of const fixed = []; engineObjectsCollide = []; engineObjectsCollideStaticLast = []; for (const o of engineObjects) o.collideSolidObjects && (engineObjectsCollide.push(o), (o.mass ? engineObjectsCollideStaticLast : fixed).push(o)); for (const o of fixed) engineObjectsCollideStaticLast.push(o); // written only when there is a grid: V8 keeps a variable that is never written as a constant, and writing it // every frame makes this function deoptimize again and again in a game with no grid if (engineObjectsCollideStaticLast.length >= engineCollideGridMin) engineCollideGrid = {list: engineObjectsCollideStaticLast, built: undefined}; // written only when there is a one way solid, as the grid is if (engineObjectsCollide.some((o)=> o.oneWay)) engineObjectsOneWayStart = new Map(engineObjectsCollide.map((o)=> [o, o.pos.copy()])); // update physics before object update, each solid put where it moved to in the grid, for the movers after it; // the grid is let go even when a callback throws, so an updatePhysics called before the next update checks all // a one way solid with no mass moves first, nothing pushes it, so what lands on it or passes up through it sees it // where it is now and where it was, whatever the render order const oneWayFirst = (o)=> o.oneWay && !o.mass; try { if (engineObjectsOneWayStart) for (const o of engineObjects) if (!o.parent && !o.destroyed && oneWayFirst(o)) { o.updatePhysics(); engineCollideGrid?.built && engineCollideGridPlace(engineCollideGrid.built, o); } for (const o of engineObjects) if (!o.parent && !o.destroyed && !(engineObjectsOneWayStart && oneWayFirst(o))) { o.updatePhysics(); engineCollideGrid?.built && engineCollideGridPlace(engineCollideGrid.built, o); } } finally { engineCollideGrid && (engineCollideGrid = undefined); engineObjectsOneWayStart && (engineObjectsOneWayStart = undefined); } // recursive object update: the children are walked from a copy on a shared stack, since a child that // destroys itself leaves its parent's list on the spot and the next child would slide past the loop function updateChildObjects(children) { if (!children.length) return; // most objects have none, and this runs for every one const start = engineChildStack.length; for (const child of children) engineChildStack.push(child); try { for (let i = start; i < engineChildStack.length; ++i) updateChildObject(engineChildStack[i]); } finally { engineChildStack.length = start; } // put back when an update throws too, or it keeps them } const pass = engineObjectsUpdateCount; function updateChildObject(o) { if (o.destroyed || o.updatePass === pass) return; // its parent is up to date, so it updates from where it is now, and its children from where it is after o.updatePass = pass; o.updateTransforms(false); o.update(); o.children.length && o.updateTransforms(false); updateChildObjects(o.children); } function updateTopObject(o) { if (o.parent || o.destroyed || o.updatePass === pass) return; // a child that let go is not updated twice // update top level objects, each child places itself before it updates so it sees this frame's position, // then the whole tree is placed again so what the children changed in their localPos lands before render o.updatePass = pass; o.update(); updateChildObjects(o.children); o.updateTransforms(); } for (const o of engineObjects) updateTopObject(o); // a child let go during the update from a place in the list already walked is on its own now, updated here for (const o of engineObjects) updateTopObject(o); // remove destroyed objects engineObjects.some(o=>o.destroyed) && (engineObjects = engineObjects.filter(o=>!o.destroyed)); } // sort the objects by render order, keeping the order of equals, only when one is out of order, as they are kept // sorted and most frames change none function engineObjectsSort() { for (let i = engineObjects.length; --i > 0;) if (engineObjects[i].renderOrder < engineObjects[i-1].renderOrder) return void engineObjects.sort((a,b)=> a.renderOrder - b.renderOrder); } /** Destroy and remove all objects * - This can be used to clear out all objects when restarting a level * - Objects with the persistent flag set are left alone, for things that outlive a level * - Objects can override their destroy function to do cleanup or stick around * @param {boolean} [immediate] - true removes attached effects like particle emitters at once, false lets them finish first * @memberof Engine */ function engineObjectsDestroy(immediate=true) { for (const o of engineObjects) o.parent || o.persistent || o.destroy(immediate); engineObjects = engineObjects.filter(o=>!o.destroyed); } /** Collects all object within a given area * - An object is collected when its box overlaps the area, or with testCenters when its center is inside it * - Objects destroyed this frame are left out, they are only in the list until the frame ends * @param {Vector2} [pos] - Center of test area, or undefined for all objects * @param {Vector2|number} [size] - Diameter of a circle if a number, full size of a rectangle if a Vector2, * left out or 0 the objects that overlap the point at pos * @param {Array} [objects=engineObjects] - List of objects to check * @param {boolean} [testCenters] - Test only each object's center, a little faster, and ignores object sizes * @return {Array} - List of collected objects * @memberof Engine */ function engineObjectsCollect(pos, size, objects=engineObjects, testCenters=false) { const collectedObjects = []; if (!pos) { // all objects for (const o of objects) o.destroyed || collectedObjects.push(o); } else if (!size || size instanceof Vector2) { // bounding box test, a point when there is no size or a size of 0 const boxSize = size instanceof Vector2 ? size : vec2(); for (const o of objects) o.destroyed || (testCenters ? isOverlapping(pos, boxSize, o.pos) : o.isOverlapping(pos, boxSize)) && collectedObjects.push(o); } else { // circle test, a diameter like every other size, against the nearest point of each box const radiusSquared = (size/2)**2; for (const o of objects) { if (o.destroyed) continue; const dx = testCenters ? pos.x - o.pos.x : max(abs(pos.x - o.pos.x) - o.size.x/2, 0); const dy = testCenters ? pos.y - o.pos.y : max(abs(pos.y - o.pos.y) - o.size.y/2, 0); dx*dx + dy*dy < radiusSquared && collectedObjects.push(o); } } return collectedObjects; } /** * @callback ObjectCallbackFunction - Function that processes an object * @param {EngineObject} object * @memberof Engine */ /** Triggers a callback for each object within a given area, objects destroyed this frame left out * @param {Vector2} [pos] - Center of test area, or undefined for all objects * @param {Vector2|number} [size] - Diameter of a circle if a number, full size of a rectangle if a Vector2 * @param {ObjectCallbackFunction} [callbackFunction] - Calls this function on every object that passes the test, needed * (marked optional only because the area before it is) * @param {Array} [objects=engineObjects] - List of objects to check * @param {boolean} [testCenters] - Test only each object's center, see engineObjectsCollect * @memberof Engine */ function engineObjectsCallback(pos, size, callbackFunction, objects=engineObjects, testCenters=false) { // an object an earlier callback destroyed is skipped for (const o of engineObjectsCollect(pos, size, objects, testCenters)) o.destroyed || callbackFunction(o); } /** Return a list of objects intersecting a ray, objects destroyed this frame left out * - Only objects with collideRaycast set are hit, which setCollision turns on * @param {Vector2} start * @param {Vector2} end * @param {Array} [objects=engineObjects] - List of objects to check * @return {Array} - List of objects hit * @memberof Engine */ function engineObjectsRaycast(start, end, objects=engineObjects) { const hitObjects = []; for (const o of objects) { if (o.collideRaycast && !o.destroyed && isIntersecting(start, end, o.pos, o.size)) { debugRaycast && debugRect(o.pos, o.size, '#f00', 0, 0, false, false); hitObjects.push(o); } } debugRaycast && debugLine(start, end, hitObjects.length ? '#f00' : '#00f', .02, 0, false); return hitObjects; } /** * LittleJS Debug System * - Press Esc to toggle debug overlay with object picking * - Number keys toggle debug visualizations (physics, particles, etc.) while the overlay is open * - +/- keys control time scale for slow motion/fast forward while the overlay is open * - setDebugKeysAlways lets those keys work with the overlay closed too * - ASSERT and LOG macros for development (removed in release builds) * - Debug primitive rendering (rectangles, circles, lines, points, text) * - Screenshot support * - FPS counter and performance watermark * - Debug overlay shows mouse position and picked objects * @namespace Debug */ /** True if debug is enabled * @type {boolean} * @default * @memberof Debug */ const debug = true; /** Size to render debug points by default * @type {number} * @default * @memberof Debug */ const debugPointSize = .5; /** Width in pixels of the outlines debug shapes are drawn with, the physics boxes and the tile collision view * among them; larger makes them easier to see * @type {number} * @default * @memberof Debug */ let debugLineWidth = 2; /** True if watermark with FPS should be shown, false in release builds * @type {boolean} * @default * @memberof Debug */ let debugWatermark = true; /** Key code used to toggle debug mode, Esc by default * @type {string} * @default * @memberof Debug */ let debugKey = 'Escape'; /** Let the debug keys work while the overlay is closed, the number keys and the +/- time keys, for a game that does * not use them; off by default, so they only work while the overlay is open * @type {boolean} * @default * @memberof Debug */ let debugKeysAlways = false; /** True if the debug overlay is active, always false in release builds * @type {boolean} * @default * @memberof Debug */ let debugOverlay = false; /** Open or close the debug overlay from code, as the debug key does; does nothing in release builds * @param {boolean} [show] * @memberof Debug */ function setDebugOverlay(show=true) { debug && (debugOverlay = !!show); } /** True if the tweakables panel is shown, 9 toggles it while the overlay is open; set it to show the panel * from the start, the panel is never shown in release builds * @type {boolean} * @default * @memberof Debug */ let debugTweakables = false; // the shadow behind debug text, so it reads over anything: the overlay menu and the mouse text both use it; // a smaller blur is tighter and darker, a larger one softer and fainter const debugTextShadowColor = '#000', debugTextShadowBlur = 9; function debugTextShadow(context) { context.shadowColor = debugTextShadowColor; context.shadowBlur = debugTextShadowBlur; } // Engine internal variables not exposed to documentation let debugPrimitives = [], debugClearCount = 0, debugPhysics = false, debugRaycast = false, debugParticles = false, debugGamepads = false, debugSound = false, debugTiles = 0, debugTakeScreenshot; // debugTiles is 0 for off, 1 for every layer, and 2 on for one layer at a time, see debugTileLayersSelected /////////////////////////////////////////////////////////////////////////////// // Debug helper functions /** Asserts if the expression is false, does nothing in release builds * Halts execution if the assert fails and throws an error * @param {*} assert - any value, the assert fails when it is falsy * @param {...Object} output - error message output * @memberof Debug */ function ASSERT(assert, ...output) { if (assert) return; console.assert(assert, ...output) // the error says what went wrong, so an error box or overlay that shows only the error shows the message too: a // value with a toString of its own as that, a vec2 or a color, a plain object as JSON, an object of a class with // none by its class's name, each cut at 200 characters, so a whole level is not the message const text = output.map((value)=> { let text; try { const prototype = value && typeof value === 'object' ? Object.getPrototypeOf(value) : undefined; text = prototype === Object.prototype || prototype === null ? JSON.stringify(value) : prototype && value.toString === Object.prototype.toString ? '[' + (value.constructor?.name || 'object') + ']' : String(value); } catch { text = Object.prototype.toString.call(value); } text = String(text); // JSON of nothing, as from a toJSON that returns nothing, is undefined return text.length > 200 ? text.slice(0, 199) + '…' : text; }).join(' '); throw new Error(text ? 'Assert failed: ' + text : 'Assert failed!'); // halt execution } /** Log to console if debug is enabled, does nothing in release builds * @param {...Object} output - message output * @memberof Debug */ function LOG(...output) { console.log(...output); } /** Draw a debug rectangle in world space, or on the screen with screenSpace * @param {Vector2} pos * @param {Vector2} [size=vec2()] * @param {Color|string} [color] * @param {number} [time] * @param {number} [angle] * @param {boolean} [fill] * @param {boolean} [screenSpace=drawScreenSpace] * @memberof Debug */ function debugRect(pos, size=vec2(), color=WHITE, time=0, angle=0, fill=false, screenSpace=drawScreenSpace) { ASSERT(isVector2(pos), 'pos must be a vec2'); ASSERT(isVector2(size), 'size must be a vec2'); ASSERT(isStringLike(color) || isColor(color), 'color is invalid'); ASSERT(isNumber(time), 'time must be a number'); ASSERT(isNumber(angle), 'angle must be a number'); if (headlessMode) return; // nothing draws them, they would pile up if (isColor(color)) color = color.toString(); const timer = new Timer(time, true); // real time, so they still expire while the game is paused debugPrimitives.push({pos:pos.copy(), size:size.copy(), color, timer, angle, fill, screenSpace}); } /** Draw a debug poly in world space, or on the screen with screenSpace * @param {Vector2} pos * @param {Array} points * @param {Color|string} [color] * @param {number} [time] * @param {number} [angle] * @param {boolean} [fill] * @param {boolean} [screenSpace=drawScreenSpace] * @memberof Debug */ function debugPoly(pos, points, color=WHITE, time=0, angle=0, fill=false, screenSpace=drawScreenSpace) { ASSERT(isVector2(pos), 'pos must be a vec2'); ASSERT(isArray(points), 'points must be an array'); ASSERT(isStringLike(color) || isColor(color), 'color is invalid'); ASSERT(isNumber(time), 'time must be a number'); ASSERT(isNumber(angle), 'angle must be a number'); if (headlessMode) return; if (isColor(color)) color = color.toString(); pos = pos.copy(); points = points.map(p=>p.copy()); const timer = new Timer(time, true); // real time, so they still expire while the game is paused debugPrimitives.push({pos, points, color, timer, angle, fill, screenSpace}); } /** Draw a debug circle in world space, or on the screen with screenSpace * @param {Vector2} pos * @param {number} [size] - diameter * @param {Color|string} [color] * @param {number} [time] * @param {boolean} [fill] * @param {boolean} [screenSpace=drawScreenSpace] * @memberof Debug */ function debugCircle(pos, size=0, color=WHITE, time=0, fill=false, screenSpace=drawScreenSpace) { ASSERT(isVector2(pos), 'pos must be a vec2'); ASSERT(isNumber(size), 'size must be a number'); ASSERT(isStringLike(color) || isColor(color), 'color is invalid'); ASSERT(isNumber(time), 'time must be a number'); if (headlessMode) return; if (isColor(color)) color = color.toString(); pos = pos.copy(); const timer = new Timer(time, true); // real time, so they still expire while the game is paused debugPrimitives.push({pos, size, color, timer, angle:0, fill, screenSpace}); } /** Draw a debug point in world space, or on the screen with screenSpace * @param {Vector2} pos * @param {Color|string} [color] * @param {number} [time] * @param {number} [angle] * @param {boolean} [screenSpace=drawScreenSpace] * @memberof Debug */ function debugPoint(pos, color, time, angle, screenSpace=drawScreenSpace) { debugRect(pos, undefined, color, time, angle, false, screenSpace); } /** Draw a debug line in world space, or on the screen with screenSpace * @param {Vector2} posA * @param {Vector2} posB * @param {Color|string} [color] * @param {number} [width] * @param {number} [time] * @param {boolean} [screenSpace=drawScreenSpace] * @memberof Debug */ function debugLine(posA, posB, color, width=.1, time=0, screenSpace=drawScreenSpace) { ASSERT(isVector2(posA), 'posA must be a vec2'); ASSERT(isVector2(posB), 'posB must be a vec2'); ASSERT(isNumber(width), 'width must be a number'); const halfDelta = vec2((posB.x - posA.x)/2, (posB.y - posA.y)/2); const size = vec2(width, halfDelta.length()*2); // screen space y points down, so its angle is measured the other way, as drawLine does const angle = vec2(halfDelta.x, screenSpace ? -halfDelta.y : halfDelta.y).angle(); debugRect(posA.add(halfDelta), size, color, time, angle, true, screenSpace); } /** Draw a debug combined axis aligned bounding box in world space, or on the screen with screenSpace * @param {Vector2} posA * @param {Vector2} sizeA * @param {Vector2} posB * @param {Vector2} sizeB * @param {Color|string} [color] * @param {number} [time] * @param {boolean} [screenSpace=drawScreenSpace] * @memberof Debug */ function debugOverlap(posA, sizeA, posB, sizeB, color, time, screenSpace=drawScreenSpace) { ASSERT(isVector2(posA), 'posA must be a vec2'); ASSERT(isVector2(posB), 'posB must be a vec2'); ASSERT(isVector2(sizeA), 'sizeA must be a vec2'); ASSERT(isVector2(sizeB), 'sizeB must be a vec2'); const minPos = vec2( min(posA.x - sizeA.x/2, posB.x - sizeB.x/2), min(posA.y - sizeA.y/2, posB.y - sizeB.y/2) ); const maxPos = vec2( max(posA.x + sizeA.x/2, posB.x + sizeB.x/2), max(posA.y + sizeA.y/2, posB.y + sizeB.y/2) ); debugRect(minPos.lerp(maxPos,.5), maxPos.subtract(minPos), color, time, 0, false, screenSpace); } /** Draw debug text in world space, or on the screen with screenSpace * @param {string|number} text * @param {Vector2} pos * @param {number} [size] * @param {Color|string} [color] * @param {number} [time] * @param {number} [angle] * @param {string} [font] * @param {boolean} [screenSpace=drawScreenSpace] * @memberof Debug */ function debugText(text, pos, size=1, color=WHITE, time=0, angle=0, font='monospace', screenSpace=drawScreenSpace) { ASSERT(isStringLike(text), 'text must be a string'); ASSERT(isVector2(pos), 'pos must be a vec2'); ASSERT(isNumber(size), 'size must be a number'); ASSERT(isStringLike(color) || isColor(color), 'color is invalid'); ASSERT(isNumber(time), 'time must be a number'); ASSERT(isNumber(angle), 'angle must be a number'); ASSERT(isStringLike(font), 'font must be a string'); if (headlessMode) return; if (isColor(color)) color = color.toString(); pos = pos.copy(); const timer = new Timer(time, true); // real time, so they still expire while the game is paused debugPrimitives.push({text, pos, size, color, timer, angle, font, screenSpace}); } /** Clear all debug primitives in the list * @memberof Debug */ function debugClear() { debugPrimitives = []; ++debugClearCount; } // the count lets plugins clear their own /** Trigger debug system to take a screenshot * @memberof Debug */ function debugScreenshot() { debugTakeScreenshot = 1; } /** Breaks on all asserts/errors, hides the canvas, and shows message in plain text * This is a good function to call at the start of your game to catch all errors * In release builds this function has no effect * @memberof Debug */ function debugShowErrors() { const showError = (message)=> { // replace entire page with error message document.body.style.cssText = 'background-color:#111;margin:8px'; const pre = document.createElement('pre'); // the message as text, a file name in it never markup pre.style.cssText = 'color:#f00;font-size:28px;white-space:pre-wrap'; pre.textContent = String(message); document.body.replaceChildren(pre); } const originalAssert = console.assert; console.assert = (assertion, ...output)=> { originalAssert(assertion, ...output); if (!assertion) { const message = output.join(' '); const stack = new Error().stack; throw 'Assertion failed!\n' + message + '\n' + stack; } }; onunhandledrejection = (event)=> showError(event.reason?.stack || event.reason); onerror = (message, source, lineno, colno)=> showError(`${message}\n${source}\nLn ${lineno}, Col ${colno}`); } /////////////////////////////////////////////////////////////////////////////// // Engine debug functions (called automatically) function debugInit() { if (showEngineVersion) console.warn("LittleJS DEBUG build loaded. Use the release build for production."); } function debugUpdate() { if (!debug) return; if (keyWasPressed(debugKey)) // Esc debugOverlay = !debugOverlay; const debugKeys = debugOverlay || debugKeysAlways; // the keys work while the overlay is open, or always if set if (debugKeys) { if (keyWasPressed('Digit1')) debugPhysics = !debugPhysics, debugParticles = false; if (keyWasPressed('Digit2')) debugTiles = (debugTiles + 1) % (debugTileLayersShown().length + 2); // off, all, then each layer if (keyWasPressed('Digit3')) debugParticles = !debugParticles, debugPhysics = false; if (keyWasPressed('Digit4')) debugRaycast = !debugRaycast; if (keyWasPressed('Digit5')) debugGamepads = !debugGamepads; if (keyWasPressed('Digit6')) debugSound = !debugSound; if (keyWasPressed('Digit7')) debugScreenshot(); if (keyWasPressed('Digit9')) debugTweakables = !debugTweakables; if (keyWasPressed('Digit0')) levelEditor.isOpen ? levelEditor.close() : levelEditor.open(); } } // the text beside the mouse, with the same shadow as the overlay function debugMouseText(text) { mainContext.save(); debugTextShadow(mainContext); drawTextScreen(text, mousePosScreen, 24, WHITE, 0, BLACK, 'center', 'monospace', undefined, undefined, 0, mainContext); mainContext.restore(); } // the center of the tile under a world position, on the grid of the collision layer that has a tile there function debugTileCellCenter(pos) { for (const layer of tileCollisionLayers) { const local = pos.subtract(layer.pos); if (local.arrayCheck(layer.size) && layer.getCollisionData(local)) return local.floor().add(layer.pos).add(vec2(.5)); } return pos.floor().add(vec2(.5)); } // the tile layers Debug Tiles can show, the ones not switched off with debugShow, in render order function debugTileLayersShown() { return engineObjects.filter(o=> o instanceof TileLayer && !o.destroyed && o.debugShow) .sort((a, b)=> a.renderOrder - b.renderOrder); // a stable sort, so equal orders keep the order they were made in } // the layers Debug Tiles shows right now, from the ones shown: every one, or the one the 2 key has stepped to, // every one again if layers went away since function debugTileLayersSelected(layers) { const layer = layers[debugTiles - 2]; return layer ? [layer] : layers; } // what the overlay menu says Debug Tiles is showing function debugTilesLabel(layers) { if (!debugTiles) return ''; const layer = layers[debugTiles - 2]; return layer ? ` (layer ${debugTiles - 1} of ${layers.length}, renderOrder ${layer.renderOrder}${layer instanceof TileCollisionLayer ? ', collision' : ''})` : ' (all)'; } // Debug Tiles: each layer's bounds, then the collision value of every cell on screen, tinted by value, with the // number when a tile is big enough on screen to read it function debugTileLayers(layers) { // everything the camera can see, turned or not const reach = getCameraSize().length() / 2; const showValues = cameraScale >= 24; for (const layer of debugTileLayersSelected(layers)) { const isCollision = layer instanceof TileCollisionLayer, size = layer.size, pos = layer.pos; const color = isCollision ? '#f80' : '#0cf'; debugRect(pos.add(size.scale(.5)), size, color, 0, 0, false, false); const label = 'layer ' + (layers.indexOf(layer) + 1) + ', ' + size.x + 'x' + size.y + ', renderOrder ' + layer.renderOrder + (isCollision ? ', collision' : ''); debugText(label, pos.add(vec2(size.x / 2, size.y + .4)), .6, color, 0, 0, 'monospace', false); if (!isCollision) continue; const x0 = max(0, floor(cameraPos.x - reach - pos.x)), x1 = min(size.x, ceil(cameraPos.x + reach - pos.x)); const y0 = max(0, floor(cameraPos.y - reach - pos.y)), y1 = min(size.y, ceil(cameraPos.y + reach - pos.y)); for (let y = y0; y < y1; ++y) for (let x = x0; x < x1; ++x) { const data = layer.collisionData[y * size.x + x]; if (!data) continue; const center = vec2(pos.x + x + .5, pos.y + y + .5), tint = hsl(data * .17 % 1, 1, .6, .8); debugRect(center, vec2(.9), tint, 0, 0, false, false); showValues && debugText(data, center, .5, tint, 0, 0, 'monospace', false); } } } // the tile and collision value under the mouse of each layer shown by Debug Tiles, as lines of the mouse text function debugTileText(layers) { if (!debugTiles) return ''; // each layer by its number in the 2 key's cycle, the one the menu shows let text = ''; for (const layer of debugTileLayersSelected(layers)) { const local = mousePos.subtract(layer.pos); if (!local.arrayCheck(layer.size)) continue; // only a collision layer has collision data, the others are just drawn const data = layer.getData(local); const collision = layer instanceof TileCollisionLayer && layer.getCollisionData(local); text += '\nlayer ' + (layers.indexOf(layer) + 1) + ': tile ' + (data?.tile ?? 'empty') + (collision ? ', collision ' + collision : ''); } return text; } function debugRender() { if (debugTakeScreenshot) { // combine canvases, remove alpha and save glFlush(); combineCanvases(); saveCanvas(mainCanvas); debugTakeScreenshot = 0; } // flush any gl sprites before drawing debug info glFlush(); // the tile layers Debug Tiles can show, found once for everything below const tileLayers = debugTiles ? debugTileLayersShown() : []; const savedDrawCount = drawCount; const savedPrimitiveCount = primitiveCount; const debugContext = mainContext; if (debugGamepads && gamepadsEnable) { // draw gamepads const maxGamepads = 8; let gamepadConnectedCount = 0; for (let i = 0; i < maxGamepads; i++) gamepadConnected(i) && gamepadConnectedCount++; for (let i = 0; i < maxGamepads; i++) { if (!gamepadConnected(i)) continue; const stickScale = 1; const buttonScale = .2; const cornerPos = cameraPos.add(vec2(-stickScale*2, ((gamepadConnectedCount-1)/2-i)*stickScale*3)); debugText(i, cornerPos.add(vec2(-stickScale, stickScale)), 1, WHITE, 0, 0, 'monospace', false); if (i === gamepadPrimary) debugText('Main', cornerPos.add(vec2(-stickScale*2, 0)),1, '#0f0', 0, 0, 'monospace', false); // read analog sticks const stickCount = gamepadStickCount(i); // none after an inputClear this frame for (let j = 0; j < stickCount; j++) { if (!(j in gamepadStickData[i])) continue; // skip sticks that are not present (eg a disabled touch left stick) const stick = gamepadStick(j, i); const drawPos = cornerPos.add(vec2(j*stickScale*2, 0)); const stickPos = drawPos.add(stick.scale(stickScale)); debugCircle(drawPos, stickScale*2, '#fff7',0,true, false); debugLine(drawPos, stickPos, '#f00', .1, 0, false); debugText(j, drawPos, .3, WHITE, 0, 0, 'monospace', false); debugPoint(stickPos, '#f00', undefined, undefined, false); } const buttonCount = inputData[i+1].length; for (let j = 0; j < buttonCount; j++) { const drawPos = cornerPos.add(vec2(j*buttonScale*2, -stickScale-buttonScale*2)); const pressed = gamepadIsDown(j, i); debugCircle(drawPos, buttonScale*2, pressed ? '#f00' : '#fff7', 0, true, false); debugText(j, drawPos, .3, WHITE, 0, 0, 'monospace', false); } } } let debugObject; if (debugOverlay) { // draw red rectangle around screen const cameraSize = getCameraSize(); debugRect(cameraPos, cameraSize.subtract(vec2(.1)), '#f008', 0, 0, false, false); // mouse pick let bestDistance = Infinity; for (const o of engineObjects) { if (o.destroyed) continue; if (o instanceof TileLayer) continue; // prevent tile layers from being picked o.renderDebugInfo(); if (!o.size.x || !o.size.y) continue; const distance = mousePos.distanceSquared(o.pos); if (distance < bestDistance) { bestDistance = distance; debugObject = o; } } if (tileCollisionTest(mousePos)) { // show the collision tile under the mouse, on its layer's own grid drawRect(debugTileCellCenter(mousePos), vec2(1), rgb(1,1,0,.5), 0, false, false); } } // Debug Tiles draws with the overlay closed too, like the other modes listed in the corner debugTiles && debugTileLayers(tileLayers); { // draw debug primitives, the line width put back after so the game's next frame starts as it would in release debugContext.save(); debugContext.lineWidth = debugLineWidth; debugPrimitives.forEach(p=> { debugContext.save(); // create canvas transform from world space to screen space // without scaling because we want consistent pixel sizes let pos = p.pos, scale = 1, angle = p.angle; if (!p.screenSpace) { pos = worldToScreen(p.pos); scale = cameraScale; angle -= cameraAngle; } debugContext.translate(pos.x|0, pos.y|0); debugContext.rotate(angle); debugContext.scale(1, p.text !== undefined ? 1 : -1); debugContext.fillStyle = p.color; debugContext.strokeStyle = p.color; if (p.text !== undefined) { debugContext.font = p.size*scale + 'px '+ p.font; debugContext.textAlign = 'center'; debugContext.textBaseline = 'middle'; debugContext.fillText(p.text, 0, 0); } else if (p.points !== undefined) { // poly debugContext.beginPath(); for (const point of p.points) { const p2 = point.scale(scale).floor(); debugContext.lineTo(p2.x, p2.y); } debugContext.closePath(); p.fill && debugContext.fill(); debugContext.stroke(); } else if (p.size === 0 || (p.size.x === 0 && p.size.y === 0)) { // point const pointSize = debugPointSize * scale; debugContext.fillRect(-pointSize/2, -1, pointSize, 3); debugContext.fillRect(-1, -pointSize/2, 3, pointSize); } else if (p.size.x !== undefined) { // rect const s = p.size.scale(scale).floor(); const w = s.x, h = s.y; p.fill && debugContext.fillRect(-w/2|0, -h/2|0, w, h); debugContext.strokeRect(-w/2|0, -h/2|0, w, h); } else { // circle debugContext.beginPath(); debugContext.arc(0, 0, abs(p.size)*scale/2, 0, 9); // a negative radius would throw p.fill && debugContext.fill(); debugContext.stroke(); } debugContext.restore(); }); debugContext.restore(); // remove expired primitives debugPrimitives = debugPrimitives.filter(r=>r.timer<0); } if (debugObject) { const raycastHitPos = tileCollisionRaycast(debugObject.pos, mousePos); raycastHitPos && drawRect(debugTileCellCenter(raycastHitPos), vec2(1), rgb(0,1,1,.3), 0, false, false); drawLine(mousePos, debugObject.pos, .1, raycastHitPos ? rgb(1,0,0,.5) : rgb(0,1,0,.5), undefined, undefined, false, false); let debugText = 'mouse pos = ' + mousePos; if (tileCollisionLayers.length) debugText += '\nmouse collision = ' + tileCollisionGetData(mousePos); debugText += debugTileText(tileLayers); debugText += '\n\n--- object info ---\n'; debugText += debugObject.toString(); debugMouseText(debugText); } else if (debugOverlay && debugTiles) { // no object to pick, the tiles under the mouse on their own const text = debugTileText(tileLayers); text && debugMouseText('mouse pos = ' + mousePos + text); } { // draw debug overlay const fontSize = 20; const lineHeight = fontSize * 1.2 | 0; debugContext.save(); debugContext.fillStyle = '#fff'; debugContext.textAlign = 'left'; debugContext.textBaseline = 'top'; debugContext.font = fontSize + 'px monospace'; debugTextShadow(debugContext); let x = 9, y = 0, h = lineHeight; if (debugOverlay) { debugContext.fillText(`${engineName} v${engineVersion}`, x, y += h/2 ); debugContext.fillText('Time: ' + formatTime(time), x, y += h); debugContext.fillText('FPS: ' + averageFPS.toFixed(1) + (glEnable?' WebGL':' Canvas2D'), x, y += h); debugContext.fillText('Objects: ' + engineObjects.length, x, y += h); debugContext.fillText('Draw Calls: ' + drawCount, x, y += h); debugContext.fillText('Primitives: ' + primitiveCount, x, y += h); debugContext.fillText('---------', x, y += h); debugContext.fillStyle = '#f00'; debugContext.fillText('ESC: Debug Overlay', x, y += h); debugContext.fillStyle = debugPhysics ? '#f00' : '#fff'; debugContext.fillText('1: Debug Physics', x, y += h); debugContext.fillStyle = debugTiles ? '#f00' : '#fff'; debugContext.fillText('2: Debug Tiles' + debugTilesLabel(tileLayers), x, y += h); debugContext.fillStyle = debugParticles ? '#f00' : '#fff'; debugContext.fillText('3: Debug Particles', x, y += h); debugContext.fillStyle = debugRaycast ? '#f00' : '#fff'; debugContext.fillText('4: Debug Raycasts', x, y += h); debugContext.fillStyle = debugGamepads ? '#f00' : '#fff'; debugContext.fillText('5: Debug Gamepads', x, y += h); debugContext.fillStyle = debugSound ? '#f00' : '#fff'; debugContext.fillText('6: Debug Sound', x, y += h); debugContext.fillStyle = '#fff'; debugContext.fillText('7: Save Screenshot', x, y += h); debugContext.fillStyle = debugTweakables ? '#f00' : '#fff'; debugContext.fillText('9: Tweakables', x, y += h); debugContext.fillStyle = levelEditor.isOpen ? '#f00' : '#fff'; debugContext.fillText('0: Edit Level', x, y += h); for (const line of debugOverlayKeys) { // a key a debug plugin added, when it has something to say const key = line(); if (!key) continue; debugContext.fillStyle = key.on ? '#f00' : '#fff'; debugContext.fillText(key.text, x, y += h); } debugContext.fillStyle = '#fff'; let keysPressed = ''; let mousePressed = ''; for (const i in inputData[0]) { // read the input state itself, keyIsDown asserts on the numbered keys for..in gives as strings if (!(inputData[0][i] & 1)) continue; if (!isNaN(+i)) // mouse buttons are numbered, keys are named mousePressed += i + ' ' ; else keysPressed += i + ' ' ; } mousePressed && debugContext.fillText('Mouse: ' + mousePressed, x, y += h); keysPressed && debugContext.fillText('Keys: ' + keysPressed, x, y += h); // show gamepad buttons for (let i = 1; i < inputData.length; i++) { let buttonsPressed = ''; if (inputData[i]) for (const j in inputData[i]) { if (inputData[i][j] & 1) buttonsPressed += j + ' ' ; } buttonsPressed && debugContext.fillText(`Gamepad ${i-1}: ` + buttonsPressed, x, y += h); } } else { debugContext.fillText(debugPhysics ? 'Debug Physics' : '', x, y += h); debugContext.fillText(debugTiles ? 'Debug Tiles' + debugTilesLabel(tileLayers) : '', x, y += h); debugContext.fillText(debugParticles ? 'Debug Particles' : '', x, y += h); debugContext.fillText(debugRaycast ? 'Debug Raycasts' : '', x, y += h); debugContext.fillText(debugGamepads ? 'Debug Gamepads' : '', x, y += h); debugContext.fillText(debugSound ? 'Debug Sound' : '', x, y += h); } debugContext.restore(); } if (debugWatermark || debugOverlay) { // show fps stats display, the text state put back after for the game's next frame mainContext.save(); mainContext.textAlign = 'right'; mainContext.textBaseline = 'top'; mainContext.font = '1em monospace'; mainContext.fillStyle = '#000'; const text = engineName + ' v' + engineVersion + ' / ' + savedDrawCount + ' / ' + savedPrimitiveCount + ' / ' + engineObjects.length + ' / ' + averageFPS.toFixed(1) + (glEnable ? ' GL' : ' 2D') ; mainContext.fillText(text, mainCanvasSize.x-3, 3); mainContext.fillStyle = '#fff'; mainContext.fillText(text, mainCanvasSize.x-2, 2); mainContext.restore(); } } /////////////////////////////////////////////////////////////////////////////// // debug utility functions // make color constants immutable with debug assertions function debugProtectConstant(obj) { if (debug) { // get properties and store original values const props = Object.keys(obj), values = {}; props.forEach(prop => values[prop] = obj[prop]); // replace with getters/setters that assert props.forEach(prop => { Object.defineProperty(obj, prop, { get: ()=> values[prop], set: (value)=> { ASSERT(false, `engine constants like RED can not be changed, change a copy made with copy(); ` + `tried to set ${prop} of ${obj} to ${value}`); }, enumerable: true }); }); } // freeze the object to prevent adding new properties return Object.freeze(obj); } /////////////////////////////////////////////////////////////////////////////// // Input capture: the free camera and the 3D level editor take the keyboard and mouse, so the game under them reads // every key and mouse button as up, and no mouse movement or wheel; gamepads are left to the game let inputCaptureOn = false; // something has taken the keyboard and mouse let inputCaptureReading = false; // it is reading them now let inputCaptureDeltaScreen, inputCaptureWheel = 0; // this update's mouse movement and wheel, for it alone // the game let go of a captured mouse itself, with pointerLockExit, since the debug tools last looked: a capture // that is lost without it is the browser's doing, which is how Chrome takes Escape let inputLockLetGo = false; function inputLockExit() { inputLockLetGo = true; } // the keys debug plugins add to the overlay's list, each a function that gives {text, on} or nothing to show const debugOverlayKeys = []; // take the keyboard and mouse from the game, or hand them back function inputCapture(on=true) { inputCaptureOn = !!on; inputCaptureDeltaScreen = vec2(); inputCaptureWheel = 0; } // read the input as the one that took it, keyIsDown and the rest work as always inside read function inputCaptureRead(read) { const was = inputCaptureReading; inputCaptureReading = true; try { return read(); } finally { inputCaptureReading = was; } } // if a read of the keyboard and mouse, device 0, is the game's while they are taken function inputCaptureHides(device) { return inputCaptureOn && !inputCaptureReading && !device; } // called first in inputUpdate: the mouse movement and wheel go to the one that took the input function inputCaptureMouse() { if (!inputCaptureOn) return; inputCaptureDeltaScreen = mouseDeltaScreen; inputCaptureWheel = mouseWheel; mouseDeltaScreen = vec2(); mouseWheel = 0; } /** * LittleJS Math Classes and Functions * - Comprehensive math utilities for game development * - Vector2 class for 2D positions, directions, and math operations * - Color class for RGBA colors with interpolation and manipulation * - RandomGenerator for seeded pseudo-random number generation * - Math shortcuts (PI, abs, floor, ceil, min, max, sin, cos, etc.) * - Interpolation functions (lerp, smoothStep, percent) * - Clamping, wrapping, and modulo operations * - Angle utilities with wrap-around support * - Collision detection (overlapping, intersection, line tests) * - Random number generation and seeding * - Type checking utilities * @namespace Math */ /** The value of PI * @type {number} * @default Math.PI * @memberof Math */ const PI = Math.PI; /** Returns absolute value of value passed in * @param {number} x * @return {number} * @memberof Math */ const abs = Math.abs; /** Returns floored value of value passed in * @param {number} x * @return {number} * @memberof Math */ const floor = Math.floor; /** Returns ceiled value of value passed in * @param {number} x * @return {number} * @memberof Math */ const ceil = Math.ceil; /** Returns rounded value passed in * @param {number} x * @return {number} * @memberof Math */ const round = Math.round; /** Returns lowest value passed in * @param {...number} values * @return {number} * @memberof Math */ const min = Math.min; /** Returns highest value passed in * @param {...number} values * @return {number} * @memberof Math */ const max = Math.max; /** Returns the sign of value passed in * @param {number} x * @return {number} * @memberof Math */ const sign = (x) => Math.sign(x); /** Returns hypotenuse of values passed in * @param {...number} values * @return {number} * @memberof Math */ const hypot = Math.hypot; // not a function of its own, which would gather its arguments into an array each call /** Returns log2 of value passed in * @param {number} x * @return {number} * @memberof Math */ const log2 = (x) => Math.log2(x); /** Returns sin of value passed in * @param {number} x * @return {number} * @memberof Math */ const sin = Math.sin; /** Returns cos of value passed in * @param {number} x * @return {number} * @memberof Math */ const cos = Math.cos; /** Returns tan of value passed in * @param {number} x * @return {number} * @memberof Math */ const tan = Math.tan; /** Returns atan2 of values passed in * @param {number} y * @param {number} x * @return {number} * @memberof Math */ const atan2 = Math.atan2; /** Returns first param modulo the second param, but adjusted so negative numbers work as expected * @param {number} dividend * @param {number} [divisor] * @return {number} * @memberof Math */ function mod(dividend, divisor=1) { return ((dividend % divisor) + divisor) % divisor; } /** Clamps the value between max and min * @param {number} value * @param {number} [min] * @param {number} [max] * @return {number} * @memberof Math */ function clamp(value, min=0, max=1) { return value < min ? min : value > max ? max : value; } /** Returns what percentage the value is between valueA and valueB, clamped to 0-1 * @param {number} value * @param {number} valueA * @param {number} valueB * @return {number} * @memberof Math */ function percent(value, valueA, valueB) { return (valueB-=valueA) ? clamp((value-valueA)/valueB) : 0; } /** Linearly interpolates between values passed in using percent, percent is clamped to 0-1 * @param {number} valueA * @param {number} valueB * @param {number} percent * @return {number} * @memberof Math */ function lerp(valueA, valueB, percent) { return valueA + clamp(percent) * (valueB-valueA); } /** Gets percent between percentA and percentB and linearly interpolates between lerpA and lerpB * A shortcut for lerp(lerpA, lerpB, percent(value, percentA, percentB)) * @param {number} value * @param {number} percentA * @param {number} percentB * @param {number} lerpA * @param {number} lerpB * @return {number} * @memberof Math */ function percentLerp(value, percentA, percentB, lerpA, lerpB) { return lerp(lerpA, lerpB, percent(value, percentA, percentB)); } /** Returns signed wrapped distance between the two values passed in * @param {number} valueA * @param {number} valueB * @param {number} [wrapSize] * @return {number} * @memberof Math */ function distanceWrap(valueA, valueB, wrapSize=1) { ASSERT(wrapSize > 0, 'distanceWrap wrapSize must be > 0'); const d = (valueA - valueB) % wrapSize; return d*2 % wrapSize - d; } /** Linearly interpolates between values passed in with wrapping, percent is clamped to 0-1 * @param {number} valueA * @param {number} valueB * @param {number} percent * @param {number} [wrapSize] * @return {number} * @memberof Math */ function lerpWrap(valueA, valueB, percent, wrapSize=1) { return valueA + clamp(percent) * distanceWrap(valueB, valueA, wrapSize); } /** Returns signed wrapped distance between the two angles passed in * @param {number} angleA * @param {number} angleB * @return {number} * @memberof Math */ function distanceAngle(angleA, angleB) { return distanceWrap(angleA, angleB, 2*PI); } /** Linearly interpolates between the angles passed in with wrapping, percent is clamped to 0-1 * @param {number} angleA * @param {number} angleB * @param {number} percent * @return {number} * @memberof Math */ function lerpAngle(angleA, angleB, percent) { return lerpWrap(angleA, angleB, percent, 2*PI); } /** Applies smoothstep function to the percentage value, clamped between 0 and 1 * @param {number} percent * @return {number} * @memberof Math */ function smoothStep(percent) { percent = clamp(percent); return percent * percent * (3 - 2 * percent); } /** Checks if the value passed in is a power of two * @param {number} value * @return {boolean} * @memberof Math */ function isPowerOfTwo(value) { return value > 0 && value % 1 === 0 && 2**round(log2(value)) === value; } // any size, not only 32 bits /** Returns the nearest whole power of two not less than the value, 1 for a value of 1 or less * @param {number} value * @return {number} * @memberof Math */ function nearestPowerOfTwo(value) { const power = 2**ceil(log2(max(value, 1))); return power < value ? power * 2 : power; // log2 of a value just above a power can round down to it } /** Returns true if two axis aligned bounding boxes are overlapping * this can be used for simple collision detection between objects * @param {Vector2} posA - Center of box A * @param {Vector2} sizeA - Size of box A * @param {Vector2} posB - Center of box B * @param {Vector2} [sizeB=vec2()] - Size of box B, uses a point if undefined * @return {boolean} - True if overlapping * @memberof Math */ function isOverlapping(posA, sizeA, posB, sizeB=vec2()) { const dx = (posA.x - posB.x)*2; const dy = (posA.y - posB.y)*2; const sx = sizeA.x + sizeB.x; const sy = sizeA.y + sizeB.y; // symmetric so isOverlapping(A,B) === isOverlapping(B,A) at touching edges return abs(dx) < sx && abs(dy) < sy; } /** Returns true if a line segment is intersecting an axis aligned box * @param {Vector2} start - Start of raycast * @param {Vector2} end - End of raycast * @param {Vector2} pos - Center of box * @param {Vector2} size - Size of box * @return {boolean} - True if intersecting * @memberof Math */ function isIntersecting(start, end, pos, size) { // Liang-Barsky algorithm const boxMin = pos.subtract(size.scale(.5)); const boxMax = boxMin.add(size); const delta = end.subtract(start); const a = start.subtract(boxMin); const b = start.subtract(boxMax); const p = [-delta.x, delta.x, -delta.y, delta.y]; const q = [a.x, -b.x, a.y, -b.y]; let tMin = 0, tMax = 1; for (let i = 4; i--;) { if (p[i]) { const t = q[i] / p[i]; if (p[i] < 0) { if (t > tMax) return false; tMin = max(t, tMin); } else { if (t < tMin) return false; tMax = min(t, tMax); } } else if (q[i] < 0) return false; } return true; } /////////////////////////////////////////////////////////////////////////////// // Collision helpers, none of them change anything that is passed in // Boxes are axis aligned and centered on pos with a full size, like drawRect // Each returns how far to move the first shape to get it clear, or undefined when they are not touching // isOverlapping answers whether two boxes touch, these answer how far out /** Returns the vector to move circle A by so it no longer overlaps circle B, or undefined * @param {Vector2} posA - Center of circle A * @param {number} radiusA * @param {Vector2} posB - Center of circle B * @param {number} radiusB * @return {Vector2|undefined} * @memberof Math */ function collideCircleCircle(posA, radiusA, posB, radiusB) { const d = posA.subtract(posB); const r = radiusA + radiusB; const dist = d.length(); if (dist >= r) return undefined; return d.normalize(r - dist); // coincident centers normalize to straight up } /** Returns the vector to move a circle out of an axis aligned box, or undefined * @param {Vector2} pos - Center of the circle * @param {number} radius * @param {Vector2} boxPos - Center of the box * @param {Vector2} boxSize - Full size of the box * @return {Vector2|undefined} * @memberof Math */ function collideCircleBox(pos, radius, boxPos, boxSize) { const h = boxSize.scale(.5); const closest = vec2(clamp(pos.x, boxPos.x - h.x, boxPos.x + h.x), clamp(pos.y, boxPos.y - h.y, boxPos.y + h.y)); const d = pos.subtract(closest), distSq = d.lengthSquared(); if (distSq) return distSq >= radius*radius ? undefined : d.normalize(radius - distSq**.5); // center is inside the box, push out along the axis of least penetration const offset = pos.subtract(boxPos); return pushOutAxis(offset, h.x - abs(offset.x), h.y - abs(offset.y), radius); } /** Returns the vector to move box A by so it no longer overlaps box B, the shortest way out, or undefined * - isOverlapping is the yes or no version of this * @param {Vector2} posA - Center of box A * @param {Vector2} sizeA - Full size of box A * @param {Vector2} posB - Center of box B * @param {Vector2} sizeB - Full size of box B * @return {Vector2|undefined} * @memberof Math */ function collideBoxBox(posA, sizeA, posB, sizeB) { const d = posA.subtract(posB); const overlapX = (sizeA.x + sizeB.x)/2 - abs(d.x); const overlapY = (sizeA.y + sizeB.y)/2 - abs(d.y); if (overlapX <= 0 || overlapY <= 0) return undefined; return pushOutAxis(d, overlapX, overlapY); } // the axis with the smallest penetration, pointing the way d does, with extra distance added function pushOutAxis(d, penX, penY, extra=0) { const s = (v)=> v >= 0 ? 1 : -1; // sign() gives 0 on a tie, which would be no push return penX <= penY ? vec2(s(d.x)*(penX + extra), 0) : vec2(0, s(d.y)*(penY + extra)); } /** Returns an oscillating wave between 0 and amplitude with frequency of 1 Hz by default * @param {number} [frequency] - Frequency of the wave in Hz * @param {number} [amplitude] - Amplitude (max height) of the wave * @param {number} [t=time] - Value to use for time of the wave * @param {number} [offset] - Value to use for time offset of the wave * @param {number} [type] - Wave type: 0=sine, 1=triangle, 2=square, 3=sawtooth * @return {number} - Value waving between 0 and amplitude * @memberof Math */ function oscillate(frequency=1, amplitude=1, t=time, offset=0, type=0) { const phase = mod(offset + t*frequency, 1); let value; if (type === 1) // triangle, from 0 up and back like the others value = 1 - 2 * abs(2 * phase - 1); else if (type === 2) // square value = phase < .5 ? -1 : 1; else if (type === 3) // sawtooth value = 2 * phase - 1; else // sine value = -cos(phase * 2*PI); return amplitude/2 * (value + 1); } /** * Check if object is a valid number, not NaN or undefined, but it may be infinite * @param {any} n * @return {boolean} * @memberof Math */ function isNumber(n) { return typeof n === 'number' && n === n; } // NaN is the one number not equal to itself /** * Check if a value is stringifiable — i.e. it has a toString that returns * a string. Use this for ASSERTs and inputs that will be coerced to text; * use `typeof x === 'string'` inline if you need strict-string semantics. * - Returns true for strings, numbers, and most objects * - Returns false for null and undefined * @param {any} s * @return {boolean} * @memberof Math */ function isStringLike(s) { return s != null && typeof s.toString === 'function' && typeof s.toString() === 'string'; } /** * Check if object is an array * @param {any} a * @return {a is Array} * @memberof Math */ function isArray(a) { return Array.isArray(a); } /** * @callback LineTestFunction - Checks if a position is colliding * @param {Vector2} pos * @memberof Math */ /** * Casts a ray and returns position of the first collision found, or undefined if none are found * @param {Vector2} posStart * @param {Vector2} posEnd * @param {LineTestFunction} testFunction - Check if colliding * @param {Vector2} [normal] - Optional vector to store the normal * @return {Vector2|undefined} - Position of the collision or undefined if none found * @memberof Math */ function lineTest(posStart, posEnd, testFunction, normal) { ASSERT(isVector2(posStart), 'posStart must be a vec2'); ASSERT(isVector2(posEnd), 'posEnd must be a vec2'); ASSERT(typeof testFunction === 'function', 'testFunction must be a function'); ASSERT(!normal || isVector2(normal), 'normal must be a vec2'); // get ray direction and length const dx = posEnd.x - posStart.x; const dy = posEnd.y - posStart.y; const totalLength = (dx*dx + dy*dy)**.5; if (!totalLength) return; // current integer cell we are in, the one below a whole start heading down the grid, which it goes into const pos = posStart.floor(); if (dx < 0 && pos.x === posStart.x) --pos.x; if (dy < 0 && pos.y === posStart.y) --pos.y; // normalize ray direction const dirX = dx / totalLength; const dirY = dy / totalLength; // step direction in grid const stepX = sign(dirX); const stepY = sign(dirY); // distance along the ray to cross one full cell in X or Y const tDeltaX = dirX ? abs(1 / dirX) : Infinity; const tDeltaY = dirY ? abs(1 / dirY) : Infinity; // distance along the ray from start to the first grid boundary const nextGridX = stepX > 0 ? pos.x + 1 : pos.x; const nextGridY = stepY > 0 ? pos.y + 1 : pos.y; const tMaxX = dirX ? (nextGridX - posStart.x) / dirX : Infinity; const tMaxY = dirY ? (nextGridY - posStart.y) / dirY : Infinity; // use line drawing algorithm to test for collisions let t = 0, tX = tMaxX, tY = tMaxY, wasX = tDeltaX < tDeltaY; while (t < totalLength) { if (testFunction(pos)) { // set hit point const hitPos = vec2(posStart.x + dirX*t, posStart.y + dirY*t); // ensure result is inside the tile const e = 1e-9; const hitPosFloor = hitPos.floor(); if (hitPosFloor.x < pos.x) hitPos.x = pos.x; else if (hitPosFloor.x > pos.x) hitPos.x = pos.x + 1 - e; if (hitPosFloor.y < pos.y) hitPos.y = pos.y; else if (hitPosFloor.y > pos.y) hitPos.y = pos.y + 1 - e; // set normal if (normal) wasX ? normal.set(-stepX,0) : normal.set(0,-stepY); return hitPos; } // advance to the next grid boundary if (wasX = tX < tY) { pos.x += stepX; t = tX; tX += tDeltaX; } else { pos.y += stepY; t = tY; tY += tDeltaY; } } } /////////////////////////////////////////////////////////////////////////////// /** Random global functions * @namespace Random */ /** Returns a random value between the two values passed in * @param {number} [valueA] * @param {number} [valueB] * @return {number} * @memberof Random */ function rand(valueA=1, valueB=0) { return valueB + Math.random() * (valueA-valueB); } /** Returns a floored random value between the two values passed in * The upper bound is exclusive. (If 2 is passed in, result will be 0 or 1) * @param {number} valueA * @param {number} [valueB] * @return {number} * @memberof Random */ function randInt(valueA, valueB=0) { return floor(rand(valueA,valueB)); } /** Randomly returns true or false given the chance of true passed in * @param {number} [chance] * @return {boolean} * @memberof Random */ function randBool(chance=.5) { return rand() < chance; } /** Randomly returns either -1 or 1 * @return {number} * @memberof Random */ function randSign() { return randInt(2) * 2 - 1; } /** Returns a random Vector2 with the passed in length * @param {number} [length] * @return {Vector2} * @memberof Random */ function randVec2(length=1) { return new Vector2().setAngle(rand(2*PI), length); } /** Returns a random Vector2 within a circular shape * @param {number} [radius] * @param {number} [minRadius] * @return {Vector2} * @memberof Random */ function randInCircle(radius=1, minRadius=0) { // r is uniform in area ⇒ r² uniform in [minRadius², radius²] // (the squared inner bound is what makes minRadius the actual exclusion edge) if (radius <= 0) return new Vector2; const ratio = clamp(minRadius / radius); return randVec2(radius * rand(ratio*ratio, 1)**.5); } /** Returns a random color between the two passed in colors, combine components if linear * @param {Color} [colorA=WHITE] * @param {Color} [colorB=BLACK] * @param {boolean} [linear] * @return {Color} * @memberof Random */ function randColor(colorA=new Color, colorB=new Color(0,0,0,1), linear=false) { return linear ? colorA.lerp(colorB, rand()) : new Color(rand(colorA.r,colorB.r), rand(colorA.g,colorB.g), rand(colorA.b,colorB.b), rand(colorA.a,colorB.a)); } /////////////////////////////////////////////////////////////////////////////// /** * Seeded random number generator * - Can be used to create a deterministic random number sequence * - The seed works as a 32 bit integer, and one that is 0 as an integer * (0, a fraction between -1 and 1, or a multiple of 2**32) uses the default seed * @memberof Engine * @example * let r = new RandomGenerator(123); // random number generator with seed 123 * let a = r.float(); // random value between 0 and 1 * let b = r.int(10); // random integer between 0 and 9 * r.seed = 123; // reset the seed * let c = r.float(); // the same value as a */ class RandomGenerator { /** Create a random number generator with the seed passed in * @param {number} [seed] - Starting seed, 0 as an integer uses the default seed */ constructor(seed = 123456789) { /** @property {number} - random seed, set it to reseed */ this.seed = seed; } /** Returns a seeded random value between the two values passed in * @param {number} [valueA] * @param {number} [valueB] * @return {number} */ float(valueA=1, valueB=0) { // xorshift stays at 0 once there, so a seed that is 0 as an integer uses the default seed this.seed = this.seed|0 || 123456789; // xorshift algorithm steps the seed this.seed ^= this.seed << 13; this.seed ^= this.seed >>> 17; this.seed ^= this.seed << 5; // the value is the seed scrambled by a hash, since xorshift barely mixes a small seed in its first steps, // so nearby seeds would start with nearly the same values; the seed itself steps as before return valueB + (valueA - valueB) * noiseHash(this.seed); } /** Returns a floored seeded random value between the two values passed in * @param {number} valueA * @param {number} [valueB] * @return {number} */ int(valueA, valueB=0) { return floor(this.float(valueA, valueB)); } /** Randomly returns true or false given the chance of true passed in * @param {number} [chance] * @return {boolean} */ bool(chance=.5) { return this.float() < chance; } /** Randomly returns either -1 or 1 deterministically * @return {number} */ sign() { return this.float() > .5 ? 1 : -1; } /** Returns a seeded random value between the two values passed in with a random sign * @param {number} [valueA] * @param {number} [valueB] * @return {number} */ floatSign(valueA=1, valueB=0) { const lo = min(valueA, valueB); const hi = max(valueA, valueB); const d = hi - lo; const e = this.float(d*2); return e < d ? lo + e : d - lo - e; } /** Returns a random angle between -PI and PI * @return {number} */ angle() { return this.float(-PI, PI); } /** Returns a seeded vec2 with each component between the two values passed in * - A point in a square, not a random direction like randVec2, see direction for that * @param {number} [valueA] * @param {number} [valueB] * @return {Vector2} */ vec2(valueA=1, valueB=0) { return vec2(this.float(valueA, valueB), this.float(valueA, valueB)); } /** Returns a seeded Vector2 pointing a random way with the length passed in, the twin of randVec2 * @param {number} [length] * @return {Vector2} */ direction(length=1) { return new Vector2().setAngle(this.float(2*PI), length); } /** Returns a random color between the two passed in colors, combine components if linear * @param {Color} [colorA=WHITE] * @param {Color} [colorB=BLACK] * @param {boolean} [linear] * @return {Color} */ randColor(colorA=new Color, colorB=new Color(0,0,0,1), linear=false) { return linear ? colorA.lerp(colorB, this.float()) : new Color( this.float(colorA.r,colorB.r), this.float(colorA.g,colorB.g), this.float(colorA.b,colorB.b), this.float(colorA.a,colorB.a)); } /** Returns a new color that has each component randomly adjusted * @param {Color} color * @param {number} [amount] * @param {number} [alphaAmount] * @return {Color} */ mutateColor(color, amount=.05, alphaAmount=0) { ASSERT_NUMBER_VALID(amount); ASSERT_NUMBER_VALID(alphaAmount); return new Color ( color.r + this.float(amount, -amount), color.g + this.float(amount, -amount), color.b + this.float(amount, -amount), color.a + this.float(alphaAmount, -alphaAmount) ).clamp(); } } /////////////////////////////////////////////////////////////////////////////// /** * Create a 2d vector, can take 1 or 2 scalar values * @param {number} [x] * @param {number} [y] - if y is undefined, x is used for both * @return {Vector2} * @example * let a = vec2(0, 1); // vector with coordinates (0, 1) * a = vec2(5); // set a to (5, 5) * b = vec2(); // set b to (0, 0) * @memberof Math */ function vec2(x=0, y) { return new Vector2(x, y ?? x); } /** * Check if object is a valid Vector2 * @param {any} v * @return {boolean} * @memberof Math */ function isVector2(v) { return v instanceof Vector2 && v.isValid(); } // vector2 asserts function ASSERT_VECTOR2_VALID(v) { ASSERT(isVector2(v), 'expected a vec2', v); } function ASSERT_NUMBER_VALID(n) { ASSERT(isNumber(n), 'expected a number', n); } /** * 2D Vector object with vector math library * - Most functions return a new vector so they can be chained, the set functions change this one * @memberof Engine * @example * let a = new Vector2(2, 3); // vector with coordinates (2, 3) * let b = new Vector2; // vector with coordinates (0, 0) * let c = vec2(4, 2); // use the vec2 function to make a Vector2 * let d = a.add(b).scale(5); // operators can be chained */ class Vector2 { /** Create a 2D vector with the x and y passed in, can also be created with vec2() * @param {number} [x] - X axis location * @param {number} [y] - Y axis location */ constructor(x=0, y=0) { /** @property {number} - X axis location */ this.x = x; /** @property {number} - Y axis location */ this.y = y; ASSERT(this.isValid(), 'vec2: x and y must be numbers (add, subtract, multiply and divide take a vec2, scale a number)', this); // one literal, as it is built at every vector made in a debug build } /** Sets values of this vector and returns self * @param {number} [x] - X axis location * @param {number} [y] - Y axis location * @return {Vector2} */ set(x=0, y=0) { this.x = x; this.y = y; ASSERT_VECTOR2_VALID(this); return this; } /** Sets this vector from another vector and returns self * @param {Vector2} v - other vector * @return {Vector2} */ setFrom(v) { return this.set(v.x, v.y); } /** Returns a new vector that is a copy of this * @return {Vector2} */ copy() { return new Vector2(this.x, this.y); } /** Returns a copy of this vector plus the vector passed in * @param {Vector2} v - other vector * @return {Vector2} */ add(v) { return new Vector2(this.x + v.x, this.y + v.y);} /** Returns a copy of this vector minus the vector passed in * @param {Vector2} v - other vector * @return {Vector2} */ subtract(v) { return new Vector2(this.x - v.x, this.y - v.y); } /** Returns a copy of this vector times the vector passed in * @param {Vector2} v - other vector * @return {Vector2} */ multiply(v) { return new Vector2(this.x * v.x, this.y * v.y); } /** Returns a copy of this vector divided by the vector passed in * @param {Vector2} v - other vector * @return {Vector2} */ divide(v) { return new Vector2(this.x / v.x, this.y / v.y); } /** Returns a copy of this vector scaled by the number passed in * @param {number} s - scale * @return {Vector2} */ scale(s) { return new Vector2(this.x * s, this.y * s); } /** Returns the length of this vector * @return {number} */ length() { return this.lengthSquared()**.5; } /** Returns the length of this vector squared * @return {number} */ lengthSquared() { return this.x**2 + this.y**2; } /** Returns the distance from this vector to vector passed in * @param {Vector2} v - other vector * @return {number} */ distance(v) { return this.distanceSquared(v)**.5; } /** Returns the distance squared from this vector to vector passed in * @param {Vector2} v - other vector * @return {number} */ distanceSquared(v) { return (this.x - v.x)**2 + (this.y - v.y)**2; } /** Returns a new vector in same direction as this one with the length passed in * - A zero vector has no direction, so it normalizes to straight up * @param {number} [length] * @return {Vector2} */ normalize(length=1) { const l = this.length(); return l ? this.scale(length/l) : new Vector2(0, length); } /** Returns a new vector clamped to length passed in * @param {number} [length] * @return {Vector2} */ clampLength(length=1) { const l = this.length(); return l > length ? this.scale(length/l) : this.copy(); } /** Returns the dot product of this and the vector passed in * @param {Vector2} v - other vector * @return {number} */ dot(v) { return this.x*v.x + this.y*v.y; } /** Returns the cross product of this and the vector passed in * @param {Vector2} v - other vector * @return {number} */ cross(v) { return this.x*v.y - this.y*v.x; } /** Returns a copy this vector reflected by the surface normal * @param {Vector2} normal - surface normal (should be normalized) * @param {number} [restitution] - how much to bounce, 1 is perfect bounce, 0 is no bounce * @return {Vector2} */ reflect(normal, restitution=1) { return this.subtract(normal.scale((1+restitution)*this.dot(normal))); } /** Returns the clockwise angle of this vector, up is angle 0 * @return {number} */ angle() { return atan2(this.x, this.y); } /** Sets this vector with clockwise angle and length passed in * @param {number} [angle] * @param {number} [length] * @return {Vector2} */ setAngle(angle=0, length=1) { ASSERT_NUMBER_VALID(angle); ASSERT_NUMBER_VALID(length); this.x = length*sin(angle); this.y = length*cos(angle); return this; } /** Returns copy of this vector rotated by the clockwise angle passed in * @param {number} angle * @return {Vector2} */ rotate(angle) { ASSERT_NUMBER_VALID(angle); const c = cos(-angle), s = sin(-angle); return new Vector2(this.x*c - this.y*s, this.x*s + this.y*c); } /** Sets this vector to point in the specified integer direction (0-3), corresponding to multiples of 90 degree rotation * @param {number} direction * @param {number} [length] * @return {Vector2} */ setDirection(direction, length=1) { ASSERT_NUMBER_VALID(direction); ASSERT_NUMBER_VALID(length); direction = mod(direction, 4); ASSERT(direction===0 || direction===1 || direction===2 || direction===3, 'Vector2.setDirection() direction must be an integer between 0 and 3.'); this.x = direction%2 ? direction-1 ? -length : length : 0; this.y = direction%2 ? 0 : direction ? -length : length; return this; } /** Returns the integer direction of this vector, corresponding to multiples of 90 degree rotation (0-3) * @return {number} */ direction() { return abs(this.x) > abs(this.y) ? this.x < 0 ? 3 : 1 : this.y < 0 ? 2 : 0; } /** Returns a copy of this vector with absolute values * @return {Vector2} */ abs() { return new Vector2(abs(this.x), abs(this.y)); } /** Returns a copy of this vector with each axis floored * @return {Vector2} */ floor() { return new Vector2(floor(this.x), floor(this.y)); } /** Returns a copy of this vector with each axis rounded * @return {Vector2} */ round() { return new Vector2(round(this.x), round(this.y)); } /** Returns a copy of this vector snapped down to a grid. Note that `grid` is * the number of snap steps per unit (so `grid=2` snaps to halves and * `grid=0.5` snaps to twos), not the cell size. * @param {number} grid - snap steps per unit * @return {Vector2} */ snap(grid) { ASSERT_NUMBER_VALID(grid); return new Vector2(floor(this.x*grid)/grid, floor(this.y*grid)/grid); } /** Returns new vec2 with modded values * @param {number} [divisor] * @return {Vector2} */ mod(divisor=1) { return new Vector2(mod(this.x, divisor), mod(this.y, divisor)); } /** Returns the area this vector covers as a rectangle * @return {number} */ area() { return abs(this.x * this.y); } /** Returns a new vector that is p percent between this and the vector passed in, percent is clamped to 0-1 * @param {Vector2} v - other vector * @param {number} percent * @return {Vector2} */ lerp(v, percent) { ASSERT_VECTOR2_VALID(v); ASSERT_NUMBER_VALID(percent); const p = clamp(percent); return new Vector2(v.x*p + this.x*(1-p), v.y*p + this.y*(1-p)); } /** Returns true if this vector is within the bounds of an array size passed in * @param {Vector2} arraySize * @return {boolean} */ arrayCheck(arraySize) { return this.x >= 0 && this.y >= 0 && this.x < arraySize.x && this.y < arraySize.y; } /** Returns this vector expressed as a string * @param {number} [digits] - precision to display * @return {string} */ toString(digits=3) { ASSERT_NUMBER_VALID(digits); if (this.isValid()) return `(${(this.x<0?'':' ') + this.x.toFixed(digits)},${(this.y<0?'':' ') + this.y.toFixed(digits)} )`; else return `(${this.x}, ${this.y})`; } /** Checks if this is a valid vector * @return {boolean} */ isValid() { return isNumber(this.x) && isNumber(this.y); } } /////////////////////////////////////////////////////////////////////////////// /** * Create a color object with RGBA values, white by default * @param {number} [r=1] - red * @param {number} [g=1] - green * @param {number} [b=1] - blue * @param {number} [a=1] - alpha * @return {Color} * @memberof Math */ function rgb(r, g, b, a) { debug && (r > 2 || g > 2 || b > 2) && colorRangeWarn('rgb(255, 0, 0) is rgb(1, 0, 0)'); return new Color(r, g, b, a); } // a color given as 0 to 255 or as percents, as other tools take them, said once in a debug build; a value a little // past 1 is a bright color, which is why only past 2 counts let colorRangeWarned = false; function colorRangeWarn(example) { if (colorRangeWarned) return; colorRangeWarned = true; console.warn('color values are 0 to 1 here, not 0 to 255 or percents: ' + example); } /** * Create a color object with HSLA values, white by default * @param {number} [h=0] - hue * @param {number} [s=0] - saturation * @param {number} [l=1] - lightness * @param {number} [a=1] - alpha * @return {Color} * @memberof Math */ function hsl(h, s, l, a) { debug && (s > 2 || l > 2) && colorRangeWarn('hsl(.5, 100, 50) is hsl(.5, 1, .5)'); return new Color().setHSLA(h, s, l, a); } /** * Check if object is a valid Color * @param {any} c * @return {boolean} * @memberof Math */ function isColor(c) { return c instanceof Color && c.isValid(); } // color asserts function ASSERT_COLOR_VALID(c) { ASSERT(isColor(c), 'expected a color', c); } /** * Color object (red, green, blue, alpha) with some helpful functions * @memberof Engine * @example * let a = new Color; // white * let b = new Color(1, 0, 0); // red * let c = new Color(0, 0, 0, 0); // transparent black * let d = rgb(0, 0, 1); // blue using rgb color * let e = hsl(.3, 1, .5); // green using hsl color */ class Color { /** Create a color with the rgba components passed in, white by default * @param {number} [r] - red * @param {number} [g] - green * @param {number} [b] - blue * @param {number} [a] - alpha*/ constructor(r=1, g=1, b=1, a=1) { /** @property {number} - Red */ this.r = r; /** @property {number} - Green */ this.g = g; /** @property {number} - Blue */ this.b = b; /** @property {number} - Alpha */ this.a = a; ASSERT(this.isValid(), 'Color: r, g, b and a must be numbers', this); } /** Sets values of this color and returns self * @param {number} [r] - red * @param {number} [g] - green * @param {number} [b] - blue * @param {number} [a] - alpha * @return {Color} */ set(r=1, g=1, b=1, a=1) { this.r = r; this.g = g; this.b = b; this.a = a; ASSERT_COLOR_VALID(this); return this; } /** Sets this color from another color and returns self * @param {Color} c - other color * @return {Color} */ setFrom(c) { return this.set(c.r, c.g, c.b, c.a); } /** Sets the alpha of this color and returns self * @param {number} [a] - alpha * @return {Color} */ setAlpha(a=1) { this.a = a; ASSERT_COLOR_VALID(this); return this; } /** Returns a new color that is a copy of this * @return {Color} */ copy() { return new Color(this.r, this.g, this.b, this.a); } /** Returns a copy of this color with the alpha set * @param {number} [a] - alpha * @return {Color} */ withAlpha(a=1) { return new Color(this.r, this.g, this.b, a); } /** Returns a copy of this color plus the color passed in * @param {Color} c - other color * @return {Color} */ add(c) { return new Color(this.r+c.r, this.g+c.g, this.b+c.b, this.a+c.a); } /** Returns a copy of this color minus the color passed in * @param {Color} c - other color * @return {Color} */ subtract(c) { return new Color(this.r-c.r, this.g-c.g, this.b-c.b, this.a-c.a); } /** Returns a copy of this color times the color passed in * @param {Color} c - other color * @return {Color} */ multiply(c) { return new Color(this.r*c.r, this.g*c.g, this.b*c.b, this.a*c.a); } /** Returns a copy of this color divided by the color passed in * @param {Color} c - other color * @return {Color} */ divide(c) { return new Color(this.r/c.r, this.g/c.g, this.b/c.b, this.a/c.a); } /** Returns a copy of this color scaled by the value passed in, alpha can be scaled separately * @param {number} scale * @param {number} [alphaScale=scale] * @return {Color} */ scale(scale, alphaScale=scale) { return new Color(this.r*scale, this.g*scale, this.b*scale, this.a*alphaScale); } /** Returns a copy of this color clamped to the valid range between 0 and 1 * @return {Color} */ clamp() { return new Color(clamp(this.r), clamp(this.g), clamp(this.b), clamp(this.a)); } /** Returns a new color that is p percent between this and the color passed in, percent is clamped to 0-1 * @param {Color} c - other color * @param {number} percent * @return {Color} */ lerp(c, percent) { ASSERT_COLOR_VALID(c); ASSERT_NUMBER_VALID(percent); const p = clamp(percent); return new Color( c.r*p + this.r*(1-p), c.g*p + this.g*(1-p), c.b*p + this.b*(1-p), c.a*p + this.a*(1-p)); } /** Sets this color given a hue, saturation, lightness, and alpha * @param {number} [h] - hue * @param {number} [s] - saturation * @param {number} [l] - lightness * @param {number} [a] - alpha * @return {Color} */ setHSLA(h=0, s=0, l=1, a=1) { h = mod(h,1); s = clamp(s); l = clamp(l); const q = l < .5 ? l*(1+s) : l+s-l*s, p = 2*l-q, f = (p, q, t)=> (t = mod(t,1))*6 < 1 ? p+(q-p)*6*t : t*2 < 1 ? q : t*3 < 2 ? p+(q-p)*(4-t*6) : p; this.r = f(p, q, h + 1/3); this.g = f(p, q, h); this.b = f(p, q, h - 1/3); this.a = a; ASSERT_COLOR_VALID(this); return this; } /** Returns this color expressed in hsla format * @return {[number, number, number, number]} - hue, saturation, lightness and alpha */ HSLA() { const r = clamp(this.r); const g = clamp(this.g); const b = clamp(this.b); const a = clamp(this.a); const maxC = max(r, g, b); const minC = min(r, g, b); const l = (maxC + minC) / 2; let h = 0, s = 0; if (maxC !== minC) { let d = maxC - minC; s = l > .5 ? d / (2 - maxC - minC) : d / (maxC + minC); if (r === maxC) h = (g - b) / d + (g < b ? 6 : 0); else if (g === maxC) h = (b - r) / d + 2; else if (b === maxC) h = (r - g) / d + 4; } return [h / 6, s, l, a]; } /** Returns a new color that has each component randomly adjusted * @param {number} [amount] * @param {number} [alphaAmount] * @return {Color} */ mutate(amount=.05, alphaAmount=0) { ASSERT_NUMBER_VALID(amount); ASSERT_NUMBER_VALID(alphaAmount); return new Color ( this.r + rand(amount, -amount), this.g + rand(amount, -amount), this.b + rand(amount, -amount), this.a + rand(alphaAmount, -alphaAmount) ).clamp(); } /** Returns this color expressed as a hex color code * @param {boolean} [useAlpha] - if alpha should be included in result * @return {string} */ toString(useAlpha = true) { if (!this.isValid()) return '#000'; // in release too, as a canvas would keep its last color for #NaN // two hex digits of a channel of 0 to 1, kept in here as the script build shares its top level with the game const colorHex = (c)=> ((c = round(clamp(c) * 255)) < 16 ? '0' : '') + c.toString(16); return '#' + colorHex(this.r) + colorHex(this.g) + colorHex(this.b) + (useAlpha ? colorHex(this.a) : ''); } /** Set this color from a hex code * @param {string} hex - html hex code * @return {Color} */ setHex(hex) { ASSERT(isStringLike(hex), 'Color hex code must be a string'); ASSERT(hex[0] === '#', 'Color hex code must start with #'); ASSERT([4,5,7,9].includes(hex.length), 'setHex: use #rgb, #rgba, #rrggbb or #rrggbbaa', hex); if (hex.length < 6) { const fromHex = (c)=> clamp(parseInt(hex[c],16)/15); this.r = fromHex(1); this.g = fromHex(2); this.b = fromHex(3); this.a = hex.length === 5 ? fromHex(4) : 1; } else { const fromHex = (c)=> clamp(parseInt(hex.slice(c,c+2),16)/255); this.r = fromHex(1); this.g = fromHex(3); this.b = fromHex(5); this.a = hex.length === 9 ? fromHex(7) : 1; } ASSERT_COLOR_VALID(this); return this; } /** Returns this color expressed as 32 bit RGBA value * @return {number} */ rgbaInt() { // round like toString, so WebGL and Canvas2D colors match const r = clamp(this.r)*255+.5|0; const g = clamp(this.g)*255+.5<<8; const b = clamp(this.b)*255+.5<<16; const a = clamp(this.a)*255+.5<<24; return r + g + b + a; } /** Checks if this is a valid color * @return {boolean} */ isValid() { return isNumber(this.r) && isNumber(this.g) && isNumber(this.b) && isNumber(this.a); } } /////////////////////////////////////////////////////////////////////////////// // Default Colors /** Color - White #ffffff * @type {Color} * @memberof Math */ const WHITE = debugProtectConstant(rgb()); /** Color - Clear White #ffffff00 with 0 alpha * @type {Color} * @memberof Math */ const CLEAR_WHITE = debugProtectConstant(rgb(1,1,1,0)); /** Color - Black #000000 * @type {Color} * @memberof Math */ const BLACK = debugProtectConstant(rgb(0,0,0)); /** Color - Clear Black #00000000 with 0 alpha * @type {Color} * @memberof Math */ const CLEAR_BLACK = debugProtectConstant(rgb(0,0,0,0)); /** Color - Gray #808080 * @type {Color} * @memberof Math */ const GRAY = debugProtectConstant(rgb(.5,.5,.5)); /** Color - Red #ff0000 * @type {Color} * @memberof Math */ const RED = debugProtectConstant(rgb(1,0,0)); /** Color - Orange #ff8000 * @type {Color} * @memberof Math */ const ORANGE = debugProtectConstant(rgb(1,.5,0)); /** Color - Yellow #ffff00 * @type {Color} * @memberof Math */ const YELLOW = debugProtectConstant(rgb(1,1,0)); /** Color - Green #00ff00 * @type {Color} * @memberof Math */ const GREEN = debugProtectConstant(rgb(0,1,0)); /** Color - Cyan #00ffff * @type {Color} * @memberof Math */ const CYAN = debugProtectConstant(rgb(0,1,1)); /** Color - Blue #0000ff * @type {Color} * @memberof Math */ const BLUE = debugProtectConstant(rgb(0,0,1)); /** Color - Purple #8000ff * @type {Color} * @memberof Math */ const PURPLE = debugProtectConstant(rgb(.5,0,1)); /** Color - Magenta #ff00ff * @type {Color} * @memberof Math */ const MAGENTA = debugProtectConstant(rgb(1,0,1)); /** * LittleJS Utility Classes and Functions * - Timer - tracks time automatically with support for pause and real-time modes * - Time formatting helper * - JSON file fetching * - File saving (text, canvas, data URLs) * - Native share dialog support * - Local storage save data management * - Value noise (1D and 2D) * @namespace Utilities */ /** * Timer object tracks how long has passed since it was set * @memberof Engine * @example * let a = new Timer; // creates a timer that is not set * a.set(3); // sets the timer to 3 seconds * * let b = new Timer(1); // creates a timer with 1 second left * b.unset(); // unset the timer */ class Timer { /** Create a timer object set time passed in * @param {number} [timeLeft] - How much time left before the timer is elapsed in seconds (undefined = unset) * @param {boolean} [useRealTime] - Should the timer keep running even when the game is paused? (useful for UI) */ constructor(timeLeft, useRealTime=false) { ASSERT(timeLeft === undefined || isNumber(timeLeft), 'Timer: time must be a number of seconds', timeLeft); this.useRealTime = useRealTime; const globalTime = this.getGlobalTime(); /** @type {number|undefined} */ this.time = timeLeft === undefined ? undefined : globalTime + timeLeft; /** @type {number|undefined} */ this.setTime = timeLeft; } /** Set the timer with seconds passed in * @param {number} [timeLeft] - How much time left before the timer is elapsed in seconds */ set(timeLeft=0) { ASSERT(isNumber(timeLeft), 'Timer.set: time must be a number of seconds', timeLeft); const globalTime = this.getGlobalTime(); this.time = globalTime + timeLeft; this.setTime = timeLeft; } /** Set if the timer should keep running even when the game is paused * @param {boolean} [useRealTime] */ setUseRealTime(useRealTime=true) { ASSERT(!this.isSet(), 'Cannot change global time setting while timer is set.'); this.useRealTime = useRealTime; } /** Unset the timer */ unset() { this.time = undefined; } /** Returns true if set * @return {boolean} */ isSet() { return this.time !== undefined; } /** Returns true if set and has not elapsed * @return {boolean} */ active() { return this.getGlobalTime() < this.time; } /** Returns true if set and elapsed * @return {boolean} */ elapsed() { return this.getGlobalTime() >= this.time; } /** Get how long since elapsed, returns 0 if not set (returns negative if currently active) * @return {number} */ get() { return this.isSet()? this.getGlobalTime() - this.time : 0; } /** Get percentage elapsed based on time it was set to, returns 0 if not set. * Zero-duration timers report 1 (already elapsed). * @return {number} */ getPercent() { if (!this.isSet()) return 0; if (!(this.setTime > 0)) return 1; return 1 - percent(this.time - this.getGlobalTime(), 0, this.setTime); } /** Get the time this timer was set to, returns 0 if not set * @return {number} */ getSetTime() { return this.isSet() ? this.setTime : 0; } /** Get the current global time this timer is based on * @return {number} */ getGlobalTime() { return this.useRealTime ? timeReal : time; } /** Returns this timer expressed as a string * @return {string} */ toString() { return this.isSet() ? abs(this.get()) + ' seconds ' + (this.get()<0 ? 'before' : 'after' ) : 'unset'; } /** Get how long since elapsed, returns 0 if not set (returns negative if currently active) * @return {number} */ valueOf() { return this.get(); } } /////////////////////////////////////////////////////////////////////////////// /** Formats seconds to mm:ss style for display purposes * @param {number} t - time in seconds * @return {string} * @memberof Utilities */ function formatTime(t) { const signStr = t <= -1 ? '-' : ''; // no sign when it shows 0:00 t = abs(t)|0; return signStr + (t/60|0) + ':' + (t%60<10?'0':'') + t%60; } /** Fetches a JSON file from a URL and returns the parsed JSON object. Must be used with await! * @param {string} url - URL of JSON file * @return {Promise} - The parsed JSON, any shape, as response.json() gives it * @memberof Utilities */ async function fetchJSON(url) { const response = await loadFetch(url, 'fetchJSON'); if (!response.ok) throw new Error(`fetchJSON: could not load ${url}, HTTP ${response.status} ${response.statusText}`); const text = await response.text(); let json; try { json = JSON.parse(text); } catch (e) { // a dev server answers a mistyped path with its own page, which is no JSON, so the error says which file throw new Error(`fetchJSON: ${url} is not JSON` + (loadIsWebPage(text) ? ', it is a web page, so the path may be wrong' : ': ' + e.message)); } editorJSONFetched(url, json); // debug builds remember the file a level came from, to save it by that name return json; } // is a file's text a web page, as a dev server sends for a path it does not have const loadIsWebPage = (text)=> /^\s* URL.revokeObjectURL(url), revokeTime); } /** Share content using the native share dialog if available * @param {string} title - title of the share * @param {string} url - url to share * @param {Function} [callback] - Called when share is complete * @memberof Utilities */ function shareURL(title, url, callback) { ASSERT(isStringLike(title), 'shareURL requires title string'); ASSERT(isStringLike(url), 'shareURL requires url string'); navigator.share?.({title, url}).then(()=>callback?.(), ()=>{}); // a player who cancels is not an error } /////////////////////////////////////////////////////////////////////////////// /** Read save data from local storage * - The result has the type of defaultSaveData, or any when there is none * @template {Object} [T=any] * @param {string} saveName - unique name for the game/save * @param {T} [defaultSaveData] - default values, result is {...default, ...loaded} so this must be an object * @return {T} * @memberof Utilities */ function readSaveData(saveName, defaultSaveData) { ASSERT(isStringLike(saveName), 'readSaveData requires saveName string'); ASSERT(defaultSaveData === undefined || (typeof defaultSaveData === 'object' && defaultSaveData !== null), 'readSaveData: default must be an object - the result is ' + '{...default, ...loaded}, so a scalar default yields {}. ' + 'Use readSaveData(key, {best:0}).best'); // tolerate localStorage being unavailable (iOS private mode, sandboxed // iframes) and corrupt JSON in stored data let loadedData = {}; try { const data = localStorage.getItem(saveName); // not the brackets, a name like 'key' or 'length' is a Storage member if (data) { try { loadedData = JSON.parse(data); } catch { console.warn('readSaveData: the save ' + saveName + ' can not be read, using the defaults'); } if (!loadedData || typeof loadedData !== 'object' || isArray(loadedData)) loadedData = {}; // only an object of saved values, never a string spread into its letters } } catch { LOG('readSaveData: localStorage unavailable — using defaults'); } return { .../** @type {object} */ (defaultSaveData), ...loadedData }; } /** Write save data to local storage, an object as readSaveData gives it back * @param {string} saveName - unique name for the game/save * @param {object} saveData - object containing data to be saved * @return {boolean} - Whether it was written, false when storage is unavailable or full * @memberof Utilities */ function writeSaveData(saveName, saveData) { ASSERT(isStringLike(saveName), 'writeSaveData requires saveName string'); ASSERT(typeof saveData === 'object' && saveData !== null && !isArray(saveData), 'writeSaveData: save data must be an object, readSaveData reads it back into one'); // tolerate localStorage being unavailable or quota exceeded; in release too, a player's save going nowhere is // not silent, and the warning says which it was let text; try { text = JSON.stringify(saveData); } catch (error) { console.warn('writeSaveData: the save ' + saveName + ' can not be written as JSON, ' + error.message); return false; } if (text === undefined) // a toJSON that returns nothing, which would be stored as the text undefined { console.warn('writeSaveData: the save ' + saveName + ' is nothing as JSON, it is not written'); return false; } try { localStorage.setItem(saveName, text); return true; } catch { console.warn('writeSaveData: the save ' + saveName + ' could not be written, storage is full or off'); return false; } } /////////////////////////////////////////////////////////////////////////////// // Deterministic well-distributed hash of an integer lattice index to [0, 1). // Murmur3 finalizer — adjacent integers produce uncorrelated outputs. function noiseHash(i) { let h = (i | 0) ^ 0x9e3779b9; h = Math.imul(h ^ (h >>> 16), 0x85ebca6b); h = Math.imul(h ^ (h >>> 13), 0xc2b2ae35); h ^= h >>> 16; return (h >>> 0) / 2**32; } /** 1D value noise — returns a smooth value in [0, 1] for any real x. * Integer inputs land on deterministic lattice values; non-integer inputs * are interpolated with smoothStep for C1 continuity. * @param {number} x * @return {number} * @memberof Utilities */ function noise1D(x) { const i = floor(x); return lerp(noiseHash(i), noiseHash(i + 1), smoothStep(x - i)); } /** 2D value noise — returns a smooth value in [0, 1] for any real (x, y) * @param {number} x * @param {number} y * @return {number} * @memberof Utilities */ function noise2D(x, y) { const ix = floor(x), iy = floor(y); const fx = smoothStep(x - ix), fy = smoothStep(y - iy); // large prime decorrelates neighboring rows; multiplied as 32 bit integers, which is what the hash keeps of it, // so the row stays exact however far out y is, where a plain product would lose its low bits const h = (a, b) => noiseHash(a + Math.imul(b, 374761393)); return lerp( lerp(h(ix, iy ), h(ix + 1, iy ), fx), lerp(h(ix, iy + 1), h(ix + 1, iy + 1), fx), fy); } /** * LittleJS Engine Settings * - All settings for the engine are here * @namespace Settings */ /////////////////////////////////////////////////////////////////////////////// // Camera settings /** Position of camera in world space * @type {Vector2} * @default Vector2() * @memberof Settings */ let cameraPos = vec2(); /** Rotation angle of camera in world space * @type {number} * @default * @memberof Settings */ let cameraAngle = 0; /** Scale of camera in world space * @type {number} * @default * @memberof Settings */ let cameraScale = 32; /////////////////////////////////////////////////////////////////////////////// // Time settings /** Scale applied to engine time, can be used for slow motion or fast forward * - 1 is normal speed, 2 is double speed, 0.5 is half speed * - 0 freezes the game like a pause without setting the paused flag, gameUpdatePost and input still run * - Should be >= 0; stacks multiplicatively with the debug +/- shortcut * @type {number} * @default * @memberof Settings */ let timeScale = 1; /////////////////////////////////////////////////////////////////////////////// // Display settings /** Enable applying color to tiles when using canvas2d * - This is slower but should be the same as WebGL rendering * @type {boolean} * @default * @memberof Settings */ let canvasColorTiles = true; /** Color to clear the canvas to before render, does not clear if alpha is 0 * @type {Color} * @memberof Settings */ let canvasClearColor = CLEAR_BLACK; /** The max size of the canvas in css pixels, centered if window is larger * - Not affected by canvasPixelRatio, the backing store may be larger than this * @type {Vector2} * @default Vector2(3840,2160) * @memberof Settings */ let canvasMaxSize = vec2(3840, 2160); /** Minimum aspect ratio of the canvas (width/height), unused if 0 * Can be used with canvasMaxAspect to limit aspect ratio * @type {number} * @default * @memberof Settings */ let canvasMinAspect = 0; /** Maximum aspect ratio of the canvas (width/height), unused if 0 * Can be used with canvasMinAspect to limit aspect ratio * @type {number} * @default * @memberof Settings */ let canvasMaxAspect = 0; /** Fixed size of the canvas in css pixels, if enabled canvas size never changes * - canvasPixelRatio still applies, it only scales the backing store * @type {Vector2} * @default Vector2() * @memberof Settings */ let canvasFixedSize = vec2(); /** Use nearest canvas scaling for more pixelated look * - If enabled sets css image-rendering:pixelated * @type {boolean} * @default * @memberof Settings */ let canvasPixelated = false; /** Disables texture filtering for crisper pixel art * - Leave true for pixel art so sprites stay sharp when scaled (uses NEAREST filtering) * - Set false for smooth/high-resolution art to enable bilinear filtering and mipmaps; those textures upload * premultiplied, so filtering does not pull dark from see through texels into the edges * @type {boolean} * @default * @memberof Settings */ let tilesPixelated = true; /** Scale factor applied to the canvas resolution for sharper rendering * Pass 1 for no scaling, a number for an explicit ratio, or undefined to track devicePixelRatio each frame. * - Only the backing store scales, so this changes sharpness and nothing else * - mainCanvasSize, cameraScale, mousePos and screen space stay in css pixels, * so the same code draws the same size at any ratio * - Pixel art usually looks best left at 1 or set to whole numbers, * a fractional ratio samples texels unevenly * @type {number|undefined} * @default * @memberof Settings */ let canvasPixelRatio = 1; /** Default font used for text rendering * @type {string} * @default * @memberof Settings */ let fontDefault = 'arial'; /** Enable to show the LittleJS splash screen on startup, must be set before engineInit * @type {boolean} * @default * @memberof Settings */ let showSplashScreen = false; /** The loading screen, drawn after the splash while the images, gameInit and what they load are loading, once that * takes more than half a second: a function given the part done, 0 to 1, or undefined for none, which leaves the * screen blank; the default says Loading over a bar * @type {LoadingScreenCallback|undefined} * @memberof Settings */ let loadingScreen = drawLoadingScreen; /** Disables all rendering, audio, and input for servers, must be set before engineInit * @type {boolean} * @default * @memberof Settings */ let headlessMode = false; /** Disables the automatic requestAnimationFrame loop so the engine only * advances when engineStep is called, for tests and frame-stepping tools * @type {boolean} * @default * @memberof Settings */ let engineManualStep = false; /** Run one update per display frame with timeDelta the time it covers, in place of fixed updates at frameRate * - Per-frame values are not scaled: velocity, gravity, damping and particle speeds act once per update, so a * game that turns this on scales its own movement by timeDelta * - Values in seconds follow real time as they are: time, timers, particle emit rate and life, animation, Box2D * @type {boolean} * @default * @memberof Settings */ let engineVariableStep = false; /////////////////////////////////////////////////////////////////////////////// // WebGL settings /** Enable WebGL accelerated rendering * @type {boolean} * @default * @memberof Settings */ let glEnable = true; /** Draw in screen space by default: what the screenSpace parameter of the draw and debug functions defaults to, so a * heads up display, or a 3D game that only draws 2D on the screen, can turn it on and leave the flag out * - The engine says its own space when it draws, so objects, tile layers and particles stay in world space, but a * game's own draws follow it, a render() override included: turn it on around the hud, or pass false there * - Sizes and widths keep their defaults, which are world sized, so pass them in pixels * - drawText has no screenSpace and stays in world space, drawTextScreen is the one for the screen * @type {boolean} * @default * @memberof Settings */ let drawScreenSpace = false; /** How many sided poly to use when drawing circles and ellipses with WebGL * @type {number} * @default * @memberof Settings */ let glCircleSides = 32; /////////////////////////////////////////////////////////////////////////////// // Tile sheet settings /** Default size of tiles in pixels * @type {Vector2} * @default Vector2(16,16) * @memberof Settings */ let tileDefaultSize = vec2(16); /** Default padding pixels around tiles * @type {number} * @default * @memberof Settings */ let tileDefaultPadding = 0; /** Default amount of pixels smaller to draw tiles to prevent neighbor bleeding * @type {number} * @default * @memberof Settings */ let tileDefaultBleed = 0; /////////////////////////////////////////////////////////////////////////////// // Object settings /** Enable physics solver for collisions, between objects and with tiles * @type {boolean} * @default * @memberof Settings */ let enablePhysicsSolver = true; /** Default object mass for collision calculations (how heavy objects are) * @type {number} * @default * @memberof Settings */ let objectDefaultMass = 1; /** Fraction of velocity objects keep each frame, 1 keeps all of it, 0 stops at once * @type {number} * @default * @memberof Settings */ let objectDefaultDamping = 1; /** Fraction of angular velocity objects keep each frame, 1 keeps all of it, 0 stops at once * @type {number} * @default * @memberof Settings */ let objectDefaultAngleDamping = 1; /** How much to bounce when a collision occurs (0-1) * @type {number} * @default * @memberof Settings */ let objectDefaultRestitution = 0; /** Fraction of sliding speed objects keep each frame on the ground, 1 is no friction, 0 stops at once * - The more slippery of an object and its ground is used * @type {number} * @default * @memberof Settings */ let objectDefaultFriction = .8; /** Clamp max speed to avoid fast objects missing collisions, in world units per frame on each axis * @type {number} * @default * @memberof Settings */ let objectMaxSpeed = 1; /** How much gravity to apply to objects, negative Y is down, in world units per frame per frame * @type {Vector2} * @default * @memberof Settings */ let gravity = vec2(); /** Scales emit rate of particles, useful for low graphics mode (0 disables particle emitters) * @type {number} * @default * @memberof Settings */ let particleEmitRateScale = 1; /////////////////////////////////////////////////////////////////////////////// // Input settings /** Should gamepads be allowed * @type {boolean} * @default * @memberof Settings */ let gamepadsEnable = true; /** If true, the dpad input is also routed to the left analog stick (for better accessibility) * @type {boolean} * @default * @memberof Settings */ let gamepadDirectionEmulateStick = true; /** If true, axes that do not rest near center are ignored on gamepads without * standard mapping. Steering wheels and flight sticks report pedal and throttle * axes that rest at full deflection, which otherwise reads as a stick held down. * @type {boolean} * @default * @memberof Settings */ let gamepadAxisFilterEnable = true; /** If true the WASD keys are also routed to the direction keys (for better accessibility) * - Turn it off for a game with two players on one keyboard, one on WASD and one on the arrows * @type {boolean} * @default * @memberof Settings */ let inputWASDEmulateDirection = true; /** True if touch input is enabled for mobile devices * - Touch events will be routed to mouse events * @type {boolean} * @default * @memberof Settings */ let touchInputEnable = true; /** True if touch gamepad should appear on mobile devices * - Supports left analog stick, 4 face buttons and start button (button 9) * - setTouchGamepadRightStick(true) for a right analog stick in place of the face buttons * - Analog stick buttons 10 and 11 are also activated when virtual sticks are touched * - Rendered as a full-viewport HTML/SVG overlay, so controls may sit outside the game canvas * - It is gamepad 0 once touched; a real gamepad being used takes over and hides it until the screen is touched again * @type {boolean} * @default * @memberof Settings */ let touchGamepadEnable = false; /** True if touches outside the gamepad controls should still drive mouse/touch input * - When false (the default), enabling the touch gamepad suppresses touch-to-mouse input entirely * - Set true to also pass touches outside the controls through to the game as mouse/touch input * - Touches on the gamepad controls never drive the mouse regardless of this setting * @type {boolean} * @default * @memberof Settings */ let touchGamepadPassthrough = false; /** True if a pinch of two fingers turns the mouse wheel: touchPinch is added to mouseWheel, so what zooms with * the wheel zooms with a pinch, the mouse is between the two fingers, and a second finger lets go of the button * the first pressed, since a pinch is not a drag; turn it off for a game with its own use for two fingers, the * first finger stays the mouse and touchPinch can still be read * @type {boolean} * @default * @memberof Settings */ let touchPinchWheel = true; /** Size of center button if touch gamepad should have start button in the center * - Prevents activating when pressed near virtual stick or face buttons * - When the game is paused, any touch will press the button * - Measured in viewport CSS pixels * @type {number} * @default * @memberof Settings */ let touchGamepadCenterButtonSize = 0; /** Number of buttons on the right side of the touch gamepad (0-4), using gamepad buttons 0-3 * - A count of 1 is a single large button (the size of a stick) * - Ignored when touchGamepadRightStick is set (the right side is a stick instead) * @type {number} * @default * @memberof Settings */ let touchGamepadButtonCount = 4; /** True if the touch gamepad should have a left analog stick (or dpad) * - When false, the left side is face buttons (touchGamepadLeftButtonCount) or nothing * @type {boolean} * @default * @memberof Settings */ let touchGamepadLeftStick = true; /** Number of buttons on the left side of the touch gamepad (0-4), using gamepad buttons 4-7 * - Only used when touchGamepadLeftStick is false (otherwise the left side is a stick) * - A count of 1 is a single large button (the size of a stick) * @type {number} * @default * @memberof Settings */ let touchGamepadLeftButtonCount = 0; /** True if the touch gamepad right side should be an analog stick (or dpad) instead of face buttons * - When set, touchGamepadButtonCount is ignored and the right side is a stick * - Uses an analog stick when touchGamepadAnalog is true, otherwise an 8 way dpad * @type {boolean} * @default * @memberof Settings */ let touchGamepadRightStick = false; /** True if touch gamepad should be analog stick or false to use if 8 way dpad * @type {boolean} * @default * @memberof Settings */ let touchGamepadAnalog = true; /** True if touch gamepad directional controls should float to where you press * - Only affects analog sticks and dpads, not face buttons * - Directional controls re-anchor to where you press within the bottom ~60% of their screen half; the top ~40% passes through to the game * - The right side floats only when it acts as the right analog stick (touchGamepadRightStick is set) * - A center button (touchGamepadCenterButtonSize) still works since it ignores touches near the sticks * @type {boolean} * @default * @memberof Settings */ let touchGamepadFloating = false; /** Size of virtual gamepad for touch devices in viewport CSS pixels * @type {number} * @default * @memberof Settings */ let touchGamepadSize = 100; /** Transparency of touch gamepad overlay * @type {number} * @default * @memberof Settings */ let touchGamepadAlpha = .3; /** How long to display the touch gamepad on screen in seconds, set to 0 to always display * @type {number} * @default * @memberof Settings */ let touchGamepadDisplayTime = 3; /** Duration in ms to vibrate when a touch gamepad face button or start button is pressed * - Set to 0 to disable, also requires vibrateEnable and hardware support (ignored on iOS) * @type {number} * @default * @memberof Settings */ let touchGamepadVibration = 0; /** Allow vibration hardware if it exists * @type {boolean} * @default * @memberof Settings */ let vibrateEnable = true; /////////////////////////////////////////////////////////////////////////////// // Audio settings /** All audio code can be disabled and removed from build * @type {boolean} * @default * @memberof Settings */ let soundEnable = true; /** Play sound with an iPhone's silent switch on, as media does; off by default, so the switch mutes the game as it * does a ringtone, and the player's own music keeps playing beside it; Safari 16.4 and up, elsewhere it does nothing * @type {boolean} * @default * @memberof Settings */ let soundIgnoreSilentSwitch = false; /** Volume scale to apply to all sound, music and speech * Use setSoundVolume to also update the audio master gain immediately * @type {number} * @default * @memberof Settings */ let soundVolume = .3; /** Default range where sound no longer plays * @type {number} * @default * @memberof Settings */ let soundDefaultRange = 40; /** Default range percent to start tapering off sound (0-1) * @type {number} * @default * @memberof Settings */ let soundDefaultTaper = .7; /** Pause all sound while the page is hidden, and pick up where it was when it shows again * - A hidden page stops the game, so without this a looping sound plays on over a frozen game * - Turn it off to keep music playing in a background tab * @type {boolean} * @default * @memberof Settings */ let soundPauseWhenHidden = true; /////////////////////////////////////////////////////////////////////////////// // Setters for global variables /** Set position of camera in world space * @param {Vector2} pos * @memberof Settings */ function setCameraPos(pos) { ASSERT(isVector2(pos), 'setCameraPos: pos is a vec2, like vec2(5, 2)', pos); cameraPos = pos.copy(); } /** Set angle of camera in world space * @param {number} angle * @memberof Settings */ function setCameraAngle(angle) { cameraAngle = angle; } /** Set scale of camera in world space * @param {number} scale * @memberof Settings */ function setCameraScale(scale) { cameraScale = scale; } /** Set scale applied to engine time * - 0 freezes the game like a pause, gameUpdatePost and input still run so the game can set it back * - Above 1 the fixed step runs that many updates each frame, so a large scale costs as many updates * @param {number} scale - 0 or more * @memberof Settings */ function setTimeScale(scale) { ASSERT(scale >= 0, 'time scale must be 0 or more'); timeScale = max(0, scale); // a negative scale would build a debt of time the game then waits out } /** Set if tiles should be colorized when using canvas2d * This can be slower but results should look nearly identical to WebGL rendering * It can be enabled/disabled at any time * Optimized for performance, and will use faster method if color is white or untextured * @param {boolean} colorTiles * @memberof Settings */ function setCanvasColorTiles(colorTiles) { canvasColorTiles = colorTiles; } /** Set color to clear the canvas to before render, does not clear if alpha is 0 * @param {Color} color * @memberof Settings */ function setCanvasClearColor(color) { ASSERT(isColor(color), 'setCanvasClearColor: color is a Color, like hsl(.6, .5, .2)', color); canvasClearColor = color.copy(); } /** Set max size of the canvas * @param {Vector2} size * @memberof Settings */ function setCanvasMaxSize(size) { ASSERT(isVector2(size), 'setCanvasMaxSize: size is a vec2, like vec2(1920, 1080)', size); canvasMaxSize = size.copy(); } /** Set minimum aspect ratio of the canvas (width/height), unused if 0 * @param {number} aspect * @memberof Settings */ function setCanvasMinAspect(aspect) { canvasMinAspect = aspect; } /** Set maximum aspect ratio of the canvas (width/height), unused if 0 * @param {number} aspect * @memberof Settings */ function setCanvasMaxAspect(aspect) { canvasMaxAspect = aspect; } /** Set fixed size of the canvas * @param {Vector2} size * @memberof Settings */ function setCanvasFixedSize(size) { ASSERT(isVector2(size), 'setCanvasFixedSize: size is a vec2, like vec2(1280, 720)', size); canvasFixedSize = size.copy(); } /** Use nearest scaling algorithm for canvas for more pixelated look * @param {boolean} pixelated * @memberof Settings */ function setCanvasPixelated(pixelated) { canvasPixelated = pixelated; if (mainCanvas) mainCanvas.style.imageRendering = pixelated ? 'pixelated' : ''; if (glCanvas) glCanvas.style.imageRendering = pixelated ? 'pixelated' : ''; } /** Disables texture filtering for crisper pixel art * - Leave true for pixel art; set false for smooth/high-resolution art * - Set it before engineInit, a texture already loaded keeps the filtering it was made with * @param {boolean} pixelated * @memberof Settings */ function setTilesPixelated(pixelated) { tilesPixelated = pixelated; } /** Set the canvas pixel ratio, scales the render resolution for sharper output * Pass a number for an explicit ratio, or call with no argument to track devicePixelRatio each frame. * - The canvas stays the same size on screen and everything draws the same * size, it just renders at a higher resolution so nothing looks blurry * - Game code is unaffected, it always works in css pixels * @param {number} [pixelRatio] * @example * // render at native resolution, capped so phones don't pay for 3x * setCanvasPixelRatio(min(devicePixelRatio, 2)); * @memberof Settings */ function setCanvasPixelRatio(pixelRatio) { canvasPixelRatio = pixelRatio; } /** Get the pixel ratio currently applied to the canvas backing store * - Resolves canvasPixelRatio, falling back to devicePixelRatio when it is undefined * - Game code works in css pixels so this is rarely needed, it is for sizing * render targets and viewports that must match the backing store * @return {number} * @memberof Settings */ function getCanvasPixelRatio() { return canvasPixelRatio ?? (devicePixelRatio || 1); } /** Set default font used for text rendering * - A family name with spaces or digits goes in quotes, as "'Press Start 2P'", or the canvas does not take it * @param {string} font * @memberof Settings */ function setFontDefault(font) { fontDefault = font; } /** Set if the LittleJS splash screen should be shown on startup, must be set before engineInit * @param {boolean} show * @memberof Settings */ function setShowSplashScreen(show) { showSplashScreen = show; } /** Set the loading screen, drawn after the splash while the game loads, once that takes more than half a second * @param {LoadingScreenCallback} [callback] - Draws on mainContext given the part done, 0 to 1; undefined for none * @memberof Settings */ function setLoadingScreen(callback) { loadingScreen = callback; } /** Set to disable rendering, audio, and input for servers, must be set before engineInit * @param {boolean} headless * @memberof Settings */ function setHeadlessMode(headless) { headlessMode = headless; } /** Set if the engine only advances when engineStep is called * Set before engineInit for a test or a server; turned on in a running game it stops the loop, and turned off * again it starts the loop from real time * @param {boolean} [enable] * @memberof Settings */ function setEngineManualStep(enable=true) { const resume = engineManualStep && !enable; if (enable && !engineManualStep) frameTimeBufferMS = -.5e3 / frameRate; // half a frame short, so each engineStep frame runs one update, // not one more from time the last real frame left over engineManualStep = enable; if (resume && engineUpdateInternal) { // engineStep may have run the clock ahead of real time, start from now so that is not waited out frameTimeLastMS = performance.now(); engineScheduleFrame(); } } /** Set if the engine runs one update per display frame with timeDelta the time it covers * Can be set before engineInit or while running; turned off it goes back to fixed updates at frameRate * @param {boolean} [enable] * @memberof Settings */ function setEngineVariableStep(enable=true) { if (engineVariableStep && !enable) { // fixed time goes on one fixed step after the last update, if one ran, and timeDelta is the fixed step again timeFixedStart = frame ? time + 1 / frameRate : time; frameFixedStart = frame; timeDelta = 1 / frameRate; frameTimeBufferMS = engineManualStep ? -.5e3 / frameRate : 0; } engineVariableStep = enable; } /** Set whether the draw and debug functions draw in screen space when a call leaves screenSpace out * @param {boolean} screenSpace * @memberof Settings */ function setDrawScreenSpace(screenSpace) { drawScreenSpace = screenSpace; } /** Set if WebGL rendering is enabled * @param {boolean} enable * @memberof Settings */ function setGLEnable(enable) { if (enable && !glCanBeEnabled) { console.warn('Can not enable WebGL if it was disabled on start.'); return; } glEnable = enable; if (glCanvas) // hide glCanvas if WebGL is disabled glCanvas.style.display = enable ? '' : 'none'; } /** Set how many sided polygons to use when drawing circles and ellipses with WebGL * @param {number} sides * @memberof Settings */ function setGLCircleSides(sides) { ASSERT(isNumber(sides) && sides >= 3, 'circles need at least 3 sides'); glCircleSides = sides; } /** Set default size of tiles in pixels * @param {Vector2} size * @memberof Settings */ function setTileDefaultSize(size) { ASSERT(isVector2(size), 'setTileDefaultSize: size is a vec2, like vec2(16)', size); tileDefaultSize = size.copy(); } /** Default padding pixels around tiles * @param {number} padding * @memberof Settings */ function setTileDefaultPadding(padding) { tileDefaultPadding = padding; } /** Default amount of pixels smaller to draw tiles to prevent neighbor bleeding * @param {number} bleed * @memberof Settings */ function setTileDefaultBleed(bleed) { tileDefaultBleed = bleed; } /** Set if collisions are enabled, between objects and with tiles * @param {boolean} enable * @memberof Settings */ function setEnablePhysicsSolver(enable) { enablePhysicsSolver = enable; } /** Set default object mass for collision calculations * @param {number} mass * @memberof Settings */ function setObjectDefaultMass(mass) { objectDefaultMass = mass; } /** Set the fraction of velocity objects keep each frame, 1 keeps all of it, 0 stops at once * @param {number} damp * @memberof Settings */ function setObjectDefaultDamping(damp) { ASSERT(damp >= 0 && damp <= 1, 'setObjectDefaultDamping: damping is 0 to 1, the fraction of velocity kept each frame', damp); objectDefaultDamping = damp; } /** Set the fraction of angular velocity objects keep each frame, 1 keeps all of it, 0 stops at once * @param {number} damp * @memberof Settings */ function setObjectDefaultAngleDamping(damp) { ASSERT(damp >= 0 && damp <= 1, 'setObjectDefaultAngleDamping: angle damping is 0 to 1, the fraction of angular velocity kept each frame', damp); objectDefaultAngleDamping = damp; } /** Set how much to bounce when a collision occurs * @param {number} restitution * @memberof Settings */ function setObjectDefaultRestitution(restitution) { objectDefaultRestitution = restitution; } /** Set the fraction of sliding speed objects keep each frame on the ground, 1 is no friction, 0 stops at once * @param {number} friction * @memberof Settings */ function setObjectDefaultFriction(friction) { objectDefaultFriction = friction; } /** Set max speed to avoid fast objects missing collisions * @param {number} speed * @memberof Settings */ function setObjectMaxSpeed(speed) { objectMaxSpeed = speed; } /** Set how much gravity to apply to objects * @param {Vector2} newGravity * @memberof Settings */ function setGravity(newGravity) { ASSERT(isVector2(newGravity), 'setGravity: newGravity is a vec2, like vec2(0, -.01)', newGravity); gravity = newGravity.copy(); } /** Set the scale for the emit rate of particles, 0 disables particle emitters * @param {number} scale * @memberof Settings */ function setParticleEmitRateScale(scale) { particleEmitRateScale = scale; } /** Set if gamepads are enabled * @param {boolean} enable * @memberof Settings */ function setGamepadsEnable(enable) { gamepadsEnable = enable; } /** Set if the dpad input is also routed to the left analog stick * @param {boolean} enable * @memberof Settings */ function setGamepadDirectionEmulateStick(enable) { gamepadDirectionEmulateStick = enable; } /** Set if axes that do not rest near center are ignored on non-standard gamepads * @param {boolean} enable * @memberof Settings */ function setGamepadAxisFilterEnable(enable) { gamepadAxisFilterEnable = enable; } /** Set if the WASD keys are also routed to the direction keys * @param {boolean} enable * @memberof Settings */ function setInputWASDEmulateDirection(enable) { inputWASDEmulateDirection = enable; } /** Set if touch input is allowed * @param {boolean} enable * @memberof Settings */ function setTouchInputEnable(enable) { touchInputEnable = enable; } /** Set if touch gamepad should appear on mobile devices * @param {boolean} enable * @memberof Settings */ function setTouchGamepadEnable(enable) { touchGamepadEnable = enable; } /** Set if a pinch of two fingers turns the mouse wheel * @param {boolean} enable * @memberof Settings */ function setTouchPinchWheel(enable) { touchPinchWheel = enable; } /** Set if touches outside the gamepad controls should still drive mouse/touch input * @param {boolean} passthrough * @memberof Settings */ function setTouchGamepadPassthrough(passthrough) { touchGamepadPassthrough = passthrough; } /** Set if touch gamepad should have start button in the center * - Set size to enable the center button * - When the game is paused, any touch will press the button * @param {number} size * @memberof Settings */ function setTouchGamepadCenterButtonSize(size) { touchGamepadCenterButtonSize = size; } /** Set number of buttons on the right side of the touch gamepad (0-4, gamepad buttons 0-3) * @param {number} count * @memberof Settings */ function setTouchGamepadButtonCount(count) { touchGamepadButtonCount = count; if (count > 0) touchGamepadRightStick = false; } /** Set if the touch gamepad should have a left analog stick (or dpad) * @param {boolean} enable * @memberof Settings */ function setTouchGamepadLeftStick(enable) { touchGamepadLeftStick = enable; if (enable) touchGamepadLeftButtonCount = 0; } /** Set number of buttons on the left side of the touch gamepad (0-4, gamepad buttons 4-7) * - Only used when touchGamepadLeftStick is false * @param {number} count * @memberof Settings */ function setTouchGamepadLeftButtonCount(count) { touchGamepadLeftButtonCount = count; if (count > 0) touchGamepadLeftStick = false; } /** Set if the touch gamepad right side is an analog stick (or dpad) instead of face buttons * @param {boolean} rightStick * @memberof Settings */ function setTouchGamepadRightStick(rightStick) { touchGamepadRightStick = rightStick; if (rightStick) touchGamepadButtonCount = 0; } /** Set if touch gamepad should be analog stick or 8 way dpad * @param {boolean} analog * @memberof Settings */ function setTouchGamepadAnalog(analog) { touchGamepadAnalog = analog; } /** Set if touch gamepad directional controls should float to where you press * @param {boolean} floating * @memberof Settings */ function setTouchGamepadFloating(floating) { touchGamepadFloating = floating; } /** Set size of virtual gamepad for touch devices in viewport CSS pixels * @param {number} size * @memberof Settings */ function setTouchGamepadSize(size) { touchGamepadSize = size; } /** Set transparency of touch gamepad overlay * @param {number} alpha * @memberof Settings */ function setTouchGamepadAlpha(alpha) { touchGamepadAlpha = alpha; } /** Set how long to display the touch gamepad on screen in seconds, set to 0 to always display * @param {number} time * @memberof Settings */ function setTouchGamepadDisplayTime(time) { touchGamepadDisplayTime = time; } /** Set duration in ms to vibrate when a touch gamepad face or start button is pressed (0 disables) * @param {number} ms * @memberof Settings */ function setTouchGamepadVibration(ms) { touchGamepadVibration = ms; } /** Set to allow vibration hardware if it exists * @param {boolean} enable * @memberof Settings */ function setVibrateEnable(enable) { vibrateEnable = enable; } /** Set if audio is enabled, false turns all sound off; a setting for before sounds are made, since a sound made * while it is off has nothing to play and one already playing goes on; to mute while the game runs, setSoundVolume(0) * @param {boolean} enable * @memberof Settings */ function setSoundEnable(enable) { soundEnable = enable; } /** Set if sound plays with an iPhone's silent switch on, as media does, which also pauses the player's own music * @param {boolean} ignore * @memberof Settings */ function setSoundIgnoreSilentSwitch(ignore) { soundIgnoreSilentSwitch = ignore; audioSetSession(); } /** Set volume scale to apply to all sound, music and speech * @param {number} volume * @memberof Settings */ function setSoundVolume(volume) { ASSERT(isNumber(volume) && abs(volume) < Infinity, 'setSoundVolume: volume must be a finite number, 1 is full', volume); soundVolume = volume; audioUpdateVolume(); // update gain immediately, sound off or not } /** Set default range where sound no longer plays * @param {number} range * @memberof Settings */ function setSoundDefaultRange(range) { soundDefaultRange = range; } /** Set default range percent to start tapering off sound * @param {number} taper * @memberof Settings */ function setSoundDefaultTaper(taper) { soundDefaultTaper = taper; } /** Set if all sound pauses while the page is hidden * @param {boolean} pause * @memberof Settings */ function setSoundPauseWhenHidden(pause) { soundPauseWhenHidden = pause; } /** Set if watermark with FPS should be shown * @param {boolean} show * @memberof Debug */ function setDebugWatermark(show) { debugWatermark = show; } /** Set the width in pixels of the outlines debug shapes are drawn with * @param {number} width * @memberof Debug */ function setDebugLineWidth(width) { debugLineWidth = width; } /** Set key code used to toggle debug mode, Esc by default * @param {string} key * @memberof Debug */ function setDebugKey(key) { debugKey = key; } /** Set if the debug keys work while the overlay is closed, the number keys and the +/- time keys * @param {boolean} [enable] * @memberof Debug */ function setDebugKeysAlways(enable=true) { debugKeysAlways = enable; } /** Set if the tweakables panel is shown, as 9 does while the debug overlay is open * @param {boolean} [show] * @memberof Debug */ function setDebugTweakables(show=true) { debugTweakables = show; } /** * LittleJS Object System * - EngineObject is the base class for all game objects * - Handles automatic updating, rendering, physics, and collision * - Supports parent-child hierarchies with transform inheritance * - 2D physics with velocity, acceleration, damping, and gravity * - Collision system with tiles and other objects * - Renders sprites from tile sheets with color and rotation * - Objects sorted by renderOrder for layered rendering */ // a one way solid lets a box at from pass, when it was not wholly on the solid's far side, the way it is passed // through; both are where they were before this frame's moves, so it does not matter which updated first function engineObjectOneWayPass(solid, solidPos, from, fromSize) { const way = solid.oneWay, size = solid.size, epsilon = 1e-3; return way.x > .5 ? from.x - fromSize.x/2 < solidPos.x + size.x/2 - epsilon : way.x < -.5 ? from.x + fromSize.x/2 > solidPos.x - size.x/2 + epsilon : way.y > .5 ? from.y - fromSize.y/2 < solidPos.y + size.y/2 - epsilon : from.y + fromSize.y/2 > solidPos.y - size.y/2 + epsilon; } // where a move from start to end goes into the cell at its corner, the first point of it inside the cell's box, and // whether it goes in through a side, x, or the top or bottom, the axis whose slab it enters last; one starting on a // face is on that face, as its entry there is 0 and the other's is before it function engineObjectRayEntry(start, end, cell) { const dx = end.x - start.x, dy = end.y - start.y; const tx = dx ? min((cell.x - start.x) / dx, (cell.x + 1 - start.x) / dx) : -Infinity; const ty = dy ? min((cell.y - start.y) / dy, (cell.y + 1 - start.y) / dy) : -Infinity; const t = clamp(max(tx, ty)); return {pos: vec2(start.x + dx*t, start.y + dy*t), side: tx >= ty}; } // a bullet's move from one point to another through the tiles: the first tile its collideWithTile takes, nearest // first across the solid layers, with where the move goes into it, the face's normal and the layer; undefined for none // - the cell it starts in, a hair back along its move so one stopped on a tile's face going into it is outside, is not // tested, so it moves out of a tile it is inside, as a box that starts in one does, and a tile after it still stops it // - collideWithTile is asked with the bullet where the move goes into the tile // - only the stretches of the move near a solid layer are walked, nearest first and joined where they meet, so a long // move through empty space, or between layers far apart, costs nothing; the layers are whole numbers, so the cells // are the same as over the whole move /** @param {EngineObject} object * @param {Vector2} from * @param {Vector2} to * @return {{pos: Vector2, normal: Vector2, layer: TileCollisionLayer}|undefined} */ function engineObjectBulletSweep(object, from, to) { const move = to.subtract(from), length = move.length(); if (!length) return; const back = from.subtract(move.scale(1e-6 / length)), startX = floor(back.x), startY = floor(back.y); /** @type {{pos: Vector2, normal: Vector2, layer: TileCollisionLayer}|undefined} */ let hit; const testCell = (cell)=> { // a bullet its collideWithTile destroyed is asked about nothing more if (cell.x === startX && cell.y === startY || object.destroyed) return false; for (const layer of tileCollisionLayers) { if (!layer.isSolid) continue; const x = cell.x - layer.pos.x, y = cell.y - layer.pos.y; if (x < 0 || y < 0 || x >= layer.size.x || y >= layer.size.y) continue; const data = layer.collisionData[y*layer.size.x + x]; if (!data || tileCollisionOneWayPass(layer, x, y, from.x, from.y)) continue; const tilePos = vec2(cell.x, cell.y), entry = engineObjectRayEntry(from, to, tilePos); object.pos = entry.pos; if (!object.collideWithTile(data, tilePos, true)) { if (object.destroyed) return false; // nor the other layers' tiles in this cell continue; } const normal = entry.side ? vec2(-sign(move.x), 0) : vec2(0, -sign(move.y)); hit = {pos: entry.pos, normal, layer}; return true; } return false; }; const stretches = []; for (const layer of tileCollisionLayers) { if (!layer.isSolid) continue; tileCollisionAssertWhole(layer); let t0 = 0, t1 = 1; for (const [p, d, a, b] of [[from.x, move.x, layer.pos.x - 1, layer.pos.x + layer.size.x + 1], [from.y, move.y, layer.pos.y - 1, layer.pos.y + layer.size.y + 1]]) { if (!d) { if (p < a || p > b) t0 = 2; continue; } const u = (a - p) / d, v = (b - p) / d; t0 = max(t0, min(u, v)), t1 = min(t1, max(u, v)); } t0 < t1 && stretches.push([t0, t1]); } stretches.sort((a, b)=> a[0] - b[0]); const at = (t)=> t <= 0 ? from : t >= 1 ? to : from.add(move.scale(t)); for (let i = 0; i < stretches.length && !hit;) { const [t0] = stretches[i]; let t1 = stretches[i][1]; while (++i < stretches.length && stretches[i][0] <= t1) t1 = max(t1, stretches[i][1]); lineTest(at(t0), at(t1), testCell); } return hit; } // where an object was before this frame's moves, for a one way test: kept by the update when a one way solid is in // it, or where it is when updatePhysics is called on its own function engineObjectStartPos(o) { return engineObjectsOneWayStart?.get(o) || o.pos; } // debug only: where each child was last placed from its parent, [x, y, angle], so a pos or angle the game set on it, // which its parent's transform puts back at once, can be said once; a WeakMap keeps the objects' own shape const engineObjectChildPlacedAt = new WeakMap; let engineObjectChildWarned = false; function engineObjectChildPlaced(o) { const placed = engineObjectChildPlacedAt.get(o); if (placed) placed[0] = o.pos.x, placed[1] = o.pos.y, placed[2] = o.angle; else engineObjectChildPlacedAt.set(o, [o.pos.x, o.pos.y, o.angle]); } function engineObjectChildCheck(o) { const placed = engineObjectChildPlacedAt.get(o); if (!placed || engineObjectChildWarned) return; const posSet = o.pos.x !== placed[0] || o.pos.y !== placed[1], angleSet = o.angle !== placed[2]; if (!posSet && !angleSet) return; engineObjectChildWarned = true; console.warn(`a child object's ${posSet ? 'pos' : 'angle'} was set, but its parent places it each frame: ` + `set its ${posSet ? 'localPos' : 'localAngle'} instead, which is relative to the parent`, o); } /** * LittleJS Object Base Object Class * - Top level object class used by the engine * - Automatically adds self to object list * - Will be updated and rendered each frame * - Renders as a sprite from a tilesheet by default * - Can have color and additive color applied * - 2D Physics and collision system * - Sorted by renderOrder * - Objects can have children attached * - Parents are updated before children, and set child transform * - Call destroy() to get rid of objects * * The physics system used by objects is simple and fast with some caveats... * - Collision uses the axis aligned size, the object's rotation angle is only for rendering * - Objects are guaranteed to not intersect tile collision from physics * - If an object starts or is moved inside tile collision, it will not collide with that tile * - Collision for objects can be set to be solid to block other objects * - Objects may get pushed into overlapping other solid objects, if so they will push away * - A static solid (mass 0) moved by its velocity, like a door or an elevator, pushes objects out of its way * - Solid objects are more performance intensive and should be used sparingly * @memberof Engine * @example * // create an engine object, normally you would first extend the class with your own * const pos = vec2(2,3); * const object = new EngineObject(pos); */ class EngineObject { /** Create an engine object and adds it to the list of objects * @param {Vector2} [pos=vec2()] - World space position of the object * @param {Vector2} [size=vec2(1)] - World space size of the object * @param {TileInfo} [tileInfo] - Tile info to render object (undefined is untextured) * @param {number} [angle] - Angle the object is rotated by * @param {Color} [color=WHITE] - Color to apply to tile when rendered * @param {number} [renderOrder] - Objects sorted by renderOrder before being rendered */ constructor(pos=vec2(), size=vec2(1), tileInfo, angle=0, color=WHITE, renderOrder=0) { // check passed in params ASSERT(isVector2(pos), 'object pos must be a vec2'); ASSERT(isVector2(size), 'object size must be a vec2'); ASSERT(!tileInfo || tileInfo instanceof TileInfo, 'object tileInfo should be a TileInfo or undefined'); ASSERT(typeof angle === 'number' && isFinite(angle), 'object angle should be a number'); ASSERT(isColor(color), 'object color should be a valid rgba color'); ASSERT(typeof renderOrder === 'number', 'object renderOrder should be a number'); /** @property {Vector2} - World space position of the object */ this.pos = pos.copy(); /** @property {Vector2} - World space width and height of the object */ this.size = size.copy(); /** @property {Vector2|undefined} - Size of object used for drawing, uses size if not set * @type {Vector2|undefined} */ this.drawSize = undefined; /** @property {TileInfo|undefined} - Tile info to render object (undefined is untextured) * @type {TileInfo|undefined} */ this.tileInfo = tileInfo; /** @property {number} - Angle to rotate the object */ this.angle = angle; /** @property {Color} - Color to apply when rendered */ this.color = color.copy(); /** @property {Color|undefined} - Additive color to apply when rendered * @type {Color|undefined} */ this.additiveColor = undefined; /** @property {Shader|undefined} - Custom shader to render with, undefined for the engine's own * @type {Shader|undefined} */ this.shader = undefined; /** @property {boolean} - Does this object draw into the light system's shadow map; false for a floor layer, a background, a pickup */ this.castShadow = true; /** @property {boolean} - Does this object draw into the light system's background map, which a * DirectionalLight lights only at its edges facing the light; for a background layer, with castShadow false */ this.castBackgroundShadow = false; /** @property {number} - With the light system, how much it lights itself: 0 lit only by the lights, 1 full * brightness in its own colors whatever the lights do, between partly; drawn into the lightmap through * renderEmissive, as 3D's emissive. Exact for solid pixels; a partly transparent one is self lit by its alpha * too, so a half alpha pixel shows at a quarter and a fading emissive sprite fades a little faster */ this.emissive = 0; /** @property {boolean} - Should the rendered tile flip along the y axis. Affects rendering and the local→world transform of attached children (a mirrored parent flips its children's localPos.x and localAngle). Does not affect this object's own physics, collision, or localToWorld/worldToLocal. */ this.mirror = false; /** @property {boolean} - Has object been destroyed? */ this.destroyed = false; this.updatePass = 0; // the engine update pass it was last updated in, so nothing updates twice in one // physical properties /** @property {number} - How heavy the object is, static if 0: a static object moves by its velocity but does not * collide on its own, the moving ones collide with it */ this.mass = objectDefaultMass; /** @property {number} - Fraction of velocity kept each frame, 1 keeps all of it, 0 stops at once */ this.damping = objectDefaultDamping; /** @property {number} - Fraction of angular velocity kept each frame, 1 keeps all of it, 0 stops at once */ this.angleDamping = objectDefaultAngleDamping; /** @property {number} - How bouncy the object is when colliding (0-1) */ this.restitution = objectDefaultRestitution; /** @property {number} - Fraction of sliding speed kept each frame on the ground, 1 is no friction, 0 stops at * once, the more slippery of the object and its ground is used */ this.friction = objectDefaultFriction; /** @property {number} - How much to scale gravity by for this object */ this.gravityScale = 1; /** @property {number} - Objects are sorted by render order */ this.renderOrder = renderOrder; /** @property {Vector2} - Velocity of the object, in world units per frame */ this.velocity = vec2(); /** @property {number} - Angular velocity of the object, in radians per frame */ this.angleVelocity = 0; /** @property {number} - Track when object was created */ this.spawnTime = time; /** @property {Array} - List of children of this object * @type {Array} */ this.children = []; /** @property {boolean} - Limit object speed along x and y axis */ this.clampSpeed = true; /** @property {boolean} - Move through the tiles as a point along a ray, so it can not pass through one at * any speed, for small fast things like bullets: its speed is not held to objectMaxSpeed for the tiles, * collideWithTile is asked with this.pos where the ray goes into the tile, and on a hit it stops at the * surface, bounces off it by restitution and goes on with the rest of its move, so it slides along a floor * - Its collision with solid objects is the usual one, so one with a size that collides with them is still held * to objectMaxSpeed; one of no size is not, it never collides with them * - this.pos is set for each tile asked about and put where the move ends after, so a change collideWithTile * makes to it is not kept; move it after the physics, in update * - Once destroyed, as by its own collideWithTile, it is asked about no more tiles that frame, where an object * that is not a bullet still is */ this.isBullet = false; /** @property {EngineObject|undefined} - Object we are standing on, if any * @type {EngineObject|undefined} */ this.groundObject = undefined; /** @property {Vector2|undefined} - Makes a solid object one way, like a platform jumped up through: up, down, * left or right, the way others pass through it; it blocks only what was wholly on its far side before it * moved, or stood on it, and the rest are not stopped or asked through collideWithObject; with no mass it * moves before the other objects each frame, so a lift works the same at any render order; tiles are made one * way by the layer's setOneWay * @type {Vector2|undefined} */ this.oneWay = undefined; // parent child system /** @property {EngineObject|undefined} - Parent of object if in local space * @type {EngineObject|undefined} */ this.parent = undefined; /** @property {Vector2|undefined} - Position relative to the parent, only while attached to one * @type {Vector2|undefined} */ this.localPos = undefined; /** @property {number} - Local angle if child */ this.localAngle = 0; // collision flags /** @property {boolean} - Object collides with the level, its tile collision layers * @type {boolean} */ this.collideLevel = false; /** @property {boolean} - Object collides with solid objects */ this.collideSolidObjects = false; /** @property {boolean} - Object collides with and blocks other objects */ this.isSolid = false; /** @property {boolean} - Object collides with raycasts */ this.collideRaycast = false; /** @property {boolean} - Object is skipped by engineObjectsDestroy, for things that outlive a level like a camera * - Calling destroy on it still destroys it, and its children go with it either way */ this.persistent = false; // add to list of objects engineObjects.push(this); } /** Update the object transform, called automatically by engine even when paused * @param {boolean} [updateChildren] - Also update the children's transforms */ updateTransforms(updateChildren=true) { const parent = this.parent; if (parent) { // in a debug build, a pos or angle the game set on a child is said once, since it is placed over here debug && engineObjectChildCheck(this); // compose with parent transform inline to avoid intermediate vector allocs const mirror = parent.getMirrorSign(); const lp = this.localPos, pp = parent.pos; const lx = lp.x*mirror, ly = lp.y, pa = parent.angle; if (pa) { const c = cos(-pa), s = sin(-pa); this.pos.set(lx*c - ly*s + pp.x, lx*s + ly*c + pp.y); } else this.pos.set(lx + pp.x, ly + pp.y); this.angle = mirror*this.localAngle + pa; debug && engineObjectChildPlaced(this); } // update children if (updateChildren) for (const child of this.children) child.updateTransforms(); } /** Update the object physics, called automatically by engine once each frame. Can be overridden to stop or change how physics works for an object * - With many solids each mover finds those near it through a grid, which follows a solid after its own physics * update: a solid an override moves for another object, a lift carrying a crate, is found there from the next * update on */ updatePhysics() { // child objects do not have physics ASSERT(!this.parent, 'updatePhysics: a child has no physics of its own, its parent moves it'); // bail if a collision callback destroyed us mid-frame if (this.destroyed) return; // physics sanity checks ASSERT(this.angleDamping >= 0 && this.angleDamping <= 1, 'angleDamping must be 0 to 1, the fraction kept each frame'); ASSERT(this.damping >= 0 && this.damping <= 1, 'damping must be 0 to 1, the fraction of velocity kept each frame'); // apply physics; only collision needs where the object was, so only then is it copied const solve = enablePhysicsSolver && this.mass; const oldPos = solve && (this.collideSolidObjects || this.collideLevel) ? this.pos.copy() : undefined; this.velocity.x *= this.damping; this.velocity.y *= this.damping; if (this.mass) { // apply gravity only if it has mass this.velocity.x += gravity.x * this.gravityScale; this.velocity.y += gravity.y * this.gravityScale; } // limit max speed to prevent missing collisions, after gravity so no move is past it, only for what collides: // with solids, or with tiles while it has a mass, which tile collision needs; anything else moves as fast as // it is told if (this.clampSpeed && enablePhysicsSolver && (this.collideSolidObjects && this.size.x && this.size.y || this.collideLevel && this.mass && !this.isBullet)) { this.velocity.x = clamp(this.velocity.x, -objectMaxSpeed, objectMaxSpeed); this.velocity.y = clamp(this.velocity.y, -objectMaxSpeed, objectMaxSpeed); } this.pos.x += this.velocity.x; this.pos.y += this.velocity.y; this.angle += this.angleVelocity *= this.angleDamping; // don't do collision for static objects or if solver disabled if (!solve) return; // which way is down for this object, a negative gravityScale falls up and lands on ceilings const gravityY = this.gravityScale < 0 ? -gravity.y : gravity.y; const wasFalling = this.velocity.y < 0 && gravityY < 0 || this.velocity.y > 0 && gravityY > 0; const wasOn = this.groundObject; // a one way solid it stood on still holds it if (this.groundObject) { // apply friction in local space of ground object const friction = max(this.friction, this.groundObject.friction); const groundSpeed = this.groundObject.velocity.x; this.velocity.x = groundSpeed + (this.velocity.x - groundSpeed) * friction; this.groundObject = undefined; } // an object with no width or height has no box to push out of, or to be pushed out of if (this.collideSolidObjects && this.size.x && this.size.y) { // check collisions against solid objects const epsilon = .001; // necessary to push slightly outside of the collision // with many solids, only those near it, as a grid finds them, in the same order for (const o of engineCollideGrid ? engineCollideGridWalk(this) : engineObjectsCollideStaticLast) { // skip destroyed, child objects, self collision, or objects with no box if (o.destroyed || o.parent || o === this || !o.size.x || !o.size.y) continue; // non solid objects don't collide with each other if (!this.isSolid && !o.isSolid) continue; // check collision if (!this.isOverlappingObject(o)) continue; // a one way solid lets through what was not wholly on its far side before it moved, either way round if (o.oneWay && wasOn !== o && engineObjectOneWayPass(o, engineObjectStartPos(o), oldPos, this.size)) continue; if (this.oneWay && engineObjectOneWayPass(this, oldPos, engineObjectStartPos(o), o.size)) continue; // each moving object checks its own contacts, so a pair the other one already asked about this frame // is not asked twice: left overlapping, ignored or only nudged apart it is skipped, and one both said // to resolve, pushed back together since, is resolved again const answer = engineObjectsCollidePairAnswer(o, this); if (answer === false) continue; if (!answer) { // notify objects of collision and check if should be resolved const collide1 = this.collideWithObject(o); const collide2 = o.collideWithObject(this); // a callback may have moved either one, the grid follows engineCollideGrid?.built && engineCollideGridPlace(engineCollideGrid.built, o); if (!collide1 || !collide2) { engineObjectsCollidePairAdd(this, o); continue; } } const wasOverlapping = isOverlapping(oldPos, this.size, o.pos, o.size); if (wasOverlapping && (!o.mass || o.groundObject)) { // a static solid that moved into it, like a door or an elevator, pushes it out the shortest way // at once and carries it along, it would only drift out slowly and the solid would pass through; // an object standing on something counts as fixed too, so a stack on an elevator rides together; // it bounces off relative to the mover, a paddle moved by setting pos bounces a ball as a wall does const push = collideBoxBox(this.pos, this.size, o.pos, o.size); if (push) { this.pos.x += push.x + sign(push.x) * epsilon; this.pos.y += push.y + sign(push.y) * epsilon; const restitution = max(this.restitution, o.restitution); if (push.x) { const v = this.velocity.x - o.velocity.x; if (v * push.x < 0) // moving into it this.velocity.x = o.velocity.x - v * restitution; } else { const v = this.velocity.y - o.velocity.y; if (v * push.y < 0) // moving into it this.velocity.y = o.velocity.y - v * restitution; if (push.y * gravityY < 0) // pushed up against gravity, it stands on it this.groundObject = o; } } engineObjectsCollidePairAdd(this, o); debugPhysics && debugOverlap(this.pos, this.size, o.pos, o.size, '#f00', undefined, false); continue; } if (wasOverlapping) { // if already was touching, try to push away const deltaPos = oldPos.subtract(o.pos); const length = deltaPos.length(); const pushAwayAccel = .001; const velocity = length < .001 ? vec2(0, pushAwayAccel) : deltaPos.scale(pushAwayAccel/length); this.velocity = this.velocity.add(velocity); if (o.mass) // push away other object if not fixed o.velocity = o.velocity.subtract(velocity); engineObjectsCollidePairAdd(this, o); debugPhysics && debugOverlap(this.pos, this.size, o.pos, o.size, '#f00', undefined, false); continue; } // check for collision const sizeBoth = this.size.add(o.size); // prefer to push up if small delta, up away from this object's own gravity const smallStepUp = (gravityY > 0 ? o.pos.y - oldPos.y : oldPos.y - o.pos.y)*2 > sizeBoth.y - abs(gravityY); const isBlockedX = abs(oldPos.y - o.pos.y)*2 < sizeBoth.y; const isBlockedY = abs(oldPos.x - o.pos.x)*2 < sizeBoth.x; const restitution = max(this.restitution, o.restitution); if (smallStepUp || isBlockedY || !isBlockedX) // resolve y collision { // push outside object collision this.pos.y = o.pos.y + (sizeBoth.y/2 + epsilon) * sign(oldPos.y - o.pos.y); if ((o.groundObject && wasFalling) || !o.mass) { // set ground object if landed on something if (wasFalling) this.groundObject = o; // bounce if other object is fixed or grounded, relative to it so a rider keeps up with a // platform moving down instead of landing on it again every few frames this.velocity.y = o.velocity.y - (this.velocity.y - o.velocity.y) * restitution; } else // o has mass here, the other branch already handled the massless case { // inelastic collision const inelastic = (this.mass * this.velocity.y + o.mass * o.velocity.y) / (this.mass + o.mass); // elastic collision const elastic0 = this.velocity.y * (this.mass - o.mass) / (this.mass + o.mass) + o.velocity.y * 2 * o.mass / (this.mass + o.mass); const elastic1 = o.velocity.y * (o.mass - this.mass) / (this.mass + o.mass) + this.velocity.y * 2 * this.mass / (this.mass + o.mass); // lerp between elastic or inelastic based on restitution this.velocity.y = lerp(inelastic, elastic0, restitution); o.velocity.y = lerp(inelastic, elastic1, restitution); } } if (!smallStepUp && isBlockedX) // resolve x collision { // push outside collision this.pos.x = o.pos.x + (sizeBoth.x/2 + epsilon) * sign(oldPos.x - o.pos.x); if (o.mass) { // inelastic collision const inelastic = (this.mass * this.velocity.x + o.mass * o.velocity.x) / (this.mass + o.mass); // elastic collision const elastic0 = this.velocity.x * (this.mass - o.mass) / (this.mass + o.mass) + o.velocity.x * 2 * o.mass / (this.mass + o.mass); const elastic1 = o.velocity.x * (o.mass - this.mass) / (this.mass + o.mass) + this.velocity.x * 2 * this.mass / (this.mass + o.mass); // lerp between elastic or inelastic based on restitution this.velocity.x = lerp(inelastic, elastic0, restitution); o.velocity.x = lerp(inelastic, elastic1, restitution); } else // bounce if other object is fixed, relative to it as a landing is this.velocity.x = o.velocity.x - (this.velocity.x - o.velocity.x) * restitution; } engineObjectsCollidePairAdd(this, o, true); debugPhysics && debugOverlap(this.pos, this.size, o.pos, o.size, '#f0f', undefined, false); } } if (this.collideLevel) { // check collision against tiles, one way tiles go by where it was; put back after, a callback may throw const fromObject = tileCollisionFromObject, fromPos = tileCollisionFromPos; const hitObject = tileCollisionHitObject; tileCollisionFromObject = this, tileCollisionFromPos = oldPos; try { this.isBullet ? this.updatePhysicsBullet(oldPos, gravityY, wasFalling) : this.updatePhysicsTiles(oldPos, gravityY, wasFalling); } finally { tileCollisionFromObject = fromObject, tileCollisionFromPos = fromPos; tileCollisionHitObject = hitObject; } } } // a bullet's move from oldPos through the tiles, as a point along a ray, called by updatePhysics /** @private */ updatePhysicsBullet(oldPos, gravityY, wasFalling) { // up to three legs: the move, then what is left of it after each hit, along the surface or bounced off it as // its velocity is, so one rolling along the floor under gravity keeps going as a box does let from = oldPos, to = this.pos.copy(); for (let leg = 0; leg < 3; ++leg) { const hit = engineObjectBulletSweep(this, from, to); if (!hit) { this.pos = to; break; } // stop at the surface, a hair off it, more than a one way tile's margin, so going on along a ceiling a // one way tile it rose through lets it on; bounce off it by the more bouncy of it and the layer const {normal, layer} = hit, restitution = max(this.restitution, layer.restitution); const stop = hit.pos.add(normal.scale(2e-3)); const into = this.velocity.dot(normal); if (into < 0) this.velocity = this.velocity.subtract(normal.scale(into * (1 + restitution))); if (normal.y * gravityY < 0 && wasFalling) // landed, against its gravity this.groundObject = layer; this.pos = stop; // the rest of the move from there, turned the same way; none once its callback destroyed it const rest = to.subtract(hit.pos), restInto = rest.dot(normal); const next = stop.add(restInto < 0 ? rest.subtract(normal.scale(restInto * (1 + restitution))) : rest); if (this.destroyed || next.distanceSquared(stop) < 1e-12) break; from = stop, to = next; } debugPhysics && debugPoint(this.pos, '#f00', undefined, undefined, false); } // resolve the tile collision of a move from oldPos, called by updatePhysics /** @private */ updatePhysicsTiles(oldPos, gravityY, wasFalling) { // the first test is where the move put it, so the tiles it asks about are hits; the tests after it only try // places to find which side it hit and where it can go tileCollisionHitObject = this; const hitLayer = tileCollisionTest(this.pos, this.size, this); tileCollisionHitObject = undefined; if (hitLayer) { // if already was stuck in collision, don't do anything // this should not happen unless something starts in collision if (!tileCollisionTest(oldPos, this.size, this)) { // test which side we bounced off (or both if a corner) const isBlockedX = tileCollisionTest(vec2(this.pos.x, oldPos.y), this.size, this); const isBlockedY = tileCollisionTest(vec2(oldPos.x, this.pos.y), this.size, this); const restitution = max(this.restitution, hitLayer.restitution); if (isBlockedX) { // a ledge caught less than maxMove below its top lifts the object onto it instead of stopping it, // down off a ceiling ledge when gravity points up; zero gravity counts as down const epsilon = 1e-3; const maxMove = .1; const y = gravityY > 0 ? ceil( oldPos.y+this.size.y/2-1) - this.size.y/2 - epsilon : floor(oldPos.y-this.size.y/2+1) + this.size.y/2 + epsilon; if (abs(y - this.pos.y) < maxMove && !tileCollisionTest(vec2(this.pos.x, y), this.size, this)) { this.pos.y = y; debugPhysics && debugRect(this.pos, this.size, '#ff0', 0, 0, false, false); return; } // move against the wall and bounce, its side on the tile edge it moved toward, rounded in the // layer's space as its collision test is; back to its previous X when that spot is not clear const snapEpsilon = .0001, layerX = hitLayer.pos.x, offsetX = this.size.x/2 + snapEpsilon; // never back past where it was: a leading edge already on a grid line would step back by // the epsilon and take its trailing edge into the tile behind, which nothing tests, since // only X is blocked; between there and the wall it covers no column it did not cover before const movingLeft = this.pos.x < oldPos.x; const snap = layerX + (movingLeft ? floor(oldPos.x - layerX - this.size.x/2) + offsetX : ceil( oldPos.x - layerX + this.size.x/2) - offsetX); const x = movingLeft ? min(snap, oldPos.x) : max(snap, oldPos.x); this.pos.x = tileCollisionTest(vec2(x, oldPos.y), this.size, this) ? oldPos.x : x; this.velocity.x *= -restitution; } if (isBlockedY || !isBlockedX) { // adjust position to slightly away from the nearest tile, the floor or the ceiling it moved // toward, which prevents a gap between them; rounded in the layer's space as its collision // test is, or a bottom a hair below a grid line would round to the row under it, inside the // floor, and fall through; back to its previous Y when that spot is not clear const epsilon = .0001; const offset = this.size.y/2 + epsilon; const layerY = hitLayer.pos.y; const y = layerY + (this.pos.y < oldPos.y ? floor(oldPos.y - layerY - this.size.y/2) + offset : ceil( oldPos.y - layerY + this.size.y/2) - offset); const isClear = this.pos.y !== oldPos.y && !tileCollisionTest(vec2(this.pos.x, y), this.size, this); this.pos.y = isClear ? y : oldPos.y; // set ground object for tile collision this.groundObject = wasFalling ? hitLayer : undefined; // bounce velocity this.velocity.y *= -restitution; } debugPhysics && debugRect(this.pos, this.size, '#f00', 0, 0, false, false); } } } /** Update the object, called automatically by engine once each frame. Does nothing by default */ update() {} /** Render the object, draws a tile by default, automatically called each frame, sorted by renderOrder */ render() { // default object render drawTile(this.pos, this.drawSize || this.size, this.tileInfo, this.color, this.angle, this.mirror, this.additiveColor, glEnable, false); } /** Optional hook called during the light system plugin's lightmap pass to draw this object's lightmap contribution. Does nothing by default */ renderLight() {} /** Draw this object into the light system's shadow map, called during its shadow pass when castShadow is set, and into * its background map when castBackgroundShadow is set and a DirectionalLight is out. * Calls render() by default so the object casts its own shape; override to cast a different one, like a blob at a character's feet so its body stays lit; * screen space WebGL draws in render() are skipped during the pass */ renderShadow() { this.render(); } /** Draw this object's shape into the light system's lightmap, called during its light pass when emissive is above * 0; what it draws shows in its own colors that much brighter. Calls render() by default so the whole object * glows; override to glow a part, like a robot's eyes */ renderEmissive() { this.render(); } /** Destroy this object, destroy its children, detach its parent, and mark it for removal * @param {boolean} [immediate] - true removes attached effects like particle emitters at once, false lets them finish first */ destroy(immediate=false) { if (this.destroyed) return; // disconnect from parent and destroy children this.destroyed = true; this.parent?.removeChild(this); for (const child of this.children) { child.parent = undefined; child.destroy(immediate); } } /** Convert from local space to world space * @param {Vector2} pos - local space point * @return {Vector2} */ localToWorld(pos) { return this.pos.add(pos.rotate(this.angle)); } /** Convert from world space to local space * @param {Vector2} pos - world space point * @return {Vector2} */ worldToLocal(pos) { return pos.subtract(this.pos).rotate(-this.angle); } /** Convert from local space to world space for a vector (rotation only) * @param {Vector2} vec - local space vector * @return {Vector2} */ localToWorldVector(vec) { return vec.rotate(this.angle); } /** Convert from world space to local space for a vector (rotation only) * @param {Vector2} vec - world space vector * @return {Vector2} */ worldToLocalVector(vec) { return vec.rotate(-this.angle); } /** Called to check if a tile collision should be resolved. Return true for physics to resolve the collision or false to ignore and resolve it manually * - Called for each solid tile the physics tests, which can be several times a frame for the same tile, and for * positions it only tries, so keep side effects to the calls where isHit is true * - isHit is true when the tile is one the object overlaps where its move put it, the physics' first test, or * one a bullet's ray goes into; a box is asked about the tiles it overlaps until one returns true, so a box * against two tiles at once is asked about one of them; false for the places the physics only tries after * that, and for the game's own tests and raycasts * - this.pos has already moved, so a check on where it came from needs the position saved in update; for a one * way platform the layer's setOneWay does that, and this is not asked about a one way tile it passes through * - For the point of impact, like sparks where a bullet hit, set isBullet: this.pos is then where it meets the * tile, as the platformer's Bullet uses it * @param {number} tileData - the value of the tile at the position * @param {Vector2} pos - the tile's bottom left corner in world space * @param {boolean} [isHit] - true when it overlaps the tile where it moved to, false for a place only tried * @return {boolean} - true if the collision should be resolved by modifying it's position and velocity */ collideWithTile(tileData, pos, isHit) { return tileData > 0; } /** Called by the engine to check if an object collision should be resolved. Return true for physics to resolve the collision or false to ignore and resolve it manually * - Both objects of a touching pair are asked once a frame, whichever order they update in; an object that * destroys itself here is gone at the end of the frame and is still asked about the pairs left this frame, so a * bullet that should hit one thing checks its own destroyed flag first * - With many solids each mover finds those near it through a grid, which follows the other object after this * returns; a third object moved here is found where it is now from the next update on * @param {EngineObject} object - the object to test against * @param {Vector3} [push] - what it would take to move this object clear, a Vector3 from the 3D plugin, undefined in 2D * @return {boolean} - true if the collision should be resolved by modifying it's position and velocity */ collideWithObject(object, push) { return true; } /** Get this object's up vector * @param {number} [scale] - length of the vector * @return {Vector2} */ getUp(scale=1) { return vec2().setAngle(this.angle, scale); } /** Get this object's right vector * @param {number} [scale] - length of the vector * @return {Vector2} */ getRight(scale=1) { return vec2().setAngle(this.angle+PI/2, scale); } /** How long since the object was created * @return {number} */ getAliveTime() { return time - this.spawnTime; } /** Get the speed of this object * @return {number} */ getSpeed() { return this.velocity.length(); } /** Get the velocity of a point on this object, its velocity and its spin about pos together, per frame like * velocity, so the tip of a spinning blade moves faster than its middle; its length is the point's speed * @param {Vector2} pos - the point in world space * @return {Vector2} */ getVelocityAtPoint(pos) { // angleVelocity is clockwise, so a point above pos moves right const w = this.angleVelocity, x = pos.x - this.pos.x, y = pos.y - this.pos.y; return vec2(this.velocity.x + w*y, this.velocity.y - w*x); } /** Apply acceleration to this object (adjust velocity, not affected by mass) * - Does nothing on a static object (mass 0), set its velocity instead * @param {Vector2} acceleration */ applyAcceleration(acceleration) { if (this.mass) this.velocity = this.velocity.add(acceleration); } /** Apply angular acceleration to this object * - Does nothing on a static object (mass 0), set its angleVelocity instead * @param {number} acceleration */ applyAngularAcceleration(acceleration) { if (this.mass) this.angleVelocity += acceleration; } /** Apply force to this object (adjust velocity, affected by mass) * - Does nothing on a static object (mass 0), set its velocity instead * @param {Vector2} force */ applyForce(force) { if (this.mass) this.applyAcceleration(force.scale(1/this.mass)); } /** Get the direction of the mirror * @return {number} -1 if this.mirror is true, or 1 if not mirrored */ getMirrorSign() { return this.mirror ? -1 : 1; } /** Attaches a child to this with a local transform, returns child for chaining * @param {EngineObject} child * @param {Vector2} [localPos=vec2()] * @param {number} [localAngle] * @return {EngineObject} The child object added */ addChild(child, localPos=vec2(), localAngle=0) { ASSERT(!this.destroyed, 'cannot add child to destroyed object'); if (this.destroyed) return child; ASSERT(!child.parent && !this.children.includes(child), 'child already has a parent, removeChild it first or use attach'); ASSERT(child instanceof EngineObject, 'child must be an EngineObject'); ASSERT(!child.destroyed, 'cannot add a destroyed child'); for (let p = /** @type {EngineObject} */ (this); p; p = p.parent) ASSERT(p !== child, 'cannot add an object as a child of itself or of its own child'); this.children.push(child); child.parent = this; child.localPos = localPos.copy(); child.localAngle = localAngle; debug && engineObjectChildPlacedAt.delete(child); // where it was placed by an earlier parent does not count child.updateTransforms(); return child; } /** Attaches a child to this without moving it: the local transform is worked out from where the child is now, * where addChild takes one; a child of something else is moved over, returns child for chaining * @param {EngineObject} child * @return {EngineObject} The child object attached */ attach(child) { ASSERT(child instanceof EngineObject, 'child must be an EngineObject'); ASSERT(child !== this, 'cannot attach self'); child.parent?.removeChild(child); // the local values that updateTransforms turns back into the child's current pos and angle const mirror = this.getMirrorSign(), local = this.worldToLocal(child.pos); local.x *= mirror; return this.addChild(child, local, mirror * (child.angle - this.angle)); } /** Removes a child from this one, it stays where it is in the world * @param {EngineObject} child */ removeChild(child) { ASSERT(child.parent === this && this.children.includes(child), 'removeChild: that object is not a child of this one', child); const i = this.children.indexOf(child); if (i < 0) return; // not a child of this one, release has no assert this.children.splice(i, 1); child.parent = child.localPos = undefined; } /** Check if this object's box overlaps another object's box * @param {EngineObject} object * @return {boolean} */ isOverlappingObject(object) { return this.isOverlapping(object.pos, object.size); } /** Check if overlapping a point or aligned bounding box * @param {Vector2} pos - Center of box * @param {Vector2} [size=vec2()] - Size of box, uses a point if undefined * @return {boolean} */ isOverlapping(pos, size=vec2()) { return isOverlapping(this.pos, this.size, pos, size); } /** Set how this object collides * @param {boolean} [collideSolidObjects] - Does it collide with solid objects? * @param {boolean} [isSolid] - Does it collide with and block other objects? (expensive in large numbers) * @param {boolean} [collideLevel] - Does it collide with the level, its tile collision layers? * @param {boolean} [collideRaycast] - Does it collide with raycasts? */ setCollision(collideSolidObjects=true, isSolid=true, collideLevel=true, collideRaycast=true) { ASSERT(collideSolidObjects || !isSolid, 'solid objects must be set to collide'); this.collideSolidObjects = collideSolidObjects; this.isSolid = isSolid; this.collideLevel = collideLevel; this.collideRaycast = collideRaycast; } /** Returns string containing info about this object for debugging * @return {string} */ toString() { let text = 'type = ' + this.constructor.name; if (this.pos.x || this.pos.y) text += '\npos = ' + this.pos; if (this.velocity.x || this.velocity.y) text += '\nvelocity = ' + this.velocity; if (this.size.x || this.size.y) text += '\nsize = ' + this.size; if (this.angle) text += '\nangle = ' + this.angle.toFixed(3); text += '\ncolor = ' + this.color; return text; } /** Render debug info for this object */ renderDebugInfo() { if (!debug) return; // check if there is anything to show const hasPhysics = this.collideLevel || this.collideSolidObjects || this.isSolid; if (!hasPhysics && !this.parent) return; // show object info for debugging const size = vec2(max(this.size.x, .2), max(this.size.y, .2)); const color = rgb(this.collideLevel?1:0, this.collideSolidObjects?1:0, this.isSolid?1:0, .5); debugRect(this.pos, size, color, 0, hasPhysics ? 0 : this.angle, hasPhysics, false); // collision ignores the angle if (this.parent) debugRect(this.pos, size.scale(.8), rgb(1,1,1,.5), 0, this.angle, false, false); this.parent && debugLine(this.pos, this.parent.pos, rgb(1,1,1,.5), .5, 0, false); } } /** * LittleJS Drawing System * - Hybrid rendering with both Canvas2D and WebGL support * - Optimized tile sheet sprite rendering using WebGL batching * - Primitive drawing for polygons, ellipses, and lines * - Tile-based rendering with TileInfo and TextureInfo classes * - Text rendering with custom fonts and ImageFont support * - Color and additive color blending for effects * - Rotation, mirroring, and scaling transformations * - Camera system with position, scale, and rotation * - Multiple canvas support (main, WebGL, work canvases) * - Gradient fills and outlined shapes * - Image manipulation and color tinting * * Rendering Architecture: * - glCanvas: WebGL canvas for accelerated sprite batch rendering * - mainCanvas: Canvas2D overlay for text, UI, and custom drawing * - All draw functions default to WebGL when enabled, can force Canvas2D with useWebGL parameter * * @namespace Draw */ /** The primary 2D canvas visible to the user * @type {HTMLCanvasElement} * @memberof Draw */ let mainCanvas; /** 2d context for mainCanvas * - Scaled by canvasPixelRatio, so drawing to it is in css pixels * - getImageData and putImageData ignore that scale and work in backing store * pixels, so use workReadCanvas to read pixels back instead of this * @type {CanvasRenderingContext2D} * @memberof Draw */ let mainContext; /** The default 2d context to use for drawing, usually mainContext * @type {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} * @memberof Draw */ let drawContext; /** Offscreen canvas that can be used for image processing * @type {OffscreenCanvas} * @memberof Draw */ let workCanvas; /** Offscreen canvas that can be used for image processing * @type {OffscreenCanvasRenderingContext2D} * @memberof Draw */ let workContext; /** Offscreen canvas with willReadFrequently that can be used for image processing * @type {OffscreenCanvas} * @memberof Draw */ let workReadCanvas; /** Offscreen canvas with willReadFrequently that can be used for image processing * @type {OffscreenCanvasRenderingContext2D} * @memberof Draw */ let workReadContext; /** Extra canvas to composite behind the engine canvases when combining canvases * Set by plugins that render to their own canvas below the LittleJS canvases * @type {HTMLCanvasElement} * @memberof Draw */ let backgroundCanvas; /** The size of the main canvas (and other secondary canvases) in css pixels * - This is the screen space coordinate system, matching mousePos * - With canvasPixelRatio set the backing store is larger than this * @type {Vector2} * @memberof Draw */ let mainCanvasSize = vec2(); /** Array containing texture info for batch rendering system * @type {Array} * @memberof Draw */ let textureInfos = []; /** Keeps track of how many draw calls there were each frame for debugging * @type {number} * @memberof Draw */ let drawCount = 0; /** Keeps track of how many primitives were drawn each frame for debugging * A single draw call can render many primitives (e.g. a WebGL sprite batch). * @type {number} * @memberof Draw */ let primitiveCount = 0; // internal predicates for tint short-circuiting in canvas2D draw paths // isWhite ignores alpha because alpha is applied via globalAlpha, not multiply // isBlack includes alpha so additive colors that only contribute alpha are not skipped /** @ignore * @param {Color} c */ function isWhite(c) { return c.r >= 1 && c.g >= 1 && c.b >= 1; } /** @ignore * @param {Color} c */ function isBlack(c) { return c.r <= 0 && c.g <= 0 && c.b <= 0 && c.a <= 0; } /////////////////////////////////////////////////////////////////////////////// /** * Create a tile info object using a grid based system * - This can take vecs or floats for easier use and conversion * - If an index is passed in, the tile size and index will determine the position * @param {Vector2|number} [index] - Index of the tile in 1d or 2d form * @param {Vector2|number} [size] - Size of tile in pixels * @param {TextureInfo|number} [texture] - Texture index or info to use * @param {number} [padding] - How many pixels padding around tiles * @param {number} [bleed] - How many pixels smaller to draw tiles * @return {TileInfo} * @example * tile(2) // a tile at index 2 using the default tile size of 16 * tile(5, 8) // a tile at index 5 using a tile size of 8 * tile(1, 16, 3) // a tile at index 1 of size 16 on texture 3 * tile(vec2(4,8), vec2(30,10)) // a tile at index (4,8) with a size of (30,10) * @memberof Draw */ function tile(index=0, size=tileDefaultSize, texture=0, padding=tileDefaultPadding, bleed=tileDefaultBleed) { ASSERT(isVector2(index) || typeof index === 'number', 'index must be a vec2 or number'); ASSERT(isVector2(size) || typeof size === 'number', 'size must be a vec2 or number'); ASSERT(isNumber(texture) || texture instanceof TextureInfo, 'texture must be a number or TextureInfo'); ASSERT(isNumber(padding), 'padding must be a number'); if (typeof size === 'number') { // if size is a number, make it a vector ASSERT(size > 0, 'tile: size must be above 0', size); size = new Vector2(size, size); } // create tile info object const textureInfo = typeof texture === 'number' ? textureInfos[texture] : texture; if (!textureInfo?.size.x) { // no image: headless loads none and keeps the size; an image that failed to load, whose warning named it, gives // a tile of no size, which draws nothing; a slot never given an image is a mistake a debug build points out ASSERT(headlessMode || textureInfo instanceof TextureInfo, 'tile: no texture ' + texture + ', pass its image to engineInit and make tiles in gameInit or later'); return new TileInfo(new Vector2, headlessMode ? size.copy() : new Vector2, textureInfo, padding, bleed); } // get the position of the tile const sizePaddedX = size.x + padding*2; const sizePaddedY = size.y + padding*2; let x, y; if (typeof index === 'number') { const cols = textureInfo.size.x / sizePaddedX |0; x = index % cols; y = index / cols |0; } else { x = index.x; y = index.y; } const pos = new Vector2(x*sizePaddedX + padding, y*sizePaddedY + padding); return new TileInfo(pos, size, textureInfo, padding, bleed); } /** * Tile Info - Stores info about how to draw a tile * @memberof Draw */ class TileInfo { /** Create a tile info object * @param {Vector2} [pos=vec2()] - Top left corner of tile in pixels * @param {Vector2} [size] - Size of tile in pixels * @param {TextureInfo} [textureInfo] - Texture info to use * @param {number} [padding] - How many pixels padding around all sides of each tile (increases grid size, does not affect tile size) * @param {number} [bleed] - How many pixels smaller to shrink UVS of tiles (does not affect grid size, only UVs) * @param {number} [columns] - How many frames per row for frame(), 0 to keep frames on a single row */ constructor(pos=vec2(), size=tileDefaultSize, textureInfo=textureInfos[0], padding=tileDefaultPadding, bleed=tileDefaultBleed, columns=0) { /** @property {Vector2} - Top left corner of tile in pixels */ this.pos = pos.copy(); /** @property {Vector2} - Size of tile in pixels */ this.size = size.copy(); /** @property {number} - How many pixels padding around tiles */ this.padding = padding; /** @property {TextureInfo} - The texture info for this tile */ this.textureInfo = textureInfo; /** @property {Array|undefined} - A tile set's tiles, each wherever it was packed, as loadTiles makes * them: a tile layer given this tile info draws its tile n from tiles[n] and not from a grid on one sheet * @type {Array|undefined} */ this.tiles = undefined; /** @property {number} - Shrinks tile by this many pixels to prevent neighbors bleeding */ this.bleed = bleed; /** @property {number} - How many frames per row for frame(), 0 to keep frames on a single row */ this.columns = columns; } /** Returns a copy of this tile offset by a vector * @param {Vector2} offset - Offset to apply in pixels * @return {TileInfo} */ offset(offset) { return new TileInfo(this.pos.add(offset), this.size, this.textureInfo, this.padding, this.bleed, this.columns); } /** Returns a copy of this tile offset by a number of animation frames * Frames wrap down to the next row if columns is set * @param {number} frame - Offset to apply in animation frames * @return {TileInfo} */ frame(frame) { ASSERT(typeof frame === 'number', 'TileInfo.frame: frame must be a number', frame); const w = this.size.x + this.padding*2; const h = this.size.y + this.padding*2; const x = (this.columns ? frame % this.columns : frame) * w; const y = (this.columns ? frame / this.columns | 0 : 0) * h; ASSERT(!this.textureInfo || this.pos.x + x + this.size.x <= this.textureInfo.size.x, 'frame extends beyond texture width!'); ASSERT(!this.textureInfo || this.pos.y + y + this.size.y <= this.textureInfo.size.y, 'frame extends beyond texture height!'); return this.offset(new Vector2(x, y)); } /** Set how many frames per row this tile uses, so frame() can wrap * @param {number} [columns] - Frames per row, 0 to keep frames on a single row * @return {TileInfo} */ setColumns(columns=0) { ASSERT(isNumber(columns) && columns >= 0, 'columns must be a number >= 0'); this.columns = columns; return this; } /** * Returns a tile info for an index using this tile's size, texture and padding as reference * - the index counts from the texture's origin like tile(), for a sprite packed in a texture sheet use frame() * @param {Vector2|number} [index=0] * @return {TileInfo} */ index(index) { return tile(index, this.size, this.textureInfo, this.padding, this.bleed).setColumns(this.columns); } /** * Set this tile to use a full image in a texture info * @param {TextureInfo} [textureInfo] * @return {TileInfo} */ setFullImage(textureInfo=this.textureInfo) { this.textureInfo = textureInfo; this.pos = new Vector2; this.size = textureInfo.size.copy(); this.bleed = this.padding = this.columns = 0; return this; } } /** * Texture Info - Stores info about each texture * @memberof Draw */ class TextureInfo { /** * Create a TextureInfo, called automatically by the engine * @param {HTMLImageElement|HTMLCanvasElement|OffscreenCanvas|ImageBitmap} image * @param {boolean} [useWebGL] - Should use WebGL if available? * @param {boolean|Array} [wrap] - Should the texture wrap (REPEAT) or clamp (CLAMP_TO_EDGE)? Or the WebGL * modes across and down, like [gl.CLAMP_TO_EDGE, gl.MIRRORED_REPEAT], as a glTF sampler gives them * @param {boolean} [pixelated] - Hard edged or smooth for this texture alone, undefined follows tilesPixelated */ constructor(image, useWebGL=true, wrap=false, pixelated) { /** @property {HTMLImageElement|HTMLCanvasElement|OffscreenCanvas|ImageBitmap} - image source */ this.image = image; /** @property {Vector2} - size of the image */ this.size = image ? vec2(image.width, image.height) : vec2(); /** @property {Vector2} - inverse of the size, cached for rendering */ this.sizeInverse = image ? vec2(1/image.width, 1/image.height) : vec2(); /** @property {WebGLTexture|undefined} - WebGL texture * @type {WebGLTexture|undefined} */ this.glTexture = undefined; /** @property {boolean|Array} - true for REPEAT wrap mode, false for CLAMP_TO_EDGE, or the WebGL modes * across and down * @type {boolean|Array} */ this.wrap = wrap; /** @property {boolean|undefined} - Hard edged or smooth for this texture alone, a soft glow in a pixel art * game or pixel art in a smooth one; undefined follows tilesPixelated * @type {boolean|undefined} */ this.pixelated = pixelated; useWebGL && this.createWebGLTexture(); } /** Creates the WebGL texture, updates if already created */ createWebGLTexture() { const image = this.image; if (image?.width) // the image may have been resized since { this.size = vec2(image.width, image.height); this.sizeInverse = vec2(1/image.width, 1/image.height); } glRegisterTextureInfo(this); } /** Destroys the WebGL texture */ destroyWebGLTexture() { glUnregisterTextureInfo(this); } /** Check if the texture is webgl enabled * @return {boolean} */ hasWebGL() { return !!this.glTexture; } /** Set the wrap mode for this texture * @param {boolean|Array} [wrap] - true for REPEAT, false for CLAMP_TO_EDGE, or the WebGL modes across * and down */ setWrap(wrap=true) { this.wrap = wrap; glSetTextureWrap(this.glTexture, wrap); } /** Make this texture hard edged or smooth on its own, whatever tilesPixelated says for the rest; it is made again * with the new filtering * @param {boolean} [pixelated] - undefined follows tilesPixelated again */ setPixelated(pixelated) { this.pixelated = pixelated; if (!this.glTexture) return; this.destroyWebGLTexture(); this.createWebGLTexture(); } } /////////////////////////////////////////////////////////////////////////////// /** * @callback SpriteAnimationEndCallback - Called once when a SpriteAnimation's play ends * @return {void} * @memberof Draw */ /** * SpriteAnimation - Steps a tile through its frames over time: looping, once, or there and back * - Driven by the engine time like a Timer, so it pauses with the game and needs no update call * - Read tileInfo each frame for the frame to draw, from an object's update or before a drawTile * - loop, play and pingPong each start over from the first frame; stop holds the current one * - play(onEnd) calls onEnd once the play ends, on the first read after it, since there is no update to call it * - Frames follow each other along the row, as tileInfo.frame counts them * - SpriteAnimator switches between a character's animations by name * @example * const walk = new SpriteAnimation(tile(0, 16), 4, .1); // four frames, a tenth of a second each * const attack = new SpriteAnimation(tile(4, 16), 3, .05).play(); // once, then holds the last frame * // in update: this.tileInfo = (attack.isDone ? walk : attack).tileInfo; * @memberof Draw */ class SpriteAnimation { /** Create an animation over a run of frames, looping from the start * @param {TileInfo} tileInfo - The first frame * @param {number} frameCount - How many frames, one or more * @param {number} [frameTime] - Seconds each frame shows for */ constructor(tileInfo, frameCount, frameTime=.1) { ASSERT(tileInfo instanceof TileInfo, 'the first frame must be a TileInfo'); ASSERT(frameCount >= 1 && frameTime > 0, 'an animation needs at least one frame and a positive frame time'); /** @property {TileInfo} - The first frame, the others follow it along the row */ this.firstTile = tileInfo; /** @property {number} - How many frames */ this.frameCount = frameCount; /** @property {number} - Seconds each frame shows for */ this.frameTime = frameTime; /** @property {number} - Rate multiplier, 2 plays twice as fast; set it before starting */ this.speed = 1; /** @property {string} - How it runs: 'loop', 'once' or 'pingPong', set by loop, play and pingPong */ this.mode = 'loop'; /** @property {number} - Engine time it started at */ this.startTime = time; /** @property {number|undefined} - The frame held by stop, undefined while running * @type {number|undefined} */ this.heldFrame = undefined; /** @property {SpriteAnimationEndCallback|undefined} - Called once when a play ends, on the first read after it * @type {SpriteAnimationEndCallback|undefined} */ this.onEnd = undefined; } /** Start over from the first frame and repeat forever * @return {SpriteAnimation} */ loop() { return this.restart('loop'); } /** Start over from the first frame, run through once and hold the last frame, last to first at a negative speed * @param {function():void} [onEnd] - Called once when it ends, on the first read of it after that * @return {SpriteAnimation} */ play(onEnd) { this.restart('once'); this.onEnd = onEnd; return this; } /** Start over from the first frame and run there and back forever * @return {SpriteAnimation} */ pingPong() { return this.restart('pingPong'); } /** Hold the current frame * @return {SpriteAnimation} */ stop() { this.heldFrame = this.frame; return this; } /** Start over from the first frame in a mode, with no end callback * @param {string} [mode] - 'loop', 'once' or 'pingPong', the current mode when left out * @return {SpriteAnimation} */ restart(mode=this.mode) { this.mode = mode; this.startTime = time; this.heldFrame = undefined; this.onEnd = undefined; return this; } /** How many frames have gone by since the start, fractional * @return {number} */ get elapsedFrames() { return (time - this.startTime) * this.speed / this.frameTime; } /** The frame showing now, 0 to frameCount-1 * @return {number} */ get frame() { this.isDone; // a read after a play ends calls onEnd, which may start it over if (this.heldFrame !== undefined) return this.heldFrame; const n = this.frameCount, f = floor(this.elapsedFrames); if (this.mode == 'once') // backward it counts down from the last frame, showing it from the start return clamp(this.speed < 0 ? n - 1 + ceil(this.elapsedFrames) : f, 0, n - 1); if (this.mode == 'loop') return mod(f, n); const period = max(2 * n - 2, 1), k = mod(f, period); // there and back, the ends once each return k < n ? k : period - k; } /** The tile of the frame showing now * @return {TileInfo} */ get tileInfo() { return this.firstTile.frame(this.frame); } /** True once a play has shown its last frame for its time, the first read after that calls onEnd * @return {boolean} */ get isDone() { const done = this.mode == 'once' && this.heldFrame === undefined && abs(this.elapsedFrames) >= this.frameCount; if (done && this.onEnd) { const onEnd = this.onEnd; this.onEnd = undefined; // once, and it can start a new play with a callback of its own onEnd(); } return done; } } /////////////////////////////////////////////////////////////////////////////// /** * SpriteAnimator - A character's animations by name, like idle, walk and attack, and the one showing now * - Each clip is a SpriteAnimation and keeps its own mode, loop, play or pingPong * - set starts a clip over only when it changes or a play of it has ended, so it can be called every update * - Read tileInfo each frame for the frame to draw, as with a SpriteAnimation * @example * const hero = new SpriteAnimator({ * idle: new SpriteAnimation(tile(0, 16), 2, .4), * walk: new SpriteAnimation(tile(2, 16), 4, .1), * attack: new SpriteAnimation(tile(6, 16), 3, .05).play(), * }); * hero.set('attack', ()=> hero.set('idle')); // back to idle when the attack ends * // in update: this.tileInfo = hero.tileInfo; * @memberof Draw */ class SpriteAnimator { /** Create an animator from its clips, showing the first one * @param {Object} clips - The clips by name */ constructor(clips) { const names = Object.keys(clips); ASSERT(names.length > 0, 'an animator needs at least one clip'); /** @property {Object} - The clips by name */ this.clips = clips; /** @property {string} - The name of the clip showing now */ this.name = names[0]; } /** Show a clip, starting it over in its own mode unless it is showing and has not ended * @param {string} name - The clip's name * @param {function():void} [onEnd] - Called once when a play clip ends, as with SpriteAnimation.play * @return {SpriteAnimator} */ set(name, onEnd) { ASSERT(this.clips[name], 'no clip named ' + name); if (name == this.name && !this.clip.isDone) return this; this.name = name; this.clip.restart(); this.clip.onEnd = onEnd; return this; } /** The clip showing now * @return {SpriteAnimation} */ get clip() { return this.clips[this.name]; } /** The clip showing now, once a clip that has ended has called its onEnd: the callback may switch clips, and a * read gives the clip it switched to, not the one that ended * @return {SpriteAnimation} * @ignore */ get settledClip() { // each clip's own isDone calls its onEnd once; a few switches in a row at most for (let i = 4, clip; i-- && clip !== this.clip;) (clip = this.clip).isDone; return this.clip; } /** The tile of the frame showing now * @return {TileInfo} */ get tileInfo() { return this.settledClip.tileInfo; } /** The frame of the clip showing now * @return {number} */ get frame() { return this.settledClip.frame; } /** True once the clip showing now is a play that has ended * @return {boolean} */ get isDone() { return this.settledClip.isDone; } } /////////////////////////////////////////////////////////////////////////////// /** * Shader - A custom fragment shader for objects and draws, 2D or 3D * - Write a mainImage function in the post processing style, the renderer wraps it with its own program * - It gives the surface color, then the object's color and additive color apply in 2D, and the lighting, * shadows and fog in 3D; set emissive to 1 on a 3D object for the snippet's color to be final * - Set it as obj.shader, or use setShader for 2D draws and render3D.shader for 3D draws * - Draws that share a Shader share a batch; with no Shader set nothing changes * - In 2D it shades textured draws, untextured ones like drawRect draw as they are * - A render target, like a tile layer drawn in WebGL, holds premultiplied color, and so does every image when * tilesPixelated is false; there iChannel0 reads premultiplied texels and the snippet's color is taken as * premultiplied too. premultipliedTexture is true there, so a snippet that changes the alpha, or makes a see * through color of its own, scales the rgb with it: `if (premultipliedTexture) c.rgb *= k;` * - Compiled once per renderer by the first draw that needs it; a bad snippet throws with the GLSL log in debug * - Make each Shader once, at init, and share it; every one made lives for the session with its programs * - Names in both renderers: iChannel0 the texture, iTime, iResolution, premultipliedTexture, and localUV, 0 to 1 * across the sprite or the mesh's own uv * - Names in 3D only: worldPos, worldNormal, cameraPos, sunDirection, sunColor, ambientColor, ambientGroundColor, * lightCount, lights[i], lightColors[i] and shadow() * - In 3D the snippet may also define void mainNormal(inout vec3 n), given the normal facing the camera after the * normal map, in world space, to bend it per pixel for waves or ripples; the lighting, specular, reflection and * fog then all use it, as they use a normal map * - In 3D the shadow map is drawn without the Shader, cut only by the texture's alpha, so a snippet that removes * parts of a surface still shadows with the whole of it * @example * const fade = new Shader(` * void mainImage(out vec4 c, vec2 uv) * { * c = texture(iChannel0, uv); * float fade = .5 + .5*sin(iTime); * c.a *= fade; * if (premultipliedTexture) c.rgb *= fade; // a tile layer, or any image with tilesPixelated false * }`); * obj.shader = fade; * @memberof Draw */ class Shader { /** Create a shader from a fragment snippet that defines void mainImage(out vec4 c, vec2 uv) * @param {string} fragmentCode */ constructor(fragmentCode) { ASSERT(isStringLike(fragmentCode) && String(fragmentCode).includes('mainImage'), 'a Shader needs fragment code that defines mainImage'); /** @property {string} - The mainImage snippet */ this.fragmentCode = String(fragmentCode); /** @property {WebGLProgram|undefined} - The 2D program, compiled by the first draw that needs it, read only * @type {WebGLProgram|undefined} */ this.program = undefined; /** @property {WebGLProgram|undefined} - The 3D program, compiled by the 3D plugin the same way, read only * @type {WebGLProgram|undefined} */ this.program3D = undefined; glShaderObjects.push(this); // a lost context drops the programs of every one } /** Let go of this shader: its compiled programs are freed and it leaves the engine's list of shaders, which * keeps every one made for a lost context, so a scene made again and again, or an editor trying snippets, does * not keep them all; calling it again does nothing, and a draw with it afterwards compiles it again and takes it * back, so only let go of one no object still draws with when it should stay freed */ dispose() { glShaderDispose(this); } } /////////////////////////////////////////////////////////////////////////////// // Drawing functions /** Draw textured tile centered in world space * @param {Vector2} pos - Center of the tile in world space * @param {Vector2} [size=vec2(1)] - Size of the tile in world space * @param {TileInfo} [tileInfo] - Tile info to use, untextured if undefined * @param {Color} [color=WHITE] - Color to modulate with * @param {number} [angle] - Angle to rotate by * @param {boolean} [mirror] - Is the image flipped left to right? * @param {Color} [additiveColor] - Additive color to be applied if any * @param {boolean} [useWebGL=glEnable] - Use accelerated WebGL rendering? * @param {boolean} [screenSpace=drawScreenSpace] - Are the pos and size in screen space? * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context] - Canvas 2D context to draw to * @memberof Draw */ function drawTile(pos, size=vec2(1), tileInfo, color=WHITE, angle=0, mirror, additiveColor, useWebGL=glEnable, screenSpace=drawScreenSpace, context) { ASSERT(isVector2(pos), 'pos must be a vec2'); ASSERT(isVector2(size), 'size must be a vec2'); ASSERT(!tileInfo || tileInfo instanceof TileInfo, 'drawTile: tileInfo must be a TileInfo, color comes after it', tileInfo); ASSERT(isColor(color), 'color is invalid'); ASSERT(isNumber(angle), 'angle must be a number'); ASSERT(!additiveColor || isColor(additiveColor), 'additiveColor must be a color'); ASSERT(!context || !useWebGL, 'context only supported in canvas 2D mode'); if (headlessMode && !context) return; // headless has no canvas, only a context passed in is drawn to const textureInfo = tileInfo?.textureInfo; if (textureInfo && !(tileInfo.size.x && tileInfo.size.y && textureInfo.size.x)) return; // a tile with no area draws nothing, like a sprite still loading, and nor does an image that failed const bleed = tileInfo?.bleed ?? 0; if (useWebGL && glEnable) { ASSERT(!!glContext, 'WebGL is not enabled!'); if (screenSpace) { if (glSkipScreenSpace) return; [pos, size, angle] = screenToWorldTransform(pos, size, angle); } if (textureInfo) { ASSERT(!!textureInfo.glTexture, 'texture has no WebGL texture, draw it with useWebGL false'); // calculate uvs and render const sizeInverse = textureInfo.sizeInverse; const x = tileInfo.pos.x * sizeInverse.x; const y = tileInfo.pos.y * sizeInverse.y; const w = tileInfo.size.x * sizeInverse.x; const h = tileInfo.size.y * sizeInverse.y; glSetTexture(textureInfo.glTexture); if (bleed) { const bleedX = sizeInverse.x*bleed; const bleedY = sizeInverse.y*bleed; glDraw(pos.x, pos.y, mirror ? -size.x : size.x, size.y, angle, x + bleedX, y + bleedY, x - bleedX + w, y - bleedY + h, color.rgbaInt(), additiveColor && additiveColor.rgbaInt()); } else { glDraw(pos.x, pos.y, mirror ? -size.x : size.x, size.y, angle, x, y, x + w, y + h, color.rgbaInt(), additiveColor && additiveColor.rgbaInt()); } } else { // untextured: color plus additive in one color, as the Canvas2D path below does const combined = additiveColor ? color.add(additiveColor) : color; glDrawUntextured(pos.x, pos.y, size.x, size.y, angle, combined.rgbaInt()); } } else { // normal canvas 2D rendering method (slower) ++drawCount; ++primitiveCount; drawCanvas2D(pos, size, angle, mirror, (context)=> { if (textureInfo) { // un-flip Y so the image renders right-side up under drawCanvas2D's Y flip context.scale(1, -1); // smooth or pixelated as the texture says, as WebGL draws it, inside drawCanvas2D's save textureInfo.pixelated === undefined || (context.imageSmoothingEnabled = !textureInfo.pixelated); // calculate uvs and render const x = tileInfo.pos.x, y = tileInfo.pos.y; const w = tileInfo.size.x, h = tileInfo.size.y; drawImageColor(context, textureInfo.image, x, y, w, h, -.5, -.5, 1, 1, color, additiveColor, bleed); } else { // if no tile info, use untextured rect (Y-symmetric, no compensation needed) const c = additiveColor ? color.add(additiveColor) : color; context.fillStyle = c.toString(); context.fillRect(-.5, -.5, 1, 1); } }, screenSpace, context); } } /** Draw colored rect centered on pos * @param {Vector2} pos * @param {Vector2} [size=vec2(1)] * @param {Color} [color=WHITE] * @param {number} [angle] * @param {boolean} [useWebGL=glEnable] * @param {boolean} [screenSpace=drawScreenSpace] * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context] * @memberof Draw */ function drawRect(pos, size, color, angle, useWebGL, screenSpace, context) { drawTile(pos, size, undefined, color, angle, false, undefined, useWebGL, screenSpace, context); } /** Draw a rect centered on pos with a gradient from top to bottom * @param {Vector2} pos * @param {Vector2} [size=vec2(1)] * @param {Color} [colorTop=WHITE] * @param {Color} [colorBottom=CLEAR_WHITE] * @param {number} [angle] * @param {boolean} [useWebGL=glEnable] * @param {boolean} [screenSpace=drawScreenSpace] * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context] * @memberof Draw */ function drawRectGradient(pos, size=vec2(1), colorTop=WHITE, colorBottom=CLEAR_WHITE, angle=0, useWebGL=glEnable, screenSpace=drawScreenSpace, context) { ASSERT(isVector2(pos), 'pos must be a vec2'); ASSERT(isVector2(size), 'size must be a vec2'); ASSERT(isColor(colorTop) && isColor(colorBottom), 'color is invalid'); ASSERT(isNumber(angle), 'angle must be a number'); ASSERT(!context || !useWebGL, 'context only supported in canvas 2D mode'); if (headlessMode && !context) return; // headless has no canvas, only a context passed in is drawn to if (useWebGL && glEnable) { ASSERT(!!glContext, 'WebGL is not enabled!'); if (screenSpace) { if (glSkipScreenSpace) return; [pos, size, angle] = screenToWorldTransform(pos, size, angle); } // build 4 corner points for the rectangle const points = [], colors = []; const halfSizeX = size.x/2, halfSizeY = size.y/2; const colorTopInt = colorTop.rgbaInt(); const colorBottomInt = colorBottom.rgbaInt(); const c = cos(-angle), s = sin(-angle); for (let i=4; i--;) { const x = i & 1 ? halfSizeX : -halfSizeX; const y = i & 2 ? halfSizeY : -halfSizeY; const rx = x * c - y * s; const ry = x * s + y * c; const color = i & 2 ? colorTopInt : colorBottomInt; points.push(vec2(pos.x + rx, pos.y + ry)); colors.push(color); } glDrawColoredPoints(points, colors); } else { // normal canvas 2D rendering method (slower) ++drawCount; ++primitiveCount; drawCanvas2D(pos, size, angle, false, (context)=> { // gradient endpoints are flipped to match the Y flip inside drawCanvas2D const gradient = context.createLinearGradient(0, .5, 0, -.5); gradient.addColorStop(0, colorTop.toString()); gradient.addColorStop(1, colorBottom.toString()); context.fillStyle = gradient; context.fillRect(-.5, -.5, 1, 1); }, screenSpace, context); } } /** Draw a texture tiled (wrapped) across a rectangle in world space. * Useful for backgrounds, repeating patterns, and seamless fills. * The whole texture is tiled — sub-region (TileInfo) wrapping is not supported. * @param {Vector2} pos - Center of the rect in world space * @param {Vector2} size - Size of the rect in world space * @param {Vector2} wrapCount - How many times the texture repeats (x, y) * @param {TextureInfo|number} [texture] - TextureInfo or texture index into textureInfos * @param {Color} [color=WHITE] - Color to modulate with * @param {number} [angle] - Angle to rotate by * @param {Color} [additiveColor] - Additive color to be applied if any * @param {boolean} [useWebGL=glEnable] - Use accelerated WebGL rendering? * @param {boolean} [screenSpace=drawScreenSpace] - Are pos and size in screen space? * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context] - Canvas 2D context to draw to * @memberof Draw */ function drawTextureWrapped(pos, size, wrapCount, texture=0, color=WHITE, angle=0, additiveColor, useWebGL=glEnable, screenSpace=drawScreenSpace, context) { ASSERT(isVector2(pos), 'pos must be a vec2'); ASSERT(isVector2(size), 'size must be a vec2'); ASSERT(isVector2(wrapCount), 'wrapCount must be a vec2'); ASSERT(isColor(color), 'color is invalid'); ASSERT(isNumber(angle), 'angle must be a number'); ASSERT(!additiveColor || isColor(additiveColor), 'additiveColor must be a color'); ASSERT(!context || !useWebGL, 'context only supported in canvas 2D mode'); ASSERT(!(texture instanceof TileInfo), 'pass a TextureInfo or texture index, not a TileInfo — use tileInfo.textureInfo'); // only a context passed in is drawn to in headless mode, before the texture lookup, textureInfos is empty there if (headlessMode && !context) return; // resolve texture argument: TextureInfo or index const textureInfo = typeof texture === 'number' ? textureInfos[texture] : texture; ASSERT(textureInfo instanceof TextureInfo, 'texture not loaded'); ASSERT(textureInfo.size.x > 0, 'texture not loaded'); ASSERT(textureInfo.wrap, 'drawTextureWrapped requires a wrap-enabled texture; call textureInfo.setWrap(true) first'); if (useWebGL && glEnable) { ASSERT(!!glContext, 'WebGL is not enabled!'); if (screenSpace) { if (glSkipScreenSpace) return; [pos, size, angle] = screenToWorldTransform(pos, size, angle); } glSetTexture(textureInfo.glTexture); glDraw(pos.x, pos.y, size.x, size.y, angle, 0, 0, wrapCount.x, wrapCount.y, color.rgbaInt(), additiveColor && additiveColor.rgbaInt()); return; } // Canvas2D path — increment counts here (WebGL counts via glFlush) ++drawCount; ++primitiveCount; if (!screenSpace) { pos = worldToScreen(pos); size = size.scale(cameraScale); angle -= cameraAngle; } // pick image source: raw, or tinted bake. Match drawImageColor's // "no tint needed" predicate so behavior stays consistent. const noTint = !canvasColorTiles || isWhite(color) && (!additiveColor || isBlack(additiveColor)); // alpha is baked into pixels by bakeTintedImage's additive branch; // in that case globalAlpha must NOT also apply color.a const alphaBaked = !noTint && additiveColor && !isBlack(additiveColor); let source = textureInfo.image; if (!noTint) { // a bake is a pass over every pixel, so it is kept for the image until its tint changes; a canvas, which has // getContext, may have been drawn into since, so it is baked again at every draw, its copy kept at its size const key = color.r + ',' + color.g + ',' + color.b + ',' + color.a + (additiveColor ? ',' + additiveColor.r + ',' + additiveColor.g + ',' + additiveColor.b + ',' + additiveColor.a : ''); let baked = drawTextureWrappedBakes.get(source); if (baked?.key !== key || 'getContext' in source) { const kept = baked?.context, fits = kept?.canvas.width === (source.width|0) && kept?.canvas.height === (source.height|0); const bakeContext = fits ? kept : createCanvasContext(source.width|0, source.height|0, true); bakeTintedImage(source, color, additiveColor, bakeContext); drawTextureWrappedBakes.set(source, baked = {key, context: bakeContext}); } source = baked.context.canvas; } context = context || drawContext; context.save(); // smooth or pixelated as the texture says, as WebGL draws it textureInfo.pixelated === undefined || (context.imageSmoothingEnabled = !textureInfo.pixelated); context.translate(pos.x + .5, pos.y + .5); context.rotate(angle); context.globalAlpha = alphaBaked ? 1 : color.a; const pattern = context.createPattern(source, 'repeat'); // map pattern-source pixels into user space so the rect contains // wrapCount.x × wrapCount.y repeats const m = new DOMMatrix() .translate(-size.x/2, -size.y/2) .scale(size.x / (wrapCount.x * source.width), size.y / (wrapCount.y * source.height)); pattern.setTransform(m); context.fillStyle = pattern; context.fillRect(-size.x/2, -size.y/2, size.x, size.y); context.globalAlpha = 1; context.restore(); } /** Draw connected lines between a series of points * @param {Array} points * @param {number} [width] * @param {Color} [color=WHITE] * @param {boolean} [wrap] - Should the last point connect to the first? * @param {Vector2} [pos=vec2()] - Offset to apply * @param {number} [angle] - Angle to rotate by * @param {boolean} [useWebGL=glEnable] * @param {boolean} [screenSpace=drawScreenSpace] * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context] * @memberof Draw */ function drawLineList(points, width=.1, color=WHITE, wrap=false, pos=vec2(), angle=0, useWebGL=glEnable, screenSpace=drawScreenSpace, context) { ASSERT(isArray(points), 'points must be an array'); ASSERT(isNumber(width), 'width must be a number'); ASSERT(isColor(color), 'color is invalid'); ASSERT(isVector2(pos), 'pos must be a vec2'); ASSERT(isNumber(angle), 'angle must be a number'); ASSERT(!context || !useWebGL, 'context only supported in canvas 2D mode'); if (headlessMode && !context) return; // headless has no canvas, only a context passed in is drawn to if (useWebGL && glEnable) { ASSERT(!!glContext, 'WebGL is not enabled!'); let sx = 1, sy = 1; if (screenSpace) { if (glSkipScreenSpace) return; let size; [pos, size, angle] = screenToWorldTransform(pos, vec2(1), angle); sx = size.x, sy = size.y; } glDrawOutlineTransform(points, color.rgbaInt(), width, pos.x, pos.y, sx, sy, angle, wrap); } else { // normal canvas 2D rendering method (slower) ++drawCount; ++primitiveCount; drawCanvas2D(pos, vec2(1), angle, false, (context)=> { // Canvas2D ignores a width of 0 or below and keeps the last one, WebGL draws none for 0 // and a negative width as its size, the outline turned inside out if (!width) return; context.strokeStyle = color.toString(); context.lineWidth = abs(width); context.beginPath(); for (let i=0; i 0;) // a count that is not whole, or below zero, still ends { const a = (i/sides)*PI*2, point = pool[points.length] ||= vec2(); point.x = sin(a)*sizeX, point.y = cos(a)*sizeY; points.push(point); } drawPoly(points, color, lineWidth, lineColor, pos, angle, useWebGL, screenSpace, context); } // the points drawRegularPoly draws and the vectors it writes them into const drawRegularPolyPoints = [], drawRegularPolyPool = []; /** Draw colored polygon using passed in points * - WebGL fills a polygon whose edges do not cross, concave or not; a self crossing one, like a star through its * outer points, fills wrong there, so draw it as its simple outline or in parts * @param {Array} points - Array of Vector2 points * @param {Color} [color=WHITE] * @param {number} [lineWidth] * @param {Color} [lineColor=BLACK] * @param {Vector2} [pos=vec2()] - Offset to apply * @param {number} [angle] - Angle to rotate by * @param {boolean} [useWebGL=glEnable] * @param {boolean} [screenSpace=drawScreenSpace] * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context] * @memberof Draw */ function drawPoly(points, color=WHITE, lineWidth=0, lineColor=BLACK, pos=vec2(), angle=0, useWebGL=glEnable, screenSpace=drawScreenSpace, context=undefined) { ASSERT(isVector2(pos), 'pos must be a vec2'); ASSERT(isArray(points), 'points must be an array'); ASSERT(isColor(color) && isColor(lineColor), 'color is invalid'); ASSERT(isNumber(lineWidth), 'lineWidth must be a number'); ASSERT(isNumber(angle), 'angle must be a number'); ASSERT(!context || !useWebGL, 'context only supported in canvas 2D mode'); if (headlessMode && !context) return; // headless has no canvas, only a context passed in is drawn to if (useWebGL && glEnable) { ASSERT(!!glContext, 'WebGL is not enabled!'); let sx = 1, sy = 1; if (screenSpace) { if (glSkipScreenSpace) return; let size; [pos, size, angle] = screenToWorldTransform(pos, vec2(1), angle); sx = size.x, sy = size.y; } glDrawPointsTransform(points, color.rgbaInt(), pos.x, pos.y, sx, sy, angle); if (lineWidth > 0) glDrawOutlineTransform(points, lineColor.rgbaInt(), lineWidth, pos.x, pos.y, sx, sy, angle); } else { ++drawCount; ++primitiveCount; drawCanvas2D(pos, vec2(1), angle, false, context=> { context.fillStyle = color.toString(); context.beginPath(); for (const point of points) context.lineTo(point.x, point.y); context.closePath(); context.fill(); if (lineWidth > 0) // as in WebGL, Canvas2D would stroke a width below 0 with the last one { context.strokeStyle = lineColor.toString(); context.lineWidth = lineWidth; context.stroke(); } }, screenSpace, context); } } // the unit rings drawEllipse fills with in WebGL, one for each side count const drawEllipseRings = new Map; /** Draw colored ellipse using passed in point * @param {Vector2} pos * @param {Vector2} [size=vec2(1)] - Width and height diameter * @param {Color} [color=WHITE] * @param {number} [angle] * @param {number} [lineWidth] * @param {Color} [lineColor=BLACK] * @param {boolean} [useWebGL=glEnable] * @param {boolean} [screenSpace=drawScreenSpace] * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context] * @memberof Draw */ function drawEllipse(pos, size=vec2(1), color=WHITE, angle=0, lineWidth=0, lineColor=BLACK, useWebGL=glEnable, screenSpace=drawScreenSpace, context) { ASSERT(isVector2(pos), 'pos must be a vec2'); ASSERT(isVector2(size), 'size must be a vec2'); ASSERT(isColor(color) && isColor(lineColor), 'color is invalid'); ASSERT(isNumber(angle), 'angle must be a number'); ASSERT(isNumber(lineWidth), 'lineWidth must be a number'); ASSERT(lineWidth >= 0, 'lineWidth must be a positive value or 0'); ASSERT(!context || !useWebGL, 'context only supported in canvas 2D mode'); if (headlessMode && !context) return; // headless has no canvas, only a context passed in is drawn to // clamp line width to prevent artifacts, a negative size draws mirrored lineWidth = clamp(lineWidth, 0, min(abs(size.x), abs(size.y))); if (useWebGL && glEnable) { const sides = glCircleSides; if (lineWidth > 0) { // draw as a regular polygon, the outline is made from the points at their size drawRegularPoly(pos, size, sides, color, lineWidth, lineColor, angle, useWebGL, screenSpace, context); return; } // a fill is a unit ring scaled, made once for each side count and already in strip order let ring = drawEllipseRings.get(sides); if (!ring) { const points = []; for (let i=sides; i-- > 0;) { const a = (i/sides)*PI*2; points.push(vec2(sin(a), cos(a))); } drawEllipseRings.set(sides, ring = glPolyStrip(points)); } if (screenSpace) { if (glSkipScreenSpace) return; [pos, size, angle] = screenToWorldTransform(pos, size, angle); } glDrawPointsTransform(ring, color.rgbaInt(), pos.x, pos.y, size.x/2, size.y/2, angle, false); } else { ++drawCount; ++primitiveCount; drawCanvas2D(pos, vec2(1), angle, false, context=> { context.fillStyle = color.toString(); context.beginPath(); context.ellipse(0, 0, abs(size.x)/2, abs(size.y)/2, 0, 0, 9); // it throws on a negative radius context.fill(); if (lineWidth > 0) { context.strokeStyle = lineColor.toString(); context.lineWidth = lineWidth; context.stroke(); } }, screenSpace, context); } } /** Draw colored circle using passed in point * @param {Vector2} pos * @param {number} [size] - Diameter * @param {Color} [color=WHITE] * @param {number} [lineWidth] * @param {Color} [lineColor=BLACK] * @param {boolean} [useWebGL=glEnable] * @param {boolean} [screenSpace=drawScreenSpace] * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context] * @memberof Draw */ function drawCircle(pos, size=1, color=WHITE, lineWidth=0, lineColor=BLACK, useWebGL=glEnable, screenSpace=drawScreenSpace, context) { ASSERT(isNumber(size), 'size must be a number'); drawEllipse(pos, vec2(size), color, 0, lineWidth, lineColor, useWebGL, screenSpace, context); } let drawEllipseGradientOffset = 0; /** Draw an ellipse filled with a radial gradient from the center to the rim * - Best when batched with other untextured polys * - If drawing mostly textured sprites, bake the gradient into a texture and use drawTile instead * - Stacking gradients at the exact same position may show a faint vertical artifact * @param {Vector2} pos * @param {Vector2} [size=vec2(1)] - Width and height diameter * @param {Color} [colorInner=WHITE] * @param {Color} [colorOuter=CLEAR_WHITE] * @param {number} [angle] * @param {boolean} [useWebGL=glEnable] * @param {boolean} [screenSpace=drawScreenSpace] * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context] * @memberof Draw */ function drawEllipseGradient(pos, size=vec2(1), colorInner=WHITE, colorOuter=CLEAR_WHITE, angle=0, useWebGL=glEnable, screenSpace=drawScreenSpace, context) { ASSERT(isVector2(pos), 'pos must be a vec2'); ASSERT(isVector2(size), 'size must be a vec2'); ASSERT(isColor(colorInner) && isColor(colorOuter), 'color is invalid'); ASSERT(isNumber(angle), 'angle must be a number'); ASSERT(!context || !useWebGL, 'context only supported in canvas 2D mode'); if (headlessMode && !context) return; // headless has no canvas, only a context passed in is drawn to if (useWebGL && glEnable) { ASSERT(!!glContext, 'WebGL is not enabled!'); if (screenSpace) { if (glSkipScreenSpace) return; [pos, size, angle] = screenToWorldTransform(pos, size, angle); } // fan as tristrip; rotate the boundary vertex by one slice per call // so back-to-back gradients at the same position have their hole // (from gpu edge-rule on the boundary line-degen) at different rim // verts and don't visibly stack const sides = glCircleSides; const radiusX = size.x/2, radiusY = size.y/2; const innerInt = colorInner.rgbaInt(); const outerInt = colorOuter.rgbaInt(); const offset = drawEllipseGradientOffset++; const c = cos(-angle), s = sin(-angle); const rim = (a) => { const lx = sin(a)*radiusX, ly = cos(a)*radiusY; return vec2(pos.x + lx*c - ly*s, pos.y + lx*s + ly*c); }; const startA = (offset%sides)/sides*PI*2; const points = [rim(startA)]; const colors = [outerInt]; for (let i=sides; i-- > 0;) { const a = ((i+offset)%sides)/sides*PI*2; points.push(pos); colors.push(innerInt); points.push(rim(a)); colors.push(outerInt); } glDrawColoredPoints(points, colors); } else { // normal canvas 2D rendering method (slower) ++drawCount; ++primitiveCount; drawCanvas2D(pos, size, angle, false, (context)=> { const gradient = context.createRadialGradient(0, 0, 0, 0, 0, .5); gradient.addColorStop(0, colorInner.toString()); gradient.addColorStop(1, colorOuter.toString()); context.fillStyle = gradient; context.beginPath(); context.ellipse(0, 0, .5, .5, 0, 0, 9); context.fill(); }, screenSpace, context); } } /** Draw a circle filled with a radial gradient from the center to the rim * - Best when batched with other untextured polys * - If drawing mostly textured sprites, bake the gradient into a texture and use drawTile instead * - Stacking gradients at the exact same position may show a faint vertical artifact * @param {Vector2} pos * @param {number} [size] - Diameter * @param {Color} [colorInner=WHITE] * @param {Color} [colorOuter=CLEAR_WHITE] * @param {boolean} [useWebGL=glEnable] * @param {boolean} [screenSpace=drawScreenSpace] * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context] * @memberof Draw */ function drawCircleGradient(pos, size=1, colorInner=WHITE, colorOuter=CLEAR_WHITE, useWebGL=glEnable, screenSpace=drawScreenSpace, context) { ASSERT(isNumber(size), 'size must be a number'); drawEllipseGradient(pos, vec2(size), colorInner, colorOuter, 0, useWebGL, screenSpace, context); } /** * @callback Canvas2DDrawFunction - A function that draws to a 2D canvas context * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} context * @memberof Draw */ /** Draw directly to a 2d canvas context in world space. * The Y axis is flipped so world-Y-up coordinates render right-side up * (matches the WebGL path). Callers whose drawing depends on Y direction * (e.g. linear gradients) should flip their own Y endpoints accordingly. * @param {Vector2} pos * @param {Vector2} size * @param {number} [angle] * @param {boolean} [mirror] * @param {Canvas2DDrawFunction} [drawFunction] - Needed, marked optional only because the ones before it are * @param {boolean} [screenSpace=drawScreenSpace] * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context=drawContext] * @memberof Draw */ function drawCanvas2D(pos, size, angle=0, mirror=false, drawFunction, screenSpace=drawScreenSpace, context=drawContext) { ASSERT(isVector2(pos), 'pos must be a vec2'); ASSERT(isVector2(size), 'size must be a vec2'); ASSERT(isNumber(angle), 'angle must be a number'); ASSERT(typeof drawFunction === 'function', 'drawFunction must be a function'); if (headlessMode && !context) return; // headless has no canvas, only a context passed in is drawn to if (!screenSpace) { pos = worldToScreen(pos); size = size.scale(cameraScale); angle -= cameraAngle; } context.save(); context.translate(pos.x+.5, pos.y+.5); context.rotate(angle); context.scale(mirror ? -size.x : size.x, -size.y); drawFunction(context); context.restore(); } /////////////////////////////////////////////////////////////////////////////// // Text Drawing Functions /** Draw text on main canvas in world space * Automatically splits new lines into rows * @param {string|number} text * @param {Vector2} pos * @param {number} [size] * @param {Color} [color=WHITE] * @param {number} [lineWidth] * @param {Color} [lineColor=BLACK] * @param {'left'|'center'|'right'} [textAlign] * @param {string} [font=fontDefault] * @param {string} [fontStyle] * @param {number} [maxWidth] * @param {number} [angle] * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context=drawContext] * @memberof Draw */ function drawText(text, pos, size=1, color=WHITE, lineWidth=0, lineColor=BLACK, textAlign='center', font=fontDefault, fontStyle='', maxWidth, angle=0, context=drawContext) { // checked before it is scaled, a vec2 size would come out NaN ASSERT(isVector2(pos), 'drawText: pos must be a vec2', pos); ASSERT(isNumber(size), 'drawText: size is a number, the height of the text in world units', size); // convert to screen space pos = worldToScreen(pos); size *= cameraScale; lineWidth *= cameraScale; if (maxWidth !== undefined) maxWidth *= cameraScale; angle -= cameraAngle; drawTextScreen(text, pos, size, color, lineWidth, lineColor, textAlign, font, fontStyle, maxWidth, angle, context); } // the fonts a debug build has checked the canvas takes, by style and family, as a size never decides it and a zooming // camera draws at a new size each frame, and a font it does not, which would draw as 10px sans-serif with no word, // warned of once; a family name with spaces and digits, as Press Start 2P, needs quotes const drawFontsChecked = new Set; function drawFontCheck(context, fontText, font, fontStyle) { const key = fontStyle + '|' + font; if (drawFontsChecked.has(key)) return; drawFontsChecked.add(key); const sentinel = '1px littlejs-font-check'; // no font a game draws with context.font = sentinel; context.font = fontText; if (context.font === sentinel) console.warn(`the canvas does not take the font ${font}, so text draws as 10px sans-serif; ` + `a family name with spaces or digits goes in quotes, as "'Press Start 2P'"`); } // the lines of a text, split at a Windows line ending too const textLines = (text)=> (text += '').includes('\r') ? text.split(/\r?\n/) : text.split('\n'); // how many lines a text has, counted without splitting it function textLineCount(text) { let count = 1; for (let i = -1; (i = text.indexOf('\n', i + 1)) >= 0;) ++count; return count; } // the characters of a line of text as a reader counts them, each as its first code point: an emoji with its joiners // is one, and a letter with combining accents its letter; plain text takes the quick way, it is most of what is drawn, // so read character i with textCharacterCode const textIntl = /** @type {any} */ (globalThis.Intl); // Segmenter is newer than the library the source is checked with const textSegmenter = textIntl?.Segmenter && new textIntl.Segmenter; // a line with no mark, joiner or character past the 16 bit range, plain or accented letters of their own, Greek or // Japanese, has one code unit a character, so it is handed back as it is, read with charCodeAt, and nothing is made; // the class also holds what the segmenter joins that is not a mark: joiners, prepend, Thai and Lao SARA AM, Hangul // jamo extended and the halfwidth katakana voiced marks const textJoins = /[\p{M}\u200c\u200d\u0600-\u0605\u06dd\u070f\u0890\u0891\u08e2\u0d4e\u0e33\u0eb3\u1100-\u11ff\ua960-\ua97c\ud7b0-\ud7fb\uff9e\uff9f\u{10000}-\u{10ffff}]/u; /** @return {string|Array} */ function textCharacters(line) { if (!textJoins.test(line)) return line; // where graphemes can not be split, code points with the joiners, variation selectors and accents left out return textSegmenter ? Array.from(textSegmenter.segment(line), (s)=> s.segment.codePointAt(0)) : Array.from(line, (c)=> c.codePointAt(0)).filter((c)=> c !== 0x200d && c !== 0xfe0f && !(c >= 0x300 && c < 0x370)); } // the characters of a text as a reader counts them, each a string, an emoji with its joiners one; code points where // graphemes can not be split, and code units for a text that needs nothing joined, the quick way const textGraphemes = (text)=> !textJoins.test(text) ? text.split('') : textSegmenter ? Array.from(textSegmenter.segment(text), (s)=> s.segment) : [...text]; // character i of what textCharacters gave, its code; a Latin letter with an accent of its own, as é, is its letter, as // an e with a combining accent is, for the fonts that draw only the plain letters const textCharacterCode = (characters, i)=> textBaseLetter(typeof characters == 'string' ? characters.charCodeAt(i) : characters[i]); const textBaseLetters = new Map; function textBaseLetter(code) { if (code < 0xc0 || code >= 0x250) return code; let base = textBaseLetters.get(code); base === undefined && textBaseLetters.set(code, base = String.fromCharCode(code).normalize('NFD').charCodeAt(0)); return base; } /** Draw text in screen space * Automatically splits new lines into rows * @param {string|number} text * @param {Vector2} pos * @param {number} size * @param {Color} [color=WHITE] * @param {number} [lineWidth] * @param {Color} [lineColor=BLACK] * @param {'left'|'center'|'right'} [textAlign] * @param {string} [font=fontDefault] * @param {string} [fontStyle] * @param {number} [maxWidth] * @param {number} [angle] - Clockwise, like the other screen space draws * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context=drawContext] * @memberof Draw */ function drawTextScreen(text, pos, size, color=WHITE, lineWidth=0, lineColor=BLACK, textAlign='center', font=fontDefault, fontStyle='', maxWidth, angle=0, context=drawContext) { ASSERT(isStringLike(text), 'drawTextScreen: text must be a string', text); ASSERT(isVector2(pos), 'drawTextScreen: pos must be a vec2', pos); ASSERT(isNumber(size), 'drawTextScreen: size is a number, the height of the text in pixels', size); ASSERT(isColor(color), 'drawTextScreen: color must be a color', color); ASSERT(isNumber(lineWidth), 'drawTextScreen: lineWidth must be a number', lineWidth); ASSERT(isColor(lineColor), 'drawTextScreen: lineColor must be a color', lineColor); ASSERT(['left','center','right'].includes(textAlign), 'drawTextScreen: textAlign must be left, center or right', textAlign); ASSERT(isStringLike(font), 'font must be a string'); ASSERT(isStringLike(fontStyle), 'fontStyle must be a string'); ASSERT(isNumber(angle), 'angle must be a number'); if (headlessMode && !context) return; // headless has no canvas, only a context passed in is drawn to const lines = textLines(text); // a Windows line ending too // save before style mutations so caller's context state is preserved context.save(); context.fillStyle = color.toString(); context.strokeStyle = lineColor.toString(); context.lineWidth = lineWidth; context.textAlign = textAlign; const fontText = fontStyle + ' ' + size + 'px '+ font; debug && drawFontCheck(context, fontText, font, fontStyle); context.font = fontText; context.textBaseline = 'middle'; context.translate(pos.x + .5, pos.y + .5); // a screen position is the center of a pixel, as for every other draw context.rotate(angle); let yOffset = -(lines.length-1) * size/2; // center vertically lines.forEach(line=> { lineWidth && context.strokeText(line, 0, yOffset, maxWidth); context.fillText(line, 0, yOffset, maxWidth); yOffset += size; }); context.restore(); } /////////////////////////////////////////////////////////////////////////////// // Drawing utilities /** Load a texture at a specific index after engineInit, the images passed to engineInit load this way * @param {number} textureIndex - Index to store the texture at, an unused one * @param {string} [src] - Image source path * @return {Promise} Resolves to the texture info once the image loads, or fails to with a warning * @memberof Draw */ async function loadTexture(textureIndex, src) { ASSERT(isNumber(textureIndex), 'textureIndex must be a number'); // engineInit's empty placeholder in slot 0, when it was given no images, or a load that failed can be replaced const old = textureInfos[textureIndex]; ASSERT(!old?.size.x, 'textureIndex is already loaded!'); old?.destroyWebGLTexture(); ASSERT(!src || isStringLike(src), 'image src must be a string'); const image = new Image; if (src) { await engineAddLoad(new Promise(resolve => // startup waits for it { image.onload = resolve; image.onerror = ()=> { console.warn('failed to load image: ' + src); // in release too, like the WebGL warning resolve(); }; image.crossOrigin = 'anonymous'; image.src = src; })); } return textureInfos[textureIndex] = new TextureInfo(image); } /** Convert from screen to world space coordinates * @param {Vector2} screenPos * @return {Vector2} * @memberof Draw */ function screenToWorld(screenPos) { ASSERT(isVector2(screenPos), 'screenPos must be a vec2'); let x = (screenPos.x - mainCanvasSize.x/2 + .5) / cameraScale; let y = (screenPos.y - mainCanvasSize.y/2 + .5) / -cameraScale; if (cameraAngle) { // apply camera rotation const c = cos(-cameraAngle), s = sin(-cameraAngle); const xr = x * c - y * s, yr = x * s + y * c; x = xr; y = yr; } return new Vector2(x + cameraPos.x, y + cameraPos.y); } /** Convert from world to screen space coordinates * @param {Vector2} worldPos * @return {Vector2} * @memberof Draw */ function worldToScreen(worldPos) { ASSERT(isVector2(worldPos), 'worldPos must be a vec2'); let x = worldPos.x - cameraPos.x; let y = worldPos.y - cameraPos.y; if (cameraAngle) { // apply inverse camera rotation const c = cos(cameraAngle), s = sin(cameraAngle); const xr = x * c - y * s, yr = x * s + y * c; x = xr; y = yr; } return new Vector2 ( x * cameraScale + mainCanvasSize.x/2 - .5, y * -cameraScale + mainCanvasSize.y/2 - .5 ); } /** Convert from screen to world space coordinates for a directional vector (no translation) * @param {Vector2} screenDelta * @return {Vector2} * @memberof Draw */ function screenToWorldDelta(screenDelta) { ASSERT(isVector2(screenDelta), 'screenDelta must be a vec2'); let x = screenDelta.x / cameraScale; let y = screenDelta.y / -cameraScale; if (cameraAngle) { // apply camera rotation const c = cos(-cameraAngle), s = sin(-cameraAngle); const xr = x * c - y * s, yr = x * s + y * c; x = xr; y = yr; } return new Vector2(x, y); } /** Convert from world to screen space coordinates for a directional vector (no translation) * @param {Vector2} worldDelta * @return {Vector2} * @memberof Draw */ function worldToScreenDelta(worldDelta) { ASSERT(isVector2(worldDelta), 'worldDelta must be a vec2'); let x = worldDelta.x; let y = worldDelta.y; if (cameraAngle) { // apply inverse camera rotation const c = cos(cameraAngle), s = sin(cameraAngle); const xr = x * c - y * s, yr = x * s + y * c; x = xr; y = yr; } return new Vector2(x * cameraScale, y * -cameraScale); } /** Convert screen space transform to world space * @param {Vector2} screenPos * @param {Vector2} screenSize * @param {number} [screenAngle] * @return {[Vector2, Vector2, number]} - [pos, size, angle] * @memberof Draw */ function screenToWorldTransform(screenPos, screenSize, screenAngle=0) { ASSERT(isVector2(screenPos), 'screenPos must be a vec2'); ASSERT(isVector2(screenSize), 'screenSize must be a vec2'); ASSERT(isNumber(screenAngle), 'screenAngle must be a number'); return [ screenToWorld(screenPos), screenSize.scale(1/cameraScale), screenAngle + cameraAngle ]; } /** Get the size of the camera window in world space * @return {Vector2} * @memberof Draw */ function getCameraSize() { return mainCanvasSize.scale(1/cameraScale); } /** Padding for each side of a rectangle, the sides left out are 0 * @typedef {Object} CameraFitSides * @property {number} [top] * @property {number} [right] * @property {number} [bottom] * @property {number} [left] * @memberof Draw */ /** Fit the camera to a rectangle in world space by setting cameraPos and cameraScale * - worldMargin pads the content rectangle in world units, so the gap scales with the content on resize * - screenInset reserves space in screen pixels on each viewport edge (for example a HUD band) and * re-centers the content away from that edge, so the reserved band stays a fixed pixel size on resize * - worldMargin and screenInset may each be a number for all sides, a Vector2 (x=left/right, y=top/bottom), * or an object with any of {top, right, bottom, left} * @param {Vector2} center - Center of the rectangle in world space * @param {Vector2} size - Size of the rectangle in world space * @param {number|Vector2|CameraFitSides} [worldMargin] - World space padding added around the content rectangle * @param {number|Vector2|CameraFitSides} [screenInset] - Screen space padding in pixels reserved on each viewport edge * @return {number} - The new camera scale * @memberof Draw */ function cameraFit(center, size, worldMargin, screenInset) { ASSERT(isVector2(center), 'center must be a vec2'); ASSERT(isVector2(size), 'size must be a vec2'); // pad the content const margin = padSides(worldMargin); const inset = padSides(screenInset); const worldW = size.x + margin.left + margin.right; const worldH = size.y + margin.top + margin.bottom; const viewW = mainCanvasSize.x - inset.left - inset.right; const viewH = mainCanvasSize.y - inset.top - inset.bottom; // bail on a degenerate rect or viewport rather than NaN the camera if (!(worldW > 0 && worldH > 0 && viewW > 0 && viewH > 0)) return cameraScale; // scale to fit the padded content, measured along the camera's axes when it is turned const c = cos(cameraAngle), s = sin(cameraAngle); const fitW = abs(c) * worldW + abs(s) * worldH, fitH = abs(s) * worldW + abs(c) * worldH; cameraScale = min(viewW / fitW, viewH / fitH); // calculate offset vectors, the inset is on the screen, so it turns back into the world with the camera const marginVector = vec2(margin.right - margin.left, margin.top - margin.bottom).scale(.5); const ix = (inset.right - inset.left) * .5 / cameraScale, iy = (inset.top - inset.bottom) * .5 / cameraScale; const insetVector = vec2(ix * c + iy * s, iy * c - ix * s); // apply the offsets and return camera scale cameraPos = center.add(marginVector).add(insetVector); return cameraScale; function padSides(p) { // normalize a padding option to {top, right, bottom, left} if (p === undefined || isNumber(p)) p = vec2(p); if (isVector2(p)) return { top: p.y, right: p.x, bottom: p.y, left: p.x }; return { top: p.top || 0, right: p.right || 0, bottom: p.bottom || 0, left: p.left || 0, }; } } /** Check if a box, point, or circle is on screen with a circle test * If size is a Vector2, uses the length as diameter * This can be used to cull offscreen objects from render or update * @param {Vector2} pos - world space position * @param {Vector2|number} [size] - world space size or diameter * @return {boolean} * @memberof Draw */ function isOnScreen(pos, size=0) { ASSERT(isVector2(pos), 'pos must be a vec2'); ASSERT(isVector2(size) || isNumber(size), 'size must be a vec2 or number'); // cameraScale of 0 collapses world coords; nothing is visible if (!cameraScale) return false; // circle on screen test, worldToScreen inlined in doubled screen units so it compares against the full canvas size let x = pos.x - cameraPos.x; let y = pos.y - cameraPos.y; if (cameraAngle) { // apply inverse camera rotation const c = cos(cameraAngle), s = sin(cameraAngle); const xr = x * c - y * s, yr = x * s + y * c; x = xr; y = yr; } x *= cameraScale*2; y *= -cameraScale*2; if (size instanceof Vector2) size = size.length(); // use length of vector as diameter size *= cameraScale; // check against screen bounds const w = mainCanvasSize.x, h = mainCanvasSize.y; return x + size > -w && x - size < w && y + size > -h && y - size < h; } /** Enable additive blending * @param {boolean} [additive] * @memberof Draw */ function setAdditiveBlendMode(additive=true) { glAdditive = additive; if (drawContext) // none headless drawContext.globalCompositeOperation = additive ? 'lighter' : 'source-over'; } /** Set the Shader that 2D draws use from now on, none for the engine's own * - The object render loop sets each object's own shader, so this is for draws in gameRender and gameRenderPost * @param {Shader} [shader] * @memberof Draw */ function setShader(shader) { ASSERT(!shader || shader instanceof Shader, 'shader must be a Shader'); glCustomShader = shader || undefined; // null is no shader too, so it batches with none } /** Set an extra canvas to composite behind the engine canvases when combining * Plugins that insert their own canvas below the LittleJS canvases should set * this so it appears in screenshots * @param {HTMLCanvasElement} [canvas] * @memberof Draw */ function setBackgroundCanvas(canvas) { backgroundCanvas = canvas; } /** Combines LittleJS canvases onto the main canvas * This is necessary for things like screenshots and video * @memberof Draw */ function combineCanvases() { // this composites raw canvases so it works in backing store pixels, // mainCanvasSize is css pixels and would throw away resolution const w = mainCanvas.width, h = mainCanvas.height; workCanvas.width = w; workCanvas.height = h; // remove background alpha — explicit fillStyle so a previous caller // leaving workContext.fillStyle transparent can't silently no-op this workContext.fillStyle = '#000'; workContext.fillRect(0,0,w,h); if (backgroundCanvas) workContext.drawImage(backgroundCanvas, 0, 0, w, h); glCopyToContext(workContext); workContext.drawImage(mainCanvas, 0, 0); // draw back 1:1, mainContext is scaled to css pixels mainContext.save(); mainContext.setTransform(1, 0, 0, 1, 0, 0); mainContext.drawImage(workCanvas, 0, 0); mainContext.restore(); } // tint straight color pixels in place, rgb times color, plus additiveColor with alpha when it adds anything; // returns true when color.a went into the alpha, so it is not applied again as globalAlpha function tintImageData(data, color, additiveColor) { if (additiveColor && !isBlack(additiveColor)) { // multiply + additive (slower), color.a is baked into the alpha channel here const colorMultiply = [clamp(color.r), clamp(color.g), clamp(color.b), clamp(color.a)]; const colorAdd = [additiveColor.r * 255, additiveColor.g * 255, additiveColor.b * 255, additiveColor.a * 255]; for (let i = 0; i < data.length; ++i) data[i] = data[i] * colorMultiply[i&3] + colorAdd[i&3] |0; return true; } // RGB only, faster — alpha left intact for the caller for (let i = 0; i < data.length; i+=4) { data[i ] *= clamp(color.r); data[i+1] *= clamp(color.g); data[i+2] *= clamp(color.b); } return false; } // Internal: bake a color/additive-color tint into a context's canvas, the work // canvas by default, at the image's native resolution. Returns that canvas, // suitable for passing to context.createPattern. Used by drawTextureWrapped's // Canvas2D path, which keeps a canvas of its own for each image. Caller is responsible for short-circuiting when no // tint is needed (i.e. color is white and additiveColor is black/none). function bakeTintedImage(image, color, additiveColor, context=workReadContext) { const w = image.width|0, h = image.height|0; context.canvas.width = w; context.canvas.height = h; context.drawImage(image, 0, 0); const imageData = context.getImageData(0, 0, w, h); tintImageData(imageData.data, color, additiveColor); context.putImageData(imageData, 0, 0); return context.canvas; } // the tinted bake drawTextureWrapped keeps for each image, its tint and the context it is in const drawTextureWrappedBakes = new WeakMap; /** Internal: draw an image with color and additive color applied in Canvas2D, drawTile calls it * This is slower than normal drawImage when color is applied * @ignore * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} context * @param {HTMLImageElement|HTMLCanvasElement|OffscreenCanvas|ImageBitmap} image * @param {number} sx * @param {number} sy * @param {number} sWidth * @param {number} sHeight * @param {number} dx * @param {number} dy * @param {number} dWidth * @param {number} dHeight * @param {Color} color * @param {Color} [additiveColor] * @param {number} [bleed] - How many pixels to shrink the source, used to fix bleeding */ function drawImageColor(context, image, sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight, color, additiveColor, bleed=0) { const sx2 = bleed; const sy2 = bleed; sWidth = max(1,sWidth|0); sHeight = max(1,sHeight|0); const sWidth2 = sWidth - 2*bleed; const sHeight2 = sHeight - 2*bleed; if (!canvasColorTiles || isWhite(color) && (!additiveColor || isBlack(additiveColor))) { // white tint and nothing added, the texels are drawn as they are context.globalAlpha = color.a; context.drawImage(image, sx+sx2, sy+sy2, sWidth2, sHeight2, dx, dy, dWidth, dHeight); context.globalAlpha = 1; } else { // copy to offscreen canvas, sized again only when the size changes: setting a size remakes the canvas even // to the same one, and a game's tinted tiles are mostly of one size if (workReadCanvas.width !== sWidth || workReadCanvas.height !== sHeight) workReadCanvas.width = sWidth, workReadCanvas.height = sHeight; else workReadContext.clearRect(0, 0, sWidth, sHeight); workReadContext.drawImage(image, sx|0, sy|0, sWidth, sHeight, 0, 0, sWidth, sHeight); // tint image using offscreen work context const imageData = workReadContext.getImageData(0, 0, sWidth, sHeight); const alphaBaked = tintImageData(imageData.data, color, additiveColor); workReadContext.putImageData(imageData, 0, 0); if (!alphaBaked) context.globalAlpha = color.a; context.drawImage(workReadCanvas, sx2, sy2, sWidth2, sHeight2, dx, dy, dWidth, dHeight); if (!alphaBaked) context.globalAlpha = 1; } } /** Returns true if fullscreen mode is active * @return {boolean} * @memberof Draw */ function isFullscreen() { return !!document.fullscreenElement; } /** Toggle fullscreen mode * @memberof Draw */ function toggleFullscreen() { const rootElement = mainCanvas.parentElement; if (isFullscreen()) { if (document.exitFullscreen) document.exitFullscreen()?.catch?.(()=> {}); // refused outside a click, it stays as it is } else if (rootElement.requestFullscreen) rootElement.requestFullscreen()?.catch?.(()=> {}); } /** Set the cursor style * @param {string} [cursorStyle] - CSS cursor style (auto, none, crosshair, etc) * @memberof Draw */ function setCursor(cursorStyle = 'auto') { const rootElement = mainCanvas.parentElement; rootElement.style.cursor = cursorStyle; } /////////////////////////////////////////////////////////////////////////////// /** Engine font image, 8x8 font provided by the engine * @type {ImageFont} * @memberof Draw */ let engineImageFont; /** * Image Font Object - Draw text by using tiles in an image * - 96 characters (from space to tilde) are stored in an image * - A 8x8 default engine font is supplied for general use * - This system is WebGL enabled for fast text rendering * - Fonts can also be colored and scaled along each axis * * @memberof Draw * @example * // use built in font * const font = engineImageFont; * * // draw text * font.drawTextScreen('LittleJS\nHello World!', vec2(200, 50), 16); */ class ImageFont { /** Create an image font * @param {TileInfo} tileInfo - Tile info of first character in font */ constructor(tileInfo) { ASSERT(!!tileInfo, 'tileInfo is required for ImageFont'); /** @property {TileInfo} - Tile info for the font */ this.tileInfo = tileInfo; // kept, not copied, so a loadSprite tile filled in once it loads is seen } /** Draw text in world space using the image font * - The text stays upright and ignores cameraAngle, each glyph is snapped to whole screen pixels to keep it crisp * @param {string|number} text * @param {Vector2} pos * @param {Vector2|number} [size] * @param {boolean} [center=true] - center each line on pos, and the lines of multi-line text around it * @param {Color} [color=WHITE] * @param {boolean} [useWebGL=glEnable] * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context] */ drawText(text, pos, size=1, center, color, useWebGL, context) { ASSERT(isVector2(size) || typeof size === 'number', 'size must be a vec2 or number'); if (typeof size === 'number') { // if size is a number, make it a vector ASSERT(size > 0, 'ImageFont.drawText: size must be above 0', size); size *= cameraScale; size = new Vector2(size, size); } else size = size.scale(cameraScale); // world text, only drawn through screen space, so it still casts in a pass that skips screen space draws const skip = glSkipScreenSpace; glSkipScreenSpace = false; this.drawTextScreen(text, worldToScreen(pos), size, center, color, useWebGL, context); glSkipScreenSpace = skip; } /** Draw text in screen space using the image font * @param {string|number} text * @param {Vector2} pos * @param {Vector2|number} size * @param {boolean} [center] - center each line on pos, and the lines of multi-line text around it * @param {Color} [color=WHITE] * @param {boolean} [useWebGL=glEnable] * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context] */ drawTextScreen(text, pos, size, center=true, color=WHITE, useWebGL=glEnable, context) { ASSERT(isStringLike(text), 'text must be a string'); ASSERT(isVector2(pos), 'pos must be a vec2'); ASSERT(isVector2(size) || typeof size === 'number', 'size must be a vec2 or number'); ASSERT(typeof center === 'boolean', 'center must be a boolean, the color comes after it, unlike drawText'); ASSERT(isColor(color), 'color must be a color'); // if size is a number, make it a vector const glyphSize = typeof size === 'number' ? new Vector2(size, size) : size; // precache objects for drawing, a copy of the tile info each glyph moves, the font's own stays put const drawPos = new Vector2; const tileInfo = this.tileInfo.frame(0); // draw each line of text, centered vertically like drawTextScreen when center is set const lines = textLines(text); // a Windows line ending too const centerOffsetY = center ? (lines.length-1) * glyphSize.y / 2 : 0; lines.forEach((line, j)=> { const characters = textCharacters(line); const centerOffset = center ? (characters.length-1) * glyphSize.x / 2 : 0; for (let i=characters.length; i--;) { // get the glyph, out of range characters use the last one const charCode = textCharacterCode(characters, i); this.getGlyphPos(charCode < 32 || charCode > 127 ? 95 : charCode - 32, tileInfo.pos); // snap the glyph edges to whole pixels // tiles are drawn from their center, so snapping the center // to a whole pixel puts the edges on half pixels when the // size is even, and a row or column of the glyph then has // no pixel center inside it and is not rasterized at all // ceil picks the nearest aligned position, breaking ties // downward to match how this used to truncate drawPos.x = ceil(pos.x + i * glyphSize.x - centerOffset - glyphSize.x/2) + glyphSize.x/2 - .5; drawPos.y = ceil(pos.y + j * glyphSize.y - centerOffsetY - glyphSize.y/2) + glyphSize.y/2 - .5; drawTile(drawPos, glyphSize, tileInfo, color, 0, false, undefined, useWebGL, true, context); } }); } /** Get where a glyph sits in the texture: counted in the font's own columns when its tile has them, like a font * packed by loadSprite, otherwise along the texture's grid from the font's first tile, the way tile() lays it out * @param {number} index - Glyph number, 0 is the space and the characters follow in ASCII order * @param {Vector2} [pos] - Written into and returned, for a loop that places many * @return {Vector2} */ getGlyphPos(index, pos=new Vector2) { const t = this.tileInfo, padding = t.padding; const w = t.size.x + padding*2, h = t.size.y + padding*2; if (t.columns) return pos.set(t.pos.x + index % t.columns * w, t.pos.y + (index / t.columns |0) * h); const columns = t.textureInfo.size.x / w |0; const glyph = ((t.pos.y - padding) / h |0) * columns + ((t.pos.x - padding) / w |0) + index; return pos.set(glyph % columns * w + padding, (glyph / columns |0) * h + padding); } } // how strong a light's glow is at a distance from its middle, 0 there to 1 at the edge: a bell, full in the middle // and nothing at the edge, fading faster the higher the falloff function engineGlowAlpha(r, falloff) { const k = 3.5 * falloff, edge = Math.exp(-k); return (Math.exp(-k * r * r) - edge) / (1 - edge); } // the soft round glow of the 2D and 3D lights, one texture for each falloff, rounded to a tenth so a changing falloff // makes only a few, each made once from a canvas; undefined headless or without a canvas const engineGlowTextures = new Map; function engineGlowTexture(falloff=1) { ASSERT(isNumber(falloff) && falloff > 0, 'glowFalloff must be a number above 0'); const key = max(round(falloff * 10), 1) / 10; let texture = engineGlowTextures.get(key); if (texture || !glContext || !canvasAvailable()) return texture; const size = 64, context = createCanvasContext(size), steps = 16; const gradient = context.createRadialGradient(size / 2, size / 2, 0, size / 2, size / 2, size / 2); for (let i = 0; i <= steps; ++i) gradient.addColorStop(i / steps, 'rgba(255,255,255,' + engineGlowAlpha(i / steps, key).toFixed(4) + ')'); context.fillStyle = gradient; context.fillRect(0, 0, size, size); engineGlowTextures.set(key, texture = new TextureInfo(context.canvas, true, false, false)); return texture; } // load engine font, called automatically on startup async function imageFontInit() { const image = new Image; await new Promise(resolve => { image.onerror = image.onload = resolve; image.crossOrigin = 'anonymous'; image.src = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAUAAAAAeAQMAAABnrVXaAAAABlBMVEUAAAD///+l2Z/dAAAAAXRSTlMAQObYZgAAAjpJREFUOMu9kzFu2zAUhn+CAROgqrk+B2l0BWYxMjlXeYaAtFtbdA1sGgHqRQfI0CNkSG5AwYB0BQ8d5Bsomwah6CPVeGg6tEPzAxLwyI+P78cP4u9lNO9OoMKnLMOobG5020/yaj/MrRcCGh1gBbyiLTPJEYaIiom5KM9Jq7KgynMGtb6L4GL4MF2H4LQKCXTvDVw2I4MsgZT7QLExdiutH+D08VOP3INXRrWX1/mmpbkNgAPYRVANb4xpcegYvhiNbIXauQICEjBuYLfMakaakWQeXxiZ0VDtuJCKs3ztMV59QtsHJNcRxDzfdL21ty3PrfIcXTN+E+GFAv6T5nbT9jd50/WFxb5ksdAv49qS6ouymG66ji08UMT6moykYLAo+V0j23GN4m829ZySAD5K7QsBfQTvOG8eE+gTeGYRAmnNAubN3hf5Zv9tJWDHp/VTuaSm7SN4fyINQqaNO3RMVxvpSPXnOChnRNvFcGY0gnwiPswYwTKVPE0zVtX3mTEIOoFzaqLrGuJaV+Uqumb71fVk/VoOH3cdLNQP/FHi8hV0CQNoqBZsUPlLPMsdCJro9QAaQQ0woDy9BJm0eTxCFnO9srcYlhNVlfR2EyTrph1uUtbUtAJifwRgrKuYdXVHeb0YI3QpawohQHkloI3J5FuVwI5ORxC9k2Tuz9Ir1IjgeIPGMHYkAZe2RuYkmWFmt3gGbTPOmBUWVTmRmHtGrfpzG/yuQNOKa6gBB/WA9khitPgl6/GP+gl2Af6tCbvaygAAAABJRU5ErkJggg=='; }); const tilePos=vec2(), tileSize=vec2(8), padding=1, bleed=0; const textureInfo = new TextureInfo(image); const tileInfo = new TileInfo(tilePos, tileSize, textureInfo, padding, bleed); engineImageFont = new ImageFont(tileInfo); } /** * LittleJS Input System * - Keyboard input with key down, pressed, and released states * - Mouse input with position (world and screen space), buttons, and wheel * - Gamepad support for multiple controllers with analog sticks and buttons * - Touch input mapped to mouse position and buttons * - Virtual on-screen gamepad for mobile devices * - Automatic gamepad vs keyboard/mouse detection * - Input event prevention for canvas focus * - Clipboard copy/paste support * @namespace Input */ /** Mouse pos in world space * @type {Vector2} * @memberof Input */ let mousePos = vec2(); /** Mouse pos in screen space * @type {Vector2} * @memberof Input */ let mousePosScreen = vec2(); /** Mouse movement delta in world space * @type {Vector2} * @memberof Input */ let mouseDelta = vec2(); /** Mouse movement delta in screen space * @type {Vector2} * @memberof Input */ let mouseDeltaScreen = vec2(); /** Mouse wheel delta this frame * @type {number} * @memberof Input */ let mouseWheel = 0; /** Touch pinch delta this frame, in the mouse wheel's units: two fingers moving apart are negative, as the wheel * turned up is, and moving together positive, 1 for every 50 pixels; added to mouseWheel while touchPinchWheel * is on, so a game that zooms with the wheel zooms with a pinch * @type {number} * @memberof Input */ let touchPinch = 0; /** True if mouse was inside the document window, set to false when mouse leaves * @type {boolean} * @memberof Input */ let mouseInWindow = true; /** True if a gamepad is the most recently used input device. * Equivalent to usingGamepadInput(); derived from lastInputDevice each frame. * @type {boolean} * @memberof Input */ let isUsingGamepad = false; /** The most recently used input device: 'mouse' | 'keyboard' | 'gamepad'. * Sticky: it holds its value while every device is idle, so a mouse-follow * control (e.g. paddle = mousePos) won't snap back the instant the stick/keys * are released. With several devices in play at once (e.g. keyboard to move + * mouse to aim) it tracks whichever was touched last each frame, so it may * alternate — that's intended; use it to pick which control drives a shared * action. Updated every frame by inputUpdate(). * @type {'mouse'|'keyboard'|'gamepad'} * @memberof Input */ let lastInputDevice = 'mouse'; /** Screen-pixel mouse movement per frame that counts as "using the mouse" * (so sub-pixel hand jitter doesn't steal focus from the keyboard/gamepad). * @type {number} * @default * @memberof Input */ let inputMouseMoveThreshold = 6; /** Prevents input continuing to the default browser handling (true by default) * @type {boolean} * @memberof Input */ let inputPreventDefault = true; /** Primary gamepad index, automatically set to first gamepad with input * @type {number} * @memberof Input */ let gamepadPrimary = 0; /** True if a touch device has been detected * @memberof Input */ const isTouchDevice = !headlessMode && typeof window != 'undefined' && window.ontouchstart !== undefined; /** Prevents input continuing to the default browser handling * This is useful to disable for html menus so the browser can handle input normally, * the right click menu included; over an html text field that menu always shows * - While on, the mouse's back and forward buttons don't leave the page, a game can read them as mouse 3 and 4 * @param {boolean} [preventDefault] * @memberof Input */ function setInputPreventDefault(preventDefault=true) { inputPreventDefault = preventDefault; } /** Set the screen-pixel mouse movement per frame that counts as using the mouse * @param {number} threshold * @memberof Input */ function setInputMouseMoveThreshold(threshold) { inputMouseMoveThreshold = threshold; } /** Is the mouse the most recently used input device? * @return {boolean} * @memberof Input */ function usingMouseInput() { return lastInputDevice === 'mouse'; } /** Is the keyboard the most recently used input device? * @return {boolean} * @memberof Input */ function usingKeyboardInput() { return lastInputDevice === 'keyboard'; } /** Is a gamepad the most recently used input device? * @return {boolean} * @memberof Input */ function usingGamepadInput() { return lastInputDevice === 'gamepad'; } /** Clear a key's state, so it stops reading as down, pressed or released this frame; a key still held is not * pressed again until it is let go * @param {string|number} key - A key code like 'Space', or a mouse or gamepad button number * @param {number} [device] - 0 for the keyboard and mouse, gamepad n is device n + 1 * @param {boolean} [clearDown] - Clear the held state * @param {boolean} [clearPressed] - Clear the pressed this frame state * @param {boolean} [clearReleased] - Clear the released this frame state * @memberof Input */ function inputClearKey(key, device=0, clearDown=true, clearPressed=true, clearReleased=true) { if (!inputData[device]) return; inputData[device][key] &= ~((clearDown?1:0)|(clearPressed?2:0)|(clearReleased?4:0)); } // the WASD keys and the arrows they stand in for when inputWASDEmulateDirection is on, and the arrows' aliases const inputWASDToArrow = {KeyW:'ArrowUp', KeyS:'ArrowDown', KeyA:'ArrowLeft', KeyD:'ArrowRight'}; const inputArrowToWASD = {ArrowUp:'KeyW', ArrowDown:'KeyS', ArrowLeft:'KeyA', ArrowRight:'KeyD'}; const inputKeysHeld = new Set; // the keys physically down, since an arrow's slot is shared with its alias let inputPinch; // the two fingers pinching and how far apart they were, undefined with no pinch let inputWasTouching = 0, inputTouchIdentifier; // the touch driving the mouse, cleared with the input so only a new touch presses let inputLastTouchTime = -1e9; // when a touch event last came, the mouse events a browser makes from a tap follow it // is this mouse event one the browser made from a touch, which the touch input already handled function inputIsTouchMouseEvent(e) { return e.sourceCapabilities?.firesTouchEvents || performance.now() - inputLastTouchTime < 500; } // is a real gamepad being used, a button down or a standard stick pushed, only read to hand over from the touch // gamepad, a non standard pad's axes may rest at full deflection so they are not trusted here function inputRealGamepadUsed() { for (const gamepad of inputGetGamepads()) { if (!gamepad) continue; for (const button of gamepad.buttons) if (button.pressed) return true; if (gamepad.mapping === 'standard') for (let j = 0; j < 4 && j < gamepad.axes.length; ++j) if (abs(gamepad.axes[j]) > .5) return true; } return false; } // let go of every keyboard key, for when something else takes the keyboard, like a text field function inputClearKeyboard() { const keys = inputData[0]; for (const key in keys) isNaN(+key) && (keys[key] = 0); // mouse buttons are numbers, keys are codes inputKeysHeld.clear(); } /** Clears all input * @memberof Input */ function inputClear() { // empty every device but keep which gamepad slots exist, so gamepadConnected still reads true through a // clear, like the one every frame while the window is unfocused; a disconnect clears its slot in the poll for (let i = inputData.length; i--;) if (!i || inputData[i]) inputData[i] = []; inputKeysHeld.clear(); inputWasTouching = 0; inputPinch = undefined; // release the touch gamepad, a finger still on it has to lift before it can take a control again touchGamepadPointerRole.clear(); touchGamepadButtons.length = 0; touchGamepadButtonsPressed.length = 0; touchGamepadSticks.length = 0; touchGamepadStickPointerId.length = 0; // release floating sticks so they re-anchor gamepadStickData.length = 0; gamepadDpadData.length = 0; // gamepadButtonsLast and gamepadAxisCentered describe the pads, not input, so they are kept: // a button held through the clear reads as down, not newly pressed, and axes stay trusted } /////////////////////////////////////////////////////////////////////////////// /** Returns true if device key is down * @param {string|number} key * @param {number} [device] - 0 for the keyboard and mouse, gamepad n is device n + 1 * @return {boolean} * @memberof Input */ function keyIsDown(key, device=0) { ASSERT(isStringLike(key), 'keyIsDown: key must be a code like \'KeyA\' or a number', key); ASSERT(typeof key !== 'string' || key.length > 1, "keyIsDown: keys are codes like 'KeyW' or 'Space', not characters", key); ASSERT(device > 0 || typeof key !== 'number' || key < 5, "keyIsDown: keyboard keys are codes like 'KeyA' or 'Space', the numbers 0 to 4 are mouse buttons", key); return !!(inputData[device]?.[key] & 1) && !inputCaptureHides(device); } /** Returns true if device key was pressed this frame * @param {string|number} key * @param {number} [device] - 0 for the keyboard and mouse, gamepad n is device n + 1 * @return {boolean} * @memberof Input */ function keyWasPressed(key, device=0) { ASSERT(isStringLike(key), 'keyWasPressed: key must be a code like \'KeyA\' or a number', key); ASSERT(typeof key !== 'string' || key.length > 1, "keyWasPressed: keys are codes like 'KeyW' or 'Space', not characters", key); ASSERT(device > 0 || typeof key !== 'number' || key < 5, "keyWasPressed: keyboard keys are codes like 'KeyA' or 'Space', the numbers 0 to 4 are mouse buttons", key); return !!(inputData[device]?.[key] & 2) && !inputCaptureHides(device); } /** Returns true if device key was released this frame * @param {string|number} key * @param {number} [device] - 0 for the keyboard and mouse, gamepad n is device n + 1 * @return {boolean} * @memberof Input */ function keyWasReleased(key, device=0) { ASSERT(isStringLike(key), 'keyWasReleased: key must be a code like \'KeyA\' or a number', key); ASSERT(typeof key !== 'string' || key.length > 1, "keyWasReleased: keys are codes like 'KeyW' or 'Space', not characters", key); ASSERT(device > 0 || typeof key !== 'number' || key < 5, "keyWasReleased: keyboard keys are codes like 'KeyA' or 'Space', the numbers 0 to 4 are mouse buttons", key); return !!(inputData[device]?.[key] & 4) && !inputCaptureHides(device); } /** Returns input vector from arrow keys or WASD if enabled * @param {string} [up] * @param {string} [down] * @param {string} [left] * @param {string} [right] * @return {Vector2} * @memberof Input */ function keyDirection(up='ArrowUp', down='ArrowDown', left='ArrowLeft', right='ArrowRight') { ASSERT(isStringLike(up), 'up key must be a string'); ASSERT(isStringLike(down), 'down key must be a string'); ASSERT(isStringLike(left), 'left key must be a string'); ASSERT(isStringLike(right), 'right key must be a string'); const k = (key)=> keyIsDown(key) ? 1 : 0; return vec2(k(right) - k(left), k(up) - k(down)); } /** Returns true if mouse button is down * @param {number} button * @return {boolean} * @memberof Input */ function mouseIsDown(button) { ASSERT(isNumber(button), 'mouseIsDown: button is 0 (left), 1 (middle) or 2 (right)', button); return keyIsDown(button); } /** Returns true if mouse button was pressed * @param {number} button * @return {boolean} * @memberof Input */ function mouseWasPressed(button) { ASSERT(isNumber(button), 'mouseWasPressed: button is 0 (left), 1 (middle) or 2 (right)', button); return keyWasPressed(button); } /** Returns true if mouse button was released * @param {number} button * @return {boolean} * @memberof Input */ function mouseWasReleased(button) { ASSERT(isNumber(button), 'mouseWasReleased: button is 0 (left), 1 (middle) or 2 (right)', button); return keyWasReleased(button); } /** Returns true if gamepad button is down * @param {number} button * @param {number} [gamepad] * @return {boolean} * @memberof Input */ function gamepadIsDown(button, gamepad=gamepadPrimary) { ASSERT(isNumber(button), 'button must be a number'); ASSERT(isNumber(gamepad), 'gamepad must be a number'); return keyIsDown(button, gamepad+1); } /** Returns true if gamepad button was pressed * @param {number} button * @param {number} [gamepad] * @return {boolean} * @memberof Input */ function gamepadWasPressed(button, gamepad=gamepadPrimary) { ASSERT(isNumber(button), 'button must be a number'); ASSERT(isNumber(gamepad), 'gamepad must be a number'); return keyWasPressed(button, gamepad+1); } /** Returns true if gamepad button was released * @param {number} button * @param {number} [gamepad] * @return {boolean} * @memberof Input */ function gamepadWasReleased(button, gamepad=gamepadPrimary) { ASSERT(isNumber(button), 'button must be a number'); ASSERT(isNumber(gamepad), 'gamepad must be a number'); return keyWasReleased(button, gamepad+1); } /** Returns gamepad stick value * @param {number} stick * @param {number} [gamepad] * @return {Vector2} - Where the stick points, y up, length 0 to 1 past the dead zone, zero when there is no such stick * @memberof Input */ function gamepadStick(stick, gamepad=gamepadPrimary) { ASSERT(isNumber(stick), 'stick must be a number'); ASSERT(isNumber(gamepad), 'gamepad must be a number'); return gamepadStickData[gamepad]?.[stick] ?? vec2(); } /** Returns gamepad dpad value * @param {number} [gamepad] * @return {Vector2} * @memberof Input */ function gamepadDpad(gamepad=gamepadPrimary) { ASSERT(isNumber(gamepad), 'gamepad must be a number'); return gamepadDpadData[gamepad]?.copy() ?? vec2(); // a copy, the poller keeps its own } /** Returns true if passed in gamepad is connected * @param {number} [gamepad] * @return {boolean} * @memberof Input */ function gamepadConnected(gamepad=gamepadPrimary) { ASSERT(isNumber(gamepad), 'gamepad must be a number'); return !!inputData[gamepad+1]; } /** Returns how many control sticks the passed in gamepad has * @param {number} [gamepad] * @return {number} * @memberof Input */ function gamepadStickCount(gamepad=gamepadPrimary) { ASSERT(isNumber(gamepad), 'gamepad must be a number'); return gamepadStickData[gamepad]?.length ?? 0; } /** Pulse a gamepad's vibration hardware using the dual-rumble effect if it exists * Strong magnitude is usually the left side motor, weak magnitude is usually the right side motor * @param {number} [gamepad] - gamepad index * @param {number} [duration] - effect duration in ms, browsers limit it and the delay to 5 seconds together * @param {number} [strongMagnitude] - strong (left) motor intensity, 0 to 1 * @param {number} [weakMagnitude] - weak (right) motor intensity, 0 to 1 * @param {number} [startDelay] - delay in ms before the effect starts * @memberof Input */ function gamepadVibrate(gamepad=gamepadPrimary, duration=200, strongMagnitude=1, weakMagnitude=1, startDelay=0) { ASSERT(isNumber(gamepad), 'gamepad must be a number'); ASSERT(strongMagnitude >= 0 && strongMagnitude <= 1 && weakMagnitude >= 0 && weakMagnitude <= 1, 'rumble magnitudes must be 0 to 1'); ASSERT(duration >= 0 && startDelay >= 0 && duration + startDelay <= 5e3, 'browsers limit a rumble and its delay to 5 seconds'); if (!vibrateEnable || headlessMode) return; const pad = inputGetGamepads()[gamepad]; // a browser refuses a rumble it can not play by rejecting, which is nothing to report pad?.vibrationActuator?.playEffect?.('dual-rumble', {duration, strongMagnitude, weakMagnitude, startDelay}) ?.catch?.(()=> {}); } /** Stop vibration on a gamepad * @param {number} [gamepad] - gamepad index * @memberof Input */ function gamepadVibrateStop(gamepad=gamepadPrimary) { ASSERT(isNumber(gamepad), 'gamepad must be a number'); if (!vibrateEnable || headlessMode) return; const pad = inputGetGamepads()[gamepad]; pad?.vibrationActuator?.reset?.(); } // the gamepads the browser reports, none when it has no gamepad support or refuses the page them, which it // does by throwing a SecurityError from the call, as the spec says, for a page not allowed gamepads function inputGetGamepads() { try { return navigator?.getGamepads?.() || []; } catch (e) { return []; } } /////////////////////////////////////////////////////////////////////////////// /** Pulse the vibration hardware if it exists * @param {number|Array} [pattern] - single value in ms or vibration interval array * @memberof Input */ function vibrate(pattern=100) { ASSERT(isNumber(pattern) || isArray(pattern), 'pattern must be a number or array'); vibrateEnable && !headlessMode && navigator?.vibrate?.(pattern); } /** Cancel any ongoing vibration * @memberof Input */ function vibrateStop() { vibrate(0); } /////////////////////////////////////////////////////////////////////////////// // Pointer Lock /** Request to lock the pointer, for a mouse; a device with only touch refuses it * @memberof Input */ function pointerLockRequest() { // newer browsers return a promise that rejects when the lock is refused, like just after Esc left it, // or on a phone; a touchscreen laptop's mouse can still lock try { /** @type {any} */ (mainCanvas.requestPointerLock?.())?.catch?.(()=>{}); } catch { } } /** Request to unlock the pointer * @memberof Input */ function pointerLockExit() { inputLockExit(); // debug builds: the free camera and the 3D editor tell this from Escape document.exitPointerLock?.(); } /** Check if pointer is locked (true if locked) * @return {boolean} * @memberof Input */ function pointerLockIsActive() { return document.pointerLockElement === mainCanvas; } /////////////////////////////////////////////////////////////////////////////// // Input variables used by engine // input uses bit field for each key: 1=isDown, 2=wasPressed, 4=wasReleased // mouse and keyboard stored in device 0, gamepads stored in devices > 0 const inputData = [[]]; // gamepad internal variables const gamepadStickData = [], gamepadDpadData = [], gamepadHadInput = []; // per gamepad, each button's raw pressed state from the last poll, kept through inputClear and cleared on // disconnect, so a button held through a clear reads as down and not newly pressed const gamepadButtonsLast = []; // per gamepad, how many consecutive frames each axis has rested inside the // dead zone, used to tell stick axes from axes that rest at full deflection const gamepadAxisCentered = []; // how long an axis must rest inside the dead zone before it counts as a stick const gamepadAxisCenteredFrames = 15; // touch gamepad internal variables const touchGamepadTimer = new Timer(undefined, true), touchGamepadButtons = [], touchGamepadSticks = []; // buttons pressed since the last poll, so a tap that lifts before the poll still counts const touchGamepadButtonsPressed = []; // floating stick anchors (stage-local CSS pixels) and owning pointer ids, indexed by stick (0=left, 1=right) const touchGamepadStickAnchors = [], touchGamepadStickPointerId = []; // pointerId -> control role ('stick0', 'stick1', 'face', or 'start') const touchGamepadPointerRole = new Map(); // overlay DOM elements (created lazily on touch devices) and cached SVG shapes let touchGamepadOverlay, touchGamepadStage, touchGamepadSvg, touchGamepadSvgEls; let touchGamepadSideZones = [], touchGamepadZoneC; let touchGamepadNeedRelayout = true, touchGamepadLastLayout; /////////////////////////////////////////////////////////////////////////////// // Input system functions used by engine function inputInit() { if (headlessMode) return; // add event listeners document.addEventListener('keydown', onKeyDown); document.addEventListener('keyup', onKeyUp); document.addEventListener('mousedown', onMouseDown); document.addEventListener('mouseup', onMouseUp); document.addEventListener('mousemove', onMouseMove); document.addEventListener('mouseleave', onMouseLeave); document.addEventListener('wheel', onMouseWheel, { passive: false }); document.addEventListener('contextmenu', onContextMenu); addEventListener('blur', onBlur); // the window's, the browser fires blur there and it does not bubble to the document // init touch input, its handler checks touchInputEnable itself, so turning it on later works too if (isTouchDevice) touchInputInit(); function onKeyDown(e) { // fix stalled audio requiring user interaction, a keyboard only game has no other gesture if (soundEnable && !headlessMode && audioContext && !audioIsRunning()) audioResume(); // keys typed into an html text field are the player's typing, not game input; // a key already down still releases on keyup, which only lets go of keys that are down const typing = isTextInput(e.target) || isTextInput(document.activeElement); // a key already held, a keydown the browser did not mark as a repeat, is not pressed again, but with Cmd down // it is: a Mac sends no keyup for a key let go while Cmd is held, so each tap of Cmd+Z comes as a keydown alone if (!e.repeat && !typing && (!inputKeysHeld.has(e.code) || e.metaKey)) { inputKeysHeld.add(e.code); // an arrow its alias already holds down is not pressed again, like its release waits for both; a release // earlier this frame is kept, so a quick release and press reads as both if (!(inputWASDEmulateDirection && inputKeysHeld.has(inputArrowToWASD[e.code]) && inputData[0][e.code] & 1)) inputData[0][e.code] = 3 | inputData[0][e.code] & 4; // an alias presses its arrow's slot too, unless the arrow itself already holds it down const remap = remapKey(e.code); if (remap !== e.code && !(inputData[0][remap] & 1)) inputData[0][remap] = 3 | inputData[0][remap] & 4; } // try to prevent default browser handling of input if (!inputPreventDefault || !e.cancelable || !document.hasFocus()) return; // don't break browser shortcuts if (e.ctrlKey || e.metaKey || e.altKey) return; // don't interfere with user typing into UI fields if (typing) return; // fix browser setting "Search for text when you start typing" const printable = typeof e.key === 'string' && e.key.length === 1; // prevent arrow key and other default keys from messing with stuff const preventDefaultKeys = [ 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', // scrolling 'Space', // page down scroll 'Tab', // focus navigation 'Backspace', // browser back ]; if (preventDefaultKeys.includes(e.code) || printable) e.preventDefault(); } // true if an event is on an HTML control on the page, which handles it itself function isOnControl(e) { const target = /** @type {HTMLElement} */ (e.target); return !!target?.closest?.('input,textarea,select,button,a,label,[contenteditable]'); } function isTextInput(element) { // a field that takes typing or arrow keys, not an input that is really a button, checkbox or slider, // which would otherwise keep every key from the game after it was clicked const tag = element?.tagName; if (tag === 'INPUT') return !['button','checkbox','color','file','image','radio','range','reset','submit'].includes(element.type); return !!element?.isContentEditable || tag === 'TEXTAREA' || tag === 'SELECT'; } function onKeyUp(e) { inputKeysHeld.delete(e.code); // the key's own slot and the arrow slot an alias shares, each released only once nothing holds it: // an arrow held with its alias stays down until both are let go; a slot not down was never pressed as far // as the game knows (held since before focus or through a clear), so it is not released either // the alias is released whatever the setting is now, emulation turned off while W is held still lets go of // the ArrowUp it pressed const remap = inputWASDToArrow[e.code] || e.code; for (const key of remap === e.code ? [e.code] : [e.code, remap]) if (!inputKeysHeld.has(key) && !(inputWASDEmulateDirection && inputKeysHeld.has(inputArrowToWASD[key]))) if (inputData[0][key] & 1) inputData[0][key] = (inputData[0][key]&2) | 4; // a Mac sends no keyup for a key let go while Cmd is held, so letting go of Cmd lets go of every key if (e.key === 'Meta') for (const code of [...inputKeysHeld]) onKeyUp({code}); } function remapKey(k) { // handle remapping wasd keys to directions return inputWASDEmulateDirection && inputWASDToArrow[k] || k; } function onMouseDown(e) { // a mouse on a touchscreen laptop works, only the mouse events a tap makes are left to the touch input if (touchInputEnable && inputIsTouchMouseEvent(e)) return; // fix stalled audio requiring user interaction if (soundEnable && !headlessMode && audioContext && !audioIsRunning()) audioResume(); // a press in the bars around a letterboxed canvas is not one on its edge; a drag out of it still moves; under // pointer lock the mouse stays where the lock began, which may be in a bar, and every click is the game's if (!pointerLockIsActive() && !inCanvas(e.x, e.y)) return; inputData[0][e.button] = 3 | inputData[0][e.button] & 4; // a release earlier this frame is kept const mousePosScreenLast = mousePosScreen; mousePosScreen = mouseEventToScreen(vec2(e.x,e.y)); mouseDeltaScreen = mouseDeltaScreen.add(mousePosScreen.subtract(mousePosScreenLast)); // a click on an HTML form control on the page is left to it, so it can take focus, place the caret or drag if (inputPreventDefault && e.cancelable && document.hasFocus() && !isOnControl(e)) { // this keeps focus where it is, so a click outside a text field lets it go, or it keeps the keys const active = /** @type {HTMLElement} */ (document.activeElement); isTextInput(active) && !active.contains(/** @type {Node} */ (e.target)) && active.blur(); e.preventDefault(); } } function onMouseUp(e) { if (touchInputEnable && inputIsTouchMouseEvent(e)) return; // released only if it was pressed, like a key or a touch if (inputData[0][e.button] & 1) inputData[0][e.button] = (inputData[0][e.button]&2) | 4; // the mouse's back and forward buttons would leave the page, like Backspace would if (inputPreventDefault && e.cancelable && (e.button === 3 || e.button === 4)) e.preventDefault(); } function onMouseMove(e) { mouseInWindow = true; const mousePosScreenLast = mousePosScreen; mousePosScreen = mouseEventToScreen(vec2(e.x,e.y)); // when pointer is locked use movementX/Y for delta, css pixels scaled to canvas units the way // mouseEventToScreen maps positions, so the delta matches the unlocked one with canvasFixedSize let movement; if (pointerLockIsActive()) { const rect = mainCanvas.getBoundingClientRect(); movement = vec2(e.movementX * mainCanvasSize.x / rect.width, e.movementY * mainCanvasSize.y / rect.height); } else movement = mousePosScreen.subtract(mousePosScreenLast); mouseDeltaScreen = mouseDeltaScreen.add(movement); } function onMouseLeave() { mouseInWindow = false; } // mouse moved off window function onMouseWheel(e) { // accumulate so multiple wheel events in one frame are not lost // a text area, list or field on the page scrolls itself, and the game does not take that wheel const target = /** @type {HTMLElement} */ (e.target); if (target?.closest?.('input,textarea,select,[contenteditable]')) return; if (!e.ctrlKey) mouseWheel += sign(e.deltaY); if (inputPreventDefault && e.cancelable && document.hasFocus()) e.preventDefault(); // prevent page scrolling } function onContextMenu(e) { // prevent right click menu, but a text field keeps its copy and paste menu and a link its open in new tab const target = /** @type {HTMLElement} */ (e.target); if (inputPreventDefault && !isTextInput(target) && !target?.closest?.('a[href]')) e.preventDefault(); } function onBlur() { // inputClear also releases any held virtual gamepad controls so they don't stick inputClear(); } // enable touch input mouse passthrough function touchInputInit() { // add non passive touch event listeners document.addEventListener('touchstart', (e)=> handleTouch(e), { passive: false }); document.addEventListener('touchmove', (e)=> handleTouch(e), { passive: false }); document.addEventListener('touchend', (e)=> handleTouch(e), { passive: false }); document.addEventListener('touchcancel', (e)=> handleTouch(e), { passive: false }); // the browser ended it // handle all touch events the same way function handleTouch(e) { // fix stalled audio requiring user interaction, with touch input off too since the page's sound needs it if (soundEnable && !headlessMode && audioContext && !audioIsRunning()) audioResume(); if (!touchInputEnable) { // turned off mid touch, the finger that drove the mouse lets go of it if (inputWasTouching && (inputData[0][0] & 1)) inputData[0][0] = inputData[0][0] & 2 | 4; inputWasTouching = 0; return; } inputLastTouchTime = performance.now(); // when the touch gamepad is enabled it owns touch input: suppress the // touch->mouse passthrough entirely unless touchGamepadPassthrough is set // (its own zones drive gameplay via pointer events) if (!touchGamepadEnable || touchGamepadPassthrough) { // touches that landed on a virtual gamepad zone are owned by the gamepad // (handled by its own pointer listeners) and must not drive the game mouse const isGamepadTouch = (t)=> touchGamepadSideZones.includes(t.target) || t.target === touchGamepadZoneC; const gameTouches = []; for (const t of e.touches) if (!isGamepadTouch(t)) gameTouches.push(t); // check if touching and pass to mouse events const touching = gameTouches.length; const button = 0; // all touches are left mouse button if (touching) { // only a game finger coming down presses, not one held through a clear, and it drives the mouse const pressTouch = !inputWasTouching && e.type == 'touchstart' && [...e.changedTouches].find(t=> !isGamepadTouch(t)); const touch = pressTouch || gameTouches[0]; let pos = vec2(touch.clientX, touch.clientY); // two fingers pinch: how far they moved together or apart is the touch screen's wheel, a // click for every 50 pixels, and the mouse is between them, the place to zoom about; with // touchPinchWheel off the pinch is only measured, and the first finger is the mouse as ever const pinching = touching > 1 && touchPinchWheel; if (touching > 1) { const [a, b] = gameTouches, key = a.identifier + ' ' + b.identifier; const distance = hypot(a.clientX - b.clientX, a.clientY - b.clientY); if (inputPinch && inputPinch.key === key) { const delta = (inputPinch.distance - distance) / 50; touchPinch += delta; if (touchPinchWheel) mouseWheel += delta; } inputPinch = {key, distance}; // other fingers start it over, with no jump if (pinching) pos = vec2((a.clientX + b.clientX) / 2, (a.clientY + b.clientY) / 2); } else inputPinch = undefined; // set event pos and pass it along const mousePosScreenLast = mousePosScreen; mousePosScreen = mouseEventToScreen(pos); if (pinching) { // a pinch is not a press or a drag, the finger that pressed lets go if (inputData[0][button] & 1) inputData[0][button] = inputData[0][button] & 2 | 4; } else if (inputWasTouching && touch.identifier === inputTouchIdentifier) mouseDeltaScreen = mouseDeltaScreen.add(mousePosScreen.subtract(mousePosScreenLast)); else if (pressTouch && inCanvas(pressTouch.clientX, pressTouch.clientY)) inputData[0][button] = 3 | inputData[0][button] & 4; // a tap in the bars is not a press // the finger left after a pinch moves the mouse from where it is, with no jump and no press inputTouchIdentifier = pinching ? undefined : touch.identifier; } else { inputPinch = undefined; if (inputWasTouching && (inputData[0][button] & 1)) inputData[0][button] = inputData[0][button] & 2 | 4; // released only if it was pressed } // set was touching inputWasTouching = touching; } // prevent default handling like copy, magnifier lens, and scrolling, but a tap on an HTML control is // left to it, cancelling a touch would also take away a button's click or a slider's drag if (inputPreventDefault && e.cancelable && document.hasFocus() && !isOnControl(e)) { // like a mouse click, a tap outside a focused text field lets it go, the cancel keeps focus where it is const active = /** @type {HTMLElement} */ (document.activeElement); e.type == 'touchstart' && isTextInput(active) && !active.contains(/** @type {Node} */ (e.target)) && active.blur(); e.preventDefault(); } // must return true so the document will get focus return true; } } // is a point of the window on the canvas function inCanvas(x, y) { const rect = mainCanvas.getBoundingClientRect(); return x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom; } // convert a mouse or touch event position to screen space function mouseEventToScreen(mousePos) { const rect = mainCanvas.getBoundingClientRect(); const px = percent(mousePos.x, rect.left, rect.right); const py = percent(mousePos.y, rect.top, rect.bottom); return vec2(px*mainCanvasSize.x, py*mainCanvasSize.y); } } function inputUpdate() { inputCaptureMouse(); // debug builds: the free camera or the 3D editor may have the mouse if (headlessMode) return; // clear input when lost focus (prevent stuck keys) if (!(touchInputEnable && isTouchDevice) && !document.hasFocus()) inputClear(); // update mouse world space position and delta mousePos = screenToWorld(mousePosScreen); mouseDelta = screenToWorldDelta(mouseDeltaScreen); // build the touch gamepad overlay lazily once enabled on a touch device touchGamepadInit(); // update gamepads if enabled gamepadsUpdate(); // update most recently used input device updateLastInputDevice(); function updateLastInputDevice() { // mouse: any button held or moved const mouseActive = mouseIsDown(0) || mouseIsDown(1) || mouseIsDown(2) || mouseDeltaScreen.length() > inputMouseMoveThreshold; // gamepad: any button held or stick moved let gamepadActive = false; for (let s = gamepadStickCount(); s-- && !gamepadActive;) gamepadActive = gamepadStick(s).lengthSquared() > .2; for (let b = 17; b-- && !gamepadActive;) gamepadActive = gamepadIsDown(b); // keyboard: any non-mouse key down let keyboardActive = false; for (const k in inputData[0]) if (isNaN(+k) && (inputData[0][k] & 1)) { keyboardActive = true; break; } // update the last input if (gamepadActive) lastInputDevice = 'gamepad'; else if (mouseActive) lastInputDevice = 'mouse'; else if (keyboardActive) lastInputDevice = 'keyboard'; // set flag if gamepad is last device isUsingGamepad = lastInputDevice === 'gamepad'; } // gamepads are updated by engine every frame automatically function gamepadsUpdate() { const deadZoneMin=.3, deadZoneMax=.8; const applyDeadZones = (v)=> { const deadZone = (v)=> v > deadZoneMin ? percent(v, deadZoneMin, deadZoneMax) : v < -deadZoneMin ? -percent(-v, deadZoneMin, deadZoneMax) : 0; return vec2(deadZone(v.x), deadZone(-v.y)).clampLength(); }; // the touch gamepad owns gamepad 0 once touched, until a real gamepad is used: that one takes over and the // touch gamepad hides until the screen is touched again if (touchGamepadEnable && isTouchDevice && touchGamepadTimer.isSet() && gamepadsEnable && inputRealGamepadUsed()) touchGamepadTimer.unset(); if (touchGamepadEnable && isTouchDevice && touchGamepadTimer.isSet()) { // read virtual analog stick gamepadPrimary = 0; // touch gamepad uses index 0 const sticks = gamepadStickData[0] ?? (gamepadStickData[0] = []); const dpad = gamepadDpadData[0] ?? (gamepadDpadData[0] = vec2()); sticks.length = 0; // only report sticks that are enabled dpad.set(); // read each side's directional stick (analog, or quantized to an 8 way dpad) for (let side = 0; side < 2; side++) { if (!touchGamepadSideStick(side)) continue; const out = touchGamepadStickOut(side); sticks[out] = vec2(); const touchStick = touchGamepadSticks[side] ?? vec2(); if (touchGamepadAnalog) sticks[out] = applyDeadZones(touchStick); else if (touchStick.lengthSquared() > .3) { const x = clamp(round(touchStick.x), -1, 1); const y = clamp(round(touchStick.y), -1, 1); sticks[out] = vec2(x, -y).clampLength(); // clamp to circle if (!out) dpad.set(x, -y); // the primary (stick 0) also drives the dpad vector } } // read virtual gamepad buttons // a press latched since the last poll counts even when the finger already lifted, // then it reads pressed and released at once, like a quick key tap const data = inputData[1] ?? (inputData[1] = []); for (let i=12; i--;) { const wasDown = gamepadIsDown(i,0), isDown = touchGamepadButtons[i]; const pressed = touchGamepadButtonsPressed[i] || isDown && !wasDown; data[i] = isDown ? pressed ? 3 : 1 : pressed ? 6 : wasDown ? 4 : 0; // haptic tap when a face button or start button is first pressed // skip stick touches (10, 11) so movement doesn't buzz if (touchGamepadVibration && (data[i] & 2) && (i === 9 || touchGamepadIsFaceButton(i))) vibrate(touchGamepadVibration); } touchGamepadButtonsPressed.length = 0; gamepadButtonsLast[0] = undefined; // a real gamepad 0 starts fresh when it takes over again // real gamepads are not read while the touch gamepad is in use return; } // only poll gamepads when focused or in debug mode; // what the last poll saw is forgotten meanwhile, a button let go while away is not released on return if (gamepadsEnable && !debug && !document.hasFocus()) return void (gamepadButtonsLast.length = 0); // poll gamepads; every slot is visited and a slot with no gamepad is cleared, so a refused or // vanished gamepad does not leave its buttons held, even past the end of a shorter array // like the one Firefox returns after the highest numbered gamepad disconnects const maxGamepads = 8; const gamepads = gamepadsEnable ? inputGetGamepads() : []; // disabled lets go of them like unplugged for (let i=0; i { const v = isFinite(gamepad.axes[j]) ? gamepad.axes[j] : 0; // a broken axis reads as centered if (isStandard && j < 4) return v; // spec guarantees axes 0-3 are the two sticks if (!gamepadAxisFilterEnable) return v; // once an axis has proven it rests at center it stays trusted, // otherwise moving it would immediately disqualify it again const frames = centered[j] | 0; if (frames > gamepadAxisCenteredFrames) return v; centered[j] = abs(v) < deadZoneMin ? frames + 1 : 0; return 0; }; for (let j = 0; j < gamepad.axes.length-1; j+=2) sticks[j>>1] = applyDeadZones(vec2(readAxis(j), readAxis(j+1))); // read buttons; a stick pushed past halfway counts as input too, after the dead zone and the // axis filter, so a controller can become the primary one by moving and a noisy axis cannot let hadInput = sticks.some(stick=> stick.lengthSquared() > .25); for (let j = gamepad.buttons.length; j--;) { // compare with the raw state of the last poll, not the input data an inputClear may have emptied const button = gamepad.buttons[j]; const wasDown = buttonsLast[j]; buttonsLast[j] = button.pressed; data[j] = button.pressed ? wasDown ? 1 : 3 : wasDown ? 4 : 0; // check for any input on this gamepad, analog must be full press if (button.pressed && (!button.value || button.value > .9)) hadInput = true; } // a different gamepad in the same slot with fewer buttons or sticks holds none of the last one's data.length = buttonsLast.length = gamepad.buttons.length; sticks.length = gamepad.axes.length >> 1; // set new primary gamepad if current is not connected if (hadInput) { gamepadHadInput[i] = true; if (!gamepadHadInput[gamepadPrimary]) gamepadPrimary = i; } if (gamepad.mapping === 'standard') { // get dpad buttons (standard mapping) dpad.set( (gamepadIsDown(15,i)&&1) - (gamepadIsDown(14,i)&&1), (gamepadIsDown(12,i)&&1) - (gamepadIsDown(13,i)&&1)); } // copy dpad to left analog stick when pressed; a gamepad with no sticks has that one, at rest when not if (gamepadDirectionEmulateStick && (dpad.x || dpad.y)) sticks[0] = dpad.clampLength(); else if (gamepadDirectionEmulateStick && !sticks.length) sticks[0] = vec2(); } // disable touch gamepad if using real gamepad touchGamepadEnable && isUsingGamepad && touchGamepadTimer.unset(); } } function inputUpdatePost() { if (headlessMode) return; // clear input to prepare for next frame for (const deviceInputData of inputData) for (const i in deviceInputData) deviceInputData[i] &= 1; mouseWheel = touchPinch = 0; mouseDelta = vec2(); mouseDeltaScreen = vec2(); } function inputRender() { touchGamepadRender(); } /////////////////////////////////////////////////////////////////////////////// // Touch gamepad - full-viewport HTML/SVG overlay driven by Pointer Events const touchGamepadSvgNS = 'http://www.w3.org/2000/svg'; // build the overlay DOM once; no-op if already built, disabled, headless, or non-touch function touchGamepadInit() { if (touchGamepadOverlay || !touchGamepadEnable || !isTouchDevice || headlessMode || !document.body) // body may not exist yet; retry on a later frame return; // full-viewport overlay; only the input zones receive pointer events. The // env() padding insets the stage out of notches / home indicators natively. const overlay = touchGamepadOverlay = document.createElement('div'); overlay.style.cssText = 'position:fixed;inset:0;z-index:50;pointer-events:none;opacity:0;' + 'touch-action:none;user-select:none;-webkit-user-select:none;' + '-webkit-touch-callout:none;transition:opacity .2s;box-sizing:border-box;' + 'padding:env(safe-area-inset-top) env(safe-area-inset-right) ' + 'env(safe-area-inset-bottom) env(safe-area-inset-left)'; // stage fills the padded (safe-area) content box; all controls live inside it const stage = touchGamepadStage = document.createElement('div'); stage.style.cssText = 'position:relative;width:100%;height:100%;pointer-events:none'; overlay.appendChild(stage); // svg draws every visual and never blocks input const svg = touchGamepadSvg = document.createElementNS(touchGamepadSvgNS, 'svg'); svg.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;' + 'pointer-events:none;overflow:visible;fill:none;stroke:#fff;stroke-width:3'; stage.appendChild(svg); // invisible input zones (left stick, right buttons/stick, center start) const makeZone = ()=> { const z = document.createElement('div'); z.style.cssText = 'position:absolute;pointer-events:auto;touch-action:none'; z.addEventListener('pointerdown', e=> touchGamepadPointerDown(e, z)); z.addEventListener('pointermove', e=> touchGamepadPointerMove(e)); z.addEventListener('pointerup', e=> touchGamepadPointerUp(e)); z.addEventListener('pointercancel', e=> touchGamepadPointerUp(e)); stage.appendChild(z); return z; }; touchGamepadSideZones[0] = makeZone(); // left touchGamepadSideZones[1] = makeZone(); // right touchGamepadZoneC = makeZone(); // center/start, appended last so it sits above the sides addEventListener('resize', ()=> touchGamepadNeedRelayout = true); document.body.appendChild(overlay); touchGamepadNeedRelayout = true; } // stage-local size in CSS pixels (excludes safe-area insets) function touchGamepadStageRect() { return touchGamepadStage.getBoundingClientRect(); } // per-side touch gamepad config (side 0 = left, 1 = right) - the left and right // sides behave identically, differing only in position and gamepad button indices function touchGamepadSideStick(side) { return side ? touchGamepadRightStick : touchGamepadLeftStick; } function touchGamepadSideButtonCount(side) { return side ? touchGamepadButtonCount : touchGamepadLeftButtonCount; } // gamepad button index a side's buttons start at (right 0-3, left 4-7) function touchGamepadSideButtonBase(side) { return side ? 0 : 4; } // output stick index for a side: the right stick uses stick 0 when there is no left stick function touchGamepadStickOut(side) { return side && touchGamepadLeftStick ? 1 : 0; } // the button a stick's touch presses, 10 for stick 0 and 11 for stick 1, following the output index function touchGamepadStickButton(side) { return touchGamepadStickOut(side) ? 11 : 10; } // a face button's place around the cluster to its gamepad index, 2 and 3 swap to match the gamepad layout function touchGamepadFaceIndex(b) { return b === 3 ? 2 : b === 2 ? 3 : b; } // true if the side has any control (a stick or at least one button) function touchGamepadSideHasControl(side) { return touchGamepadSideStick(side) || touchGamepadSideButtonCount(side) > 0; } // true if gamepad button index i is an active touch gamepad face/single button function touchGamepadIsFaceButton(i) { for (let side = 0; side < 2; side++) { const base = touchGamepadSideButtonBase(side); if (!touchGamepadSideStick(side) && i >= base && i < base + touchGamepadSideButtonCount(side)) return true; } return false; } // center of a side's controls in stage-local CSS pixels (stick rest / button cluster) // returns the floating stick anchor when that side is an active floating stick function touchGamepadSideCenter(side, W, H) { if (touchGamepadFloating && touchGamepadSideStick(side) && touchGamepadStickAnchors[side]) return touchGamepadStickAnchors[side]; let y = H - touchGamepadSize; const count = touchGamepadSideButtonCount(side); if (!touchGamepadSideStick(side) && (count === 2 || count === 3)) y -= touchGamepadSize/4; // nudge a 2/3 button cluster up a bit return vec2(side ? W - touchGamepadSize : touchGamepadSize, y); } // position the input zones for the current mode and rebuild the SVG visuals function touchGamepadRelayout() { if (!touchGamepadOverlay) return; const r = touchGamepadStageRect(); const W = r.width, H = r.height, S = touchGamepadSize; const setZone = (z, css)=> z.style.cssText = 'position:absolute;pointer-events:auto;touch-action:none;' + css; if (paused) { // the gamepad is hidden while paused, so its side zones must not capture // touches - otherwise they silently steal taps from menus and dialogs for (const zone of touchGamepadSideZones) zone.style.display = 'none'; if (touchGamepadCenterButtonSize) { // any touch presses start setZone(touchGamepadZoneC, 'inset:0'); touchGamepadZoneC.style.display = ''; } else touchGamepadZoneC.style.display = 'none'; } else { // position each side zone (left/right differ only by which edge they hug) for (let side = 0; side < 2; side++) { const zone = touchGamepadSideZones[side], edge = side ? 'right' : 'left'; zone.style.display = touchGamepadSideHasControl(side) ? '' : 'none'; if (touchGamepadFloating && touchGamepadSideStick(side)) { // bottom 60% grabs the stick; the top 40% passes through. A side with no // control on the other side uses the full width (matching the hit-test) const width = touchGamepadSideHasControl(side ? 0 : 1) ? '50%' : '100%'; setZone(zone, `${edge}:0;bottom:0;width:${width};height:60%`); } else // fixed, and face buttons which never float: a compact box hugging the corner control setZone(zone, `${edge}:0;bottom:0;width:${3*S}px;height:${3*S}px`); } touchGamepadZoneC.style.display = touchGamepadCenterButtonSize ? '' : 'none'; const c = touchGamepadCenterButtonSize; setZone(touchGamepadZoneC, `left:50%;top:50%;width:${2*c}px;height:${2*c}px;transform:translate(-50%,-50%)`); } touchGamepadBuildSvg(W, H); touchGamepadNeedRelayout = false; } // (re)build the SVG shapes for the current layout; dynamic bits update per-frame function touchGamepadBuildSvg(W, H) { const svg = touchGamepadSvg; while (svg.firstChild) svg.removeChild(svg.firstChild); const els = touchGamepadSvgEls = { face: [], thumb: [] }; const S = touchGamepadSize; const circle = (cx, cy, rr, fill)=> { const c = document.createElementNS(touchGamepadSvgNS, 'circle'); c.setAttribute('cx', cx); c.setAttribute('cy', cy); c.setAttribute('r', rr); if (fill) c.setAttribute('fill', fill); svg.appendChild(c); return c; }; const cross = (ctr)=> { // plus-shaped dpad outline centered at ctr const a = S*.18, b = S*.5, x = ctr.x, y = ctr.y; const p = document.createElementNS(touchGamepadSvgNS, 'path'); p.setAttribute('d', `M ${x-a} ${y-b} H ${x+a} V ${y-a} H ${x+b} V ${y+a} H ${x+a} ` + `V ${y+b} H ${x-a} V ${y+a} H ${x-b} V ${y-a} H ${x-a} Z`); svg.appendChild(p); }; // draw each side: a directional stick, a single large button, or face buttons for (let side = 0; side < 2; side++) { const count = touchGamepadSideButtonCount(side); const base = touchGamepadSideButtonBase(side); const ctr = touchGamepadSideCenter(side, W, H); if (touchGamepadSideStick(side)) { // directional stick (circle or cross) with a thumb dot that moves per-frame if (touchGamepadAnalog) circle(ctr.x, ctr.y, S/2); else cross(ctr); els.thumb[side] = circle(ctr.x, ctr.y, S/4, '#fff'); } else if (count === 1) els.face[base] = circle(ctr.x, ctr.y, S/2, '#000'); // single large button else for (let i = 0; i < count; i++) { const j = mod(i-1, 4); let button = count > 2 ? j : min(j, count-1); button = touchGamepadFaceIndex(button); const offset = vec2().setDirection(j, S/2); if (count === 2) offset.x *= -1; // left side mirrors the right layout's positions, keeping indices in order // (e.g. 2 buttons -> button 4 at bottom, button 5 at left) if (!side) offset.x *= -1; const pos = ctr.add(offset); els.face[base + button] = circle(pos.x, pos.y, S/4, '#000'); } } // debug: draw the proximity hit regions the hit-test actually uses if (debug && debugGamepads) touchGamepadBuildDebug(W, H); } // draw debug outlines of the touch control hit regions into the overlay svg function touchGamepadBuildDebug(W, H) { const S = touchGamepadSize, svg = touchGamepadSvg; const shape = (tag, attrs, stroke)=> { const el = document.createElementNS(touchGamepadSvgNS, tag); for (const k in attrs) el.setAttribute(k, attrs[k]); el.setAttribute('stroke', stroke); el.setAttribute('stroke-width', 2); el.setAttribute('fill', 'none'); svg.appendChild(el); }; const ring = (c, rr, stroke)=> shape('circle', {cx:c.x, cy:c.y, r:rr}, stroke); // green line: the left/right split that assigns a stick press to a side shape('line', {x1:W/2, y1:0, x2:W/2, y2:H}, '#0f0'); // cyan: where each side's control can be grabbed for (let side = 0; side < 2; side++) { if (touchGamepadSideStick(side)) { if (touchGamepadFloating) { // grab region: this side's half (or the full width if the other side is empty) const top = H*.4, full = !touchGamepadSideHasControl(side ? 0 : 1); const x = full ? 0 : (side ? W/2 : 0); shape('rect', {x, y:top, width:full ? W : W/2, height:H-top}, '#0ff'); } else ring(touchGamepadSideCenter(side, W, H), 2*S, '#0ff'); } else if (touchGamepadSideButtonCount(side) >= 1) ring(touchGamepadSideCenter(side, W, H), S, '#0ff'); // face / single-button radius } // yellow: start button radius; magenta: where start is blocked (near a control) if (touchGamepadCenterButtonSize) { ring(vec2(W/2, H/2), touchGamepadCenterButtonSize, '#ff0'); for (let side = 0; side < 2; side++) if (touchGamepadStartBlockSide(side)) ring(touchGamepadSideCenter(side, W, H), 2*S, '#f0f'); } } // per-frame: fade the overlay and move the thumbs / set pressed states function touchGamepadRender() { if (!touchGamepadOverlay || headlessMode) return; // hide and bail if disabled at runtime (overlay stays in the DOM for reuse) // display:none also takes the input zones out of hit-testing so touches are // not silently captured away from the game while disabled if (!touchGamepadEnable || !isTouchDevice) { if (touchGamepadOverlay.style.display !== 'none') { // just disabled: hide the overlay and release any held controls, and the gamepad slot they // fed, which nothing else rewrites while real gamepads are off touchGamepadOverlay.style.display = 'none'; touchGamepadPointerRole.clear(); touchGamepadButtons.length = 0; touchGamepadButtonsPressed.length = 0; touchGamepadSticks.length = 0; touchGamepadStickPointerId.length = 0; inputData[1] = undefined; gamepadStickData[0] = gamepadDpadData[0] = undefined; } return; } touchGamepadOverlay.style.display = ''; // relayout when the paused state, a layout setting, or the debug view changes const dbg = debug && debugGamepads; const layout = [touchGamepadButtonCount, touchGamepadLeftButtonCount, touchGamepadLeftStick, touchGamepadRightStick, touchGamepadAnalog, touchGamepadSize, touchGamepadFloating, touchGamepadCenterButtonSize, paused, dbg].join(); if (layout !== touchGamepadLastLayout) { touchGamepadLastLayout = layout; touchGamepadNeedRelayout = true; } // relayout before the visibility bail-out so the paused full-screen start zone applies if (touchGamepadNeedRelayout) touchGamepadRelayout(); // fade out when idle (always show when displayTime is 0, or while debugging), a control held is in use, // its release sets the timer the fade counts from const fade = touchGamepadDisplayTime && !touchGamepadPointerRole.size ? percent(touchGamepadTimer.get(), touchGamepadDisplayTime+1, touchGamepadDisplayTime) : 1; const visible = dbg || (touchGamepadTimer.isSet() && fade > 0 && !paused); touchGamepadOverlay.style.opacity = !visible ? 0 : dbg ? 1 : fade*touchGamepadAlpha; if (!visible) return; const r = touchGamepadStageRect(); const W = r.width, H = r.height, S = touchGamepadSize; const els = touchGamepadSvgEls; if (!els) return; for (let side = 0; side < 2; side++) if (touchGamepadSideStick(side) && els.thumb[side]) { const ctr = touchGamepadSideCenter(side, W, H); const t = ctr.add((touchGamepadSticks[side] ?? vec2()).scale(S/2)); els.thumb[side].setAttribute('cx', t.x); els.thumb[side].setAttribute('cy', t.y); } for (let i = 0; i < els.face.length; i++) if (els.face[i]) els.face[i].setAttribute('fill', touchGamepadButtons[i] ? '#fff' : '#000'); } // convert a pointer event to stage-local CSS pixels function touchGamepadEventPos(e) { const r = touchGamepadStageRect(); return vec2(e.clientX - r.left, e.clientY - r.top); } // set a directional stick from a stage-local point and flag its stick-touch button // (stick 0 press = button 10, stick 1 press = button 11, following the output index) function touchGamepadApplyStick(side, p) { const delta = p.subtract(touchGamepadStickAnchors[side]); touchGamepadSticks[side] = delta.scale(2/touchGamepadSize).clampLength(); touchGamepadButtons[touchGamepadStickButton(side)] = 1; } // pick a side's gamepad button index from a stage-local point, or -1 if outside the cluster function touchGamepadFaceButtonAt(side, p, W, H) { const count = touchGamepadSideButtonCount(side); const base = touchGamepadSideButtonBase(side); const bc = touchGamepadSideCenter(side, W, H); if (bc.distance(p) >= touchGamepadSize) return -1; if (count === 1) return base; // single large button const d = bc.subtract(p); if (!side) d.x *= -1; // left side mirrors the right layout's positions horizontally let button = count === 2 ? (d.x < d.y ? 1 : 0) : mod(d.direction()+2, 4); button = touchGamepadFaceIndex(button); return button < count ? base + button : -1; } // pick which control a stage-local press activates, by priority then proximity, // independent of which zone element captured it - so overlapping zones on small // screens resolve to the nearest control instead of whichever zone is topmost // returns {role:'stick', side} or {role:'face', btn} or {role:'start'} or undefined function touchGamepadControlAt(p, W, H) { const S = touchGamepadSize; const leftHalf = p.x < W/2; const floatTop = H*.4; // floating grab region is the bottom 60% of the screen // center start button first, a floating stick's region always covers it; blocked within 2*size of a fixed // control so drift off a control can't accidentally fire start (matches the original exclusion logic) if (touchGamepadCenterButtonSize && vec2(W/2, H/2).distance(p) < touchGamepadCenterButtonSize && !touchGamepadStartBlockedAt(p, W, H)) return {role:'start'}; // check each side (left first for priority); a side is a stick or buttons for (let side = 0; side < 2; side++) { const onHalf = side ? !leftHalf : leftHalf; if (touchGamepadSideStick(side)) { // a side with no control on the other side uses the full width const otherControl = touchGamepadSideHasControl(side ? 0 : 1); const grab = touchGamepadFloating ? (!otherControl || onHalf) && p.y > floatTop : onHalf && touchGamepadSideCenter(side, W, H).distance(p) < 2*S; if (grab) return {role:'stick', side}; } else if (touchGamepadSideButtonCount(side) >= 1) { const btn = touchGamepadFaceButtonAt(side, p, W, H); if (btn >= 0) return {role:'face', btn}; } } } // true if a press is too near a fixed control for the start button, a floating stick has no fixed place function touchGamepadStartBlockedAt(p, W, H) { for (let side = 0; side < 2; side++) if (touchGamepadStartBlockSide(side) && touchGamepadSideCenter(side, W, H).distance(p) < 2*touchGamepadSize) return true; return false; } function touchGamepadStartBlockSide(side) { return touchGamepadSideHasControl(side) && !(touchGamepadFloating && touchGamepadSideStick(side)); } function touchGamepadPointerDown(e, zone) { // a mouse on a touchscreen laptop clicks the game, the zones are there even while the gamepad is hidden if (!touchGamepadEnable || e.pointerType === 'mouse') return; e.preventDefault(); zone.setPointerCapture(e.pointerId); touchGamepadTimer.set(); // resume audio on first interaction if (soundEnable && !headlessMode && audioContext && !audioIsRunning()) audioResume(); // while paused, any touch is the start button; a control belongs to the first finger on it until that // finger lifts, so a second finger landing on the same one neither takes it over nor lets it go if (paused) { if (touchGamepadCenterButtonSize && !touchGamepadButtons[9]) { touchGamepadButtons[9] = touchGamepadButtonsPressed[9] = 1; touchGamepadPointerRole.set(e.pointerId, 'start'); } return; } const r = touchGamepadStageRect(); const W = r.width, H = r.height; const p = vec2(e.clientX - r.left, e.clientY - r.top); // choose the control by proximity/priority, not by which zone captured the touch const hit = touchGamepadControlAt(p, W, H); if (!hit) return; if (hit.role === 'stick') { const side = hit.side; if (touchGamepadStickPointerId[side] !== undefined) return; // another finger has the stick touchGamepadStickAnchors[side] = touchGamepadFloating ? p : touchGamepadSideCenter(side, W, H); touchGamepadStickPointerId[side] = e.pointerId; touchGamepadPointerRole.set(e.pointerId, 'stick'+side); touchGamepadNeedRelayout = true; // base may have re-anchored touchGamepadApplyStick(side, p); touchGamepadButtonsPressed[touchGamepadStickButton(side)] = 1; // the stick's own press, latched } else if (hit.role === 'face') { if (touchGamepadButtons[hit.btn]) return; // another finger holds the button touchGamepadButtons[hit.btn] = touchGamepadButtonsPressed[hit.btn] = 1; touchGamepadPointerRole.set(e.pointerId, 'face'+hit.btn); } else // 'start' { if (touchGamepadButtons[9]) return; touchGamepadButtons[9] = touchGamepadButtonsPressed[9] = 1; touchGamepadPointerRole.set(e.pointerId, 'start'); } } function touchGamepadPointerMove(e) { const role = touchGamepadPointerRole.get(e.pointerId); if (!role) return; e.preventDefault(); const p = touchGamepadEventPos(e); if (role === 'stick0' || role === 'stick1') touchGamepadApplyStick(role === 'stick1' ? 1 : 0, p); // face buttons & start are held until release (no slide-between this pass) } function touchGamepadPointerUp(e) { const role = touchGamepadPointerRole.get(e.pointerId); if (!role) return; touchGamepadPointerRole.delete(e.pointerId); if (role === 'stick0' || role === 'stick1') { const side = role === 'stick1' ? 1 : 0; touchGamepadStickPointerId[side] = undefined; touchGamepadSticks[side] = vec2(); delete touchGamepadButtons[touchGamepadStickButton(side)]; } else if (role === 'start') delete touchGamepadButtons[9]; else // 'face' delete touchGamepadButtons[+role.slice(4)]; touchGamepadTimer.set(); } /** * LittleJS Audio System * - Play audio files (mp3, ogg, wave) and generate sounds with ZzFX * - ZzFX sound generator integration: ZzFX * - Sound caching for fast playback and memory efficiency * - Volume control with attenuation and stereo panning * - 2D spatial audio based on camera position with distance-based falloff * - Sound instance management (pause, resume, stop) * - Speech synthesis for text-to-speech * - Music playback from audio files, looping by default * - Web Audio API integration with master gain control * - Sounds and the master bus can route through effects, see the audio effects plugin * @namespace Audio */ /** Audio context used by the engine, undefined outside a browser, where the engine runs headless * @type {AudioContext} * @memberof Audio */ let audioContext = typeof AudioContext == 'undefined' ? undefined : new AudioContext; // resume the audio, a refusal caught so it is not an uncaught error in the console, the next gesture tries again function audioResume() { audioContext?.resume()?.catch?.(()=> {}); } /** Master gain node for all audio to pass through, made at load so effects can connect to it any time * @type {GainNode} * @memberof Audio */ let audioMasterGain = audioContext?.createGain(); let audioMasterVolume; // the soundVolume the master gain was last set to if (audioMasterGain) { audioMasterGain.connect(audioContext.destination); audioMasterGain.gain.value = audioMasterVolume = soundVolume; // set starting value audioContext.addEventListener?.('statechange', audioStateChange); } // soundVolume can be set directly, so the master gain follows it each frame function audioUpdateVolume() { // a volume that is not a number, from a slider's text say, is not handed on, where every frame it would throw if (audioMasterGain && soundVolume !== audioMasterVolume && isFinite(soundVolume)) audioMasterGain.gain.value = audioMasterVolume = soundVolume; } // the current master effect, kept so setAudioMasterEffect can undo the route it made, // and whether its output came from an effect, which gets its default route back let audioMasterEffectInput, audioMasterEffectOutput, audioMasterEffectOutputIsEffect; /** Default sample rate used for sounds * @default 44100 * @memberof Audio */ const audioDefaultSampleRate = 44100; /** Check if the audio context is running and available for playback * @return {boolean} - True if the audio context is running, false when there is none * @memberof Audio */ function audioIsRunning() { return audioContext?.state === 'running'; } function audioInit() { if (!soundEnable || headlessMode) return; audioSetSession(); document.addEventListener('visibilitychange', audioVisibilityChange); } // on an iPhone the silent switch mutes the page's sound, unless soundIgnoreSilentSwitch has it play as media, // Safari 16.4 and up; elsewhere there is no audioSession and nothing changes function audioSetSession() { const session = typeof navigator != 'undefined' && /** @type {any} */ (navigator).audioSession; if (session) session.type = soundIgnoreSilentSwitch ? 'playback' : 'auto'; } // a hidden page stops the game, so its sound stops too, and the audio clock with it so every sound picks up // exactly where it was; only a suspend made here is undone, not one the browser holds until the first input let audioSuspendedWhenHidden = false; function audioVisibilityChange() { if (!audioContext) return; // a browser with no audio has none to pause if (document.hidden) { if (!soundPauseWhenHidden || audioContext.state != 'running') return; audioSuspendedWhenHidden = true; audioContext.suspend(); } else if (audioSuspendedWhenHidden) { audioSuspendedWhenHidden = false; audioResume(); } } // sound instances whose start failed only because the context was not running, like music started in gameInit // before the first input, each with the time it tried; they start once the context runs unless paused or stopped // first, and a one shot drops out once it would have ended anyway, so a backlog of sounds can't all play at once const audioWaitingInstances = new Map; function audioWaitingPrune(now=performance.now()) { for (const [instance, startTime] of audioWaitingInstances) { const remaining = (instance.getDuration() - instance.pausedTime) / instance.rate; if (!instance.loop && now - startTime > remaining * 1e3) audioWaitingInstances.delete(instance); } } function audioStateChange() { if (!audioIsRunning()) return; audioWaitingPrune(); const instances = [...audioWaitingInstances.keys()]; audioWaitingInstances.clear(); for (const instance of instances) instance.resume(); } /** Anything with input and output audio nodes, like an effect from the audio effects plugin * @typedef {{input: AudioNode, output: AudioNode}} AudioEffectNodes * @memberof Audio */ /** Route all sound through an effect between the master gain and the speakers * - Pass a node or an effect, or the first and last of a chain, each a node or an effect * - With one argument a node is both ends, and an effect uses its own input and output * - The output node is disconnected from everything else first, so it only feeds the speakers * - The two ends of a chain must already be connected to each other, like effectA.connect(effectB) * - Call with no arguments to remove the effect, an effect that was the master goes back to feeding the master gain * @param {AudioNode|AudioEffectNodes} [input] - Node or effect the master gain connects to * @param {AudioNode|AudioEffectNodes} [output] - Node or effect that connects to the audio destination, defaults to the input's output * @memberof Audio */ function setAudioMasterEffect(input, output) { // an effect stands in for its nodes, and a node is both ends when no output is passed // (the output resolves first since its default comes from the input effect) const outputArg = output || input; const outputIsEffect = !!outputArg && 'input' in outputArg; output = audioEffectNode(output, 'output') || audioEffectNode(input, 'output'); input = audioEffectNode(input, 'input'); ASSERT(!input || typeof input.connect === 'function', 'input must be an AudioNode or an effect with input and output nodes'); ASSERT(!output || typeof output.connect === 'function', 'output must be an AudioNode or an effect with input and output nodes'); if (!audioMasterGain) return; // no audio outside a browser, where the engine runs headless // undo the current route selectively so other taps survive; an effect's output that still feeds the speakers // goes back to the master gain, its default, so it still works for sounds, but one that connect() already moved // on, like the head of a longer chain being set now, stays where it was sent audioMasterGain.disconnect(audioMasterEffectInput || audioContext.destination); if (audioMasterEffectOutput) { let fedSpeakers = true; try { audioMasterEffectOutput.disconnect(audioContext.destination); } catch { fedSpeakers = false; } if (audioMasterEffectOutputIsEffect && fedSpeakers) audioMasterEffectOutput.connect(audioMasterGain); } audioMasterEffectInput = input; audioMasterEffectOutput = output; audioMasterEffectOutputIsEffect = outputIsEffect; // connect the master gain to the speakers, through the effect if there is one if (input) { audioMasterGain.connect(input); output.disconnect(); output.connect(audioContext.destination); } else audioMasterGain.connect(audioContext.destination); } // get one of an effect's nodes, or the thing itself when it is already a node /** @param {AudioNode|AudioEffectNodes|undefined} effectOrNode * @param {'input'|'output'} key * @return {AudioNode} * @ignore */ function audioEffectNode(effectOrNode, key) { if (effectOrNode && 'input' in effectOrNode) return /** @type {AudioEffectNodes} */ (effectOrNode)[key]; return /** @type {AudioNode} */ (effectOrNode); } // a value for an audio param, which throws on one that is not finite, so a release build, with no asserts to stop it, // takes NaN or Infinity as the value given in its place: silent, centered or the normal rate const audioFinite = (value, otherwise)=> isFinite(value) ? value : otherwise; // ramp an audio param to a value, cancelling anything already scheduled so stacked calls don't fight; // returns when the ramp ends function audioParamRamp(param, value, fadeTime=0) { ASSERT(fadeTime >= 0, 'fadeTime must be positive or zero'); value = audioFinite(value, 0), fadeTime = audioFinite(fadeTime, 0); const startTime = audioContext.currentTime; param.cancelScheduledValues(startTime); if (fadeTime) { param.setValueAtTime(param.value, startTime); param.linearRampToValueAtTime(value, startTime + fadeTime); } else param.value = value; return startTime + fadeTime; } /////////////////////////////////////////////////////////////////////////////// /** * @callback SoundLoadCallback - Function called once a sound has loaded, or failed to, which sound.isLoaded() tells * @param {Sound} sound * @memberof Audio */ /** * Sound Object - Stores a sound for later * - this can be used to load and play wave, mp3, and ogg files * - it can also create sounds using the ZzFX sound generator * - can attenuate and apply stereo panning to sounds * - sound instance control with pause/resume capability * * Create sounds using the ZzFX Sound Designer. * @memberof Audio * @example * // load an audio asset file * const music = new Sound('sound.mp3'); * * // create a zzfx sound * const blip = new Sound([.5,.5]); * * // play a sound * blip.play(); */ class Sound { /** Create a sound object and cache the audio for later use * @param {string|URL|Array} [asset] - Filename or URL of an audio file, or a zzfx array * @param {number} [randomness] - How much to randomize frequency each time sound plays, for zzfx sounds it overrides the array's own randomness, which is used if undefined * @param {number} [range=soundDefaultRange] - World space max range of sound * @param {number} [taper=soundDefaultTaper] - At what percentage of range should it start tapering * @param {SoundLoadCallback} [onloadCallback] - Called once the sound has loaded, or its file failed to, so a * game counting its sounds goes on; isLoaded says which */ constructor(asset, randomness, range, taper=soundDefaultTaper, onloadCallback) { if (!soundEnable || headlessMode || !audioContext) // a browser with no audio makes none either { // no sound is made: it counts as loaded, so a game that waits for its sounds goes on this.loadedPercent = 1; onloadCallback?.(this); return; } const rangeIsDefault = range === undefined; if (rangeIsDefault) range = soundDefaultRange; ASSERT(!asset || isArray(asset) || isStringLike(asset), 'asset must be a file name or zzfx array'); ASSERT(randomness === undefined || isNumber(randomness), 'randomness must be a number'); ASSERT(randomness === undefined || randomness >= 0 && randomness <=1, 'randomness must be between 0 and 1'); ASSERT(isNumber(range), 'range must be a number'); ASSERT(isNumber(taper), 'taper must be a number'); this.range = range; /** @property {boolean} - No range was given, so it got soundDefaultRange; played in 3D such a sound is heard * to render3D.soundDefaultRange instead, which is further; setting range makes it false */ this.rangeIsDefault = rangeIsDefault; /** @property {number} - At what percentage of range should it start tapering */ this.taper = taper; /** @property {number} - How much to randomize frequency each time sound plays * @type {number} */ this.randomness = randomness ?? 0; /** @property {number} - Sample rate for this sound */ this.sampleRate = audioDefaultSampleRate; /** @property {number} - How many samples per channel this sound has */ this.sampleLength = 0; /** @property {AudioBuffer|undefined} - Decoded audio shared by every play of this sound * @type {AudioBuffer|undefined} */ this.sampleBuffer = undefined; // internal, the 3D plugin reads it to know the sound has loaded /** @ignore * @type {Array|Float32Array>|undefined} */ this._sampleChannels = undefined; /** @property {number} - Percentage of this sound currently loaded, sounds * fetched from a url stay at 0 until decoding completes */ this.loadedPercent = 0; /** @property {SoundLoadCallback|undefined} - function to call when sound is loaded * @type {SoundLoadCallback|undefined} */ this.onloadCallback = onloadCallback; /** @property {AudioNode|AudioEffectNodes|undefined} - Node or effect to route every play of this sound through instead of the master gain * - Where this sound's audio goes, unlike AudioEffect.output which is an effect's own node, effects chain with connect() * @type {AudioNode|AudioEffectNodes|undefined} */ this.output = undefined; if (isArray(asset)) { // generate zzfx sound — copy so we don't mutate the caller's array const zzfxSound = asset.slice(); // remove randomness so it can be applied on playback, a value passed in wins over the array's const randomnessIndex = 1; this.randomness = randomness ?? clamp(zzfxSound[randomnessIndex] ?? .05); // 0 to 1, as one passed in zzfxSound[randomnessIndex] = 0; // generate the zzfx samples, then hand them to an audio buffer so // the plain arrays can be released and every play shares the buffer this.sampleChannels = [zzfxG(...zzfxSound)]; this.buildSampleBuffer(); this.loadedPercent = 1; onloadCallback?.(this); } else if (asset) { // load the audio file, a URL object as bundlers give works like its string; // report failures rather than leaving an unhandled rejection, the sound just stays unloaded and silent const filename = asset + ''; engineAddLoad(this.loadSound(filename).catch(e=> // startup waits for it, and a release build says why too { console.warn('Sound load failed for', filename, '-', e?.message ?? e); // failed, not loaded and its callback threw; one that throws here says so, a load is no place to stop try { this.loadedPercent === 1 || this.onloadCallback?.(this); } catch (callbackError) { console.warn('Sound onloadCallback failed for', filename, '-', callbackError); } })); } } /** World space max range of sound, 0 for no limit; a range that is set is the sound's own, in 3D too * @type {number} */ get range() { return this.rangeValue; } set range(range) { this.rangeValue = range; this.rangeIsDefault = false; } /** Sample data for each channel * Sounds keep their samples in an audio buffer, so reading this rebuilds * the arrays from it and caches them. The copies are safe to hold onto, * playing a sound detaches the buffer's own channel arrays. * @return {Array|Float32Array>|undefined} */ get sampleChannels() { const buffer = this.sampleBuffer; if (!this._sampleChannels && buffer) { const channels = []; for (let i = 0; i < buffer.numberOfChannels; i++) channels.push(buffer.getChannelData(i).slice()); this._sampleChannels = channels; } return this._sampleChannels; } /** @param {Array|Float32Array>} sampleChannels */ set sampleChannels(sampleChannels) { // new samples invalidate the buffer built from the old ones this._sampleChannels = sampleChannels; this.sampleBuffer = undefined; this.sampleLength = sampleChannels?.[0]?.length || 0; } /** Move this sound's samples into an audio buffer that every play can share * Does nothing if there is already a buffer or no samples to build one from */ buildSampleBuffer() { if (this.sampleBuffer || !this._sampleChannels || headlessMode) return; this.sampleBuffer = createAudioBuffer(this._sampleChannels, this.sampleRate); // the buffer owns the samples now, release the arrays we built it from this._sampleChannels = undefined; } /** Play the sound * - Browsers hold audio until the first user input, a sound played before it returns a paused instance * that starts on its own once audio runs, unless paused or stopped first; a one shot that would have * ended by then is dropped, and only the newest play of each sound waits, so a sound played every frame * starts once * @param {Vector2} [pos] - World space position to play the sound if any * @param {number} [volume] - How much to scale volume by * @param {number} [pitch] - How much to scale pitch by * @param {number} [randomnessScale] - How much to scale pitch randomness * @param {boolean} [loop] - Should the sound loop? * @param {boolean} [paused] - Should the sound start paused * @return {SoundInstance|undefined} - The sound instance, or undefined if sound is disabled, not loaded, out of range, or running in headless mode */ play(pos, volume=1, pitch=1, randomnessScale=1, loop=false, paused=false) { ASSERT(!pos || isVector2(pos), 'pos must be a vec2'); ASSERT(isNumber(volume), 'volume must be a number'); ASSERT(isNumber(pitch), 'pitch must be a number'); ASSERT(isNumber(randomnessScale), 'randomnessScale must be a number'); if (!soundEnable || headlessMode) return; if (!this.sampleBuffer && !this._sampleChannels) return; let pan; if (pos) { const range = this.range; if (range) { // apply range based fade const lengthSquared = cameraPos.distanceSquared(pos); if (lengthSquared >= range*range) return; // out of range, at it too, where the fade is silent // attenuate volume by distance, full volume out to the taper and a fade past it, // so a taper of 1 plays at full volume right up to the range const distance = lengthSquared**.5, taperRange = range*this.taper; if (distance > taperRange) volume *= percent(distance, range, taperRange); } // get pan from screen space coords pan = worldToScreen(pos).x * 2/mainCanvasSize.x - 1; } // Create sound instance const rate = pitch + pitch * this.randomness*randomnessScale*rand(-1,1); const instance = new SoundInstance(this, volume, rate, pan, loop, paused); if (debug && debugSound && pos) { // visualize where positioned sounds play and their falloff range debugCircle(pos, .5, '#0ff', .5, true, false); if (this.range) { debugCircle(pos, 2*this.range, '#0ff', .5, false, false); // silent radius debugCircle(pos, 2*this.range*this.taper, '#0ff', .5, false, false); // full volume radius } debugText('vol '+volume.toFixed(2)+' pitch '+rate.toFixed(2), pos, .5, '#0ff', .5, 0, 'monospace', false); } return instance; } /** Play the sound on a loop, the same as play with loop on; stop or change it through the SoundInstance returned * @param {Vector2} [pos] - World space position to play the sound if any * @param {number} [volume] - How much to scale volume by * @param {number} [pitch] - How much to scale pitch by * @param {number} [randomnessScale] - How much to scale pitch randomness * @param {boolean} [paused] - Should the sound start paused * @return {SoundInstance|undefined} - The sound instance, or undefined if sound is disabled, not loaded, out of range, or running in headless mode */ playLoop(pos, volume=1, pitch=1, randomnessScale=1, paused=false) { return this.play(pos, volume, pitch, randomnessScale, true, paused); } /** Play a music track that loops by default * @param {number} [volume] - Volume to play the music at * @param {boolean} [loop] - Should the music loop? * @param {boolean} [paused] - Should the music start paused * @return {SoundInstance|undefined} - The sound instance, or undefined if sound is disabled, not loaded, or running in headless mode */ playMusic(volume=1, loop=true, paused=false) { return this.play(undefined, volume, 1, 0, loop, paused); } /** Play the sound as a musical note with a semitone offset * This can be used to play music with chromatic scales * @param {number} [semitoneOffset] - How many semitones to offset pitch * @param {Vector2} [pos] - World space position to play the sound if any * @param {number} [volume=1] - How much to scale volume by * @return {SoundInstance|undefined} - The sound instance, or undefined if sound is disabled, not loaded, out of range, or running in headless mode */ playNote(semitoneOffset=0, pos, volume) { ASSERT(isNumber(semitoneOffset), 'semitoneOffset must be a number'); const pitch = getNoteFrequency(semitoneOffset, 1); return this.play(pos, volume, pitch, 0); } /** Get how long this sound is in seconds * @return {number} - How long the sound is in seconds (0 if loading) */ getDuration() { return this.sampleLength / this.sampleRate || 0; } /** Check if sound is loaded, for sounds fetched from a url * @return {boolean} - True if sound is loaded and ready to play */ isLoaded() { return this.loadedPercent === 1; } /** Loads a sound from a URL and decodes it into sample data * @param {string} filename * @return {Promise} */ async loadSound(filename) { const response = await loadFetch(filename, 'loadSound'); // named, with the hint for a page opened from disk if (!response.ok) throw new Error(`loadSound: could not load ${filename}, HTTP ${response.status} ${response.statusText}`); const arrayBuffer = await response.arrayBuffer(); const audioBuffer = await audioContext.decodeAudioData(arrayBuffer); // keep the decoded buffer as is, it is exactly what playback needs and // every play shares it, no channel data is read or copied this.sampleRate = audioBuffer.sampleRate; this.sampleLength = audioBuffer.length; this.sampleBuffer = audioBuffer; this._sampleChannels = undefined; // samples read from a sound loaded before are that sound's, not this one's this.loadedPercent = 1; this.onloadCallback?.(this); } } /////////////////////////////////////////////////////////////////////////////// /** * Sound Instance - Wraps an AudioBufferSourceNode for individual sound control * Represents a single playing instance of a sound with pause/resume capabilities * @memberof Audio * @example * // Play a sound and get an instance for control * const jumpSound = new Sound([.5,.5,220]); * const instance = jumpSound.play(); * * // Control the individual instance * instance.setVolume(.5); * instance.pause(); * instance.resume(); * instance.stop(); */ class SoundInstance { /** Create a sound instance * @param {Sound} sound - The sound object * @param {number} [volume] - How much to scale volume by * @param {number} [rate] - The playback rate to use * @param {number} [pan] - How much to apply stereo panning * @param {boolean} [loop] - Should the sound loop? * @param {boolean} [paused] - Should the sound start paused? */ constructor(sound, volume=1, rate=1, pan=0, loop=false, paused=false) { ASSERT(sound instanceof Sound, 'SoundInstance requires a valid Sound object'); ASSERT(volume >= 0, 'Sound volume must be positive or zero'); ASSERT(rate >= 0, 'Sound rate must be positive or zero'); ASSERT(isNumber(pan), 'Sound pan must be a number'); /** @property {Sound} - The sound object */ this.sound = sound; /** @property {number} - How much to scale volume by */ this.volume = audioFinite(volume, 0); /** @property {number} - The playback rate to use */ this.rate = audioFinite(rate, 1); /** @property {number} - How much to apply stereo panning */ this.pan = audioFinite(pan, 0); /** @property {boolean} - Should the sound loop */ this.loop = loop; /** @property {number|undefined} - Where it is in the sound while not playing, in the sound's own seconds, undefined while playing * @type {number|undefined} */ this.pausedTime = 0; /** @property {number} - Audio context time its place was last taken at, while playing * @type {number|undefined} */ this.startTime = undefined; /** @property {number} - Where it was in the sound at startTime, in the sound's own seconds */ this.startOffset = 0; /** @property {GainNode|undefined} - Gain node for the sound, undefined once it is stopped or paused * @type {GainNode|undefined} */ this.gainNode = undefined; /** @property {StereoPannerNode|undefined} - Stereo panner for the sound, undefined once it is stopped or paused * @type {StereoPannerNode|undefined} */ this.pannerNode = undefined; /** @property {AudioBufferSourceNode|undefined} - Source node of the audio, undefined while not playing * @type {AudioBufferSourceNode|undefined} */ this.source = undefined; /** @property {AudioNode|AudioEffectNodes|undefined} - Node or effect to route this instance through, copied from the sound * @type {AudioNode|AudioEffectNodes|undefined} */ this.output = sound.output; /** @property {AudioEndedCallback|undefined} - Called when this instance plays to its end, not when it is stopped * or paused; it is read when the sound ends, so it can be set at any time * @type {AudioEndedCallback|undefined} */ this.onendedCallback = undefined; /** A playback that ends on its own leaves the instance stopped, its time back at 0; the ended event of one * stopped or replaced since is too late to change anything * @private */ this.sourceEnded = (source)=> { if (source !== this.source) return; this.source = this.gainNode = this.pannerNode = undefined; this.startTime = undefined; this.pausedTime = 0; this.onendedCallback?.(source); }; // start sound if (!paused) this.start(); } /** Start playing the sound instance from a place in the sound * @param {number} [offset] - Where to start in the sound, in its own seconds whatever the rate */ start(offset=0) { ASSERT(offset >= 0, 'Sound start offset must be positive or zero'); if (this.isPlaying()) this.stop(); this.gainNode = audioContext.createGain(); this.pannerNode = new StereoPannerNode(audioContext, {'pan':clamp(this.pan, -1, 1)}); // build the shared buffer if it was not made at load time, then play it this.sound.buildSampleBuffer(); this.source = this.sound.sampleBuffer ? playAudioBuffer(this.sound.sampleBuffer, this.volume, this.rate, this.pan, this.loop, this.gainNode, offset, this.sourceEnded, this.output, this.pannerNode) : playSamples(this.sound.sampleChannels, this.volume, this.rate, this.pan, this.loop, this.sound.sampleRate, this.gainNode, offset, this.sourceEnded, this.output, this.pannerNode); audioWaitingInstances.delete(this); if (this.source) { this.startTime = audioContext.currentTime; this.startOffset = offset; this.pausedTime = undefined; } else { // the sound could not start, keep the place so a later resume picks it up, // which happens on its own when it failed only because audio is not running yet this.startTime = this.gainNode = this.pannerNode = undefined; this.pausedTime = offset; if (!audioIsRunning()) { // only the newest of each sound waits, a loop a game plays again each frame is one loop audioWaitingPrune(); for (const other of audioWaitingInstances.keys()) other.sound === this.sound && audioWaitingInstances.delete(other); audioWaitingInstances.set(this, performance.now()); } } } /** Set the volume of this sound instance, with an optional fade to it * - A fade ducks music under dialogue or cross fades two tracks without a click * @param {number} volume * @param {number} [fadeTime] - Seconds to fade to the new volume over */ setVolume(volume, fadeTime=0) { ASSERT(volume >= 0, 'Sound volume must be positive or zero'); ASSERT(fadeTime >= 0, 'Sound fade time must be positive or zero'); this.volume = volume = audioFinite(volume, 0); this.gainNode && audioParamRamp(this.gainNode.gain, volume, fadeTime); } /** Set the stereo pan of this sound instance, while it plays too * - A looping sound can follow its source across the screen this way * @param {number} pan - -1 is left, 0 is center, 1 is right, clamped to that range */ setPan(pan) { ASSERT(isNumber(pan), 'Sound pan must be a number'); this.pan = pan = audioFinite(pan, 0); if (this.pannerNode) this.pannerNode.pan.value = clamp(pan, -1, 1); } /** Set the playback rate of this sound instance, its speed and pitch, while it plays * - A looping sound can follow something smoothly this way, like an engine with the speed * - A rate of 0 freezes the sound in place, and it carries on from there when the rate comes back * @param {number} rate - 1 is normal, 2 is twice as fast and an octave up */ setRate(rate) { ASSERT(rate >= 0, 'Sound rate must be positive or zero'); // keep the place in the sound, only the speed changes from here if (this.isPlaying()) { this.startOffset = this.getCurrentTime(); this.startTime = audioContext.currentTime; } this.rate = rate = audioFinite(rate, 1); if (this.source) this.source.playbackRate.value = rate; } /** Stop this sound instance and reset position to the start * @param {number} [fadeTime] - Seconds to fade out over before stopping */ stop(fadeTime=0) { ASSERT(fadeTime >= 0, 'Sound fade time must be positive or zero'); audioWaitingInstances.delete(this); // a sound waiting for audio to run no longer starts if (this.isPlaying()) { if (fadeTime) { // ramp off gain from where it is now (not 1, or low-volume // instances would jump back up before fading, and a volume // fade in flight carries on down from its current point); // the ramp and the stop share one end time this.source.stop(audioParamRamp(this.gainNode.gain, 0, fadeTime)); } else this.source.stop(); } this.pausedTime = 0; this.source = undefined; this.startTime = undefined; // let go of the gain node so a later setVolume can't cancel the fade out, the ended listener disconnects // it, and start makes a new one this.gainNode = undefined; this.pannerNode = undefined; } /** Pause this sound instance */ pause() { audioWaitingInstances.delete(this); // a sound waiting for audio to run no longer starts if (this.isPaused()) return; // save current time and stop sound this.pausedTime = this.getCurrentTime(); this.source.stop(); this.source = undefined; this.startTime = undefined; this.gainNode = undefined; // resume starts with a new one at the volume set meanwhile this.pannerNode = undefined; } /** Resume this sound instance */ resume() { if (!this.isPaused()) return; // restart sound from paused time this.start(this.pausedTime); } /** Check if this instance is currently playing * @return {boolean} - True if playing */ isPlaying() { return !!this.source; } /** Check if this instance is paused or stopped (not currently playing) * @return {boolean} - True if not playing */ isPaused() { return !this.isPlaying(); } /** Get where it is in the sound, in the sound's own seconds: at a rate of 2 it moves two seconds for each one * that passes, and at 0 it stays put * @return {number} - Seconds into the sound */ getCurrentTime() { if (!this.isPlaying()) return this.pausedTime; const duration = this.getDuration(); const place = this.startOffset + (audioContext.currentTime - this.startTime) * this.rate; // a sound still loading has no length yet; a loop goes around, a one shot past its end, before its ended // event, stays at its end, so a pause there does not play it again on resume return !duration ? 0 : this.loop ? mod(place, duration) : min(place, duration); } /** Get the length of the sound in its own seconds, the same at any rate; divide by the rate for how long it takes to play * @return {number} - Length in seconds (0 if loading) */ getDuration() { return this.sound.getDuration(); } /** Get source of this sound instance * @return {AudioBufferSourceNode|undefined} - The source, or undefined while not playing */ getSource() { return this.source; } } /////////////////////////////////////////////////////////////////////////////// /** Speak text with passed in settings * @param {string} text - The text to speak * @param {number} [volume] - How much to scale volume by * @param {number} [rate] - How quickly to speak * @param {number} [pitch] - How much to change the pitch by * @param {string} [language] - The language/accent to use (examples: en, it, ru, ja, zh) * @return {SpeechSynthesisUtterance|undefined} - The utterance that was spoken, or undefined if speech is unavailable * @memberof Audio */ function speak(text, volume=1, rate=1, pitch=1, language='') { ASSERT(typeof volume !== 'string', 'speak() signature changed: language is now the last parameter, after pitch'); if (!soundEnable || headlessMode) return; if (typeof speechSynthesis === 'undefined') return; // common languages (not supported by all browsers) // en - english, it - italian, fr - french, de - german, es - spanish // ja - japanese, ru - russian, zh - chinese, hi - hindi, ko - korean // build utterance and speak const utterance = new SpeechSynthesisUtterance(text); utterance.lang = language; utterance.volume = clamp(volume*soundVolume); utterance.rate = rate; utterance.pitch = pitch; speechSynthesis.speak(utterance); return utterance; } /** Stop all queued speech * @memberof Audio */ function speakStop() { if (typeof speechSynthesis !== 'undefined') speechSynthesis.cancel(); } /** Get frequency of a note on a musical scale * @param {number} semitoneOffset - How many semitones away from the root note * @param {number} [rootFrequency] - Frequency at semitone offset 0 * @return {number} - The frequency of the note * @memberof Audio */ function getNoteFrequency(semitoneOffset, rootFrequency=220) { return rootFrequency * 2**(semitoneOffset/12); } /////////////////////////////////////////////////////////////////////////////// /** * @callback AudioEndedCallback - Function called when a sound ends * @param {AudioBufferSourceNode} source * @memberof Audio */ /** Play cached audio samples with given settings * @param {Array} sampleChannels - Array of arrays of samples to play (for stereo playback) * @param {number} [volume] - How much to scale volume by * @param {number} [rate] - The playback rate to use * @param {number} [pan] - How much to apply stereo panning * @param {boolean} [loop] - True if the sound should loop when it reaches the end * @param {number} [sampleRate=audioDefaultSampleRate] - Sample rate for the sound * @param {GainNode} [gainNode] - Optional gain node for volume control while playing (disconnected when the sound ends) * @param {number} [offset] - Where to start in the sound, in its own seconds whatever the rate * @param {AudioEndedCallback} [onended] - Callback for when the sound ends * @param {AudioNode|AudioEffectNodes} [output] - Node or effect to connect the gain to instead of the master gain * @param {StereoPannerNode} [pannerNode] - Optional stereo panner for panning while playing, its pan already set (disconnected when the sound ends) * @return {AudioBufferSourceNode|undefined} - The source node of the sound played, undefined if play fails * @memberof Audio */ function playSamples(sampleChannels, volume=1, rate=1, pan=0, loop=false, sampleRate=audioDefaultSampleRate, gainNode, offset=0, onended, output, pannerNode) { if (!soundEnable || headlessMode) return; if (!audioIsRunning()) { // fix stalled audio, don't build a buffer that can't be played; // but a context suspended because the page is hidden stays suspended until it shows if (!audioSuspendedWhenHidden) audioResume(); return; } const buffer = createAudioBuffer(sampleChannels, sampleRate); return playAudioBuffer(buffer, volume, rate, pan, loop, gainNode, offset, onended, output, pannerNode); } /** Copy arrays of samples into a new audio buffer * @param {Array} sampleChannels - Array of arrays of samples (for stereo playback) * @param {number} [sampleRate=audioDefaultSampleRate] - Sample rate for the sound * @return {AudioBuffer} - The audio buffer holding the samples * @memberof Audio */ function createAudioBuffer(sampleChannels, sampleRate=audioDefaultSampleRate) { const channelCount = sampleChannels.length; const sampleLength = sampleChannels[0].length; const buffer = audioContext.createBuffer(channelCount, sampleLength, sampleRate); sampleChannels.forEach((c,i)=> buffer.getChannelData(i).set(c)); return buffer; } /** Play an audio buffer with given settings * The buffer can be shared by any number of sounds playing at once * @param {AudioBuffer} buffer - The audio buffer to play * @param {number} [volume] - How much to scale volume by * @param {number} [rate] - The playback rate to use * @param {number} [pan] - How much to apply stereo panning * @param {boolean} [loop] - True if the sound should loop when it reaches the end * @param {GainNode} [gainNode] - Optional gain node for volume control while playing (disconnected when the sound ends) * @param {number} [offset] - Where to start in the sound, in its own seconds whatever the rate * @param {AudioEndedCallback} [onended] - Callback for when the sound ends * @param {AudioNode|AudioEffectNodes} [output] - Node or effect to connect the gain to instead of the master gain * @param {StereoPannerNode} [pannerNode] - Optional stereo panner for panning while playing, its pan already set (disconnected when the sound ends) * @return {AudioBufferSourceNode|undefined} - The source node of the sound played, undefined if play fails * @memberof Audio */ function playAudioBuffer(buffer, volume=1, rate=1, pan=0, loop=false, gainNode, offset=0, onended, output, pannerNode) { if (!soundEnable || headlessMode) return; if (!audioIsRunning()) { // fix stalled audio, this sound won't be able to play; // but a context suspended because the page is hidden stays suspended until it shows if (!audioSuspendedWhenHidden) audioResume(); return; } volume = audioFinite(volume, 0), rate = audioFinite(rate, 1), pan = audioFinite(pan, 0); // setup source, many sources can share one buffer const source = audioContext.createBufferSource(); source.buffer = buffer; source.playbackRate.value = rate; source.loop = loop; // create and connect gain node gainNode = gainNode || audioContext.createGain(); gainNode.gain.value = volume; const outputNode = audioEffectNode(output, 'input') || audioMasterGain; ASSERT(typeof outputNode.connect === 'function', 'output must be an AudioNode or an effect with input and output nodes'); gainNode.connect(outputNode); // connect source to stereo panner and gain const panner = pannerNode || new StereoPannerNode(audioContext, {'pan':clamp(pan, -1, 1)}); source.connect(panner).connect(gainNode); // disconnect nodes when the sound ends so the audio graph doesn't grow // unbounded across many play() calls (source.stop() also fires 'ended') source.addEventListener('ended', ()=> { gainNode.disconnect(); panner.disconnect(); if (onended) onended(source); }); // play and return sound, the offset is a place in the buffer whatever the rate source.start(0, offset); if (debug && debugSound) LOG('sound', 'vol', volume.toFixed(2), 'rate', rate.toFixed(2), 'pan', pan.toFixed(2), loop ? 'loop' : ''); return source; } /////////////////////////////////////////////////////////////////////////////// // ZzFX - Zuper Zmall Zound Zynth - v1.4.0 by Frank Force /** Generate and play a ZzFX sound * * Create sounds using the ZzFX Sound Designer. * @param {Array} zzfxSound - Array of ZzFX parameters, ex. [.5,.5] * @return {AudioBufferSourceNode|undefined} - The audio node of the sound played, undefined if play fails * @memberof Audio */ function zzfx(...zzfxSound) { return playSamples([zzfxG(...zzfxSound)]); } /** Generate samples for a ZzFX sound * @param {number} [volume] - Volume scale (percent) * @param {number} [randomness] - How much to randomize frequency (percent Hz) * @param {number} [frequency] - Frequency of sound (Hz) * @param {number} [attack] - Attack time, how fast sound starts (seconds) * @param {number} [sustain] - Sustain time, how long sound holds (seconds) * @param {number} [release] - Release time, how fast sound fades out (seconds) * @param {number} [shape] - Shape of the sound wave * @param {number} [shapeCurve] - Squareness of wave (0=square, 1=normal, 2=pointy) * @param {number} [slide] - How much to slide frequency, 1 is about 500 Hz a second, as ZzFX has it * @param {number} [deltaSlide] - How much to change slide each second, in the units of slide * @param {number} [pitchJump] - Frequency of pitch jump (Hz) * @param {number} [pitchJumpTime] - Time of pitch jump (seconds) * @param {number} [repeatTime] - Resets some parameters periodically (seconds) * @param {number} [noise] - How much random noise to add (percent) * @param {number} [modulation] - Frequency of modulation wave, negative flips phase (Hz) * @param {number} [bitCrush] - Resamples at a lower frequency in (samples*100) * @param {number} [delay] - Overlap sound with itself for reverb and flanger effects (seconds) * @param {number} [sustainVolume] - Volume level for sustain (percent) * @param {number} [decay] - Decay time, how long to reach sustain after attack (seconds) * @param {number} [tremolo] - Trembling effect, rate controlled by repeat time (percent) * @param {number} [filter] - Filter cutoff, positive for HPF, negative for LPF; the cutoff is about twice this in * Hz, as ZzFX has it, and its resonance can take the samples past 1 * @return {Float32Array} - The audio samples * @memberof Audio */ function zzfxG ( volume = 1, randomness = .05, frequency = 220, attack = 0, sustain = 0, release = .1, shape = 0, shapeCurve = 1, slide = 0, deltaSlide = 0, pitchJump = 0, pitchJumpTime = 0, repeatTime = 0, noise = 0, modulation = 0, bitCrush = 0, delay = 0, sustainVolume = 1, decay = 0, tremolo = 0, filter = 0 ) { // init parameters let sampleRate = audioDefaultSampleRate, PI2 = PI*2, startSlide = slide *= 500 * PI2 / sampleRate / sampleRate, startFrequency = frequency *= (1 + rand(randomness,-randomness)) * PI2 / sampleRate, modOffset = 0, // modulation offset repeat = 0, // repeat offset crush = 0, // bit crush offset jump = 1, // pitch jump timer length, // sample length b, // sample buffer t = 0, // sample time i = 0, // sample index s = 0, // sample value f, // wave frequency // biquad LP/HP filter quality = 2, w = PI2 * min(abs(filter), sampleRate/4 - 1) * 2 / sampleRate, // stable below a quarter rate cosw = cos(w), alpha = sin(w) / 2 / quality, a0 = 1 + alpha, a1 = -2*cosw / a0, a2 = (1 - alpha) / a0, b0 = (1 + sign(filter) * cosw) / 2 / a0, b1 = -(sign(filter) + cosw) / a0, b2 = b0, x2 = 0, x1 = 0, y2 = 0, y1 = 0; // scale by sample rate const minAttack = 9; // prevent pop if attack is 0 attack = attack * sampleRate || minAttack; decay *= sampleRate; sustain *= sampleRate; release *= sampleRate; delay *= sampleRate; deltaSlide *= 500 * PI2 / sampleRate**3; modulation *= PI2 / sampleRate; pitchJump *= PI2 / sampleRate; pitchJumpTime *= sampleRate; repeatTime = repeatTime * sampleRate | 0; // allocate the full sample buffer up front, much faster than growing an array length = attack + decay + sustain + release + delay | 0; b = new Float32Array(length > 0 ? length : 0); // generate waveform for (; i < length; b[i++] = s * volume) // sample { if (!(++crush%(bitCrush*100|0))) // bit crush { s = shape? shape>1? shape>2? shape>3? shape>4? // wave shape (t/PI2%1 < shapeCurve/2? 1 : -1) : // 5 square duty sin(t**3) : // 4 noise max(min(tan(t),1),-1): // 3 tan 1-(2*t/PI2%2+2)%2: // 2 saw 1-4*abs(round(t/PI2)-t/PI2): // 1 triangle sin(t); // 0 sin s = (repeatTime ? 1 - tremolo + tremolo*sin(PI2*i/repeatTime) // tremolo : 1) * (shape>4?s:sign(s)*abs(s)**shapeCurve) * // shape curve (i < attack ? i/attack : // attack i < attack + decay ? // decay 1-((i-attack)/decay)*(1-sustainVolume) : // decay falloff i < attack + decay + sustain ? // sustain sustainVolume : // sustain volume i < length - delay ? // release (length - i - delay)/release * // release falloff sustainVolume : // release volume 0); // post release s = delay ? s/2 + (delay > i ? 0 : // delay (i pitchJumpTime) // pitch jump { frequency += pitchJump; // apply pitch jump startFrequency += pitchJump; // also apply to start jump = 0; // stop pitch jump time } if (repeatTime && !(++repeat % repeatTime)) // repeat { frequency = startFrequency; // reset frequency slide = startSlide; // reset slide jump ||= 1; // reset pitch jump time } } return b; // return sample buffer } /** * LittleJS Tile Layer System * - Renders large tile-based levels efficiently using cached canvases * - Unlimited tile layers with automatic canvas allocation * - Layers support both rendering and collision detection * - Direct canvas2d drawing access for custom tile rendering * - TileLayer for rendering, TileCollisionLayer for physics * - Collision callbacks for tile interactions with objects * - Optimized raycast support for tile-based physics * - Integration with Box2D physics via the Box2dTileLayer plugin * @namespace TileLayers */ /////////////////////////////////////////////////////////////////////////////// // Tile Layer System /** A Tiled map as Tiled saves it as JSON, what tileLayersLoad and objectLayersLoad take and tileLayersFromLDtk * makes; the fields read are listed, and the rest of the file is kept as it is * @typedef {Object} TiledMap * @property {number} width - Cells across * @property {number} height - Cells up * @property {number} [tilewidth] - A tile's width in pixels * @property {number} [tileheight] - A tile's height in pixels * @property {Array} layers - Tile layers, object layers and groups of them, bottom first * @property {Array} [tilesets] - The first one's margin, spacing and columns are read * @property {number} [nextlayerid] * @property {number} [nextobjectid] * @property {string} [orientation] * @property {string} [renderorder] * @property {boolean} [infinite] * @property {string} [type] * @property {string|number} [version] - A number in files from Tiled before 1.6 * @property {string} [tiledversion] * @property {number} [compressionlevel] * @property {string} [backgroundcolor] * @property {Array} [properties] * @memberof TileLayers */ /** Keep track of all tile layers with collision * @type {Array} * @memberof TileLayers */ const tileCollisionLayers = []; // a tile collision layer's position is whole numbers, so its cells are the world grid the physics lands objects on function tileCollisionAssertWhole(layer) { ASSERT(layer.pos.x % 1 === 0 && layer.pos.y % 1 === 0, 'a tile collision layer must sit at a whole number position', layer.pos); } // the object the physics is moving and where it was before this frame's move, for one way tiles let tileCollisionFromObject, tileCollisionFromPos; // the object whose tiles are asked about where its move put it, for collideWithTile's isHit let tileCollisionHitObject; // a one way tile at a layer's cell lets a box at from pass, when the cell draws a tile set one way and the box was not // wholly on its solid side, the side it can be passed through toward; the way is turned and mirrored as the tile's // art is, mirror first, then its quarter turns clockwise; a point is a box of no size function tileCollisionOneWayPass(layer, x, y, fromX, fromY, sizeX=0, sizeY=0) { const oneWayTiles = layer.oneWayTiles; if (!oneWayTiles.size) return false; const data = layer.data[y*layer.size.x + x], way = data && oneWayTiles.get(data.tile); if (!way) return false; let wayX = data.mirror ? -way.x : way.x, wayY = way.y; for (let turn = data.direction & 3; turn--;) [wayX, wayY] = [wayY, -wayX]; const epsilon = 1e-3, cellX = layer.pos.x + x, cellY = layer.pos.y + y; return wayX > .5 ? fromX - sizeX/2 < cellX + 1 - epsilon : wayX < -.5 ? fromX + sizeX/2 > cellX + epsilon : wayY > .5 ? fromY - sizeY/2 < cellY + 1 - epsilon : fromY + sizeY/2 > cellY + epsilon; } // the layer whose tile tileCollisionGetDataFrom last found solid, undefined for none, the one a particle bounces off let tileCollisionDataLayer; // tileCollisionGetData of the solid layers for particles, which pass a one way tile when they were not on its far // side at from: a solid tile's data, else a negative marker, else 0 function tileCollisionGetDataFrom(pos, fromX, fromY) { let found = 0; tileCollisionDataLayer = undefined; for (const layer of tileCollisionLayers) if (layer.isSolid) { const x = pos.x - layer.pos.x, y = pos.y - layer.pos.y, size = layer.size; if (x >= 0 && y >= 0 && x < size.x && y < size.y) { const data = layer.collisionData[(y|0)*size.x + (x|0)]; if (data > 0) { if (!tileCollisionOneWayPass(layer, x|0, y|0, fromX, fromY)) return tileCollisionDataLayer = layer, data; } else if (data && !found) found = data; } } return found; } // the test a tile query applies to a cell's data: the callback, the object's collideWithTile, or solid data function tileCollisionTester(callbackObject) { ASSERT(!callbackObject || typeof callbackObject === 'function' || callbackObject instanceof EngineObject, 'callbackObject must be a function or EngineObject'); return !callbackObject ? (tileData)=> tileData > 0 : typeof callbackObject === 'function' ? (tileData, pos)=> callbackObject(tileData, pos) : (tileData, pos)=> callbackObject.collideWithTile(tileData, pos, callbackObject === tileCollisionHitObject); } /** Get tile collision data for a given cell in the grid * @param {Vector2} pos * @param {boolean} [solidOnly] - Only check solid layers? * @return {number} * @memberof TileLayers */ function tileCollisionGetData(pos, solidOnly=true) { // check all tile collision layers, in scalars since particles ask this every frame // solid (positive) data wins, a negative marker is returned only when no layer is solid there let found = 0; for (const layer of tileCollisionLayers) if (!solidOnly || layer.isSolid) { // world pos to the layer's cell const x = pos.x - layer.pos.x, y = pos.y - layer.pos.y, size = layer.size; if (x >= 0 && y >= 0 && x < size.x && y < size.y) { const data = layer.collisionData[(y|0)*size.x + (x|0)]; if (data > 0) return data; if (data && !found) found = data; } } return found; } /** Check if a tile layer collides with another object * @param {Vector2} pos * @param {Vector2} [size=vec2()] * @param {EngineObject|TileCollisionCallback} [callbackObject] - Callback, engine object, or undefined * @param {boolean} [solidOnly] - Only check solid layers? * @return {TileCollisionLayer|undefined} * @memberof TileLayers */ function tileCollisionTest(pos, size=vec2(), callbackObject, solidOnly=true) { for (const layer of tileCollisionLayers) { if (!solidOnly || layer.isSolid) if (layer.collisionTest(pos, size, callbackObject)) return layer; } } /** * @callback TileCollisionCallback - Decides whether a tile counts as solid for a collision test or raycast * @param {number} tileData - the value of the tile at the position * @param {Vector2} pos - world space position of tile where the collision occurred * @return {boolean} - true for a hit; a callback that returns nothing lets everything through * @memberof TileLayers */ /** Return the exact position of the boundary of first tile hit, undefined if nothing was hit. * The point will be inside the colliding tile if it hits * @param {Vector2} posStart * @param {Vector2} posEnd * @param {EngineObject|TileCollisionCallback} [callbackObject] - Callback, engine object, or undefined * @param {Vector2} [normal] - Optional normal of the surface hit * @param {boolean} [solidOnly] - Only check solid layers? * @return {Vector2|undefined} - where the ray meets the first tile hit, nudged just inside it, or undefined if no hit * @memberof TileLayers */ function tileCollisionRaycast(posStart, posEnd, callbackObject, normal, solidOnly=true) { // check every layer and keep the closest hit so a far hit in an // earlier-registered layer doesn't shadow a closer hit in a later one let closestHit, closestDistSq, closestNormal; const scratchNormal = normal && vec2(); for (const layer of tileCollisionLayers) { if (!solidOnly || layer.isSolid) { const hitPos = layer.collisionRaycast(posStart, posEnd, callbackObject, scratchNormal); if (hitPos) { const d = posStart.distanceSquared(hitPos); if (closestHit === undefined || d < closestDistSq) { closestHit = hitPos; closestDistSq = d; if (normal) closestNormal = scratchNormal.copy(); } } } } if (closestHit && normal) normal.setFrom(closestNormal); return closestHit; } /////////////////////////////////////////////////////////////////////////////// // Tiled's flip flags, horizontal, vertical and diagonal as bits 2, 1 and 0, as [direction, mirror] const tileLayersTiledFlips = [[0,0], [3,1], [2,1], [3,0], [0,1], [1,0], [2,0], [1,1]]; // the tile info a layer's tile info gives a tile: a tile set's tile, a frame of a sheet read by its columns, or a // tile of its grid; undefined past the end of a tile set, or of a sheet read by its columns, as values kept only for // collision can be, which the level editor reads the same way function tileLayerTileInfo(t, tile) { if (!t) return; if (t.tiles) return t.tiles[tile]; if (!t.columns) return t.index(tile); if (t.textureInfo && t.pos.y + (tile / t.columns | 0) * (t.size.y + t.padding*2) + t.size.y > t.textureInfo.size.y) return; return t.frame(tile); } /** * Load tile layers from exported data * - Tiled maps come in as they are, flipped and turned tiles included, from one tileset image (a second tileset's * tiles continue its numbering), finite maps in the CSV or array layer format; layer offsets and parallax are not read * - A tileset kept in the map with a margin or a spacing, a sheet with gaps between its tiles, is read where its * tiles are, whatever padding the tile info has; one in a file of its own (a tsx) is not read, pass a tile info * with its padding * - An LDtk level loads through tileLayersFromLDtk, which makes it a map like these * - Group layers are flattened in order, each replaced by the layers inside it, so the layer indices * (collisionLayer and the returned array) count that flattened list; a group's tint, opacity and * visibility carry to the layers inside it * - An object or image layer keeps its index, with its slot in the returned array left empty * - A hidden layer (visible false) is loaded, its collision included, but not drawn; its render * is a no-op, delete that and call redraw() to show it * @param {TiledMap} [tileMapData] - Level data from exported data, a 50 by 50 empty level when left out * @param {TileInfo} [tileInfo] - Default tile info (used for size and texture), tile() by default, none when no image is loaded * @param {number} [renderOrder] - Render order of the top layer * @param {number|string} [collisionLayer] - Layer to use for collision if any, by its index or its name * @param {boolean} [draw] - Should the layer be drawn automatically * @return {Array} - It throws for a map that is not whole cells, or a layer whose tiles do not * fill it, before it makes any layer * @memberof TileLayers */ function tileLayersLoad(tileMapData, tileInfo=tileLayerDefaultTile(), renderOrder=0, collisionLayer, draw=true) { if (!tileMapData) { // default level data if loading failed const s = 50; tileMapData = {width: s, height: s, layers: [{data: new Array(s*s).fill(0)}]}; } // the editor, in debug builds, keeps the map as the source of its edits and brings back autosaved ones tileMapData = editorMapRestore(tileMapData); // validate the tile map data: a size that is not whole cells, or tiles that do not fill it, is said in any build, // since the layers and their loops are made from that size const {width: mapWidth, height: mapHeight} = tileMapData; if (!(Number.isInteger(mapWidth) && Number.isInteger(mapHeight) && mapWidth > 0 && mapHeight > 0)) throw new Error(`tileLayersLoad: a map is a whole number of cells across and down, not ${mapWidth} by ${mapHeight}`); if (!isArray(tileMapData.layers) || !tileMapData.layers.length) throw new Error('tileLayersLoad: a map has a list of layers'); // a sheet with a margin around its tiles or a spacing between them: the first tile is at the margin and each // cell is a tile and a spacing, which is a padding of half the spacing counted from there, by the tileset's // own count of columns, since the image's width does not say with a spacing that is not all around // a tile set from loadTiles has each tile where it was packed, whatever the map says of its own image const tileset = tileMapData.tilesets?.[0]; if (tileInfo && !tileInfo.tiles && tileset && (tileset.margin > 0 || tileset.spacing > 0)) { const margin = tileset.margin || 0, spacing = tileset.spacing || 0, size = tileInfo.size; const width = tileInfo.textureInfo?.size.x || 0; const columns = tileset.columns || max(1, floor((width - margin*2 + spacing) / (size.x + spacing))); const origin = tileInfo.pos.subtract(vec2(tileInfo.padding)); // where the sheet starts, in an atlas too tileInfo = new TileInfo(origin.add(vec2(margin)), size, tileInfo.textureInfo, spacing / 2, tileInfo.bleed, columns); } // flatten group layers in order, a group's color and visibility carry to the layers inside it /** @type {Array<{dataLayer: Object, color?: Color, visible?: boolean}>} */ const layers = []; const addLayers = (dataLayers, groupColor, groupVisible)=> { for (const dataLayer of dataLayers) { const type = dataLayer.type; if (type && type !== 'tilelayer' && type !== 'group') { layers.push({dataLayer}); // an object or image layer has no tiles, its slot is left empty continue; } // apply layer color, Tiled writes a tint with alpha as #AARRGGBB const tint = dataLayer.tintcolor; const color = tint ? tileLayersColor(tint) : (dataLayer.color || WHITE).copy(); ASSERT(isColor(color), 'layer color is not a color'); color.a *= dataLayer.opacity ?? 1; const visible = groupVisible && dataLayer.visible !== false; if (type === 'group') addLayers(dataLayer.layers || [], groupColor.multiply(color), visible); else layers.push({dataLayer, color: groupColor.multiply(color), visible}); } }; addLayers(tileMapData.layers, WHITE, true); // a layer named for collision is the first tile layer of that name, which stays the same when layers are added if (typeof collisionLayer === 'string') { const name = collisionLayer; collisionLayer = layers.findIndex((l)=> l.color && l.dataLayer.name === name); collisionLayer < 0 && console.error('tileLayersLoad: no tile layer is named ' + name + ', none is solid'); } // create tile layers and fill with data, every layer's tiles checked first, so a bad one leaves none made const tileLayers = []; const levelSize = vec2(tileMapData.width, tileMapData.height); const layerCount = layers.length; for (const [layerIndex, {dataLayer, color}] of layers.entries()) { const tiles = dataLayer.data; // a list of gids, or a typed array of them if (color && (!(isArray(tiles) || ArrayBuffer.isView(tiles)) || dataLayer.data.length !== levelSize.area())) throw new Error(`tileLayersLoad: layer ${dataLayer.name ?? layerIndex} has ${dataLayer.data?.length} tiles for a map of ` + `${levelSize.area()}; infinite maps and compressed layers are not read`); } for (let layerIndex=layerCount; layerIndex--;) { const {dataLayer, color: layerColor, visible} = layers[layerIndex]; if (!layerColor) continue; const layerRenderOrder = renderOrder - (layerCount - 1 - layerIndex); const tileLayer = new TileCollisionLayer(vec2(), levelSize, tileInfo, layerRenderOrder); tileLayer.isSolid = layerIndex === collisionLayer; // the others are art, the solid tests skip them tileLayers[layerIndex] = tileLayer; if (!visible) tileLayer.render = ()=> {}; // a hidden layer keeps its tiles and collision but is not drawn for (let x=0; x>> 29]; const tileIndex = (data & 0x0fffffff) - 1; // bit 28, a hexagonal turn, is not read const layerData = new TileLayerData(tileIndex, direction, !!mirror, layerColor); tileLayer.setData(pos, layerData); // set collision for top layer if (layerIndex === collisionLayer) tileLayer.setCollisionData(pos, 1); } } if (draw && visible) tileLayer.redraw(); } editorMapLoaded(tileMapData, tileLayers, layers); return tileLayers; } // a color as Tiled writes it, #AARRGGBB, or #RRGGBB function tileLayersColor(hex) { return new Color().setHex(hex.length === 9 ? '#' + hex.slice(3) + hex.slice(1, 3) : hex); } // the Tiled property type of an LDtk field type, for the ones Tiled has a property for const tileLayersLDtkTypes = {Int: 'int', Float: 'float', Bool: 'bool', String: 'string', Multilines: 'string', Color: 'color', FilePath: 'file'}; /** * Make a Tiled map of a level of an LDtk project, to load with tileLayersLoad and objectLayersLoad * - Each Tiles, AutoLayer and IntGrid layer is a tile layer, the bottom one first as in Tiled; where LDtk stacks * tiles in a cell, an edge over a fill, each tile over another goes in a layer of its own just above, named with * (2), (3) and so on, and a tile LDtk draws see-through goes in one of its own with that opacity, times the * layer's, named with it, like Ground .25 * - An IntGrid layer is a hidden layer of its values under its own name, for collision, whatever tiles its rules * make, which are layers over it named with tiles, like Collisions tiles; pass the name as collisionLayer, since * the stacked and see-through layers change the indices from level to level: * tileLayersLoad(map, tile(0, 16), 0, 'Collisions') * - An Entities layer is an object layer: an entity's name is its type for objectLayersAddType, it is placed at * its middle, and its Int, Float, Bool, String, Color and FilePath fields are its properties (an enum is a string) * - The tileset is the first tile layer's, with its padding and spacing; give tileLayersLoad a tile info of its image * - The level is in the project file (not saved as separate level files), its layers of one grid size; a layer of * another grid size or another tileset is left out, with a warning in debug builds, and a project with no tileset * keeps its tiles for the tile info the game gives; a project of several worlds has its levels counted in order * - Layer offsets are not read * - The level editor edits the map this returns, and saves it as a Tiled map * @param {Object} ldtk - The LDtk project, its JSON * @param {number|string} [level] - Which level, by its index or its identifier * @return {TiledMap} - A Tiled map: width, height, tilewidth, tileheight, tilesets and layers * @example * const map = tileLayersFromLDtk(await fetchJSON('world.ldtk'), 'Level_0'); * const layers = tileLayersLoad(map, tile(0, 16), 0, 'Collisions'); // its IntGrid layer is solid * objectLayersLoad(map); * @memberof TileLayers */ function tileLayersFromLDtk(ldtk, level=0) { const levels = ldtk?.levels?.length ? ldtk.levels : (ldtk?.worlds || []).flatMap((w)=> w.levels || []); const data = typeof level === 'string' ? levels.find((l)=> l.identifier === level) : levels[level]; if (!data) throw new Error('tileLayersFromLDtk: no level ' + level); if (!isArray(data.layerInstances)) throw new Error('tileLayersFromLDtk: level ' + data.identifier + ' has no layers, levels saved as separate files are not read'); const instances = data.layerInstances; const tiled = (l)=> l.__type !== 'Entities'; const first = instances.find((l)=> tiled(l) && l.__tilesetDefUid != undefined) || instances.find(tiled) || instances[0]; const grid = first?.__gridSize || 16; const width = first?.__cWid || ceil(data.pxWid / grid), height = first?.__cHei || ceil(data.pxHei / grid); if (!(width > 0 && height > 0 && width * height <= 1 << 22)) throw new Error('tileLayersFromLDtk: level ' + data.identifier + ' is ' + width + ' by ' + height + ' cells'); const map = {width, height, tilewidth: grid, tileheight: grid, orientation: 'orthogonal', renderorder: 'right-down', infinite: false, layers: [], nextlayerid: 1, nextobjectid: 1}; // the tileset of the first layer that has one, where its tiles are in its image const tileset = (ldtk?.defs?.tilesets || []).find((t)=> t.uid === first?.__tilesetDefUid); if (tileset) map.tilesets = [{firstgid: 1, name: String(tileset.relPath || '').replace(/^.*[\\/]/, '').replace(/\.\w+$/, ''), image: tileset.relPath, imagewidth: tileset.pxWid, imageheight: tileset.pxHei, tilewidth: tileset.tileGridSize, tileheight: tileset.tileGridSize, margin: tileset.padding || 0, spacing: tileset.spacing || 0, columns: tileset.__cWid, tilecount: tileset.__cWid * tileset.__cHei}]; // LDtk lists the top layer first, Tiled the bottom one for (let i = instances.length; i--;) { const instance = instances[i], name = instance.__identifier, id = map.nextlayerid++; if (!tiled(instance)) { const objects = (instance.entityInstances || []).filter((entity)=> isArray(entity.px)).map((entity)=> { // an entity is placed by its pivot, an object here by its middle const [pivotX=0, pivotY=0] = entity.__pivot || [], w = entity.width || 0, h = entity.height || 0; const properties = []; for (const field of entity.fieldInstances || []) { const type = tileLayersLDtkTypes[field.__type] || (/^(Local|Extern)Enum\./.test(field.__type) && 'string'); type && field.__value != undefined && properties.push({name: field.__identifier, type, value: field.__value}); } return {id: map.nextobjectid++, name: '', type: entity.__identifier, x: entity.px[0] + (.5 - pivotX) * w, y: entity.px[1] + (.5 - pivotY) * h, width: w, height: h, rotation: 0, visible: true, properties}; }); map.layers.push({id, name, type: 'objectgroup', objects, opacity: 1, visible: true, x: 0, y: 0}); continue; } if (instance.__gridSize !== grid || instance.__cWid !== width || instance.__cHei !== height) { console.warn(`tileLayersFromLDtk: layer ${name} has another grid, left out`); continue; } // an IntGrid layer's values, hidden, under its own name, for collision whatever its tiles do const empty = ()=> new Array(width * height).fill(0), opacity = instance.__opacity ?? 1; const values = instance.__type === 'IntGrid'; if (values) { const data = empty(); (instance.intGridCsv || []).forEach((value, k)=> k < data.length && (data[k] = value)); map.layers.push({id, name, type: 'tilelayer', width, height, data, opacity, visible: false, x: 0, y: 0}); } // its tiles, those with a place, from the first layer's tileset const tiles = [...(instance.autoLayerTiles || []), ...(instance.gridTiles || [])].filter((t)=> isArray(t.px)); if (!tiles.length) { // a tile layer with none yet is an empty layer, to paint in the editor, and a level of one still loads values || map.layers.push({id, name, type: 'tilelayer', width, height, data: empty(), opacity, visible: instance.visible !== false, x: 0, y: 0}); continue; } if (tileset && (instance.__tilesetDefUid !== tileset.uid || tileset.tileGridSize !== grid)) { console.warn(`tileLayersFromLDtk: the tiles of layer ${name} are on another tileset, left out`); continue; } // a layer for each depth in a cell and each opacity, the first tiles drawn solid first const tilesName = values ? name + ' tiles' : name; const stacks = [{depth: 0, alpha: 1, data: empty()}], stack = (depth, alpha)=> { let s = stacks.find((s)=> s.depth === depth && s.alpha === alpha); s || stacks.push(s = {depth, alpha, data: empty()}); return s.data; }; { // a tile's place in pixels, its tile in the sheet, its flips, bit 0 across and bit 1 down, which are // Tiled's two top bits, and its opacity; LDtk lists them in the order it draws them, so the first in a // cell is the bottom one and each after it goes a layer higher const depth = new Uint16Array(width * height); for (const t of tiles) { const x = floor(t.px[0] / grid), y = floor(t.px[1] / grid), cell = x + y * width; if (x >= 0 && x < width && y >= 0 && y < height) stack(depth[cell]++, round(clamp(t.a ?? 1) * 1e3) / 1e3)[cell] = (t.t + 1 | (t.f & 1 ? 0x80000000 : 0) | (t.f & 2 ? 0x40000000 : 0)) >>> 0; } } const visible = instance.visible !== false; stacks.sort((a, b)=> a.depth - b.depth || b.alpha - a.alpha); for (const {depth, alpha, data} of stacks) { const layerName = tilesName + (depth ? ` (${depth + 1})` : '') + (alpha < 1 ? ' ' + String(alpha).replace(/^0/, '') : ''); map.layers.push({id: values || depth || alpha < 1 ? map.nextlayerid++ : id, name: layerName, type: 'tilelayer', width, height, data, opacity: opacity * alpha, visible, x: 0, y: 0}); } } return map; } /////////////////////////////////////////////////////////////////////////////// // Object layers // the types a map's objects are made from, by the name they have in Tiled const objectLayersTypes = new Map; /** Add a type of object, so objectLayersLoad makes one wherever a map's object layer has an object of that type * - The name is the object's type in Tiled (its class in Tiled 1.9); it is a string because minified builds * rename classes * - A class, or any function with a prototype, is made with new make(pos); an arrow function is called as * make(pos), for what is not an object, like a player start * - The defaults, and then the properties the object has in Tiled, are set on what it made * - Adding a name again replaces it * @param {string} name - The type the objects have in Tiled * @param {Function} make - A class made at each object's position, or a function called with it * @param {Object} [defaults] - Properties set on each one made, the level editor shows inputs for them * @param {TileInfo} [tileInfo] - An icon for the level editor * @memberof TileLayers * @example * objectLayersAddType('Coin', Coin, {value: 1}, tile(5, 16)); * objectLayersAddType('PlayerStart', (pos)=> playerStartPos = pos); */ function objectLayersAddType(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'); objectLayersTypes.set(String(name), {make, defaults, tileInfo}); } /** Make the objects in a map's object layers, each from the type added for its name with objectLayersAddType * - An object is made at its position, the world y up as tileLayersLoad places the layers; layer offsets are * not read, and a shape or tile object is made at its position too * - Group layers are flattened in order, as tileLayersLoad does * - The object's properties in Tiled are set over the type's defaults: numbers, booleans, strings, and colors, * and for a Vector2 default the string x,y * - An object whose type was not added is skipped, with a warning in debug builds * @param {TiledMap} tileMapData - The same Tiled map given to tileLayersLoad * @return {Array} - What each object's type made, a function that made nothing is left out * @memberof TileLayers */ function objectLayersLoad(tileMapData) { // the level editor, in debug builds, keeps the map and brings back its autosaved objects before they are made tileMapData && editorMapRestore(tileMapData); const made = []; const addObjects = (dataLayers)=> { for (const dataLayer of dataLayers || []) { if (dataLayer.type === 'group') addObjects(dataLayer.layers); if (dataLayer.type !== 'objectgroup') continue; for (const object of dataLayer.objects || []) { const result = objectLayersMake(tileMapData, object); editorObjectMade(tileMapData, dataLayer, object, result); // debug builds link it for the level editor result && made.push(result); } } }; addObjects(tileMapData?.layers); return made; } // the defaults of an object's type, a Color or Vector2 copied for each, then its properties in Tiled over them function objectLayersProperties(type, object) { const properties = {}; for (const [key, value] of Object.entries(type.defaults)) properties[key] = value?.copy ? value.copy() : value; for (const property of object.properties || []) { const {name, value} = property; if (property.type === 'color') value && (properties[name] = tileLayersColor(value)); // an empty color is unset else if (isVector2(type.defaults[name])) { // Tiled has no Vector2, one is the string x,y, and one it can not read keeps the default const parts = String(value).split(','), [x, y] = parts.map(Number); parts.length === 2 && parts.every((part)=> part.trim()) && isNumber(x) && isNumber(y) && (properties[name] = vec2(x, y)); } else properties[name] = value; } return properties; } // make one Tiled object from the type added for its name, undefined when there is no such type or it made nothing function objectLayersMake(tileMapData, object) { const name = object.type || object.class, type = objectLayersTypes.get(name); if (!type) { // an object with no type, a shape or a text someone drew in Tiled, is not one a game makes debug && name && console.warn(`objectLayersLoad: no type added for ${name}, skipped`); return; } const {height=0, tilewidth=1, tileheight=1} = tileMapData; const pos = vec2(object.x / tilewidth, height - object.y / tileheight); const {make} = type, result = make.prototype ? new make(pos) : make(pos); if (!result || typeof result !== 'object') return; for (const [key, value] of Object.entries(objectLayersProperties(type, object))) { // a property named as a method of the object, like update, would take the method's place, so it is left out if (typeof result[key] === 'function' && typeof value !== 'function') debug && console.warn(`objectLayersLoad: ${name} property ${key} has a method's name, left out`); else result[key] = value; } return result; } /////////////////////////////////////////////////////////////////////////////// /** * Tile layer data object stores info about how to draw a tile * @memberof TileLayers * @example * // create tile layer data with tile index 0 and random orientation and color * const tileIndex = 0; * const direction = randInt(4) * const mirror = randBool(); * const color = randColor(); * const data = new TileLayerData(tileIndex, direction, mirror, color); */ class TileLayerData { /** Create a tile layer data object, one for each tile in a TileLayer * @param {number} [tile] - The tile to use, from 0 like tile(); undefined is an empty cell that draws nothing * @param {number} [direction] - Integer direction of tile, in 90 degree increments * @param {boolean} [mirror] - If the tile is flipped left to right * @param {Color} [color] - Color of the tile */ constructor(tile, direction=0, mirror=false, color=new Color) { /** @property {number|undefined} - The tile to use, from 0 like tile(); undefined is an empty cell that draws nothing * @type {number|undefined} */ this.tile = tile; /** @property {number} - Integer direction of tile, in 90 degree increments */ this.direction = direction; /** @property {boolean} - If the tile is flipped left to right */ this.mirror = mirror; /** @property {Color} - Color of the tile */ this.color = color.copy(); } /** Set this tile to clear, it will not be rendered */ clear() { this.tile = undefined; this.direction = 0; this.mirror = false; this.color = new Color; } } /////////////////////////////////////////////////////////////////////////////// /** * Canvas Layer - cached off screen rendering system * - Contains an offscreen canvas that can be rendered to * - WebGL rendering is optional, call updateWebGL to enable/update * - A TileLayer using WebGL redraws into its texture and leaves this canvas blank, so drawing on its context * only shows on a layer made with useWebGL=false (or with WebGL off); use drawLayerTile/drawLayerRect inside * redrawStart/End for drawing that works both ways * @extends EngineObject * @memberof TileLayers * @example * const canvasLayer = new CanvasLayer(vec2(), vec2(200,100)); */ class CanvasLayer extends EngineObject { /** Create a canvas layer object * @param {Vector2} [pos] - World space position of the layer * @param {Vector2} [size] - World space size of the layer * @param {number} [angle] - Angle the layer is rotated by * @param {number} [renderOrder] - Objects sorted by renderOrder * @param {Vector2} [canvasSize] - Default size of canvas, can be changed later * @param {boolean} [useWebGL] - Should this layer use WebGL for rendering */ constructor(pos, size, angle=0, renderOrder=0, canvasSize=vec2(512), useWebGL=true) { ASSERT(isVector2(canvasSize), 'canvasSize must be a Vector2'); super(pos, size, undefined, angle, WHITE, renderOrder); /** @property {OffscreenCanvasRenderingContext2D} - The 2D canvas context used by this layer */ this.context = headlessMode ? undefined : createCanvasContext(canvasSize.x, canvasSize.y); /** @property {OffscreenCanvas} - The canvas used by this layer */ this.canvas = this.context?.canvas; /** @property {TextureInfo} - Texture info to use for this object rendering */ this.textureInfo = new TextureInfo(this.canvas, useWebGL); // a texture past the device's limit fails with only a WebGL warning and draws black, and phones often // allow 4096 where a desktop allows 16384, so say so in release builds too const maxSize = useWebGL && glContext ? glContext.getParameter(glContext.MAX_TEXTURE_SIZE) : 0; if (maxSize && max(canvasSize.x, canvasSize.y) > maxSize) console.warn(`LittleJS: a ${canvasSize.x}x${canvasSize.y} layer is over this device's ${maxSize} pixel texture limit and draws black, split it into smaller layers`); // disable physics by default this.mass = 0; } /** Destroy this canvas layer * @param {boolean} [immediate] - Remove it now, as EngineObject.destroy does, children included */ destroy(immediate=false) { if (this.destroyed) return; this.textureInfo.destroyWebGLTexture(); super.destroy(immediate); } // Render the layer, called automatically by the engine render() { this.draw(this.pos, this.size, this.color, this.angle, this.mirror, this.additiveColor, false); } /** Draw this canvas layer centered in world space * @param {Vector2} pos - Center in world space * @param {Vector2} [size] - Size in world space * @param {Color} [color] - Color to modulate with * @param {number} [angle] - Angle to rotate by * @param {boolean} [mirror] - If true the image is flipped left to right * @param {Color} [additiveColor] - Additive color to be applied if any * @param {boolean} [screenSpace=drawScreenSpace] - If true the pos and size are in screen space * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context] - Canvas 2D context to draw to */ draw(pos, size, color=WHITE, angle=0, mirror=false, additiveColor, screenSpace=drawScreenSpace, context) { // the canvas may have been resized since, updateWebGL only refreshes the size for WebGL const t = this.textureInfo, c = this.canvas; if (c && !this.hasWebGL() && (c.width !== t.size.x || c.height !== t.size.y)) { t.size = vec2(c.width, c.height); t.sizeInverse = vec2(1/c.width, 1/c.height); } // draw the canvas layer as a single tile that uses the whole texture const tileInfo = new TileInfo().setFullImage(t); const useWebGL = !context && this.hasWebGL(); // a context given is drawn to with Canvas2D drawTile(pos, size, tileInfo, color, angle, mirror, additiveColor, useWebGL, screenSpace, context); } /** Create WebGL texture if necessary and copy layer canvas to it */ updateWebGL() { this.textureInfo.createWebGLTexture(); } /** Check if this layer is using WebGL * @return {boolean} */ hasWebGL() { return glEnable && this.textureInfo.hasWebGL(); } } /////////////////////////////////////////////////////////////////////////////// // a layer's default tile, none when no image is loaded, so a collision only layer works in a game with no images function tileLayerDefaultTile() { return textureInfos[0]?.size.x ? tile() : undefined; } /** * Tile Layer - cached rendering system for tile layers * - Tiles are drawn once into a texture, a WebGL render target, or the layer's canvas when WebGL is off * or useWebGL is false, and the layer draws that as one image * - Some devices like mobile phones are limited to 4k textures, which with 16x16 tiles limits a layer to 256x256 * - Tile layers are centered on their corner, so normal levels are at (0,0) * @extends CanvasLayer * @memberof TileLayers * @example * const tileLayer = new TileLayer(vec2(), vec2(200,100)); */ class TileLayer extends CanvasLayer { /** Create a tile layer object * @param {Vector2} pos - World space position * @param {Vector2} size - World space size * @param {TileInfo} [tileInfo] - Default tile info for layer (used for size and texture), tile() by default, none when no image is loaded * @param {number} [renderOrder] - Objects are sorted by renderOrder * @param {boolean} [useWebGL] - Should this layer use WebGL for rendering */ constructor(pos, size, tileInfo=tileLayerDefaultTile(), renderOrder=0, useWebGL=true) { ASSERT(!tileInfo || tileInfo.size.x > 0 && tileInfo.size.y > 0, 'the tile has no size yet, a loadSprite tile is filled in once spritesReady resolves'); size = size.floor(); // whole cells, a fractional size would never finish filling the data const canvasSize = tileInfo ? size.multiply(tileInfo.size) : size; super(pos, size, 0, renderOrder, canvasSize, useWebGL); /** @property {TileInfo|undefined} - Default tile info for layer * @type {TileInfo|undefined} */ this.tileInfo = undefined; /** @property {Array} - Array of tile data for the layer */ this.data = []; /** @property {boolean} - Is this layer using a webgl texture? */ this.isUsingWebGL = false; // which side holds the whole layer, set by a full redraw, undefined before the first one; a partial redraw // or a render that finds WebGL turned on or off since then draws it all again on the side now in use this.tilesInWebGL = undefined; /** @property {boolean} - Show this layer's bounds and values when the debug overlay's Debug Tiles is on, * turn it off for layers that only add noise */ this.debugShow = true; if (tileInfo) { // set tile info this.tileInfo = tileInfo.frame(0); this.tileInfo.bleed = 0; // disable bleed for tile layers this.tileInfo.tiles = tileInfo.tiles; // a tile set's list, filled in as its images load } // init tile data for (let j = this.size.area(); j--;) this.data.push(new TileLayerData); if (headlessMode) { // disable rendering in headless mode this.render = ()=> {}; this.redraw = ()=> {}; this.redrawStart = ()=> {}; this.redrawEnd = ()=> {}; this.drawTileData = ()=> {}; this.redrawTileData = ()=> {}; this.drawLayerTile = ()=> {}; this.drawLayerRect = ()=> {}; this.drawTile = ()=> {}; this.drawRect = ()=> {}; this.clearLayerRect = ()=> {}; } } /** Set data at a given position in the array * @param {Vector2} layerPos - Local position in array * @param {TileLayerData} data - Data to set * @param {boolean} [redraw] - Force the tile to redraw if true */ setData(layerPos, data, redraw=false) { ASSERT(isVector2(layerPos), 'layerPos must be a Vector2'); ASSERT(data instanceof TileLayerData, 'data must be a TileLayerData'); layerPos = layerPos.floor(); if (!layerPos.arrayCheck(this.size)) return; this.data[(layerPos.y|0)*this.size.x + (layerPos.x|0)] = data; if (!redraw) return; const isRedraw = drawContext === this.context; isRedraw ? this.drawTileData(layerPos) : this.redrawTileData(layerPos); } /** Clear data at a given position in the array * @param {Vector2} layerPos - Local position in array * @param {boolean} [redraw] - Force the tile to redraw if true */ clearData(layerPos, redraw=false) { this.setData(layerPos, new TileLayerData, redraw) } /** Get data at a given position in the array * @param {Vector2} layerPos - Local position in array * @return {TileLayerData|undefined} */ getData(layerPos) { ASSERT(isVector2(layerPos), 'layerPos must be a Vector2'); return layerPos.arrayCheck(this.size) ? this.data[(layerPos.y|0)*this.size.x + (layerPos.x|0)] : undefined; } // Update the tile layer, a layer has no physics update() {} // Render the tile layer, called automatically by the engine render() { ASSERT(drawContext !== this.context, 'must call redrawEnd() after drawing tiles!'); // redraw here, not in update, which does not run while paused, if WebGL was turned off or lost, or came back this.redrawIfSwitched(); const size = this.drawSize || this.size; const pos = this.pos.add(size.scale(.5)); this.draw(pos, size, this.color, this.angle, this.mirror, this.additiveColor, false); } /** Called after this layer is redrawn, does nothing by default */ onRedraw() {} /** Draw all the tile data to an offscreen canvas * - This may be slow if not using webgl but only needs to be done once */ redraw() { // the camera, canvas and target are the game's again even when a tile draw or onRedraw throws this.redrawStart(true); try { for (let x = this.size.x; x--;) for (let y = this.size.y; y--;) this.drawTileData(vec2(x,y), false); this.isUsingWebGL && glFlush(); this.onRedraw(); } finally { this.redrawEnd(); } this.tilesInWebGL = this.isUsingWebGL; } // draw the whole layer again if the side that holds it is not the one in use now redrawIfSwitched() { if (this.tilesInWebGL !== undefined && this.hasWebGL() !== this.tilesInWebGL) this.redraw(); } /** Call to start the redraw process * - This can be used to manually update parts of the level * @param {boolean} [clear] - Should it clear the canvas before drawing */ redrawStart(clear=false) { if (!this.context) return; ASSERT(drawContext !== this.context, 'redrawStart: already started, call redrawEnd() first'); clear || this.redrawIfSwitched(); // a partial redraw goes on top of the whole layer on the side in use // save current render settings /** @type {[CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D, Vector2, Vector2, number, number, Color, Shader|undefined]} */ this.savedRenderSettings = [drawContext, mainCanvasSize, cameraPos, cameraScale, cameraAngle, canvasClearColor, glCustomShader]; setShader(); // the tiles are drawn plain, a layer's own Shader applies when the layer is drawn // a redraw from inside another target's pass, like the light system's shadow map, draws the tiles in color // and hands that target back after this.savedRenderTarget = [glRenderTarget, glColorMask, glColorAdditive, glSkipScreenSpace]; glColorMask = -1; glColorAdditive = 0; glSkipScreenSpace = false; // screen space is the layer's own pixels here // set the draw canvas and context to this layer // use camera settings to match this layer's canvas drawContext = this.context; const tileSize = this.tileInfo?.size ?? vec2(1); mainCanvasSize = this.size.multiply(tileSize); canvasClearColor = CLEAR_BLACK; cameraPos = this.size.multiply(tileSize).scale(.5); cameraScale = 1; cameraAngle = 0; // the tiles are drawn flat, the world camera turns the whole layer later // set render target to this layer this.isUsingWebGL = this.hasWebGL(); if (this.isUsingWebGL) glSetRenderTarget(this.textureInfo.glTexture, clear); else { if (clear) { // clear and set size this.canvas.width = mainCanvasSize.x; this.canvas.height = mainCanvasSize.y; } // disable smoothing for pixel art, after the resize which resets it this.context.imageSmoothingEnabled = !tilesPixelated; } } /** Call to end the redraw process */ redrawEnd() { if (!this.context) return; ASSERT(drawContext === this.context, 'redrawEnd: call redrawStart() first'); // set stuff back to normal, the camera first, so a target that was drawing before gets its own transform back [drawContext, mainCanvasSize, cameraPos, cameraScale, cameraAngle, canvasClearColor, glCustomShader] = this.savedRenderSettings; const [target, colorMask, colorAdditive, skipScreenSpace] = this.savedRenderTarget; if (this.isUsingWebGL) glSetRenderTarget(target); glColorMask = colorMask; glColorAdditive = colorAdditive; glSkipScreenSpace = skipScreenSpace; } /** Draw the tile at a given position in the tile layer * This can be used to clear out tiles when they are destroyed * Tiles can also be redrawn if inside a redrawStart/End block * @param {Vector2} layerPos * @param {boolean} [clear] - should the old tile be cleared out */ drawTileData(layerPos, clear=true) { if (!this.context) return; ASSERT(drawContext === this.context, 'must call redrawStart() before drawing tiles'); // clear out where the tile was, can be skipped for fully opaque tiles const cellPixels = this.tileInfo?.size ?? vec2(1); const drawPos = layerPos.multiply(cellPixels); clear && this.clearLayerRect(drawPos, cellPixels); // draw the tile if it has layer data, an empty cell has no tile and tile 0 is a tile like any other const d = this.getData(layerPos); if (!d || d.tile === undefined) return; // a tile set from loadTiles has each tile where it was packed, a tileset packed by loadSprite keeps its own // columns, counted from its first tile, not the sheet's grid const t = this.tileInfo, tileInfo = tileLayerTileInfo(t, d.tile); if (t && !tileInfo) return; // past the end of the sheet or the tile set, or loading this.drawLayerTile(drawPos, cellPixels, tileInfo, d.color, d.direction*PI/2, d.mirror); } /** Draw the tile at a given position in the tile layer * This can be used to clear tiles when they are destroyed * For better performance use drawTileData inside a redrawStart/End block * @param {Vector2} layerPos * @param {boolean} [clear] - should the old tile be cleared */ redrawTileData(layerPos, clear=true) { if (!this.context) return; ASSERT(drawContext !== this.context, 'redrawStart() should not be active when calling redrawTileData(), instead use drawTileData()'); this.redrawStart(); try { this.drawTileData(layerPos, clear); } finally { this.redrawEnd(); } } /** Draw textured tile in layer space * @param {Vector2} pos - Position in pixel coordinates * @param {Vector2} [size=vec2(1)] - Size of the tile * @param {TileInfo} [tileInfo] - Tile info to use, untextured if undefined * @param {Color} [color=WHITE] - Color to modulate with * @param {number} [angle] - Angle to rotate by * @param {boolean} [mirror] - Is the image flipped left to right? * @param {Color} [additiveColor] - Additive color to be applied if any */ drawLayerTile(pos, size=vec2(1), tileInfo, color=WHITE, angle=0, mirror, additiveColor) { const drawPos = pos.add(size.scale(.5)); drawTile(drawPos, size, tileInfo, color, angle, mirror, additiveColor, this.isUsingWebGL, false); } /** Draw a rectangle in layer space * @param {Vector2} pos * @param {Vector2} size * @param {Color} [color=WHITE] - Color to modulate with * @param {number} [angle] - Angle to rotate by */ drawLayerRect(pos, size, color, angle=0) { this.drawLayerTile(pos, size, undefined, color, angle); } /** Draw a tile onto the layer canvas in world space * @param {Vector2} pos * @param {Vector2} [size=vec2(1)] * @param {TileInfo} [tileInfo] * @param {Color} [color=WHITE] * @param {number} [angle] * @param {boolean} [mirror] */ drawTile(pos, size=vec2(1), tileInfo, color=new Color, angle=0, mirror=false) { const tileSize = this.tileInfo?.size ?? vec2(1); // a layer made without a tile info draws a pixel a cell pos = pos.subtract(this.pos).multiply(tileSize); size = size.multiply(tileSize); // a screen position is the center of a pixel, so the layer pixel coordinate moves back half a pixel pos.x -= .5; pos.y = this.canvas.height - pos.y - .5; // draw the tile onto the layer canvas // in color and handing back a target that was drawing before, like the light system's shadow map const oldMainCanvasSize = mainCanvasSize, oldTarget = glRenderTarget, oldColorMask = glColorMask; const oldSkip = glSkipScreenSpace, oldColorAdditive = glColorAdditive, oldShader = glCustomShader; mainCanvasSize = vec2(this.canvas.width, this.canvas.height); glColorMask = -1; glColorAdditive = 0; setShader(); // plain, as a redraw draws, a layer's own Shader applies when the layer is drawn glSkipScreenSpace = false; // its screen space is the layer's own canvas const useWebGL = this.hasWebGL(); useWebGL && glSetRenderTarget(this.textureInfo.glTexture); const drawContext = useWebGL ? undefined : this.context; drawTile(pos, size, tileInfo, color, angle, mirror, undefined, useWebGL, true, drawContext); mainCanvasSize = oldMainCanvasSize; useWebGL && glSetRenderTarget(oldTarget); glColorMask = oldColorMask; glColorAdditive = oldColorAdditive; glSkipScreenSpace = oldSkip; setShader(oldShader); } /** Draw a rectangle onto the layer canvas in world space * @param {Vector2} pos * @param {Vector2} [size=vec2(1)] * @param {Color} [color=WHITE] * @param {number} [angle] */ drawRect(pos, size, color, angle) { this.drawTile(pos, size, undefined, color, angle); } /** Clear a rectangle in layer space * @param {Vector2} pos - position in pixel coordinates * @param {Vector2} size */ clearLayerRect(pos, size) { ASSERT(drawContext === this.context, 'must call redrawStart() before clearing tiles'); const x = pos.x, y = this.canvas.height - pos.y - size.y; const useWebGL = this.hasWebGL(); if (useWebGL) glClearRect(x, y, size.x, size.y); else this.context.clearRect(x, y, size.x, size.y); } } /////////////////////////////////////////////////////////////////////////////// /** * Tile Collision Layer - a tile layer with collision * - adds collision data and functions to TileLayer * - there can be multiple tile collision layers * - its pos must be whole numbers, so its cells line up with the world grid objects land on * @extends TileLayer * @memberof TileLayers */ class TileCollisionLayer extends TileLayer { /** Create a tile layer object * @param {Vector2} pos - World space position * @param {Vector2} size - World space size * @param {TileInfo} [tileInfo] - Tile info for layer, tile() by default, none when no image is loaded * @param {number} [renderOrder] - Objects are sorted by renderOrder * @param {boolean} [useWebGL] - Should this layer use WebGL for rendering */ constructor(pos, size, tileInfo=tileLayerDefaultTile(), renderOrder=0, useWebGL=true) { super(pos, size.floor(), tileInfo, renderOrder, useWebGL); /** @property {Array} - The tile collision grid * @type {Array} */ this.collisionData = []; this.initCollision(this.size); // keep track of all collision layers tileCollisionLayers.push(this); /** @property {boolean} - Solid layers block objects and particles, the solidOnly tests skip the others */ this.isSolid = true; /** @property {boolean} - In the light system's shadow pass, cast only from the cells with collision, drawn * as the layer shows them, so a floor in the same layer stays lit; false casts every tile */ this.shadowSolidOnly = true; /** @property {Map} - The tiles set one way, by tile index, and the way each can be passed * through, see setOneWay * @type {Map} */ this.oneWayTiles = new Map; } /** Make the cells that draw a tile one way, like a platform jumped up through and landed on: an object, a * particle or a ray passes through moving that way, and the tile blocks only what was wholly on its far side * before it moved, the side it is passed toward; turned and mirrored as each cell's tile is, so a platform turned * a quarter is one way to the side * - A moving object is not stopped or asked through collideWithTile until the tile would block it, so a * collideWithTile that returns false lets it drop through * - tileCollisionTest with an object goes by the object's pos, a raycast by its start, and a test with no object * or with a callback finds every one way tile solid * @param {number} tile - The tile index, as tile() and TileLayerData take it * @param {Vector2} [direction] - Up, down, left or right, the way it can be passed through: vec2(0, 1), the * default, is a platform landed on from above; layer.oneWayTiles.delete(tile) makes the tile solid again * @example * layer.setOneWay(5); // tile 5 is a platform, jumped up through and stood on */ setOneWay(tile, direction=vec2(0, 1)) { ASSERT(isNumber(tile), 'setOneWay: tile is a tile index, a number', tile); ASSERT(isVector2(direction) && abs(direction.x) + abs(direction.y) === 1 && !(direction.x && direction.y), 'setOneWay: direction is up, down, left or right, like vec2(0, 1)', direction); this.oneWayTiles.set(tile, direction.copy()); } /** Draw this layer's shadow shape: the cells with collision in the part the shadow map covers, each row of * them in one draw from that part of the layer's texture, so see through pixels in a tile cast nothing; * every tile when shadowSolidOnly is off, or when the layer is turned or mirrored */ renderShadow() { if (!this.shadowSolidOnly || this.angle || this.mirror) return super.renderShadow(); // the cells in view, which in the shadow pass is the shadow map const size = this.size, drawSize = this.drawSize || size; const cellWorld = drawSize.divide(size), cellPixels = this.tileInfo ? this.tileInfo.size : vec2(1); const view = getCameraSize().scale(.5), low = cameraPos.subtract(view), high = cameraPos.add(view); const x0 = max(0, floor((low.x - this.pos.x) / cellWorld.x)), x1 = min(size.x, ceil((high.x - this.pos.x) / cellWorld.x)); const y0 = max(0, floor((low.y - this.pos.y) / cellWorld.y)), y1 = min(size.y, ceil((high.y - this.pos.y) / cellWorld.y)); const textureHeight = size.y * cellPixels.y, useWebGL = this.hasWebGL(); for (let y = y0; y < y1; ++y) for (let x = x0; x < x1; ++x) { if (!(this.collisionData[y*size.x + x] > 0)) continue; // solid is positive, a negative is a marker let end = x + 1; // a run of solid cells along the row while (end < x1 && this.collisionData[y*size.x + end] > 0) ++end; const count = end - x; const tileInfo = new TileInfo(vec2(x*cellPixels.x, textureHeight - (y+1)*cellPixels.y), vec2(count*cellPixels.x, cellPixels.y), this.textureInfo, 0, 0); const pos = vec2(this.pos.x + (x + count/2)*cellWorld.x, this.pos.y + (y + .5)*cellWorld.y); drawTile(pos, vec2(count*cellWorld.x, cellWorld.y), tileInfo, this.color, 0, false, undefined, useWebGL, false); x = end; } } /** Destroy this tile layer * @param {boolean} [immediate] - Remove it now, as EngineObject.destroy does, children included */ destroy(immediate=false) { if (this.destroyed) return; // remove from collision layers array and destroy const index = tileCollisionLayers.indexOf(this); ASSERT(index >= 0, 'tile collision layer not found in array'); index >= 0 && tileCollisionLayers.splice(index, 1); super.destroy(immediate); } /** Clear and initialize tile collision, the size is the layer's own, the tile data and canvas keep it * @param {Vector2} size - width and height of tile collision 2d grid */ initCollision(size) { ASSERT(isVector2(size), 'size must be a Vector2'); ASSERT(!this.collisionData.length || (size.x|0) === this.size.x && (size.y|0) === this.size.y, 'initCollision cannot resize a layer'); this.size = size.floor(); this.collisionData = new Array(this.size.area()).fill(0); } /** Set tile collision data for a given cell in the layer * @param {Vector2} layerPos * @param {number} [data] */ setCollisionData(layerPos, data=1) { ASSERT(isVector2(layerPos), 'layerPos must be a Vector2'); const i = (layerPos.y|0)*this.size.x + (layerPos.x|0); layerPos.arrayCheck(this.size) && (this.collisionData[i] = data); } /** Clear tile collision data for a given cell in the layer * @param {Vector2} layerPos */ clearCollisionData(layerPos) { this.setCollisionData(layerPos, 0); } /** Get tile collision data for a given cell in the layer * @param {Vector2} layerPos * @return {number} */ getCollisionData(layerPos) { ASSERT(isVector2(layerPos), 'layerPos must be a Vector2'); const i = (layerPos.y|0)*this.size.x + (layerPos.x|0); return layerPos.arrayCheck(this.size) ? this.collisionData[i] : 0; } /** Check if collision with another object should occur * @param {Vector2} pos * @param {Vector2} [size=vec2()] * @param {EngineObject|TileCollisionCallback} [callbackObject] - Callback, engine object, or undefined * @return {boolean} */ collisionTest(pos, size=new Vector2, callbackObject) { ASSERT(isVector2(pos) && isVector2(size), 'pos and size must be Vector2s'); tileCollisionAssertWhole(this); const collisionTest = tileCollisionTester(callbackObject); // a one way tile goes by where an object was before the physics moved it, or where it is now const object = callbackObject instanceof EngineObject && this.oneWayTiles.size ? callbackObject : undefined; const from = object && (object === tileCollisionFromObject ? tileCollisionFromPos : object.pos); // check any tiles in the area for collision const posX = pos.x - this.pos.x; const posY = pos.y - this.pos.y; // reject AABBs entirely past either edge; without this, the negative // side leaks into row/col 0 because minX/minY clamp to 0 and the // point-test floor below forces maxX/maxY up to 1 if (posX + size.x/2 < 0 || posX - size.x/2 > this.size.x) return false; if (posY + size.y/2 < 0 || posY - size.y/2 > this.size.y) return false; const minX = max(posX - size.x/2|0, 0); const minY = max(posY - size.y/2|0, 0); // a zero size is a point test, one cell even when pos lands exactly on an integer boundary const maxX = min(size.x ? posX + size.x/2 : minX + 1, this.size.x); const maxY = min(size.y ? posY + size.y/2 : minY + 1, this.size.y); for (let y = minY; y < maxY; ++y) for (let x = minX; x < maxX; ++x) { // check if the object should collide with this tile, the callback gets its own vector, one it can keep const tileData = this.collisionData[y*this.size.x+x]; if (tileData && !(from && tileCollisionOneWayPass(this, x, y, from.x, from.y, size.x, size.y)) && collisionTest(tileData, vec2(x+this.pos.x, y+this.pos.y))) return true; } return false; } /** Return the exact position of the boundary of first tile hit, undefined if nothing was hit. * The point will be inside the colliding tile if it hits (may have a tiny shift) * @param {Vector2} posStart * @param {Vector2} posEnd * @param {EngineObject|TileCollisionCallback} [callbackObject] - Callback, engine object, or undefined * @param {Vector2} [normal] - Optional normal of the surface hit * @return {Vector2|undefined} */ collisionRaycast(posStart, posEnd, callbackObject, normal) { ASSERT(isVector2(posStart) && isVector2(posEnd), 'positions must be Vector2s'); tileCollisionAssertWhole(this); const collisionTest = tileCollisionTester(callbackObject); // the line is walked in the layer's own space, so its cells are the tiles wherever the layer sits const offset = this.pos; // a one way tile blocks a ray that starts on its far side const testFunction = (pos)=> { const tileData = this.getCollisionData(pos); return tileData && !tileCollisionOneWayPass(this, pos.x|0, pos.y|0, posStart.x, posStart.y) && collisionTest(tileData, vec2(pos.x + offset.x, pos.y + offset.y)); } // only the part of the line over the layer, and a cell around it, can meet a tile, so a ray toward a point // far beyond is walked across the layer and no farther; the cell around keeps the step into the layer as it was const a = posStart.subtract(offset), d = posEnd.subtract(posStart); let t0 = 0, t1 = 1; for (const [p, q, high] of [[a.x, d.x, this.size.x + 1], [a.y, d.y, this.size.y + 1]]) { if (!q) { if (p < -1 || p > high) return; continue; } const u = (-1 - p) / q, v = (high - p) / q; t0 = max(t0, min(u, v)), t1 = min(t1, max(u, v)); } if (t0 > t1) return; const hitPos = lineTest(t0 ? a.add(d.scale(t0)) : a, t1 < 1 ? a.add(d.scale(t1)) : posEnd.subtract(offset), testFunction, normal); if (hitPos) { // into the world, kept inside its tile there too, as adding the layer's place can round it onto the edge const cell = hitPos.floor(); hitPos.x += offset.x, hitPos.y += offset.y; const inside = (v, low)=> v < low ? low : v < low + 1 ? v : low + 1 - max(1e-9, abs(low) * 1e-15); hitPos.x = inside(hitPos.x, cell.x + offset.x); hitPos.y = inside(hitPos.y, cell.y + offset.y); } if (debugRaycast && hitPos) { const tilePos = hitPos.floor().add(vec2(.5)); debugRect(tilePos, vec2(1), '#f008', 0, 0, false, false); debugLine(posStart, posEnd, '#00f', .02, 0, false); debugLine(posStart, hitPos, '#f00', .02, 0, false); debugPoint(hitPos, '#0f0', undefined, undefined, false); normal && debugLine(hitPos, hitPos.add(normal), '#ff0', .02, 0, false); } return hitPos; } } /** * LittleJS Particle System * - Fast and flexible particle effects system * - ParticleEmitter spawns and manages lightweight Particle objects * - Particles support color gradients, fading, rotation, and scaling * - Physics simulation with velocity, gravity, and damping * - Collision detection with tile layers * - Additive blending for glowing effects * - Cone-based emission with randomization * - Particle design tool available for easy emitter creation * @namespace Particles */ /** * @callback ParticleCallback - Function that processes a particle * @param {Particle} particle * @memberof Particles */ /** * @callback ParticleCollideCallback - Decides whether a particle stops at a tile, it is a filter rather than a notice * @param {Particle} particle * @param {number} tileData * @param {Vector2} pos * @return {boolean} - true to stop the particle there; a callback that returns nothing lets it pass through * @memberof Particles */ /** * Particle Emitter - Spawns particles with the given settings * @extends EngineObject * @memberof Particles * @example * // create a particle emitter * let pos = vec2(2,3); * let particleEmitter = new ParticleEmitter * ( * pos, 0, 1, 0, 500, PI, // pos, angle, emitSize, emitTime, emitRate, emitConeAngle * tile(0, 16), // tileInfo * rgb(1,1,1,1), rgb(0,0,0,1), // colorStartA, colorStartB * rgb(1,1,1,0), rgb(0,0,0,0), // colorEndA, colorEndB * 1, .2, .2, .1, .05, // particleTime, sizeStart, sizeEnd, speed, angleSpeed * .99, 1, 1, PI, .05, // damping, angleDamping, gravityScale, particleConeAngle, fadeRate * .5, true // randomness, collideLevel * ); */ class ParticleEmitter extends EngineObject { /** Create a particle system with the given settings * @param {Vector2} pos - World space position of the emitter * @param {number} [angle] - Angle to emit the particles * @param {number|Vector2} [emitSize] - World space size of the emitter (float for circle diameter, vec2 for rect) * @param {number} [emitTime] - How long to stay alive (0 is forever) * @param {number} [emitRate] - How many particles per second to spawn, does not emit if 0 * @param {number} [emitConeAngle=PI] - Half angle of the cone around the emitter's angle that particles move along, PI is every direction * @param {TileInfo} [tileInfo] - Tile info to render particles (undefined is untextured) * @param {Color} [colorStartA=WHITE] - Color at start of life 1, randomized between start colors * @param {Color} [colorStartB=WHITE] - Color at start of life 2, randomized between start colors * @param {Color} [colorEndA=CLEAR_WHITE] - Color at end of life 1, randomized between end colors * @param {Color} [colorEndB=CLEAR_WHITE] - Color at end of life 2, randomized between end colors * @param {number} [particleTime] - How long particles live * @param {number} [sizeStart] - How big are particles at start * @param {number} [sizeEnd] - How big are particles at end * @param {number} [speed] - How fast are particles when spawned, in world units per frame (at 60fps, so multiply units/sec by 1/60) * @param {number} [angleSpeed] - How fast are particles rotating, in radians per frame (at 60fps) * @param {number} [damping] - How much to dampen particle speed, per-frame velocity multiplier (1 = no damping, .9 = lose 10% speed each frame) * @param {number} [angleDamping] - How much to dampen particle angular speed, per-frame multiplier (1 = no damping) * @param {number} [gravityScale] - How much gravity affects particles * @param {number} [particleConeAngle] - Half angle each side of the emitter's angle for a particle's start angle, PI is any angle * @param {number} [fadeRate] - Fraction of life spent fading: half at fade-in (start), half at fade-out (end). e.g. .2 = 10% fade-in, 80% full opacity, 10% fade-out * @param {number} [randomness] - Apply extra randomness percent * @param {boolean} [collideLevel] - Do particles collide with the level's tiles, world space emitters only * @param {boolean} [additive] - Should particles use additive blend * @param {boolean} [randomColorLinear] - Should color be randomized linearly or across each component * @param {number} [renderOrder] - Render order for particles (additive is above other stuff by default) * @param {boolean} [localSpace] - Should it be in local space of emitter (world space is default) */ constructor ( pos, angle, emitSize = 0, emitTime = 0, emitRate = 100, emitConeAngle = PI, tileInfo, colorStartA = WHITE, colorStartB = WHITE, colorEndA = CLEAR_WHITE, colorEndB = CLEAR_WHITE, particleTime = .5, sizeStart = .1, sizeEnd = 1, speed = .1, angleSpeed = .05, damping = 1, angleDamping = 1, gravityScale = 0, particleConeAngle = PI, fadeRate = .1, randomness = .2, collideLevel = false, additive = false, randomColorLinear = true, renderOrder = additive ? 1e9 : 0, localSpace = false ) { super(pos, vec2(), tileInfo, angle, undefined, renderOrder); if (debug) { // the arguments come in a long row, so one out of place is named rather than emitting NaN particles ASSERT(typeof emitSize === 'number' || isVector2(emitSize), 'ParticleEmitter: emitSize must be a number or vec2', emitSize); ASSERT(!tileInfo || tileInfo instanceof TileInfo, 'ParticleEmitter: tileInfo must be a TileInfo or undefined', tileInfo); const numbers = {emitTime, emitRate, emitConeAngle, particleTime, sizeStart, sizeEnd, speed, angleSpeed, damping, angleDamping, gravityScale, particleConeAngle, fadeRate, randomness}; // plain strings, as a message is built at every emitter made in a debug build for (const name in numbers) ASSERT(isNumber(numbers[name]), 'ParticleEmitter: an argument is not a number, they may be out of order; its name and value:', name, numbers[name]); const colors = {colorStartA, colorStartB, colorEndA, colorEndB}; for (const name in colors) ASSERT(isColor(colors[name]), 'ParticleEmitter: an argument is not a color, they may be out of order; its name and value:', name, colors[name]); } // emitter settings /** @property {boolean} - Should particles be emitted in a circle */ this.emitCircle = typeof emitSize === 'number'; /** @property {Vector2} - World space size of the emitter, x is the diameter when emitCircle is set */ this.emitSize = typeof emitSize === 'number' ? vec2(emitSize) : emitSize.copy(); /** @property {number} - How long to emit for in seconds, 0 is forever; raised while its particles are still * alive, it emits again for the added time */ this.emitTime = emitTime; /** @property {number} - How many particles per second to spawn, does not emit if 0 */ this.emitRate = emitRate; /** @property {number} - Half angle of the cone around the emitter's angle that particles move along, PI is every direction */ this.emitConeAngle = emitConeAngle; // color settings /** @property {Color} - Color at start of life 1, randomized between start colors */ this.colorStartA = colorStartA.copy(); /** @property {Color} - Color at start of life 2, randomized between start colors */ this.colorStartB = colorStartB.copy(); /** @property {Color} - Color at end of life 1, randomized between end colors */ this.colorEndA = colorEndA.copy(); /** @property {Color} - Color at end of life 2, randomized between end colors */ this.colorEndB = colorEndB.copy(); /** @property {boolean} - Should color be randomized linearly or across each component */ this.randomColorLinear = randomColorLinear; // particle settings /** @property {number} - How long particles live */ this.particleTime = particleTime; /** @property {number} - How big are particles at start */ this.sizeStart = sizeStart; /** @property {number} - How big are particles at end */ this.sizeEnd = sizeEnd; /** @property {number} - Particle speed when spawned, in world units per frame (at 60fps) */ this.speed = speed; /** @property {number} - Particle angular speed when spawned, in radians per frame (at 60fps) */ this.angleSpeed = angleSpeed; /** @property {number} - Per-frame velocity multiplier (1 = no damping, .9 = lose 10% speed each frame) */ this.damping = damping; /** @property {number} - Per-frame angular velocity multiplier (1 = no damping) */ this.angleDamping = angleDamping; /** @property {number} - How much gravity affects particles */ this.gravityScale = gravityScale; /** @property {number} - Half angle each side of the emitter's angle for a particle's start angle, PI is any angle */ this.particleConeAngle = particleConeAngle; /** @property {number} - Fraction of life spent fading, split half at start and half at end (e.g. .2 = 10% fade-in + 10% fade-out) */ this.fadeRate = fadeRate; /** @property {number} - Apply extra randomness percent */ this.randomness = randomness; /** @property {boolean} - Do particles collide with the level's tiles */ this.collideLevel = collideLevel; /** @property {boolean} - Should particles use additive blend */ this.additive = additive; /** @property {boolean} - Should it be in local space of emitter */ this.localSpace = localSpace; /** @property {number} - If non zero the particle is drawn as a trail, stretched in the direction of velocity */ this.trailScale = 0; /** @property {number} - Grows the whole effect as each particle is born: the spawn area, the particles' sizes, * speed and fall, so it looks the same only bigger; 1 is as made, and a particle keeps the scale it was born * with, so a change never moves the ones already out */ this.scale = 1; /** @property {number} - The effect's own fall, added to each particle's velocity y every frame, on top of the * world's gravity times gravityScale; an effect that sets its own looks the same in any game */ this.gravity = 0; /** @property {ParticleCallback|undefined} - Callback when particle is created * @type {ParticleCallback|undefined} */ this.particleCreateCallback = undefined; /** @property {ParticleCallback|undefined} - Callback when particle is destroyed * @type {ParticleCallback|undefined} */ this.particleDestroyCallback = undefined; /** @property {ParticleCollideCallback|undefined} - Callback when particle collides * @type {ParticleCollideCallback|undefined} */ this.particleCollideCallback = undefined; /** @property {ParticleCallback|undefined} - Callback each time a particle updates, after it moves * @type {ParticleCallback|undefined} */ this.particleUpdateCallback = undefined; /** @property {(function(ParticleEmitter): void)|undefined} - Called once with the emitter when it finishes: * done emitting and its last particle gone, or destroyed with none left; not when destroyed immediately, * as engineObjectsDestroy does, which takes its particles with no particleDestroyCallback either * @type {(function(ParticleEmitter): void)|undefined} */ this.finishCallback = undefined; /** @property {number} - Percentage of velocity to pass to particles (0-1) */ this.velocityInheritance = 0; /** @property {number} - Particles owed to the emit rate, starts at one so the first comes out at once */ this.emitTimeBuffer = 1; /** @property {number} - Seconds of its emit time it has emitted for, counted by its updates * @type {number} */ this.emitElapsed = 0; /** @property {Array} - Array of particles for this emitter * @type {Array} */ this.particles = []; // track previous position and angle, set on the first update once a parent has placed the emitter /** @type {Vector2|undefined} */ this.previousPos = undefined; this.previousAngle = this.angle; // where it was last update, and how far back toward it the particle being made starts, 0 to 1: a moving // emitter spreads an update's particles along its move, where they came out in a clump where it is this.emitFrom = vec2(); this.emitBehind = 0; } /** Update the emitter to spawn particles, called automatically by engine once each frame */ update() { // physics sanity checks ASSERT(this.angleDamping >= 0 && this.angleDamping <= 1, 'angleDamping must be 0 to 1, the fraction kept each frame'); ASSERT(this.damping >= 0 && this.damping <= 1, 'damping must be 0 to 1, the fraction of velocity kept each frame'); if (!this.previousPos) { // first update, addChild moves an emitter after it is made this.previousPos = this.pos.copy(); this.previousAngle = this.angle; } if (this.velocityInheritance) { // pass emitter velocity to particles const p = this.velocityInheritance; this.velocity.x = p * (this.pos.x - this.previousPos.x); this.velocity.y = p * (this.pos.y - this.previousPos.y); this.angleVelocity = p * (this.angle - this.previousAngle); } // tracked even while velocityInheritance is off, so turning it on does not jump this.previousAngle = this.angle; this.emitFrom.x = this.previousPos.x; this.emitFrom.y = this.previousPos.y; this.previousPos.x = this.pos.x; this.previousPos.y = this.pos.y; // update emitter if (this.isActive()) { // emit particles for the part of this update inside the emit time, counted by its own updates rather than // the clock, so a one shot gives the same count whichever frame it is made on const rate = this.emitRate * particleEmitRateScale; const step = this.emitTime ? min(timeDelta, this.emitTime - this.emitElapsed) : timeDelta; this.emitElapsed += timeDelta; if (rate > 0 && rate < Infinity) { // counted in particles, so a new rate applies at once; the update that ends the emit time adds a // hair, as the steps' sum comes out a rounding under it and lost the last particle this.emitTimeBuffer += rate * step + (this.emitTime && this.emitElapsed >= this.emitTime ? 1e-6 : 0); // spread along the move since the last update, the last one where it is now; a jump further than // anything travels in an update, twice objectMaxSpeed, a teleport or a respawn, spreads nothing const travelled = this.emitFrom && this.pos.distance(this.emitFrom) <= 2 * objectMaxSpeed; const count = floor(this.emitTimeBuffer); try { for (let i = count; i--; --this.emitTimeBuffer) { this.emitBehind = travelled ? i / count : 0; this.emitParticle(); } } finally { this.emitBehind = 0; } // a create callback that throws leaves no offset for the next } } else if (this.particles.length === 0) { this.destroy(true); this.finishCallback?.(this); // after, so one that throws is not called again } // a local space particle is placed relative to the emitter, but the tile collision is in the world ASSERT(!this.localSpace || !this.collideLevel, 'local space particles cannot collide with the level, turn one of them off'); // update and remove destroyed particles in place to avoid per-frame array allocation const particles = this.particles; let alive = 0; for (let i = 0; i < particles.length; ++i) { const p = particles[i]; p.update(); if (!p.destroyed) particles[alive++] = p; } particles.length = alive; if (debugParticles) { // show emitter bounds if (this.emitCircle) debugCircle(this.pos, this.emitSize.x * this.scale, '#0f0', 0, false, false); else debugRect(this.pos, this.emitSize.scale(this.scale), '#0f0', 0, this.angle, false, false); } } /** Spawn one particle * @return {Particle} */ emitParticle() { // spawn a particle const scale = this.scale; // grows the spawn area, the sizes and the speed of each particle born let pos = this.emitCircle ? // check if circle emitter randInCircle(this.emitSize.x/2 * scale) // circle emitter : vec2(rand(-.5,.5) * this.emitSize.x * scale, // box emitter, one vector made rand(-.5,.5) * this.emitSize.y * scale); let angle = rand(this.particleConeAngle, -this.particleConeAngle); if (!this.localSpace) { // into the world: a local space particle is turned with the emitter when it draws instead this.emitCircle || (pos = pos.rotate(this.angle)); const behind = this.emitBehind; // back along its move, as the update spreads them pos.x += this.pos.x + (this.emitFrom.x - this.pos.x) * behind; pos.y += this.pos.y + (this.emitFrom.y - this.pos.y) * behind; angle += this.angle; } // randomness scales each parameter by a percentage const randomness = this.randomness; const randomizeScale = (v)=> v + v*rand(randomness, -randomness); // randomize particle settings const particleTime = randomizeScale(this.particleTime); const sizeStart = randomizeScale(this.sizeStart) * scale; const sizeEnd = randomizeScale(this.sizeEnd) * scale; const speed = randomizeScale(this.speed) * scale; const angleSpeed = randomizeScale(this.angleSpeed) * randSign(); const coneAngle = rand(this.emitConeAngle, -this.emitConeAngle); const colorStart = randColor(this.colorStartA, this.colorStartB, this.randomColorLinear); const colorEnd = randColor(this.colorEndA, this.colorEndB, this.randomColorLinear); const velocityAngle = this.localSpace ? coneAngle : this.angle + coneAngle; // build particle const velocity = vec2(speed*sin(velocityAngle), speed*cos(velocityAngle)); let angleVelocity = angleSpeed; if (!this.localSpace && this.velocityInheritance > 0) { // apply emitter velocity to particle velocity.x += this.velocity.x; velocity.y += this.velocity.y; angleVelocity += this.angleVelocity; } const particle = new Particle(this, pos, angle, colorStart, colorEnd, particleTime, sizeStart, sizeEnd, velocity, angleVelocity); this.particles.push(particle); // call particle create callback this.particleCreateCallback?.(particle); // return the newly created particle return particle; } /** Particle emitters do not have physics */ updatePhysics() {} /** Render all particles for this emitter */ render() { // render all particles, the blend switched once for them all, not around each one // and the tile's draw worked out once for them all, fresh each frame so a tile changed in place draws changed this.additive && setAdditiveBlendMode(); particleTile = null; // none yet, undefined is the untextured tile particleTileBatch = true; for (const particle of this.particles) particle.render(); particleTileBatch = false; this.additive && setAdditiveBlendMode(false); } /** is emitter actively spawning * @return {boolean} */ isActive() { return !this.emitTime || this.emitElapsed < this.emitTime; } /** Destroy the particle emitter * @param {boolean} [immediate] - true removes it and its particles at once, with no particleDestroyCallback for * them, false stops emitting and lets the particles finish first */ destroy(immediate=false) { if (this.destroyed) return; super.destroy(immediate); if (!immediate && this.particles.length > 0) { // wait for particles to die off this.destroyed = false; this.emitTime = -1; } else if (!immediate) this.finishCallback?.(this); // none left, it finishes now } } /////////////////////////////////////////////////////////////////////////////// // scratch vectors reused by Particle.render and the tile collision to avoid per-frame allocations const particleDrawPos = new Vector2, particleDrawSize = new Vector2, particleCollidePos = new Vector2; // the WebGL draw of the tile particles draw with, what drawTile works out for each sprite: kept while an emitter // renders its particles, so each one only writes its own values into the batch let particleTile, particleTileBatch = false, particleTileTextured = false, particleTileEmpty = false; let particleTileU0 = 0, particleTileV0 = 0, particleTileU1 = 0, particleTileV1 = 0; function particleTileSet(tileInfo) { particleTile = tileInfo; const textureInfo = tileInfo?.textureInfo; particleTileTextured = !!textureInfo; particleTileEmpty = !!textureInfo && !(tileInfo.size.x && tileInfo.size.y); // like a sprite still loading if (!textureInfo || particleTileEmpty) return; ASSERT(!!textureInfo.glTexture, 'texture has no WebGL texture, draw it with useWebGL false'); glSetTexture(textureInfo.glTexture); // bound, and its mipmaps brought up to date, once for the emitter const sizeInverse = textureInfo.sizeInverse, bleed = tileInfo.bleed; const x = tileInfo.pos.x * sizeInverse.x; const y = tileInfo.pos.y * sizeInverse.y; const w = tileInfo.size.x * sizeInverse.x; const h = tileInfo.size.y * sizeInverse.y; if (bleed) { const bleedX = sizeInverse.x*bleed; const bleedY = sizeInverse.y*bleed; particleTileU0 = x + bleedX; particleTileV0 = y + bleedY; particleTileU1 = x - bleedX + w; particleTileV1 = y - bleedY + h; } else { particleTileU0 = x; particleTileV0 = y; particleTileU1 = x + w; particleTileV1 = y + h; } } // where the particle being tested was before its move, which one way tiles go by let particleCollideFromX = 0, particleCollideFromY = 0; // tests if a particle collides with tiles at x, y, through its emitter's collide callback if it has one function particleCollideTest(particle, collideCallback, x, y) { const data = tileCollisionGetDataFrom(particleCollidePos.set(x, y), particleCollideFromX, particleCollideFromY); if (!collideCallback) return data > 0; // the callback gets its own vector, made only when there is a tile to decide on return !!data && !!collideCallback(particle, data, vec2(x, y)); } /** * Particle Object - Created automatically by Particle Emitters * @memberof Particles */ class Particle { /** * Create a particle with the passed in settings * Typically this is created automatically by a ParticleEmitter * @param {ParticleEmitter} emitter - The emitter that created this particle * @param {Vector2} pos - World or local space position * @param {number} angle - Angle of the particle * @param {Color} colorStart - Color at start of life * @param {Color} colorEnd - Color at end of life * @param {number} lifeTime - How long to live for * @param {number} sizeStart - Size at start of life * @param {number} sizeEnd - Size at end of life * @param {Vector2} [velocity] - Velocity of the particle * @param {number} [angleVelocity] - Angular speed of the particle */ constructor(emitter, pos, angle, colorStart, colorEnd, lifeTime, sizeStart, sizeEnd, velocity = vec2(), angleVelocity = 0) { /** @property {ParticleEmitter} - The emitter this particle came from */ this.emitter = emitter; /** @property {number} - The emitter's scale when it was made, which grows its fall */ this.scale = emitter.scale ?? 1; // a hand made emitter-like object may have none /** @property {Vector2} - Position, world space or local to the emitter when localSpace is set */ this.pos = pos; /** @property {number} - Angle in radians */ this.angle = angle; /** @property {Vector2} - Current size, updated as it renders */ this.size = vec2(sizeStart); /** @property {Color} - Current color, updated as it renders */ this.color = colorStart.copy(); /** @property {Color} - Color at start of life */ this.colorStart = colorStart; /** @property {Color} - Color at end of life */ this.colorEnd = colorEnd; /** @property {number} - How long this particle lives for */ this.lifeTime = lifeTime; /** @property {number} - Size at start of life */ this.sizeStart = sizeStart; /** @property {number} - Size at end of life */ this.sizeEnd = sizeEnd; /** @property {Vector2} - Velocity in world units per frame (at 60fps) */ this.velocity = velocity; /** @property {number} - Angular speed in radians per frame (at 60fps) */ this.angleVelocity = angleVelocity; /** @property {number} - Engine time it was made at */ this.spawnTime = time; /** @property {boolean} - If true the tile is flipped along the x axis */ this.mirror = randBool(); /** @property {EngineObject|undefined} - Tile layer it last landed on, undefined in the air * @type {EngineObject|undefined} */ this.groundObject = undefined; /** @property {boolean} - Has this particle been destroyed */ this.destroyed = false; /** @property {TileInfo|undefined} - The emitter's tile, undefined for an untextured one * @type {TileInfo|undefined} */ this.tileInfo = emitter.tileInfo; } /** Update the particle */ update() { if (this.destroyed) return; // gone already, destroyed by the game this frame // emitter properties const emitter = this.emitter; const damping = emitter.damping; const angleDamping = emitter.angleDamping; const restitution = emitter.restitution; const friction = emitter.friction; const gravityScale = emitter.gravityScale; const collideLevel = emitter.collideLevel; const collideCallback = emitter.particleCollideCallback; const updateCallback = emitter.particleUpdateCallback; // destroy particle when its time runs out if (this.lifeTime <= 0 || time - this.spawnTime > this.lifeTime) // no lifetime is gone at once, not never { this.destroy(); return; } // apply physics; only the tile collision needs where the particle was const solve = enablePhysicsSolver && collideLevel; const oldX = this.pos.x, oldY = this.pos.y; // the world's gravity times gravityScale plus the emitter's own, grown by the particle's scale const fall = this.scale; let gravityX = gravity.x * gravityScale * fall; let gravityY = (gravity.y * gravityScale + (emitter.gravity || 0)) * fall; if (emitter.localSpace && emitter.angle) { // world gravity turned into the emitter's space, the render turns it back const c = cos(emitter.angle), s = sin(emitter.angle); [gravityX, gravityY] = [gravityX*c - gravityY*s, gravityX*s + gravityY*c]; } this.velocity.x = this.velocity.x * damping + gravityX; this.velocity.y = this.velocity.y * damping + gravityY; if (solve) { // apply max circular speed to prevent going through collision, before the move it protects const length2 = this.velocity.lengthSquared(); if (length2 > objectMaxSpeed*objectMaxSpeed) { const s = objectMaxSpeed / length2**.5; this.velocity.x *= s; this.velocity.y *= s; } } this.pos.x += this.velocity.x; this.pos.y += this.velocity.y; this.angle += this.angleVelocity *= angleDamping; // don't do collision if solver disabled if (!solve) { updateCallback?.(this); return; } // check collision against tiles this.groundObject = undefined; particleCollideFromX = oldX, particleCollideFromY = oldY; if (particleCollideTest(this, collideCallback, this.pos.x, this.pos.y)) { // the layer that stopped it, as the test found it, a one way layer it passes is not; if already was stuck // in collision, don't do anything const hitLayer = tileCollisionDataLayer; if (!particleCollideTest(this, collideCallback, oldX, oldY)) { // test which side we bounced off (or both if a corner) const isBlockedX = particleCollideTest(this, collideCallback, this.pos.x, oldY); const isBlockedY = particleCollideTest(this, collideCallback, oldX, this.pos.y); // collide callback may hit where the layer test does not, so hitLayer can be undefined const hitRestitution = hitLayer ? max(restitution, hitLayer.restitution) : restitution; const hitFriction = hitLayer ? max(friction, hitLayer.friction) : friction; if (isBlockedX) { // move to previous X position and bounce this.pos.x = oldX; this.velocity.x *= -hitRestitution; this.velocity.y *= hitFriction; } if (isBlockedY || !isBlockedX) { // down is the way it falls, the world's gravity by its gravityScale and the emitter's own const down = gravityY; const wasFalling = this.velocity.y < 0 && down < 0 || this.velocity.y > 0 && down > 0; if (wasFalling) this.groundObject = hitLayer; // move to previous Y position and bounce this.pos.y = oldY; this.velocity.y *= -hitRestitution; this.velocity.x *= hitFriction; } debugPhysics && debugRect(this.pos, this.size, '#f00', 0, 0, false, false); } } // a collide callback may have destroyed it this.destroyed || updateCallback?.(this); } /** Destroy this particle, once: a second call does nothing */ destroy() { if (this.destroyed) return; const destroyCallback = this.emitter.particleDestroyCallback; const c = this.colorEnd; this.color.set(c.r, c.g, c.b, c.a); this.size.set(this.sizeEnd, this.sizeEnd); this.destroyed = true; destroyCallback?.(this); } /** Render the particle, automatically called each frame */ render() { // emitter properties const emitter = this.emitter; const localSpace = emitter.localSpace; const additive = emitter.additive && !glAdditive; // switched here only for a particle drawn on its own const trailScale = emitter.trailScale; const fadeRate = emitter.fadeRate / 2; // lerp color and size const p1 = this.lifeTime > 0 ? min((time - this.spawnTime) / this.lifeTime, 1) : 1, p2 = 1-p1; const sizeNow = p2 * this.sizeStart + p1 * this.sizeEnd; this.size.set(sizeNow, sizeNow); // kept current for callbacks, drawn from the scratch since a trail stretches it const size = particleDrawSize.set(sizeNow, sizeNow); const alphaFade = p1 < fadeRate ? p1/fadeRate : p1 > 1-fadeRate ? (1-p1)/fadeRate : 1; this.color.r = p2 * this.colorStart.r + p1 * this.colorEnd.r; this.color.g = p2 * this.colorStart.g + p1 * this.colorEnd.g; this.color.b = p2 * this.colorStart.b + p1 * this.colorEnd.b; this.color.a = (p2 * this.colorStart.a + p1 * this.colorEnd.a) * alphaFade; // update the position and angle for drawing const pos = particleDrawPos.set(this.pos.x, this.pos.y); let angle = this.angle; if (localSpace) { // in local space of emitter const a = emitter.angle; const c = cos(-a), s = sin(-a); pos.set(emitter.pos.x + pos.x*c - pos.y*s, emitter.pos.y + pos.x*s + pos.y*c); angle += a; } // draw the particle additive && setAdditiveBlendMode(); if (trailScale) { // trail style particles stretch in the direction of motion, and draw as they are at rest const velocity = localSpace ? this.velocity.rotate(emitter.angle) : this.velocity; const speed = velocity.length(); if (speed) { size.y = max(size.x, speed * trailScale); angle = atan2(velocity.x, velocity.y); } } if (glEnable && !headlessMode) { // straight into the batch, the tile's draw worked out once for the emitter's particles const tileInfo = this.tileInfo; if (!particleTileBatch || tileInfo !== particleTile) particleTileSet(tileInfo); if (!particleTileTextured) glDrawUntextured(pos.x, pos.y, size.x, size.y, angle, this.color.rgbaInt()); else if (!particleTileEmpty) { const texture = tileInfo.textureInfo.glTexture; glActiveTexture === texture || glSetTexture(texture); // bound again only if a draw between changed it glDraw(pos.x, pos.y, this.mirror ? -size.x : size.x, size.y, angle, particleTileU0, particleTileV0, particleTileU1, particleTileV1, this.color.rgbaInt()); } } else drawTile(pos, size, this.tileInfo, this.color, angle, this.mirror, undefined, false, false); additive && setAdditiveBlendMode(false); debugParticles && debugRect(pos, size, '#f005', 0, angle, false, false); } } /** * LittleJS WebGL Interface * - WebGL2 rendering engine for high-performance graphics * - Batched sprite rendering for drawing thousands of sprites efficiently * - Instanced rendering using vertex array objects (VAOs) * - Polygon rendering with triangle strip support * - Shader system with custom vertex and fragment shaders * - Texture management with automatic atlas support * - Post-processing effects via framebuffer and shader plugins * - Automatic fallback to Canvas2D if WebGL is unavailable * - Context loss and restoration handling * - Can be disabled with glEnable setting * - Advanced users can create custom shaders and render targets * @namespace WebGL */ /** The WebGL canvas which appears below the main canvas * @type {HTMLCanvasElement} * @memberof WebGL */ let glCanvas; /** WebGL2 context for `glCanvas` * @type {WebGL2RenderingContext} * @memberof WebGL */ let glContext; /** The largest texture this device makes, in pixels across, 0 until WebGL starts; at least 2048 with WebGL2 * @type {number} * @memberof WebGL */ let glMaxTextureSize = 0; /** A texture size this device can make: the size asked for, or the largest it supports when that is smaller * - The engine sizes the textures it makes itself through this, the shadow maps and the like, so a size a device * does not have falls back instead of failing * @param {number} size - Pixels across * @return {number} * @memberof WebGL */ function glClampTextureSize(size) { return glMaxTextureSize ? min(size, glMaxTextureSize) : size; } /** Should WebGL be setup with anti-aliasing? must be set before calling engineInit * @type {boolean} * @memberof WebGL */ let glAntialias = true; // WebGL internal variables not exposed to documentation let glMipmappedTextures = new WeakSet, glMipmapsUntilTarget = new WeakSet, glMipmapsStale = new Set, glPremultipliedTextures = new WeakSet, glShaderPremultiplied, glEnableBeforeLoss = true, glShader, glPolyShader, glPolyMode, glAdditive, glBatchAdditive, glActiveTexture, glArrayBuffer, glGeometryBuffer, glPositionData, glColorData, glBatchCount, glTextureInfos = new Set, glInstancedVAO, glPolyVAO, glFramebuffer, glRenderTarget, glShaderObjects = [], glCustomShader, glBatchShader, glProgramCustom, glTransform, glRenderTargetSaved, glUniformLocations = new WeakMap, glCanBeEnabled = true, glDeviceFailed = false; let glFailedPrograms = new WeakSet; // programs that did not build in a release build, which nothing draws with // ANDed onto every packed color as a draw is queued; the light system's shadow pass sets 0xff000000 // to draw everything black with its alpha kept (rgbaInt packs alpha in the top byte) let glColorMask = -1; // ORed onto the additive color of every quad and onto every poly point's color as a draw is queued; the light system's // emissive pass sets a gray with the mask at 0xff000000, so a draw comes out that gray in its own shape let glColorAdditive = 0; // a texture drawn into with the canvas's own transform and its size, the light system's lightmap while its pass // runs: a target set and ended inside it, like a tile layer redrawn in a renderLight, goes back to it, not the canvas let glRenderTargetBase; // set by the light system's shadow pass, which draws the world with its own camera, so a screen space // WebGL draw in a render() is skipped instead of landing somewhere in the world let glSkipScreenSpace = false; // WebGL internal constants const gl_ARRAY_BUFFER_SIZE = 5e5; const gl_INDICES_PER_INSTANCE = 11; const gl_INSTANCE_BYTE_STRIDE = gl_INDICES_PER_INSTANCE * 4; const gl_MAX_INSTANCES = gl_ARRAY_BUFFER_SIZE / gl_INSTANCE_BYTE_STRIDE | 0; const gl_INDICES_PER_POLY_VERTEX = 3; const gl_POLY_VERTEX_BYTE_STRIDE = gl_INDICES_PER_POLY_VERTEX * 4; const gl_MAX_POLY_VERTEXES = gl_ARRAY_BUFFER_SIZE / gl_POLY_VERTEX_BYTE_STRIDE | 0; /////////////////////////////////////////////////////////////////////////////// // the sprite vertex shader, shared by the engine's program and every Shader so one vertex layout fits all const gl_VERTEX_SOURCE = '#version 300 es\n' + // specify GLSL ES version 'precision highp float;'+ // use highp for accuracy 'uniform mat4 m;'+ // transform matrix 'layout(location=0) in vec2 g;'+ // in: geometry 'layout(location=1) in vec4 p;'+ // in: position/size 'layout(location=2) in vec4 u;'+ // in: uvs 'layout(location=3) in vec4 c;'+ // in: color 'layout(location=4) in vec4 a;'+ // in: additiveColor 'layout(location=5) in float r;'+// in: rotation 'out vec2 v,l;'+ // out: uv, and 0 to 1 across the sprite for a Shader's localUV 'out vec4 d,e;'+ // out: color, additiveColor 'void main(){'+ // shader entry point 'vec2 s=(g-.5)*p.zw;'+ // get size offset 'gl_Position=m*vec4(p.xy+s*cos(r)-vec2(-s.y,s)*sin(r),1,1);'+ // transform position 'v=mix(u.xw,u.zy,g);'+ // pass uv to fragment shader 'l=g;d=c;e=a;'+ // pass local uv and colors to fragment shader '}'; // end of shader // the end of every sprite fragment shader, the engine's and each Shader's: t is the surface color // a render target and a smooth image hold premultiplied color, so their batches blend with ONE and premultiply here, // giving what a straight texel gives, clamped as the blend clamps it: exactly when the additive alpha is 0 or // the texel is clear or opaque, and close otherwise, since the straight color is not kept to be multiplied const gl_FRAGMENT_TINT_SOURCE = 'c=t*d+e;'+ // modulate by color plus additive 'if(premultipliedTexture){'+ // a render target or a smooth image 'c.a=min(c.a,1.);'+ // the alpha the blend uses 'c.rgb=min(t.rgb*d.rgb*min(d.a+e.a,1.)+e.rgb*c.a,c.a);}'; // Initialize WebGL, called automatically by the engine function glInit(rootElement) { if (!glEnable || headlessMode) { glCanBeEnabled = false; return; } // create the canvas and textures glCanvas = document.createElement('canvas'); glContext = glCanvas.getContext('webgl2', {antialias:glAntialias}); if (!glContext) { console.warn('WebGL2 not supported, falling back to 2D canvas rendering!'); glCanvas = glContext = undefined; glEnable = false; glCanBeEnabled = false; glDeviceFailed = true; // the device, not the game, turned it off return; } // attach the WebGL canvas; rootElement.appendChild(glCanvas); // startup webgl, and make the textures of any texture infos made before it initWebGL(); for (const info of glTextureInfos) info.glTexture ||= glCreateTexture(info.image, info.wrap, info.pixelated); // setup context lost and restore handlers glCanvas.addEventListener('webglcontextlost', (e)=> { glEnableBeforeLoss = glEnable; glEnable = false; // disable WebGL rendering glCanvas.style.display = 'none'; // hide the gl canvas e.preventDefault(); // prevent default to allow restoration LOG('WebGL context lost! Switching to Canvas2d rendering.'); // remove WebGL textures for (const info of glTextureInfos) info.glTexture = undefined; glActiveTexture = undefined; // every Shader compiles again on its next draw, and the first flush after restore picks its program again for (const shader of glShaderObjects) shader.program = undefined; glBatchShader = undefined; glProgramCustom = true; glUniformLocations = new WeakMap; // the programs those belonged to are gone // drop any partially-filled batch so the next glFlush doesn't // upload stale glBatchCount against fresh empty buffers on restore glBatchCount = 0; glPolyMode = false; pluginList.forEach(plugin=>plugin.glContextLost?.()); }); glCanvas.addEventListener('webglcontextrestored', ()=> { glEnable = glEnableBeforeLoss; // WebGL rendering as it was before the loss glCanvas.style.display = ''; // show the gl canvas LOG('WebGL context restored, reinitializing...'); // reinit WebGL and restore textures glMipmappedTextures = new WeakSet; glMipmapsUntilTarget = new WeakSet; glMipmapsStale.clear(); glPremultipliedTextures = new WeakSet; // the tile layers draw into their new textures again below initWebGL(); for (const info of glTextureInfos) info.glTexture = glCreateTexture(info.image, info.wrap, info.pixelated); pluginList.forEach(plugin=>plugin.glContextRestored?.()); // a tile layer drawn on the GPU only had its tiles in the lost texture, it draws them again for (const o of engineObjects) o instanceof TileLayer && o.isUsingWebGL && !o.destroyed && o.redraw(); }); function initWebGL() { glMaxTextureSize = glContext.getParameter(glContext.MAX_TEXTURE_SIZE) | 0; // setup instanced rendering shader program glShader = glCreateProgram(gl_VERTEX_SOURCE, '#version 300 es\n' + // specify GLSL ES version 'precision highp float;'+ // use highp for accuracy 'uniform sampler2D s;'+ // texture 'uniform bool premultipliedTexture;'+ // is the texture premultiplied, a render target or a smooth image 'in vec2 v;'+ // in: uv 'in vec4 d,e;'+ // in: color, additiveColor 'out vec4 c;'+ // out: color 'void main(){'+ // shader entry point 'vec4 t=texture(s,v);'+ // sample the texture gl_FRAGMENT_TINT_SOURCE + // apply color and additive '}' // end of shader ); glShaderPremultiplied = false; // a new program starts with its uniforms at 0 // setup poly rendering shaders glPolyShader = glCreateProgram( '#version 300 es\n' + // specify GLSL ES version 'precision highp float;'+ // use highp for better accuracy 'uniform mat4 m;'+ // transform matrix 'in vec2 p;'+ // in: position 'in vec4 c;'+ // in: color 'out vec4 d;'+ // out: color 'void main(){'+ // shader entry point 'gl_Position=m*vec4(p,1,1);'+ // transform position 'd=c;'+ // pass color to fragment shader '}' // end of shader , '#version 300 es\n' + // specify GLSL ES version 'precision highp float;'+ // use highp for better accuracy 'in vec4 d;'+ // in: color 'out vec4 c;'+ // out: color 'void main(){'+ // shader entry point 'c=d;'+ // set color '}' // end of shader ); // a device that can not build the engine's own programs draws with Canvas2D, as after a lost context if (glFailedPrograms.has(glShader) || glFailedPrograms.has(glPolyShader)) { console.error('LittleJS: WebGL can not draw on this device, using Canvas2D'); glEnable = glCanBeEnabled = false; glDeviceFailed = true; glCanvas.style.display = 'none'; } // init buffers const glInstanceData = new ArrayBuffer(gl_ARRAY_BUFFER_SIZE); glPositionData = new Float32Array(glInstanceData); glColorData = new Uint32Array(glInstanceData); glArrayBuffer = glContext.createBuffer(); glGeometryBuffer = glContext.createBuffer(); glFramebuffer = glContext.createFramebuffer(); glBatchCount = 0; // create the geometry buffer, triangle strip square const geometry = new Float32Array([0,0,1,0,0,1,1,1]); glContext.bindBuffer(glContext.ARRAY_BUFFER, glGeometryBuffer); glContext.bufferData(glContext.ARRAY_BUFFER, geometry, glContext.STATIC_DRAW); let offset, shader, stride; const initVertexAttrib = (name, type, typeSize, size, divisor=0)=> { const location = glContext.getAttribLocation(shader, name); const normalize = typeSize === 1; const fixedStride = typeSize && stride; glContext.enableVertexAttribArray(location); glContext.vertexAttribPointer(location, size, type, normalize, fixedStride, offset); glContext.vertexAttribDivisor(location, divisor); offset += size*typeSize; } // setup VAO for instanced rendering glInstancedVAO = glContext.createVertexArray(); glContext.bindVertexArray(glInstancedVAO); // configure instanced vertex attributes offset = 0, shader = glShader, stride = gl_INSTANCE_BYTE_STRIDE; glContext.bindBuffer(glContext.ARRAY_BUFFER, glGeometryBuffer); // geometry, its own packed buffer: typeSize 0 gives stride 0 and does not advance the offset initVertexAttrib('g', glContext.FLOAT, 0, 2); glContext.bindBuffer(glContext.ARRAY_BUFFER, glArrayBuffer); glContext.bufferData(glContext.ARRAY_BUFFER, gl_ARRAY_BUFFER_SIZE, glContext.DYNAMIC_DRAW); initVertexAttrib('p', glContext.FLOAT, 4, 4, 1); // position & size initVertexAttrib('u', glContext.FLOAT, 4, 4, 1); // texture coords initVertexAttrib('c', glContext.UNSIGNED_BYTE, 1, 4, 1); // color initVertexAttrib('a', glContext.UNSIGNED_BYTE, 1, 4, 1); // additiveColor initVertexAttrib('r', glContext.FLOAT, 4, 1, 1); // rotation // setup VAO for poly rendering glPolyVAO = glContext.createVertexArray(); glContext.bindVertexArray(glPolyVAO); // configure poly vertex attributes offset = 0, shader = glPolyShader, stride = gl_POLY_VERTEX_BYTE_STRIDE; initVertexAttrib('p', glContext.FLOAT, 4, 2); // position initVertexAttrib('c', glContext.UNSIGNED_BYTE, 1, 4); // color } } function glSetInstancedMode(force=false) { if (!force && !glPolyMode) return; // setup instanced mode glFlush(); glPolyMode = false; glContext.useProgram(glShader); glContext.bindVertexArray(glInstancedVAO); glContext.bindBuffer(glContext.ARRAY_BUFFER, glArrayBuffer); // a VAO does not keep this binding } function glSetPolyMode() { if (glPolyMode) return; // setup poly mode glFlush(); glPolyMode = true; glContext.useProgram(glPolyShader); glContext.bindVertexArray(glPolyVAO); } // Setup WebGL render each frame, called automatically by engine // Also used by tile layer rendering when redrawing tiles function glPreRender(clear=true) { if (!glEnable || !glContext) return; ASSERT(!glBatchCount, 'glPreRender called with unflushed batch.'); // mainCanvasSize is css pixels, the backing store is scaled by the pixel // ratio, render targets are offscreen so they are never scaled const dpr = glRenderTarget ? 1 : getCanvasPixelRatio(); const bufferSizeX = mainCanvasSize.x * dpr | 0; const bufferSizeY = mainCanvasSize.y * dpr | 0; if (!glRenderTarget) { // set to same size as main canvas, only when it changes because // setting it reallocates the drawing buffer and invalidates the frame if (glCanvas.width !== bufferSizeX || glCanvas.height !== bufferSizeY) { glCanvas.width = bufferSizeX; glCanvas.height = bufferSizeY; } } glContext.viewport(0, 0, bufferSizeX, bufferSizeY); clear && glClearCanvas(); // build the transform matrix const s = vec2(2*cameraScale).divide(mainCanvasSize); if (glRenderTarget) s.y = -s.y; // invert y when using render target const rotatedCam = cameraPos.rotate(-cameraAngle); const p = vec2(-1).subtract(rotatedCam.multiply(s)); const ca = cos(cameraAngle); const sa = sin(cameraAngle); // column major; the shaders send (x, y, 1, 1), so the third column adds 1 to x and y and p takes it back off: // clip = s * (world turned by -cameraAngle - the camera turned the same way), z = 1 const transform = [ s.x * ca, s.y * sa, 0, 0, -s.x * sa, s.y * ca, 0, 0, 1, 1, 1, 0, p.x, p.y, 0, 1]; glTransform = transform; // set the same transform matrix for both shaders const initUniform = (program, uniform, value)=> { glContext.useProgram(program); glContext.uniformMatrix4fv(glUniformLocation(program, uniform), false, value); } initUniform(glPolyShader, 'm', transform); initUniform(glShader, 'm', transform); // set the active texture glContext.activeTexture(glContext.TEXTURE0); if (textureInfos[0]) { glActiveTexture = textureInfos[0].glTexture; glContext.bindTexture(glContext.TEXTURE_2D, glActiveTexture); } // rebind the array buffer glContext.bindBuffer(glContext.ARRAY_BUFFER, glArrayBuffer); // start with additive blending off glAdditive = glBatchAdditive = false; // force it to set instanced mode glSetInstancedMode(true); } /** Clear the canvas or render target to canvasClearColor * @memberof WebGL */ function glClearCanvas() { if (!glContext) return; // clear using the canvasClearColor, premultiplied like everything the blend writes const color = canvasClearColor; glContext.clearColor(color.r*color.a, color.g*color.a, color.b*color.a, color.a); glContext.clear(glContext.COLOR_BUFFER_BIT); } /** Set the WebGL texture, called automatically if using multiple textures * - This may also flush the gl buffer resulting in more draw calls and worse performance * @param {WebGLTexture} texture * @memberof WebGL */ function glSetTexture(texture) { if (!glContext) return; if (texture !== glActiveTexture) { // must flush cache with the old texture to set a new one glFlush(); glActiveTexture = texture; glContext.bindTexture(glContext.TEXTURE_2D, glActiveTexture); } glMipmapsStale.size && glUpdateMipmaps(texture); // bound already or not, drawn into since } // make the smaller levels of a texture again if it was drawn into since, it must be the bound texture function glUpdateMipmaps(texture) { if (!glMipmapsStale.has(texture)) return; glMipmapsStale.delete(texture); glContext.generateMipmap(glContext.TEXTURE_2D); } // a texture's wrap as its two WebGL modes, across and down: true repeats both and false clamps both, or a pair of // modes as a glTF sampler gives them; the pairs are WebGL's fixed values, shared so a draw makes no array const glWrapRepeat = Object.freeze([10497, 10497]), glWrapClamp = Object.freeze([33071, 33071]); function glWrapModes(wrap) { return isArray(wrap) ? wrap : wrap ? glWrapRepeat : glWrapClamp; } /** Set the wrap mode on an existing WebGL texture * Flushes the current batch only if the texture is the active one * @param {WebGLTexture} texture * @param {boolean|Array} [wrap] - true for REPEAT, false for CLAMP_TO_EDGE, or the WebGL modes across and * down, like [gl.CLAMP_TO_EDGE, gl.MIRRORED_REPEAT] * @memberof WebGL */ function glSetTextureWrap(texture, wrap=true) { if (!glContext || !texture) return; // flush only if changing wrap on the currently bound texture const isCurrent = texture === glActiveTexture; if (isCurrent) glFlush(); else glContext.bindTexture(glContext.TEXTURE_2D, texture); const [wrapS, wrapT] = glWrapModes(wrap); glContext.texParameteri(glContext.TEXTURE_2D, glContext.TEXTURE_WRAP_S, wrapS); glContext.texParameteri(glContext.TEXTURE_2D, glContext.TEXTURE_WRAP_T, wrapT); if (!isCurrent && glActiveTexture) glContext.bindTexture(glContext.TEXTURE_2D, glActiveTexture); } /** Compile WebGL shader of the given type, will throw errors if in debug mode * @param {string} source * @param {number} type * @return {WebGLShader|undefined} - undefined with no WebGL * @memberof WebGL */ function glCompileShader(source, type) { if (!glContext) return; // build the shader const shader = glContext.createShader(type); glContext.shaderSource(shader, source); glContext.compileShader(shader); // check for errors if (debug && !glContext.getShaderParameter(shader, glContext.COMPILE_STATUS)) throw glContext.getShaderInfoLog(shader); return shader; } /** Create WebGL program with given shaders * @param {string} vsSource * @param {string} fsSource * @return {WebGLProgram|undefined} - undefined with no WebGL * @memberof WebGL */ function glCreateProgram(vsSource, fsSource) { if (!glContext) return; // build the program const program = glContext.createProgram(); const vertexShader = glCompileShader(vsSource, glContext.VERTEX_SHADER); const fragmentShader = glCompileShader(fsSource, glContext.FRAGMENT_SHADER); glContext.attachShader(program, vertexShader); glContext.attachShader(program, fragmentShader); glContext.linkProgram(program); // check for errors in every build, as a driver may refuse what another takes: a debug build throws, a release // one says why once and marks the program, which the engine's draws then do without if (!glContext.getProgramParameter(program, glContext.LINK_STATUS)) { const log = glContext.getProgramInfoLog(program) + '\n' + glContext.getShaderInfoLog(vertexShader) + glContext.getShaderInfoLog(fragmentShader); if (debug) throw log; console.error('LittleJS: a shader failed to build\n' + log); glFailedPrograms.add(program); } return program; } // a uniform location, looked up once per program function glUniformLocation(program, name) { let cache = glUniformLocations.get(program); cache || glUniformLocations.set(program, cache = {}); return cache[name] ??= glContext.getUniformLocation(program, name); } // a Shader's 2D program, compiled the first time a batch needs it: the snippet's mainImage gives the surface // color, then the sprite's color and additive color apply as the engine's own fragment shader does function glShaderProgram(shader) { shader.program || glShaderTrack(shader); const program = shader.program ||= glCreateProgram(gl_VERTEX_SOURCE, '#version 300 es\n' + 'precision highp float;' + 'uniform sampler2D iChannel0;' + // the texture 'uniform vec3 iResolution;' + // canvas size in pixels 'uniform float iTime;' + // engine time 'uniform bool premultipliedTexture;' + // is the texture premultiplied, a render target or a smooth image 'in vec2 v,l;in vec4 d,e;out vec4 c;' + // main first, the snippet's mainImage declared for it, so a define in the snippet can not reach main's names 'void mainImage(out vec4,vec2);' + 'void main(){vec4 t;mainImage(t,v);' + gl_FRAGMENT_TINT_SOURCE + '}\n' + // a define needs its own line '#define localUV l\n' + shader.fragmentCode + '\n'); return glFailedPrograms.has(program) ? glShader : program; } // a Shader compiled again after dispose is kept for a lost context again function glShaderTrack(shader) { glShaderObjects.includes(shader) || glShaderObjects.push(shader); } // let go of a Shader's programs, 2D and 3D, and of its place in the list; a batch drawing with it is drawn first function glShaderDispose(shader) { // a batch drawing with it is drawn first, the batch holds the Shader; its first draw compiles it and lists it, so // it comes off the list after if (glContext && !headlessMode && glBatchShader === shader) glFlush(); const i = glShaderObjects.indexOf(shader); i < 0 || glShaderObjects.splice(i, 1); if (glContext && !headlessMode) for (const program of [shader.program, shader.program3D]) program && glContext.deleteProgram(program); shader.program = shader.program3D = undefined; } /** Create WebGL texture from an image and init the texture settings * Restores the active texture when done * @param {HTMLImageElement|HTMLCanvasElement|OffscreenCanvas|ImageBitmap} [image] * @param {boolean|Array} [wrap] - true for REPEAT, false for CLAMP_TO_EDGE, or the WebGL modes across and down * @param {boolean} [pixelated] - Hard edged or smooth, undefined follows tilesPixelated * @return {WebGLTexture|undefined} - undefined with no WebGL * @memberof WebGL */ function glCreateTexture(image, wrap=false, pixelated=tilesPixelated) { if (!glContext) return; // build the texture const texture = glContext.createTexture(); let mipMap = false; if (image?.width) { glSetTextureData(texture, image, pixelated); glContext.bindTexture(glContext.TEXTURE_2D, texture); // WebGL2 makes mipmaps at any size, a texture that becomes a render target keeps them only at powers of two mipMap = !pixelated; if (mipMap && !(isPowerOfTwo(image.width) && isPowerOfTwo(image.height))) glMipmapsUntilTarget.add(texture); } else { // create a white texture const whitePixel = new Uint8Array([255, 255, 255, 255]); glContext.bindTexture(glContext.TEXTURE_2D, texture); glContext.texImage2D(glContext.TEXTURE_2D, 0, glContext.RGBA, 1, 1, 0, glContext.RGBA, glContext.UNSIGNED_BYTE, whitePixel); } // set texture filtering const magFilter = pixelated ? glContext.NEAREST : glContext.LINEAR; const minFilter = mipMap ? glContext.LINEAR_MIPMAP_LINEAR : magFilter; glContext.texParameteri(glContext.TEXTURE_2D, glContext.TEXTURE_MAG_FILTER, magFilter); glContext.texParameteri(glContext.TEXTURE_2D, glContext.TEXTURE_MIN_FILTER, minFilter); const [wrapS, wrapT] = glWrapModes(wrap); glContext.texParameteri(glContext.TEXTURE_2D, glContext.TEXTURE_WRAP_S, wrapS); glContext.texParameteri(glContext.TEXTURE_2D, glContext.TEXTURE_WRAP_T, wrapT); if (mipMap) { glContext.generateMipmap(glContext.TEXTURE_2D); glMipmappedTextures.add(texture); } // rebind active texture glContext.bindTexture(glContext.TEXTURE_2D, glActiveTexture); return texture; } /** Deletes a WebGL texture * @param {WebGLTexture} [texture] * @memberof WebGL */ function glDeleteTexture(texture) { if (!glContext || !texture) return; // what is batched with it is drawn first, since deleting unbinds it, and nothing keeps it after if (texture === glActiveTexture) { glFlush(); glActiveTexture = undefined; // so nothing binds the deleted texture again } glMipmapsStale.delete(texture); glPremultipliedTextures.delete(texture); glContext.deleteTexture(texture); } /** Set WebGL texture data from an image, restores the active texture when done * @param {WebGLTexture} texture * @param {HTMLImageElement|HTMLCanvasElement|OffscreenCanvas|ImageBitmap} image * @param {boolean} [pixelated] - Hard edged or smooth, undefined follows tilesPixelated * @memberof WebGL */ function glSetTextureData(texture, image, pixelated=tilesPixelated) { if (!glContext) return; // build the texture, after drawing what was queued with the old image ASSERT(image?.width > 0, 'Invalid image data.'); texture === glActiveTexture && glFlush(); glContext.bindTexture(glContext.TEXTURE_2D, texture); if (typeof ImageBitmap != 'undefined' && image instanceof ImageBitmap) { // WebGL ignores the unpack flag below for a bitmap and uploads it as it was made, so it is drawn onto a // canvas first, which reads either kind of bitmap right and uploads as the flag says const context = createCanvasContext(image.width, image.height); context.drawImage(image, 0, 0); image = context.canvas; } // smooth filtering mixes a texel with its see through neighbors, right only for premultiplied color, or the // edges go dark; pixel art is sampled a texel at a time, and uploads straight color as it always has const premultiply = !pixelated; glContext.pixelStorei(glContext.UNPACK_PREMULTIPLY_ALPHA_WEBGL, premultiply); glContext.texImage2D(glContext.TEXTURE_2D, 0, glContext.RGBA, glContext.RGBA, glContext.UNSIGNED_BYTE, image); glContext.pixelStorei(glContext.UNPACK_PREMULTIPLY_ALPHA_WEBGL, false); if (premultiply) glPremultipliedTextures.add(texture); // drawn with the premultiplied blend, as a render target is else glPremultipliedTextures.delete(texture); // an image uploads straight color, even into a used render target // keep mipmaps in sync with new level 0 data, for any texture that has them if (glMipmappedTextures.has(texture)) glContext.generateMipmap(glContext.TEXTURE_2D); // rebind active texture glContext.bindTexture(glContext.TEXTURE_2D, glActiveTexture); } /** Internal: tells WebGL to create or update the glTexture and start tracking it, TextureInfo calls it * @param {TextureInfo} textureInfo * @ignore */ function glRegisterTextureInfo(textureInfo) { if (headlessMode) return; // add texture info to tracking list even if gl is not enabled glTextureInfos.add(textureInfo); if (!glContext) return; // create or set the texture data if (textureInfo.glTexture) glSetTextureData(textureInfo.glTexture, textureInfo.image, textureInfo.pixelated); else textureInfo.glTexture = glCreateTexture(textureInfo.image, textureInfo.wrap, textureInfo.pixelated); } /** Internal: tells WebGL to destroy the glTexture and stop tracking it, TextureInfo calls it * @param {TextureInfo} textureInfo * @ignore */ function glUnregisterTextureInfo(textureInfo) { if (headlessMode) return; // delete texture info from tracking list even if gl is not enabled glTextureInfos.delete(textureInfo); // unset and destroy the texture, drawing what is batched with it first, since deleting unbinds it const glTexture = textureInfo.glTexture; textureInfo.glTexture = undefined; glDeleteTexture(glTexture); } /** Draw all sprites and clear out the buffer, called automatically by the system whenever necessary * @memberof WebGL */ function glFlush() { if (glEnable && glContext && glBatchCount) { // a render target holds premultiplied color, a batch drawing one takes its color as it is // glSetTexture flushes on every change, so the whole batch drew with the bound texture const premultiplied = !glPolyMode && glPremultipliedTextures.has(glActiveTexture); // set blend mode const sourceBlend = premultiplied ? glContext.ONE : glContext.SRC_ALPHA; const destBlend = glBatchAdditive ? glContext.ONE : glContext.ONE_MINUS_SRC_ALPHA; glContext.blendFuncSeparate(sourceBlend, destBlend, glContext.ONE, destBlend); glContext.enable(glContext.BLEND); // a Shader's program for this batch, or the engine's own again after one if (!glPolyMode && (glBatchShader || glProgramCustom)) { const program = glBatchShader ? glShaderProgram(glBatchShader) : glShader; glContext.useProgram(program); glProgramCustom = !!glBatchShader; if (glBatchShader) { const uniform = (name)=> glUniformLocation(program, name); glContext.uniformMatrix4fv(uniform('m'), false, glTransform); glContext.uniform1f(uniform('iTime'), time); // a render target is the size glPreRender gave its viewport const width = glRenderTarget ? mainCanvasSize.x : glCanvas.width; const height = glRenderTarget ? mainCanvasSize.y : glCanvas.height; glContext.uniform3f(uniform('iResolution'), width, height, 1); glContext.uniform1i(uniform('premultipliedTexture'), +premultiplied); if (program === glShader) glShaderPremultiplied = premultiplied; // a Shader that did not build draws with the engine's } } if (!glPolyMode && !glBatchShader && glShaderPremultiplied !== premultiplied) { // the engine's program is the one in use, and it keeps the flag until it changes glContext.uniform1i(glUniformLocation(glShader, 'premultipliedTexture'), +premultiplied); glShaderPremultiplied = premultiplied; } const byteLength = glBatchCount * (glPolyMode ? gl_INDICES_PER_POLY_VERTEX : gl_INDICES_PER_INSTANCE); glContext.bufferSubData(glContext.ARRAY_BUFFER, 0, glPositionData, 0, byteLength); // draw the batch if (glPolyMode) glContext.drawArrays(glContext.TRIANGLE_STRIP, 0, glBatchCount); else glContext.drawArraysInstanced(glContext.TRIANGLE_STRIP, 0, 4, glBatchCount); ++drawCount; primitiveCount += glBatchCount; glBatchCount = 0; } glBatchAdditive = glAdditive; glBatchShader = glCustomShader; } /** Flush any sprites still in the buffer and copy the WebGL canvas to a 2D context * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} context * @memberof WebGL */ function glCopyToContext(context) { if (!glEnable || !glContext) return; glFlush(); context.drawImage(glCanvas, 0, 0); } /** Set anti-aliasing for WebGL canvas * Must be called before engineInit * @param {boolean} [antialias] * @memberof WebGL */ function glSetAntialias(antialias=true) { ASSERT(!glCanvas, 'must be called before engineInit'); glAntialias = antialias; } /** Add a sprite to the gl draw list, used by all gl draw functions * @param {number} x * @param {number} y * @param {number} sizeX * @param {number} sizeY * @param {number} [angle] * @param {number} [uv0X] * @param {number} [uv0Y] * @param {number} [uv1X] * @param {number} [uv1Y] * @param {number} [rgba] - white is -1 * @param {number} [rgbaAdditive] - black is 0 * @memberof WebGL */ function glDraw(x, y, sizeX, sizeY, angle=0, uv0X=0, uv0Y=0, uv1X=1, uv1Y=1, rgba=-1, rgbaAdditive=0) { // flush if there is not enough room or if different blend mode if (glBatchCount >= gl_MAX_INSTANCES || glBatchAdditive !== glAdditive || glBatchShader !== glCustomShader) glFlush(); glSetInstancedMode(); let offset = glBatchCount++ * gl_INDICES_PER_INSTANCE; glPositionData[offset++] = x; glPositionData[offset++] = y; glPositionData[offset++] = sizeX; glPositionData[offset++] = sizeY; glPositionData[offset++] = uv0X; glPositionData[offset++] = uv0Y; glPositionData[offset++] = uv1X; glPositionData[offset++] = uv1Y; glColorData[offset++] = rgba & glColorMask; glColorData[offset++] = rgbaAdditive & glColorMask | glColorAdditive; glPositionData[offset++] = angle; } /** Add an untextured rect to the gl draw list * Zeroes the uvs and rgba so the texture contribution multiplies to 0, * then carries the real color in the additive slot. Works regardless of * which texture is currently bound. * @param {number} x * @param {number} y * @param {number} sizeX * @param {number} sizeY * @param {number} angle * @param {number} rgba - color as 32-bit integer * @memberof WebGL */ function glDrawUntextured(x, y, sizeX, sizeY, angle, rgba) { glDraw(x, y, sizeX, sizeY, angle, 0, 0, 0, 0, 0, rgba); } // the list and the vectors glDrawPointsTransform writes its points into const glPointsOut = [], glPointsPool = []; /** Transform and add a polygon to the gl draw list * @param {Array} points - Array of Vector2 points * @param {number} rgba - Color of the polygon as a 32-bit integer * @param {number} x * @param {number} y * @param {number} sx * @param {number} sy * @param {number} angle * @param {boolean} [tristrip] - should tristrip algorithm be used * @memberof WebGL */ function glDrawPointsTransform(points, rgba, x, y, sx, sy, angle, tristrip=true) { // written into vectors kept for this, a circle would make one for each of its sides at every draw; they are // read into the batch before this returns const pointsOut = glPointsOut, pool = glPointsPool, count = points.length; pointsOut.length = count; const sa = sin(-angle); const ca = cos(-angle); for (let i = 0; i < count; ++i) { // transform the point const p = points[i], out = pool[i] ||= vec2(); const px = p.x*sx; const py = p.y*sy; out.x = x + ca*px - sa*py; out.y = y + sa*px + ca*py; pointsOut[i] = out; } const drawPoints = tristrip ? glPolyStrip(pointsOut) : pointsOut; glDrawPoints(drawPoints, rgba); } /** Transform and add a polygon's outline to the gl draw list * @param {Array} points - Array of Vector2 points * @param {number} rgba - Color of the polygon as a 32-bit integer * @param {number} lineWidth - Width of the outline * @param {number} x * @param {number} y * @param {number} sx * @param {number} sy * @param {number} angle * @param {boolean} [wrap] - Should the outline connect the first and last points * @memberof WebGL */ function glDrawOutlineTransform(points, rgba, lineWidth, x, y, sx, sy, angle, wrap=true) { const outlinePoints = glMakeOutline(points, lineWidth, wrap); glDrawPointsTransform(outlinePoints, rgba, x, y, sx, sy, angle, false); } /** Add a list of points to the gl draw list * @param {Array} points - Array of Vector2 points in tri strip order * @param {number} rgba - Color as a 32-bit integer * @memberof WebGL */ function glDrawPoints(points, rgba) { glDrawPolyStrip(points, rgba); } /** Add a list of colored points to the gl draw list * @param {Array} points - Array of Vector2 points in tri strip order * @param {Array} pointColors - Array of 32-bit integer colors * @memberof WebGL */ function glDrawColoredPoints(points, pointColors) { glDrawPolyStrip(points, 0, pointColors); } // queue a triangle strip in the poly batch with degenerate verts at start and end, // one color for all its points or one each function glDrawPolyStrip(points, rgba, pointColors) { if (!glEnable || points.length < 3) return; // needs at least 3 points to have area // flush if there is not enough room or if different blend mode const vertCount = points.length + 2; if (glBatchCount+vertCount >= gl_MAX_POLY_VERTEXES || glBatchAdditive !== glAdditive) glFlush(); ASSERT(vertCount < gl_MAX_POLY_VERTEXES, 'poly exceeds max batch size'); if (vertCount >= gl_MAX_POLY_VERTEXES) return; // release-build safety net glSetPolyMode(); // setup triangle strip with degenerate verts at start and end let offset = glBatchCount * gl_INDICES_PER_POLY_VERTEX; for (let i = vertCount; i--;) { const j = clamp(i-1, 0, vertCount-3); const point = points[j]; const color = pointColors ? pointColors[j] : rgba; glPositionData[offset++] = point.x; glPositionData[offset++] = point.y; glColorData[offset++] = color & glColorMask | glColorAdditive; } glBatchCount += vertCount; } /** Set the WebGL render target to the given texture or back to the canvas * - What is drawn into a texture is stored premultiplied, and draws of that texture blend it as such * @param {WebGLTexture} [texture] - a texture or undefined to use normal glCanvas * @param {boolean} [clear] - should the render target be cleared, to canvasClearColor, CLEAR_BLACK for a transparent one * @memberof WebGL */ function glSetRenderTarget(texture, clear=false) { // what was batched so far draws where it was meant to, before the target changes glFlush(); const previousTarget = glRenderTarget; if (texture) { if (glMipmapsUntilTarget.has(texture)) { // a layer at a size other than a power of two draws without mipmaps as it always has, // so they are not made again after every redraw, the 3D renderer still makes its own glMipmapsUntilTarget.delete(texture); glMipmappedTextures.delete(texture); glContext.bindTexture(glContext.TEXTURE_2D, texture); glContext.texParameteri(glContext.TEXTURE_2D, glContext.TEXTURE_MIN_FILTER, glContext.LINEAR); glContext.bindTexture(glContext.TEXTURE_2D, glActiveTexture); } glPremultipliedTextures.add(texture); // the blend writes premultiplied color into it // coming from the canvas, keep its transform and blend mode to put back after glRenderTarget || (glRenderTargetSaved = [glTransform, glAdditive]); glRenderTarget = texture; glContext.bindFramebuffer(glContext.FRAMEBUFFER, glFramebuffer); glContext.framebufferTexture2D(glContext.FRAMEBUFFER, glContext.COLOR_ATTACHMENT0, glContext.TEXTURE_2D, texture, 0); glPreRender(clear); } else { glRenderTarget = undefined; if (glRenderTargetBase) { // back to the texture the canvas transform was drawing into, at its own size const [baseTexture, baseSize] = glRenderTargetBase; glContext.bindFramebuffer(glContext.FRAMEBUFFER, glFramebuffer); glContext.framebufferTexture2D(glContext.FRAMEBUFFER, glContext.COLOR_ATTACHMENT0, glContext.TEXTURE_2D, baseTexture, 0); glContext.viewport(0, 0, baseSize.x, baseSize.y); } else { glContext.bindFramebuffer(glContext.FRAMEBUFFER, null); // use the backing store size, mainCanvasSize is css pixels and may // still be the render target's size when unwinding a layer redraw glContext.viewport(0, 0, glCanvas.width, glCanvas.height); } // the canvas's own transform and blend mode again, the target set its own if (glRenderTargetSaved) { [glTransform, glAdditive] = glRenderTargetSaved; glRenderTargetSaved = undefined; for (const program of [glPolyShader, glShader]) { glContext.useProgram(program); glContext.uniformMatrix4fv(glUniformLocation(program, 'm'), false, glTransform); } glBatchAdditive = glAdditive; glSetInstancedMode(true); } } // a target with mipmaps drawn into has only its top level new, the smaller levels are made again from it // the next time it is drawn from if (previousTarget && previousTarget !== texture && glMipmappedTextures.has(previousTarget)) glMipmapsStale.add(previousTarget); } /** Clear out a rectangle area of the WebGL canvas or render target * - In framebuffer pixels from the bottom left: backing store pixels on the canvas, texture pixels in a * render target * @param {number} x * @param {number} y * @param {number} width * @param {number} height * @memberof WebGL */ function glClearRect(x, y, width, height) { if (!glEnable) return; glFlush(); // tiles batched before the clear go down before it, not over what replaces them // Enable scissor test to clear only the specified area glContext.enable(glContext.SCISSOR_TEST); glContext.scissor(x, y, width, height); glContext.clearColor(0, 0, 0, 0); glContext.clear(glContext.COLOR_BUFFER_BIT); glContext.disable(glContext.SCISSOR_TEST); } /////////////////////////////////////////////////////////////////////////////// // the strip glMakeOutline makes and the vectors it writes, kept for the next, as it is read into the batch first const glOutlineStrip = [], glOutlinePool = []; // WebGL internal function to convert polygon to outline triangle strip function glMakeOutline(points, width, wrap=true) { if (points.length < 2) return []; const halfWidth = width / 2; const strip = glOutlineStrip, pool = glOutlinePool; strip.length = 0; const n = points.length; const e = 1e-6; // miter ratio cap (dimensionless, matches SVG/Canvas2D convention) const miterLimit = 10; for (let i = 0; i < n; i++) { // for each vertex, calculate normal based on adjacent edges const prev = points[wrap ? (i - 1 + n) % n : max(i - 1, 0)]; const curr = points[i]; const next = points[wrap ? (i + 1) % n : min(i + 1, n - 1)]; // direction from previous to current const dx1 = curr.x - prev.x; const dy1 = curr.y - prev.y; const len1 = (dx1*dx1 + dy1*dy1)**.5; // direction from current to next const dx2 = next.x - curr.x; const dy2 = next.y - curr.y; const len2 = (dx2*dx2 + dy2*dy2)**.5; if (len1 < e && len2 < e) continue; // skip degenerate point // calculate perpendicular normals for each edge const nx1 = len1 > e ? -dy1 / len1 : 0; const ny1 = len1 > e ? dx1 / len1 : 0; const nx2 = len2 > e ? -dy2 / len2 : 0; const ny2 = len2 > e ? dx2 / len2 : 0; // average the normals for miter let nx = nx1 + nx2; let ny = ny1 + ny2; const nlen = (nx*nx + ny*ny)**.5; if (nlen < e) { // 180 degree turn - use perpendicular nx = nx1; ny = ny1; } else { // calculate miter length nx /= nlen; ny /= nlen; const dot = nx1 * nx + ny1 * ny; if (dot > e) { // scale normal by miter length, clamped to miterLimit const miterLength = min(1 / dot, miterLimit); nx *= miterLength; ny *= miterLength; } } // create inner and outer points along the normal const inner = pool[strip.length] ||= vec2(), outer = pool[strip.length + 1] ||= vec2(); inner.x = curr.x - nx * halfWidth, inner.y = curr.y - ny * halfWidth; outer.x = curr.x + nx * halfWidth, outer.y = curr.y + ny * halfWidth; strip.push(inner, outer); } if (strip.length > 1 && wrap) { // close the loop strip.push(strip[0]); strip.push(strip[1]); } return strip; } // WebGL internal function to convert polys to tri strips function glPolyStrip(points) { // validate input if (points.length < 3) return []; // cross product helper: (b-a) x (c-a) const cross = (a,b,c)=> (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x); // calculate signed area of polygon const signedArea = (poly)=> { let area = 0; for (let i = poly.length; i--;) { const j = (i+1) % poly.length; area += poly[i].cross(poly[j]); } return area; } // ensure counter-clockwise winding (slice first so we don't mutate caller's array) if (signedArea(points) < 0) points = points.slice().reverse(); // a convex polygon, which every circle, ellipse and regular polygon is, zigzags between its two sides // into one strip with no bridges, so it skips the ear clipping below and draws with a fifth of the vertices // convex is every corner turning left, and the edges going right then left only once each way round, since // a star listed point by point also turns left at every corner but winds around twice const e = 1e-9, n = points.length; let convex = true, flips = 0, lastSide = 0; for (let i = 0; convex && i < n; ++i) { const a = points[(i + n - 1) % n], b = points[i], dx = points[(i + 1) % n].x - b.x; convex = cross(a, b, points[(i + 1) % n]) > -e; const side = dx > e ? 1 : dx < -e ? -1 : 0; if (side && side !== lastSide) lastSide && ++flips, lastSide = side; } if (convex && flips <= 2) { const strip = [points[0]]; for (let i = 1, j = n - 1; i <= j; ++i, --j) { strip.push(points[i]); i === j || strip.push(points[j]); } return strip; } // check if point is inside triangle const pointInTriangle = (p, a, b, c)=> { const c1 = cross(a, b, p); const c2 = cross(b, c, p); const c3 = cross(c, a, p); const negative = (c1<-e?1:0) + (c2<-e?1:0) + (c3<-e?1:0); const positive = (c1> e?1:0) + (c2> e?1:0) + (c3> e?1:0); return !(negative && positive); }; // ear clipping triangulation const indices = []; for (let i = 0; i < points.length; ++i) indices[i] = i; const triangles = []; let attempts = 0; const maxAttempts = points.length ** 2 + 100; while (indices.length > 3 && attempts++ < maxAttempts) { let foundEar = false; for (let i = 0; i < indices.length; i++) { const i0 = indices[(i + indices.length - 1) % indices.length]; const i1 = indices[i]; const i2 = indices[(i + 1) % indices.length]; const a = points[i0], b = points[i1], c = points[i2]; // check if convex if (cross(a, b, c) < e) continue; // check if any other point is inside let hasInside = false; for (let j = 0; j < indices.length; j++) { const k = indices[j]; if (k === i0 || k === i1 || k === i2) continue; const p = points[k]; hasInside = pointInTriangle(p, a, b, c); if (hasInside) break; } if (hasInside) continue; // found valid ear triangles.push([i0, i1, i2]); indices.splice(i, 1); foundEar = true; break; } // fallback for degenerate cases if (!foundEar) { let worstIndex = -1, worstValue = Infinity; for (let i = 0; i < indices.length; i++) { const i0 = indices[(i + indices.length - 1) % indices.length]; const i1 = indices[i]; const i2 = indices[(i + 1) % indices.length]; const value = abs(cross(points[i0], points[i1], points[i2])); if (value < worstValue) { worstValue = value; worstIndex = i; } } if (worstIndex < 0) break; const i0 = indices[(worstIndex + indices.length - 1) % indices.length]; const i1 = indices[worstIndex]; const i2 = indices[(worstIndex + 1) % indices.length]; triangles.push([i0, i1, i2]); indices.splice(worstIndex, 1); } } // add final triangle if (indices.length === 3) triangles.push([indices[0], indices[1], indices[2]]); if (!triangles.length) return []; // convert triangles to triangle strip with degenerate connectors const strip = []; let [a0, b0, c0] = triangles[0]; strip.push(points[a0], points[b0], points[c0]); for (let i = 1; i < triangles.length; i++) { // add degenerate bridge from last vertex to first of new triangle const [a, b, c] = triangles[i]; strip.push(points[c0], points[a]); strip.push(points[a], points[b], points[c]); c0 = c; } return strip; } /** * LittleJS Engine Logo * - Draws the LittleJS splash screen logo * - Used internally during engine startup */ /////////////////////////////////////////////////////////////////////////////// // the default loading screen: Loading over a bar across the middle, filled by the part of the loads done function drawLoadingScreen(progress) { const x = mainContext, w = mainCanvasSize.x, h = mainCanvasSize.y; const barWidth = min(w * .6, 400), barHeight = 12, left = (w - barWidth) / 2, top = h / 2; x.save(); x.fillStyle = '#000'; x.fillRect(0, 0, w, h); x.fillStyle = x.strokeStyle = '#fff'; x.font = '28px ' + fontDefault; x.textAlign = 'center'; x.textBaseline = 'bottom'; x.fillText('Loading', w / 2, top - 16); x.lineWidth = 2; x.strokeRect(left, top, barWidth, barHeight); x.fillRect(left, top, barWidth * clamp(progress), barHeight); x.restore(); } function drawEngineLogo(t) { const blackAndWhite = 0; const showName = 1; // LittleJS Logo and Splash Screen // the splash runs before the engine loop so size the canvas here too engineUpdateCanvas(); const x = mainContext; const w = mainCanvasSize.x; const h = mainCanvasSize.y; { // background const p3 = percent(t, 1, .8); const p4 = percent(t, 0, .5); const g = x.createRadialGradient(w/2,h/2,0,w/2,h/2,hypot(w,h)*.6); g.addColorStop(0,hsl(0,0,lerp(0,p3/2,p4),p3).toString()); g.addColorStop(1,hsl(0,0,0,p3).toString()); x.save(); x.fillStyle = g; x.fillRect(0,0,w,h); } const gradient = (X1,Y1,X2,Y2,C,S=1)=> { if (C >= 0) { if (blackAndWhite) x.fillStyle = '#fff'; else { const g = x.fillStyle = x.createLinearGradient(X1,Y1,X2,Y2); g.addColorStop(0,color(C,2)); g.addColorStop(1,color(C,1)); } } else x.fillStyle = '#000'; C >= -1 ? (x.fill(), S && x.stroke()) : x.stroke(); } const circle = (X,Y,R,A=0,B=2*PI,C,S)=> { x.beginPath(); x.arc(X,Y,R,p*A,p*B); gradient(X,Y-R,X,Y+R,C,S); } const rect = (X,Y,W,H,C)=> { x.beginPath(); x.rect(X,Y,W,H*p); gradient(X,Y+H,X+W,Y,C); } const poly = (points,C,Y,H)=> { x.beginPath(); for (const p of points) x.lineTo(p.x, p.y); x.closePath(); gradient(0, Y, 0, Y+H,C); } const color = (c,l)=> l?`hsl(${[.95,.56,.13][c%3]*360} 99%${[0,50,75][l]}%)`:'#000'; // center and fit to screen const alpha = oscillate(1,1,t); const p = percent(alpha, .1, .5); const size = min(6, min(w,h)/99); x.translate(w/2,h/2); x.scale(size,size); x.translate(-40,-35); p < 1 && x.setLineDash([99*p,99]); x.lineJoin = x.lineCap = 'round'; x.lineWidth = .1 + p*1.9; //x.strokeStyle='#fff7'; if (showName) { // engine name text const Y = 54; const s = 'LittleJS'; x.font = '900 15.5px arial'; x.lineWidth = .1+p*3.9; x.textAlign = 'center'; x.textBaseline = 'top'; rect(11,Y+1,59,8*p,-1); x.beginPath(); let w2 = 0; for (let i=0;i5?1:0); x[j?'strokeText':'fillText'](s[i],X2,Y+.5,17*p); X += w; } x.lineWidth = .1 + p*1.9; rect(3,Y,73,0); // bottom } rect(7,15,26,-7,0); // cab top rect(25,15,8,25,-1); // cab front rect(10,40,15,-25,1); // cab back rect(14,21,7,9,2); // cab window rect(38,20,6,-6,2); // little stack // big stack rect(49,20,10,-6,0); const stackPoints = [vec2(44,8),vec2(64,8),vec2(59,8+6*p),vec2(49,8+6*p)]; poly(stackPoints,2,8,6*p); rect(44,8,20,-7,0); // engine for (let i=5;i--;) circle(59-i*6*p,30,10,0,2*PI,1,0); circle(59,30,4,0,7,2); // light // engine outline rect(35,20,24,0); // top circle(59,30,10); // front circle(47,30,10,PI/2,PI*3/2); // middle circle(35,30,10,PI/2,PI*3/2); // back rect(7,40,13,7,-1); // bottom back rect(17,40,43,14,-1); // bottom center // wheels for (let i=3;i--;) for (let j=2;j--;) circle(17+15*i,47,j?7:1,0,2*PI,2); // cowcatcher for (let i=2;i--;) { let w=6, s=7, o=53+w*p*i const points = [vec2(o+s,54),vec2(o,40),vec2(o+w*p,40),vec2(o+s+w*p,54)]; poly(points,0,40,14); } x.restore(); } /** * LittleJS Medal System * - Achievement/trophy system for games * - Medal class with name, description, icon, and unlock tracking * - Automatic saving to local storage, unless a service like Newgrounds holds the medal (see Medal.isLocal) * - Visual display queue with slide-in notifications * - The Newgrounds plugin extends it with NewgroundsMedal, held on the server while logged in, and the Wavedash plugin with WavedashMedal, saved here and sent to Wavedash * - Setting debugMedals = true in the game code before medalsInit skips the load and the save, sends nothing to Wavedash, and in the debug build logs the Newgrounds traffic; it is not exported, so only a script tag build can set it * @namespace Medals */ let debugMedals = false; // see the header /////////////////////////////////////////////////////////////////////////////// // Medals settings /** How long to show medals for in seconds * @type {number} * @default * @memberof Settings */ let medalDisplayTime = 5; /** How quickly to slide on/off medals in seconds * @type {number} * @default * @memberof Settings */ let medalDisplaySlideTime = .5; /** Size of medal display * @type {Vector2} * @default Vector2(640,80) * @memberof Settings */ let medalDisplaySize = vec2(640, 80); /** Set to stop medals from being unlockable (like if cheats are enabled) * @type {boolean} * @default * @memberof Settings */ let medalsPreventUnlock = false; /** List of all medals * @type {Object} * @memberof Medals */ const medals = {}; // Engine internal variables not exposed to documentation let medalsDisplayQueue = [], medalsSaveName, medalsDisplayTimeLast, medalsRenderAdded; /////////////////////////////////////////////////////////////////////////////// /** Initialize medals with a save name used for storage * - Call this after creating all medals * - Loads which medals are unlocked from the save, and writes the catalog back * - A medal a service like Newgrounds holds is left as it is, see Medal.isLocal * - A medal unlocked before the first call stays unlocked and is saved by it * @param {string} saveName - The localStorage key the medals are kept under, a different one from the game's own * readSaveData and writeSaveData, or each would overwrite the other * @memberof Medals */ function medalsInit(saveName) { // unlocks from before the first call, which had nowhere to be saved, are kept: medals favour the player; a call // after that, another player's save, starts from its own const early = []; medalsSaveName || medalsForEach((medal)=> medal.unlocked && medal.isLocal() && early.push(medal)); medalsSaveName = saveName; medalsLoad(); if (early.length) { early.forEach((medal)=> medal.unlocked = true); medalsSave(); } // the saved unlocks of Wavedash medals go to Wavedash, when that plugin is in the build and started typeof wavedash != 'undefined' && wavedash?.sendAchievements(); // add the medal display once, however often this is called if (!medalsRenderAdded) engineAddPlugin(undefined, medalsRender); medalsRenderAdded = true; } // check which local medals are unlocked in the save, and write the catalog back function medalsLoad() { // the saved unlocks of medals not made yet stay for them, a medal made late or behind a flag keeps its unlock if (debugMedals || !medalsSaveName) return; const saved = readSaveData(medalsSaveName); ASSERT(Object.keys(saved).every(key=> isNumber(+key)), 'the medals save name holds other data, give medalsInit a name of its own'); medalsForEach(medal=> { if (medal.isLocal()) medal.unlocked = !!saved[medal.id]?.unlocked; }); medalsSave(); } // show the first medal in the queue, sliding it on and off function medalsRender() { if (!medalsDisplayQueue.length) return; // update first medal in queue const medal = medalsDisplayQueue[0]; const elapsed = timeReal - medalsDisplayTimeLast; if (!medalsDisplayTimeLast) medalsDisplayTimeLast = timeReal; else if (elapsed > medalDisplayTime) { medalsDisplayTimeLast = 0; medalsDisplayQueue.shift(); } else { // slide on/off medals, the slides share the display time when it is short const slideTime = min(medalDisplaySlideTime, medalDisplayTime/2); const slideOffTime = medalDisplayTime - slideTime; const hidePercent = elapsed < slideTime ? 1 - elapsed / slideTime : elapsed > slideOffTime ? (elapsed - slideOffTime) / slideTime : 0; medal.render(hidePercent); } } /** * @callback MedalCallbackFunction - Function that processes a medal * @param {Medal} medal * @memberof Medals */ /** Calls a function for each medal * @param {MedalCallbackFunction} callback * @memberof Medals */ function medalsForEach(callback) { Object.values(medals).forEach(medal=> callback(medal)); } /** Reset all medals to locked and persist the cleared catalog * - A medal a service like Newgrounds holds is left alone, the service has it * @memberof Medals */ function medalsReset() { medalsForEach(medal=> medal.isLocal() && (medal.unlocked = false)); if (medalsSaveName && !debugMedals) { // the saved unlocks of the local medals and of medals not made yet are cleared too, since a save keeps an // unlock another tab wrote, and those not made yet read theirs when they are made const saved = readSaveData(medalsSaveName); for (const id in saved) if ((!medals[id] || medals[id].isLocal()) && saved[id] && typeof saved[id] === 'object') saved[id].unlocked = false; writeSaveData(medalsSaveName, saved); } medalsSave(); } // write the local medals to the save, keeping the entries of medals a service holds function medalsSave() { if (debugMedals || !medalsSaveName) return; // every saved entry stays: a medal not made this time keeps its unlock for when it is, since a player given a // medal back is better than one who lost it, and what is not a medal is the game's own, under the same name const saved = readSaveData(medalsSaveName); const data = {...saved}; medalsForEach(medal=> { if (!medal.isLocal()) { // a service holds this medal, its entry stays as it was for when it is local again if (saved[medal.id]) data[medal.id] = saved[medal.id]; return; } const entry = { name: medal.name, description: medal.description, icon: medal.icon, unlocked: medal.unlocked || !!saved[medal.id]?.unlocked, // another tab may have unlocked it since }; if (medal.image) entry.src = medal.image.src; data[medal.id] = entry; }); writeSaveData(medalsSaveName, data); } /////////////////////////////////////////////////////////////////////////////// /** * Medal - Tracks an unlockable medal * @memberof Medals * @example * // create a medal * const medal_example = new Medal(0, 'Example Medal', 'More info about the medal goes here.', '🎖️'); * * // initialize medals * medalsInit('Example Game'); * * // unlock the medal * medal_example.unlock(); */ class Medal { /** Create a medal and add it to the list of medals * @param {number} id - The unique identifier of the medal * @param {string} name - Name of the medal * @param {string} [description] - Description of the medal * @param {string} [icon] - Icon for the medal * @param {string} [src] - Image location for the medal */ constructor(id, name, description='', icon='🏆', src) { ASSERT(isNumber(id) && id >= 0 && !medals[id], 'medal id must be a unique number of 0 or more'); /** @property {number} - The unique identifier of the medal */ this.id = id; /** @property {string} - Name of the medal */ this.name = name; /** @property {string} - Description of the medal */ this.description = description; /** @property {string} - Icon for the medal */ this.icon = icon; /** @property {boolean} - Is the medal unlocked? */ this.unlocked = false; /** @property {HTMLImageElement|undefined} - Source image for the medal icon, if any * @type {HTMLImageElement|undefined} */ this.image = undefined; if (src) (this.image = new Image).src = src; // add this to list of medals, unlocked if the save says so when medalsInit came first medals[id] = this; if (medalsSaveName && !debugMedals && this.isLocal()) this.unlocked = !!readSaveData(medalsSaveName)[id]?.unlocked; } /** Unlocks a medal if not already unlocked * - The promise is optional, for when a game wants to know the outcome * @return {Promise} - Whether the medal is unlocked, right away unless a service like Newgrounds has to confirm */ unlock() { if (!medalsPreventUnlock && !this.unlocked) { this.unlocked = true; // saved now, or by medalsInit when it comes medalsSave(); medalsDisplayQueue.push(this); } return Promise.resolve(this.unlocked); } /** Whether the local save holds this medal, it is neither loaded nor written while a service like Newgrounds holds it * @return {boolean} */ isLocal() { return true; } /** Render a medal * @param {number} [hidePercent] - How much to slide the medal off screen */ render(hidePercent=0) { const context = mainContext; const width = min(medalDisplaySize.x, mainCanvasSize.x); const height = medalDisplaySize.y; const x = mainCanvasSize.x - width; const y = -height*hidePercent; const backgroundColor = hsl(0,0,.9); // draw containing rect and clip to that region context.save(); context.beginPath(); context.fillStyle = backgroundColor.toString(); context.strokeStyle = BLACK.toString(); const lineWidth = context.lineWidth = 3; context.rect(x + lineWidth/2, y + lineWidth/2, width - lineWidth, height - lineWidth); // the whole border shows context.fill(); context.stroke(); context.clip(); // draw the icon const gap = vec2(.1, .05).scale(height); const iconSize = height - 2*gap.x; this.renderIcon(vec2(x + gap.x + iconSize/2, y + height/2), iconSize); // draw the name const nameSize = height*.5; const descriptionSize = height*.3; const pos = vec2(x + iconSize + 2*gap.x, y + gap.y*2 + nameSize/2); const textWidth = width - iconSize - 3*gap.x; drawTextScreen(this.name, pos, nameSize, BLACK, 0, undefined, 'left', undefined, undefined, textWidth); // draw the description pos.y = y + height - gap.y*2 - descriptionSize/2; drawTextScreen(this.description, pos, descriptionSize, BLACK, 0, undefined, 'left', undefined, undefined, textWidth); context.restore(); } /** Render the icon for a medal * @param {Vector2} pos - Screen space position * @param {number} size - Screen space size */ renderIcon(pos, size) { // draw the image once it has loaded, or the icon; a broken image would throw const image = this.image; if (image && image.complete && image.naturalWidth) mainContext.drawImage(image, pos.x-size/2, pos.y-size/2, size, size); else drawTextScreen(this.icon, pos, size*.7, BLACK); } } /////////////////////////////////////////////////////////////////////////////// // Medals setting setters /** Set how long to show medals for in seconds * @param {number} time * @memberof Settings */ function setMedalDisplayTime(time) { medalDisplayTime = time; } /** Set how quickly to slide on/off medals in seconds * @param {number} time * @memberof Settings */ function setMedalDisplaySlideTime(time) { medalDisplaySlideTime = time; } /** Set size of medal display * @param {Vector2} size * @memberof Settings */ function setMedalDisplaySize(size) { medalDisplaySize = size.copy(); } /** Set to stop medals from being unlockable * @param {boolean} preventUnlock * @memberof Settings */ function setMedalsPreventUnlock(preventUnlock) { medalsPreventUnlock = preventUnlock; } /** * LittleJS Newgrounds Plugin * - NewgroundsMedal extends Medal with Newgrounds API functionality * - When logged in, Newgrounds holds the player's NewgroundsMedals: they unlock once the server confirms and the local save leaves them alone * - A plain Medal is never touched, so a game can use the plugin for scoreboards alone * - Without a session the medal and scoreboard lists still come in, so the medals get their names and icons; unlocking on the server and posting scores need a logged in player * - Create the medals as NewgroundsMedals with their ids on Newgrounds, then new NewgroundsPlugin(app_id, cipher); medalsInit is still needed, before or after * - Encrypts medal unlocks and posted scores, the calls Newgrounds secures, with the browser's own WebCrypto when the app has a cipher, no library needed * - Logs a view when it starts, and provides functions to unlock medals and to post and read scoreboards * - Tells the Newgrounds page around the game when a medal unlocks or a score posts, as the official client does * - Checks the session every minute when logged in, which keeps it alive, and plays as not logged in once it is lost * - Where the browser has AbortSignal.timeout, a request that takes longer than 15 seconds fails like one that could not reach the server * - Every call is a fetch, so the functions return promises; await newgrounds.ready for the medals and scoreboards * @namespace Newgrounds */ /** Global Newgrounds object * @type {NewgroundsPlugin} * @memberof Newgrounds */ let newgrounds; // Engine internal variables not exposed to documentation const newgroundsUnlocksToResend = new Set; // pending medals whose request did not reach the server const newgroundsUnlocksRefused = new Set; // medals the server refused this visit, asked again they answer no unsent const newgroundsSecureComponents = ['Medal.unlock', 'ScoreBoard.postScore']; // the calls encrypted with a cipher const newgroundsSessionErrors = [104, 110, 111]; // expired session, login required, session canceled const newgroundsTimeoutMS = 15e3; // how long a request may take before it fails // whether the server answered that the session is gone, as opposed to a request that failed on the way function newgroundsSessionLost(response) { return newgroundsSessionErrors.includes(response?.result?.data?.error?.code ?? response?.error?.code); } // tell the Newgrounds page around the game, with the message the official client sends function newgroundsNotifyPage(component, id) { globalThis.top?.postMessage(JSON.stringify({'ngioComponent':component, 'id':id}), '*'); } /////////////////////////////////////////////////////////////////////////////// /** * NewgroundsMedal: its id is the medal's id on the Newgrounds API Tools page; when logged in it only unlocks once the server confirms * @extends Medal * @memberof Newgrounds */ class NewgroundsMedal extends Medal { /** Create a NewgroundsMedal and add it to the list of medals * @param {number} id - The unique identifier of the medal * @param {string} name - Name of the medal * @param {string} [description] - Description of the medal * @param {string} [icon] - Icon for the medal * @param {string} [src] - Image location for the medal */ constructor(id, name, description, icon, src) { super(id, name, description, icon, src); /** @property {number|undefined} - Difficulty from the server once ready, 1 easy to 5 brutal * @type {number|undefined} */ this.difficulty = undefined; /** @property {number|undefined} - Point value from the server once ready * @type {number|undefined} */ this.value = undefined; } /** Whether the local save holds this medal, false while logged in when Newgrounds holds it * @return {boolean} */ isLocal() { return !newgrounds?.session_id; } /** Unlocks a medal if not already unlocked, once Newgrounds confirms it when logged in * - The promise is optional, for when a game wants to know the outcome * - A request that did not reach the server is sent again after the session check every minute, and calling * unlock again while the medal is pending returns the same promise * - One the server refused is not sent again this visit, calling unlock again resolves false without a request * - An answer that the session is gone makes the game play as not logged in, and the pending medals, this one too, * unlock locally unless unlocks are prevented; a refused one only unlocks if it is earned again * @return {Promise} - Whether the medal is unlocked, once the server has answered when logged in */ unlock() { if (medalsPreventUnlock || this.unlocked || this.isLocal()) return super.unlock(); // nothing to send, or not logged in and the local save holds the medal // logged in, Newgrounds holds the medal: it unlocks once the server confirms, one request at a time ASSERT(medalsSaveName, 'save name must be set'); if (newgroundsUnlocksRefused.has(this)) return Promise.resolve(false); // refused this visit, asking again will not change that const pending = newgrounds.pendingUnlocks; if (pending.has(this)) return pending.get(this); const request = newgrounds.unlockMedal(this.id).then(response=> { if (newgroundsSessionLost(response)) newgrounds.dropSession(); // the pending unlocks, this one too, are local now const serverMedal = response?.result?.data?.medal; if (!serverMedal?.unlocked || medalsPreventUnlock) { debugMedals && LOG('Newgrounds did not unlock medal', this.id, response?.result?.data?.error || response?.error); if (this.isLocal()) return this.unlocked; // the session dropped, the medal is local now if (!response || serverMedal?.unlocked) newgroundsUnlocksToResend.add(this); // did not reach the server, or confirmed while prevented: pending else { // refused, which will not change: no longer pending, so a session drop does not unlock it pending.delete(this); newgroundsUnlocksRefused.add(this); } return false; } const listed = newgrounds.medals.find(m=> m['id'] == this.id); listed && Object.assign(listed, serverMedal); // keep the fetched list in step if (serverMedal['icon'] && serverMedal['icon'] != this.image?.src) (this.image = new Image).src = serverMedal['icon']; // a secret medal shows its real icon once unlocked pending.delete(this); newgroundsNotifyPage('Medal.unlock', this.id); return super.unlock(); }); pending.set(this, request); return request; } } /////////////////////////////////////////////////////////////////////////////// /** * Newgrounds API object * @memberof Newgrounds */ class NewgroundsPlugin { /** Create the global newgrounds object * - Logs a view right away, for a guest and a logged in player alike, so a game does not have to * - Create the medals first: they take their name and icon from the server once it answers, and when logged in they are locked here until it does * - Call medalsInit too, before or after: an unlock asserts without it, and it keeps the medals while not logged in * @param {string} app_id - The Newgrounds App ID * @param {string} [cipher] - The encryption key from the app's settings, AES-128 as Base64; medal unlocks and posted * scores are encrypted with the browser's WebCrypto, which needs a secure page, https or localhost * @example * // create the newgrounds object, replace the app id with your own * const app_id = 'your_app_id_here'; * new NewgroundsPlugin(app_id); */ constructor(app_id, cipher) { ASSERT(!newgrounds, 'there can only be one newgrounds object'); ASSERT(!cipher || typeof crypto != 'undefined' && crypto.subtle, 'a cipher needs WebCrypto, which the browser only has on a secure page'); ASSERT(!cipher || /^[A-Za-z0-9+/]{22}==$/.test(cipher), 'the cipher must be the Base64 AES-128 key from the app settings'); newgrounds = this; // set global newgrounds object /** @property {string} - The Newgrounds App ID */ this.app_id = app_id; /** @property {string|undefined} - AES-128/Base64 encryption key, if any * @type {string|undefined} */ this.cipher = cipher; /** @type {CryptoKey|undefined} */ this.cryptoKey = undefined; // the cipher imported for WebCrypto, on the first encrypted call const hasLocation = typeof location != 'undefined'; /** @property {string} - Hostname sent with the view the plugin logs when it starts */ this.host = hasLocation ? location.hostname : ''; /** @property {Array} - Medals fetched from Newgrounds, empty until ready, with the unlocks only when logged in * @type {Array} */ this.medals = []; /** @property {Array} - Scoreboards fetched from Newgrounds, empty until ready * @type {Array} */ this.scoreboards = []; /** @type {Map} */ this.loadFailures = new Map; // why each slot's last load gave undefined, see loadFailure /** @property {{id: number, name: string, url: string, supporter: boolean}|null} - The logged in player once ready, null when not logged in * @type {{id: number, name: string, url: string, supporter: boolean}|null} */ this.user = null; /** @property {Map>} - Medals whose unlock is in flight or waiting to be resent, with their request's promise; one the server refused leaves it * @type {Map>} */ this.pendingUnlocks = new Map; // get session id from url search params /** @property {string|null} - Newgrounds session id from the URL, null when not logged in or once the session is lost * @type {string|null} */ this.session_id = hasLocation ? new URL(location.href).searchParams.get('ngio_session_id') : null; // Newgrounds holds this player's NewgroundsMedals: locked until the server says otherwise, the local save leaves them alone if (this.session_id) medalsForEach(medal=> medal instanceof NewgroundsMedal && (medal.unlocked = false)); /** @property {Promise} - Resolves once the session is checked and the lists are in, empty if the server could not be reached */ this.ready = this.init(); } /** Log the view, check the session, fetch the medals and scoreboards, then keep the session alive; the constructor runs it once * @private */ async init() { this.call('App.logView', {'host':this.host}); // every view counts, guest or logged in let medalList; if (this.session_id) { // the player is logged in when the server knows the session, it has a user and the medals come in const sessionResult = await this.call('App.checkSession'); const session = sessionResult?.result?.data?.['session']; const user = session && !session['expired'] && session['user']; medalList = user && (await this.call('Medal.getList'))?.result?.data?.['medals']; if (medalList && this.session_id) this.user = user; else { this.dropSession(); // without the server (offline / bad session / server error), or lost meanwhile medalList = undefined; // its unlocks belong to the lost session } } // not logged in, the list comes too, without the unlocks medalList = medalList || (await this.call('Medal.getList'))?.result?.data?.['medals']; this.medals = medalList || []; debugMedals && LOG(this.medals); for (const newgroundsMedal of this.medals) { const medal = medals[newgroundsMedal['id']]; if (medal instanceof NewgroundsMedal) // a plain medal with the same id is left alone { // copy the server's medal data medal.image = new Image; medal.image.src = newgroundsMedal['icon']; medal.name = newgroundsMedal['name']; medal.description = newgroundsMedal['description']; medal.unlocked = medal.unlocked || !!newgroundsMedal['unlocked']; // keeps a local unlock, or one that landed first medal.difficulty = newgroundsMedal['difficulty']; medal.value = newgroundsMedal['value']; if (medal.value) // add value to description medal.description += ` (${ medal.value })`; if (this.session_id && medal.unlocked) { newgroundsMedal['unlocked'] = true; // the list says so too this.pendingUnlocks.delete(medal); // and a request waiting to be resent, or refused, is done newgroundsUnlocksToResend.delete(medal); newgroundsUnlocksRefused.delete(medal); } } } const scoreboardResult = await this.call('ScoreBoard.getBoards'); this.scoreboards = scoreboardResult?.result?.data?.scoreboards || []; debugMedals && LOG(this.scoreboards); if (!this.session_id) return this; // logged in, check the session every minute, which keeps it alive, and resend the unlocks that did not reach the server const keepAlive = setInterval(async ()=> { if (!this.session_id) return clearInterval(keepAlive); const response = await this.call('App.checkSession'); const session = response?.result?.data?.['session']; if (newgroundsSessionLost(response) || session && (session['expired'] || !session['user'])) return this.dropSession(); this.resendUnlocks(); }, 60e3); return this; } /** Play as not logged in from now on: the NewgroundsMedals come back from the local save, keeping the unlocks the * server confirmed meanwhile, and the unlocks still pending unlock locally; a refused one only if it is earned again * - internal, the medals call it too when the server says the session is gone * @ignore */ dropSession() { if (!this.session_id) return; debugMedals && LOG('Newgrounds session unavailable; medals are local'); const confirmed = Object.values(medals).filter(medal=> medal.unlocked); this.session_id = null; this.user = null; medalsLoad(); // the NewgroundsMedals are local again, back from the save confirmed.forEach(medal=> medal.unlocked = true); medalsSave(); // the unlocks still out are local too, they unlock now, or are dropped like any local unlock while prevented const pending = [...this.pendingUnlocks.keys()]; this.pendingUnlocks.clear(); newgroundsUnlocksToResend.clear(); newgroundsUnlocksRefused.clear(); for (const medal of pending) medal.unlock(); } /** Send the unlocks whose request did not reach the server again, which the session check does every minute * - A request still out is left to answer, and while unlocks are prevented they wait */ resendUnlocks() { if (medalsPreventUnlock) return; for (const medal of newgroundsUnlocksToResend) { this.pendingUnlocks.delete(medal); medal.unlock(); // a failure is only added back once the new request answers } newgroundsUnlocksToResend.clear(); } /** Send a request to unlock a medal by id, the local medal is not changed; NewgroundsMedal.unlock sends this and waits for the answer * @param {number} id - The medal id * @return {Promise} - The response JSON object, undefined when the call failed */ unlockMedal(id) { return this.call('Medal.unlock', {'id':id}); } /** Send message to post score * @param {number} id - The scoreboard id * @param {number} value - The score value, a whole number * @return {Promise} - The response JSON object, undefined when the call failed; result.data.success says whether * it posted, which needs a logged in player; an answer that the session is gone makes the game play as not logged * in, and one that timed out may still have posted */ postScore(id, value) { return this.call('ScoreBoard.postScore', {'id':id, 'value':value}).then(response=> { response?.result?.data?.success && newgroundsNotifyPage('ScoreBoard.postScore', id); newgroundsSessionLost(response) && this.dropSession(); return response; }); } /** Get scores from a scoreboard * @param {number} id - The scoreboard id * @param {string|number} [user] - A user's id or name, to load only their scores * @param {boolean} [social] - If true, only the scores of the user and their friends, the logged in player when user is left out * @param {number} [skip] - Number of scores to skip over * @param {number} [limit] - Number of scores to include in the list * @param {string} [period] - 'D' today, which the server assumes when left out, 'W' this week, 'M' this month, 'Y' this year or 'A' all time * @return {Promise} - The response JSON object, undefined when the call failed; the scores are in * result.data.scores, each with user.name, value and formatted_value; without a user or social it is the whole board */ getScores(id, user, social=false, skip=0, limit=10, period) { // the whole board goes without the session, which the server would narrow down to the logged in player const session_id = user || social ? this.session_id : null; const parameters = {'id':id, 'user':user, 'social':social, 'skip':skip, 'limit':limit, 'period':period}; return this.call('ScoreBoard.getScores', parameters, session_id); } /** Save a value to one of the player's cloud save slots, which needs a logged in player * - Any value JSON can hold; the slots are numbered from 1, as many as the app's Newgrounds settings give it * @param {number} slot - The slot number * @param {*} data - The value to save * @return {Promise} - Whether it saved, false when not logged in * @example * newgrounds.cloudSave(1, {level, coins}); */ async cloudSave(slot, data) { ASSERT(isNumber(slot), 'Newgrounds cloudSave: slot must be a number', slot); if (!this.session_id) return false; let text; try { text = JSON.stringify(data); } catch (error) {} if (text === undefined) return console.warn('Newgrounds cloudSave: slot ' + slot + ' was given a value JSON can not hold'), false; const response = await this.call('CloudSave.setData', {'id':slot, 'data':text}); newgroundsSessionLost(response) && this.dropSession(); return !!response?.result?.data?.['success']; } /** Load the value a cloud save slot holds: null when Newgrounds says it holds none, undefined when it could not be * loaded or the player is not logged in, said in the console when logged in * - Do not save over a slot that loaded as undefined, it may hold the player's save; loadFailure(slot) says why * it did: 'notSave' for a file there that is not a save, where a game may offer to start over, 'failed' otherwise * - A saved null loads as null, as an empty slot does * @param {number} slot - The slot number * @return {Promise<*>} - The value saved, null for none, undefined for a load that failed * @example * const save = await newgrounds.cloudLoad(1); */ async cloudLoad(slot) { ASSERT(isNumber(slot), 'Newgrounds cloudLoad: slot must be a number', slot); const {value, failure} = await this.loadSlot(slot); failure ? this.loadFailures.set(+slot, failure) : this.loadFailures.delete(+slot); return value; } /** Why a slot's last cloudLoad gave undefined: 'notSave' when the slot holds a file that is not a save, where a * game may offer to start over, 'failed' when it could not be loaded, as when not logged in, where a game must * not save over it; undefined when it loaded a save or found the slot empty, or was not loaded * @param {number} slot - The slot number * @return {'failed'|'notSave'|undefined} */ loadFailure(slot) { return this.loadFailures.get(+slot); } /** A slot's load, its value and why it failed, if it did * @param {number} slot * @return {Promise<{value: *, failure: 'failed'|'notSave'|undefined}>} * @ignore */ async loadSlot(slot) { const failed = (message)=> { message && console.warn('Newgrounds could not load slot ' + slot + message); return {value: undefined, failure: /** @type {'failed'} */ ('failed')}; }; if (!this.session_id) return failed(''); const response = await this.call('CloudSave.loadSlot', {'id':slot}); newgroundsSessionLost(response) && this.dropSession(); const slotData = response?.result?.data?.['slot']; if (!slotData) return failed('.'); const url = slotData['url']; // where the saved text is, none for an empty slot if (!url) return {value: null, failure: undefined}; let text; try { const signal = globalThis.AbortSignal?.timeout?.(newgroundsTimeoutMS); const saved = await fetch(url, {'cache':'no-store', 'signal':signal}); if (!saved.ok) // an error page is not the save, even one that is JSON return failed(': ' + saved.status); text = await saved.text(); } catch(e) { return failed(': ' + e); } if (!text) return failed(', it came back empty'); // a save is never empty try { return {value: JSON.parse(text), failure: undefined}; } catch(e) { console.warn('Newgrounds cloudLoad: slot ' + slot + ' holds a file that is not a save'); return {value: undefined, failure: 'notSave'}; } } /** Count an event of the game's own on its Newgrounds stats page, like a level finished or a button pressed * @param {string} name - The event's name * @return {Promise} - The response JSON object, undefined when the call failed */ logEvent(name) { ASSERT(typeof name === 'string' && name !== '', 'Newgrounds logEvent: name must be a string', name); return this.call('Event.logEvent', {'event_name':name, 'host':this.host}); } /** Encrypt text the way the Newgrounds gateway expects, AES-128 CBC with a random iv in front, as Base64 * @param {string} text * @return {Promise} */ async encrypt(text) { if (!this.cryptoKey) { const keyBytes = Uint8Array.from(atob(this.cipher), c=> c.charCodeAt(0)); this.cryptoKey = await crypto.subtle.importKey('raw', keyBytes, 'AES-CBC', false, ['encrypt']); } const iv = crypto.getRandomValues(new Uint8Array(16)); const encrypted = new Uint8Array(await crypto.subtle.encrypt({'name':'AES-CBC', iv}, this.cryptoKey, new TextEncoder().encode(text))); const bytes = new Uint8Array(iv.length + encrypted.length); bytes.set(iv); bytes.set(encrypted, iv.length); let binary = ''; for (const b of bytes) binary += String.fromCharCode(b); return btoa(binary); } /** Send a message to call a component of the Newgrounds API * @param {string} component - Name of the component * @param {Object} [parameters] - Parameters to use for call * @param {string|null} [session_id] - The session to send, the player's by default * @return {Promise} - The response JSON object, undefined when the call failed or took over 15 seconds; * a component's own success and error are in result.data, and a cipher that is not a key gives error 201 */ async call(component, parameters, session_id=this.session_id) { const url = 'https://www.newgrounds.io/gateway_v3.php'; try { /** @type {Object} */ let execute = {'component':component, 'parameters':parameters}; if (this.cipher && newgroundsSecureComponents.includes(component)) { // only the encrypted call goes; a key that will not even import is refused here the way the server // refuses a wrong one, since sending again cannot fix it const secure = await this.encrypt(JSON.stringify(execute)).catch(e=> debugMedals && LOG('Newgrounds cipher failed', e)); if (!secure) return {'success':false, 'error':{'code':201, 'message':'Invalid Encryption: the cipher is not a Base64 AES-128 key'}}; execute = {'secure': secure}; } // build the request object, in the form the Newgrounds.io docs give const request = { 'app_id': this.app_id, 'session_id': session_id, 'execute': execute }; // send it as post data const formData = new FormData(); formData.append('request', JSON.stringify(request)); const signal = globalThis.AbortSignal?.timeout?.(newgroundsTimeoutMS); // a stalled request fails const response = await fetch(url, {'method':'POST', 'body':formData, 'signal':signal}); const text = await response.text(); debugMedals && LOG(text); return text ? JSON.parse(text) : undefined; } catch(e) { debugMedals && LOG('newgrounds call failed', e); } } } /** * LittleJS Wavedash Plugin * - The Wavedash twin of the Newgrounds plugin: achievements, leaderboards and cloud saves, the same shape so a game * can switch, and stats and presence of Wavedash's own * - Wavedash serves the game's page and puts its SDK in window.Wavedash before the game runs, so nothing is bundled; * off Wavedash (local, itch, GitHub Pages) there is none, and every call does nothing * - Make the plugin when the game can draw, at the end of gameInit: Wavedash.init is called then, and until it is * Wavedash keeps its loading screen over the game * - WavedashMedal is a Medal with the identifier of its Wavedash achievement; on Wavedash an unlock is sent as that * achievement and Wavedash shows its own toast in place of the engine's popup, off it the medal unlocks as any does * - Wavedash refuses an achievement until it has loaded the player's, a moment after launch, so refused and earlier * unlocks are sent again every two seconds until it takes them; medals unlocked before, in the save, are sent too * - Leaderboards are made from code: give the plugin a table of them, each with how it sorts and shows its scores, * and they are made at once, so each one exists from the first launch * - The SDK checks its arguments' types and throws on a wrong one, so every call passes real booleans and whole * numbers and is caught; a game built with its own minifier keeps the SDK's names, see REFERENCE * @namespace Wavedash */ /** Global Wavedash plugin object * @type {WavedashPlugin} * @memberof Wavedash */ let wavedash; // Engine internal variables not exposed to documentation const wavedashRetryMS = 2e3; // how long to wait before sending refused achievements again const wavedashRetries = 30; // how many times one is sent before it is taken as never to be, about a minute const wavedashTimeoutMS = 15e3; // how long a leaderboard call may take, as the Newgrounds plugin waits const wavedashDisplayTypes = {number: 0, seconds: 1, milliseconds: 2, ticks: 3}; // the SDK's display types // the SDK Wavedash put on the page, read at each call, undefined off Wavedash const wavedashSDK = ()=> globalThis['Wavedash']; // the file a cloud save slot is kept in const wavedashSavePath = (slot)=> 'saves/slot' + slot + '.json'; // a call's answer, or undefined if it does not come in time or fails, with a warning; the time limit is let go of once // the answer is in, so nothing waits on it after function wavedashWait(name, answer, quiet=false) { let timer; const timedOut = {}; // what the time limit gives, told apart from any answer const limit = new Promise((resolve)=> timer = setTimeout(()=> resolve(timedOut), wavedashTimeoutMS)); return Promise.race([Promise.resolve(answer), limit]) .then((value)=> value !== timedOut ? value : quiet ? undefined : void console.warn('Wavedash ' + name + ' took over ' + wavedashTimeoutMS / 1e3 + ' seconds'), (error)=> { console.warn('Wavedash ' + name + ' failed: ' + error); }) .finally(()=> clearTimeout(timer)); } // a value as the text a cloud save keeps, undefined with a warning for one JSON can not hold, as a circular object, // a BigInt, a function or undefined function wavedashSaveText(slot, data) { let text; try { text = JSON.stringify(data); } catch (error) {} text === undefined && console.warn('Wavedash cloudSave: slot ' + slot + ' was given a value JSON can not hold'); return text; } /////////////////////////////////////////////////////////////////////////////// /** * WavedashMedal: a medal that is also a Wavedash achievement, unlocked on Wavedash by its identifier * @extends Medal * @memberof Wavedash * @example * const medal_finish = new WavedashMedal(0, 'ACH_01_FINISH', 'Finish', 'Finish a level'); * medal_finish.unlock(); // Wavedash's toast on Wavedash, the engine's popup anywhere else */ class WavedashMedal extends Medal { /** Create a WavedashMedal and add it to the list of medals * @param {number} id - The unique identifier of the medal, as for any Medal * @param {string} achievement - The identifier of its Wavedash achievement, as made with the Wavedash CLI * @param {string} name - Name of the medal * @param {string} [description] - Description of the medal * @param {string} [icon] - Icon for the medal * @param {string} [src] - Image location for the medal */ constructor(id, achievement, name, description, icon, src) { ASSERT(typeof achievement === 'string' && achievement !== '', 'WavedashMedal: achievement must be the identifier of a Wavedash achievement', achievement); super(id, name, description, icon, src); /** @property {string} - The identifier of its Wavedash achievement */ this.achievement = achievement; } /** Unlocks the medal if not already unlocked, saved as any medal is; on Wavedash its achievement is sent, until * Wavedash takes it, and Wavedash shows its own toast in place of the engine's popup; one unlocked before the * plugin is made is sent when it is * @return {Promise} - Whether the medal is unlocked */ unlock() { // off Wavedash, or testing medals with debugMedals, the engine's own popup and nothing sent if (medalsPreventUnlock || this.unlocked || !wavedashSDK() || debugMedals) return super.unlock(); this.unlocked = true; medalsSave(); wavedash?.sendAchievements(); return Promise.resolve(true); } } /////////////////////////////////////////////////////////////////////////////// /** * Wavedash plugin: starts the Wavedash SDK, sends achievements and posts and reads leaderboards * @memberof Wavedash * @example * // at the end of gameInit, with the leaderboards the game posts to * new WavedashPlugin({LEVEL_1: {lowerWins: true, display: 'milliseconds'}, HIGH_SCORE: {}}); * wavedash.postScore('LEVEL_1', timeMs); */ class WavedashPlugin { /** Start the Wavedash SDK when the game is on Wavedash, and make its leaderboards * @param {Object} [leaderboards] - The game's leaderboards by * name: lowerWins for times and golf, where a lower score is better, higher wins when left out; display how * Wavedash shows a score, 'number', 'seconds', 'milliseconds' or 'ticks' (60 a second), 'number' when left out */ constructor(leaderboards={}) { ASSERT(!wavedash, 'WavedashPlugin already initialized'); ASSERT(!!leaderboards && typeof leaderboards === 'object', 'WavedashPlugin: leaderboards is an object of them by name'); wavedash = this; /** @property {Object>} - Each leaderboard's id by its name, once Wavedash * has made or found it, undefined when it could not * @type {Object>} */ this.leaderboards = Object.create(null); // no inherited names, a board may be called constructor /** @type {Object} */ this.leaderboardSettings = leaderboards; // how each one sorts and shows its scores, read as its own entries /** @type {Set} */ this.achievementsSent = new Set; // the achievements Wavedash took this visit, or refused for good /** @type {Object} */ this.achievementTries = Object.create(null); // how many times each one not taken yet was sent /** @type {number|undefined} */ this.achievementRetry = undefined; // the timer that sends refused ones again /** @type {Promise|undefined} */ this.stats = undefined; // the player's stats loading, asked for the first time a stat is /** @type {Map>} */ this.slotQueues = new Map; // each slot's last save or load, the next waits for it, as they share a file /** @type {Map>>} */ this.slotCalls = new Map; // each slot's file calls Wavedash has not answered, a timed out one may still change it /** @type {Map} */ this.loadFailures = new Map; // why each slot's last load gave undefined, by its file, see loadFailure // Wavedash keeps its loading screen until init, which is called once wavedashWait('init', this.call('init')); for (const name in leaderboards) this.leaderboard(name); this.sendAchievements(); // the medals the save already has, unlocked before or off Wavedash } /** Whether the game is on Wavedash, its SDK on the page * @return {boolean} */ isActive() { return !!wavedashSDK(); } /** Call the SDK, undefined off Wavedash; a call that throws, as on an argument of the wrong type, says so in * the console and gives undefined * @param {string} name - The SDK function * @param {...*} args * @return {*} * @ignore */ call(name, ...args) { const sdk = wavedashSDK(); if (!sdk) return; try { return sdk[name](...args); } catch (error) { console.warn('Wavedash ' + name + ' failed: ' + error); } } /** A leaderboard's id by its name, made on Wavedash the first time it is asked for, with the settings it was given * @param {string} name * @return {Promise} * @ignore */ leaderboard(name) { ASSERT(typeof name === 'string' && name !== '', 'Wavedash: a leaderboard name must be a string', name); if (this.leaderboards[name]) return this.leaderboards[name]; const settings = Object.prototype.hasOwnProperty.call(this.leaderboardSettings, name) ? this.leaderboardSettings[name] : undefined; // its own entry, not an inherited one const {lowerWins=false, display='number'} = settings || {}; const known = wavedashDisplayTypes.hasOwnProperty(display); ASSERT(known, 'Wavedash: a leaderboard display is number, seconds, milliseconds or ticks', display); const made = this.call('getOrCreateLeaderboard', name, lowerWins ? 0 : 1, known ? wavedashDisplayTypes[display] : 0); const id = wavedashWait('leaderboard ' + name, made).then((result)=> result?.['success'] ? result['data']?.['id'] : undefined); // one that could not be made is asked for again next time id.then((value)=> value === undefined && this.leaderboards[name] === id && delete this.leaderboards[name]); return this.leaderboards[name] = id; } /** Post a score to a leaderboard, Wavedash keeping the player's best; off Wavedash it does nothing * - A leaderboard not in the table given to the plugin is made the first time, higher wins and shown as a number * @param {string} name - The leaderboard's name * @param {number} score - A whole number, milliseconds for a time * @return {Promise} - Whether it was posted */ async postScore(name, score) { ASSERT(isNumber(score), 'Wavedash postScore: score must be a number', score); if (!this.isActive()) return false; const id = await this.leaderboard(name); if (id === undefined) return false; const result = await wavedashWait('postScore', this.call('uploadLeaderboardScore', id, round(score), true)); return !!result?.['success']; } /** Read a leaderboard's entries, undefined off Wavedash or when it could not be read * @param {string} name - The leaderboard's name * @param {number} [offset] - How many entries to skip over * @param {number} [limit] - How many entries to read * @param {boolean} [friendsOnly] - Only the player and their friends * @return {Promise|undefined>} - The entries, as Wavedash gives them */ async getScores(name, offset=0, limit=10, friendsOnly=false) { if (!this.isActive()) return; const id = await this.leaderboard(name); if (id === undefined) return; const result = await wavedashWait('getScores', this.call('listLeaderboardEntries', id, offset|0, limit|0, !!friendsOnly)); return result?.['success'] ? result['data'] : undefined; } /** Save a value to one of the player's cloud save slots, kept by Wavedash across devices; off Wavedash it does * nothing * - Any value JSON can hold, stored as the file saves/slot#.json * - Wavedash takes at most 30 saves a minute and 300 an hour from a player, so save at checkpoints, the end of a * level or when asked, not every frame; one over the limit is refused, said in the console * @param {number} slot - The slot number, any whole number the game picks * @param {*} data - The value to save * @return {Promise} - Whether it saved * @example * wavedash.cloudSave(1, {level, coins}); */ async cloudSave(slot, data) { ASSERT(isNumber(slot), 'Wavedash cloudSave: slot must be a number', slot); if (!this.isActive()) return false; const text = wavedashSaveText(slot, data); // as it is now, the save waits its turn if (text === undefined) return false; return this.slotQueue(slot, false, async (track)=> { // written first, and only a write Wavedash says it made is uploaded, never an older file at that path const path = wavedashSavePath(slot); const written = await wavedashWait('cloudSave', track(this.call('writeLocalFile', path, new TextEncoder().encode(text)))); if (written !== true && !written?.['success']) { written && console.warn('Wavedash refused writing slot ' + slot + ': ' + (written['message'] ?? written['error'] ?? 'no reason given')); return false; } const result = await wavedashWait('cloudSave', track(this.call('uploadRemoteFile', path))); result && !result['success'] && console.warn('Wavedash refused cloudSave of slot ' + slot + ': ' + (result['message'] ?? result['error'] ?? 'no reason given')); return !!result?.['success']; }); } /** Load the value a cloud save slot holds: null when Wavedash says it holds none, undefined when it could not be * loaded or off Wavedash, said in the console on Wavedash * - Do not save over a slot that loaded as undefined, it may hold the player's save; loadFailure(slot) says why * it did: 'notSave' for a file there that is not a save, where a game may offer to start over, 'failed' otherwise * - A saved null loads as null, as an empty slot does * @param {number} slot - The slot number * @return {Promise<*>} - The value saved, null for none, undefined for a load that failed * @example * const save = await wavedash.cloudLoad(1); */ async cloudLoad(slot) { ASSERT(isNumber(slot), 'Wavedash cloudLoad: slot must be a number', slot); const failed = {value: undefined, failure: /** @type {'failed'} */ ('failed')}; const {value, failure} = this.isActive() ? await this.slotQueue(slot, failed, (track)=> this.loadSlot(slot, track)) : failed; const key = wavedashSavePath(slot); failure ? this.loadFailures.set(key, failure) : this.loadFailures.delete(key); return value; } /** Why a slot's last cloudLoad gave undefined: 'notSave' when the slot holds a file that is not a save, where a * game may offer to start over, 'failed' when it could not be loaded, as off Wavedash or on a timeout, where a * game must not save over it; undefined when it loaded a save or found the slot empty, or was not loaded * @param {number} slot - The slot number * @return {'failed'|'notSave'|undefined} */ loadFailure(slot) { return this.loadFailures.get(wavedashSavePath(slot)); } /** A slot's load, its value and why it failed, if it did * @param {number} slot * @param {function(*): *} track * @return {Promise<{value: *, failure: 'failed'|'notSave'|undefined}>} * @ignore */ async loadSlot(slot, track) { const path = wavedashSavePath(slot); const failed = (message)=> { console.warn('Wavedash ' + message); return {value: undefined, failure: /** @type {'failed'} */ ('failed')}; }; const downloaded = await wavedashWait('cloudLoad', track(this.call('downloadRemoteFile', path))); if (downloaded !== true && !downloaded?.['success']) // an answer of true or of {success: true} { // a file Wavedash says is not there is an empty slot, anything else a load that failed; it may answer // false or {success: true, data: false} const exists = await wavedashWait('cloudLoad', track(this.call('remoteFileExists', path))); if (exists === false || exists?.['success'] && exists['data'] === false) return {value: null, failure: undefined}; return failed('could not load slot ' + slot); } // the file's text, its bytes given as they are or in a response's data; none is a read that failed, as a // save is never empty const answer = await wavedashWait('cloudLoad', track(this.call('readLocalFile', path))); const bytes = answer instanceof Uint8Array || answer instanceof ArrayBuffer || typeof answer == 'string' ? answer : answer?.['data']; const text = !bytes ? '' : typeof bytes == 'string' ? bytes : new TextDecoder().decode(bytes); if (!text) return failed('could not read slot ' + slot); try { return {value: JSON.parse(text), failure: undefined}; } catch (error) { console.warn('Wavedash cloudLoad: slot ' + slot + ' holds a file that is not a save'); return {value: undefined, failure: 'notSave'}; } } /** Run a slot's save or load after the one before it, as they share the slot's local file; other slots go on * at once, and one that fails does not hold up the next * - A call that timed out may still change the file when Wavedash gets to it, so the next waits for it to be * answered too, and gives up with failed when it is not, leaving the file alone * @param {number} slot * @param {*} failed - What the task gives when it can not run * @param {function(function(*): *): Promise<*>} task - Given track, which keeps a call's answer until it is in * @return {Promise<*>} * @ignore */ slotQueue(slot, failed, task) { const key = wavedashSavePath(slot); // by its file, so a slot given as '1' and as 1 share one const calls = this.slotCalls.get(key) || new Set; this.slotCalls.set(key, calls); const track = (answer)=> { if (typeof answer?.then == 'function') // any promise, one made elsewhere too { calls.add(answer); const done = ()=> { calls.delete(answer); // the last of them, answered after the slot was let go of, lets go of its calls too calls.size || this.slotQueues.has(key) || this.slotCalls.get(key) !== calls || this.slotCalls.delete(key); }; answer.then(done, done); } return answer; }; const start = async ()=> { if (calls.size && !await wavedashWait('slot ' + slot, Promise.allSettled([...calls]).then(()=> true), true)) { console.warn('Wavedash slot ' + slot + ' is still busy with a call that timed out, not used'); return failed; } return task(track); }; const run = (this.slotQueues.get(key) || Promise.resolve()).then(start, start); const done = run.catch(()=> {}); this.slotQueues.set(key, done); done.then(()=> { // the slot is let go of once nothing waits on it, and its calls with it once all are answered if (this.slotQueues.get(key) !== done) return; this.slotQueues.delete(key); calls.size || this.slotCalls.get(key) !== calls || this.slotCalls.delete(key); }); return run; } /** Set one of the player's stats, as made for the game in the Wavedash developer portal, which can unlock an * achievement set to follow it; Wavedash keeps it about a second later, or at once with storeNow; off Wavedash it * does nothing * @param {string} name - The stat's identifier * @param {number} value * @param {boolean} [storeNow] - Keep it now, as at the end of a game, not a second later * @return {Promise} - Whether Wavedash took it */ async setStat(name, value, storeNow=false) { ASSERT(typeof name === 'string' && name !== '', 'Wavedash setStat: name must be a string', name); ASSERT(isNumber(value), 'Wavedash setStat: value must be a number', value); if (!this.isActive() || !await this.statsLoaded()) return false; return this.call('setStat', name, value, !!storeNow) === true; } /** Read one of the player's stats, 0 for one never set or off Wavedash * @param {string} name - The stat's identifier * @return {Promise} */ async getStat(name) { if (!this.isActive() || !await this.statsLoaded()) return 0; const value = this.call('getStat', name); return isNumber(value) ? value : 0; } /** The player's stats loaded from Wavedash, which they must be before they are read or set, asked for the first * time a stat is * @return {Promise} - Whether they loaded * @ignore */ statsLoaded() { return this.stats ||= wavedashWait('requestStats', this.call('requestStats')).then((result)=> { const loaded = !!result?.['success']; loaded || (this.stats = undefined); // asked for again next time return loaded; }); } /** Set what the player is doing, kept with their presence on Wavedash; off Wavedash it does nothing * @param {string} [status] - One line of what they are doing, like 'In a race' * @param {string} [details] - More about it, like 'Lap 2 of 3'; both left out clears it * @return {Promise} - Whether it was set */ async setPresence(status, details) { if (!this.isActive()) return false; const presence = {}; status === undefined || (presence['status'] = String(status)); details === undefined || (presence['details'] = String(details)); const result = await wavedashWait('setPresence', this.call('updateUserPresence', presence)); return !!result?.['success']; } /** Send every unlocked WavedashMedal's achievement Wavedash has not taken yet, again every two seconds while it * refuses some, as it does until it has loaded the player's achievements; one refused for about a minute is * taken as an identifier Wavedash does not have, said once in the console, and not sent again this visit * @ignore */ sendAchievements() { if (!this.isActive() || debugMedals) return; let refused = false; medalsForEach((medal)=> { const achievement = medal instanceof WavedashMedal && medal.unlocked && medal.achievement; if (!achievement || this.achievementsSent.has(achievement)) return; const result = this.call('setAchievement', achievement, true); if (result === true || result?.['success'] === true) this.achievementsSent.add(achievement); else if ((this.achievementTries[achievement] = (this.achievementTries[achievement] || 0) + 1) < wavedashRetries) refused = true; else { console.warn('Wavedash refused achievement ' + achievement + ' ' + wavedashRetries + ' times; is it made, with that identifier?'); this.achievementsSent.add(achievement); // not sent again this visit } }); clearTimeout(this.achievementRetry); this.achievementRetry = refused ? setTimeout(()=> this.sendAchievements(), wavedashRetryMS) : undefined; } } /** * LittleJS Post Processing Plugin * - Supports shadertoy style post processing shaders * - call new PostProcessPlugin() to setup post processing * - can be enabled to pass other canvases through a final shader * - iResolution is the canvas backing store, so it grows with canvasPixelRatio * like shadertoy does. Effects that use it only for uv (p/iResolution.xy) are * unaffected, but ones that set a feature size from it, like scan lines, get * finer as the ratio rises. Divide by getCanvasPixelRatio() to pin them. * @namespace PostProcess */ /////////////////////////////////////////////////////////////////////////////// /** Global Post Process plugin object * @type {PostProcessPlugin} * @memberof PostProcess */ let postProcess; ///////////////////////////////////////////////////////////////////////// /** * Post Process Plugin - Applies a full screen shader to the rendered output * - Create it after any plugin that draws, since plugins render in the order they are made * and this one shades what is on the canvas when its turn comes * - It runs after gameRenderPost, so a HUD drawn there with WebGL is shaded (and bloomed) too; * draw the HUD with useWebGL=false (the main canvas) or from a plugin created after this one * @memberof PostProcess */ class PostProcessPlugin { /** Create global post processing shader * @param {string} [shaderCode] - Shadertoy style mainImage code, or an effect alone like postProcessVignette(), * which postProcessEffects joins; a pass-through when left out * @param {boolean} [includeMainCanvas] - combine mainCanvas onto glCanvas * @param {boolean} [feedbackTexture] - also pass the shader's own output from the previous frame as iChannel1, * for trails and echoes; iChannel0 is still the frame just drawn * @example * // create the post process plugin object * new PostProcessPlugin(shaderCode); */ constructor(shaderCode, includeMainCanvas=false, feedbackTexture=false) { ASSERT(engineInitialized || headlessMode, 'create the plugin after engineInit, e.g. in gameInit'); ASSERT(!postProcess, 'Post process already initialized'); ASSERT(!(includeMainCanvas && feedbackTexture), 'Post process cannot both include main canvas and use feedback texture'); postProcess = this; /** @property {string} - The shadertoy style mainImage code it shades with, see setShaderCode */ this.shaderCode = postProcessShaderCode(shaderCode); /** @property {boolean} - Is the pass on? Off, the frame shows as the engine drew it, the shader is kept, * and the feedback texture holds the last frame drawn with it on */ this.enabled = true; /** @property {Object>} - The game's own values for the shader, a uniform each * by its name, a number a float and a list of 2 to 4 numbers a vector, set every frame as they are; an * effect setting can be one of these names, so it changes every frame without making the shader again, all * but glow's size, which sets how many samples it takes; adding or removing a name makes the shader again, * and the shader is first made at the first render, so values set right after the plugin are in it; a name * is a GLSL name not starting with an underscore, which the effects keep for their own, nor i and a capital * or gl_, and not c, uv or p, the names mainImage works on * @type {Object>} */ this.values = {}; // the names of the values the shader was made with this.valueNames = ''; /** @property {WebGLProgram|undefined} - Shader for post processing * @type {WebGLProgram|undefined} */ this.shader = undefined; /** @property {WebGLTexture|undefined} - Texture for post processing * @type {WebGLTexture|undefined} */ this.texture = undefined; /** @property {WebGLTexture|undefined} - The previous frame's output, iChannel1, when feedbackTexture is set * @type {WebGLTexture|undefined} */ this.feedbackTexture = undefined; /** @property {WebGLVertexArrayObject|undefined} - Vertex array object * @type {WebGLVertexArrayObject|undefined} */ this.vao = undefined; // the shader is made at the first render, so values the game sets after this, which its code may name, are // declared in it !headlessMode && !glEnable && console.warn('PostProcessPlugin: WebGL not enabled!'); engineAddPlugin(undefined, postProcessRender, postProcessContextLost, postProcessContextRestored); function initPostProcess() { if (headlessMode || !glEnable) return; // create resources, the feedback starting black, as if the frame before the first were empty if (feedbackTexture) { postProcess.feedbackTexture = glCreateTexture(); glContext.bindTexture(glContext.TEXTURE_2D, postProcess.feedbackTexture); glContext.texImage2D(glContext.TEXTURE_2D, 0, glContext.RGBA, 1, 1, 0, glContext.RGBA, glContext.UNSIGNED_BYTE, new Uint8Array([0, 0, 0, 255])); glContext.bindTexture(glContext.TEXTURE_2D, glActiveTexture); } postProcess.texture = glCreateTexture(); postProcess.shader = glCreateProgram( '#version 300 es\n' + // specify GLSL ES version 'precision highp float;'+ // use highp for accuracy 'in vec2 p;'+ // position 'void main(){'+ // shader entry point 'gl_Position=vec4(p+p-1.,1,1);'+ // set position '}' // end of shader , postProcessFragmentSource(postProcess.shaderCode, postProcess.values) ); postProcess.valueNames = postProcessValueKey(postProcess.values); // setup VAO for post processing postProcess.vao = glContext.createVertexArray(); glContext.bindVertexArray(postProcess.vao); glContext.bindBuffer(glContext.ARRAY_BUFFER, glGeometryBuffer); // configure vertex attributes const vertexByteStride = 8; const pLocation = glContext.getAttribLocation(postProcess.shader, 'p'); glContext.enableVertexAttribArray(pLocation); glContext.vertexAttribPointer(pLocation, 2, glContext.FLOAT, false, vertexByteStride, 0); // the engine's own vertex array and buffer back, its next batch is written into the buffer bound glContext.bindVertexArray(glPolyMode ? glPolyVAO : glInstancedVAO); glContext.bindBuffer(glContext.ARRAY_BUFFER, glArrayBuffer); } function postProcessContextLost() { postProcess.shader = undefined; postProcess.texture = undefined; postProcess.feedbackTexture = undefined; LOG('PostProcessPlugin: WebGL context lost'); } function postProcessContextRestored() { initPostProcess(); LOG('PostProcessPlugin: WebGL context restored'); } function postProcessRender() { if (headlessMode || !glEnable || !postProcess.enabled) return; // clear out the buffer, before anything here binds its own glFlush(); // made now if WebGL was off when the plugin was made, when a lost context came back, when setShaderCode // gave it new code, or when the game added or took away a value, which the shader declares if (postProcess.shader && postProcessValueKey(postProcess.values) !== postProcess.valueNames) postProcess.setShaderCode(postProcess.shaderCode); if (!postProcess.shader) { if (glContext.isContextLost()) return; initPostProcess(); } if (glFailedPrograms.has(postProcess.shader)) return; // a shader that did not build in a release build, the frame shows as it is // ensure we render to the default framebuffer (in case any earlier // caller this frame left a render target bound) glContext.bindFramebuffer(glContext.FRAMEBUFFER, null); // setup shader program to draw a quad glContext.useProgram(postProcess.shader); glContext.bindVertexArray(postProcess.vao); glContext.pixelStorei(glContext.UNPACK_FLIP_Y_WEBGL, true); // upload the canvas the way it shows, premultiplied, since the shader writes full alpha; unpremultiplied // a see-through pixel would come out at its full brightness instead of faded over the background glContext.pixelStorei(glContext.UNPACK_PREMULTIPLY_ALPHA_WEBGL, true); glContext.disable(glContext.BLEND); // setup texture glContext.activeTexture(glContext.TEXTURE0); glContext.bindTexture(glContext.TEXTURE_2D, postProcess.texture); if (includeMainCanvas) { // copy main canvas to work canvas at the backing store size, // mainCanvasSize is css pixels so it would lose resolution // sized again only when the canvas changed, setting a size remakes the canvas even to the same one if (workCanvas.width !== mainCanvas.width || workCanvas.height !== mainCanvas.height) workCanvas.width = mainCanvas.width, workCanvas.height = mainCanvas.height; else workContext.clearRect(0, 0, workCanvas.width, workCanvas.height); glCopyToContext(workContext); workContext.drawImage(mainCanvas, 0, 0); // clear the main canvas with clearRect, resizing it would also // reset the context state (smoothing, line caps) for anything // drawn after this and reallocate the canvas every frame mainContext.save(); mainContext.setTransform(1, 0, 0, 1, 0, 0); mainContext.clearRect(0, 0, mainCanvas.width, mainCanvas.height); mainContext.restore(); // copy work canvas to texture glContext.texImage2D(glContext.TEXTURE_2D, 0, glContext.RGBA, glContext.RGBA, glContext.UNSIGNED_BYTE, workCanvas); } else { // copy the drawing buffer to the texture on the GPU, bottom row first as the flip above would have, // and premultiplied as the context keeps it; a canvas source is a slow readback in Firefox glContext.copyTexImage2D(glContext.TEXTURE_2D, 0, glContext.RGBA, 0, 0, glCanvas.width, glCanvas.height, 0); } // the previous frame's output, on the second texture unit if (feedbackTexture) { glContext.activeTexture(glContext.TEXTURE1); glContext.bindTexture(glContext.TEXTURE_2D, postProcess.feedbackTexture); } // the 3D depth, when render3D draws it, on the third texture unit, with what sceneDepth needs to read it const depth = typeof render3D != 'undefined' && render3D?.depthTexture ? render3D : undefined; glContext.activeTexture(glContext.TEXTURE2); glContext.bindTexture(glContext.TEXTURE_2D, depth?.cameraDepthTexture || null); glContext.activeTexture(glContext.TEXTURE0); // all 0 without one, which an effect reads as no depth const camera = depth?.camera; glContext.uniform3f(glUniformLocation(postProcess.shader, 'iDepthRange'), camera?.near || 0, !camera || camera.far == Infinity ? 0 : camera.far, camera?.orthographic ? 1 : 0); // set uniforms and draw const uniformLocation = (name)=>glUniformLocation(postProcess.shader, name); glContext.uniform1i(uniformLocation('iChannel0'), 0); glContext.uniform1i(uniformLocation('iChannel1'), 1); glContext.uniform1i(uniformLocation('iChannel2'), 2); glContext.uniform1f(uniformLocation('iTime'), time); glContext.uniform3f(uniformLocation('iResolution'), mainCanvas.width, mainCanvas.height, 1); for (const name in postProcess.values) { const value = postProcess.values[name], location = uniformLocation(name); if (isArray(value)) [, , glContext.uniform2fv, glContext.uniform3fv, glContext.uniform4fv][value.length]?.call(glContext, location, value); else glContext.uniform1f(location, value); } glContext.drawArrays(glContext.TRIANGLE_STRIP, 0, 4); if (feedbackTexture) { // keep this frame's output for the next one, in the feedback texture still bound to the second // unit, then hand the first texture unit back to the engine glContext.activeTexture(glContext.TEXTURE1); glContext.copyTexImage2D(glContext.TEXTURE_2D, 0, glContext.RGBA, 0, 0, glCanvas.width, glCanvas.height, 0); glContext.activeTexture(glContext.TEXTURE0); } // the depth leaves the third unit for whatever draws next glContext.activeTexture(glContext.TEXTURE2); glContext.bindTexture(glContext.TEXTURE_2D, null); glContext.activeTexture(glContext.TEXTURE0); // restore defaults so subsequent dynamic texture uploads aren't flipped or premultiplied glContext.pixelStorei(glContext.UNPACK_FLIP_Y_WEBGL, false); glContext.pixelStorei(glContext.UNPACK_PREMULTIPLY_ALPHA_WEBGL, false); // bind back the texture the 2D batch thinks is bound, a plugin drawing after this one uses it if (glActiveTexture) glContext.bindTexture(glContext.TEXTURE_2D, glActiveTexture); // force it to set instanced mode glSetInstancedMode(true); } } /** Shade with new code from the next frame on, to switch effects while the game runs; the shader is made again * @param {string} [shaderCode] - Shadertoy style mainImage code, postProcessEffects builds it, or an effect * alone; none passes the frame through */ setShaderCode(shaderCode) { this.shaderCode = postProcessShaderCode(shaderCode); if (!this.shader || headlessMode || !glContext) return; // what the new shader replaces, made again by the next render glContext.deleteProgram(this.shader); glContext.deleteTexture(this.texture); this.feedbackTexture && glContext.deleteTexture(this.feedbackTexture); glContext.deleteVertexArray(this.vao); this.shader = this.texture = this.feedbackTexture = this.vao = undefined; } } /////////////////////////////////////////////////////////////////////////////// // what the shader declares of the game's values, each name and its length, so a value that changes either makes the // shader again const postProcessValueKey = (values)=> Object.entries(values).map(([name, v])=> name + ':' + (isArray(v) ? v.length : 1)).join(); // the code the pass shades with: none passes the frame through, and code with no mainImage of its own, an effect like // postProcessVignette() given alone, is a piece that postProcessEffects joins into one function postProcessShaderCode(shaderCode) { ASSERT(!shaderCode || typeof shaderCode === 'string', 'shader code must be a string'); return !shaderCode ? postProcessEffects() : shaderCode.includes('mainImage') ? shaderCode : postProcessEffects(shaderCode); } // the fragment shader of the post process pass around its mainImage snippet: the frame on iChannel0, the last output // on iChannel1 with a feedback texture, and the 3D depth on iChannel2 when render3D.depthTexture is on, read with // sceneDepth(uv), the distance from the camera along its view in world units, uv 0 to 1 across the screen; and a // uniform for each of the game's values, a float or a vector of the list's length function postProcessFragmentSource(shaderCode, values={}) { let declared = ''; for (const name in values) { const value = values[name], length = isArray(value) ? value.length : 1; ASSERT(postProcessNameCheck(name), 'a postProcess value needs a name GLSL takes that the shader does not use', name); ASSERT(!isArray(value) || length >= 2 && length <= 4, 'a postProcess value is a number or 2 to 4 of them', name); declared += `uniform ${length > 1 ? 'vec' + length : 'float'} ${name};`; } return '#version 300 es\n' + // specify GLSL ES version 'precision highp float;'+ // use highp for accuracy 'uniform sampler2D iChannel0;'+ // input texture 'uniform sampler2D iChannel1;'+ // the previous frame's output, when feedbackTexture is set 'uniform highp sampler2D iChannel2;'+ // the 3D depth, when render3D.depthTexture is on, highp or it is 11 bits 'uniform vec3 iResolution;'+ // size of output texture 'uniform float iTime;'+ // time 'uniform vec3 iDepthRange;'+ // the camera's near, its far or 0 for none, and 1 when orthographic declared + // the game's own values 'out vec4 c;'+ // out color // the depth texture's value back to a distance, as the camera's projection put it there 'float sceneDepth(vec2 uv){'+ 'float d=texture(iChannel2,uv).r*2.-1.,n=iDepthRange.x,f=iDepthRange.y;'+ 'return iDepthRange.z>0.?(d*(f-n)+f+n)/2.:f>0.?2.*n*f/(f+n-d*(f-n)):2.*n/max(1.-d,1e-7);}'+ // whether there is depth to read: a range of all 0 is none, an orthographic near plane may be behind '\n#define LJS_HAS_DEPTH (iDepthRange != vec3(0))\n'+ // a define needs a line of its own // main first, the code's mainImage declared for it, so a define in the code can not reach main 'void mainImage(out vec4,vec2);'+ 'void main(){'+ // shader entry point 'mainImage(c,gl_FragCoord.xy);'+ // call post process function 'c.a=1.;'+ // always use full alpha '}'+ // end of shader '\n' + shaderCode + '\n'; // insert custom shader code } /** * Shader code for a bloom effect, the bright parts of the image blurred back over it * - Pass it to PostProcessPlugin, or edit the string to build an effect on top of it * @param {number|string} [threshold] - Brightness where the glow starts, 0 is everything and 1 is only pure white * @param {number|string} [strength] - How much glow to add * @param {number} [size] - How far the glow spreads in pixels, which also sets how many samples it takes, so a * number and not a value's name * @return {string} * @memberof PostProcess */ function postProcessBloomShader(threshold=.6, strength=1, size=6) { return postProcessEffects(postProcessGlow(threshold, strength, size)); } /** * Set up post processing with a bloom effect, so bright colors and lights glow * @param {number|string} [threshold] - Brightness where the glow starts, 0 is everything and 1 is only pure white * @param {number|string} [strength] - How much glow to add * @param {number} [size] - How far the glow spreads in pixels * @param {boolean} [includeMainCanvas] - Glow the 2D canvas too, off by default so HUD text stays crisp * (a HUD drawn with WebGL in gameRenderPost glows either way, draw it with useWebGL=false) * @return {PostProcessPlugin} * @memberof PostProcess * @example * postProcessBloom(); // in gameInit, after any Render3DPlugin */ function postProcessBloom(threshold=.6, strength=1, size=6, includeMainCanvas=false) { return new PostProcessPlugin(postProcessBloomShader(threshold, strength, size), includeMainCanvas); } /////////////////////////////////////////////////////////////////////////////// // Effects: pieces of shader code, each with its settings written in as numbers, that postProcessEffects joins in // order into one shader; a piece works on c, the pixel's color, and uv, where it is on the screen from 0 to 1, with p // its pixel; one that bends uv or samples the frame, the curve, chromatic and glow, goes before the ones that shade // a number as GLSL writes it, a float with a point // an effect setting: a number written into the code, or the name of a postProcess value, read from it each frame const postProcessNumber = (n)=> typeof n === 'string' ? (ASSERT(postProcessNameCheck(n), 'an effect setting is a number or the name of a postProcess value', n), n) : (ASSERT(isNumber(n) && isFinite(n), 'an effect setting is a finite number or the name of a postProcess value', n), n && abs(n) < .01 ? String(n) : n.toFixed(4)); // a small one keeps its digits, as 1.5e-7 or 0.00015 // a GLSL name for a value or a setting, which no part of the shader uses already: not the engine's own, i and a // capital, nor those of mainImage or an effect's own locals, which start with an underscore, nor a GLSL word const postProcessGLSLWords = new Set(('c uv p sceneDepth mainImage main ' + // keywords and reserved words 'attribute const uniform varying layout centroid flat smooth noperspective patch sample break continue do for ' + 'while switch case default if else subroutine in out inout float double int void bool true false invariant ' + 'precise discard return lowp mediump highp precision struct common partition active asm class union enum ' + 'typedef template this resource goto inline noinline public static extern external interface long short half ' + 'fixed unsigned superp input output filter sizeof cast namespace using coherent volatile restrict readonly ' + 'writeonly atomic_uint uint mat2 mat3 mat4 vec2 vec3 vec4 ivec2 ivec3 ivec4 bvec2 bvec3 bvec4 uvec2 uvec3 uvec4 ' + // built in functions 'radians degrees sin cos tan asin acos atan sinh cosh tanh asinh acosh atanh pow exp log exp2 log2 sqrt ' + 'inversesqrt abs sign floor trunc round roundEven ceil fract mod modf min max clamp mix step smoothstep isnan ' + 'isinf length distance dot cross normalize faceforward reflect refract matrixCompMult outerProduct transpose ' + 'determinant inverse lessThan lessThanEqual greaterThan greaterThanEqual equal notEqual any all not textureSize ' + 'texture textureProj textureLod textureOffset texelFetch texelFetchOffset textureProjOffset textureLodOffset ' + 'textureProjLod textureProjLodOffset textureGrad textureGradOffset textureProjGrad textureProjGradOffset dFdx ' + 'dFdy fwidth').split(' ')); const postProcessNameCheck = (name)=> /^[A-Za-z][A-Za-z0-9_]*$/.test(name) && !name.includes('__') && !/^(i[A-Z]|gl_|webgl_|GL_|mat[2-4]|[dfh]vec[2-4]|[iu]?(sampler|image)([123]D|Cube|Buffer|External))/.test(name) && !postProcessGLSLWords.has(name); /** * Join effects into one post process shader, in the order given, for PostProcessPlugin or setShaderCode * - Each effect is a piece of shader code on c, the pixel's color, and uv, where it is on the screen from 0 to 1; * your own code is a piece too, a line of GLSL or many, dropped in where you put it * - Put the ones that bend the picture or sample it first: postProcessCurve, postProcessChromatic, postProcessGlow * @param {...string} effects - The pieces, from the effect functions or your own code * @return {string} - Shadertoy style mainImage code * @example * new PostProcessPlugin(postProcessEffects( * postProcessScanlines(.5), postProcessVignette(), 'c.rgb *= vec3(1, .9, .8);')); * @memberof PostProcess */ function postProcessEffects(...effects) { ASSERT(effects.every((e)=> typeof e === 'string'), 'each effect is a piece of shader code, a string'); return 'void mainImage(out vec4 c, vec2 p)\n{\n' + ' vec2 uv = p / iResolution.xy;\n' + ' c = texture(iChannel0, uv);\n' + effects.map((e)=> ' {\n' + e + '\n }\n').join('') + '}\n'; } /** * Bright parts glow, the bloom as an effect to join with others; postProcessBloom sets up bloom on its own * @param {number|string} [threshold] - Brightness where the glow starts, 0 is everything and 1 is only pure white * @param {number|string} [strength] - How much glow to add * @param {number} [size] - How far the glow spreads in pixels, which also sets how many samples it takes, so a * number and not a value's name * @return {string} * @memberof PostProcess */ function postProcessGlow(threshold=.6, strength=1, size=6) { ASSERT(isNumber(size), 'glow size is a number, it sets how many samples the glow takes'); ASSERT(size > 0, 'glow size must be above zero'); ASSERT(size <= 32, 'a glow this wide takes a sample every few pixels of every ring, which is hundreds of samples a pixel', size); // Taps on three rings over a disc of the given size, one every three pixels or so of each ring // so there is no gap wide enough to show. The count follows the ring all the way out: hold it // still and a wider glow only spreads the same taps further apart, until they show up as the // ring of evenly spaced copies a single ring of eight leaves around anything bright. // Each ring has its own count, odd and unequal, and its own turn off the last, so the little // the taps do miss comes out as fine ripple instead of a shape of its own. const rings = 3; let code = '', taps = 0; for (let j = 0; j < rings; ++j) { const radius = ((j + .5) / rings) ** .5 * size; // equal area per ring const count = max(5 + 2 * j, round(2 * radius)) | 1; taps += count; code += ` for (int _k = 0; _k < ${count}; ++_k) { float _a = float(_k) * ${(2 * PI / count).toFixed(7)}${j ? ' + ' + (j * 2.3999632).toFixed(7) : ''}; _glow += max(vec3(0), texture(iChannel0, uv + vec2(cos(_a), sin(_a)) * ${radius.toFixed(4)} / iResolution.xy).rgb - ${postProcessNumber(threshold)}); }`; } return ` // glow vec3 _glow = vec3(0);${code} c.rgb += _glow * ${typeof strength === 'string' ? postProcessNumber(strength) + ' / ' + taps + '.' : (strength / taps).toFixed(6)};`; // a number as before, a value's name divided in the shader } /** * Scan lines across the screen, like an old TV * @param {number|string} [strength] - How dark the lines are, and how bright between them * @param {number|string} [spacing] - Pixels from one line to the next * @return {string} * @memberof PostProcess */ function postProcessScanlines(strength=.5, spacing=6) { return ` // scanlines c.rgb *= 1. - ${postProcessNumber(strength)} * cos(p.y * 6.2832 / ${postProcessNumber(spacing)});`; } /** * Static noise over the picture, changing every frame * @param {number|string} [strength] - How bright the static is * @param {number|string} [size] - Size of a speck in pixels * @return {string} * @memberof PostProcess */ function postProcessNoise(strength=.1, size=2) { return ` // noise vec2 _q = fract((floor(p / ${postProcessNumber(size)}) + mod(iTime * 500., 1e3)) * .3197); c.rgb += ${postProcessNumber(strength)} * fract(1. + sin(51. * _q.x + 73. * _q.y) * 13753.3);`; } /** * Darken toward the edges and corners * @param {number|string} [strength] - How dark the corners get, 1 is black * @param {number|string} [falloff] - How far in it reaches, low darkens most of the screen, high only the corners * @return {string} * @memberof PostProcess */ function postProcessVignette(strength=1, falloff=3) { return ` // vignette vec2 _d = uv * 2. - 1.; c.rgb *= 1. - ${postProcessNumber(strength)} * min(1., pow(dot(_d, _d) / 2., ${postProcessNumber(falloff)}));`; } /** * Bend the picture like the bulged glass of an old TV, black past the corners; put it first * @param {number|string} [strength] - How much it bends * @return {string} * @memberof PostProcess */ function postProcessCurve(strength=.1) { return ` // curve vec2 _d = uv * 2. - 1.; _d *= 1. + ${postProcessNumber(strength)} * dot(_d, _d); uv = _d * .5 + .5; c = all(lessThan(abs(_d), vec2(1))) ? texture(iChannel0, uv) : vec4(0, 0, 0, 1);`; } /** * Split red and blue apart toward the edges, like a cheap lens; put it before what shades the picture * @param {number|string} [strength] - How far apart at the edge, as a part of the screen * @return {string} * @memberof PostProcess */ function postProcessChromatic(strength=.005) { return ` // chromatic vec2 _d = (uv - .5) * ${postProcessNumber(strength)} * 2.; c.r = texture(iChannel0, uv + _d).r; c.b = texture(iChannel0, uv - _d).b;`; } // a blur's widest is 32 pixels, past which its 24 taps sit far enough apart to show; a value's is the game's to keep const postProcessBlurCheck = (blur)=> ASSERT(typeof blur === 'string' || blur <= 32, 'a blur of more than 32 pixels shows its taps as copies', blur); // a blur over a disc of radius _r pixels around uv, of 24 taps spread evenly by the golden angle; weight, when given, // is GLSL for how much a tap at _puv, _s of the radius out, counts, from 0 to 1 function postProcessDiscBlur(weight) { return ` vec3 _sum = vec3(0); float _total = 0.; for (int _k = 0; _k < 24; ++_k) { float _s = sqrt((float(_k) + .5) / 24.), _a = float(_k) * 2.39996; vec2 _puv = uv + vec2(cos(_a), sin(_a)) * _s * _r / iResolution.xy; float _w = ${weight || '1.'}; _sum += texture(iChannel0, _puv).rgb * _w; _total += _w; } c.rgb = _total > 0. ? _sum / _total : c.rgb;`; } /** * Keep a band across the screen sharp and blur the picture above and below it, as a tilt shift lens does, which * makes a scene look like a small model; it reads only the screen, so it works in 2D and 3D; put it first * @param {number|string} [focus] - Height of the middle of the sharp band, 0 the bottom of the screen and 1 the top * @param {number|string} [size] - Height of the sharp band, as a part of the screen * @param {number|string} [blur] - Widest blur in pixels, reached half the screen past the band, at most 32 * @return {string} * @memberof PostProcess * @example * new PostProcessPlugin(postProcessEffects(postProcessTiltShift(.4, .2, 10), postProcessVignette(.5))); */ function postProcessTiltShift(focus=.5, size=.25, blur=8) { postProcessBlurCheck(blur); const n = postProcessNumber; return ` // tilt shift float _r = ${n(blur)} * smoothstep(0., .5, abs(uv.y - ${n(focus)}) - ${n(size)} * .5);${postProcessDiscBlur()}`; } /** * Keep what is a distance from the camera sharp and blur what is nearer or farther, as a camera lens does; needs * render3D.depthTexture on, and blurs 3D only, 2D draws having no depth; put it first * - What is in focus stays sharp at its edges: a blur in front of or behind it leaves out what is in focus * @param {number|string} [focus] - Distance from the camera, along its view, that is sharpest, in world units * @param {number|string} [range] - How deep the sharp part is; the blur grows over as far again past it * @param {number|string} [blur] - Widest blur in pixels, at most 32 * @return {string} * @memberof PostProcess * @example * render3D.depthTexture = true; * new PostProcessPlugin(postProcessEffects(postProcessDepthOfField(10, 4, 8))); */ function postProcessDepthOfField(focus=10, range=4, blur=8) { postProcessBlurCheck(blur); const n = postProcessNumber; const amount = (depth)=> `${n(blur)} * smoothstep(0., ${n(range)}, abs(${depth} - ${n(focus)}) - ${n(range)} * .5)`; // a tap counts as far as its own blur reaches back to here, so a sharp thing in front is not smeared over return ` // depth of field, none where there is no depth, as with render3D.depthTexture off float _r = LJS_HAS_DEPTH ? ${amount('sceneDepth(uv)')} : 0.;${postProcessDiscBlur(`clamp(${amount('sceneDepth(_puv)')} - _s * _r + 1., 0., 1.)`)}`; } /** * Draw lines where the 3D depth jumps, around objects and along their creases; needs render3D.depthTexture on * @param {Color} [color] - The lines' color, its alpha how strong they are * @param {number|string} [thickness] - How wide the lines are in pixels * @param {number|string} [threshold] - How big a jump makes a line, as a part of the distance, lower draws more * @return {string} * @memberof PostProcess */ function postProcessOutline(color=BLACK, thickness=1, threshold=.02) { ASSERT(isColor(color), 'outline color must be a Color'); const n = postProcessNumber; return ` // outline vec2 _o = ${n(thickness)} / iResolution.xy; float _d = sceneDepth(uv); float _dx = abs(sceneDepth(uv + vec2(_o.x, 0)) + sceneDepth(uv - vec2(_o.x, 0)) - 2. * _d); float _dy = abs(sceneDepth(uv + vec2(0, _o.y)) + sceneDepth(uv - vec2(0, _o.y)) - 2. * _d); vec4 _line = vec4(${n(color.r)}, ${n(color.g)}, ${n(color.b)}, ${n(color.a)}); if (LJS_HAS_DEPTH) c.rgb = mix(c.rgb, _line.rgb, _line.a * step(${n(threshold)}, max(_dx, _dy) / max(abs(_d), 1e-6)));`; } /** * The look of an old TV, as one effect to use alone or join with others: static noise, scan lines, a soft glow and * a vignette, and a bulged screen when curve is set; any setting at 0 leaves that part out * @param {Object} [settings] * @param {number|string} [settings.noise] - Static noise strength * @param {number|string} [settings.scanlines] - Scan line strength * @param {number|string} [settings.scanlineSpacing] - Pixels from one scan line to the next * @param {number|string} [settings.glow] - Soft glow strength * @param {number|string} [settings.vignette] - Vignette strength * @param {number|string} [settings.curve] - How much the screen bulges, 0 by default for flat * @return {string} * @example * new PostProcessPlugin(postProcessEffects(postProcessTV({scanlines: .4, curve: .1}))); * @memberof PostProcess */ function postProcessTV({noise=.1, scanlines=.5, scanlineSpacing=6, glow=.4, vignette=1, curve=0}={}) { const parts = []; curve && parts.push(postProcessCurve(curve)); glow && parts.push(postProcessGlow(0, glow, 2)); scanlines && parts.push(postProcessScanlines(scanlines, scanlineSpacing)); noise && parts.push(postProcessNoise(noise, 1)); vignette && parts.push(postProcessVignette(vignette, 6)); return parts.map((part)=> ' {\n' + part + '\n }').join('\n'); } /** * LittleJS Light System Plugin * - Adds 2D dynamic lighting to the scene * - Lights are first-class EngineObjects (the Light class) * - Each Light draws a soft falloff blob of its color into a shared lightmap * - Lights accumulate ADDITIVELY in the lightmap (red + blue = magenta) * - The lightmap is then MULTIPLIED with the scene during composite, so unlit * areas go to the ambient color and lit areas show the scene tinted by the * accumulated light color * - Draw the world at full brightness — the lightmap does the darkening * - The pass runs after gameRenderPost, so a HUD drawn there with WebGL is * darkened too; draw the HUD with useWebGL=false (the main canvas) or from * a plugin created after this one * - Any EngineObject may override renderLight() to additively contribute to the * lightmap (e.g. lava that lights the floor around it, weapon flashes, glowing crystals) * - Set obj.emissive to 1 to show an object at full brightness in its own colors, lit or not, or between 0 and 1 for * partly: it draws its shape into the lightmap through renderEmissive(), which calls render() by default; exact for * solid pixels, a partly transparent one is self lit by its alpha too, so it shows darker * - Set lightSystem.shadows for objects to block light: each frame every object draws black into a * shadow map through renderShadow(), which calls render() by default; obj.castShadow = false keeps it * out (a floor TileLayer, a background), a draw's alpha sets how much light it blocks, and * setShadowTransparent lets its color tint the light * - Set light.glow for a soft hazy glow over a light, like a lamp at night; it is added over the lit scene after the * lightmap, so it shows in the dark and sits in front of everything there * - A DirectionalLight is a sun: one per scene, it lights everything from one direction, foreground casters throw long * shadows and objects with castBackgroundShadow are lit only at their edges facing it * - Must be constructed BEFORE PostProcessPlugin so post-process sees lit pixels * @namespace LightSystem */ /////////////////////////////////////////////////////////////////////////////// /** Global Light System plugin object * @type {LightSystemPlugin} * @memberof LightSystem */ let lightSystem; /////////////////////////////////////////////////////////////////////////////// /** * LightSystemPlugin * - Owns the offscreen lightmap texture, falloff/composite shaders, and the * per-frame render pass that multiplies the lightmap onto the WebGL scene * - The composite is MULTIPLICATIVE: unlit areas get the ambient color, lit * areas show the scene tinted by the accumulated light color. So you should * draw your world at full brightness — the lightmap handles the darkening. * @memberof LightSystem */ class LightSystemPlugin { /** Create the global light system plugin * @param {Vector2} [textureSize] - Size of the lightmap texture (defaults to following mainCanvasSize, which is css pixels, so the lightmap is not scaled by canvasPixelRatio; pass mainCanvasSize.scale(getCanvasPixelRatio()) for a full resolution lightmap) * @param {Color} [ambientColor] - Color applied to unlit areas of the scene (defaults to BLACK = pitch dark). Set a small RGB like rgb(0.1,0.1,0.15) for a faint "moonlight" baseline so unlit areas aren't fully black. * @example * // simplest usage * new LightSystemPlugin(); */ constructor(textureSize, ambientColor) { ASSERT(engineInitialized || headlessMode, 'create the plugin after engineInit, e.g. in gameInit'); ASSERT(!lightSystem, 'LightSystemPlugin already initialized'); ASSERT(!postProcess, 'LightSystemPlugin must be created before PostProcessPlugin'); lightSystem = this; /** @property {boolean} - When false, the render pass is skipped entirely */ this.enabled = true; this.shadersFailed = false; // a shader did not build on this device, so the system draws nothing /** @property {Color} - Baseline color applied to unlit areas of the scene. Defaults to BLACK (pitch dark). Set to a small RGB for a faint ambient. The lightmap is cleared to this color each frame, then lights add on top, then the result multiplies the scene. */ this.ambientColor = (ambientColor || BLACK).copy(); /** @property {Vector2} - Size of the lightmap texture, follows mainCanvasSize (css pixels, so it is not scaled by canvasPixelRatio) unless a size was passed */ this.textureSize = textureSize ? textureSize.copy() : undefined; /** @property {boolean} - True when no size was passed, so the lightmap follows mainCanvasSize */ this.textureSizeAuto = !textureSize; /** @property {WebGLTexture|undefined} - The lightmap texture * @type {WebGLTexture|undefined} */ this.texture = undefined; /** @property {WebGLProgram|undefined} - Shader for drawing per-Light falloff blobs into the lightmap * @type {WebGLProgram|undefined} */ this.lightShader = undefined; /** @property {WebGLProgram|undefined} - Shader for compositing the lightmap over the main scene * @type {WebGLProgram|undefined} */ this.compositeShader = undefined; /** @property {WebGLVertexArrayObject|undefined} - Vertex array object for the light shader * @type {WebGLVertexArrayObject|undefined} */ this.lightVAO = undefined; /** @property {WebGLVertexArrayObject|undefined} - Vertex array object for the composite shader * @type {WebGLVertexArrayObject|undefined} */ this.compositeVAO = undefined; /** @property {boolean} - Cast shadows: every object draws black into a shadow map once a frame and each light's rays stop at them; off by default and free when off */ this.shadows = false; /** @property {number} - Pixels across the square shadow map, made again when changed */ this.shadowMapSize = 1024; /** @property {number} - How many times the larger side of the view the shadow map covers, so casters just off screen still cast in; raise it when lights reach further than a view past the screen */ this.shadowMapScale = 2; /** @property {number} - Pixels across each light's own shadow texture, made again when changed; larger is sharper, * and a gap between casters narrower than about 4*radius/shadowTextureSize world units closes */ this.shadowTextureSize = 256; /** @property {number} - Stretch passes per shadow casting light, fewer is cheaper and shorter shadows */ this.shadowPassCount = 16; /** @property {number} - How much light bleeds into a caster's near side, 0 for hard edged casters, 1 for most; * the bleed reaches further in under a bigger light, so a thin wall under a big one lets some through, lower it * for those */ this.shadowSoftness = .5; /** @property {boolean} - True while the shadow pass runs, read only, so a render() can skip parts that should not cast */ this.shadowPass = false; /** @property {boolean} - True while emissive objects draw into the lightmap, read only, see EngineObject.emissive */ this.emissivePass = false; /** @property {WebGLTexture|undefined} - The shadow map, casters drawn black on white around the camera, read only * @type {WebGLTexture|undefined} */ this.shadowMap = undefined; /** @property {WebGLTexture|undefined} - The background map, objects with castBackgroundShadow drawn black on * white, the shadow map's size and place, made by a directional light when something casts into it and kept while the light is, read only * @type {WebGLTexture|undefined} */ this.backgroundMap = undefined; /** @property {WebGLTexture|undefined} - One of the two textures each light's shadow is built in * @type {WebGLTexture|undefined} */ this.shadowTextureA = undefined; /** @property {WebGLTexture|undefined} - The other * @type {WebGLTexture|undefined} */ this.shadowTextureB = undefined; /** @property {WebGLProgram|undefined} - Copies the shadow map around a light into its texture * @type {WebGLProgram|undefined} */ this.shadowCopyShader = undefined; /** @property {WebGLProgram|undefined} - One stretch pass of a light's shadow texture * @type {WebGLProgram|undefined} */ this.shadowStretchShader = undefined; /** @property {WebGLVertexArrayObject|undefined} - Vertex array object for the copy shader * @type {WebGLVertexArrayObject|undefined} */ this.shadowCopyVAO = undefined; /** @property {WebGLVertexArrayObject|undefined} - Vertex array object for the stretch shader * @type {WebGLVertexArrayObject|undefined} */ this.shadowStretchVAO = undefined; /** @property {OffscreenCanvasRenderingContext2D|undefined} - Where Canvas2D draws go during the shadow and emissive passes, a 1x1 canvas, so text in a render() is not drawn twice * @type {OffscreenCanvasRenderingContext2D|undefined} */ this.shadowContext = undefined; /** @property {Vector2} - World position of the shadow map's bottom left corner, set each shadow pass */ this.shadowMapOrigin = vec2(); /** @property {number} - World size the shadow map covers, set each shadow pass */ this.shadowMapWorldSize = 0; /** @property {DirectionalLight|undefined} - The scene's directional light, a sun, or undefined, read only * @type {DirectionalLight|undefined} */ this.directionalLight = undefined; /** @property {number} - Pixels across the square textures a directional light is built in, covering the * shadow map's area; larger is sharper and slower; a power of two, as shadowMapSize, so their texels line up */ this.directionalTextureSize = 512; /** @property {WebGLTexture|undefined} - The directional light as built this frame, white where it reaches, * over the shadow map's area, read only * @type {WebGLTexture|undefined} */ this.directionalTexture = undefined; /** @type {WebGLTexture|undefined} */ this.directionalTextureA = undefined; // where it is built, ping ponged /** @type {WebGLTexture|undefined} */ this.directionalTextureB = undefined; this.directionalTextureSizeAllocated = 0; this.backgroundCasters = false; // anything has castBackgroundShadow, checked each shadow pass /** @type {Object} */ this.directionalPrograms = {}; // its programs and their vertex arrays, by name this.shadowMapSizeAllocated = 0; // sizes the textures were made at, to remake them on a change this.shadowTextureSizeAllocated = 0; // a full texture quad, the vertex shader of the shadow and directional programs const quadVertex = '#version 300 es\n' + 'precision highp float;'+ 'in vec2 p;'+ // unit quad [0..1] 'out vec2 uv;'+ 'void main(){gl_Position=vec4(p+p-1.,1,1);uv=p;}'; initLightSystem(); engineAddPlugin(undefined, lightSystemRender, lightSystemContextLost, lightSystemContextRestored); function initLightSystem() { if (headlessMode) return; if (!glEnable) { console.warn('LightSystemPlugin: WebGL not enabled!'); return; } // where Canvas2D draws go during the shadow and emissive passes lightSystem.shadowContext ||= createCanvasContext(1); // resolve texture size default at init time (mainCanvasSize may // not be set yet at the moment the constructor first ran), and // again on a context restore, the canvas may have changed since if (lightSystem.textureSizeAuto) lightSystem.textureSize = mainCanvasSize.copy(); lightSystem.clampTextureSizes(); // allocate the lightmap texture with null data at textureSize lightSystem.texture = glContext.createTexture(); glContext.bindTexture(glContext.TEXTURE_2D, lightSystem.texture); glContext.texImage2D(glContext.TEXTURE_2D, 0, glContext.RGBA, lightSystem.textureSize.x, lightSystem.textureSize.y, 0, glContext.RGBA, glContext.UNSIGNED_BYTE, null); glContext.texParameteri(glContext.TEXTURE_2D, glContext.TEXTURE_MAG_FILTER, glContext.LINEAR); glContext.texParameteri(glContext.TEXTURE_2D, glContext.TEXTURE_MIN_FILTER, glContext.LINEAR); glContext.texParameteri(glContext.TEXTURE_2D, glContext.TEXTURE_WRAP_S, glContext.CLAMP_TO_EDGE); glContext.texParameteri(glContext.TEXTURE_2D, glContext.TEXTURE_WRAP_T, glContext.CLAMP_TO_EDGE); // light falloff shader: one quad per Light, fragment computes radial falloff lightSystem.lightShader = glCreateProgram( '#version 300 es\n' + 'precision highp float;'+ 'uniform mat4 m;'+ 'uniform vec2 lightPos;'+ 'uniform float radius;'+ 'in vec2 g;'+ // unit quad geometry [0..1] 'out vec2 vWorldPos;'+ 'out vec2 vUV;'+ // across the light's quad, the shadow texture's uv 'void main(){'+ 'vec2 worldP=lightPos+(g-.5)*2.*radius;'+ 'gl_Position=m*vec4(worldP,1,1);'+ 'vWorldPos=worldP;'+ 'vUV=g;'+ '}' , '#version 300 es\n' + 'precision highp float;'+ 'uniform vec2 lightPos;'+ 'uniform float radius;'+ 'uniform float fadeRange;'+ 'uniform vec4 color;'+ 'uniform sampler2D shadowTexture;'+ // this light's shadow, white where its rays reach 'uniform bool useShadow;'+ 'uniform vec2 forward;'+ // the light's up, the way its cone and gel look 'uniform vec2 cone;'+ // the cone's factor and its edge times it, (0, -1) for none 'uniform sampler2D gelTexture;'+ 'uniform vec4 gelRect;'+ // the gel's tile in its texture, corner and size 'uniform bool useGel;'+ 'in vec2 vWorldPos;'+ 'in vec2 vUV;'+ 'out vec4 c;'+ 'void main(){'+ 'vec2 d=vWorldPos-lightPos;'+ 'float dist=length(d);'+ 'float t=clamp((radius-dist)/max(fadeRange,1e-6),0.,1.);'+ 'c=vec4(color.rgb*t*color.a,1.);'+ 'if(useShadow)c.rgb*=texture(shadowTexture,vUV).rgb;'+ // the cone: full where its fade starts, nothing at its edge, smooth between, as a 3D spotlight's 'float k=clamp(dot(forward,d/max(dist,1e-6))*cone.x-cone.y,0.,1.);'+ 'c.rgb*=k*k*(3.-2.*k);'+ // the gel across the light's square, turned with it, its top the way the light looks 'if(useGel){'+ 'vec2 g=clamp(vec2(dot(d,vec2(forward.y,-forward.x)),dot(d,forward))/(radius*2.)+.5,0.,1.);'+ 'c.rgb*=texture(gelTexture,gelRect.xy+vec2(g.x,1.-g.y)*gelRect.zw).rgb;}'+ '}' ); if (lightSystemShadersFailed(lightSystem.lightShader)) return; // the shadow texture is on unit 1 and the gel on 2, the engine's tracked texture stays on unit 0 glContext.useProgram(lightSystem.lightShader); glContext.uniform1i(glUniformLocation(lightSystem.lightShader, 'shadowTexture'), 1); glContext.uniform1i(glUniformLocation(lightSystem.lightShader, 'gelTexture'), 2); // composite shader: fullscreen quad, samples the lightmap lightSystem.compositeShader = glCreateProgram( '#version 300 es\n' + 'precision highp float;'+ 'in vec2 p;'+ 'void main(){'+ 'gl_Position=vec4(p+p-1.,1,1);'+ '}' , '#version 300 es\n' + 'precision highp float;'+ 'uniform sampler2D s;'+ 'uniform vec3 iResolution;'+ 'out vec4 c;'+ 'void main(){'+ 'vec2 uv=gl_FragCoord.xy/iResolution.xy;'+ 'c=vec4(texture(s,uv).rgb,1.);'+ '}' ); if (lightSystemShadersFailed(lightSystem.compositeShader)) return; // one quad VAO per program, the engine's unit triangle strip through the named attribute lightSystem.lightVAO = createQuadVAO(lightSystem.lightShader, 'g'); lightSystem.compositeVAO = createQuadVAO(lightSystem.compositeShader, 'p'); } // a program that did not build in a release build draws nothing, so the light system turns itself off, said // once, and the scene draws as it would without it; true when it is off function lightSystemShadersFailed(...programs) { if (!lightSystem.shadersFailed && programs.some((program)=> glFailedPrograms.has(program))) { console.error('LightSystemPlugin: its shaders did not build on this device, the scene draws without it'); lightSystem.shadersFailed = true; } return lightSystem.shadersFailed; } function createQuadVAO(program, attribute) { const gl = glContext, vao = gl.createVertexArray(); gl.bindVertexArray(vao); gl.bindBuffer(gl.ARRAY_BUFFER, glGeometryBuffer); const location = gl.getAttribLocation(program, attribute); gl.enableVertexAttribArray(location); gl.vertexAttribPointer(location, 2, gl.FLOAT, false, 8, 0); return vao; } function createTexture(size) { const gl = glContext, texture = gl.createTexture(); gl.bindTexture(gl.TEXTURE_2D, texture); gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, size, size, 0, gl.RGBA, gl.UNSIGNED_BYTE, null); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); return texture; } function initShadows() { const gl = glContext, ls = lightSystem; ls.shadowMap = createTexture(ls.shadowMapSize); ls.shadowTextureA = createTexture(ls.shadowTextureSize); ls.shadowTextureB = createTexture(ls.shadowTextureSize); ls.shadowMapSizeAllocated = ls.shadowMapSize; ls.shadowTextureSizeAllocated = ls.shadowTextureSize; // put back the texture the engine tracks if (glActiveTexture) gl.bindTexture(gl.TEXTURE_2D, glActiveTexture); if (ls.shadowCopyShader) return; // the programs are kept while the textures are made again // copy: the shadow map around the light into the light's texture, light at the center ls.shadowCopyShader = glCreateProgram(quadVertex, '#version 300 es\n' + 'precision highp float;'+ 'uniform sampler2D s;'+ // the shadow map 'uniform vec2 lightPos;'+ 'uniform float radius;'+ 'uniform vec2 mapOrigin;'+ // world bottom left of the map 'uniform float mapInvSize;'+ // 1 over the world size it covers 'uniform float tap;'+ // half a texel of the light's texture, in world units 'uniform float core;'+ // radius around the light where casters are left out 'in vec2 uv;'+ 'out vec4 c;'+ 'void main(){'+ 'vec2 w=lightPos+(uv-.5)*2.*radius;'+ // world position of this texel 'vec3 o=vec3(1);'+ // the darkest of four taps a half texel out, so a caster a texel thin still covers two whole // texels, the stretch's filtering between two would leak through one 'for(int i=0;i<4;i++){'+ 'vec2 m=(w+tap*(vec2(i%2,i/2)*2.-1.)-mapOrigin)*mapInvSize;'+ // in the map, world up at v=0 // past the map's edge is open, clamping would stretch its edge texels across the light 'o=min(o,m==clamp(m,0.,1.)?texture(s,vec2(m.x,1.-m.y)).rgb:vec3(1));'+ '}'+ 'c=vec4(length(w-lightPos) { glSetRenderTarget(target, true); for (const o of engineObjects) { if (o.destroyed || !casts(o)) continue; glColorMask = 0xff000000; setShader(o.shader); // its own Shader as in the main pass, so a snippet that cuts holes casts the same shape o.renderShadow(); } glFlush(); }; if (ls.shadows || ls.directionalLight?.castShadow) drawMap(ls.shadowMap, (o)=> o.castShadow); // nothing reads it for a sun that casts no shadows // the background map only when something casts into it, most scenes have nothing there ls.backgroundCasters = !!ls.directionalLight && engineObjects.some((o)=> !o.destroyed && o.castBackgroundShadow); if (ls.backgroundCasters) { if (!ls.backgroundMap) { ls.backgroundMap = createTexture(size); glActiveTexture && glContext.bindTexture(glContext.TEXTURE_2D, glActiveTexture); } drawMap(ls.backgroundMap, (o)=> o.castBackgroundShadow); } } finally { // hand everything back even when a render threw, or every frame after it draws black with no text ls.shadowPass = false; glColorMask = -1; glSkipScreenSpace = false; glSetRenderTarget(); [drawContext, mainCanvasSize, cameraPos, cameraScale, cameraAngle, canvasClearColor, glCustomShader] = saved; } } // the directional light's textures and programs, made when one is first drawn and again when the size changes function initDirectionalTextures() { const gl = glContext, ls = lightSystem, size = ls.directionalTextureSize; ls.directionalTexture = createTexture(size); ls.directionalTextureA = createTexture(size); ls.directionalTextureB = createTexture(size); ls.directionalTextureSizeAllocated = size; glActiveTexture && gl.bindTexture(gl.TEXTURE_2D, glActiveTexture); } function freeDirectionalTextures() { const gl = glContext, ls = lightSystem; for (const texture of [ls.directionalTexture, ls.directionalTextureA, ls.directionalTextureB]) gl.deleteTexture(texture); ls.directionalTexture = ls.directionalTextureA = ls.directionalTextureB = undefined; ls.directionalTextureSizeAllocated = 0; } // its programs, made once, a new texture size keeps them function initDirectional() { const gl = glContext, ls = lightSystem, p = ls.directionalPrograms; // the maps are stored world up at v=0, the work textures world up at v=1, so a map is read at 1-v const header = '#version 300 es\nprecision highp float;in vec2 uv;out vec4 c;'; // seed: the darkest of four taps across a work texel, so a caster thinner than a texel still darkens it; // the foreground when useF is on, times the background when useB is p.seed = glCreateProgram(quadVertex, header + 'uniform sampler2D f,b;uniform float useF,useB,tap;'+ 'void main(){vec3 o=vec3(1);'+ 'for(int i=0;i<4;i++){vec2 m=uv+tap*(vec2(i%2,i/2)*2.-1.);m.y=1.-m.y;'+ 'vec3 v=mix(vec3(1),texture(f,m).rgb,useF)*mix(vec3(1),texture(b,m).rgb,useB);o=min(o,v);}'+ 'c=vec4(o,1);}'); // long shadow pass: the darker of this texel and the one a shift upstream made lighter by fade, so a // shadow lightens with its distance from the caster; past the area upstream is open p.shadow = glCreateProgram(quadVertex, header + 'uniform sampler2D s;uniform vec2 shift;uniform float fade;'+ 'void main(){vec2 u=uv-shift;'+ 'vec3 b=u==clamp(u,0.,1.)?texture(s,u).rgb+fade:vec3(1);'+ 'c=vec4(min(texture(s,uv).rgb,b),1);}'); // background leak pass, the long shadow pass turned around: the brighter of this texel and the light a // shift upstream made dimmer by fade, so light comes into a background area from its edges facing the // light and fades evenly to nothing by backgroundDepth; past the area upstream is open sky p.leak = glCreateProgram(quadVertex, header + 'uniform sampler2D s;uniform vec2 shift;uniform float fade;'+ 'void main(){vec2 u=uv-shift;'+ 'vec3 b=(u==clamp(u,0.,1.)?texture(s,u).rgb:vec3(1))-fade;'+ 'c=vec4(max(texture(s,uv).rgb,b),1);}'); // combine: long shadows times the leak, and a caster's texels take some of the light just upstream // of them, a rim on its side facing the light p.combine = glCreateProgram(quadVertex, header + 'uniform sampler2D s,t,f;uniform vec2 rim;uniform float useF,useT;'+ 'void main(){vec3 l=texture(s,uv).rgb*mix(vec3(1),texture(t,uv).rgb,useT);'+ 'vec2 u=uv-rim;vec3 up=u==clamp(u,0.,1.)?texture(s,u).rgb*mix(vec3(1),texture(t,u).rgb,useT):vec3(1);'+ 'vec3 dark=1.-mix(vec3(1),texture(f,vec2(uv.x,1.-uv.y)).rgb,useF);'+ 'c=vec4(max(l,.8*up*dark),1);}'); // add: a world space quad over the area, the built light in the light's color, into the lightmap p.add = glCreateProgram( '#version 300 es\nprecision highp float;uniform mat4 m;uniform vec2 origin;uniform float worldSize;'+ // three times the area, its edge texels carried on by the clamp, so the sun reaches the whole view however // small the map or turned the camera 'in vec2 g;out vec2 uv;void main(){uv=g*3.-1.;gl_Position=m*vec4(origin+uv*worldSize,1,1);}', header + 'uniform sampler2D s;uniform vec4 color;'+ 'void main(){c=vec4(texture(s,uv).rgb*color.rgb*color.a,1);}'); if (lightSystemShadersFailed(p.seed, p.shadow, p.leak, p.combine, p.add)) return; p.seedVAO = createQuadVAO(p.seed, 'p'); p.shadowVAO = createQuadVAO(p.shadow, 'p'); p.leakVAO = createQuadVAO(p.leak, 'p'); p.combineVAO = createQuadVAO(p.combine, 'p'); p.addVAO = createQuadVAO(p.add, 'g'); // samplers on units 1, 2 and 3, the engine's tracked texture on unit 0 stays as it is const units = (program, names)=> { gl.useProgram(program); names.forEach((name, i)=> gl.uniform1i(glUniformLocation(program, name), i + 1)); }; units(p.seed, ['f', 'b']); units(p.shadow, ['s']); units(p.leak, ['s']); units(p.combine, ['s', 't', 'f']); units(p.add, ['s']); } // let go of the textures a directional light made, its work textures and the background map; its programs // stay, small, so a sun made again, as a day and night or a level with its own does, compiles nothing function freeDirectional() { freeDirectionalTextures(); glContext.deleteTexture(lightSystem.backgroundMap); lightSystem.backgroundMap = undefined; lightSystem.backgroundCasters = false; } function clearDirectional() { const ls = lightSystem; ls.directionalTexture = ls.directionalTextureA = ls.directionalTextureB = undefined; ls.directionalPrograms = {}; ls.directionalTextureSizeAllocated = 0; } // build the directional light into directionalTexture, over the shadow map's area: long shadows from the // foreground, times the background leak, with a rim on casters; leaves no framebuffer bound function lightSystemDirectionalPass() { const gl = glContext, ls = lightSystem, light = ls.directionalLight; if (!ls.directionalPrograms.seed) { initDirectional(); if (ls.shadersFailed) return; } if (!ls.directionalTexture || ls.directionalTextureSize !== ls.directionalTextureSizeAllocated) { ls.directionalTexture && freeDirectionalTextures(); initDirectionalTextures(); } const p = ls.directionalPrograms, N = ls.directionalTextureSize, W = ls.shadowMapWorldSize; const d = light.sunDirection.normalize(-1), toUV = (texels)=> vec2(d.x * texels / N, d.y * texels / N); const casts = light.castShadow ? 1 : 0; const cap = ceil(log2(N)) + 1; // passes enough to cross the texture, so a long shadow fades out, never cut off gl.bindFramebuffer(gl.FRAMEBUFFER, glFramebuffer); gl.viewport(0, 0, N, N); gl.disable(gl.BLEND); const target = (texture)=> gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, texture, 0); const bind = (unit, texture)=> { gl.activeTexture(gl.TEXTURE0 + unit); gl.bindTexture(gl.TEXTURE_2D, texture); }; const use = (name)=> { gl.useProgram(p[name]); gl.bindVertexArray(p[name + 'VAO']); return p[name]; }; const draw = ()=> gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4); const u = glUniformLocation; // long shadows: the foreground seeded into A, then passes shifting twice as far each time, ping ponged let program = use('seed'); bind(1, ls.shadowMap); bind(2, ls.backgroundMap || ls.shadowMap); // a texture on the unit, though unread gl.uniform1f(u(program, 'useF'), casts); gl.uniform1f(u(program, 'useB'), 0); gl.uniform1f(u(program, 'tap'), .25 / N); target(ls.directionalTextureA); draw(); let src = ls.directionalTextureA, dst = ls.directionalTextureB; const L = light.shadowLength * N / W; if (casts && L > 0) { program = use('shadow'); const passes = clamp(ceil(log2(max(L, 1))), 1, cap); for (let k = 0; k < passes; ++k) { const shift = toUV(2**k); gl.uniform2f(u(program, 'shift'), shift.x, shift.y); gl.uniform1f(u(program, 'fade'), 2**k / L); bind(1, src); target(dst); draw(); [src, dst] = [dst, src]; } } const shadows = src, free = dst; // the long shadows, and the other work texture // the background leak, seeded from the background alone into the free texture, only when something casts // into the background map; with nothing there the combine leaves it out; the foreground is in the long // shadows already, and seeding it here too darkened a see through caster twice, only beside a background const leaks = ls.backgroundCasters; if (leaks) { program = use('seed'); bind(1, ls.shadowMap); bind(2, ls.backgroundMap); gl.uniform1f(u(program, 'useF'), 0); gl.uniform1f(u(program, 'useB'), 1); target(free); draw(); } let leakSrc = free, leakDst = ls.directionalTexture; const D = light.backgroundDepth * N / W; if (leaks && D > 0) { // passes shifting twice as far each time, as the long shadows, so the light fades in evenly by D program = use('leak'); const passes = clamp(ceil(log2(max(D, 1))), 1, cap); for (let k = 0; k < passes; ++k) { const shift = toUV(2**k); gl.uniform2f(u(program, 'shift'), shift.x, shift.y); gl.uniform1f(u(program, 'fade'), 2**k / D); bind(1, leakSrc); target(leakDst); draw(); [leakSrc, leakDst] = [leakDst, leakSrc]; } } // combine into the one of the three textures left, long shadows times the leak, with a rim on casters; // it is the built light from now on, the other two its work textures const textures = [ls.directionalTexture, ls.directionalTextureA, ls.directionalTextureB]; const built = textures.find((texture)=> texture !== shadows && texture !== leakSrc); program = use('combine'); bind(1, shadows); bind(2, leakSrc); bind(3, ls.shadowMap); const rim = toUV(3); gl.uniform2f(u(program, 'rim'), rim.x, rim.y); gl.uniform1f(u(program, 'useF'), casts); gl.uniform1f(u(program, 'useT'), leaks ? 1 : 0); target(built); draw(); [ls.directionalTextureA, ls.directionalTextureB] = textures.filter((texture)=> texture !== built); ls.directionalTexture = built; // hand the engine its state back: unit 0 active with its texture, no framebuffer bind(1, null); bind(2, null); bind(3, null); gl.activeTexture(gl.TEXTURE0); glActiveTexture && gl.bindTexture(gl.TEXTURE_2D, glActiveTexture); gl.bindFramebuffer(gl.FRAMEBUFFER, null); gl.enable(gl.BLEND); } function lightSystemRender() { if (headlessMode || !glEnable || lightSystem.shadersFailed) return; if (!lightSystem.enabled) return; if (!lightSystem.texture) { // made now if WebGL was off when the plugin was made, or when a lost context came back if (glContext.isContextLost()) return; initLightSystem(); if (!lightSystem.texture) return; } // 1. flush any in-flight sprite batch from earlier render passes glFlush(); const prevAdditive = glAdditive; // 1b. the shadow pass draws every caster black into the shadow map, for shadows or a directional light if (lightSystem.shadows || lightSystem.directionalLight) lightSystemShadowPass(); if (lightSystem.shadersFailed) return; // 1c. the directional light is built from the maps, before the lightmap is bound const sun = lightSystem.directionalLight; if (sun) lightSystemDirectionalPass(); else { // the sun is gone, so are its textures, and the shadow map it made when shadows are off if (lightSystem.directionalTexture || lightSystem.backgroundMap) freeDirectional(); if (!lightSystem.shadows && lightSystem.shadowMap) freeShadowTextures(); } if (lightSystem.shadersFailed) return; // an automatic size follows the canvas, so reallocate the lightmap when // the canvas changed size, after the flush so the batch keeps its texture const size = lightSystem.textureSize; const wantX = glClampTextureSize(mainCanvasSize.x), wantY = glClampTextureSize(mainCanvasSize.y); if (lightSystem.textureSizeAuto && (size.x !== wantX || size.y !== wantY)) { lightSystem.textureSize = vec2(wantX, wantY); glContext.bindTexture(glContext.TEXTURE_2D, lightSystem.texture); glContext.texImage2D(glContext.TEXTURE_2D, 0, glContext.RGBA, wantX, wantY, 0, glContext.RGBA, glContext.UNSIGNED_BYTE, null); // put back the texture the engine tracks, a draw in renderLight must not sample the lightmap if (glActiveTexture) glContext.bindTexture(glContext.TEXTURE_2D, glActiveTexture); } // 2. bind lightmap as render target, clear to ambientColor const ac = lightSystem.ambientColor; glContext.bindFramebuffer(glContext.FRAMEBUFFER, glFramebuffer); glContext.framebufferTexture2D(glContext.FRAMEBUFFER, glContext.COLOR_ATTACHMENT0, glContext.TEXTURE_2D, lightSystem.texture, 0); glContext.viewport(0, 0, lightSystem.textureSize.x, lightSystem.textureSize.y); glContext.clearColor(ac.r, ac.g, ac.b, ac.a); glContext.clear(glContext.COLOR_BUFFER_BIT); // 3. walk engineObjects calling renderLight() — additive blend // (lightmap accumulates raw additive color contributions) setAdditiveBlendMode(); glContext.enable(glContext.BLEND); glContext.blendFunc(glContext.ONE, glContext.ONE); // the camera transform is the same for every light, and a uniform // keeps its value per program, so set it once for the whole pass. // It is the canvas transform glPreRender built: world→NDC over // mainCanvasSize (not textureSize), the viewport handles the // lightmap's actual resolution. No y-flip: the composite samples // this FBO with gl_FragCoord/iResolution (origin bottom-left), so // storing world +Y at the top of the texture lines up with the canvas. const ls = lightSystem.lightShader; glContext.useProgram(ls); glContext.uniformMatrix4fv(glUniformLocation(ls, 'm'), false, glTransform); glSetInstancedMode(true); // a target set and ended while the lights draw, a tile layer redrawn in a renderLight, comes back here glRenderTargetBase = [lightSystem.texture, lightSystem.textureSize]; try { for (const o of engineObjects) { if (o.destroyed) continue; glAdditive || setAdditiveBlendMode(); // added again, an emitter ends its render with it off o.renderLight(); } // 3a. the directional light, added over the shadow map's area in its color if (sun && lightSystem.directionalTexture) { glFlush(); const gl = glContext, p = lightSystem.directionalPrograms, as = p.add, c = sun.color; gl.useProgram(as); gl.bindVertexArray(p.addVAO); gl.uniformMatrix4fv(glUniformLocation(as, 'm'), false, glTransform); gl.uniform2f(glUniformLocation(as, 'origin'), lightSystem.shadowMapOrigin.x, lightSystem.shadowMapOrigin.y); gl.uniform1f(glUniformLocation(as, 'worldSize'), lightSystem.shadowMapWorldSize); gl.uniform4f(glUniformLocation(as, 'color'), c.r, c.g, c.b, c.a); gl.activeTexture(gl.TEXTURE1); gl.bindTexture(gl.TEXTURE_2D, lightSystem.directionalTexture); gl.activeTexture(gl.TEXTURE0); gl.enable(gl.BLEND); gl.blendFunc(gl.ONE, gl.ONE); gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4); glSetInstancedMode(true); } // 3b. emissive objects draw their shape in gray at their emissive level, white at 1, adding that much // light where they are so they show their own colors; text goes to the 1x1 canvas as in the // shadow pass, so it is not drawn twice const saved = [drawContext, glCustomShader]; drawContext = lightSystem.shadowContext; lightSystem.emissivePass = true; try { for (const o of engineObjects) { if (o.destroyed || !(o.emissive > 0)) continue; const level = clamp(o.emissive)*255+.5|0; // packed like rgbaInt, red in the low byte glColorMask = 0xff000000; // its own alpha, and the gray from the additive color glColorAdditive = level | level<<8 | level<<16; glAdditive || setAdditiveBlendMode(); // added, an emitter ends its render with it off setShader(o.shader); o.renderEmissive(); } glFlush(); } finally { lightSystem.emissivePass = false; glColorMask = -1; glColorAdditive = 0; [drawContext, glCustomShader] = saved; } } catch (error) { // a renderLight or renderEmissive threw: what it queued goes to the lightmap, then the screen is the // target again with its viewport and blend, so a game that catches the error draws to the screen; a // flush that throws too is let go of, the first error is the one that counts try { glFlush(); } catch (e) {} glContext.bindFramebuffer(glContext.FRAMEBUFFER, null); glContext.viewport(0, 0, glCanvas.width, glCanvas.height); glActiveTexture && glContext.bindTexture(glContext.TEXTURE_2D, glActiveTexture); glSetInstancedMode(true); setAdditiveBlendMode(prevAdditive); throw error; } finally { glRenderTargetBase = undefined; } // 4. drain any sprite-batched draws (e.g. drawTile inside a // custom renderLight override) so they hit the FBO, not the // canvas after we unbind glFlush(); glContext.bindFramebuffer(glContext.FRAMEBUFFER, null); // backing store size, mainCanvasSize is css pixels glContext.viewport(0, 0, glCanvas.width, glCanvas.height); // 5. composite: fullscreen quad, multiplicative blend onto glCanvas // (scene * lightmap — unlit areas go to black, lit areas are // the scene tinted by the accumulated light color) glContext.useProgram(lightSystem.compositeShader); glContext.bindVertexArray(lightSystem.compositeVAO); glContext.activeTexture(glContext.TEXTURE0); glContext.bindTexture(glContext.TEXTURE_2D, lightSystem.texture); const cs = lightSystem.compositeShader; glContext.uniform1i(glUniformLocation(cs, 's'), 0); glContext.uniform3f(glUniformLocation(cs, 'iResolution'), mainCanvas.width, mainCanvas.height, 1); glContext.blendFunc(glContext.DST_COLOR, glContext.ZERO); glContext.drawArrays(glContext.TRIANGLE_STRIP, 0, 4); // 6. restore engine state so subsequent draws use the engine's // tracked texture binding (otherwise glSetTexture would think // the prior texture was still bound when actually the lightmap // is, and any debug text / future draw could sample the lightmap) if (glActiveTexture) glContext.bindTexture(glContext.TEXTURE_2D, glActiveTexture); glSetInstancedMode(true); // 7. the lights' glows, added over the lit scene so the darkness does not dim them setAdditiveBlendMode(); for (const o of engineObjects) o instanceof Light && !o.destroyed && o.renderGlow(); glFlush(); setAdditiveBlendMode(prevAdditive); } function lightSystemContextLost() { lightSystem.texture = undefined; lightSystem.lightShader = undefined; lightSystem.compositeShader = undefined; lightSystem.lightVAO = undefined; lightSystem.compositeVAO = undefined; clearShadows(); clearDirectional(); LOG('LightSystemPlugin: WebGL context lost'); } function lightSystemContextRestored() { lightSystem.shadersFailed = false; // tried again on the new context, as the 3D renderer is initLightSystem(); LOG('LightSystemPlugin: WebGL context restored'); } } /** Bring the sizes of the textures it makes down to what the device can make: shadowMapSize, shadowTextureSize * and a textureSize given by hand; called before they are made, so a size too big falls back instead of failing */ clampTextureSizes() { this.shadowMapSize = glClampTextureSize(this.shadowMapSize); this.shadowTextureSize = glClampTextureSize(this.shadowTextureSize); ASSERT(isNumber(this.directionalTextureSize) && this.directionalTextureSize >= 1, 'directionalTextureSize is texels, 1 or more, taken down to whole texels', this.directionalTextureSize); this.directionalTextureSize = glClampTextureSize(max(1, floor(this.directionalTextureSize) || 1)); const size = this.textureSize; if (!size) return; const x = glClampTextureSize(size.x), y = glClampTextureSize(size.y); if (x !== size.x || y !== size.y) this.textureSize = vec2(x, y); // only when it is too large, this runs every frame } /** Draw a single Light's falloff blob into the currently bound lightmap. * Called by Light.renderLight() during the plugin's render pass. * @param {Light} light */ drawLight(light) { if (headlessMode || !glEnable || !this.lightShader) return; // skip a light that can not touch the screen, its quad is the full // radius out from its center on every side if (!isOnScreen(light.pos, light.radius*2)) return; // drain any sprite-batched draws queued by a previous custom // renderLight() override (e.g. drawRect inside a LavaTile). They were // queued in the engine's instanced-vertex format and must flush with // the engine's shader+VAO bound — NOT this plugin's light shader. glFlush(); // a shadow casting light builds its shadow texture first, leaving it on unit 1 and the lightmap bound const useShadow = this.shadows && light.castShadow && !!this.shadowMap; useShadow && this.renderLightShadow(light); glContext.useProgram(this.lightShader); glContext.bindVertexArray(this.lightVAO); glContext.enable(glContext.BLEND); glContext.blendFunc(glContext.ONE, glContext.ONE); // added, whatever a tile layer redrawn before it left set // the camera transform 'm' was set once for the pass by the plugin const ls = this.lightShader; glContext.uniform2f(glUniformLocation(ls, 'lightPos'), light.pos.x, light.pos.y); glContext.uniform1f(glUniformLocation(ls, 'radius'), light.radius); glContext.uniform1f(glUniformLocation(ls, 'fadeRange'), light.fadeRange); glContext.uniform1i(glUniformLocation(ls, 'useShadow'), useShadow ? 1 : 0); const c = light.color; glContext.uniform4f(glUniformLocation(ls, 'color'), c.r, c.g, c.b, c.a); // the cone and the gel look along the light's up const forward = light.getUp(); glContext.uniform2f(glUniformLocation(ls, 'forward'), forward.x, forward.y); const [coneFactor, coneEdge] = lightSystemCone(light); glContext.uniform2f(glUniformLocation(ls, 'cone'), coneFactor, coneEdge); const gel = lightSystemGel(light); glContext.uniform1i(glUniformLocation(ls, 'useGel'), gel ? 1 : 0); glContext.activeTexture(glContext.TEXTURE2); glContext.bindTexture(glContext.TEXTURE_2D, gel ? gel.texture : null); glContext.activeTexture(glContext.TEXTURE0); gel && glContext.uniform4fv(glUniformLocation(ls, 'gelRect'), gel.rect); glContext.drawArrays(glContext.TRIANGLE_STRIP, 0, 4); // restore engine's instanced shader+VAO so subsequent renderLight() // overrides that batch through drawRect/drawTile work correctly glSetInstancedMode(true); } /** Build a light's shadow texture from the shadow map: the map around the light, its casters stretched * away from the light a little further each pass with light bled into their near sides. Leaves the * result on texture unit 1 and the lightmap bound again. Called by drawLight. * @param {Light} light */ renderLightShadow(light) { const gl = glContext, size = this.shadowTextureSize; gl.disable(gl.BLEND); gl.viewport(0, 0, size, size); gl.activeTexture(gl.TEXTURE1); // the shadow map around the light, light at the center of the texture gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, this.shadowTextureA, 0); const cs = this.shadowCopyShader; gl.useProgram(cs); gl.bindVertexArray(this.shadowCopyVAO); gl.bindTexture(gl.TEXTURE_2D, this.shadowMap); gl.uniform2f(glUniformLocation(cs, 'lightPos'), light.pos.x, light.pos.y); gl.uniform1f(glUniformLocation(cs, 'radius'), light.radius); gl.uniform2f(glUniformLocation(cs, 'mapOrigin'), this.shadowMapOrigin.x, this.shadowMapOrigin.y); gl.uniform1f(glUniformLocation(cs, 'mapInvSize'), 1/this.shadowMapWorldSize); gl.uniform1f(glUniformLocation(cs, 'tap'), light.radius/size); gl.uniform1f(glUniformLocation(cs, 'core'), light.shadowCore); gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4); // stretch the casters out from the light, starting a 128th of the texture and growing 1.5x a pass, which // reaches the edge in about 16 passes with no gaps since each pass scales by less than the shadow already // extends; small steps, so the bleed does not show as stairs along the shadow edges; a fraction of the // texture rather than a count of texels, so a larger texture only makes the shadows sharper, not shorter; // the bleed fades out over the first 10 passes, the distances FrankEngine's soften covered at 1.8x const ss = this.shadowStretchShader; gl.useProgram(ss); gl.bindVertexArray(this.shadowStretchVAO); let src = this.shadowTextureA, dst = this.shadowTextureB; for (let k = 0; k < this.shadowPassCount; ++k) { gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, dst, 0); gl.bindTexture(gl.TEXTURE_2D, src); gl.uniform1f(glUniformLocation(ss, 'scale'), 1 + 2*1.5**k/256); gl.uniform1f(glUniformLocation(ss, 'brightness'), clamp((10.1-k)/7.2)**2 * this.shadowSoftness); gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4); [src, dst] = [dst, src]; } // leave the result on unit 1 for the light shader and go back to the lightmap gl.bindTexture(gl.TEXTURE_2D, src); gl.activeTexture(gl.TEXTURE0); gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, this.texture, 0); gl.viewport(0, 0, this.textureSize.x, this.textureSize.y); gl.enable(gl.BLEND); gl.blendFunc(gl.ONE, gl.ONE); } /** In the shadow pass, let the draws that follow keep their color in the shadow map, so light passing * through them is tinted instead of blocked: a stained glass window, colored smoke. Any draw blocks light * by its alpha, so a fading sprite casts a fading shadow; this keeps the color as well. It covers what was drawn * under it in the map as any draw does, so glass drawn after a wall cuts a tinted window in the wall's shadow, and * a wall drawn after the glass covers it. Does nothing outside * the pass, so a render() can call it around those draws unconditionally; set it back to false after them. * @param {boolean} [transparent] */ setShadowTransparent(transparent=true) { // the mask applies as each draw is queued, so nothing needs flushing if (this.shadowPass) glColorMask = transparent ? -1 : 0xff000000; } } /////////////////////////////////////////////////////////////////////////////// // a light's cone for its shader, the factor and its edge times it: the dot of its up and a direction times the factor, // less the edge, is 1 where the fade starts and 0 at the edge; (0, -1) for no cone, which leaves the light as it was function lightSystemCone(light) { const angle = min(light.coneAngle, PI); if (!(angle > 0)) return [0, -1]; const outer = cos(angle), inner = cos(angle * (1 - clamp(light.coneSoftness))); const k = 1 / max(inner - outer, 1e-4); return [k, outer * k]; } // a light's gel for its shader, its texture and its tile's corner and size in it, a half texel in so the tiles beside // it do not bleed in; undefined for none or one not loaded yet function lightSystemGel(light) { const gel = light.gel; if (!gel) return; const isTile = gel instanceof TileInfo, textureInfo = isTile ? gel.textureInfo : gel; const texture = textureInfo?.glTexture, size = textureInfo?.size; if (!texture || !(size?.x > 0)) return; const pos = isTile ? gel.pos : vec2(), tileSize = isTile ? gel.size : size; return {texture, rect: [(pos.x + .5) / size.x, (pos.y + .5) / size.y, (tileSize.x - 1) / size.x, (tileSize.y - 1) / size.y]}; } /** * A Light is an EngineObject that contributes a soft additive blob of color * to the LightSystem plugin's lightmap. * - castShadow on a Light means its rays stop at casters when lightSystem.shadows is on, three.js's meaning * for a light; a Light's own render() draws nothing so the object meaning never applies to it * - A light inside a caster is blocked entirely, so the object that holds it, its lamp, a torch, the player * carrying it, needs a shadowCore that reaches past it, castShadow = false, or a renderShadow that leaves * the light's spot out * - A coneAngle makes it a cone along its up, turned by its angle, and a gel is a picture it shines through; both * are applied over the round light with its shadows, so they cost next to nothing * @extends EngineObject * @memberof LightSystem * @example * new Light(vec2(5, 5), 4, rgb(1, 0.5, 0)); // orange light, full soft blob * new Light(vec2(0, 0), 8, rgb(1, 1, 1), 2); // white core with 2-unit soft halo * const flashlight = new Light(vec2(), 10); // a flashlight looking right, its beam 60 degrees across * flashlight.coneAngle = PI/6; * flashlight.angle = PI/2; */ class Light extends EngineObject { /** Create a light object and add it to the engine object list * @param {Vector2} pos - World space position * @param {number} radius - Total extent of the light in world units * @param {Color} [color] - Color of the light; alpha modulates intensity * @param {number} [fadeRange] - Width of the soft edge in world units (defaults to radius) */ constructor(pos, radius, color, fadeRange) { super(pos, vec2(1), undefined, 0, color); this.mass = 0; // static, a light stays where it is put in a game with gravity ASSERT(isNumber(radius) && radius >= 0, 'Light radius must be a non-negative number'); ASSERT(fadeRange === undefined || (isNumber(fadeRange) && fadeRange >= 0), 'Light fadeRange must be a non-negative number when provided'); /** @property {number} - Total extent of the light in world units */ this.radius = radius; /** @property {number} - Width of the soft edge in world units */ this.fadeRange = fadeRange === undefined ? radius : fadeRange; /** @property {number} - Radius around the light where casters are left out of its shadow, so the lamp * or torch that holds it, or the player carrying it, does not block it; it has to reach past that object's * corners, about half its diagonal and a little more, or dark rays run out from them */ this.shadowCore = 0; /** @property {number} - Size across of a soft hazy glow drawn over the light, like a lamp at night, 0 for * none; it is added over the lit scene, in front of everything there */ this.glow = 0; /** @property {number} - How fast the glow fades from its middle: 1 by default, .5 a wide haze, 2 a tight * bright core */ this.glowFalloff = 1; /** @type {TileInfo|undefined} */ this.glowTileInfo = undefined; // the whole glow texture, kept for the falloff it was made for /** @property {number} - Makes it a cone, like a flashlight or headlight: the angle in radians from its up out * to the edge of the cone, so the beam is twice this across; it looks along getUp(), turned by its angle and * by what it is attached to; 0 for a light that shines every way */ this.coneAngle = 0; /** @property {number} - How much of the cone is its fading edge: 0 a hard edge, .2 by default, the outer * fifth, 1 fading all the way from the middle of the beam */ this.coneSoftness = .2; /** @property {TileInfo|TextureInfo|undefined} - A gel, a picture the light shines through, called a cookie in * some engines: stretched across the light's square, twice its radius, its top the way the light looks, * turned with it, and multiplied into its color, so one light can be a car's headlights and tail lights; * a TileInfo's own tile, or a whole TextureInfo; shadows still fall through it * @type {TileInfo|TextureInfo|undefined} */ this.gel = undefined; } /** Lights are invisible in the main render pass — they only contribute * to the lightmap via renderLight(). */ render() {} /** Draw this light's falloff blob into the lightmap. * Called by LightSystemPlugin during its render pass. No-op when the * plugin or WebGL is unavailable. */ renderLight() { lightSystem && lightSystem.drawLight(this); } /** Draw this light's glow, soft and round, its glow size across and in its color, added over the lit scene; * called by LightSystemPlugin after the lightmap is applied */ renderGlow() { if (!(this.glow > 0)) return; const size = vec2(this.glow); if (!isOnScreen(this.pos, size)) return; const texture = engineGlowTexture(this.glowFalloff); if (!texture) return; if (this.glowTileInfo?.textureInfo !== texture) this.glowTileInfo = new TileInfo(vec2(), texture.size, texture); drawTile(this.pos, size, this.glowTileInfo, this.color, 0, false, undefined, true, false); } } /////////////////////////////////////////////////////////////////////////////// /** * A DirectionalLight is a sun for the 2D light system: it lights the whole scene from one direction, added into the * lightmap with the point lights * - One at a time, made after the LightSystemPlugin, lightSystem.directionalLight is the one; a debug build asserts on * a second while the first lives, a release build destroys the first * - Its castShadow lets foreground casters, objects with castShadow, throw long shadows across open space, fading out * by shadowLength; a light does not need lightSystem.shadows for that * - Objects with castBackgroundShadow, a background layer, are dark to it inside and lit at the edges that face it, * fading in by backgroundDepth * @extends EngineObject * @memberof LightSystem * @example * new DirectionalLight(vec2(-1, 1), hsl(.1, .3, 1)); // a warm sun up and to the left, shining down and to the right */ class DirectionalLight extends EngineObject { /** Create the scene's directional light * @param {Vector2} [sunDirection] - Toward the sun, it shines the other way, as render3D.sunDirection * @param {Color} [color] - Color of the light; alpha modulates intensity */ constructor(sunDirection=vec2(-1, 1), color=WHITE) { ASSERT(!!lightSystem, 'make a LightSystemPlugin before a DirectionalLight'); ASSERT(isVector2(sunDirection) && !!(sunDirection.x || sunDirection.y), 'DirectionalLight: sunDirection is a vec2 that is not zero, toward the sun', sunDirection); ASSERT(!lightSystem?.directionalLight, 'there is one DirectionalLight at a time, destroy the old one first'); super(vec2(), vec2(), undefined, 0, color); this.mass = 0; // it does not fall in a game with gravity /** @property {Vector2} - Toward the sun, it shines the other way, as render3D.sunDirection */ this.sunDirection = sunDirection.copy(); /** @property {number} - World units a long shadow reaches before it has faded out; a caster casts only from * inside the shadow map, shadowMapScale views across, so past (shadowMapScale - 1) / 2 of a view beyond the * screen it throws none in */ this.shadowLength = 20; /** @property {number} - World units the light gets into a background area from its edges facing it */ this.backgroundDepth = 3; // castShadow is EngineObject's, true: foreground casters throw long shadows; it draws nothing, so never casts // in a release build, with no assert, a second takes over and the first goes, not left alive doing nothing lightSystem?.directionalLight?.destroy(); lightSystem && (lightSystem.directionalLight = this); } /** Check its settings, called automatically each frame */ update() { ASSERT(isVector2(this.sunDirection) && !!(this.sunDirection.x || this.sunDirection.y), 'DirectionalLight: sunDirection is a vec2 that is not zero, toward the sun', this.sunDirection); ASSERT(this.shadowLength >= 0 && this.backgroundDepth >= 0, 'DirectionalLight: shadowLength and backgroundDepth are world units, 0 or more', this.shadowLength, this.backgroundDepth); } /** A directional light draws nothing of its own, it is added into the lightmap by the plugin */ render() {} /** Destroy the light, the scene goes without it from the next frame * @param {boolean} [immediate] */ destroy(immediate) { if (lightSystem?.directionalLight === this) lightSystem.directionalLight = undefined; super.destroy(immediate); } } /** * LittleJS Audio Effects Plugin * - Web Audio effects with a wet/dry mix: filter, reverb, delay, distortion, compressor * - Route a sound through one with sound.output = effect * - Route everything with setAudioMasterEffect(effect) * - Chain effects with effect.connect(nextEffect) * @namespace AudioEffects */ /////////////////////////////////////////////////////////////////////////////// // audioParamRamp lives in engineAudio.js, core loads before plugins in the bundle /** * Base class for audio effects, an input and output with a wet/dry mix between them * - Sounds connect to input, output goes to the master gain until connect() moves it * - Subclasses put their nodes between input and the wet gain with connectEffect * @memberof AudioEffects * @example * const cave = new AudioReverb(3, 2); * footstep.output = cave; // every play of this sound is in the cave */ class AudioEffect { /** Create an audio effect * @param {number} [mix] - Wet/dry balance, 0 is fully dry and 1 is fully wet */ constructor(mix=1) { ASSERT(isNumber(mix), 'mix must be a number'); /** @property {number} - Wet/dry balance, 0 is fully dry and 1 is fully wet */ this.mix = clamp(mix); if (!audioContext) return; // no audio outside a browser, where the engine runs headless /** @property {GainNode} - Connect sounds to this node */ this.input = audioContext.createGain(); /** @property {GainNode} - This node carries the mixed result, send it somewhere with connect(), never by assigning here * - Unlike sound.output, which is where a sound's audio goes and can be set to an effect */ this.output = audioContext.createGain(); /** @property {GainNode} - Level of the unprocessed signal */ this.dryGain = audioContext.createGain(); /** @property {GainNode} - Level of the processed signal */ this.wetGain = audioContext.createGain(); this.input.connect(this.dryGain).connect(this.output); this.wetGain.connect(this.output); this.setMix(mix); // send the result to the speakers, connect() moves it into a chain instead this.output.connect(audioMasterGain); } /** Set the wet/dry balance * @param {number} mix - 0 is fully dry and 1 is fully wet * @param {number} [fadeTime] - Seconds to ramp over so the change doesn't click */ setMix(mix, fadeTime=0) { ASSERT(isNumber(mix), 'mix must be a number'); this.mix = mix = clamp(mix); if (!audioContext) return; this.rampParam(this.dryGain.gain, 1-mix, fadeTime); this.rampParam(this.wetGain.gain, mix, fadeTime); } /** Ramp one of this effect's params, keeping the effect running until the ramp is done * - The browser drops an effect from rendering while nothing plays through it, which * would freeze a ramp partway, so a silent source feeds the input for the ramp's length * @param {AudioParam} param - The param to ramp * @param {number} value - Where to ramp to * @param {number} [fadeTime] - Seconds to ramp over, 0 sets the value at once * @protected */ rampParam(param, value, fadeTime=0) { audioParamRamp(param, value, fadeTime); if (!fadeTime) return; const keepAlive = new ConstantSourceNode(audioContext, { offset: 0 }); keepAlive.connect(this.input); keepAlive.onended = ()=> keepAlive.disconnect(); keepAlive.start(); keepAlive.stop(audioContext.currentTime + fadeTime); } /** Send this effect's output into another effect or audio node instead of the speakers * @template {AudioEffect|AudioNode} T * @param {T} target - The next effect in the chain, or any audio node * @return {T} - The target, so chains read left to right */ connect(target) { if (!audioContext) return target; // no audio outside a browser, where the engine runs headless // an effect stands in for its input node, the same rule as sound.output const node = /** @type {AudioNode} */ (target && 'input' in target ? target.input : target); ASSERT(node && typeof node.connect === 'function', 'target must be an AudioEffect or AudioNode'); this.output.disconnect(); this.output.connect(node); return target; } /** Stop sending this effect's output anywhere */ disconnect() { this.output?.disconnect(); } /** Wire nodes between the input and the wet gain, for subclasses * @param {AudioNode} first - Node the input connects to * @param {AudioNode} [last=first] - Node that connects to the wet gain * @protected */ connectEffect(first, last=first) { this.input.connect(first); last.connect(this.wetGain); } } /////////////////////////////////////////////////////////////////////////////// /** * Filter effect, muffle sounds underwater or behind a wall * @extends AudioEffect * @memberof AudioEffects * @example * const muffle = new AudioFilter('lowpass', 400); * setAudioMasterEffect(muffle); * muffle.setFrequency(20000, .5); // sweep back to clear */ class AudioFilter extends AudioEffect { /** Create a filter effect * @param {BiquadFilterType} [type] - lowpass, highpass, bandpass, notch, etc. * @param {number} [frequency] - Cutoff or center frequency in Hz * @param {number} [q] - Resonance at the cutoff, higher is sharper * @param {number} [mix] - Wet/dry balance, 0 is fully dry and 1 is fully wet */ constructor(type='lowpass', frequency=1000, q=1, mix=1) { super(mix); ASSERT(isNumber(frequency) && frequency >= 0, 'frequency must be positive or zero'); ASSERT(isNumber(q), 'q must be a number'); if (!audioContext) return; // no audio outside a browser, where the engine runs headless /** @property {BiquadFilterNode} - The filter node */ this.node = audioContext.createBiquadFilter(); this.node.type = type; this.node.frequency.value = frequency; this.node.Q.value = q; this.connectEffect(this.node); } /** Set the cutoff or center frequency * @param {number} frequency - Frequency in Hz * @param {number} [fadeTime] - Seconds to sweep over */ setFrequency(frequency, fadeTime=0) { ASSERT(isNumber(frequency) && frequency >= 0, 'frequency must be positive or zero'); if (!audioContext) return; this.rampParam(this.node.frequency, frequency, fadeTime); } /** Set the resonance at the cutoff * @param {number} q - Higher is sharper * @param {number} [fadeTime] - Seconds to ramp over */ setQ(q, fadeTime=0) { ASSERT(isNumber(q), 'q must be a number'); if (!audioContext) return; this.rampParam(this.node.Q, q, fadeTime); } } /////////////////////////////////////////////////////////////////////////////// /** * Reverb effect, puts sounds in a room, cave, or hall * - The impulse response is generated, no audio file needed * @extends AudioEffect * @memberof AudioEffects * @example * const hall = new AudioReverb(4, 1.5, .4); * footstep.output = hall; */ class AudioReverb extends AudioEffect { /** Create a reverb effect * @param {number} [duration] - Seconds until the reverb tail is silent * @param {number} [decay] - How quickly the tail fades, higher is faster * @param {number} [mix] - Wet/dry balance, 0 is fully dry and 1 is fully wet */ constructor(duration=2, decay=2, mix=.5) { super(mix); if (!audioContext) return; // no audio outside a browser, where the engine runs headless /** @property {ConvolverNode} - The convolver node */ this.node = audioContext.createConvolver(); this.setRoom(duration, decay); this.connectEffect(this.node); } /** Change the room by rebuilding the impulse response * @param {number} duration - Seconds until the reverb tail is silent * @param {number} [decay] - How quickly the tail fades, higher is faster */ setRoom(duration, decay=2) { ASSERT(isNumber(duration) && duration > 0, 'duration must be positive'); ASSERT(isNumber(decay) && decay > 0, 'decay must be positive'); if (!audioContext) return; this.node.buffer = this.createImpulse(duration, decay); } /** Build a stereo impulse response of decaying noise * @param {number} duration - Seconds until silence * @param {number} decay - How quickly it fades, higher is faster * @return {AudioBuffer} */ createImpulse(duration, decay) { const sampleRate = audioContext.sampleRate; const length = max(1, sampleRate * duration | 0); const buffer = audioContext.createBuffer(2, length, sampleRate); for (let channel = 2; channel--;) { const samples = buffer.getChannelData(channel); for (let i = length; i--;) samples[i] = rand(-1, 1) * (1 - i/length) ** decay; } return buffer; } } /////////////////////////////////////////////////////////////////////////////// /** * Delay effect, echoes that repeat and fade * @extends AudioEffect * @memberof AudioEffects * @example * const canyon = new AudioDelay(.4, .5); * shout.output = canyon; */ class AudioDelay extends AudioEffect { /** Create a delay effect * @param {number} [time] - Seconds between echoes, up to 5 * @param {number} [feedback] - How much of each echo repeats, 0 to .95 * @param {number} [mix] - Wet/dry balance, 0 is fully dry and 1 is fully wet */ constructor(time=.3, feedback=.4, mix=.5) { super(mix); if (!audioContext) return; // no audio outside a browser, where the engine runs headless /** @property {DelayNode} - The delay node */ this.node = audioContext.createDelay(5); /** @property {GainNode} - How much of the delayed signal feeds back in */ this.feedbackGain = audioContext.createGain(); this.node.connect(this.feedbackGain).connect(this.node); this.connectEffect(this.node); this.setTime(time); this.setFeedback(feedback); } /** Set the time between echoes * - Browsers hold a delay in a feedback loop to at least one render quantum, so 0 is not a bypass * @param {number} time - Seconds, up to 5 * @param {number} [fadeTime] - Seconds to ramp over, pitch bends while it moves */ setTime(time, fadeTime=0) { ASSERT(isNumber(time) && time >= 0 && time <= 5, 'time must be between 0 and 5'); if (!audioContext) return; this.rampParam(this.node.delayTime, time, fadeTime); } /** Set how much of each echo repeats, clamped below 1 so it always dies out * @param {number} feedback - 0 to .95 * @param {number} [fadeTime] - Seconds to ramp over */ setFeedback(feedback, fadeTime=0) { ASSERT(isNumber(feedback), 'feedback must be a number'); if (!audioContext) return; this.rampParam(this.feedbackGain.gain, clamp(feedback, 0, .95), fadeTime); } } /////////////////////////////////////////////////////////////////////////////// /** * Distortion effect, overdrive for radios, damaged robots, and engines * @extends AudioEffect * @memberof AudioEffects * @example * const radio = new AudioDistortion(.8); * voice.output = radio; */ class AudioDistortion extends AudioEffect { /** Create a distortion effect * @param {number} [amount] - How hard to drive the signal, 0 is clean and 1 is crushed * @param {number} [mix] - Wet/dry balance, 0 is fully dry and 1 is fully wet */ constructor(amount=.5, mix=1) { super(mix); /** @property {number} - How hard the signal is driven, 0 is clean and 1 is crushed */ this.amount = clamp(amount); if (!audioContext) return; // no audio outside a browser, where the engine runs headless /** @property {WaveShaperNode} - The wave shaper node */ this.node = audioContext.createWaveShaper(); this.node.oversample = '2x'; this.setAmount(amount); this.connectEffect(this.node); } /** Set how hard to drive the signal, rebuilds the shaping curve * @param {number} amount - 0 is clean and 1 is crushed */ setAmount(amount) { ASSERT(isNumber(amount), 'amount must be a number'); this.amount = amount = clamp(amount); if (!audioContext) return; // soft clip curve, drive grows with the square of amount so low values stay subtle // enough points that quiet signals are still shaped at high drive, where the curve is steep near 0 const drive = 100 * amount * amount; const samples = 1024; const curve = new Float32Array(samples); for (let i = samples; i--;) { const x = i * 2 / (samples - 1) - 1; curve[i] = (1 + drive) * x / (1 + drive * abs(x)); } this.node.curve = curve; } } /////////////////////////////////////////////////////////////////////////////// /** * Compressor effect, evens out loud and quiet so many sounds at once don't clip * - Meant for the master bus, it is not on by default * @extends AudioEffect * @memberof AudioEffects * @example * const compressor = new AudioCompressor; * setAudioMasterEffect(compressor); */ class AudioCompressor extends AudioEffect { /** Create a compressor effect * @param {number} [threshold] - Level in dB above which the signal is reduced * @param {number} [ratio] - How much to reduce it, 12 means 12 dB in becomes 1 dB out * @param {number} [mix] - Wet/dry balance, 0 is fully dry and 1 is fully wet */ constructor(threshold=-24, ratio=12, mix=1) { super(mix); ASSERT(isNumber(threshold), 'threshold must be a number'); ASSERT(isNumber(ratio) && ratio >= 1, 'ratio must be 1 or more'); if (!audioContext) return; // no audio outside a browser, where the engine runs headless /** @property {DynamicsCompressorNode} - The compressor node */ this.node = audioContext.createDynamicsCompressor(); this.node.threshold.value = threshold; this.node.ratio.value = ratio; this.connectEffect(this.node); } /** Set the level above which the signal is reduced * @param {number} threshold - Level in dB * @param {number} [fadeTime] - Seconds to ramp over */ setThreshold(threshold, fadeTime=0) { ASSERT(isNumber(threshold), 'threshold must be a number'); if (!audioContext) return; this.rampParam(this.node.threshold, threshold, fadeTime); } /** Set how much the signal is reduced above the threshold * @param {number} ratio - 1 is no reduction, 20 is a hard limit * @param {number} [fadeTime] - Seconds to ramp over */ setRatio(ratio, fadeTime=0) { ASSERT(isNumber(ratio) && ratio >= 1, 'ratio must be 1 or more'); if (!audioContext) return; this.rampParam(this.node.ratio, ratio, fadeTime); } } /** * LittleJS User Interface Plugin * - call new UISystemPlugin() to setup the UI system * - Gamepad and keyboard navigation support * - Nested Menus * - Text * - Buttons * - Checkboxes * - Images * - Sliders * - Video * @namespace UISystem */ /////////////////////////////////////////////////////////////////////////////// /** Global UI system plugin object * @type {UISystemPlugin} * @memberof UISystem */ let uiSystem; /** Enable UI system debug drawing * 0=off, 1=normal, 2=show invisible * @type {number} * @default * @memberof UISystem */ let uiDebug = 0; // the active object's press was let go of in the frame it came, a tap, so it is clicked on the next with no release // then; a click needs a release, which the mouse let go of by a window losing focus is not let uiActiveReleased = false; /** Enable UI system debug drawing * 0=off, 1=normal, 2=show invisible * @param {number|boolean} debugMode * @memberof UISystem */ function uiSetDebug(debugMode) { uiDebug = typeof debugMode === 'boolean' ? (debugMode ? 1 : 0) : debugMode; } /** * @callback DragAndDropCallback - Callback for drag and drop events * @param {DragEvent} event - The drag event * @memberof UISystem */ /////////////////////////////////////////////////////////////////////////////// /** * UI System Global Object * @memberof UISystem */ class UISystemPlugin { /** Create the global UI system object * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context] * @example * // create the ui plugin object * new UISystemPlugin; */ constructor(context=mainContext) { ASSERT(!uiSystem, 'UI system already initialized'); ASSERT(context || headlessMode, 'create the UISystemPlugin after engineInit, in gameInit'); uiSystem = this; // default settings /** @property {boolean} - Activate when mouse is pressed down instead of clicked */ this.activateOnPress = false; /** @property {Color} - Default fill color for UI elements */ this.defaultColor = WHITE; /** @property {Color} - Default outline color for UI elements */ this.defaultLineColor = BLACK; /** @property {Color} - Default text color for UI elements */ this.defaultTextColor = BLACK; /** @property {Color} - Default button color for UI elements */ this.defaultButtonColor = hsl(0,0,.7); /** @property {Color} - Default hover color for UI elements */ this.defaultHoverColor = hsl(0,0,.9); /** @property {Color} - Default color for disabled UI elements */ this.defaultDisabledColor = hsl(0,0,.3); /** @property {Color|undefined} - Uses a gradient fill combined with color * @type {Color|undefined} */ this.defaultGradientColor = undefined; /** @property {number} - Default line width for UI elements */ this.defaultLineWidth = 4; /** @property {number} - Default rounded rect corner radius for UI elements */ this.defaultCornerRadius = 0; /** @property {number} - Default scale to use for fitting text to object */ this.defaultTextFitScale = .8; /** @property {string} - Default font for UI elements */ this.defaultFont = fontDefault; /** @property {Sound|undefined} - Default sound when interactive UI element is pressed * @type {Sound|undefined} */ this.defaultSoundPress = undefined; /** @property {Sound|undefined} - Default sound when interactive UI element is released * @type {Sound|undefined} */ this.defaultSoundRelease = undefined; /** @property {Sound|undefined} - Default sound when interactive UI element is clicked * @type {Sound|undefined} */ this.defaultSoundClick = undefined; /** @property {Color} - Color for shadow */ this.defaultShadowColor = CLEAR_BLACK; /** @property {number} - Size of shadow blur */ this.defaultShadowBlur = 5; /** @property {Vector2} - Offset of shadow blur */ this.defaultShadowOffset = vec2(5); /** @property {TileSlice|undefined} - Style to draw UI elements with in place of their rectangle, tinted by * their color, undefined for rectangles; needs the drawUtilities plugin * @type {TileSlice|undefined} */ this.defaultSlice = undefined; /** @property {TileSlice|undefined} - Style to draw slider handles with, undefined for the slider's own slice * @type {TileSlice|undefined} */ this.defaultHandleSlice = undefined; /** @property {number} - If set ui coords will be renormalized to this canvas height */ this.nativeHeight = 0; // navigation properties /** @property {UIObject|undefined} - Object currently selected by navigation (gamepad or keyboard) * @type {UIObject|undefined} */ this.navigationObject = undefined; /** @property {Timer} - Cool down timer for navigation inputs */ this.navigationTimer = new Timer(undefined, true); /** @property {number} - Time between navigation inputs in seconds */ this.navigationDelay = .2; /** @property {number} - Which way keys and gamepads move the selection: 0 horizontal, 1 vertical, 2 both */ this.navigationDirection = 1; /** @property {boolean} - True if user last used navigation instead of mouse */ this.navigationMode = false; // system state /** @property {Array} - List of all UI elements * @type {Array} */ this.uiObjects = []; /** @property {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} - Context to render UI elements to */ this.uiContext = context; /** @property {UIObject|undefined} - Object user is currently interacting with * @type {UIObject|undefined} */ this.activeObject = undefined; /** @property {UIObject|undefined} - Top most object user is over * @type {UIObject|undefined} */ this.hoverObject = undefined; /** @property {UIObject|undefined} - Hover object at start of update * @type {UIObject|undefined} */ this.lastHoverObject = undefined; /** @property {UIObject|undefined} - Current confirm menu being shown * @type {UIObject|undefined} */ this.confirmDialog = undefined; /** @private * @type {UIObject|undefined} */ this._keyInputObject = undefined; /** @private * @type {Array>|undefined} */ this._dragListeners = undefined; /** @private */ this._onKeyDown = (e) => { // a field that was hidden, disabled or destroyed since it took focus, or is behind a confirm dialog opened // since, does not take the key, the game gets it, and the next UI update ends the edit, so it ends one // way whichever comes first const o = this._keyInputObject; if (!o || !uiObjectIsUsable(o) || uiObjectIsBehindDialog(o)) return; // the field has the key, the game's input never sees it; browser shortcuts still work, // and only the keys a field uses lose their default, so F5, F11, F12 and the like still work e.stopPropagation(); const key = e.key || ''; if (!e.ctrlKey && !e.metaKey && !e.altKey) if (key.length === 1 || /^(Backspace|Delete|Tab|Arrow(Up|Down|Left|Right)|Home|End|Enter|Escape)$/.test(key)) e.preventDefault(); // no scrolling, find as you type or going back e.type === 'keydown' && o.onKeyDown(e); }; engineAddPlugin(uiUpdate, uiRender); // set object position based on anchor target (parent box, or canvas for roots), // self-pivot, and localPos offset function updateTransforms(o) { let targetPos, targetSize; if (o.parent) { targetPos = o.parent.nativePos; targetSize = o.parent.size; } else { // anchor to canvas in native coords (handles nativeHeight if set) targetPos = uiSystem.screenToNative(mainCanvasSize.scale(.5)); targetSize = uiSystem.nativeHeight ? vec2(mainCanvasSize.x * uiSystem.nativeHeight / mainCanvasSize.y, uiSystem.nativeHeight) : mainCanvasSize; } const a = o.anchor; o.nativePos = targetPos .add(targetSize.multiply(a).scale(.5)) // anchor point on target .subtract(o.size.multiply(a).scale(.5)) // pivot shift on self .add(o.localPos); // user offset } // setup recursive update and render // update in reverse order to detect mouse enter/leave let updatePass = 0; // marks the objects each update has reached, see updateObject function uiUpdate() { ++updatePass; // a held or focused object that can no longer be used, itself or through a parent, lets go, // and one that was hidden or disabled is still released, a destroyed one stays silent const activeObject = uiSystem.activeObject; if (activeObject && !uiObjectIsUsable(activeObject)) { // a text field being edited is active without being held, it was released when the press ended uiSystem.activeObject = undefined; activeObject.destroyed || activeObject === uiSystem.keyInputObject || activeObject.onRelease(); } // an edit whose field can no longer be used, or is behind a confirm dialog, ends as if it were finished, // so onChange keeps the text const keyInputObject = uiSystem.keyInputObject; if (keyInputObject && (!uiObjectIsUsable(keyInputObject) || uiObjectIsBehindDialog(keyInputObject))) { if (keyInputObject instanceof UITextInput && !keyInputObject.destroyed) keyInputObject.stopEditing(); else uiSystem.keyInputObject = undefined; } // a click off the field being edited ends the edit before anything updates, so the click goes on to // what it lands on, another field or a button, as in a web form const editing = uiSystem.keyInputObject; const clickedOff = editing instanceof UITextInput && mouseWasPressed(0) && !editing.isMouseOverlapping(); clickedOff && editing.stopEditing(); // reset hover object at start of update uiSystem.lastHoverObject = uiSystem.hoverObject; uiSystem.hoverObject = undefined; // a hidden object is not updated, so the one whose hover ends by hiding it, itself or through a parent, // is left here; a disabled one still updates and handles its own leave const lastHoverObject = uiSystem.lastHoverObject; if (lastHoverObject && !lastHoverObject.destroyed && uiObjectIsHidden(lastHoverObject)) { uiSystem.lastHoverObject = undefined; lastHoverObject.onLeave(); } if (mouseWasPressed(0)) { // exit navigation mode on mouse press uiSystem.navigationMode = false; uiSystem.navigationObject = undefined; } if (uiSystem.keyInputObject) { // handle text input, navigation keeps its place for when the edit ends, and the hover stays where it // was, so an edit started by navigation does not end with an onLeave that had no onEnter uiSystem.activeObject = uiSystem.keyInputObject; uiSystem.hoverObject = uiSystem.lastHoverObject; } // navigation with gamepad/keyboard const navigableObjects = uiSystem.getNavigableObjects(); if (!navigableObjects.length) uiSystem.navigationObject = undefined; else if (!uiSystem.keyInputObject) { // unselect object if it is no longer navigable if (!navigableObjects.includes(uiSystem.navigationObject)) uiSystem.navigationObject = undefined; if (!isTouchDevice) if (uiSystem.navigationMode && !uiSystem.navigationObject) { // select first auto focus object uiSystem.navigationObject = navigableObjects.find(o=>o.navigationAutoSelect); } // navigate with dpad or left stick if (!uiSystem.navigationTimer.active()) { // navigate through list with gamepad or keyboard const direction = sign(uiSystem.getNavigationDirection()); if (direction) { let newNavigationObject; if (!uiSystem.navigationObject) { // use auto select object newNavigationObject = navigableObjects.find(o=>o.navigationAutoSelect); if (!newNavigationObject) { // try first or last object const newIndex = direction > 0 ? 0 : navigableObjects.length-1; newNavigationObject = navigableObjects[newIndex]; } } else { const currentIndex = navigableObjects.indexOf(uiSystem.navigationObject); const newIndex = mod(currentIndex + direction, navigableObjects.length); newNavigationObject = navigableObjects[newIndex]; } if (uiSystem.navigationObject !== newNavigationObject) { uiSystem.navigationMode = true; uiSystem.hoverObject = undefined; uiSystem.navigationObject = newNavigationObject; uiSystem.navigationTimer.set(uiSystem.navigationDelay); newNavigationObject.soundPress && newNavigationObject.soundPress.play(); } } } // activate the navigation object when pressed, the press is used up as a mouse click is if (uiSystem.navigationObject) if (uiSystem.getNavigationWasPressed()) { uiSystem.navigationObject.navigatePressed(); if (isUsingGamepad) inputClearKey(0, gamepadPrimary+1, false, true, false); else { inputClearKey('Space', 0, false, true, false); inputClearKey('Enter', 0, false, true, false); } } } // update in reverse order so topmost objects get priority, from the list as it was // since a callback may call destroyObjects, which swaps in a shorter one // a confirm dialog is modal, so it goes first however late it was made, and UI made after it opened // can not take the mouse through it const uiObjects = uiSystem.uiObjects, dialog = uiSystem.confirmDialog; dialog && updateObject(dialog); for (let i = uiObjects.length; i--;) { const o = uiObjects[i]; o.parent || o === dialog || updateObject(o); } // a click off the field that no UI object took is used up, the game does not see the click that ended it clickedOff && mouseWasPressed(0) && inputClearKey(0, 0); // remove destroyed objects uiSystem.uiObjects = uiSystem.uiObjects.filter(o=>!o.destroyed); function updateObject(o) { // once a pass, though a callback may move it under a parent not yet reached, or detach it to the // top level, where the loop reaches it again if (o.destroyed || !o.visible || o.uiUpdatePass === updatePass) return; o.uiUpdatePass = updatePass; // update in reverse order to detect mouse enter/leave, from a copy since a child may destroy // siblings mid-update (e.g. dialog close) and the ones after it would shift under the loop updateTransforms(o); const children = o.children.slice(); for (let i=children.length; i--;) updateObject(children[i]); if (!o.destroyed) o.update(); } } function uiRender() { const context = uiSystem.uiContext; context.save(); if (uiSystem.nativeHeight) { // convert to native height const s = mainCanvasSize.y / uiSystem.nativeHeight; context.translate(-s*mainCanvasSize.x/2,0); context.scale(s,s); context.translate(mainCanvasSize.x/2/s,0); } function renderObject(o) { // a destroyed object stays in the list until the next update, but is gone now if (o.destroyed || !o.visible) return; // render object and children updateTransforms(o); o.render(); for (const c of o.children) renderObject(c); } // a confirm dialog is drawn over everything, UI made after it opened too const dialog = uiSystem.confirmDialog; uiSystem.uiObjects.forEach(o=> o.parent || o === dialog || renderObject(o)); dialog && renderObject(dialog); if (uiDebug > 0) { // debug render all objects function renderDebug(o, visible=true) { visible &&= !!o.visible; if (o.destroyed || !visible && uiDebug < 2) return; // only mode 2 shows the invisible ones updateTransforms(o); o.renderDebug(visible); for (const c of o.children) renderDebug(c, visible); } uiSystem.uiObjects.forEach(o=> o.parent || o === dialog || renderDebug(o)); dialog && renderDebug(dialog); } context.restore(); } } /** Draw a rectangle to the UI context * @param {Vector2} pos * @param {Vector2} size * @param {Color} [color] * @param {number} [lineWidth] * @param {Color} [lineColor] * @param {number} [cornerRadius] * @param {Color} [gradientColor] * @param {Color} [shadowColor] * @param {number} [shadowBlur] * @param {Vector2} [shadowOffset] */ drawRect(pos, size, color=WHITE, lineWidth=0, lineColor=BLACK, cornerRadius=0, gradientColor, shadowColor=BLACK, shadowBlur=0, shadowOffset=vec2()) { ASSERT(isVector2(pos), 'pos must be a vec2'); ASSERT(isVector2(size), 'size must be a vec2'); ASSERT(isColor(color), 'color must be a color'); ASSERT(isNumber(lineWidth), 'lineWidth must be a number'); ASSERT(isColor(lineColor), 'lineColor must be a color'); ASSERT(isNumber(cornerRadius), 'cornerRadius must be a number'); const context = uiSystem.uiContext; if (gradientColor) { const g = context.createLinearGradient( pos.x, pos.y-size.y/2, pos.x, pos.y+size.y/2); const c = color.toString(); g.addColorStop(0, c); g.addColorStop(.5, gradientColor.toString()); g.addColorStop(1, c); context.fillStyle = g; } else context.fillStyle = color.toString(); uiSetShadow(context, shadowColor, shadowBlur, shadowOffset); context.beginPath(); if (cornerRadius && context['roundRect']) context['roundRect'](pos.x-size.x/2, pos.y-size.y/2, size.x, size.y, cornerRadius); else context.rect(pos.x-size.x/2, pos.y-size.y/2, size.x, size.y); context.fill(); context.shadowColor = '#0000'; if (lineWidth && lineColor.a > 0) { context.strokeStyle = lineColor.toString(); context.lineWidth = lineWidth; context.stroke(); } } /** Draw a TileSlice to the UI context, in place of a rectangle * @param {TileSlice} slice * @param {Vector2} pos * @param {Vector2} size * @param {Color} [color] */ drawSlice(slice, pos, size, color=WHITE) { ASSERT(typeof TileSlice === 'function' && slice instanceof TileSlice, 'slice must be a TileSlice, from the drawUtilities plugin'); ASSERT(isVector2(pos), 'pos must be a vec2'); ASSERT(isVector2(size), 'size must be a vec2'); ASSERT(isColor(color), 'color must be a color'); if (color.a > 0) // a clear one, like a UIText's, costs a tint of each piece for nothing slice.drawScreen(pos, size, color, undefined, 0, false, uiSystem.uiContext); } /** Draw a line to the UI context * @param {Vector2} posA * @param {Vector2} posB * @param {number} [lineWidth=uiSystem.defaultLineWidth] * @param {Color} [lineColor=uiSystem.defaultLineColor] */ drawLine(posA, posB, lineWidth=uiSystem.defaultLineWidth, lineColor=uiSystem.defaultLineColor) { ASSERT(isVector2(posA), 'posA must be a vec2'); ASSERT(isVector2(posB), 'posB must be a vec2'); ASSERT(isNumber(lineWidth), 'lineWidth must be a number'); ASSERT(isColor(lineColor), 'lineColor must be a color'); const context = uiSystem.uiContext; context.strokeStyle = lineColor.toString(); context.lineWidth = lineWidth; context.beginPath(); context.lineTo(posA.x, posA.y); context.lineTo(posB.x, posB.y); context.stroke(); } /** Draw a tile to the UI context * @param {Vector2} pos * @param {Vector2} size * @param {TileInfo} tileInfo * @param {Color} [color=uiSystem.defaultColor] * @param {number} [angle] * @param {boolean} [mirror] * @param {Color} [shadowColor] * @param {number} [shadowBlur] * @param {Vector2} [shadowOffset] */ drawTile(pos, size, tileInfo, color=uiSystem.defaultColor, angle=0, mirror=false, shadowColor=BLACK, shadowBlur=0, shadowOffset=vec2()) { const context = uiSystem.uiContext; uiSetShadow(context, shadowColor, shadowBlur, shadowOffset); drawTile(pos, size, tileInfo, color, angle, mirror, CLEAR_BLACK, false, true, context); context.shadowColor = '#0000'; } /** Draw text to the UI context * @param {string} text * @param {Vector2} pos * @param {Vector2} size * @param {Color} [color=uiSystem.defaultColor] * @param {number} [lineWidth=uiSystem.defaultLineWidth] * @param {Color} [lineColor=uiSystem.defaultLineColor] * @param {'left'|'center'|'right'} [align] * @param {string} [font=uiSystem.defaultFont] * @param {string} [fontStyle] * @param {boolean} [applyMaxWidth] * @param {Vector2} [textShadow] * @param {Color} [shadowColor] * @param {number} [shadowBlur] * @param {Vector2} [shadowOffset] */ drawText(text, pos, size, color=uiSystem.defaultColor, lineWidth=uiSystem.defaultLineWidth, lineColor=uiSystem.defaultLineColor, align='center', font=uiSystem.defaultFont, fontStyle='', applyMaxWidth=true, textShadow=undefined, shadowColor=BLACK, shadowBlur=0, shadowOffset=vec2()) { const context = uiSystem.uiContext; if (textShadow && shadowColor.a > 0) drawTextScreen(text, pos.add(textShadow), size.y, shadowColor, lineWidth, lineColor, align, font, fontStyle, applyMaxWidth ? size.x : undefined, 0, context); uiSetShadow(context, shadowColor, shadowBlur, shadowOffset); drawTextScreen(text, pos, size.y, color, lineWidth, lineColor, align, font, fontStyle, applyMaxWidth ? size.x : undefined, 0, context); context.shadowColor = '#0000'; } /** Setup drag and drop event handlers * Automatically prevents defaults and calls the given functions * @param {DragAndDropCallback} [onDrop] - when a file is dropped * @param {DragAndDropCallback} [onDragEnter] - when a file is dragged onto the window * @param {DragAndDropCallback} [onDragLeave] - when a file is dragged off the window * @param {DragAndDropCallback} [onDragOver] - continuously when dragging over */ setupDragAndDrop(onDrop, onDragEnter, onDragLeave, onDragOver) { // remove any prior listeners so repeated setup calls don't stack if (this._dragListeners) for (const [type, listener] of this._dragListeners) document.removeEventListener(type, listener); this._dragListeners = []; const setCallback = (callback, listenerType, when=()=> true)=> { const listener = (e)=> { e.preventDefault(); when() && callback && callback(e); }; document.addEventListener(listenerType, listener); this._dragListeners.push([listenerType, listener]); }; // every element the drag crosses sends its own enter and leave, and a move between two ends with a leave, // so they are counted, and only the first enter and the last leave are the window's let depth = 0; setCallback(onDrop, 'drop', ()=> { depth = 0; return true; }); setCallback(onDragEnter, 'dragenter', ()=> !depth++); setCallback(onDragLeave, 'dragleave', ()=> !!depth && !--depth); setCallback(onDragOver, 'dragover'); } /** Convert a screen space position to native UI position * @param {Vector2} pos * @return {Vector2} */ screenToNative(pos) { if (!uiSystem.nativeHeight) return pos; const s = mainCanvasSize.y / uiSystem.nativeHeight; const sInv = 1/s; const p = pos.copy(); p.x += s*mainCanvasSize.x/2; p.x *= sInv; p.y *= sInv; p.x -= sInv*mainCanvasSize.x/2; return p; } /** Object to send keyboard input to (typically a UITextInput), which keeps the keys from the game while set. * The keyboard listeners are only attached while this is set, * so games that never use text input pay no event-handling cost. * To end typing in a field, call its stopEditing(), which also fires its onChange and release sound * @type {UIObject|undefined} */ get keyInputObject() { return this._keyInputObject; } set keyInputObject(obj) { const had = !!this._keyInputObject; this._keyInputObject = obj; // listen on the window as the event comes down, before the engine's input on the document can see it if (!had && obj) { addEventListener('keydown', this._onKeyDown, true); addEventListener('keyup', this._onKeyDown, true); inputClearKeyboard(); // keys held when editing starts let go, or they would stay down // a press that starts the edit, as with activateOnPress, is let go now, the updates skip its release // while the edit goes on; a click on release already had its release const held = this.activeObject; if (held && mouseIsDown(0)) { this.activeObject = undefined; held.onRelease(); held.soundRelease && held.soundRelease.play(); } } else if (had && !obj) { removeEventListener('keydown', this._onKeyDown, true); removeEventListener('keyup', this._onKeyDown, true); } } /** Destroy and remove all objects */ destroyObjects() { for (const o of this.uiObjects) o.parent || o.destroy(); this.uiObjects = this.uiObjects.filter(o=>!o.destroyed); this.activeObject = undefined; this.hoverObject = undefined; this.lastHoverObject = undefined; this.keyInputObject = undefined; this.confirmDialog = undefined; } /** Get all navigable UI objects sorted by navigationIndex * @return {Array} */ getNavigableObjects() { function getNavigableRecursive(o) { if (o.destroyed || !o.visible || o.disabled) return; // skip children if parent is destroyed, invisible or disabled if (o.isInteractive() && o.navigationIndex !== undefined) objects.push(o); for (const child of o.children) getNavigableRecursive(child); } // get all the valid navigable objects recursively // while the confirm dialog is open only its buttons can be navigated const objects = []; if (uiSystem.confirmDialog) getNavigableRecursive(uiSystem.confirmDialog); else for (const o of uiSystem.uiObjects) o.parent || getNavigableRecursive(o); // sort by navigationIndex (lower numbers first), ties keep creation and child order objects.sort((a, b)=> a.navigationIndex - b.navigationIndex); return objects; } /** Check if the mouse is over a visible UI object that can be hovered, or anywhere while the * confirm dialog is open, so a game can leave world clicks on the UI alone, on touch too. * The UI uses up a click before objects update and gameUpdatePost, so read world clicks there, * or check this in gameUpdate, which runs first. Positions are from the last UI update. * @return {boolean} */ isMouseOverUI() { function isOverRecursive(o) { if (o.destroyed || !o.visible) return false; // a hidden parent hides its children if (o.canBeHover && o.isMouseOverlapping()) return true; return o.children.some(isOverRecursive); } // a click while a text field is being edited ends the edit, the UI takes it if (uiSystem.keyInputObject) return true; // while the confirm dialog is open it blocks everything else if (uiSystem.confirmDialog) return isOverRecursive(uiSystem.confirmDialog); return uiSystem.uiObjects.some(o=> !o.parent && isOverRecursive(o)); } /** Get navigation direction from gamepad or keyboard * @return {number} */ getNavigationDirection() { const vertical = uiSystem.navigationDirection === 1; const both = uiSystem.navigationDirection === 2; if (isUsingGamepad) { const stick = gamepadStick(0, gamepadPrimary); const dpad = gamepadDpad(gamepadPrimary); if (both) return -(stick.y || dpad.y) || (stick.x || dpad.x); return vertical ? -(stick.y || dpad.y) : (stick.x || dpad.x); } const up = 'ArrowUp', down = 'ArrowDown', left = 'ArrowLeft', right = 'ArrowRight'; if (both) { return keyIsDown(up) || keyIsDown(left) ? -1 : keyIsDown(down) || keyIsDown(right) ? 1 : 0; } const back = vertical ? up : left; const forward = vertical ? down : right; return keyIsDown(back) ? -1 : keyIsDown(forward) ? 1 : 0; } /** Get other axis navigation direction from gamepad or keyboard * @return {number} */ getNavigationOtherDirection() { if (uiSystem.navigationDirection === 2) return 0; // other direction disabled const vertical = uiSystem.navigationDirection === 1; if (isUsingGamepad) { const stick = gamepadStick(0, gamepadPrimary); const dpad = gamepadDpad(gamepadPrimary); return !vertical ? (stick.y || dpad.y) : (stick.x || dpad.x); } // up is positive, as it is on the gamepad if (!vertical) return keyIsDown('ArrowUp') ? 1 : keyIsDown('ArrowDown') ? -1 : 0; return keyIsDown('ArrowLeft') ? -1 : keyIsDown('ArrowRight') ? 1 : 0; } /** Get if navigation button was pressed from gamepad or keyboard * @return {boolean} */ getNavigationWasPressed() { return isUsingGamepad ? gamepadWasPressed(0, gamepadPrimary) : keyWasPressed('Space') || keyWasPressed('Enter'); } /** Show a confirmation dialog with Yes/No buttons * Centers the dialog on the screen with darkened background * @param {string} [text] - The message to display * @param {Function} [yesCallback] - Called when Yes is clicked * @param {Function} [noCallback] - Called when No is clicked, or the exit key or gamepad B closes it * @param {Vector2} [size] - Size of the confirmation dialog, the title and buttons are placed by it * @param {string} [exitKey] - Key that closes the menu as No, gamepad B (button 1) does too * @return {UIObject} The confirmation menu object */ showConfirmDialog(text='Are you sure?', yesCallback, noCallback, size=vec2(500,250), exitKey='Escape') { ASSERT(!uiSystem.confirmDialog, 'a confirm dialog is already open, check uiSystem.confirmDialog'); if (uiSystem.confirmDialog) return uiSystem.confirmDialog; // in a release build, the one open, as a second would break navigation const savedNavigationDirection = uiSystem.navigationDirection; const savedNavigationObject = uiSystem.navigationObject; // allow both axes for navigation uiSystem.navigationDirection = 2; // confirm menu const confirmMenu = new UIObject(vec2(), size); uiSystem.confirmDialog = confirmMenu; confirmMenu.onRender = ()=> { const backgroundColor = hsl(0,0,0,.7); uiSystem.drawRect(vec2(), vec2(1e9), backgroundColor); } let opened = false; confirmMenu.onUpdate = ()=> { // not the press that opened it, a game may open it on the same back button, so its first update is skipped // (not by frame number, which does not advance while paused, where a confirm is usually shown) if (opened && (keyWasPressed(exitKey) || gamepadWasPressed(1))) { closeMenu(); // the exit key or gamepad B answers no noCallback && noCallback(); } opened = true; } confirmMenu.isMouseOverlapping = ()=> true; // always hover // the title and buttons are laid out for the default 500 by 250 and scaled to the dialog's size, so a // small or narrow one still fits them const scaleX = size.x/500, scaleY = size.y/250; const gap = 50, y = size.y/5; const textTitle = new UIText(vec2(0,-y), vec2(size.x-gap, 70*scaleY), text); confirmMenu.addChild(textTitle); // yes button const buttonSize = vec2(120*scaleX, 70*scaleY); const buttonYes = new UIButton(vec2(-80*scaleX,y), buttonSize, 'Yes'); buttonYes.textHeight = 40*scaleY; buttonYes.navigationIndex = 1; buttonYes.hoverColor = hsl(0,1,.5); buttonYes.onClick = ()=> { closeMenu(); yesCallback && yesCallback(); }; confirmMenu.addChild(buttonYes); // no button const buttonNo = new UIButton(vec2(80*scaleX,y), buttonSize, 'No'); buttonNo.textHeight = 40*scaleY; buttonNo.navigationIndex = 2; buttonNo.navigationAutoSelect = true; buttonNo.onClick = ()=> { closeMenu(); noCallback && noCallback(); }; confirmMenu.addChild(buttonNo); // return to normal navigation however the menu goes, by its buttons, // destroyObjects or a call to its destroy, with the item that opened it selected again const destroy = confirmMenu.destroy.bind(confirmMenu); confirmMenu.destroy = ()=> { const closing = uiSystem.confirmDialog === confirmMenu; if (closing) { uiSystem.confirmDialog = undefined; uiSystem.navigationDirection = savedNavigationDirection; } destroy(); // its buttons go, and the one selected clears the selection if (closing && savedNavigationObject && !savedNavigationObject.destroyed) uiSystem.navigationObject = savedNavigationObject; }; // close menu and clear the input that closed it function closeMenu() { ASSERT(uiSystem.confirmDialog === confirmMenu, 'the confirm dialog closing is not the one open'); confirmMenu.destroy(); inputClear(); } return confirmMenu; } } // whether a UI object can still be used: it and every parent visible, enabled and not destroyed function uiObjectIsUsable(o) { for (; o; o = o.parent) if (o.destroyed || !o.visible || o.disabled) return false; return true; } // set the canvas shadow for the next draw, when it has a color and a blur or an offset; the caller clears it function uiSetShadow(context, shadowColor, shadowBlur, shadowOffset) { if (shadowColor.a > 0 && (shadowBlur || shadowOffset.x || shadowOffset.y)) { context.shadowColor = shadowColor.toString(); context.shadowBlur = shadowBlur; context.shadowOffsetX = shadowOffset.x; context.shadowOffsetY = shadowOffset.y; } } // whether an open confirm dialog shuts a UI object out, it is modal: all but the dialog and what is in it function uiObjectIsBehindDialog(o) { const dialog = uiSystem.confirmDialog; if (!dialog) return false; for (; o; o = o.parent) if (o === dialog) return false; return true; } // whether a UI object is disabled, itself or through a parent function uiObjectIsDisabled(o) { for (; o; o = o.parent) if (o.disabled) return true; return false; } // whether a UI object is hidden, itself or through a parent function uiObjectIsHidden(o) { for (; o; o = o.parent) if (!o.visible) return true; return false; } /////////////////////////////////////////////////////////////////////////////// /** * UI Object - Base level object for all UI elements * @memberof UISystem */ class UIObject { /** Create a UIObject * @param {Vector2} [pos=vec2()] * @param {Vector2} [size=vec2()] */ constructor(pos=vec2(), size=vec2()) { ASSERT(uiSystem, 'create the UISystemPlugin before UI objects'); ASSERT(isVector2(pos), 'ui object pos must be a vec2'); ASSERT(isVector2(size), 'ui object size must be a vec2'); /** @property {Vector2} - Position you set: an offset from this object's * anchor point (the parent box, or the canvas for roots). This is the * input that controls placement — set this, not nativePos. */ this.localPos = pos.copy(); /** @property {Vector2} - Resolved position in native UI space, recomputed * every frame from localPos + anchor (and nativeHeight, if set). This is a * derived output used for drawing and hit-testing; assigning to it has no * effect since it is overwritten each frame. Set localPos instead. */ this.nativePos = pos.copy(); /** @property {Vector2} - Screen space size of the object */ this.size = size.copy(); /** @property {Color} - Color of the object */ this.color = uiSystem.defaultColor.copy(); /** @property {Color|undefined} - Color of the object when active, uses hoverColor if undefined * @type {Color|undefined} */ this.activeColor = undefined; /** @property {string|undefined} - Text for this ui object * @type {string|undefined} */ this.text = undefined; /** @property {Color} - Color when disabled */ this.disabledColor = uiSystem.defaultDisabledColor.copy(); /** @property {boolean} - Is this object disabled? */ this.disabled = false; /** @property {Color} - Color for text */ this.textColor = uiSystem.defaultTextColor.copy(); /** @property {Color} - Color used when hovering over the object */ this.hoverColor = uiSystem.defaultHoverColor.copy(); /** @property {Color} - Color for line drawing */ this.lineColor = uiSystem.defaultLineColor.copy(); /** @property {Color|undefined} - Uses a gradient fill combined with color * @type {Color|undefined} */ this.gradientColor = uiSystem.defaultGradientColor ? uiSystem.defaultGradientColor.copy() : undefined; /** @property {number} - Width for line drawing */ this.lineWidth = uiSystem.defaultLineWidth; /** @property {number} - Corner radius for rounded rects */ this.cornerRadius = uiSystem.defaultCornerRadius; /** @property {TileSlice|undefined} - Style to draw with in place of the rectangle, tinted by the color for its * state; its art has the frame, so the outline, corner radius and shadow are not drawn * @type {TileSlice|undefined} */ this.slice = uiSystem.defaultSlice; /** @property {string} - Font for this object */ this.font = uiSystem.defaultFont; /** @property {string|undefined} - Font style for this object or undefined * @type {string|undefined} */ this.fontStyle = undefined; /** @property {number|undefined} - Override for text width * @type {number|undefined} */ this.textWidth = undefined; /** @property {number|undefined} - Override for text height * @type {number|undefined} */ this.textHeight = undefined; /** @property {number} - Scale text to fit in the object */ this.textFitScale = uiSystem.defaultTextFitScale; /** @property {Vector2|undefined} - How much to offset the text shadow or undefined. * UIText draws it in its shadowColor, which is clear by default, so set that too; * the blurred shadow then shows as well unless shadowBlur and shadowOffset are zero. * The other widgets draw it in black * @type {Vector2|undefined} */ this.textShadow = undefined; /** @property {Color} - Color for text line drawing */ this.textLineColor = uiSystem.defaultLineColor.copy(); /** @property {number} - Width for text line drawing */ this.textLineWidth = 0; /** @property {boolean} - Should this object be drawn */ this.visible = true; this.uiUpdatePass = 0; // the UI update that last reached it, so a pass updates it once /** @property {Array} - A list of this object's children * @type {Array} */ this.children = []; /** @property {UIObject|undefined} - This object's parent, position is in parent space * @type {UIObject|undefined} */ this.parent = undefined; /** @property {number} - Added size to make small buttons easier to touch on mobile devices */ this.extraTouchSize = 0; /** @property {Sound|undefined} - Sound when interactive element is pressed * @type {Sound|undefined} */ this.soundPress = uiSystem.defaultSoundPress; /** @property {Sound|undefined} - Sound when interactive element is released * @type {Sound|undefined} */ this.soundRelease = uiSystem.defaultSoundRelease; /** @property {Sound|undefined} - Sound when interactive element is clicked * @type {Sound|undefined} */ this.soundClick = uiSystem.defaultSoundClick; /** @property {boolean} - Is this element interactive */ this.interactive = false; /** @property {boolean} - Activate when dragged over with mouse held down */ this.dragActivate = false; /** @property {boolean} - True if this can be a hover object */ this.canBeHover = true; /** @property {Color} - Color for shadow */ this.shadowColor = uiSystem.defaultShadowColor?.copy(); /** @property {number} - Size of shadow blur */ this.shadowBlur = uiSystem.defaultShadowBlur; /** @property {Vector2} - Offset of shadow blur */ this.shadowOffset = uiSystem.defaultShadowOffset?.copy(); /** @property {number|undefined} - Optional navigation order index, lower values are selected first * @type {number|undefined} */ this.navigationIndex = undefined; /** @property {boolean} - Should this be auto selected by navigation? Must also have valid navigation index. */ this.navigationAutoSelect = false; /** @property {Vector2} - Where on parent (or canvas if no parent) this object is anchored. * Components in [-1, 1]: (0,0)=center, (-1,-1)=top-left, (1,1)=bottom-right. * Also acts as self-pivot — e.g. (1,-1) puts your top-right corner at the anchor point. */ this.anchor = vec2(); /** @property {'left'|'center'|'right'} - Horizontal text alignment: left, center, or right * @type {'left'|'center'|'right'} */ this.align = 'center'; /** @property {boolean} - Has this object been destroyed? */ this.destroyed = false; uiSystem.uiObjects.push(this); } /** Add a child UIObject to this object, returns child for chaining * @param {UIObject} child * @return {UIObject} The child object added */ addChild(child) { ASSERT(!child.parent && !this.children.includes(child), 'child already has a parent, removeChild it first'); this.children.push(child); child.parent = this; return child; } /** Remove a child UIObject from this object * @param {UIObject} child */ removeChild(child) { ASSERT(child.parent === this && this.children.includes(child), 'removeChild: that object is not a child of this one', child); this.children.splice(this.children.indexOf(child), 1); child.parent = undefined; } /** Destroy this object, destroy its children, detach its parent, and mark it for removal */ destroy() { if (this.destroyed) return; // clear ui-system references that point at this object so events // don't keep firing against a destroyed target (especially the // keydown listener attached for keyInputObject) if (uiSystem.activeObject === this) uiSystem.activeObject = undefined; if (uiSystem.hoverObject === this) uiSystem.hoverObject = undefined; if (uiSystem.lastHoverObject === this) uiSystem.lastHoverObject = undefined; if (uiSystem.navigationObject === this) uiSystem.navigationObject = undefined; if (uiSystem.keyInputObject === this) uiSystem.keyInputObject = undefined; // disconnect from parent and destroy children this.destroyed = true; this.parent?.removeChild(this); for (const child of this.children) { child.parent = undefined; child.destroy(); } // clear references so destroyed children can be GC'd this.children.length = 0; } /** Check if the mouse is overlapping this ui object * @return {boolean} - True if overlapping */ isMouseOverlapping() { if (!mouseInWindow) return false; const size = !isTouchDevice ? this.size : this.size.add(vec2(this.extraTouchSize || 0)); const pos = uiSystem.screenToNative(mousePosScreen); return isOverlapping(this.nativePos, size, pos); } /** Update the object, called automatically by plugin once each frame * @return {void} */ update() { // call the custom update callback, which may destroy this object this.onUpdate(); if (this.destroyed) return; // unset active if disabled, itself or through a parent, and let go of it const disabled = uiObjectIsDisabled(this); if (disabled) { if (this === uiSystem.activeObject) { uiSystem.activeObject = undefined; if (this !== uiSystem.keyInputObject) // a field being edited was released when its press ended { this.onRelease(); if (this.destroyed) return; } } // a field being edited is ended by the next UI update, through stopEditing, so onChange keeps its text } if (uiSystem.keyInputObject) return; const wasHover = uiSystem.lastHoverObject === this; let isActive = this.isActiveObject(); const mouseDown = mouseIsDown(0); // a press and release in one frame is a press too, as it is for a button that is not drag activated const mousePress = mouseWasPressed(0) || this.dragActivate && mouseDown; // a new press while this is still active from the one before means that one was let go of in between, by a // second quick tap or a release missed in a hitch: it is clicked and released first, so neither is lost if (isActive && mouseWasPressed(0)) { if (!uiSystem.activateOnPress && this.interactive && !disabled) this.click(); if (this.destroyed) return; uiSystem.activeObject = undefined; isActive = false; this.onRelease(); this.soundRelease && this.soundRelease.play(); if (this.destroyed) return; } if (this.canBeHover) if (!uiSystem.navigationMode) // no mouse hover in navigation mode if (mousePress || isActive || (!mouseDown && !isTouchDevice)) if (!uiSystem.hoverObject && this.isMouseOverlapping()) uiSystem.hoverObject = this; if (this.isHoverObject()) { if (!disabled) { if (mousePress) { if (this.interactive) { // drag activate sees the held mouse as a press every frame, only a new one counts const newPress = !this.dragActivate || !wasHover || mouseWasPressed(0); if (newPress) { this.onPress(); if (this.destroyed) // the press took it away, and the press is used up return void inputClearKey(0, 0, false, true, false); this.soundPress && this.soundPress.play(); if (uiSystem.activeObject && !isActive) uiSystem.activeObject.onRelease(); } if (uiSystem.keyInputObject && uiSystem.keyInputObject !== this) { // its onPress started an edit elsewhere, the updates skip its release while it goes on this.onRelease(); this.soundRelease && this.soundRelease.play(); } else { uiSystem.activeObject = this; uiActiveReleased = !mouseDown; } if (newPress && uiSystem.activateOnPress) this.click(!this.soundPress); if (this.destroyed) return void inputClearKey(0, 0, false, true, false); } } // the object that was the active one going into this update: one pressed and let go inside a // frame is clicked on the next, with its release, not on both if (!uiSystem.activateOnPress) if (!mouseDown && isActive && this.isActiveObject() && this.interactive) if (mouseWasReleased(0) || uiActiveReleased) this.click(); if (this.destroyed) return; } // clear mouse was pressed state even when disabled mousePress && inputClearKey(0, 0, false, true, false); } if (isActive) if (!mouseDown || (this.dragActivate && !this.isHoverObject())) { this.onRelease(); this.soundRelease && this.soundRelease.play(); uiSystem.activeObject = undefined; } // call enter/leave events if (this.isHoverObject() !== wasHover) this.isHoverObject() ? this.onEnter() : this.onLeave(); } /** Render the object, called automatically by plugin once each frame */ render() { // call the custom render callback this.onRender(); if (!this.size.x || !this.size.y) return; const isNavigationObject = this.isNavigationObject(); const disabled = this.interactive ? uiObjectIsDisabled(this) : this.disabled; const lineColor = isNavigationObject ? this.color : this.interactive && this.isActiveObject() && !disabled ? this.color : this.lineColor; const color = isNavigationObject ? this.hoverColor : disabled ? this.disabledColor : this.interactive ? this.isActiveObject() ? this.activeColor || this.hoverColor : this.isHoverObject() ? this.hoverColor : this.color : this.color; const lineWidth = this.lineWidth * (isNavigationObject ? 1.5 : 1); if (this.slice) uiSystem.drawSlice(this.slice, this.nativePos, this.size, color); else uiSystem.drawRect(this.nativePos, this.size, color, lineWidth, lineColor, this.cornerRadius, this.gradientColor, this.shadowColor || CLEAR_BLACK, this.shadowBlur, this.shadowOffset); } /** Get the size for text with overrides and scale * @return {Vector2} */ getTextSize() { // text fitted to the size shares its height between its lines, a set textHeight is the height of each line const lines = this.textHeight ? 1 : textLineCount(this.text + ''); return vec2( this.textWidth || this.textFitScale * this.size.x, this.textHeight || this.textFitScale * this.size.y / lines); } /** Get where the text is drawn, the center, or the edge of the text area its align puts it against * @param {Vector2} textSize - From getTextSize * @return {Vector2} */ getTextPos(textSize) { const side = this.align === 'left' ? -1 : this.align === 'right' ? 1 : 0; return this.nativePos.add(vec2(side * textSize.x / 2, 0)); } /** Called when the navigation button is pressed on this object */ navigatePressed() { this.click(); } /** Is the mouse hovering over this element * @return {boolean} */ isHoverObject() { return uiSystem.hoverObject === this; } /** Is the mouse held onto this element * @return {boolean} */ isActiveObject() { return uiSystem.activeObject === this; } /** Is the gamepad or keyboard navigation object * @return {boolean} */ isNavigationObject() { return uiSystem.navigationObject === this; } /** Is this object in keyboard input mode * @return {boolean} */ isKeyInputObject() { return uiSystem.keyInputObject === this; } /** Can it be interacted with, it and every parent visible and enabled * @return {boolean} */ isInteractive() { return this.interactive && uiObjectIsUsable(this); } /** Returns string containing info about this object for debugging * @return {string} */ toString() { let text = 'type = ' + this.constructor.name; if (this.text) text += '\ntext = ' + this.text; if (this.nativePos.x || this.nativePos.y) text += '\nnativePos = ' + this.nativePos; if (this.localPos.x || this.localPos.y) text += '\nlocalPos = ' + this.localPos; if (this.size.x || this.size.y) text += '\nsize = ' + this.size; if (this.color) text += '\ncolor = ' + this.color; return text; } /** Called if uiDebug is enabled * @param {boolean} [visible] */ renderDebug(visible=true) { // apply color based on state const color = !visible ? GREEN : this.isHoverObject() ? YELLOW : uiObjectIsDisabled(this) ? PURPLE : this.interactive ? RED : BLUE; uiSystem.drawRect(this.nativePos, this.size, CLEAR_BLACK, 4, color); } /** Internal function called when object is clicked * @param {boolean} [playSound] */ click(playSound=true) { this.onClick(); if (playSound && this.soundClick) this.soundClick.play(); } /** Called each frame before object updates */ onUpdate() {} /** Called each frame before object renders */ onRender() {} /** Called when the mouse enters the object */ onEnter() {} /** Called when the mouse leaves the object */ onLeave() {} /** Called when the mouse is pressed while over the object */ onPress() {} /** Called when a press on it ends, wherever the mouse is let go, or when it is disabled or hidden while held, or * an edit takes the press; onClick is the one for a press let go over it */ onRelease() {} /** Called when user clicks on this object */ onClick() {} /** Called when the state of this object changes */ onChange() {} /** Called with each key while this is the keyInputObject * @param {KeyboardEvent} e */ onKeyDown(e) {} }; /////////////////////////////////////////////////////////////////////////////// /** * UIText - A UI object that displays text * @extends UIObject * @memberof UISystem */ class UIText extends UIObject { /** Create a UIText object * @param {Vector2} [pos] * @param {Vector2} [size] * @param {string} [text] * @param {'left'|'center'|'right'} [align] * @param {string} [font=uiSystem.defaultFont] */ constructor(pos, size, text='', align='center', font=uiSystem.defaultFont) { super(pos, size); ASSERT(isStringLike(text), 'ui text must be a string'); ASSERT(['left','center','right'].includes(align), 'ui text align must be left, center, or right'); ASSERT(isStringLike(font), 'ui text font must be a string'); // set properties this.text = text; this.align = align; this.font = font; // text can not be a hover object by default this.canBeHover = false; // no background by default this.color = CLEAR_BLACK.copy(); this.shadowColor = CLEAR_BLACK.copy(); /** @type {Color|undefined} */ this.gradientColor = undefined; this.lineWidth = 0; // use max fit scale by default this.textFitScale = 1; } render() { super.render(); // render the text const textSize = this.getTextSize(); uiSystem.drawText(this.text, this.getTextPos(textSize), textSize, this.textColor, this.textLineWidth, this.textLineColor, this.align, this.font, this.fontStyle, true, this.textShadow, this.shadowColor || CLEAR_BLACK, this.shadowBlur, this.shadowOffset); } } /////////////////////////////////////////////////////////////////////////////// /** * UITextInput - An editable text input field * - A simple text entry field that supports basic editing * - Suitable for short text input like names or numbers * - Reads a physical keyboard: no on-screen keyboard opens on touch devices, and IME composition and paste do not type, * use an HTML input element for those * @extends UIObject * @memberof UISystem */ class UITextInput extends UIObject { /** Create a UITextInput object * @param {Vector2} [pos] * @param {Vector2} [size] * @param {string} [text] */ constructor(pos, size, text='') { super(pos, size); ASSERT(isStringLike(text), 'ui text must be a string'); /** @property {number} - Max length of input in characters as a reader counts them (0 = no limit) */ this.maxLength = 0; // set properties, as a string, which typing adds to this.text = text + ''; this.interactive = true; this.canBeHover = true; } /** Start editing the text, called when it is clicked * @param {boolean} [playSound] */ click(playSound=true) { // an edit in another field ends first, with its onChange, like a Tab to the next field const editing = uiSystem.keyInputObject; if (editing !== this && editing instanceof UITextInput) editing.stopEditing(); // start editing the text, the gamepad press that started it is used up so it does not stop it too uiSystem.keyInputObject = this; inputClearKey(0, gamepadPrimary+1, false, true, false); this.onClick(); playSound && this.soundClick && this.soundClick.play(); } /** Stop editing the text */ stopEditing() { if (!this.isKeyInputObject()) return; if (this.soundRelease) this.soundRelease.play(); uiSystem.activeObject = undefined; uiSystem.keyInputObject = undefined; this.onChange(); } /** Key down event handler if this object is being edited * @param {KeyboardEvent} e */ onKeyDown(e) { // named keys by key, so numpad Enter works as Enter const code = e.code, key = e.key || ''; // autofill sends a keydown with no key if (e.repeat && (key === 'Enter' || code === 'Space')) return; // a key held when editing began repeats, it should not type or stop editing this.text += ''; // a game may have set a number if (key === 'Backspace') this.text = textGraphemes(this.text).slice(0, -1).join(''); // a whole character, an emoji family too else if (key === 'Enter' || key === 'Escape') this.stopEditing(); else if (key.length === 1) // printable characters { // ctrl and cmd shortcuts do not type, but AltGr reports ctrl and alt and types characters like @ if ((e.ctrlKey || e.metaKey) && !e.getModifierState?.('AltGraph')) return; if (!this.maxLength || textGraphemes(this.text).length < this.maxLength) this.text += key; } } update() { super.update(); if (!this.isKeyInputObject()) return; // the gamepad's press stops editing and is used up, a click off the field is handled by the UI update if (gamepadWasPressed(0, gamepadPrimary)) { this.stopEditing(); inputClearKey(0, gamepadPrimary+1, false, true, false); } else if (mouseWasPressed(0)) inputClearKey(0, 0, false, true, false); // a click inside is used up like any click on the UI } render() { super.render(); // draw the text scaled to fit const textSize = this.getTextSize(); let text = this.text; if (this.isKeyInputObject()) // add a cursor to end of text text += timeReal%1 < .5 ? '█' : '░'; uiSystem.drawText(text, this.getTextPos(textSize), textSize, this.textColor, this.textLineWidth, this.textLineColor, this.align, this.font, this.fontStyle, true, this.textShadow); } } /////////////////////////////////////////////////////////////////////////////// /** * UITile - A UI object that displays a tile image * @extends UIObject * @memberof UISystem */ class UITile extends UIObject { /** Create a UITile object * @param {Vector2} pos * @param {Vector2} size * @param {TileInfo} tileInfo * @param {Color} [color=WHITE] * @param {number} [angle] * @param {boolean} [mirror] */ constructor(pos, size, tileInfo, color=WHITE, angle=0, mirror=false) { super(pos, size); ASSERT(tileInfo instanceof TileInfo, 'ui tile tileInfo must be a TileInfo'); ASSERT(isColor(color), 'ui tile color must be a color'); ASSERT(isNumber(angle), 'ui tile angle must be a number'); /** @property {TileInfo} - Tile image to use */ this.tileInfo = tileInfo; /** @property {number} - Angle to rotate in radians */ this.angle = angle; /** @property {boolean} - Should it be mirrored? */ this.mirror = mirror; // set properties this.color = color.copy(); // no shadow by default this.shadowColor = CLEAR_BLACK.copy(); } render() { // call the custom render callback this.onRender(); uiSystem.drawTile(this.nativePos, this.size, this.tileInfo, this.color, this.angle, this.mirror, this.shadowColor || CLEAR_BLACK, this.shadowBlur, this.shadowOffset); } } /////////////////////////////////////////////////////////////////////////////// /** * UIButton - A UI object that acts as a button * @extends UIObject * @memberof UISystem */ class UIButton extends UIObject { /** Create a UIButton object * @param {Vector2} [pos] * @param {Vector2} [size] * @param {string} [text] * @param {Color} [color=uiSystem.defaultButtonColor] */ constructor(pos, size, text='', color=uiSystem.defaultButtonColor) { super(pos, size); ASSERT(isStringLike(text), 'ui button must be a string'); ASSERT(isColor(color), 'ui button color must be a color'); /** @property {Vector2} - Text offset for the button */ this.textOffset = vec2(); // set properties this.text = text; this.color = color.copy(); this.interactive = true; } render() { super.render(); // draw the text scaled to fit const textSize = this.getTextSize(); uiSystem.drawText(this.text, this.getTextPos(textSize).add(this.textOffset), textSize, this.textColor, this.textLineWidth, this.textLineColor, this.align, this.font, this.fontStyle, true, this.textShadow); } } /////////////////////////////////////////////////////////////////////////////// /** * UICheckbox - A UI object that acts as a checkbox * @extends UIObject * @memberof UISystem */ class UICheckbox extends UIObject { /** Create a UICheckbox object * @param {Vector2} [pos] * @param {Vector2} [size] * @param {boolean} [checked] * @param {string} [text] * @param {Color} [color=uiSystem.defaultButtonColor] */ constructor(pos, size, checked=false, text='', color=uiSystem.defaultButtonColor) { super(pos, size); ASSERT(isStringLike(text), 'ui checkbox must be a string'); ASSERT(isColor(color), 'ui checkbox color must be a color'); /** @property {boolean} - Is the checkbox currently checked? */ this.checked = checked; // set properties this.text = text; this.color = color.copy(); this.interactive = true; } /** Toggle the checkbox, called when it is clicked * @param {boolean} [playSound] */ click(playSound=true) { this.checked = !this.checked; super.click(playSound); // the click callback and sound, as every UI object has this.onChange(); } render() { super.render(); if (this.checked) { const p = this.cornerRadius / min(this.size.x, this.size.y) * 2; const length = lerp(1, 2**.5/2, p) / 2; let s = this.size.scale(length); uiSystem.drawLine(this.nativePos.add(s.multiply(vec2(-1))), this.nativePos.add(s.multiply(vec2(1))), this.lineWidth, this.lineColor); uiSystem.drawLine(this.nativePos.add(s.multiply(vec2(-1,1))), this.nativePos.add(s.multiply(vec2(1,-1))), this.lineWidth, this.lineColor); } // draw the text next to the checkbox const textSize = this.getTextSize(); const pos = this.nativePos.add(vec2(this.size.x,0)); uiSystem.drawText(this.text, pos, textSize, this.textColor, this.textLineWidth, this.textLineColor, 'left', this.font, this.fontStyle, false, this.textShadow); } } /////////////////////////////////////////////////////////////////////////////// /** * UISlider - A UI object that acts as a slider or scrollbar * @extends UIObject * @memberof UISystem */ class UISlider extends UIObject { /** Create a UISlider object * @param {Vector2} [pos] * @param {Vector2} [size] * @param {number} [value] * @param {string} [text] * @param {Color} [color=uiSystem.defaultButtonColor] * @param {Color} [handleColor=WHITE] */ constructor(pos, size, value=.5, text='', color=uiSystem.defaultButtonColor, handleColor=WHITE) { super(pos, size); ASSERT(isNumber(value), 'ui slider value must be a number'); ASSERT(isStringLike(text), 'ui slider must be a string'); ASSERT(isColor(color), 'ui slider color must be a color'); ASSERT(isColor(handleColor), 'ui slider handleColor must be a color'); /** @property {number} - Current percentage value of this slider 0-1 */ this.value = value; /** @property {Color} - Color for the handle part of the slider */ this.handleColor = handleColor.copy(); /** @property {TileSlice|undefined} - Style to draw the handle, or the fill, with; undefined for the slider's own * slice, or a rectangle when it has none * @type {TileSlice|undefined} */ this.handleSlice = uiSystem.defaultHandleSlice; /** @property {boolean} - Should it fill up like a progress bar? */ this.fillMode = false; // set properties this.text = text; this.color = color.copy(); this.interactive = true; } update() { // held when the frame began, so a release in the same frame as the last move still reads it const wasActive = this.isActiveObject(); super.update(); if (!this.interactive) return; const oldValue = this.value; if (wasActive || this.isActiveObject()) { // handle horizontal or vertical slider const isHorizontal = this.size.x > this.size.y; const handleSize = isHorizontal ? this.size.y : this.size.x; const barSize = isHorizontal ? this.size.x : this.size.y; const centerPos = isHorizontal ? this.nativePos.x : this.nativePos.y; // check if value changed const handleWidth = barSize - handleSize; const p1 = centerPos - handleWidth/2; const p2 = centerPos + handleWidth/2; const p = uiSystem.screenToNative(mousePosScreen); this.value = isHorizontal ? percent(p.x, p1, p2) : percent(p.y, p2, p1); } else if (this.isNavigationObject()) { // gamepad/keyboard navigation adjustment const direction = uiSystem.getNavigationOtherDirection(); if (!uiSystem.navigationTimer.active()) this.value = clamp(this.value + direction*.01); } this.value === oldValue || this.onChange(); } /** Draw the handle, or the fill of a fill mode slider, with the handle slice, the slider's own, or a rectangle * @param {Vector2} pos * @param {Vector2} size * @param {Color} color */ drawHandle(pos, size, color) { const slice = this.handleSlice || this.slice; if (slice) uiSystem.drawSlice(slice, pos, size, color); else uiSystem.drawRect(pos, size, color, this.lineWidth, this.lineColor, this.cornerRadius, this.gradientColor); } render() { super.render(); // handle horizontal or vertical slider const isHorizontal = this.size.x > this.size.y; const barWidth = isHorizontal ? this.size.x : this.size.y; const handleWidth = isHorizontal ? this.size.y : this.size.x; const color = uiObjectIsDisabled(this) ? this.disabledColor : this.handleColor; if (this.fillMode) { // draw progress bar const minWidth = min(handleWidth, this.cornerRadius * 2); const progressWidth = lerp(minWidth, barWidth, this.value); const p = (progressWidth - barWidth) * (isHorizontal ? .5 : -.5); const pos = this.nativePos.add(isHorizontal ? vec2(p, 0) : vec2(0, p)); const drawSize = isHorizontal ? vec2(progressWidth, this.size.y) : vec2(this.size.x, progressWidth); this.drawHandle(pos, drawSize, color); } else { // draw the slider handle const value = clamp(isHorizontal ? this.value : 1 - this.value); const p = (barWidth - handleWidth) * (value - .5); const pos = this.nativePos.add(isHorizontal ? vec2(p, 0) : vec2(0, p)); const drawSize = vec2(handleWidth); this.drawHandle(pos, drawSize, color); } // draw the text scaled to fit on the slider const textSize = this.getTextSize(); uiSystem.drawText(this.text, this.getTextPos(textSize), textSize, this.textColor, this.textLineWidth, this.textLineColor, this.align, this.font, this.fontStyle, true, this.textShadow); } navigatePressed() { // toggle value between 0 and 1 this.value = this.value ? 0 : 1; this.onChange(); this.onRelease(); super.navigatePressed(); } } /////////////////////////////////////////////////////////////////////////////// /** * UIVideo - A UI object that plays video * @extends UIObject * @example * // Create a video player UI object * const video = new UIVideo(vec2(400, 300), vec2(320, 240), 'video.mp4', true); * video.play(); * @memberof UISystem */ class UIVideo extends UIObject { /** Create a video player UI object * @param {Vector2} pos * @param {Vector2} size * @param {string} src - Video file path or URL * @param {boolean} [autoplay] - Start playing immediately? * @param {boolean} [loop] - Loop the video? * @param {number} [volume] - Volume percent scaled by global volume (0-1) */ constructor(pos, size, src, autoplay=false, loop=false, volume=1) { super(pos, size || vec2()); ASSERT(isStringLike(src), 'video src must be a string'); ASSERT(isNumber(volume), 'video volume must be a number'); this.color = BLACK.copy(); // default to black background this.cornerRadius = 0; // default to no corner radius /** @property {number} - The video volume */ this.volume = volume; // create video element /** @property {HTMLVideoElement} - The video player */ this.video = document.createElement('video'); this.video.loop = loop; this.video.volume = clamp(volume * soundVolume); this.video.muted = !soundEnable; /** @private */ this._soundEnabled = soundEnable; // the sound setting the mute last followed this.video.style.display = 'none'; this.video.playsInline = true; // an iPhone would play it fullscreen and block its muted autoplay this.video.src = src; document.body.appendChild(this.video); autoplay && this.play(); } /** Play or resume the video * @return {Promise} Resolves true once playback starts, false if the browser refused it */ async play() { // try to play the video, catch any errors (autoplay may be blocked) try { await this.video.play(); return true; } catch(e) { return false; } } /** Pause the video */ pause() { this.video.pause(); } /** Stop and reset the video */ stop() { this.video.pause(); this.video.currentTime = 0; } /** Check if video is currently loading * @return {boolean} */ isLoading() { return this.video.readyState < this.video.HAVE_CURRENT_DATA; } /** Check if video is currently paused * @return {boolean} */ isPaused() { return this.video.paused; } /** Check if video is currently playing * @return {boolean} */ isPlaying() { return !this.isPaused() && !this.hasEnded() && !this.isLoading(); } /** Check if video has ended playing * @return {boolean} */ hasEnded() { return this.video.ended; } /** Set volume (0-1) * @param {number} volume - Volume level (0-1) */ setVolume(volume) { this.volume = volume; this.video.volume = clamp(volume * soundVolume); } /** Set playback speed * @param {number} rate - Playback rate multiplier */ setPlaybackRate(rate) { this.video.playbackRate = rate; } /** Get current time in seconds * @return {number} Current playback time */ getCurrentTime() { return this.video.currentTime || 0; } /** Get duration in seconds * @return {number} Total video duration */ getDuration() { return this.video.duration || 0; } /** Get the native video dimensions * @return {Vector2} Video dimensions (may be 0,0 if metadata not loaded) */ getVideoSize() { return vec2(this.video.videoWidth, this.video.videoHeight); } /** Seek to time in seconds * @param {number} time - Time in seconds to seek to */ setTime(time) { // before the metadata loads there is no duration to clamp to, and the time set is where it starts const duration = this.video.duration; this.video.currentTime = duration ? clamp(time, 0, duration) : max(time, 0); } update() { super.update(); // update volume based on global sound volume, and the mute when sound is turned on or off, // so a game's own mute stays until then this.video.volume = clamp(this.volume * soundVolume); if (this._soundEnabled !== soundEnable) this.video.muted = !(this._soundEnabled = soundEnable); } /** Render video to UI canvas */ render() { super.render(); if (this.isLoading()) return; const context = uiSystem.uiContext; const s = this.size; context.save(); context.translate(this.nativePos.x, this.nativePos.y); context.drawImage(this.video, -s.x/2, -s.y/2, s.x, s.y); context.restore(); } /** Clean up video on destroy */ destroy() { if (this.destroyed) return; // let go of the media too, a paused video keeps what it has loaded this.video.pause(); this.video.removeAttribute?.('src'); this.video.load?.(); this.video.remove(); super.destroy(); } } /////////////////////////////////////////////////////////////////////////////// /** * UILayout - A container that auto-arranges children in a vertical list, horizontal list, or grid * - Set columns to 1 for a vertical list (default) * - Set columns to children.length for a horizontal list * - Set columns to N (1 < N < children.length) for a grid with N columns * - Per-child sizing: each row's height = max child.size.y in that row, each column's width = max child.size.x in that column * - Children are positioned centered in their cell * - Container auto-sizes to fit children plus padding * - Hidden children take no cell, call relayout after showing or hiding one * @extends UIObject * @memberof UISystem */ class UILayout extends UIObject { /** Create a UILayout container that auto-arranges children * @param {Vector2} [pos] * @param {number} [columns] - Number of columns (1 = vertical list) * @param {number} [gap] - Space between children * @param {number} [padding] - Space between container border and children * @param {boolean} [transparent] - If true, draws no background, outline, or shadow */ constructor(pos, columns=1, gap=10, padding=10, transparent=false) { super(pos); ASSERT(isNumber(columns) && columns >= 1, 'ui layout columns must be a number >= 1'); ASSERT(isNumber(gap), 'ui layout gap must be a number'); ASSERT(isNumber(padding), 'ui layout padding must be a number'); /** @property {number} - Number of columns in the layout */ this.columns = columns; /** @property {number} - Space between children */ this.gap = gap; /** @property {number} - Space between container border and children */ this.padding = padding; if (transparent) { // pure positioning helper - skip background, outline, and shadow, // and leave clicks in its gaps and padding to the game this.color = CLEAR_BLACK.copy(); this.gradientColor = undefined; this.lineWidth = 0; this.shadowColor = CLEAR_BLACK.copy(); this.canBeHover = false; } this.relayout(); } /** Add a child UIObject and re-layout * @param {UIObject} child * @return {UIObject} The child object added */ addChild(child) { super.addChild(child); this.relayout(); return child; } /** Remove a child UIObject and re-layout * @param {UIObject} child */ removeChild(child) { super.removeChild(child); this.relayout(); } /** Recompute child positions and container size based on per-child sizes. * Called automatically by addChild and removeChild. Call manually if you * mutate a child's size, show or hide one, or change columns, gap, or padding. */ relayout() { // a hidden child leaves no gap, like a Continue button hidden when there is nothing to continue const children = this.children.filter(child=> child.visible); const n = children.length; if (!n) { this.size = vec2(this.padding * 2); this.parent instanceof UILayout && this.parent.relayout(); // a layout it is in takes its new size return; } // whole columns, at least one, and no empty ones, they would push it off center const cols = clamp(floor(this.columns), 1, n); const rows = ceil(n / cols); const colWidths = new Array(cols).fill(0); const rowHeights = new Array(rows).fill(0); // first pass: compute column widths and row heights from child sizes for (let i = 0; i < n; ++i) { const col = i % cols; const row = floor(i / cols); const child = children[i]; colWidths[col] = max(colWidths[col], child.size.x); rowHeights[row] = max(rowHeights[row], child.size.y); } // total content size (sum of column widths/row heights plus gaps between them) let contentWidth = this.gap * (cols - 1); for (const w of colWidths) contentWidth += w; let contentHeight = this.gap * (rows - 1); for (const h of rowHeights) contentHeight += h; // cumulative column/row offsets so positioning is O(n) not O(n^2) const colOffsets = new Array(cols); let xAcc = 0; for (let c = 0; c < cols; ++c) { colOffsets[c] = xAcc; xAcc += colWidths[c]; } const rowOffsets = new Array(rows); let yAcc = 0; for (let r = 0; r < rows; ++r) { rowOffsets[r] = yAcc; yAcc += rowHeights[r]; } // second pass: position each child centered in its cell for (let i = 0; i < n; ++i) { const col = i % cols; const row = floor(i / cols); const x = -contentWidth/2 + colOffsets[col] + this.gap * col + colWidths[col] / 2; const y = -contentHeight/2 + rowOffsets[row] + this.gap * row + rowHeights[row] / 2; children[i].localPos = vec2(x, y); } // container size = content + padding on all sides this.size = vec2(contentWidth + this.padding * 2, contentHeight + this.padding * 2); this.parent instanceof UILayout && this.parent.relayout(); // a layout it is in takes its new size } } /** * LittleJS Box2D Physics Plugin * - Box2dObject extends EngineObject with Box2D physics * - Call box2dInit() to enable * - You will also need to include box2d.wasm.js * - Uses a super fast web assembly port of Box2D v2.3.1 * - More info: https://github.com/kripken/box2d.js * - Functions to create polygon, circle, and edge shapes * - Contact begin and end callbacks * - Wraps b2Vec2 type to/from Vector2 * - Raycasting and querying * - Box2dTileLayer for grid based collision * - Every type of joint * - Debug physics drawing * - Box2D works per second: its velocities and accelerations are in units per second, and it reads the engine's * gravity as units per second squared, where an EngineObject's are per frame * @namespace Box2D */ /** Global Box2d Plugin object * @type {Box2dPlugin} * @memberof Box2D */ let box2d; /** Enable Box2D debug drawing * @type {boolean} * @default * @memberof Box2D */ let box2dDebug = false; // Box2D copies every vector it is given, so the plugin hands it these two instead of making a new one each call, // which the binding would keep; a call that takes two vectors uses both let box2dTempVectors; function box2dTemp(v, slot=0) { ASSERT(isVector2(v), 'Box2D: expected a vec2', v); const temp = (box2dTempVectors ||= [new box2d.instance.b2Vec2(), new box2d.instance.b2Vec2()])[slot]; temp.Set(v.x, v.y); return temp; } // the native objects a query needs, one of each kind made once and reused, since the binding keeps every one made; // a query sets the callback's ReportFixture before each use, so the one callback serves every query of its kind const box2dQueryObjects = {}; const box2dGravity = {x:NaN, y:NaN}; // the gravity the world was last given function box2dQueryObject(key, type) { return box2dQueryObjects[key] ||= new box2d.instance[type](); } // how fast two bodies close along the contact normal as they start touching, the fastest of its points, with spin; // BeginContact comes before the solver changes the velocities, so this is the hit, and a sensor has no points, 0 function box2dImpactSpeed(contact) { const pointCount = contact.GetManifold().get_pointCount(); if (!pointCount) return 0; const manifold = box2dQueryObject('impactManifold', 'b2WorldManifold'); contact.GetWorldManifold(manifold); const normal = manifold.get_normal(), nx = normal.get_x(), ny = normal.get_y(); const bodyA = contact.GetFixtureA().GetBody(), bodyB = contact.GetFixtureB().GetBody(); let speed = 0; for (let i = 0; i < pointCount; ++i) { // the normal points from A to B, so A moving along it faster than B is closing const point = manifold.get_points(i); const vA = bodyA.GetLinearVelocityFromWorldPoint(point); const ax = vA.get_x(), ay = vA.get_y(); const vB = bodyB.GetLinearVelocityFromWorldPoint(point); speed = max(speed, (ax - vB.get_x())*nx + (ay - vB.get_y())*ny); } return speed; } // Box2D finds fixtures by boxes it pads and stretches ahead along the velocity, so a query checks the shape's own box function box2dFixtureOverlaps(fixture, aabb) { const shape = fixture.GetShape(), transform = fixture.GetBody().GetTransform(); const shapeBox = box2dQueryObject('shapeAABB', 'b2AABB'); const lower = aabb.get_lowerBound(), upper = aabb.get_upperBound(); for (let i = shape.GetChildCount(); i--;) { shape.ComputeAABB(shapeBox, transform, i); const a = shapeBox.get_lowerBound(), b = shapeBox.get_upperBound(); if (a.get_x() <= upper.get_x() && b.get_x() >= lower.get_x() && a.get_y() <= upper.get_y() && b.get_y() >= lower.get_y()) return true; } return false; } // the points of an edge list or loop, without one that repeats the one before, or a loop's closing repeat of the first; // an edge of no length gives its neighbors a ghost vertex on their own end, and bodies fall through them function box2dEdgePoints(points, loop) { // each point is kept if it is far enough from the last one kept, so a densely sampled curve keeps every few of // its points rather than losing them all const slop2 = .005**2; // Box2D's linear slop, what its own chain shape asserts consecutive points are apart const kept = []; for (const p of points) if (!kept.length || p.distanceSquared(kept[kept.length-1]) > slop2) kept.push(p); while (loop && kept.length > 1 && kept[kept.length-1].distanceSquared(kept[0]) <= slop2) kept.pop(); return kept; } // wake a body and whatever touches it, Box2D does not when a body is moved, and a sleeping pair never updates function box2dWakeWithContacts(body) { body.SetAwake(true); for (let edge = body.GetContactList(); !box2d.isNull(edge); edge = edge.get_next()) edge.get_other().SetAwake(true); } // a number Box2D can take: it stops for good on NaN or Infinity, and a release build has no asserts to catch one first const box2dFinite = (value, otherwise)=> isFinite(value) ? value : otherwise; // wake both bodies of a joint whose length, spring, ratio or strength changed, a sleeping body would stay where it was function box2dWakeJoint(joint) { joint.GetBodyA().SetAwake(true); joint.GetBodyB().SetAwake(true); } // what Box2D adds to the inertia for a center of mass away from the origin, in float32 as it does it, so taking it // off again gives exactly what Box2D keeps: an inertia of 0, a locked rotation, stays 0 and not a speck either side function box2dCenterInertia(mass, x, y) { const f = Math.fround; return f(f(mass)*f(f(f(x)*f(x)) + f(f(y)*f(y)))); } // what cannot happen while the world steps, like losing a body from a contact callback: done now, or queued until // the step is done; a body going calls endContact for what it touched, and a destroy from there waits in the queue // too, since the contact it would free is still in use, so the queue runs one at a time in the order things came const box2dPending = []; let box2dPendingBusy = 0; function box2dWhenUnlocked(f) { box2dPending.push(f); if (!box2d.world.IsLocked() && !box2dPendingBusy) box2dRunPending(); } function box2dRunPending() { ++box2dPendingBusy; try { while (box2dPending.length) box2dPending.shift()(); } finally { --box2dPendingBusy; } } // each Box2dJoint by its native pointer, so a joint Box2D destroys along with a body can let go of its wrapper const box2dJoints = new Map; // the gear joints, kept apart from the others, since every joint that goes looks through them const box2dGearJoints = new Set; // a gear joint keeps pointers to the joints it gears and to their bodies, so it goes before either joint does function box2dDestroyGears(joint) { for (const gear of box2dGearJoints) { if (!gear.box2dJoint) box2dGearJoints.delete(gear); // gone already, with a body else if (gear.joint1 === joint || gear.joint2 === joint) { box2dGearJoints.delete(gear); gear.destroy(); } } } /** Enable Box2D debug drawing * @param {boolean} enable * @memberof Box2D */ function box2dSetDebug(enable) { box2dDebug = enable; } // the EngineObject physics fields a Box2D body does not read, each with what moves the body instead; a debug build // warns once a field when a game sets one, which did nothing and said nothing const box2dUnusedFields = { velocity: 'setLinearVelocity', angleVelocity: 'setAngularVelocity', damping: 'setLinearDamping', angleDamping: 'setAngularDamping', mass: 'setMass or the density of its shapes', friction: 'the friction of its shapes, given to addBox and the rest', restitution: 'the restitution of its shapes, given to addBox and the rest'}; const box2dUnusedWarned = new Set, box2dUnusedStarts = new WeakMap; // the values of those fields as numbers, a vector as its two, then gravityScale, kept for the object function box2dUnusedFieldValues(o) { const values = []; for (const name in box2dUnusedFields) { const value = o[name]; value instanceof Vector2 ? values.push(value.x, value.y) : values.push(value); } values.push(o.gravityScale); return values; } // warn once a field function box2dUnusedWarn(name, use) { if (box2dUnusedWarned.has(name)) return; box2dUnusedWarned.add(name); console.warn('Box2dObject.' + name + ' does nothing on a Box2D body, use ' + use); } // warn once a field of one set on a Box2dObject, and of gravityScale set on the field, not with setGravityScale; // compared in place, as it runs for every body every step of a debug build function box2dCheckUnusedFields(o) { const start = box2dUnusedStarts.get(o); if (!start) return; let i = 0; for (const name in box2dUnusedFields) { const value = o[name]; let changed; if (value instanceof Vector2) { changed = value.x !== start[i] || value.y !== start[i + 1]; i += 2; } else changed = value !== start[i++]; changed && box2dUnusedWarn(name, box2dUnusedFields[name]); } // the body's own scale is a call into Box2D, asked only when the field changed since it was last asked, // since setGravityScale sets both if (o.gravityScale === start[i]) return; start[i] = o.gravityScale; Math.fround(o.gravityScale) === o.body.GetGravityScale() || box2dUnusedWarn('gravityScale', 'setGravityScale'); } /////////////////////////////////////////////////////////////////////////////// /** * Box2D Object - extend with your own custom physics objects * - A LittleJS object with Box2D physics, dynamic by default * - Provides interface for Box2D body and fixture functions * - Each object can have multiple fixtures and joints * - Angular values are clockwise like angle: angular velocity, torque, joint angles, limits and motor speeds * - Box2D moves the body, so the EngineObject physics fields do nothing on it: velocity, angleVelocity, damping, * angleDamping, mass, friction, restitution and gravityScale; use setLinearVelocity, setAngularVelocity, * setLinearDamping, setAngularDamping, setMass, the friction and restitution of its shapes and setGravityScale. * A debug build warns once when one of them is set * @extends EngineObject * @memberof Box2D */ class Box2dObject extends EngineObject { /** Create a LittleJS object with Box2d physics * @param {Vector2} [pos] * @param {Vector2} [size] * @param {TileInfo} [tileInfo] * @param {number} [angle] * @param {Color} [color] * @param {number} [bodyType] * @param {number} [renderOrder] */ constructor(pos=vec2(), size=vec2(), tileInfo, angle=0, color, bodyType=box2d.bodyTypeDynamic, renderOrder=0) { ASSERT(!box2d.world.IsLocked(), 'cannot create Box2D bodies during a contact callback'); super(pos, size, tileInfo, angle, color, renderOrder); // create physics body, Box2D copies the def const bodyDef = new box2d.instance.b2BodyDef(); bodyDef.set_type(bodyType); bodyDef.set_position(box2dTemp(pos)); bodyDef.set_angle(-angle); /** @property {Object} - The Box2d body, undefined once it is destroyed */ this.body = box2d.world.CreateBody(bodyDef); box2d.instance.destroy(bodyDef); /** @property {Color} - Line color used for default box2d drawing */ this.lineColor = BLACK.copy(); /** @property {number} - Line width used for default box2d drawing */ this.lineWidth = .1; /** @property {Array>} - List of all edges for default box2d drawing * @type {Array>} */ this.edgeLists = []; /** @property {Array>} - List of all edge loops for default box2d drawing * @type {Array>} */ this.edgeLoops = []; // the fixtures of the edge lists and loops, by pointer, each to the points it is drawn with this.edgeListFixtures = new Map; this.body.object = this; // link body to this object box2d.objects.push(this); // keep track of all box2d objects // a debug build notes the engine physics fields Box2D does not read, to warn when one is set debug && box2dUnusedStarts.set(this, box2dUnusedFieldValues(this)); } /** Destroy this object and its physics body * @param {boolean} [immediate] - Remove it now, as EngineObject.destroy does, children included */ destroy(immediate=false) { if (this.destroyed) return; // destroy physics body, fixtures, and joints; from a contact callback the world is still // stepping and cannot lose a body, so it goes as soon as the step is done; the object // leaves box2d.objects at the next step, or the next frame while paused or time is stopped; // the body lets go of it after, since destroying it calls endContact, which finds it there; // that endContact can destroy this same object again, before it is marked destroyed, so the body is // destroyed only by the call that still finds it, Box2D would lose count of its bodies on a second ASSERT(this.body, 'Box2dObject has no body to destroy'); const body = this.body; box2dWhenUnlocked(()=> { if (this.body !== body) return; // destroyed already box2d.world.DestroyBody(body); body.object = undefined; this.body = undefined; }); super.destroy(immediate); } /** Box2d objects updated with Box2d world step */ updatePhysics() {} /** Update the object transform, called automatically by engine even when paused; * its body places it, so it can be a parent but not a child, connect it to another with a joint * @param {boolean} [updateChildren] - Also update the children's transforms */ updateTransforms(updateChildren=true) { ASSERT(!this.parent, 'a Box2dObject cannot be a child, its body would stay behind, connect it with a joint'); super.updateTransforms(updateChildren); } /** Render the object, uses box2d drawing if no tile info exists */ render() { // use default render or draw fixtures if (this.tileInfo) super.render(); else this.drawFixtures(this.color, this.lineColor, this.lineWidth); } /** Draw its fixtures for the debug view: yellow asleep, blue static, both when both */ renderDebugInfo() { const isAsleep = !this.getIsAwake(); const isStatic = this.getBodyType() === box2d.bodyTypeStatic; const color = rgb(isAsleep?1:0, isAsleep?1:0, isStatic?1:0, .5); this.drawFixtures(color); } /** Draw all this object's fixtures * @param {Color} [color] * @param {Color} [lineColor] * @param {number} [lineWidth] * @param {boolean} [useWebGL=glEnable] * @param {CanvasRenderingContext2D} [context] */ drawFixtures(color=WHITE, lineColor=BLACK, lineWidth=.1, useWebGL, context) { // draw each fixture, but the edges of an edge list or loop, which draw below as one line const edgeFixtures = this.edgeListFixtures; this.getFixtureList().forEach((fixture)=> { if (!edgeFixtures.has(box2d.instance.getPointer(fixture))) box2d.drawFixture(fixture, this.pos, this.angle, color, lineColor, lineWidth, useWebGL, context); }); // draw edges using a single draw line for better connections this.edgeLists.forEach(points=> drawLineList(points, lineWidth, lineColor, false, this.pos, this.angle, useWebGL, false, context)); this.edgeLoops.forEach(points=> drawLineList(points, lineWidth, lineColor, true, this.pos, this.angle, useWebGL, false, context)); } /////////////////////////////////////////////////////////////////////////////// // physics contact callbacks /** Called when a contact begins, while the world steps: a destroy or a setter waits until the step is done, * and creating objects, fixtures or joints must wait until after the step * - The fixtures say which shapes touched, the same objects addBox and the others returned, so a small sensor * under a player's feet can tell standing on the ground from touching a wall * - impactSpeed is how hard they hit: how fast they were closing along the contact normal, spin included, * in world units per second, measured before the solver pushed them apart; 0 for a sensor * @param {Box2dObject} otherObject * @param {Object} [fixture] - This object's fixture that touched * @param {Object} [otherFixture] - The other object's fixture that touched * @param {number} [impactSpeed] - How fast they were closing as they touched */ beginContact(otherObject, fixture, otherFixture, impactSpeed) {} /** Called when a contact ends, while the world steps or a body is destroyed: a destroy or a setter waits * until the step is done, and creating objects, fixtures or joints must wait until after the step * @param {Box2dObject} otherObject * @param {Object} [fixture] - This object's fixture that touched * @param {Object} [otherFixture] - The other object's fixture that touched */ endContact(otherObject, fixture, otherFixture) {} /////////////////////////////////////////////////////////////////////////////// // physics fixtures and shapes /** Add a shape fixture to the body * @param {Object} shape * @param {number} [density] * @param {number} [friction] * @param {number} [restitution] * @param {boolean} [isSensor] * @return {Object} - The fixture made, Box2D's own */ addShape(shape, density=1, friction=.2, restitution=0, isSensor=false) { ASSERT(isNumber(density), 'density must be a number'); ASSERT(isNumber(friction), 'friction must be a number'); ASSERT(isNumber(restitution), 'restitution must be a number'); ASSERT(!box2d.world.IsLocked(), 'cannot create Box2D fixtures during a contact callback'); // Box2D copies the def and the shape const fd = new box2d.instance.b2FixtureDef(); fd.set_shape(shape); fd.set_density(density); fd.set_friction(friction); fd.set_restitution(restitution); fd.set_isSensor(isSensor); const fixture = this.body.CreateFixture(fd); box2d.instance.destroy(fd); return fixture; } /** Add a box shape to the body * @param {Vector2} [size] * @param {Vector2} [offset] * @param {number} [angle] - LittleJS convention (clockwise positive). * Negated internally to match Box2D's CCW-positive convention so the * fixture aligns with the same angle passed to drawRect/drawTile. * @param {number} [density] * @param {number} [friction] * @param {number} [restitution] * @param {boolean} [isSensor] * @return {Object|undefined} - The fixture made, Box2D's own, undefined for a shape too small to make */ addBox(size=vec2(1), offset=vec2(), angle=0, density, friction, restitution, isSensor) { ASSERT(isVector2(size), 'size must be a Vector2'); ASSERT(size.x > 0 && size.y > 0, 'size must be positive'); ASSERT(isVector2(offset), 'offset must be a Vector2'); ASSERT(isNumber(angle), 'angle must be a number'); // Box2D stops for good on a box with almost no area, like addPoly no fixture is made from one ASSERT(size.x * size.y > 1e-6 && isFinite(size.x * size.y), 'box is too small or too big for Box2D'); if (!(size.x * size.y > 1e-6) || !isFinite(size.x * size.y)) return; const shape = new box2d.instance.b2PolygonShape(); shape.SetAsBox(size.x/2, size.y/2, box2dTemp(offset), -angle); const fixture = this.addShape(shape, density, friction, restitution, isSensor); box2d.instance.destroy(shape); // the fixture has its own copy return fixture; } /** Add a polygon shape to the body, the convex hull of its points; Box2D takes 3 to 8 points, * not all in a line, and no fixture is made from any other; points closer than .001 count as one * @param {Array} points * @param {number} [density] * @param {number} [friction] * @param {number} [restitution] * @param {boolean} [isSensor] * @return {Object|undefined} - The fixture made, Box2D's own, undefined for a shape too small to make */ addPoly(points, density, friction, restitution, isSensor) { ASSERT(isArray(points), 'points must be an array'); function box2dCreatePolygonShape(points) { // Box2D stops for good on two hull points that nearly meet, like a loop whose last point is its first points = points.filter((p, i)=> points.slice(0, i).every(q=> p.distanceSquared(q) > 1e-6)); // Box2D stops for good on a polygon it cannot take, so one with too many points or no area makes none; // it takes the convex hull, which is at least as big as the biggest triangle of the points let area = 0; // twice the biggest triangle's if (3 <= points.length && points.length <= 8) for (const a of points) for (const b of points) for (const c of points) area = max(area, abs(b.subtract(a).cross(c.subtract(a)))); ASSERT(area >= 1e-6, 'Box2D polygons need 3 to 8 points, not all in a line'); if (!(area >= 1e-6)) return; const buffer = box2d.instance._malloc(points.length * 8); for (let i=0, offset=0; i> 2] = points[i].x; offset += 4; box2d.instance.HEAPF32[buffer + offset >> 2] = points[i].y; offset += 4; } const box2dPoints = box2d.instance.wrapPointer(buffer, box2d.instance.b2Vec2); const shape = new box2d.instance.b2PolygonShape(); shape.Set(box2dPoints, points.length); box2d.instance._free(buffer); return shape; } const shape = box2dCreatePolygonShape(points); if (!shape) return; const fixture = this.addShape(shape, density, friction, restitution, isSensor); box2d.instance.destroy(shape); // the fixture has its own copy return fixture; } /** Add a regular polygon shape to the body * @param {number} [diameter] * @param {number} [sides] - 3 to 8, the most Box2D polygons have * @param {number} [density] * @param {number} [friction] * @param {number} [restitution] * @param {boolean} [isSensor] * @return {Object|undefined} - The fixture made, Box2D's own, undefined for a shape too small to make */ addRegularPoly(diameter=1, sides=8, density, friction, restitution, isSensor) { ASSERT(isNumber(diameter) && diameter>0, 'diameter must be a positive number'); ASSERT(isNumber(sides) && sides>2, 'sides must be a positive number greater than 2'); ASSERT(sides <= 8, 'Box2D polygons have at most 8 sides'); sides = min(sides, 8); // more would stop Box2D for good const points = []; const radius = diameter/2; for (let i=sides; i-- > 0;) // a count that is not a whole number still ends points.push(vec2(radius,0).rotate((i+.5)/sides*PI*2)); return this.addPoly(points, density, friction, restitution, isSensor); } /** Add a random polygon shape to the body * @param {number} [diameter] * @param {number} [density] * @param {number} [friction] * @param {number} [restitution] * @param {boolean} [isSensor] * @return {Object|undefined} - The fixture made, Box2D's own, undefined for a shape too small to make */ addRandomPoly(diameter=1, density, friction, restitution, isSensor) { ASSERT(isNumber(diameter) && diameter>0, 'diameter must be a positive number'); const sides = randInt(3, 9); const points = []; const radius = diameter/2; for (let i=sides; i--;) points.push(vec2(rand(radius/2,radius*1.5),0).rotate(i/sides*PI*2)); return this.addPoly(points, density, friction, restitution, isSensor); } /** Add a circle shape to the body * @param {number} [diameter] * @param {Vector2} [offset] * @param {number} [density] * @param {number} [friction] * @param {number} [restitution] * @param {boolean} [isSensor] * @return {Object|undefined} - The fixture made, Box2D's own, undefined for a circle too big to make */ addCircle(diameter=1, offset=vec2(), density, friction, restitution, isSensor) { ASSERT(isNumber(diameter) && diameter>0 && isFinite(diameter), 'diameter must be a positive number'); ASSERT(isVector2(offset), 'offset must be a Vector2'); if (!isFinite(diameter)) return; // Box2D stops for good on it, no fixture is made const shape = new box2d.instance.b2CircleShape(); shape.set_m_p(box2dTemp(offset)); shape.set_m_radius(diameter/2); const fixture = this.addShape(shape, density, friction, restitution, isSensor); box2d.instance.destroy(shape); // the fixture has its own copy return fixture; } /** Add an edge shape to the body * @param {Vector2} point1 * @param {Vector2} point2 * @param {number} [density] * @param {number} [friction] * @param {number} [restitution] * @param {boolean} [isSensor] * @return {Object} - The fixture made, Box2D's own */ addEdge(point1, point2, density, friction, restitution, isSensor) { ASSERT(isVector2(point1), 'point1 must be a Vector2'); ASSERT(isVector2(point2), 'point2 must be a Vector2'); const shape = new box2d.instance.b2EdgeShape(); shape.Set(box2dTemp(point1), box2dTemp(point2, 1)); const fixture = this.addShape(shape, density, friction, restitution, isSensor); box2d.instance.destroy(shape); // the fixture has its own copy return fixture; } /** Add an edge list to the body * @param {Array} points * @param {number} [density] * @param {number} [friction] * @param {number} [restitution] * @param {boolean} [isSensor] * @return {Array} - The fixtures made, Box2D's own */ addEdgeList(points, density, friction, restitution, isSensor) { ASSERT(isArray(points), 'points must be an array'); points = box2dEdgePoints(points); const fixtures = [], edgePoints = []; if (points.length < 2) return fixtures; for (let i=0; i this.edgeListFixtures.set(box2d.instance.getPointer(f), edgePoints)); return fixtures; } /** Add an edge loop to the body, an edge loop connects the end points * @param {Array} points * @param {number} [density] * @param {number} [friction] * @param {number} [restitution] * @param {boolean} [isSensor] * @return {Array} - The fixtures made, Box2D's own */ addEdgeLoop(points, density, friction, restitution, isSensor) { ASSERT(isArray(points), 'points must be an array'); points = box2dEdgePoints(points, true); const fixtures = [], edgePoints = []; if (points.length < 2) return fixtures; const getPoint = i=> points[mod(i,points.length)]; for (let i=0; i this.edgeListFixtures.set(box2d.instance.getPointer(f), edgePoints)); return fixtures; } /** Destroy a fixture from the body, from a contact callback once the step is done * @param {Object} fixture */ destroyFixture(fixture) { const pointer = box2d.instance.getPointer(fixture); // an edge list or loop that loses a fixture is no longer one line, what is left of it draws edge by edge const edgeFixtures = this.edgeListFixtures, points = edgeFixtures.get(pointer); if (points) { this.edgeLists = this.edgeLists.filter(p=> p !== points); this.edgeLoops = this.edgeLoops.filter(p=> p !== points); edgeFixtures.forEach((p, pointer)=> p === points && edgeFixtures.delete(pointer)); } // not once the body is gone, which takes its fixtures with it, or once the fixture is, // since a second destroy of it, like one from each of two contacts in a step, stops Box2D for good box2dWhenUnlocked(()=> this.body && this.getFixtureList().some(f=> box2d.instance.getPointer(f) === pointer) && this.body.DestroyFixture(fixture)); } /** Destroy all fixtures from the body, from a contact callback once the step is done */ destroyAllFixtures() { // the fixtures it has now, each destroyed if still there, in one pass so a big tile layer rebuilds quickly this.edgeLists = []; this.edgeLoops = []; this.edgeListFixtures.clear(); const fixtures = this.getFixtureList(), getPointer = box2d.instance.getPointer; box2dWhenUnlocked(()=> { if (!this.body) return; const alive = new Set(this.getFixtureList().map(getPointer)); for (const fixture of fixtures) alive.has(getPointer(fixture)) && this.body.DestroyFixture(fixture); }); } /////////////////////////////////////////////////////////////////////////////// // physics get functions /** Gets the center of mass in world space * @return {Vector2} */ getCenterOfMass() { return box2d.vec2From(this.body.GetWorldCenter()); } /** Gets the linear velocity * @return {Vector2} */ getLinearVelocity() { return box2d.vec2From(this.body.GetLinearVelocity()); } /** Gets the angular velocity, clockwise like angle * @return {number} */ getAngularVelocity() { return -this.body.GetAngularVelocity(); } // box2d uses reverse angle /** Gets the mass * @return {number} */ getMass() { return this.body.GetMass(); } /** Gets the rotational inertia about the center of mass * @return {number} */ getInertia() { // Box2D gives it about the body origin const center = this.body.GetLocalCenter(); return max(0, Math.fround(this.body.GetInertia() - box2dCenterInertia(this.getMass(), center.get_x(), center.get_y()))); } /** Check if this object is awake * @return {boolean} */ getIsAwake() { return this.body.IsAwake(); } /** Gets the physics body type * @return {number} */ getBodyType() { return this.body.GetType(); } /** Get the speed of this object * @return {number} */ getSpeed() { return this.getLinearVelocity().length(); } /** Get the velocity of a point on this object, its linear velocity and its spin about its center of mass * together, in Box2D's units like getLinearVelocity * @param {Vector2} pos - the point in world space * @return {Vector2} */ getVelocityAtPoint(pos) { return box2d.vec2From(this.body.GetLinearVelocityFromWorldPoint(box2dTemp(pos))); } /////////////////////////////////////////////////////////////////////////////// // physics set functions /** Sets the position and angle, from a contact callback the body moves once the step is done * @param {Vector2} pos * @param {number} angle */ setTransform(pos, angle) { this.pos.set(pos.x, pos.y); // its own pos kept, as an EngineObject's is this.angle = angle; // box2d uses reverse angle const x = pos.x, y = pos.y; box2dWhenUnlocked(()=> { if (!this.body) return; this.body.SetTransform(box2dTemp(vec2(x, y)), -angle); box2dWakeWithContacts(this.body); // Box2D leaves a sleeping body, and what rests on it, in the air }); } /** Sets the position, from a contact callback the body moves once the step is done, keeping the angle it has then * @param {Vector2} pos */ setPosition(pos) { this.pos.set(pos.x, pos.y); // its own pos kept, as an EngineObject's is const x = pos.x, y = pos.y; box2dWhenUnlocked(()=> { if (!this.body) return; this.body.SetTransform(box2dTemp(vec2(x, y)), this.body.GetAngle()); box2dWakeWithContacts(this.body); }); } /** Sets the angle, from a contact callback the body turns once the step is done, keeping the position it has then * @param {number} angle */ setAngle(angle) { this.angle = angle; box2dWhenUnlocked(()=> { if (!this.body) return; this.body.SetTransform(this.body.GetPosition(), -angle); // box2d uses reverse angle box2dWakeWithContacts(this.body); }); } /** Sets the linear velocity * @param {Vector2} velocity */ setLinearVelocity(velocity) { this.body.SetLinearVelocity(box2dTemp(velocity)); } /** Sets the angular velocity, clockwise like angle * @param {number} angularVelocity */ setAngularVelocity(angularVelocity) { this.body.SetAngularVelocity(-angularVelocity); } /** Sets the linear damping, Box2D's: a rate, 0 none and larger slows it faster, with no upper limit, where the * damping of an EngineObject is the fraction it keeps each frame and does nothing on a Box2dObject * @param {number} damping */ setLinearDamping(damping) { this.body.SetLinearDamping(damping); } /** Sets the angular damping, Box2D's: a rate, 0 none and larger slows its turning faster, with no upper limit; * angleDamping does nothing on a Box2dObject * @param {number} damping */ setAngularDamping(damping) { this.body.SetAngularDamping(damping); } /** Sets the gravity scale * @param {number} [scale] */ setGravityScale(scale=1) { this.body.SetGravityScale(this.gravityScale = scale); this.body.SetAwake(true); // a sleeping body would not feel it } /** Should be like a bullet for continuous collision detection? * @param {boolean} [isBullet] */ setBullet(isBullet=true) { this.body.SetBullet(isBullet); } /** Set the sleep state of the body * @param {boolean} [isAwake] */ setAwake(isAwake=true) { this.body.SetAwake(isAwake); } /** Set the physics body type, from a contact callback it changes once the step is done * @param {number} type */ setBodyType(type) { box2dWhenUnlocked(()=> this.body && this.body.SetType(type)); } /** Set whether the body is allowed to sleep * @param {boolean} [isAllowed] */ setSleepingAllowed(isAllowed=true) { this.body.SetSleepingAllowed(isAllowed); } /** Set whether the body can rotate * @param {boolean} [isFixed] */ setFixedRotation(isFixed=true) { this.body.SetFixedRotation(isFixed); this.body.SetAwake(true); // a sleeping body would not tip over once it can turn } /** Set the center of mass of the body, local to it * @param {Vector2} center */ setCenterOfMass(center) { this.setMassData(center) } /** Set the mass of the body * @param {number} mass */ setMass(mass) { this.setMassData(undefined, mass) } /** Set the moment of inertia of the body, about its center of mass * @param {number} momentOfInertia */ setMomentOfInertia(momentOfInertia) { this.setMassData(undefined, undefined, momentOfInertia) } /** Reset the mass, center of mass, and moment, from a contact callback once the step is done */ resetMassData() { box2dWhenUnlocked(()=> this.body && this.body.ResetMassData()); } /** Set the mass data of the body, from a contact callback once the step is done; * a mass of 0 or less becomes 1, use setBodyType for a static body; call it after adding fixtures and after * setFixedRotation, both of which put the mass back to what the fixtures give * @param {Vector2} [localCenter] * @param {number} [mass] * @param {number} [momentOfInertia] - About the center of mass */ setMassData(localCenter, mass, momentOfInertia) { localCenter = localCenter && localCenter.copy(); // as it is now, even if it waits for the step box2dWhenUnlocked(()=> { if (!this.body) return; const data = box2dQueryObject('massData', 'b2MassData'); // reused, GetMassData fills it in this.body.GetMassData(data); // Box2D's inertia is about the body origin, so it is turned to the center of mass and back; kept as // it was, one for the old mass and center goes below 0 about the new ones, which stops Box2D for good const center = data.get_center(), oldMass = data.get_mass(); const cx = localCenter ? localCenter.x : center.get_x(), cy = localCenter ? localCenter.y : center.get_y(); // it is worked out in float32 as Box2D does it, which must come out above 0 or the rotation is locked const f = Math.fround; const oldInertia = f(data.get_I() - box2dCenterInertia(oldMass, center.get_x(), center.get_y())); const inertia = momentOfInertia ?? oldInertia; mass = box2dFinite(mass ?? oldMass, oldMass); const offset = box2dCenterInertia(mass > 0 ? mass : 1, cx, cy); // a mass of 0 or less is 1 to Box2D const I = f(inertia + offset); data.set_mass(mass); data.set_center(box2dTemp(vec2(cx, cy))); data.set_I(inertia > 0 && f(I - offset) > 0 ? I : 0); this.body.SetMassData(data); this.body.SetAwake(true); // a sleeping body would not tip over a new center of mass }); } /** Set the collision filter data for the fixtures this body has now, a fixture added later has the default * filter, category 1 colliding with everything * @param {number} [categoryBits] * @param {number} [ignoreCategoryBits] * @param {number} [groupIndex] */ setFilterData(categoryBits=1, ignoreCategoryBits=0, groupIndex=0) { this.getFixtureList().forEach(fixture=> { const filter = fixture.GetFilterData(); filter.set_categoryBits(categoryBits); filter.set_maskBits(0xffff & ~ignoreCategoryBits); filter.set_groupIndex(groupIndex); fixture.SetFilterData(filter); // applies and refilters contacts }); } /** Set if this body is a sensor * @param {boolean} [isSensor] */ setSensor(isSensor=true) { this.getFixtureList().forEach(f=>f.SetSensor(isSensor)); box2dWakeWithContacts(this.body); // what rests on it or sits in it moves again, a sleeping pair never updates } /////////////////////////////////////////////////////////////////////////////// // physics force and torque functions /** Apply force to this object * @param {Vector2} force * @param {Vector2} [pos] */ applyForce(force, pos) { pos ||= this.getCenterOfMass(); this.setAwake(); this.body.ApplyForce(box2dTemp(force), box2dTemp(pos, 1)); } /** Apply acceleration to this object (changes velocity by acceleration, * mass-independent like EngineObject.applyAcceleration, but in units per second). * Use applyImpulse if you want the mass-dependent velocity change * Δv = impulse / mass, or applyForce for a Newton-style sustained force. * @param {Vector2} acceleration * @param {Vector2} [pos] */ applyAcceleration(acceleration, pos) { pos ||= this.getCenterOfMass(); this.setAwake(); const impulse = acceleration.scale(this.getMass()); this.body.ApplyLinearImpulse(box2dTemp(impulse), box2dTemp(pos, 1)); } /** Apply an instantaneous linear impulse. Changes velocity immediately by * impulse / mass (so heavier bodies move less for the same impulse). * @param {Vector2} impulse * @param {Vector2} [pos] */ applyImpulse(impulse, pos) { pos ||= this.getCenterOfMass(); this.setAwake(); this.body.ApplyLinearImpulse(box2dTemp(impulse), box2dTemp(pos, 1)); } /** Apply torque to this object, clockwise like angle * @param {number} torque */ applyTorque(torque) { this.setAwake(); this.body.ApplyTorque(-torque); } /** Apply angular acceleration to this object (changes angular velocity by * acceleration, mass-independent, clockwise — matches EngineObject.applyAngularAcceleration). * @param {number} acceleration */ applyAngularAcceleration(acceleration) { // the velocity itself, since the inertia Box2D gives is about the origin, not the center of mass; // like the impulse this was, only a dynamic body turns if (this.getBodyType() !== box2d.bodyTypeDynamic || this.body.IsFixedRotation()) return; this.setAwake(); this.setAngularVelocity(this.getAngularVelocity() + acceleration); } /** Apply an instantaneous angular impulse. Changes angular velocity by * impulse / inertia immediately, clockwise like angle. * @param {number} impulse */ applyAngularImpulse(impulse) { this.setAwake(); this.body.ApplyAngularImpulse(-impulse); } /////////////////////////////////////////////////////////////////////////////// // lists of fixtures and joints /** Check if this object has any fixtures * @return {boolean} */ hasFixtures() { return !box2d.isNull(this.body.GetFixtureList()); } /** Get list of fixtures for this object * @return {Array} */ getFixtureList() { const fixtures = []; for (let fixture=this.body.GetFixtureList(); !box2d.isNull(fixture); ) { fixtures.push(fixture); fixture = fixture.GetNext(); } return fixtures; } /** Check if this object has any joints * @return {boolean} */ hasJoints() { return this.getJointList().length > 0; } // not the ones destroyed and waiting for the step to end /** Get list of joints for this object, the Box2dJoint for each one made through LittleJS, * and the Box2D joint, cast to its type, for any made on the world directly * @return {Array} */ getJointList() { // the body keeps a list of edges, each holding a joint and the next edge const joints = []; for (let edge=this.body.GetJointList(); !box2d.isNull(edge); edge = edge.get_next()) { const joint = edge.get_joint(), wrapper = box2dJoints.get(box2d.instance.getPointer(joint)); if (wrapper && !wrapper.box2dJoint) continue; // destroyed in a contact callback, Box2D lets go of it once the step is done joints.push(wrapper || box2d.castJointObject(joint)); } return joints; } } /////////////////////////////////////////////////////////////////////////////// /** * Box2D Static Object - Box2d with a static physics body * @extends Box2dObject * @memberof Box2D */ class Box2dStaticObject extends Box2dObject { /** Create a LittleJS object with Box2d physics * @param {Vector2} [pos] * @param {Vector2} [size] * @param {TileInfo} [tileInfo] * @param {number} [angle] * @param {Color} [color] * @param {number} [renderOrder] */ constructor(pos, size, tileInfo, angle=0, color, renderOrder=0) { const bodyType = box2d.bodyTypeStatic; super(pos, size, tileInfo, angle, color, bodyType, renderOrder); } } /////////////////////////////////////////////////////////////////////////////// /** * Box2D Kinematic Object - Box2d with a kinematic physics body * @extends Box2dObject * @memberof Box2D */ class Box2dKinematicObject extends Box2dObject { /** Create a LittleJS object with Box2d physics * @param {Vector2} [pos] * @param {Vector2} [size] * @param {TileInfo} [tileInfo] * @param {number} [angle] * @param {Color} [color] * @param {number} [renderOrder] */ constructor(pos, size, tileInfo, angle=0, color, renderOrder=0) { const bodyType = box2d.bodyTypeKinematic; super(pos, size, tileInfo, angle, color, bodyType, renderOrder); } } /////////////////////////////////////////////////////////////////////////////// /** * Box2d Tile Layer * - adds Box2d support to tile layers * - creates static box2d fixtures for solid tiles, call buildCollision to rebuild them after the tiles change * @extends Box2dStaticObject * @memberof Box2D */ class Box2dTileLayer extends Box2dStaticObject { /** Create a Box2d tile layer object * @param {TileCollisionLayer} tileLayer - Tile layer for this object */ constructor(tileLayer) { ASSERT(tileLayer instanceof TileCollisionLayer, 'tileLayer must be a TileCollisionLayer'); super(tileLayer.pos, tileLayer.size); /** @property {TileCollisionLayer} - The tile layer */ this.tileLayer = tileLayer; this.addChild(tileLayer); // collision for the solid tiles it has now, call buildCollision again after changing them this.buildCollision(); } render() { // do not render fixtures, tile layer handles rendering } /** Create box2d collision fixtures for solid tiles * @param {number} [friction] * @param {number} [restitution] */ buildCollision(friction=.2, restitution=0) { // destroy all fixtures and create new ones this.destroyAllFixtures(); // track which tiles have been processed const processed = []; const getIndex = (x, y)=> x + y * this.size.x; const isSolidUnprocessed = (x, y)=> !processed[getIndex(x, y)] && this.tileLayer.getCollisionData(vec2(x, y)) > 0; // combine tiles into larger boxes for (let x = 0; x < this.size.x; ++x) for (let y = 0; y < this.size.y; ++y) { if (!isSolidUnprocessed(x, y)) continue; // find max width by scanning right let width = 1, height = 1, canExpand = true; while (isSolidUnprocessed(x + width, y)) ++width; // find max height by scanning up, ensuring all rows have the same width while (canExpand) { for (let checkX = 0; checkX < width; ++checkX) { if (!isSolidUnprocessed(x + checkX, y + height)) { canExpand = false; break; } } if (canExpand) ++height; } // mark all tiles in this rectangle as processed for (let rectX = width; rectX--;) for (let rectY = height; rectY--;) processed[getIndex(x + rectX, y + rectY)] = true; // create a single fixture for the entire rectangle const shapeSize = vec2(width, height); const offset = vec2(x + width/2, y + height/2); this.addBox(shapeSize, offset, 0, 0, friction, restitution); } } } /////////////////////////////////////////////////////////////////////////////// /** * Box2D Raycast Result * - Holds results from a box2d raycast queries * - Automatically created by box2d raycast functions * @memberof Box2D */ class Box2dRaycastResult { /** Create a raycast result * @param {Object} fixture * @param {Vector2} point * @param {Vector2} normal * @param {number} fraction */ constructor(fixture, point, normal, fraction) { /** @property {Box2dObject} - The box2d object * @type {Box2dObject} */ this.object = fixture.GetBody().object; /** @property {Object} - The fixture that was hit */ this.fixture = fixture; /** @property {Vector2} - The hit point */ this.point = point; /** @property {Vector2} - The hit normal */ this.normal = normal; /** @property {number} - Distance fraction at the point of intersection */ this.fraction = fraction; } } /////////////////////////////////////////////////////////////////////////////// /** * Box2D Joint * - Base class for Box2D joints * - A joint is used to connect objects together * - Angular values are clockwise like angle: joint angles and speeds, limits, motor speeds and torques * @memberof Box2D */ class Box2dJoint { /** Create a box2d joint, the base class is not intended to be used directly * @param {Object} jointDef - Freed once the joint is made, Box2D copies it */ constructor(jointDef) { ASSERT(!box2d.world.IsLocked(), 'cannot create Box2D joints during a contact callback'); const bodyA = jointDef.get_bodyA(), bodyB = jointDef.get_bodyB(); const bothLive = !box2d.isNull(bodyA) && !box2d.isNull(bodyB); ASSERT(bothLive, 'a joint needs two objects that are not destroyed'); const twoObjects = !bothLive || box2d.instance.getPointer(bodyA) !== box2d.instance.getPointer(bodyB); ASSERT(twoObjects, 'a joint needs two different objects'); /** @property {Object} - The Box2d joint, 0 once it is destroyed, as it is when either object is */ this.box2dJoint = 0; if (!bothLive || !twoObjects) { // one of its objects is gone, so it is made destroyed, as it would be had it gone after; a joint of an // object to itself, which Box2D stops for good on, is made destroyed too in a release build box2d.instance.destroy(jointDef); return; } this.box2dJoint = box2d.castJointObject(box2d.world.CreateJoint(jointDef)); box2d.instance.destroy(jointDef); box2dJoints.set(box2d.instance.getPointer(this.box2dJoint), this); } /** Destroy this joint */ destroy() { const joint = this.box2dJoint; if (!joint) return; // destroyed already, or with one of its objects this.box2dJoint = 0; box2dDestroyGears(this); // a body destroyed before it, in the same step, takes the joint with it and lets go of it here const pointer = box2d.instance.getPointer(joint); box2dWhenUnlocked(()=> { if (box2dJoints.get(pointer) !== this) return; box2dJoints.delete(pointer); box2d.world.DestroyJoint(joint); }); } /** Get the first object attached to this joint * @return {Box2dObject} */ getObjectA() { return this.box2dJoint.GetBodyA().object; } /** Get the second object attached to this joint * @return {Box2dObject} */ getObjectB() { return this.box2dJoint.GetBodyB().object; } /** Get the first anchor for this joint in world coordinates * @return {Vector2} */ getAnchorA() { return box2d.vec2From(this.box2dJoint.GetAnchorA());} /** Get the second anchor for this joint in world coordinates * @return {Vector2} */ getAnchorB() { return box2d.vec2From(this.box2dJoint.GetAnchorB());} /** Get the reaction force on bodyB at the joint anchor over the last step * @param {number} [time] - The step length in seconds, the world steps by timeDelta * @return {Vector2} */ getReactionForce(time=timeDelta) { return box2d.vec2From(this.box2dJoint.GetReactionForce(1/time));} /** Get the reaction torque on bodyB in N*m over the last step, clockwise like angle * @param {number} [time] - The step length in seconds, the world steps by timeDelta * @return {number} */ getReactionTorque(time=timeDelta) { return -this.box2dJoint.GetReactionTorque(1/time);} // box2d uses reverse angle /** Check if the connected bodies should collide * @return {boolean} */ getCollideConnected() { return this.box2dJoint.GetCollideConnected();} /** Check if either connected body is active * @return {boolean} */ isActive() { return !!this.box2dJoint && this.box2dJoint.IsActive(); } /** Check if the joint is gone, destroyed or taken along with one of its objects; its other methods can not be used then * @return {boolean} */ isDestroyed() { return !this.box2dJoint; } } /////////////////////////////////////////////////////////////////////////////// /** * Box2D Target Joint, also known as a mouse joint * - Used to make a point on a object track a specific world point target * - This a soft constraint with a max force * - This allows the constraint to stretch and without applying huge forces * - The object must be dynamic, and stay dynamic while the joint holds it, Box2D stops for good on one with no mass * @extends Box2dJoint * @memberof Box2D */ class Box2dTargetJoint extends Box2dJoint { /** Create a target joint * @param {Box2dObject} object * @param {Box2dObject} fixedObject * @param {Vector2} worldPos */ constructor(object, fixedObject, worldPos) { ASSERT(object.getBodyType() === box2d.bodyTypeDynamic, 'a target joint needs a dynamic object'); object.setAwake(); const jointDef = new box2d.instance.b2MouseJointDef(); jointDef.set_bodyA(fixedObject.body); jointDef.set_bodyB(object.body); jointDef.set_target(box2dTemp(worldPos)); jointDef.set_maxForce(2e3 * object.getMass()); super(jointDef); } /** Set the target point in world coordinates * @param {Vector2} pos */ setTarget(pos) { this.box2dJoint.SetTarget(box2dTemp(pos)); } /** Get the target point in world coordinates * @return {Vector2} */ getTarget(){ return box2d.vec2From(this.box2dJoint.GetTarget()); } /** Sets the maximum force in Newtons * @param {number} force */ setMaxForce(force) { this.box2dJoint.SetMaxForce(force); box2dWakeJoint(this.box2dJoint); } /** Gets the maximum force in Newtons * @return {number} */ getMaxForce() { return this.box2dJoint.GetMaxForce(); } /** Sets the joint frequency in Hertz, above 0, Box2D stops for good on 0 * @param {number} hz */ setFrequency(hz) { this.box2dJoint.SetFrequency(max(box2dFinite(hz, 0), 1e-3)); box2dWakeJoint(this.box2dJoint); } /** Gets the joint frequency in Hertz * @return {number} */ getFrequency() { return this.box2dJoint.GetFrequency(); } } /////////////////////////////////////////////////////////////////////////////// /** * Box2D Distance Joint * - Constrains two points on two objects to remain at a fixed distance * - You can view this as a massless, rigid rod * @extends Box2dJoint * @memberof Box2D */ class Box2dDistanceJoint extends Box2dJoint { /** Create a distance joint * @param {Box2dObject} objectA * @param {Box2dObject} objectB * @param {Vector2} [anchorA] - World position, objectA's position if not given * @param {Vector2} [anchorB] - World position, objectB's position if not given * @param {boolean} [collide] */ constructor(objectA, objectB, anchorA, anchorB, collide=false) { anchorA ||= box2d.vec2From(objectA.body.GetPosition()); anchorB ||= box2d.vec2From(objectB.body.GetPosition()); const localAnchorA = objectA.worldToLocal(anchorA); const localAnchorB = objectB.worldToLocal(anchorB); const jointDef = new box2d.instance.b2DistanceJointDef(); jointDef.set_bodyA(objectA.body); jointDef.set_bodyB(objectB.body); jointDef.set_localAnchorA(box2dTemp(localAnchorA)); jointDef.set_localAnchorB(box2dTemp(localAnchorB)); jointDef.set_length(anchorA.distance(anchorB)); jointDef.set_collideConnected(collide); super(jointDef); } /** Get the local anchor point relative to objectA's origin * @return {Vector2} */ getLocalAnchorA() { return box2d.vec2From(this.box2dJoint.GetLocalAnchorA()); } /** Get the local anchor point relative to objectB's origin * @return {Vector2} */ getLocalAnchorB() { return box2d.vec2From(this.box2dJoint.GetLocalAnchorB()); } /** Set the length of the joint * @param {number} length */ setLength(length) { this.box2dJoint.SetLength(length); box2dWakeJoint(this.box2dJoint); } /** Get the length of the joint * @return {number} */ getLength() { return this.box2dJoint.GetLength(); } /** Set the frequency in Hertz * @param {number} hz */ setFrequency(hz) { this.box2dJoint.SetFrequency(hz); box2dWakeJoint(this.box2dJoint); } /** Get the frequency in Hertz * @return {number} */ getFrequency() { return this.box2dJoint.GetFrequency(); } /** Set how much the spring is damped, 0 for none and 1 to stop it bouncing * @param {number} ratio */ setDampingRatio(ratio) { this.box2dJoint.SetDampingRatio(ratio); box2dWakeJoint(this.box2dJoint); } /** Get how much the spring is damped, 0 for none and 1 to stop it bouncing * @return {number} */ getDampingRatio() { return this.box2dJoint.GetDampingRatio(); } } /////////////////////////////////////////////////////////////////////////////// /** * Box2D Rope Joint * - Enforces a maximum distance between two points on two objects * @extends Box2dJoint * @memberof Box2D */ class Box2dRopeJoint extends Box2dJoint { /** Create a rope joint * @param {Box2dObject} objectA * @param {Box2dObject} objectB * @param {Vector2} [anchorA] - World position, objectA's position if not given * @param {Vector2} [anchorB] - World position, objectB's position if not given * @param {number} [extraLength] * @param {boolean} [collide] */ constructor(objectA, objectB, anchorA, anchorB, extraLength=0, collide=false) { anchorA ||= box2d.vec2From(objectA.body.GetPosition()); anchorB ||= box2d.vec2From(objectB.body.GetPosition()); const localAnchorA = objectA.worldToLocal(anchorA); const localAnchorB = objectB.worldToLocal(anchorB); const jointDef = new box2d.instance.b2RopeJointDef(); jointDef.set_bodyA(objectA.body); jointDef.set_bodyB(objectB.body); jointDef.set_localAnchorA(box2dTemp(localAnchorA)); jointDef.set_localAnchorB(box2dTemp(localAnchorB)); jointDef.set_maxLength(anchorA.distance(anchorB)+extraLength); jointDef.set_collideConnected(collide); super(jointDef); } /** Get the local anchor point relative to objectA's origin * @return {Vector2} */ getLocalAnchorA() { return box2d.vec2From(this.box2dJoint.GetLocalAnchorA()); } /** Get the local anchor point relative to objectB's origin * @return {Vector2} */ getLocalAnchorB() { return box2d.vec2From(this.box2dJoint.GetLocalAnchorB()); } /** Set the max length of the joint * @param {number} length */ setMaxLength(length) { this.box2dJoint.SetMaxLength(length); box2dWakeJoint(this.box2dJoint); } /** Get the max length of the joint * @return {number} */ getMaxLength() { return this.box2dJoint.GetMaxLength(); } } /////////////////////////////////////////////////////////////////////////////// /** * Box2D Revolute Joint * - Constrains two objects to share a point while they are free to rotate around the point * - The relative rotation about the shared point is the joint angle * - You can limit the relative rotation with a joint limit * - You can use a motor to drive the relative rotation about the shared point * - A maximum motor torque is provided so that infinite forces are not generated * @extends Box2dJoint * @memberof Box2D */ class Box2dRevoluteJoint extends Box2dJoint { /** Create a revolute joint * @param {Box2dObject} objectA * @param {Box2dObject} objectB * @param {Vector2} [anchor] - World position, objectB's position if not given * @param {boolean} [collide] */ constructor(objectA, objectB, anchor, collide=false) { anchor ||= box2d.vec2From(objectB.body.GetPosition()); const localAnchorA = objectA.worldToLocal(anchor); const localAnchorB = objectB.worldToLocal(anchor); const jointDef = new box2d.instance.b2RevoluteJointDef(); jointDef.set_bodyA(objectA.body); jointDef.set_bodyB(objectB.body); jointDef.set_localAnchorA(box2dTemp(localAnchorA)); jointDef.set_localAnchorB(box2dTemp(localAnchorB)); jointDef.set_referenceAngle(objectB.body.GetAngle() - objectA.body.GetAngle()); jointDef.set_collideConnected(collide); super(jointDef); } /** Get the local anchor point relative to objectA's origin * @return {Vector2} */ getLocalAnchorA() { return box2d.vec2From(this.box2dJoint.GetLocalAnchorA()); } /** Get the local anchor point relative to objectB's origin * @return {Vector2} */ getLocalAnchorB() { return box2d.vec2From(this.box2dJoint.GetLocalAnchorB()); } /** Get the reference angle, objectB angle minus objectA angle in the reference state * @return {number} */ getReferenceAngle() { return -this.box2dJoint.GetReferenceAngle(); } // box2d uses reverse angle /** Get the current joint angle, clockwise like angle * @return {number} */ getJointAngle() { return -this.box2dJoint.GetJointAngle(); } /** Get the current joint angle speed in radians per second, clockwise like angle * @return {number} */ getJointSpeed() { return -this.box2dJoint.GetJointSpeed(); } /** Is the joint limit enabled? * @return {boolean} */ isLimitEnabled() { return this.box2dJoint.IsLimitEnabled(); } /** Enable/disable the joint limit * @param {boolean} [enable] */ enableLimit(enable=true) { this.box2dJoint.EnableLimit(enable); } /** Get the lower joint limit, clockwise like angle * @return {number} */ getLowerLimit() { return -this.box2dJoint.GetUpperLimit(); } // reversed, so Box2D's upper is the lower /** Get the upper joint limit, clockwise like angle * @return {number} */ getUpperLimit() { return -this.box2dJoint.GetLowerLimit(); } /** Set the joint limits, clockwise like angle * @param {number} min * @param {number} max */ setLimits(min, max) { ASSERT(min <= max, 'the lower limit must not be above the upper one'); min = box2dFinite(min, 0), max = box2dFinite(max, 0); // or on NaN, which passes the order check if (min > max) [min, max] = [max, min]; // Box2D stops on them reversed this.box2dJoint.SetLimits(-max, -min); } /** Is the joint motor enabled? * @return {boolean} */ isMotorEnabled() { return this.box2dJoint.IsMotorEnabled(); } /** Enable/disable the joint motor * @param {boolean} [enable] */ enableMotor(enable=true) { this.box2dJoint.EnableMotor(enable); } /** Set the motor speed, clockwise like angle * @param {number} speed */ setMotorSpeed(speed) { this.box2dJoint.SetMotorSpeed(-speed); } /** Get the motor speed, clockwise like angle * @return {number} */ getMotorSpeed() { return -this.box2dJoint.GetMotorSpeed(); } /** Set the max motor torque, a magnitude * @param {number} torque */ setMaxMotorTorque(torque) { this.box2dJoint.SetMaxMotorTorque(torque); } /** Get the most torque the motor can apply, a magnitude * @return {number} */ getMaxMotorTorque() { return this.box2dJoint.GetMaxMotorTorque(); } /** Get the motor torque over the last step, clockwise like angle * @param {number} [time] - The step length in seconds, the world steps by timeDelta * @return {number} */ getMotorTorque(time=timeDelta) { return -this.box2dJoint.GetMotorTorque(1/time); } } /////////////////////////////////////////////////////////////////////////////// /** * Box2D Pin Joint * - Pins two objects together at a point, where they still turn freely, like a nail through two boards * - A revolute joint at that point, so it holds exactly and its limits and motor work too * - new Box2dPinJoint(objectA, objectB, pos, collide): the point defaults to objectA's position, where a revolute * joint's anchor defaults to objectB's * @extends Box2dRevoluteJoint * @memberof Box2D */ class Box2dPinJoint extends Box2dRevoluteJoint { /** Create a pin joint * @param {Box2dObject} objectA * @param {Box2dObject} objectB * @param {Vector2} [pos] - World position, objectA's position if not given * @param {boolean} [collide] */ constructor(objectA, objectB, pos=objectA.pos, collide=false) { super(objectA, objectB, pos, collide); } } /////////////////////////////////////////////////////////////////////////////// /** * Box2D Gear Joint * - A gear joint is used to connect two joints together * - Either joint can be a revolute or prismatic joint * - You specify a gear ratio to bind the motions together * - joint1's angle or translation plus ratio times joint2's stays constant, angles clockwise like angle * - It is destroyed along with either joint, or an object either joint is on * - It turns objectB of each joint, so make each with its fixed or carrying object first, and a dynamic objectB * @extends Box2dJoint * @memberof Box2D */ class Box2dGearJoint extends Box2dJoint { /** Create a gear joint * @param {Box2dObject} objectA - objectB of joint1, Box2D joins that one whatever is passed * @param {Box2dObject} objectB - objectB of joint2, Box2D joins that one whatever is passed * @param {Box2dJoint} joint1 * @param {Box2dJoint} joint2 * @param {number} [ratio] */ constructor(objectA, objectB, joint1, joint2, ratio=1) { // Box2D's angles are reversed and its translations are not, so a revolute joint geared to a prismatic one // needs the ratio reversed too, two of a kind keep it const isGearable = (j)=> (j instanceof Box2dRevoluteJoint || j instanceof Box2dPrismaticJoint) && !!j.box2dJoint; ASSERT(isGearable(joint1) && isGearable(joint2), 'a gear joint needs two revolute or prismatic joints that exist'); // Box2D turns objectB of each joint, with objectA as its carrier, so a static objectB leaves the gear doing nothing ASSERT(joint1.getObjectB()?.getBodyType() === box2d.bodyTypeDynamic && joint2.getObjectB()?.getBodyType() === box2d.bodyTypeDynamic, 'a gear joint turns objectB of each joint, make each joint with its fixed or carrying object first'); ASSERT(objectA === joint1.getObjectB() && objectB === joint2.getObjectB(), 'a gear joint joins objectB of joint1 and objectB of joint2, pass those'); const ratioSign = (joint1 instanceof Box2dRevoluteJoint) === (joint2 instanceof Box2dRevoluteJoint) ? 1 : -1; const jointDef = new box2d.instance.b2GearJointDef(); jointDef.set_bodyA(objectA.body); jointDef.set_bodyB(objectB.body); jointDef.set_joint1(joint1.box2dJoint); jointDef.set_joint2(joint2.box2dJoint); jointDef.set_ratio(ratio * ratioSign); super(jointDef); this.joint1 = joint1; this.joint2 = joint2; this.ratioSign = ratioSign; box2dGearJoints.add(this); } /** Get the first joint * @return {Box2dJoint} */ getJoint1() { return this.joint1; } /** Get the second joint * @return {Box2dJoint} */ getJoint2() { return this.joint2; } /** Set the gear ratio * @param {number} ratio */ setRatio(ratio) { this.box2dJoint.SetRatio(ratio * this.ratioSign); box2dWakeJoint(this.box2dJoint); } /** Get the gear ratio * @return {number} */ getRatio() { return this.box2dJoint.GetRatio() * this.ratioSign; } } /////////////////////////////////////////////////////////////////////////////// /** * Box2D Prismatic Joint * - Provides one degree of freedom: translation along an axis fixed in objectA * - Relative rotation is prevented * - You can use a joint limit to restrict the range of motion * - You can use a joint motor to drive the motion or to model joint friction * @extends Box2dJoint * @memberof Box2D */ class Box2dPrismaticJoint extends Box2dJoint { /** Create a prismatic joint * @param {Box2dObject} objectA * @param {Box2dObject} objectB * @param {Vector2} [anchor] - World position, objectB's position if not given * @param {Vector2} [worldAxis] * @param {boolean} [collide] */ constructor(objectA, objectB, anchor, worldAxis=vec2(0,1), collide=false) { anchor ||= box2d.vec2From(objectB.body.GetPosition()); const localAnchorA = objectA.worldToLocal(anchor); const localAnchorB = objectB.worldToLocal(anchor); const localAxisA = objectA.worldToLocalVector(worldAxis); const jointDef = new box2d.instance.b2PrismaticJointDef(); jointDef.set_bodyA(objectA.body); jointDef.set_bodyB(objectB.body); jointDef.set_localAnchorA(box2dTemp(localAnchorA)); jointDef.set_localAnchorB(box2dTemp(localAnchorB)); jointDef.set_localAxisA(box2dTemp(localAxisA)); jointDef.set_referenceAngle(objectB.body.GetAngle() - objectA.body.GetAngle()); jointDef.set_collideConnected(collide); super(jointDef); } /** Get the local anchor point relative to objectA's origin * @return {Vector2} */ getLocalAnchorA() { return box2d.vec2From(this.box2dJoint.GetLocalAnchorA()); } /** Get the local anchor point relative to objectB's origin * @return {Vector2} */ getLocalAnchorB() { return box2d.vec2From(this.box2dJoint.GetLocalAnchorB()); } /** Get the local joint axis relative to bodyA * @return {Vector2} */ getLocalAxisA() { return box2d.vec2From(this.box2dJoint.GetLocalAxisA()); } /** Get the reference angle, objectB angle minus objectA angle in the reference state * @return {number} */ getReferenceAngle() { return -this.box2dJoint.GetReferenceAngle(); } // box2d uses reverse angle /** Get the current joint translation * @return {number} */ getJointTranslation() { return this.box2dJoint.GetJointTranslation(); } /** Get the current joint translation speed * @return {number} */ getJointSpeed() { return this.box2dJoint.GetJointSpeed(); } /** Is the joint limit enabled? * @return {boolean} */ isLimitEnabled() { return this.box2dJoint.IsLimitEnabled(); } /** Enable/disable the joint limit * @param {boolean} [enable] */ enableLimit(enable=true) { this.box2dJoint.EnableLimit(enable); } /** Get the lower joint limit * @return {number} */ getLowerLimit() { return this.box2dJoint.GetLowerLimit(); } /** Get the upper joint limit * @return {number} */ getUpperLimit() { return this.box2dJoint.GetUpperLimit(); } /** Set the joint limits * @param {number} min * @param {number} max */ setLimits(min, max) { ASSERT(min <= max, 'the lower limit must not be above the upper one'); min = box2dFinite(min, 0), max = box2dFinite(max, 0); // or on NaN, which passes the order check if (min > max) [min, max] = [max, min]; // Box2D stops on them reversed this.box2dJoint.SetLimits(min, max); } /** Is the motor enabled? * @return {boolean} */ isMotorEnabled() { return this.box2dJoint.IsMotorEnabled(); } /** Enable/disable the joint motor * @param {boolean} [enable] */ enableMotor(enable=true) { this.box2dJoint.EnableMotor(enable); } /** Set the speed the motor drives the bodies apart along the axis, in meters per second * @param {number} speed */ setMotorSpeed(speed) { this.box2dJoint.SetMotorSpeed(speed); } /** Get the speed the motor drives the bodies apart along the axis, in meters per second * @return {number} */ getMotorSpeed() { return this.box2dJoint.GetMotorSpeed(); } /** Set the maximum motor force * @param {number} force */ setMaxMotorForce(force) { this.box2dJoint.SetMaxMotorForce(force); } /** Get the maximum motor force * @return {number} */ getMaxMotorForce() { return this.box2dJoint.GetMaxMotorForce(); } /** Get the motor force over the last step * @param {number} [time] - The step length in seconds, the world steps by timeDelta * @return {number} */ getMotorForce(time=timeDelta) { return this.box2dJoint.GetMotorForce(1/time); } } /////////////////////////////////////////////////////////////////////////////// /** * Box2D Wheel Joint * - Provides two degrees of freedom: translation along an axis fixed in objectA and rotation * - You can use a joint motor to drive the motion or to model joint friction * - This joint is designed for vehicle suspensions * @extends Box2dJoint * @memberof Box2D */ class Box2dWheelJoint extends Box2dJoint { /** Create a wheel joint * @param {Box2dObject} objectA * @param {Box2dObject} objectB * @param {Vector2} [anchor] - World position, objectB's position if not given * @param {Vector2} [worldAxis] * @param {boolean} [collide] */ constructor(objectA, objectB, anchor, worldAxis=vec2(0,1), collide=false) { anchor ||= box2d.vec2From(objectB.body.GetPosition()); const localAnchorA = objectA.worldToLocal(anchor); const localAnchorB = objectB.worldToLocal(anchor); const localAxisA = objectA.worldToLocalVector(worldAxis).normalize(); // Box2D uses the wheel axis as given const jointDef = new box2d.instance.b2WheelJointDef(); jointDef.set_bodyA(objectA.body); jointDef.set_bodyB(objectB.body); jointDef.set_localAnchorA(box2dTemp(localAnchorA)); jointDef.set_localAnchorB(box2dTemp(localAnchorB)); jointDef.set_localAxisA(box2dTemp(localAxisA)); jointDef.set_collideConnected(collide); super(jointDef); } /** Get the local anchor point relative to objectA's origin * @return {Vector2} */ getLocalAnchorA() { return box2d.vec2From(this.box2dJoint.GetLocalAnchorA()); } /** Get the local anchor point relative to objectB's origin * @return {Vector2} */ getLocalAnchorB() { return box2d.vec2From(this.box2dJoint.GetLocalAnchorB()); } /** Get the local joint axis relative to bodyA * @return {Vector2} */ getLocalAxisA() { return box2d.vec2From(this.box2dJoint.GetLocalAxisA()); } /** Get the current joint translation * @return {number} */ getJointTranslation() { return this.box2dJoint.GetJointTranslation(); } /** Get the current joint rotation speed in radians per second, clockwise like angle, * which is what this version of Box2D measures for a wheel joint * @return {number} */ getJointSpeed() { return -this.box2dJoint.GetJointSpeed(); } // box2d uses reverse angle /** Is the joint motor enabled? * @return {boolean} */ isMotorEnabled() { return this.box2dJoint.IsMotorEnabled(); } /** Enable/disable the joint motor * @param {boolean} [enable] */ enableMotor(enable=true) { this.box2dJoint.EnableMotor(enable); } /** Set the motor speed, the wheel's turn in radians per second, clockwise like angle * @param {number} speed */ setMotorSpeed(speed) { this.box2dJoint.SetMotorSpeed(-speed); } /** Get the motor speed, clockwise like angle * @return {number} */ getMotorSpeed() { return -this.box2dJoint.GetMotorSpeed(); } /** Set the maximum motor torque, a magnitude * @param {number} torque */ setMaxMotorTorque(torque) { this.box2dJoint.SetMaxMotorTorque(torque); } /** Get the most torque the wheel's motor can apply, a magnitude * @return {number} */ getMaxMotorTorque() { return this.box2dJoint.GetMaxMotorTorque(); } /** Get the motor torque over the last step, clockwise like angle * @param {number} [time] - The step length in seconds, the world steps by timeDelta * @return {number} */ getMotorTorque(time=timeDelta) { return -this.box2dJoint.GetMotorTorque(1/time); } /** Set the spring frequency in Hertz * @param {number} hz */ setSpringFrequencyHz(hz) { this.box2dJoint.SetSpringFrequencyHz(hz); box2dWakeJoint(this.box2dJoint); } /** Get the spring frequency in Hertz * @return {number} */ getSpringFrequencyHz() { return this.box2dJoint.GetSpringFrequencyHz(); } /** Set how much the suspension spring is damped, 0 for none and 1 to stop it bouncing * @param {number} ratio */ setSpringDampingRatio(ratio) { this.box2dJoint.SetSpringDampingRatio(ratio); box2dWakeJoint(this.box2dJoint); } /** Get how much the suspension spring is damped, 0 for none and 1 to stop it bouncing * @return {number} */ getSpringDampingRatio() { return this.box2dJoint.GetSpringDampingRatio(); } } /////////////////////////////////////////////////////////////////////////////// /** * Box2D Weld Joint * - Glues two objects together * @extends Box2dJoint * @memberof Box2D */ class Box2dWeldJoint extends Box2dJoint { /** Create a weld joint * @param {Box2dObject} objectA * @param {Box2dObject} objectB * @param {Vector2} [anchor] - World position, objectB's position if not given * @param {boolean} [collide] */ constructor(objectA, objectB, anchor, collide=false) { anchor ||= box2d.vec2From(objectB.body.GetPosition()); const localAnchorA = objectA.worldToLocal(anchor); const localAnchorB = objectB.worldToLocal(anchor); const referenceAngle = objectB.body.GetAngle() - objectA.body.GetAngle(); const jointDef = new box2d.instance.b2WeldJointDef(); jointDef.set_bodyA(objectA.body); jointDef.set_bodyB(objectB.body); jointDef.set_localAnchorA(box2dTemp(localAnchorA)); jointDef.set_localAnchorB(box2dTemp(localAnchorB)); jointDef.set_referenceAngle(referenceAngle); jointDef.set_collideConnected(collide); super(jointDef); // kept here, since the binding cannot read it back from a weld joint; box2d uses reverse angle this.referenceAngle = -referenceAngle; } /** Get the local anchor point relative to objectA's origin * @return {Vector2} */ getLocalAnchorA() { return box2d.vec2From(this.box2dJoint.GetLocalAnchorA()); } /** Get the local anchor point relative to objectB's origin * @return {Vector2} */ getLocalAnchorB() { return box2d.vec2From(this.box2dJoint.GetLocalAnchorB()); } /** Get the reference angle, objectB angle minus objectA angle in the reference state * @return {number} */ getReferenceAngle() { return this.referenceAngle; } /** Set the frequency in Hertz * @param {number} hz */ setFrequency(hz) { this.box2dJoint.SetFrequency(hz); box2dWakeJoint(this.box2dJoint); } /** Get the frequency in Hertz * @return {number} */ getFrequency() { return this.box2dJoint.GetFrequency(); } /** Set how much the weld's spring is damped, 0 for none and 1 to stop it bouncing * @param {number} ratio */ setDampingRatio(ratio) { this.box2dJoint.SetDampingRatio(ratio); box2dWakeJoint(this.box2dJoint); } /** Get how much the weld's spring is damped, 0 for none and 1 to stop it bouncing * @return {number} */ getDampingRatio() { return this.box2dJoint.GetDampingRatio(); } } /////////////////////////////////////////////////////////////////////////////// /** * Box2D Friction Joint * - Used to apply top-down friction * - Provides 2D translational friction and angular friction * @extends Box2dJoint * @memberof Box2D */ class Box2dFrictionJoint extends Box2dJoint { /** Create a friction joint * @param {Box2dObject} objectA * @param {Box2dObject} objectB * @param {Vector2} [anchor] - World position, objectB's position if not given * @param {boolean} [collide] */ constructor(objectA, objectB, anchor, collide=false) { anchor ||= box2d.vec2From(objectB.body.GetPosition()); const localAnchorA = objectA.worldToLocal(anchor); const localAnchorB = objectB.worldToLocal(anchor); const jointDef = new box2d.instance.b2FrictionJointDef(); jointDef.set_bodyA(objectA.body); jointDef.set_bodyB(objectB.body); jointDef.set_localAnchorA(box2dTemp(localAnchorA)); jointDef.set_localAnchorB(box2dTemp(localAnchorB)); jointDef.set_collideConnected(collide); super(jointDef); } /** Get the local anchor point relative to objectA's origin * @return {Vector2} */ getLocalAnchorA() { return box2d.vec2From(this.box2dJoint.GetLocalAnchorA()); } /** Get the local anchor point relative to objectB's origin * @return {Vector2} */ getLocalAnchorB() { return box2d.vec2From(this.box2dJoint.GetLocalAnchorB()); } /** Set the maximum friction force * @param {number} force */ setMaxForce(force) { this.box2dJoint.SetMaxForce(max(box2dFinite(force, 0), 0)); } // Box2D stops on a negative one /** Get the maximum friction force * @return {number} */ getMaxForce() { return this.box2dJoint.GetMaxForce(); } /** Set the maximum friction torque * @param {number} torque */ setMaxTorque(torque) { this.box2dJoint.SetMaxTorque(max(box2dFinite(torque, 0), 0)); } // Box2D stops on a negative one /** Get the maximum friction torque * @return {number} */ getMaxTorque() { return this.box2dJoint.GetMaxTorque(); } } /////////////////////////////////////////////////////////////////////////////// /** * Box2D Pulley Joint * - Connects to two objects and two fixed ground points * - The pulley supports a ratio such that: length1 + ratio * length2 <= constant * - The force transmitted is scaled by the ratio * @extends Box2dJoint * @memberof Box2D */ class Box2dPulleyJoint extends Box2dJoint { /** Create a pulley joint * @param {Box2dObject} objectA * @param {Box2dObject} objectB * @param {Vector2} groundAnchorA * @param {Vector2} groundAnchorB * @param {Vector2} [anchorA] - World position, objectA's position if not given * @param {Vector2} [anchorB] - World position, objectB's position if not given * @param {number} [ratio] * @param {boolean} [collide] */ constructor(objectA, objectB, groundAnchorA, groundAnchorB, anchorA, anchorB, ratio=1, collide=false) { anchorA ||= box2d.vec2From(objectA.body.GetPosition()); anchorB ||= box2d.vec2From(objectB.body.GetPosition()); const localAnchorA = objectA.worldToLocal(anchorA); const localAnchorB = objectB.worldToLocal(anchorB); const jointDef = new box2d.instance.b2PulleyJointDef(); jointDef.set_bodyA(objectA.body); jointDef.set_bodyB(objectB.body); jointDef.set_groundAnchorA(box2dTemp(groundAnchorA)); jointDef.set_groundAnchorB(box2dTemp(groundAnchorB)); jointDef.set_localAnchorA(box2dTemp(localAnchorA)); jointDef.set_localAnchorB(box2dTemp(localAnchorB)); ASSERT(ratio, 'a pulley ratio can not be 0'); jointDef.set_ratio(box2dFinite(ratio, 1) || 1); // Box2D stops for good on 0 jointDef.set_lengthA(groundAnchorA.distance(anchorA)); jointDef.set_lengthB(groundAnchorB.distance(anchorB)); jointDef.set_collideConnected(collide); super(jointDef); } /** Get the first ground anchor * @return {Vector2} */ getGroundAnchorA() { return box2d.vec2From(this.box2dJoint.GetGroundAnchorA()); } /** Get the second ground anchor * @return {Vector2} */ getGroundAnchorB() { return box2d.vec2From(this.box2dJoint.GetGroundAnchorB()); } /** Get the rest length of the segment attached to objectA, set at creation * @return {number} */ getLengthA() { return this.box2dJoint.GetLengthA(); } /** Get the rest length of the segment attached to objectB, set at creation * @return {number} */ getLengthB(){ return this.box2dJoint.GetLengthB(); } /** Get the pulley ratio * @return {number} */ getRatio() { return this.box2dJoint.GetRatio(); } /** Get the current length of the segment attached to objectA * @return {number} */ getCurrentLengthA() { return this.box2dJoint.GetCurrentLengthA(); } /** Get the current length of the segment attached to objectB * @return {number} */ getCurrentLengthB() { return this.box2dJoint.GetCurrentLengthB(); } } /////////////////////////////////////////////////////////////////////////////// /** * Box2D Motor Joint * - Controls the relative motion between two objects * - Typical usage is to control the movement of a object with respect to the ground * @extends Box2dJoint * @memberof Box2D */ class Box2dMotorJoint extends Box2dJoint { /** Create a motor joint * @param {Box2dObject} objectA * @param {Box2dObject} objectB */ constructor(objectA, objectB) { const linearOffset = objectA.worldToLocal(box2d.vec2From(objectB.body.GetPosition())); const angularOffset = objectB.body.GetAngle() - objectA.body.GetAngle(); const jointDef = new box2d.instance.b2MotorJointDef(); jointDef.set_bodyA(objectA.body); jointDef.set_bodyB(objectB.body); jointDef.set_linearOffset(box2dTemp(linearOffset)); jointDef.set_angularOffset(angularOffset); super(jointDef); } /** Set the target linear offset, in frame A, in meters * @param {Vector2} offset */ setLinearOffset(offset) { this.box2dJoint.SetLinearOffset(box2dTemp(offset)); } /** Get the target linear offset, in frame A, in meters * @return {Vector2} */ getLinearOffset() { return box2d.vec2From(this.box2dJoint.GetLinearOffset()); } /** Set the target angular offset, objectB angle minus objectA angle, clockwise like angle * @param {number} offset */ setAngularOffset(offset) { this.box2dJoint.SetAngularOffset(-offset); } // box2d uses reverse angle /** Get the target angular offset, objectB angle minus objectA angle, clockwise like angle * @return {number} */ getAngularOffset() { return -this.box2dJoint.GetAngularOffset(); } /** Set the maximum force * @param {number} force */ setMaxForce(force) { this.box2dJoint.SetMaxForce(max(box2dFinite(force, 0), 0)); box2dWakeJoint(this.box2dJoint); } // Box2D stops on a negative one /** Get the maximum force * @return {number} */ getMaxForce() { return this.box2dJoint.GetMaxForce(); } /** Set the maximum torque * @param {number} torque */ setMaxTorque(torque) { this.box2dJoint.SetMaxTorque(max(box2dFinite(torque, 0), 0)); box2dWakeJoint(this.box2dJoint); } // Box2D stops on a negative one /** Get the maximum torque * @return {number} */ getMaxTorque() { return this.box2dJoint.GetMaxTorque(); } /** Set the position correction factor in the range [0,1] * @param {number} factor */ setCorrectionFactor(factor) { this.box2dJoint.SetCorrectionFactor(clamp(factor)); box2dWakeJoint(this.box2dJoint); } /** Get the position correction factor in the range [0,1] * @return {number} */ getCorrectionFactor() { return this.box2dJoint.GetCorrectionFactor(); } } /////////////////////////////////////////////////////////////////////////////// /** * Box2D Global Object * - Wraps Box2d world and provides global functions * @memberof Box2D */ class Box2dPlugin { /** Create the global Box2D plugin object, box2dInit does this * @param {Object} instance */ constructor(instance) { ASSERT(!box2d, 'Box2D already initialized'); box2d = this; /** @property {Object} - The Box2d instance */ this.instance = instance; /** @property {Object} - The Box2d world */ this.world = new box2d.instance.b2World(); /** @property {Array} - List of all Box2d objects, a destroyed one stays until the next step * with its body undefined * @type {Array} */ this.objects = []; /** @property {number} - Velocity iterations per update*/ this.velocityIterations = 8; /** @property {number} - Position iterations per update*/ this.positionIterations = 3; /** @property {number} - Static, zero mass, zero velocity, may be manually moved * @type {number} */ this.bodyTypeStatic = instance.b2_staticBody; /** @property {number} - Kinematic, zero mass, non-zero velocity set by user, moved by solver * @type {number} */ this.bodyTypeKinematic = instance.b2_kinematicBody; /** @property {number} - Dynamic, positive mass, non-zero velocity determined by forces, moved by solver * @type {number} */ this.bodyTypeDynamic = instance.b2_dynamicBody; // a body that goes takes its joints with it, their wrappers let go of them, and a gear joint on one of them // goes too, since it would keep pointers to what was freed; that destroy waits in the queue, which DestroyBody // runs from, and is skipped if the gear hung off this same body and went with it const destructionListener = new box2d.instance.JSDestructionListener(); destructionListener.SayGoodbyeJoint = function(jointPointer) { const joint = box2dJoints.get(jointPointer); if (joint) { box2dDestroyGears(joint); joint.box2dJoint = 0; } box2dJoints.delete(jointPointer); }; destructionListener.SayGoodbyeFixture = function() {}; box2d.world.SetDestructionListener(destructionListener); // setup contact listener const listener = new box2d.instance.JSContactListener(); listener.BeginContact = function(contactPtr) { const contact = box2d.instance.wrapPointer(contactPtr, box2d.instance.b2Contact); const fixtureA = contact.GetFixtureA(); const fixtureB = contact.GetFixtureB(); const objectA = fixtureA.GetBody().object; const objectB = fixtureB.GetBody().object; // raw user-created b2Bodies may have no .object — skip those if (!objectA || !objectB) return; const impactSpeed = box2dImpactSpeed(contact); objectA.beginContact(objectB, fixtureA, fixtureB, impactSpeed); objectB.beginContact(objectA, fixtureB, fixtureA, impactSpeed); } listener.EndContact = function(contactPtr) { const contact = box2d.instance.wrapPointer(contactPtr, box2d.instance.b2Contact); const fixtureA = contact.GetFixtureA(); const fixtureB = contact.GetFixtureB(); const objectA = fixtureA.GetBody().object; const objectB = fixtureB.GetBody().object; if (!objectA || !objectB) return; objectA.endContact(objectB, fixtureA, fixtureB); objectB.endContact(objectA, fixtureB, fixtureA); }; listener.PreSolve = function() {}; listener.PostSolve = function() {}; box2d.world.SetContactListener(listener); } /** Step the physics world simulation * @param {number} [frames] */ step(frames=1) { // the engine's gravity, Box2D does not wake a sleeping body for a new one, so a change wakes them all if (gravity.x !== box2dGravity.x || gravity.y !== box2dGravity.y) { box2dGravity.x = gravity.x, box2dGravity.y = gravity.y; box2d.world.SetGravity(box2dTemp(gravity)); for (let b = box2d.world.GetBodyList(); !box2d.isNull(b); b = b.GetNext()) b.SetAwake(true); } for (let i=frames; i--;) { box2d.world.Step(timeDelta, this.velocityIterations, this.positionIterations); // what a contact callback destroyed or changed, now the world can take it box2dRunPending(); } // remove destroyed objects, once for all of them this.objects = this.objects.filter(o=>!o.destroyed); } /////////////////////////////////////////////////////////////////////////////// // raycasting and querying /** raycast and return a list of all the results, nearest first * @param {Vector2} start * @param {Vector2} end * @param {boolean} [includeSensors] - Also hit sensors, trigger zones are passed through by default * @return {Array} */ raycastAll(start, end, includeSensors=false) { // a ray with no length fails an assert that stops Box2D for good, measured as Box2D does in 32 bit floats, // where two ends a float apart are one point; one that is not a number has no length either const f = Math.fround, dx = f(f(end.x) - f(start.x)), dy = f(f(end.y) - f(start.y)); const lengthSquared = f(f(dx*dx) + f(dy*dy)); if (!(lengthSquared > 0 && lengthSquared < Infinity)) return []; const raycastCallback = box2dQueryObject('rayCast', 'JSRayCastCallback'); raycastCallback.ReportFixture = function(fixturePointer, point, normal, fraction) { const fixture = box2d.instance.wrapPointer(fixturePointer, box2d.instance.b2Fixture); const o = fixture.GetBody().object; if (!o || o.destroyed) return 1; // a raw body with no Box2dObject or one destroyed this step, continue getting results if (!includeSensors && fixture.IsSensor()) return -1; // skip it, Box2D goes on as if it were not there point = box2d.vec2FromPointer(point); normal = box2d.vec2FromPointer(normal); raycastResults.push(new Box2dRaycastResult(fixture, point, normal, fraction)); return 1; // continue getting results }; const raycastResults = []; box2d.world.RayCast(raycastCallback, box2dTemp(start), box2dTemp(end, 1)); raycastResults.sort((a,b)=> a.fraction - b.fraction); // Box2D reports them in its tree's order debugRaycast && debugLine(start, end, raycastResults.length ? '#f00' : '#00f', .02, 0, false); return raycastResults; } /** raycast and return the first result * @param {Vector2} start * @param {Vector2} end * @param {boolean} [includeSensors] - Also hit sensors, trigger zones are passed through by default * @return {Box2dRaycastResult|undefined} */ raycast(start, end, includeSensors=false) { return box2d.raycastAll(start, end, includeSensors)[0]; } /** Every object whose shapes' bounding boxes overlap a box, not the shapes themselves, so near the corner of a * turned box or a circle it finds one the box does not touch; use pointCast or a raycast for exact * @param {Vector2} pos * @param {Vector2} size * @param {boolean} [includeSensors] - Also find sensors, trigger zones are passed through by default * @return {Array} */ boxCastAll(pos, size, includeSensors=false) { const queryCallback = box2dQueryObject('query', 'JSQueryCallback'); queryCallback.ReportFixture = function(fixturePointer) { const fixture = box2d.instance.wrapPointer(fixturePointer, box2d.instance.b2Fixture); if (!includeSensors && fixture.IsSensor()) return true; // a trigger zone, continue getting results const o = fixture.GetBody().object; if (o && !o.destroyed && !queryObjects.includes(o) // skip raw bodies and ones destroyed this step && box2dFixtureOverlaps(fixture, aabb)) queryObjects.push(o); // add if not already in list return true; // continue getting results }; const aabb = box2dQueryObject('aabb', 'b2AABB'); aabb.set_lowerBound(box2dTemp(pos.subtract(size.scale(.5)))); aabb.set_upperBound(box2dTemp(pos.add(size.scale(.5)))); let queryObjects = []; box2d.world.QueryAABB(queryCallback, aabb); debugRaycast && debugRect(pos, size, queryObjects.length ? '#f00' : '#00f', 0, 0, false, false); return queryObjects; } /** The first object whose shapes' bounding boxes overlap a box, not the shapes themselves, so near the corner of * a turned box or a circle it finds one the box does not touch; use pointCast or a raycast for exact * @param {Vector2} pos * @param {Vector2} size * @param {boolean} [includeSensors] - Also find sensors, trigger zones are passed through by default * @return {Box2dObject|undefined} */ boxCast(pos, size, includeSensors=false) { const queryCallback = box2dQueryObject('query', 'JSQueryCallback'); queryCallback.ReportFixture = function(fixturePointer) { const fixture = box2d.instance.wrapPointer(fixturePointer, box2d.instance.b2Fixture); if (!includeSensors && fixture.IsSensor()) return true; // a trigger zone, continue getting results const o = fixture.GetBody().object; if (!o || o.destroyed) return true; // a raw body with no Box2dObject or one destroyed this step, continue getting results if (!box2dFixtureOverlaps(fixture, aabb)) return true; // only near the box, continue getting results queryObject = o; return false; // stop getting results }; const aabb = box2dQueryObject('aabb', 'b2AABB'); aabb.set_lowerBound(box2dTemp(pos.subtract(size.scale(.5)))); aabb.set_upperBound(box2dTemp(pos.add(size.scale(.5)))); let queryObject; box2d.world.QueryAABB(queryCallback, aabb); debugRaycast && debugRect(pos, size, queryObject ? '#f00' : '#00f', 0, 0, false, false); return queryObject; } /** circle cast and return all the objects whose position is within the circle, wherever their shapes are * @param {Vector2} pos * @param {number} diameter * @param {boolean} [includeSensors] - Also find objects whose shapes are all sensors, like trigger zones * @return {Array} */ circleCastAll(pos, diameter, includeSensors=false) { // by each object's position, so an object whose shapes are offset from it is still found const radius2 = (diameter/2)**2; const results = box2d.objects.filter(o=> !o.destroyed && o.body && o.pos.distanceSquared(pos) < radius2 && (includeSensors || o.getFixtureList().some(fixture=> !fixture.IsSensor()))); debugRaycast && debugCircle(pos, diameter, results.length ? '#f00' : '#00f', 0, false, false); return results; } /** circle cast and return the object whose position is nearest, of those within the circle * @param {Vector2} pos * @param {number} diameter * @param {boolean} [includeSensors] - Also find objects whose shapes are all sensors, like trigger zones * @return {Box2dObject|undefined} */ circleCast(pos, diameter, includeSensors=false) { let bestResult, bestDistance2; for (const result of box2d.circleCastAll(pos, diameter, includeSensors)) { const distance2 = result.pos.distanceSquared(pos); if (!bestResult || distance2 < bestDistance2) { bestResult = result; bestDistance2 = distance2; } } return bestResult; } /** point cast and return the first object * @param {Vector2} pos * @param {boolean} [dynamicOnly] * @param {boolean} [includeSensors] - Also find sensors, so a pickup radius does not grab its object from afar * @return {Box2dObject|undefined} */ pointCast(pos, dynamicOnly=true, includeSensors=false) { const queryCallback = box2dQueryObject('query', 'JSQueryCallback'); queryCallback.ReportFixture = function(fixturePointer) { const fixture = box2d.instance.wrapPointer(fixturePointer, box2d.instance.b2Fixture); if (!includeSensors && fixture.IsSensor()) return true; // a trigger zone, continue getting results if (dynamicOnly && fixture.GetBody().GetType() !== box2d.bodyTypeDynamic) return true; // continue getting results if (!fixture.TestPoint(box2dTemp(pos))) return true; // continue getting results const o = fixture.GetBody().object; if (!o || o.destroyed) return true; // a raw body with no Box2dObject or one destroyed this step, continue getting results queryObject = o; return false; // stop getting results }; const aabb = box2dQueryObject('aabb', 'b2AABB'); aabb.set_lowerBound(box2dTemp(pos)); aabb.set_upperBound(box2dTemp(pos)); let queryObject; box2d.world.QueryAABB(queryCallback, aabb); debugRaycast && debugRect(pos, vec2(), queryObject ? '#f00' : '#00f', 0, 0, false, false); return queryObject; } /////////////////////////////////////////////////////////////////////////////// // drawing /** draws a fixture * @param {Object} fixture * @param {Vector2} pos * @param {number} angle * @param {Color} [color] * @param {Color} [lineColor] * @param {number} [lineWidth] * @param {boolean} [useWebGL=glEnable] * @param {CanvasRenderingContext2D} [context] */ drawFixture(fixture, pos, angle, color=WHITE, lineColor=BLACK, lineWidth=.1, useWebGL, context) { const shape = box2d.castShapeObject(fixture.GetShape()); switch (shape.GetType()) { case box2d.instance.b2Shape.e_polygon: { let points = []; for (let i=shape.GetVertexCount(); i--;) points.push(box2d.vec2From(shape.GetVertex(i))); drawPoly(points, color, lineWidth, lineColor, pos, angle, useWebGL, false, context); break; } case box2d.instance.b2Shape.e_circle: { const radius = shape.get_m_radius(), offset = box2d.vec2From(shape.get_m_p()); drawCircle(pos.add(offset.rotate(angle)), radius*2, color, lineWidth, lineColor, useWebGL, false, context); break; } case box2d.instance.b2Shape.e_edge: { const v1 = box2d.vec2From(shape.get_m_vertex1()); const v2 = box2d.vec2From(shape.get_m_vertex2()); drawLine(v1, v2, lineWidth, lineColor, pos, angle, useWebGL, false, context); break; } } } /////////////////////////////////////////////////////////////////////////////// // helper functions /** converts a box2d vec2 to a Vector2 * @param {Object} v * @return {Vector2} */ vec2From(v) { ASSERT(v instanceof box2d.instance.b2Vec2, 'vec2From: expected a b2Vec2', v); return new Vector2(v.get_x(), v.get_y()); } /** converts a box2d vec2 pointer to a Vector2 * @param {Object} vp * @return {Vector2} */ vec2FromPointer(vp) { const v = box2d.instance.wrapPointer(vp, box2d.instance.b2Vec2); return box2d.vec2From(v); } /** converts a Vector2 to a new box2d vec2, which stays until destroyed with box2d.instance.destroy; * the plugin itself passes Box2D reused ones, since Box2D copies every vector it is given * @param {Vector2} v * @return {Object} - A Box2D vector, its b2Vec2 */ vec2dTo(v) { ASSERT(isVector2(v), 'vec2dTo: expected a vec2', v); return new box2d.instance.b2Vec2(v.x, v.y); } /** checks if a box2d object is null * @param {Object} o * @return {boolean} */ isNull(o) { return !box2d.instance.getPointer(o); } /** casts a box2d object to a shape type * @param {Object} o * @return {Object} - The shape as its own Box2D type */ castShapeObject(o) { switch (o.GetType()) { case box2d.instance.b2Shape.e_circle: return box2d.instance.castObject(o, box2d.instance.b2CircleShape); case box2d.instance.b2Shape.e_edge: return box2d.instance.castObject(o, box2d.instance.b2EdgeShape); case box2d.instance.b2Shape.e_polygon: return box2d.instance.castObject(o, box2d.instance.b2PolygonShape); case box2d.instance.b2Shape.e_chain: return box2d.instance.castObject(o, box2d.instance.b2ChainShape); } ASSERT(false, 'Unknown box2d object type'); } /** casts a box2d object to a joint type * @param {Object} o * @return {Object} - The joint as its own Box2D type */ castJointObject(o) { switch (o.GetType()) { case box2d.instance.e_revoluteJoint: return box2d.instance.castObject(o, box2d.instance.b2RevoluteJoint); case box2d.instance.e_prismaticJoint: return box2d.instance.castObject(o, box2d.instance.b2PrismaticJoint); case box2d.instance.e_distanceJoint: return box2d.instance.castObject(o, box2d.instance.b2DistanceJoint); case box2d.instance.e_pulleyJoint: return box2d.instance.castObject(o, box2d.instance.b2PulleyJoint); case box2d.instance.e_mouseJoint: return box2d.instance.castObject(o, box2d.instance.b2MouseJoint); case box2d.instance.e_gearJoint: return box2d.instance.castObject(o, box2d.instance.b2GearJoint); case box2d.instance.e_wheelJoint: return box2d.instance.castObject(o, box2d.instance.b2WheelJoint); case box2d.instance.e_weldJoint: return box2d.instance.castObject(o, box2d.instance.b2WeldJoint); case box2d.instance.e_frictionJoint: return box2d.instance.castObject(o, box2d.instance.b2FrictionJoint); case box2d.instance.e_ropeJoint: return box2d.instance.castObject(o, box2d.instance.b2RopeJoint); case box2d.instance.e_motorJoint: return box2d.instance.castObject(o, box2d.instance.b2MotorJoint); } ASSERT(false, 'Unknown box2d object type'); } } /////////////////////////////////////////////////////////////////////////////// /** Box2d Init - Call with await to init box2d * @example * await box2dInit(); * @return {Promise} * @memberof Box2D */ async function box2dInit() { // load box2d // @ts-ignore - Box2D is the global that box2d.wasm.js defines new Box2dPlugin(await Box2D()); setupDebugDraw(); engineAddPlugin(box2dUpdate, box2dRender); return box2d; // add the box2d plugin to the engine function box2dUpdate() { // frozen like the engine objects while paused or time is stopped if (paused || !timeScale) { // what was destroyed while frozen leaves the list, the step does it otherwise box2d.objects = box2d.objects.filter(o=>!o.destroyed); return; } box2d.step(); // copy box2d physics results to engine objects for (const o of box2d.objects) { if (o.body) { debug && box2dCheckUnusedFields(o); // moved in place, as the engine moves an EngineObject's pos, so what holds it follows the body; // box2d uses reverse angle const p = o.body.GetPosition(); o.pos.set(p.get_x(), p.get_y()); o.angle = -o.body.GetAngle(); } } } function box2dRender() { if (box2dDebug || debugPhysics) box2d.world.DrawDebugData(); } // box2d debug drawing function setupDebugDraw() { // setup debug draw const debugLineWidth = .1; const debugDraw = new box2d.instance.JSDraw(); const box2dColor = (c)=> new Color(c.get_r(), c.get_g(), c.get_b()); const box2dColorPointer = (c)=> box2dColor(box2d.instance.wrapPointer(c, box2d.instance.b2Color)); const getDebugColor = (color)=>box2dColorPointer(color).scale(1,.8); const getPointsList = (vertices, vertexCount)=> { const points = []; for (let i=vertexCount; i--;) points.push(box2d.vec2FromPointer(vertices+i*8)); return points; } debugDraw.DrawSegment = function(point1, point2, color) { color = getDebugColor(color); point1 = box2d.vec2FromPointer(point1); point2 = box2d.vec2FromPointer(point2); drawLine(point1, point2, debugLineWidth, color, vec2(), 0, false, false); }; debugDraw.DrawPolygon = function(vertices, vertexCount, color) { color = getDebugColor(color); const points = getPointsList(vertices, vertexCount); drawPoly(points, CLEAR_WHITE, debugLineWidth, color, vec2(), 0, false, false); }; debugDraw.DrawSolidPolygon = function(vertices, vertexCount, color) { color = getDebugColor(color); const points = getPointsList(vertices, vertexCount); drawPoly(points, color, 0, color, vec2(), 0, false, false); }; debugDraw.DrawCircle = function(center, radius, color) { color = getDebugColor(color); center = box2d.vec2FromPointer(center); drawCircle(center, radius*2, CLEAR_WHITE, debugLineWidth, color, false, false); }; debugDraw.DrawSolidCircle = function(center, radius, axis, color) { color = getDebugColor(color); center = box2d.vec2FromPointer(center); axis = box2d.vec2FromPointer(axis).scale(radius); drawCircle(center, radius*2, color, debugLineWidth, color, false, false); drawLine(vec2(), axis, debugLineWidth, color, center, 0, false, false); }; debugDraw.DrawTransform = function(transform) { transform = box2d.instance.wrapPointer(transform, box2d.instance.b2Transform); const pos = box2d.vec2From(transform.get_p()); const angle = -transform.get_q().GetAngle(); const p1 = vec2(1,0), c1 = rgb(.75,0,0,.8); const p2 = vec2(0,1), c2 = rgb(0,.75,0,.8); drawLine(vec2(), p1, debugLineWidth, c1, pos, angle, false, false); drawLine(vec2(), p2, debugLineWidth, c2, pos, angle, false, false); } debugDraw.AppendFlags(box2d.instance.b2Draw.e_shapeBit); debugDraw.AppendFlags(box2d.instance.b2Draw.e_jointBit); //debugDraw.AppendFlags(box2d.instance.b2Draw.e_aabbBit); //debugDraw.AppendFlags(box2d.instance.b2Draw.e_pairBit); //debugDraw.AppendFlags(box2d.instance.b2Draw.e_centerOfMassBit); box2d.world.SetDebugDraw(debugDraw); } } /** * LittleJS Drawing Utilities Plugin * - Extra drawing functions for LittleJS * - Nine slice and three slice drawing, and TileSlice to keep one as a style, like a UI skin * @namespace DrawUtilities */ /////////////////////////////////////////////////////////////////////////////// /** Draw a scalable nine-slice UI element in screen space, drawNineSlice with screenSpace set * - Draws with the 2D context by default, on top of what WebGL drew, like drawTextScreen * - With no angle its pieces land on whole pixels and meet exactly, so pixel art lines up and extraSpace is not used * @param {Vector2} pos - Screen space position * @param {Vector2} size - Screen space size * @param {TileInfo} startTile - Top-left tile of the 3x3 block to sample (see drawNineSlice) * @param {Color} [color=WHITE] - Color to modulate with * @param {number} [borderSize] - Rendered thickness of the border sections * @param {Color} [additiveColor] - Additive color * @param {number} [extraSpace] - Extra spacing adjustment * @param {number} [angle] - Angle to rotate by * @param {boolean} [useWebGL] - Use WebGL for rendering * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context] - Canvas context to use * @memberof DrawUtilities */ function drawNineSliceScreen(pos, size, startTile, color=WHITE, borderSize=32, additiveColor, extraSpace=2, angle=0, useWebGL=false, context) { // the order before 1.20 had borderSize, extraSpace and angle after startTile ASSERT(isColor(color), 'drawNineSliceScreen takes a color after startTile, then borderSize', color); drawNineSlice(pos, size, startTile, color, borderSize, additiveColor, extraSpace, angle, useWebGL, true, context); } /** Draw a scalable nine-slice UI element in world space * Color and additive color apply in WebGL and Canvas2D (with canvasColorTiles) * The nine-slice samples a 3x3 block of tiles from the tilesheet, it does not * subdivide a single tile. Pass the top-left tile of that block as startTile; * the other 8 tiles (edges, corners, and center) are taken automatically from * the 3x3 grid of tiles extending right and down from it. borderSize only sets * the rendered thickness of the edges and corners, not how the texture is cut. * @param {Vector2} pos - World space position * @param {Vector2} size - World space size * @param {TileInfo} startTile - Top-left tile of the 3x3 block to sample the nine-slice from * @param {Color} [color] - Color to modulate with * @param {number} [borderSize] - Rendered thickness of the border sections, 1 in world space and 32 pixels in * screen space by default * @param {Color} [additiveColor] - Additive color * @param {number} [extraSpace] - Extra spacing adjustment, .05 in world space and 2 pixels in screen space by * default * @param {number} [angle] - Angle to rotate by * @param {boolean} [useWebGL=glEnable] - Use WebGL for rendering * @param {boolean} [screenSpace=drawScreenSpace] - Use screen space coordinates * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context] - Canvas context to use * @memberof DrawUtilities */ function drawNineSlice(pos, size, startTile, color, borderSize, additiveColor, extraSpace, angle=0, useWebGL=glEnable, screenSpace=drawScreenSpace, context) { // the border and spacing defaults are in the units of the space it draws in borderSize ??= screenSpace ? 32 : 1; extraSpace ??= screenSpace ? 2 : .05; if (!size.x || !size.y) return; // nothing to draw, the spacing must not make a sliver of it if (color && color.a < 1) extraSpace = 0; // see through, the overlap that hides a seam would draw twice and show brighter // setup nine slice tiles - startTile is the top-left of a 3x3 tile block, // so the center tile is one tile down and right from it, stepping over // the padding around each tile the way tile() lays out the grid const step = startTile.size.add(vec2(startTile.padding*2)); if (screenSpace && !angle) { // on whole pixels, the pieces in the screen's rows and columns of the block drawSliceSnapped(pos, size, borderSize, (col, row)=> [startTile.offset(step.multiply(vec2(col, row))), 0], color, additiveColor, useWebGL, context); return; } borderSize = min(borderSize, abs(size.x)/2, abs(size.y)/2); // a box too small for two borders splits between them const centerTile = startTile.offset(step); const centerSize = size.add(vec2(extraSpace-borderSize*2)); const cornerSize = vec2(borderSize); const cornerOffset = size.scale(.5).subtract(cornerSize.scale(.5)); const flip = screenSpace ? -1 : 1; const rotateAngle = screenSpace ? -angle : angle; // center drawTile(pos, centerSize, centerTile, color, angle, false, additiveColor, useWebGL, screenSpace, context); for (let i=4; i--;) { // sides const horizontal = i%2; const sidePos = cornerOffset.multiply(vec2(horizontal?i===1?1:-1:0, horizontal?0:i?-1:1)); const sideSize = vec2(horizontal ? borderSize : centerSize.x, horizontal ? centerSize.y : borderSize); const sideTile = centerTile.offset(step.multiply(vec2(i===1?1:i===3?-1:0,i===0?-flip:i===2?flip:0))) drawTile(pos.add(sidePos.rotate(rotateAngle)), sideSize, sideTile, color, angle, false, additiveColor, useWebGL, screenSpace, context); } for (let i=4; i--;) { // corners const flipX = i>1; const flipY = i && i<3; const cornerPos = cornerOffset.multiply(vec2(flipX?-1:1, flipY?-1:1)); const cornerTile = centerTile.offset(step.multiply(vec2(flipX?-1:1,flipY?flip:-flip))); drawTile(pos.add(cornerPos.rotate(rotateAngle)), cornerSize, cornerTile, color, angle, false, additiveColor, useWebGL, screenSpace, context); } } /** Draw a scalable three-slice UI element in screen space, drawThreeSlice with screenSpace set * - Draws with the 2D context by default, on top of what WebGL drew, like drawTextScreen * - With no angle its pieces land on whole pixels and meet exactly, so pixel art lines up and extraSpace is not used * @param {Vector2} pos - Screen space position * @param {Vector2} size - Screen space size * @param {TileInfo} startTile - First of 3 consecutive tiles: corner, side, center (see drawThreeSlice) * @param {Color} [color=WHITE] - Color to modulate with * @param {number} [borderSize] - Rendered thickness of the border sections * @param {Color} [additiveColor] - Additive color * @param {number} [extraSpace] - Extra spacing adjustment * @param {number} [angle] - Angle to rotate by * @param {boolean} [useWebGL] - Use WebGL for rendering * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context] - Canvas context to use * @memberof DrawUtilities */ function drawThreeSliceScreen(pos, size, startTile, color=WHITE, borderSize=32, additiveColor, extraSpace=2, angle=0, useWebGL=false, context) { // the order before 1.20 had borderSize, extraSpace and angle after startTile ASSERT(isColor(color), 'drawThreeSliceScreen takes a color after startTile, then borderSize', color); drawThreeSlice(pos, size, startTile, color, borderSize, additiveColor, extraSpace, angle, useWebGL, true, context); } /** Draw a scalable three-slice UI element in world space * Color and additive color apply in WebGL and Canvas2D (with canvasColorTiles) * The three-slice samples 3 consecutive tiles from the tilesheet, it does not * subdivide a single tile. Pass the first tile as startTile; the three tiles * are used in order as corner, side, and center, then rotated and mirrored to * build all four edges and corners. borderSize only sets the rendered thickness. * @param {Vector2} pos - World space position * @param {Vector2} size - World space size * @param {TileInfo} startTile - First of 3 consecutive tiles (corner, side, center) for the three-slice * @param {Color} [color] - Color to modulate with * @param {number} [borderSize] - Rendered thickness of the border sections, 1 in world space and 32 pixels in * screen space by default * @param {Color} [additiveColor] - Additive color * @param {number} [extraSpace] - Extra spacing adjustment, .05 in world space and 2 pixels in screen space by * default * @param {number} [angle] - Angle to rotate by * @param {boolean} [useWebGL=glEnable] - Use WebGL for rendering * @param {boolean} [screenSpace=drawScreenSpace] - Use screen space coordinates * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context] - Canvas context to use * @memberof DrawUtilities */ function drawThreeSlice(pos, size, startTile, color, borderSize, additiveColor, extraSpace, angle=0, useWebGL=glEnable, screenSpace=drawScreenSpace, context) { // the border and spacing defaults are in the units of the space it draws in borderSize ??= screenSpace ? 32 : 1; extraSpace ??= screenSpace ? 2 : .05; if (!size.x || !size.y) return; // nothing to draw, the spacing must not make a sliver of it if (color && color.a < 1) extraSpace = 0; // see through, the overlap that hides a seam would draw twice and show brighter // setup three slice tiles - 3 tiles in a row starting at startTile const cornerTile = startTile.frame(0); const sideTile = startTile.frame(1); const centerTile = startTile.frame(2); if (screenSpace && !angle) { // on whole pixels, each corner and side the top left one's tile turned a quarter more going clockwise const turns = [[0,0,1],[3,-1,1],[3,2,2]]; // by column in each row, -1 for the center drawSliceSnapped(pos, size, borderSize, (col, row)=> { const turn = turns[row][col], corner = col !== 1 && row !== 1; return turn < 0 ? [centerTile, 0] : [corner ? cornerTile : sideTile, turn*PI/2]; }, color, additiveColor, useWebGL, context); return; } borderSize = min(borderSize, abs(size.x)/2, abs(size.y)/2); // a box too small for two borders splits between them const centerSize = size.add(vec2(extraSpace-borderSize*2)); const cornerSize = vec2(borderSize); const cornerOffset = size.scale(.5).subtract(cornerSize.scale(.5)); const flip = screenSpace ? -1 : 1; const rotateAngle = screenSpace ? -angle : angle; // center drawTile(pos, centerSize, centerTile, color, angle, false, additiveColor, useWebGL, screenSpace, context); for (let i=4; i--;) { // sides const a = angle + i*PI/2; const horizontal = i%2; const sidePos = cornerOffset.multiply(vec2(horizontal?i===1?1:-1:0, horizontal?0:i?-flip:flip)); const sideSize = vec2(horizontal ? centerSize.y : centerSize.x, borderSize); drawTile(pos.add(sidePos.rotate(rotateAngle)), sideSize, sideTile, color, a, false, additiveColor, useWebGL, screenSpace, context); } for (let i=4; i--;) { // corners const a = angle + i*PI/2; const flipX = !i || i>2; const flipY = i>1; const cornerPos = cornerOffset.multiply(vec2(flipX?-1:1, flipY?-flip:flip)); drawTile(pos.add(cornerPos.rotate(rotateAngle)), cornerSize, cornerTile, color, a, false, additiveColor, useWebGL, screenSpace, context); } } /////////////////////////////////////////////////////////////////////////////// /** * A tile drawn as a box of any size, kept as a style to draw with, like a UI skin * - 9 slices is a nine-slice from the 3x3 block of tiles at tileInfo, see drawNineSlice * - 3 slices is a three-slice from the 3 tiles in a row at tileInfo, see drawThreeSlice * - 1 slice is the whole tile stretched over the box, a plain image * - The UI system draws a widget's background with one, see uiSystem.defaultSlice * @memberof DrawUtilities * @example * const panel = new TileSlice(tile(0, 16), 9, .5); // a border of half a world unit * panel.draw(vec2(0, 5), vec2(10, 4)); * uiSystem.defaultSlice = new TileSlice(tile(0, 16), 9, 16); // UI sizes are pixels, every widget made after this */ class TileSlice { /** Create a tile slice style * @param {TileInfo} tileInfo - The tile, or the first of the tiles, to draw with * @param {number} [slices] - 9 for a nine-slice, 3 for a three-slice, 1 for the whole tile * @param {number} [borderSize] - Drawn thickness of the edges and corners, undefined for the draw's own default * @param {number} [extraSpace] - Extra spacing adjustment of the slices, undefined for the draw's own default */ constructor(tileInfo, slices=9, borderSize, extraSpace) { ASSERT(tileInfo instanceof TileInfo, 'tileInfo must be a TileInfo'); ASSERT(slices === 9 || slices === 3 || slices === 1, 'slices must be 9, 3 or 1'); ASSERT(borderSize === undefined || isNumber(borderSize), 'borderSize must be a number'); /** @property {TileInfo} - The tile, or the first of the tiles, to draw with */ this.tileInfo = tileInfo; /** @property {number} - 9 for a nine-slice, 3 for a three-slice, 1 for the whole tile */ this.slices = slices; /** @property {number|undefined} - Drawn thickness of the edges and corners, undefined for the draw's default * @type {number|undefined} */ this.borderSize = borderSize; /** @property {number|undefined} - Extra spacing adjustment of the slices, undefined for the draw's default * @type {number|undefined} */ this.extraSpace = extraSpace; } /** Draw it as a box in world space, or in screen space * @param {Vector2} pos - Center position * @param {Vector2} size - Size of the box * @param {Color} [color] - Color to modulate with * @param {Color} [additiveColor] - Additive color * @param {number} [angle] - Angle to rotate by * @param {boolean} [useWebGL=glEnable] - Use WebGL for rendering * @param {boolean} [screenSpace=drawScreenSpace] - Are pos and size in screen space? * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context] - Canvas context to use */ draw(pos, size, color=WHITE, additiveColor, angle=0, useWebGL=glEnable, screenSpace=drawScreenSpace, context) { if (screenSpace) // with the screen space defaults for the border and spacing return this.drawScreen(pos, size, color, additiveColor, angle, useWebGL, context); if (this.slices === 9) drawNineSlice(pos, size, this.tileInfo, color, this.borderSize, additiveColor, this.extraSpace, angle, useWebGL, screenSpace, context); else if (this.slices === 3) drawThreeSlice(pos, size, this.tileInfo, color, this.borderSize, additiveColor, this.extraSpace, angle, useWebGL, screenSpace, context); else drawTile(pos, size, this.tileInfo, color, angle, false, additiveColor, useWebGL, screenSpace, context); } /** Draw it as a box in screen space, with the 2D context by default, on top of what WebGL drew, like drawTextScreen * @param {Vector2} pos - Screen space center position * @param {Vector2} size - Screen space size * @param {Color} [color] - Color to modulate with * @param {Color} [additiveColor] - Additive color * @param {number} [angle] - Angle to rotate by * @param {boolean} [useWebGL] - Use WebGL for rendering * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context] - Canvas context to use */ drawScreen(pos, size, color=WHITE, additiveColor, angle=0, useWebGL=false, context) { if (this.slices === 9) drawNineSliceScreen(pos, size, this.tileInfo, color, this.borderSize, additiveColor, this.extraSpace, angle, useWebGL, context); else if (this.slices === 3) drawThreeSliceScreen(pos, size, this.tileInfo, color, this.borderSize, additiveColor, this.extraSpace, angle, useWebGL, context); else drawTile(pos, size, this.tileInfo, color, angle, false, additiveColor, useWebGL, true, context); } } // draw a slice box in screen space with no angle on whole pixels: the edges and the border are rounded, and every // piece is cut from them, so the pieces meet exactly and their texels line up where they do, with no overlap to hide // a seam; pieceTile(col, row) gives the tile and angle for the piece in that column and row of the box, top left 0 function drawSliceSnapped(pos, size, borderSize, pieceTile, color, additiveColor, useWebGL, context) { // the pixels it lands on are the device's: a 2D context's own scale and offset, like the UI under nativeHeight // centered on an odd sized canvas, or the pixel ratio; the draws land half a pixel past, see shift below const canvas2D = !useWebGL || !glEnable || !!context; const transform = canvas2D && (context || drawContext)?.getTransform?.(); const ratio = getCanvasPixelRatio(); const [scaleX, scaleY, offsetX, offsetY] = transform ? [transform.a || 1, transform.d || 1, transform.e || 0, transform.f || 0] : [ratio, ratio, 0, 0]; const edges = (center, length, scale, offset)=> { // snapped in device order, which a mirrored context reverses, then put back in the box's own order const border = max(1, round(borderSize * abs(scale))); const a = round((center - length/2) * scale + offset), b = a + round(length * scale); const start = min(a, b), end = max(a, b); const inner = min(start + border, floor((start + end)/2)); // a box too small for two borders splits const device = [start, inner, max(end - border, inner), end]; return (scale < 0 ? device.reverse() : device).map(edge=> (edge - offset) / scale); }; const xs = edges(pos.x, size.x, scaleX, offsetX), ys = edges(pos.y, size.y, scaleY, offsetY); const shift = .5; // a screen space draw lands half a pixel past its position, in Canvas2D and WebGL alike for (let row = 3; row--;) for (let col = 3; col--;) { const w = xs[col+1] - xs[col], h = ys[row+1] - ys[row]; if (w <= 0 || h <= 0) continue; const [tileInfo, angle] = pieceTile(col, row); const turned = round(angle / (PI/2)) % 2; // a quarter turn swaps the sides it is drawn with const piecePos = vec2(xs[col] + w/2 - shift, ys[row] + h/2 - shift); drawTile(piecePos, turned ? vec2(h, w) : vec2(w, h), tileInfo, color, angle, false, additiveColor, useWebGL, true, context); } } /** Draw a crescent / moon-phase shape built from a polygon * Routes through drawPoly, so it supports WebGL, screen space, color, and outlines * - At angle 0 the lit side faces up while waxing (percent below .5) and down while waning, and the lit width * grows evenly with the phase, not as the real moon's does * @param {Vector2} pos - Center position * @param {number} [size] - Diameter * @param {number} [percent] - Moon phase over a full cycle (0=new, .25=first quarter, .5=full, .75=last quarter), wraps * @param {Color} [color] - Fill color * @param {number} [angle] - Angle to rotate by * @param {boolean} [invert] - Draw the unlit part of the disk instead of the lit part * @param {number} [lineWidth] - Outline width, 0 for no outline * @param {Color} [lineColor] - Outline color * @param {boolean} [useWebGL=glEnable] - Use WebGL for rendering * @param {boolean} [screenSpace=drawScreenSpace] - Use screen space coordinates * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context] - Canvas context to use * @memberof DrawUtilities */ function drawCrescent(pos, size=1, percent=0, color=WHITE, angle=0, invert=false, lineWidth=0, lineColor=BLACK, useWebGL=glEnable, screenSpace=drawScreenSpace, context) { // build local-space points and let drawPoly apply pos/angle so screen space works const points = getCrescentPoints(vec2(), size, percent, 0, invert); drawPoly(points, color, lineWidth, lineColor, pos, angle, useWebGL, screenSpace, context); } /** Get the list of points that make up a crescent / moon-phase shape * Returns world-space points with pos and angle baked in, ready for drawPoly or other use * @param {Vector2} pos - Center position * @param {number} [size] - Diameter * @param {number} [percent] - Moon phase over a full cycle (0=new, .25=first quarter, .5=full, .75=last quarter), wraps * @param {number} [angle] - Angle to rotate by * @param {boolean} [invert] - The unlit part of the disk instead of the lit part * @param {number} [sides=glCircleSides] - Number of sides for a full circle (halved per arc) * @return {Array} - List of points making up the crescent * @memberof DrawUtilities */ function getCrescentPoints(pos, size=1, percent=0, angle=0, invert=false, sides=glCircleSides) { ASSERT(isVector2(pos), 'pos must be a vec2'); ASSERT(isNumber(size) && isNumber(percent), 'size and percent must be numbers'); // map phase to a signed terminator curve: -1 new, 0 half, 1 full let p = mod(percent*4, 4); // quarter phase 0..4 if (p >= 2) // second half of cycle flips orientation angle += PI; p = p <= 2 ? p-1 : 3-p; if (invert) // the unlit part: the other side of the same curve { p = -p; angle += PI; } // build the crescent: outer semicircle, then inner half-ellipse traced back const points = []; const segs = max(3, sides>>1); const radius = size/2; for (let i=0; i<=segs; i++) { const t = i/segs*PI; points.push(vec2(radius*cos(t), radius*sin(t)).rotate(angle).add(pos)); } for (let i=segs; i>=0; i--) { const t = i/segs*PI; points.push(vec2(radius*cos(t), -radius*p*sin(t)).rotate(angle).add(pos)); } return points; } /** * LittleJS Texture Sheet Plugin * - Packs images into texture sheets as they are loaded * - Sprites are placed automatically, callers get a TileInfo * - Sheets are created and filled as needed * - Sheets fill in call order, images decode in parallel * - Animation frames keep layout and wrap across rows as needed * - WebGL textures upload once per batch of loads * - loadAtlas imports pre-packed atlases (TexturePacker and Aseprite json) * - loadTiles packs separate tile images, or several tile sheets, into one tile set for tile layers and maps * @namespace TextureSheets */ /** Width and height in pixels of texture sheets created by loadSprite * @type {number} * @default * @memberof Settings */ let textureSheetSize = 2048; /** Default padding pixels around each frame packed by loadSprite * @type {number} * @default * @memberof Settings */ let textureSheetPadding = 1; /** Array of texture sheets created by loadSprite * @type {Array} * @memberof TextureSheets */ let textureSheets = []; // pending loads pack through a queue so sheets fill in call order let textureSheetQueue = Promise.resolve(); let textureSheetPendingCount = 0; // what each load handed back by what it was given, so the same image loaded again is packed once const textureSheetLoaded = new Map; // a load's key: what it was given, a vector by its numbers const textureSheetLoadKey = (...parts)=> parts.map((p)=> isVector2(p) ? p.x + ',' + p.y : String(p)).join('|'); // a load whose file failed is forgotten, so loading it again tries again, as when the server comes back const textureSheetLoadFailed = (key, loaded)=> textureSheetLoaded.get(key) === loaded && textureSheetLoaded.delete(key); /** * Texture Sheet - A texture that images are packed into as they load * Uses shelf packing, images are placed left to right then wrap to a new row * @memberof TextureSheets */ class TextureSheet { /** Create a texture sheet, called automatically by loadSprite * @param {number} [size] - Width and height of the sheet in pixels */ constructor(size=textureSheetSize) { ASSERT(size > 0, 'texture sheet size must be positive'); /** @property {number} - Width and height of the sheet in pixels */ this.size = size; /** @property {OffscreenCanvasRenderingContext2D} - 2d context for the canvas */ this.context = headlessMode ? undefined : createCanvasContext(size); /** @property {OffscreenCanvas} - Canvas holding the packed images */ this.canvas = this.context?.canvas; /** @property {TextureInfo} - The texture info for this sheet */ this.textureInfo = new TextureInfo(this.canvas); /** @property {Vector2} - Where the next image will be packed */ this.cursor = vec2(); /** @property {number} - Height of the row being packed */ this.rowHeight = 0; /** @property {boolean} - Has the canvas changed since the last webgl upload? */ this.glDirty = false; if (headlessMode) { // tiles still need bounds when there is no canvas to measure this.textureInfo.size = vec2(size); this.textureInfo.sizeInverse = vec2(1/size); } } /** Find a spot for an image on this sheet without drawing it * @param {Vector2} imageSize - Size of the source image in pixels * @param {Vector2} [frameSize] - Size of each frame, or the whole image less its source padding if not passed * @param {number} [padding] - How many pixels padding around each frame * @param {number|Vector2} [sourcePadding] - How many pixels padding around each frame in the source image * @return {TileInfo|undefined} Tile for the packed image, or undefined if the sheet is full */ tryAdd(imageSize, frameSize, padding=textureSheetPadding, sourcePadding=0) { // a number pads both axes the same const sourcePad = isNumber(sourcePadding) ? vec2(/** @type {number} */ (sourcePadding)) : /** @type {Vector2} */ (sourcePadding); ASSERT(isVector2(sourcePad) && sourcePad.x >= 0 && sourcePad.y >= 0, 'sourcePadding must be a number or vec2 >= 0'); ASSERT(isVector2(imageSize), 'sizes must be vec2'); frameSize ||= imageSize.subtract(sourcePad.scale(2)); // the whole image, less the padding baked into it ASSERT(isVector2(frameSize), 'sizes must be vec2'); ASSERT(frameSize.x > 0 && frameSize.y > 0, 'frame size must be positive'); // the source may have its own padding baked in around each frame const sourceCellWidth = frameSize.x + sourcePad.x*2; const sourceCellHeight = frameSize.y + sourcePad.y*2; ASSERT(imageSize.x % sourceCellWidth === 0 && imageSize.y % sourceCellHeight === 0, 'image size must be a multiple of the padded frame size'); const cellWidth = frameSize.x + padding*2; const cellHeight = frameSize.y + padding*2; const maxColumns = this.size / cellWidth | 0; ASSERT(maxColumns > 0, 'frame is too wide to fit on a texture sheet'); // keep the layout of the source image, but narrow it if a row is too wide // frames wrap down to the next row, which TileInfo.frame handles via columns // whole frames only, a partial one left over at an edge is ignored const sourceColumns = imageSize.x / sourceCellWidth | 0; const frameCount = sourceColumns * (imageSize.y / sourceCellHeight | 0); if (!frameCount) return undefined; // smaller than a frame, there is nothing to pack const columns = min(sourceColumns, maxColumns); const blockWidth = columns * cellWidth; const blockHeight = ceil(frameCount / columns) * cellHeight; // probe the placement using locals so a failed try leaves the sheet unchanged let x = this.cursor.x, y = this.cursor.y, rowHeight = this.rowHeight; if (x + blockWidth > this.size) { // start a new row if this one does not have enough space left x = 0; y += rowHeight; rowHeight = 0; } // out of space, the caller needs to use a different sheet if (y + blockHeight > this.size) return undefined; // commit the placement, tile pos points inside the padding to match how tile() works this.cursor.x = x + blockWidth; this.cursor.y = y; this.rowHeight = max(rowHeight, blockHeight); return new TileInfo(vec2(x + padding, y + padding), frameSize, this.textureInfo, padding, 0, columns); } /** Draw an image into this sheet at a tile returned by tryAdd * @param {HTMLImageElement|HTMLCanvasElement|OffscreenCanvas|ImageBitmap} image - Source image to copy from * @param {TileInfo} tileInfo - Where to put it, from tryAdd * @param {boolean} [update] - Upload to webgl now, pass false when batching * @param {number|Vector2} [sourcePadding] - How many pixels padding around each frame in the source image */ drawImage(image, tileInfo, update=true, sourcePadding=0) { ASSERT(!!this.context, 'texture sheet has no canvas'); // a number pads both axes the same const sourcePad = isNumber(sourcePadding) ? vec2(/** @type {number} */ (sourcePadding)) : /** @type {Vector2} */ (sourcePadding); // copy frames in order, reading the source left to right, top to bottom // the destination wraps at tileInfo.columns which may be narrower than the source const frameSize = tileInfo.size; const sourceCellWidth = frameSize.x + sourcePad.x*2; const sourceCellHeight = frameSize.y + sourcePad.y*2; // whole frames only as tryAdd packed them, a fractional count would never end the loop const sourceColumns = image.width / sourceCellWidth | 0; const frameCount = sourceColumns * (image.height / sourceCellHeight | 0); const columns = tileInfo.columns || frameCount; const cellWidth = frameSize.x + tileInfo.padding*2; const cellHeight = frameSize.y + tileInfo.padding*2; for (let i = frameCount; i--;) { const sourceX = (i % sourceColumns) * sourceCellWidth + sourcePad.x; const sourceY = (i / sourceColumns | 0) * sourceCellHeight + sourcePad.y; this.context.drawImage(image, sourceX, sourceY, frameSize.x, frameSize.y, tileInfo.pos.x + (i % columns) * cellWidth, tileInfo.pos.y + (i / columns | 0) * cellHeight, frameSize.x, frameSize.y); } // upload now unless the caller is batching more images this.glDirty = true; update && this.updateTexture(); } /** Upload the canvas to webgl if it has changed since the last upload * Only needed after batching, drawImage uploads automatically by default */ updateTexture() { if (!this.glDirty) return; this.glDirty = false; this.textureInfo.createWebGLTexture(); } } /////////////////////////////////////////////////////////////////////////////// /** Load an image and pack it into a texture sheet * - Returns a TileInfo immediately which is filled in when the image loads * - Nothing is visible until it loads, use spritesReady to wait for it * - Pass frameSize for animations, then step through them with TileInfo.frame * - Grid images keep their layout and frames wrap down to the next row * - Pass sourcePadding if the source image has padding baked in around frames * - The same image loaded again with the same settings gives back what the first load did, packed once, unless * that load failed * @param {string} src - Image source path * @param {Vector2|number} [frameSize] - Size of each animation frame in pixels, or the whole image less its * source padding if not passed * @param {number} [padding] - How many pixels padding around each frame * @param {number|Vector2} [sourcePadding] - How many pixels padding around each frame in the source image * @return {TileInfo} * @example * const playerTile = loadSprite('player.png'); // a single sprite * const runTile = loadSprite('run.png', vec2(16)); // a 16x16 frame animation * @memberof TextureSheets */ function loadSprite(src, frameSize, padding=textureSheetPadding, sourcePadding=0) { ASSERT(isStringLike(src), 'image src must be a string'); ASSERT(!frameSize || isVector2(frameSize) || isNumber(frameSize), 'frameSize must be a vec2 or number'); ASSERT(isNumber(padding), 'padding must be a number'); ASSERT(isNumber(sourcePadding) || isVector2(sourcePadding), 'sourcePadding must be a number or vec2'); ASSERT(engineInitialized || headlessMode, 'call loadSprite after engineInit, e.g. in gameInit'); if (isNumber(frameSize)) frameSize = vec2(/** @type {number} */ (frameSize)); const key = textureSheetLoadKey('sprite', src, frameSize, padding, sourcePadding); if (!headlessMode && textureSheetLoaded.has(key)) return textureSheetLoaded.get(key); // start with an empty tile that gets filled in when the image loads const tileInfo = new TileInfo(vec2(), vec2(), undefined, padding, 0); if (headlessMode) return tileInfo; textureSheetLoaded.set(key, tileInfo); // point at a sheet right away so drawing before it loads picks up empty pixels tileInfo.textureInfo = (textureSheets[0] || textureSheetCreate()).textureInfo; // start decoding right away, images decode in parallel const image = new Image; const imagePromise = new Promise(resolve => { image.onerror = image.onload = resolve; image.crossOrigin = 'anonymous'; image.src = src; }); // pack through a queue so sheets fill in call order, not decode order textureSheetQueueJob('loadSprite ' + src, async ()=> { await imagePromise; if (image.width) { // pack onto a sheet, then fill in the tile that was already handed out, // copying every field so nothing is missed if TileInfo gains more of them const imageSize = vec2(image.width, image.height); const added = textureSheetAdd(imageSize, frameSize, padding, sourcePadding); if (!added) { // leave the tile empty, no sheet can hold it console.warn('loadSprite image is too large to fit on a texture sheet:', src); return; } Object.assign(tileInfo, added.tile); added.sheet.drawImage(image, tileInfo, false, sourcePadding); // upload once per batch below } else { // leave the tile empty if the image failed to load console.warn('loadSprite failed to load image:', src); textureSheetLoadFailed(key, tileInfo); } }, ()=> textureSheetLoadFailed(key, tileInfo)); return tileInfo; } /** Load tile images and pack them into texture sheets as one tile set, for tile layers and maps * - Each image is cut into tiles of tileSize, left to right then down, so an image of one tile is one tile and a * sheet is all of its tiles; the tiles are numbered from 0 in the order the images are given * - Returns a tile set at once, a TileInfo whose tiles fill in as the images load; wait for them with spritesReady * - Give it to tileLayersLoad, a TileLayer or a TileCollisionLayer as its tile info: tile n draws tiles[n], * wherever it was packed, and the level editor's palette offers each of them; frame and index do not read the * list, use set.tiles[n] for one tile; make the layers after spritesReady, a layer made before draws nothing * - An image that is not a whole number of tiles gives the whole tiles in it; a sheet with gaps between its tiles * is not read, cut it into its tiles first * - An image that fails to load, or that no sheet can hold, adds no tiles and says so in the console, so the tiles * of the images after it move up * - The same images loaded again with the same settings give back what the first load did, packed once, unless * an image of it failed * @param {Array} sources - Image source paths * @param {Vector2|number} [tileSize] - Size of a tile in pixels * @param {number} [padding] - How many pixels padding around each tile on the sheet * @return {TileInfo} * @example * const tiles = loadTiles(['grass.png', 'dirt.png', 'water.png', 'props.png'], 16); * await spritesReady(); * tileLayersLoad(map, tiles); // tile 0 is grass, 1 dirt, 2 water, then the tiles of props.png * @memberof TextureSheets */ function loadTiles(sources, tileSize=tileDefaultSize, padding=textureSheetPadding) { ASSERT(isArray(sources) && sources.every((src)=> isStringLike(src)), 'sources must be a list of image paths'); ASSERT(isVector2(tileSize) || isNumber(tileSize), 'tileSize must be a vec2 or number'); ASSERT(isNumber(padding), 'padding must be a number'); ASSERT(engineInitialized || headlessMode, 'call loadTiles after engineInit, e.g. in gameInit'); const size = isNumber(tileSize) ? vec2(/** @type {number} */ (tileSize)) : /** @type {Vector2} */ (tileSize).copy(); const key = textureSheetLoadKey('tiles', sources.join('\n'), size, padding); if (!headlessMode && textureSheetLoaded.has(key)) return textureSheetLoaded.get(key); const set = new TileInfo(vec2(), size, undefined, 0, 0); set.tiles = []; if (headlessMode) return set; textureSheetLoaded.set(key, set); set.textureInfo = (textureSheets[0] || textureSheetCreate()).textureInfo; // every image decodes at once, and packs through the queue in the order given, so the tiles keep that order for (const src of sources) { const image = new Image; const imagePromise = new Promise((resolve)=> { image.onerror = image.onload = resolve; image.crossOrigin = 'anonymous'; image.src = src; }); textureSheetQueueJob('loadTiles ' + src, async ()=> { await imagePromise; // the whole tiles of it, an edge past the last one left out const columns = image.width / size.x | 0, rows = image.height / size.y | 0, count = columns * rows; const added = count && textureSheetAdd(vec2(columns * size.x, rows * size.y), size, padding, 0); image.width || textureSheetLoadFailed(key, set); if (!added) return console.warn('loadTiles: ' + src + (count ? ' does not fit on a texture sheet' : ' failed to load, or is smaller than a tile') + ', its tiles are left out'); added.sheet.drawImage(image, added.tile, false); // upload once per batch for (let k = 0; k < count; ++k) set.tiles.push(added.tile.frame(k)); }, ()=> textureSheetLoadFailed(key, set)); } return set; } /** Load a pre-packed texture atlas and repack it onto texture sheets * - Supports TexturePacker json (hash and array) and Aseprite json * - Returns an empty object which is filled with TileInfos when loaded * - Frames are named by the json, animations are grouped automatically * - Aseprite frame tags become animations, so do names like run_0, run_1 * - Trimmed frames are restored to their full source size when packed * - Rotated frames are rotated back upright when packed * - The same atlas loaded again by its paths, with the same padding, gives back what the first load did, packed * once, unless that load failed * @param {string} imageSrc - Atlas image path * @param {string|Object} jsonSrc - Atlas json path, or already parsed json data * @param {number} [padding] - How many pixels padding around each frame * @return {Object} Object mapping frame and animation names to TileInfos * @example * const atlas = loadAtlas('sprites.png', 'sprites.json'); * await spritesReady(); * drawTile(pos, size, atlas.player); // a single frame * drawTile(pos, size, atlas.run.frame(2)); // frame 2 of the run animation * @memberof TextureSheets */ function loadAtlas(imageSrc, jsonSrc, padding=textureSheetPadding) { ASSERT(isStringLike(imageSrc), 'atlas image src must be a string'); ASSERT(isStringLike(jsonSrc) || typeof jsonSrc === 'object', 'atlas json must be a path or object'); ASSERT(isNumber(padding), 'padding must be a number'); ASSERT(engineInitialized || headlessMode, 'call loadAtlas after engineInit, e.g. in gameInit'); // json passed in as data may be another atlas each time, only one loaded by its path is kept const key = typeof jsonSrc === 'object' ? undefined : textureSheetLoadKey('atlas', imageSrc, jsonSrc, padding); if (!headlessMode && textureSheetLoaded.has(key)) return textureSheetLoaded.get(key); /** @type {Object} */ const atlas = {}; if (headlessMode) return atlas; key && textureSheetLoaded.set(key, atlas); // start fetching the json and decoding the image right away, in parallel const jsonPromise = typeof jsonSrc === 'object' ? Promise.resolve(jsonSrc) : fetch(jsonSrc).then(r=> r.ok && r.json()).catch(()=> undefined); const image = new Image; const imagePromise = new Promise(resolve => { image.onerror = image.onload = resolve; image.crossOrigin = 'anonymous'; image.src = imageSrc; }); // pack through a queue so sheets fill in call order, not decode order textureSheetQueueJob('loadAtlas ' + imageSrc, async ()=> { const data = await jsonPromise; await imagePromise; if (image.width && data) { for (const group of parseAtlas(data)) { // reserve a block of full size cells, one per frame if (!group.frames.length) continue; // a tag with no frames, the groups after it still load const sourceSize = group.frames[0].sourceSize; const blockSize = vec2(sourceSize.x*group.frames.length, sourceSize.y); const added = textureSheetAdd(blockSize, sourceSize, padding); if (!added) { console.warn('loadAtlas frames are too large to fit on a texture sheet:', group.name); continue; } const {sheet, tile} = added; // draw each frame untrimmed into its cell const context = sheet.context; const cellWidth = sourceSize.x + padding*2; const cellHeight = sourceSize.y + padding*2; group.frames.forEach((f, i)=> { const x = tile.pos.x + (i % tile.columns)*cellWidth + f.offset.x; const y = tile.pos.y + (i / tile.columns |0)*cellHeight + f.offset.y; if (f.rotated) { // stored rotated 90 degrees clockwise, draw it back upright context.save(); context.translate(x, y); context.rotate(-PI/2); context.drawImage(image, f.pos.x, f.pos.y, f.size.y, f.size.x, -f.size.y, 0, f.size.y, f.size.x); context.restore(); } else context.drawImage(image, f.pos.x, f.pos.y, f.size.x, f.size.y, x, y, f.size.x, f.size.y); }); sheet.glDirty = true; atlas[group.name] = tile; } } else { // leave the atlas empty if either file failed to load console.warn('loadAtlas failed to load:', imageSrc, jsonSrc); textureSheetLoadFailed(key, atlas); } }, ()=> textureSheetLoadFailed(key, atlas)); return atlas; } // run a load in the queue: a load that throws is reported, let go of so the next call loads it again (failed), as // one whose file did not load is, and the loads after it carry on; the pending count always comes back down, and the // sheets upload to webgl once per batch, when the last pending load finishes function textureSheetQueueJob(name, job, failed) { ++textureSheetPendingCount; textureSheetQueue = textureSheetQueue.then(async ()=> { try { await job(); } catch (e) { console.error(name + ' failed:', e); failed?.(); } finally { if (!--textureSheetPendingCount) for (const sheet of textureSheets) { try { sheet.updateTexture(); } catch (e) { console.error('texture sheet upload failed:', e); } } } }); } /** Parse atlas json into a list of named frame groups, used by loadAtlas * - Accepts TexturePacker json (hash and array) and Aseprite json * - Frames tagged in Aseprite or named like run_0, run_1 group into animations * @param {Object} data - Parsed atlas json data * @return {Array<{name: string, frames: Array}>} List of {name, frames} groups in atlas order * @memberof TextureSheets */ function parseAtlas(data) { ASSERT(!!data?.frames, 'unrecognized atlas format, expected TexturePacker or Aseprite json'); // normalize both hash and array frame layouts into a single list const frames = (isArray(data.frames) ? data.frames.map(f=> [f.filename, f]) : Object.entries(data.frames)) .map(([name, f])=> ({ name: name.replace(/\.[^.\\/]+$/, ''), // strip file extension pos: vec2(f.frame.x, f.frame.y), size: vec2(f.frame.w, f.frame.h), offset: vec2(f.spriteSourceSize?.x ?? 0, f.spriteSourceSize?.y ?? 0), sourceSize: vec2(f.sourceSize?.w ?? f.frame.w, f.sourceSize?.h ?? f.frame.h), rotated: !!f.rotated, })); const groups = []; const tags = data.meta?.frameTags; if (tags?.length) { // aseprite tags are authoritative, untagged frames stay individual const tagged = new Set; for (const tag of tags) { // a tag reaches only frames there are, whatever its numbers say const from = max(0, floor(tag.from) || 0), to = min(frames.length - 1, floor(tag.to) || 0); groups.push({name: tag.name, frames: frames.slice(from, to + 1)}); for (let i = from; i <= to; ++i) tagged.add(i); } frames.forEach((f, i)=> tagged.has(i) || groups.push({name: f.name, frames: [f]})); return parseAtlasNamesCheck(groups); } // group frames that share a name stem with contiguous trailing numbers // run_0.png and run_1.png become a 2 frame animation named run, and so do the folder style run/0.png and run/1.png const stems = new Map; for (const f of frames) { let match = f.name.match(/^(.+?)([-_ /])?(\d+)$/); if (match && !match[2] && /\d$/.test(match[1])) match = undefined; // all digit tails like 10 are a name, not frame 0 of 1 const stem = match ? match[1] : f.name; f.groupIndex = match ? Number(match[3]) : undefined; stems.has(stem) || stems.set(stem, []); stems.get(stem).push(f); } for (const [stem, list] of stems) { // only group 2 or more frames with contiguous indices and matching sizes list.sort((a, b)=> a.groupIndex - b.groupIndex); const grouped = list.length > 1 && list.every((f, i)=> f.groupIndex === list[0].groupIndex + i) && list.every(f=> f.sourceSize.x === list[0].sourceSize.x && f.sourceSize.y === list[0].sourceSize.y); if (grouped) groups.push({name: stem, frames: list}); else list.forEach(f=> groups.push({name: f.name, frames: [f]})); } return parseAtlasNamesCheck(groups); } // an atlas's groups, with a warning for each name two of them have, as a sprite is looked up by name and the second // takes the first's place function parseAtlasNamesCheck(groups) { const names = new Set; for (const {name} of groups) names.has(name) ? console.warn(`parseAtlas: two sprites are named ${name}, the second takes the first's place`) : names.add(name); return groups; } /** Wait for everything started by loadSprite and loadAtlas to finish packing * @return {Promise} * @example * async function gameInit() * { * playerTile = loadSprite('player.png'); * runTile = loadSprite('run.png', vec2(16)); * await spritesReady(); * } * @memberof TextureSheets */ async function spritesReady() { // keep waiting until the queue drains, more sprites may load while waiting while (textureSheetPendingCount) await textureSheetQueue; } // create a new texture sheet and add it to the list function textureSheetCreate() { const sheet = new TextureSheet; textureSheets.push(sheet); return sheet; } // use the first sheet with enough space, or make a new one, // returns undefined if it would not fit even on an empty sheet function textureSheetAdd(imageSize, frameSize, padding, sourcePadding) { let sheet, tile; for (sheet of textureSheets) if (tile = sheet.tryAdd(imageSize, frameSize, padding, sourcePadding)) break; if (!tile) { // probe an empty sheet first, a new one is a canvas and a texture kept for good const emptySheet = {size: textureSheetSize, cursor: vec2(), rowHeight: 0}; if (!TextureSheet.prototype.tryAdd.call(emptySheet, imageSize, frameSize, padding, sourcePadding)) return; sheet = textureSheetCreate(); tile = sheet.tryAdd(imageSize, frameSize, padding, sourcePadding); } return {sheet, tile}; } /////////////////////////////////////////////////////////////////////////////// // Texture sheet setting setters /** Set width and height in pixels of texture sheets created by loadSprite * @param {number} size * @memberof Settings */ function setTextureSheetSize(size) { textureSheetSize = size; } /** Set default padding pixels around each frame packed by loadSprite * @param {number} padding * @memberof Settings */ function setTextureSheetPadding(padding) { textureSheetPadding = padding; } /** * LittleJS Tween System Plugin * - Lightweight tweens for numbers, Vector2, Color, or any .lerp-able type * - Chainable easing, looping, and ping-pong * - Property-path helper for the common case of animating an object field * - Auto-updates via engineAddPlugin; pauses with the game by default * @namespace TweenSystem */ /////////////////////////////////////////////////////////////////////////////// // Module-private list of tweens currently running, each with its active flag set while it is in it; a stopped // one stays listed, inactive, until the next update takes it out, so stopping many is not a search for each const tweenActive = []; const tweenUpdateList = []; // the tweens an update moves, the ones active when it began let tweenUpdatePass = 0; // counts the updates, a tween started during one waits for the next // put a tween in the active list, or take it out, keeping its flag in step so a check costs nothing function tweenActivate(tween) { tween.activePass = tweenUpdatePass; // started again, even while active, so this update leaves it alone if (tween.active) return; tween.active = true; tween.listed || tweenActive.push(tween); // one stopped this update is still listed, in its place tween.listed = true; } function tweenDeactivate(tween) { tween.active = false; } // call a tween's callback with its value at life; one that throws, its easing too, is ended, or it would be called and // throw at every update, unless the callback itself started it again, which is its own new run (pass, the update it // is in, undefined outside one) function tweenCall(tween, life, pass) { try { tween.callback(tween.interp(life)); } catch (error) { if (tween.active && (pass === undefined || tween.activePass < pass)) tweenDeactivate(tween); throw error; } } // True if the value is an instance of a class that exposes a numeric-percent // `lerp(other, percent)` method (Vector2, Color, or any future class). function tweenIsLerpable(v) { return v && typeof v.lerp === 'function'; } /////////////////////////////////////////////////////////////////////////////// /** A tween: drives a callback with a value interpolated between * `start` and `end` over `duration` seconds. Pauses with the game by default. * - In TypeScript it is a `Tween` of the type it tweens, which comes from `start` and `end` or * the callback's parameter, so `(v: number)=> ...` takes a number * @template [T=any] * @memberof TweenSystem * @example * // Animate a fade-out over 2 seconds with an ease-out sine curve. * new Tween((v) => obj.alpha = v, 1, 0, 2, { ease: Ease.OUT(Ease.SINE) }); */ class Tween { /** Create a new tween. The callback fires immediately with `start` so the * target snaps to the start value on the same frame the tween is created. * * `start` and `end` may be numbers, Vector2, Vector3 or Color instances, or * any object exposing a `lerp(other, percent) => sameType` method. The * callback receives the interpolated value (a number, or a fresh instance * for lerp-able types). Both endpoints must be the same type. * @param {function(NonNullable):void} callback - Called with the interpolated value each frame * @param {T} [start] - Starting value * @param {T} [end] - Ending value * @param {number} [duration] - Duration in seconds * @param {Object} [options] * @param {function(number):number} [options.ease] - Easing function (defaults to LINEAR) * @param {boolean} [options.useRealTime=false] - Advance even when the game is paused (matches Timer's useRealTime) * @param {boolean} [options.paused=false] - Start in paused state */ constructor(callback, start = /** @type {T} */ (0), end = /** @type {T} */ (1), duration = 1, options = {}) { ASSERT(typeof callback === 'function', 'Tween callback must be a function'); if (tweenIsLerpable(start)) { ASSERT(start.constructor === end.constructor, 'Tween start and end must be the same type'); } else { ASSERT(isNumber(start), 'Tween start must be a number or have a .lerp method'); ASSERT(isNumber(end), 'Tween end must be a number when start is a number'); } ASSERT(isNumber(duration) && duration > 0, 'Tween duration must be > 0'); // the callback's type is NonNullable, which is T, so that TypeScript takes the type from start and end // first: a typed callback like (v: number)=> with start 10 then makes a Tween, not a Tween<0|10> /** @property {function(T):void} - Called with the interpolated value each frame * @type {function(T):void} */ this.callback = callback; /** @property {T} - Starting value * @type {T} */ this.start = start; /** @property {T} - Ending value * @type {T} */ this.end = end; /** @property {number} - Total duration in seconds */ this.duration = duration; /** @property {number} - Remaining time in seconds (counts down from duration to 0) */ this.life = duration; /** @property {function(number):number} - Easing curve mapping [0,1] -> [0,1] */ this.ease = options.ease || Ease.LINEAR; /** @property {boolean} - If true, advance even when the game is paused */ this.useRealTime = !!options.useRealTime; /** @property {boolean} - If true, stop advancing until cleared */ this.paused = !!options.paused; /** @property {undefined|function():void} - Called once the tween completes: when its last pass ends, * the last iteration of a loop or pingPong, and again each time a restart plays through; then() sets it * @type {undefined|function():void} */ this.onComplete = undefined; /** Continuation when a pass ends, set by loop() and pingPong() to start the next iteration * @private */ this.thenCallback = undefined; /** Remaining iterations including the current run (loop/pingPong only) * @private */ this.loopRemaining = 0; /** Whether it is running, see isActive * @private */ this.active = false; /** Whether it is in the active list, which a stopped one leaves at the next update * @private */ this.listed = false; /** The update it was started in, it first moves on the one after * @private */ this.activePass = 0; /** Engine time and real time of its last engine update, it moves by what passed since * @private */ this.lastTime = time; /** @private */ this.lastTimeReal = timeReal; /** @property {Object|undefined} - The object tweenProperty animates, the tween stops once it is destroyed, * even while paused * @type {{destroyed?: boolean}|undefined} */ this.target = undefined; tweenActivate(this); // Snap target to start immediately; one that throws here is no tween, the game never gets it to stop tweenCall(this, duration); } /** Set the easing curve and return this for chaining * @param {function(number):number} easeFn * @return {Tween} */ setEase(easeFn) { this.ease = easeFn; return this; } /** Set the completion callback, `onComplete`, and return this for chaining. * It is called once the tween completes: when its pass ends, or for a * `loop` or `pingPong` when its last iteration ends, so an endless one * never calls it. Calling `then` again replaces the previous callback. * - It works with `loop` and `pingPong` in either order, neither replaces the other * - It is kept by `restart`, so a restarted tween calls it again when it completes * - `stop` and `tweenStopAll` end a tween without calling it * @param {function():void} callback * @return {Tween} */ then(callback) { this.onComplete = callback; return this; } /** Repeat this tween `n` total times. After each iteration finishes, the * same tween starts over, so the handle returned stays good for the whole * loop: pause or stop it to pause or stop every iteration left. * `loop()` with no argument loops forever. * * Mutually exclusive with `pingPong`; calling either replaces the other. * A `then` callback, set before or after, is called when the last * iteration ends. * @param {number} [count=Infinity] * @return {Tween} */ loop(count = Infinity) { this.loopRemaining = count; this.thenCallback = () => tweenLoopContinuation(this); return this; } /** Like `loop`, but swap `start` and `end` between iterations so the value * bounces back and forth. `pingPong()` with no argument bounces forever. * * Mutually exclusive with `loop`; calling either replaces the other. * A `then` callback, set before or after, is called when the last * iteration ends. * @param {number} [count=Infinity] * @return {Tween} */ pingPong(count = Infinity) { this.loopRemaining = count; this.thenCallback = () => tweenPingPongContinuation(this); return this; } /** Pause this tween. While paused, tweenUpdate skips it */ pause() { this.paused = true; } /** Resume a paused tween */ resume() { this.paused = false; } /** Reset this tween to the start: life back to duration, pause cleared, * re-added to the active list if previously stopped, and the callback * re-fired with the start value. * It replays one pass: a loop or pingPong that has finished is not started * over, a pingPong that ended on its way back plays that way again, and a * restart mid loop keeps the iterations left. Call loop or pingPong again * after restart to repeat it. The `then` callback is kept and is called * again when it completes. */ restart() { this.life = this.duration; this.paused = false; this.lastTime = time; this.lastTimeReal = timeReal; tweenActivate(this); tweenCall(this, this.duration); } /** True if this tween is in the active list and not paused * @return {boolean} */ isActive() { return !this.paused && this.active; } /** Get how far this tween has progressed, from 0 (just started) to 1 * (completed). Clamped — overshoot past completion still reads 1. * @return {number} */ getPercent() { return percent(this.duration - this.life, 0, this.duration); } /** Get the current interpolated value (the value most recently passed to * the callback). Returns a number, Vector2, Vector3 or Color depending on the * tween's start/end types. * @return {T} */ getValue() { return this.interp(this.life); } /** Compute the interpolated value at the given remaining `life`. * At life === duration the result is `start`; at life === 0 it is `end`. * - At life 0 it is the end value exactly * - A vector goes past its ends as far as the easing does, as a number does; a Color stays between them, * so its channels stay in range, and any other type goes as far as its own lerp takes it * @param {number} life * @return {T} */ interp(life) { // the ends of whatever type it tweens, each kind is handled below const s = /** @type {any} */ (this.start), e = /** @type {any} */ (this.end); if (life <= 0) // the end exactly, an easing curve may land a rounding error short of it return typeof e.copy === 'function' ? e.copy() : e; const x = this.ease((this.duration - life) / this.duration); // the vectors as their lerp does it, which lands on the end exactly, but without its clamp const y = 1 - x; if (s instanceof Vector2) return /** @type {T} */ (vec2(e.x * x + s.x * y, e.y * x + s.y * y)); if (typeof Vector3 !== 'undefined' && s instanceof Vector3) // a build may leave out the 3D math return /** @type {T} */ (vec3(e.x * x + s.x * y, e.y * x + s.y * y, e.z * x + s.z * y)); if (tweenIsLerpable(s)) return s.lerp(e, x); return s + (e - s) * x; } /** Remove this tween from the active list, ending a loop or pingPong too, without calling * the then-callback. It keeps the then-callback, so a restart calls it when it completes. */ stop() { tweenDeactivate(this); this.thenCallback = undefined; } } /** Library of named easing curves and direction modifiers. * All curves accept `x` in [0,1] and return [0,1] (with possible overshoot * for ELASTIC/BACK/SPRING/BOUNCE). Curves are values you pass to `setEase` * or compose via the IN/OUT/IN_OUT/PIECEWISE/BEZIER modifiers. * @memberof TweenSystem * @example * // Use a basic curve * new Tween(callback, 0, 10, 1).setEase(Ease.SINE); * // Use a modifier on a curve * new Tween(callback, 0, 10, 1).setEase(Ease.OUT(Ease.BACK)); */ const Ease = { /** Linear (identity) curve * @param {number} x * @return {number} * @memberof TweenSystem.Ease */ LINEAR: (x) => x, /** Power curve factory: `Ease.POWER(n)` returns `x => x**n`. * Use n=2 for quadratic, n=3 for cubic, etc. * @param {number} n * @return {function(number):number} * @memberof TweenSystem.Ease */ POWER: (n) => (x) => x ** n, /** Sine ease-in curve: starts slow, ends fast * @param {number} x * @return {number} * @memberof TweenSystem.Ease */ SINE: (x) => 1 - cos(x * (PI / 2)), /** Circular ease-in curve * @param {number} x * @return {number} * @memberof TweenSystem.Ease */ CIRC: (x) => 1 - (1 - x * x)**.5, /** Exponential ease-in curve (`2^(10x-10)`) * @param {number} x * @return {number} * @memberof TweenSystem.Ease */ EXPO: (x) => x === 0 ? 0 : 2 ** (10 * x - 10), /** Back ease-in: overshoots backward at the start before snapping forward * @param {number} x * @return {number} * @memberof TweenSystem.Ease */ BACK: (x) => x * x * (2.70158 * x - 1.70158), /** Elastic ease-in: oscillations that grow toward the end * @param {number} x * @return {number} * @memberof TweenSystem.Ease */ ELASTIC: (x) => x === 0 ? 0 : x === 1 ? 1 : -(2 ** (10 * x - 10)) * sin(((37 - 40 * x) * PI) / 6), /** Spring ease-in: wobbles around the start before springing to the end; * `Ease.OUT(Ease.SPRING)` overshoots and settles on the target. * @param {number} x * @return {number} * @memberof TweenSystem.Ease */ SPRING: (x) => 1 - (sin(PI * (1 - x) * (0.2 + 2.5 * (1 - x) ** 3)) * x ** 2.2 + (1 - x)) * (1.0 + 1.2 * x), /** Bouncing ease-in: small bounces near the start, then a rise to the end. * Symmetric with the other base curves, which are all ease-in. To get the * classic "object falls and hits the ground" shape (bounces near x=1), * wrap with `Ease.OUT`: `Ease.OUT(Ease.BOUNCE)`. * @param {number} x * @return {number} * @memberof TweenSystem.Ease * @example * Ease.BOUNCE // ease-in bounce (bouncy at start) * Ease.OUT(Ease.BOUNCE) // ease-out bounce (object hits ground) * Ease.IN_OUT(Ease.BOUNCE) // bounces at both ends */ BOUNCE: (x) => { // Inverted form of the standard easeOutBounce: 1 - bounceOut(1 - x). let t = 1 - x, f; if (t < 4 / 11) f = 7.5625 * t * t; else if (t < 8 / 11) f = 7.5625 * (t -= 6 / 11) * t + 0.75; else if (t < 10 / 11) f = 7.5625 * (t -= 9 / 11) * t + 0.9375; else f = 7.5625 * (t -= 10.5 / 11) * t + 0.984375; return 1 - f; }, /** Ease-in direction modifier: returns the curve unchanged. Symmetric * with `OUT` and `IN_OUT`. Base curves are already ease-in by * convention, so wrapping a curve in `IN` is a no-op — useful when * picking the direction programmatically. * @param {function(number):number} f - Curve to use as ease-in (returned unchanged) * @return {function(number):number} * @memberof TweenSystem.Ease * @example * // Pick direction at runtime * const dir = bouncyMode ? Ease.OUT : Ease.IN; * new Tween(cb, 0, 10, 1).setEase(dir(Ease.BACK)); */ IN: (f) => f, /** Reverse a curve so it eases out instead of in: `x => 1 - f(1 - x)`. * @param {function(number):number} f * @return {function(number):number} * @memberof TweenSystem.Ease * @example * Ease.OUT(Ease.POWER(2)) // ease-out quadratic */ OUT: (f) => (x) => 1 - f(1 - x), /** Combine the first half of `f` with `Ease.OUT(f)` for a symmetric curve * @param {function(number):number} f * @return {function(number):number} * @memberof TweenSystem.Ease */ IN_OUT: (f) => Ease.PIECEWISE(f, Ease.OUT(f)), /** Split [0,1] into N equal sections and run a different curve in each. * Each curve is mapped to its section: section i runs over [i/n, (i+1)/n] * and its output is mapped to [i/n, (i+1)/n] of the overall range. * @param {...function(number):number} fns * @return {function(number):number} * @memberof TweenSystem.Ease */ PIECEWISE: (...fns) => { const n = fns.length; return (x) => { const i = (x * n - 1e-9) >> 0; return (fns[i]((x - i / n) * n) + i) / n; }; }, /** Cubic Bezier curve solver in the style of CSS `cubic-bezier`. * Control points (0,0), (x1,y1), (x2,y2), (1,1). * @param {number} x1 * @param {number} y1 * @param {number} x2 * @param {number} y2 * @return {function(number):number} * @memberof TweenSystem.Ease * @example * Ease.BEZIER(0.25, 0.1, 0.25, 1) // CSS "ease" */ BEZIER: (x1, y1, x2, y2) => { // Parametric cubic Bezier with implicit (0,0) and (1,1) endpoints. const curve = (t) => { const u = 1 - t; const c1 = 3 * u * u * t; const c2 = 3 * u * t * t; const t3 = t ** 3; return [c1 * x1 + c2 * x2 + t3, c1 * y1 + c2 * y2 + t3]; }; return (x) => { // the ends are exact, a tween starts and ends on its values if (x <= 0) return 0; if (x >= 1) return 1; // binary search for t such that curve(t).x = x, then return curve(t).y; a fixed count, since stopping // once x is close can leave y far off where the curve is steep, 32 halvings put t within 1e-9 let t0 = 0, t1 = 1; for (let i = 32; i--;) { const tMid = (t0 + t1) / 2; if (curve(tMid)[0] < x) t0 = tMid; else t1 = tMid; } return curve((t0 + t1) / 2)[1]; }; }, }; /** Tween a property on an object by dot-path. Returns the underlying Tween * so all chaining methods (`setEase`, `then`, `loop`, `pingPong`, etc.) * remain available. * * `start` and `end` may be numbers, Vector2, Vector3 or Color instances, or * any object with a `lerp(other, percent) => sameType` method. * * It stops on its own once the target is destroyed, so a looping tween on an object ends with it; a tween on a * value inside the object, like `tweenProperty(obj.pos, 'x')`, or a new Tween with its own callback, has to be * stopped by the game. * @template [T=any] * @param {Object} target - The object whose property is being animated * @param {string} propertyPath - Dot-separated path, e.g. `'pos.x'` or `'color'` * @param {T} start - Starting value * @param {T} end - Ending value * @param {number} [duration] - Duration in seconds * @param {Object} [options] - Same options as the Tween constructor * @param {function(number):number} [options.ease] - Easing function (defaults to LINEAR) * @param {boolean} [options.useRealTime=false] - Advance even when the game is paused * @param {boolean} [options.paused=false] - Start in paused state * @return {Tween} * @memberof TweenSystem * @example * // Numeric: slide an object's x with an ease-out sine curve * tweenProperty(player, 'pos.x', 0, 10, 2).setEase(Ease.OUT(Ease.SINE)); * // Vector2: animate a position diagonally * tweenProperty(player, 'pos', vec2(-5, 0), vec2(5, 3), 2); * // Color: pulse between two colors * tweenProperty(sprite, 'color', RED, BLUE, 1).pingPong(); */ function tweenProperty(target, propertyPath, start, end, duration = 1, options = {}) { ASSERT(target != null && typeof target === 'object', 'tweenProperty target must be an object'); ASSERT(isStringLike(propertyPath) && propertyPath.length > 0, 'tweenProperty propertyPath must be a non-empty string'); const parts = propertyPath.split('.'); const lastKey = parts.pop(); let tween; const callback = (value) => { // a destroyed object ends it, a loop or pingPong would run on it for good and keep it from being freed if (target.destroyed) return void tween?.stop(); let obj = target; for (const k of parts) { obj = obj[k]; ASSERT(obj != null, 'tweenProperty path does not resolve: ' + propertyPath); } obj[lastKey] = value; }; tween = new Tween(callback, start, end, duration, options); tween.target = target; return tween; } // Start the next iteration with the time the last one ran over already spent, so a loop keeps its // pace; an update that ran over by more than a whole iteration lands where the cycle is, not owing it function tweenCarryOvershoot(tween) { const duration = tween.duration; tween.life = duration > 0 ? min(tween.life, 0) % duration + duration : 1e-9; } // How many iterations the update that finished one ran through: that one and every whole one after it function tweenPassed(tween) { const duration = tween.duration; return duration > 0 ? 1 + floor(-min(tween.life, 0) / duration) : 1; // no time, or less, is one each update } // start the next iteration of a loop or pingPong, the time the last one ran over already spent, true function tweenNextIteration(tween, passed, continuation) { tween.loopRemaining -= passed; tweenCarryOvershoot(tween); tween.thenCallback = continuation; tweenActivate(tween); tweenCall(tween, tween.life); // snap to where the new iteration is return true; } // Continuation that schedules the next loop iteration when one finishes, true if it did. // Reuses the same Tween across iterations, so the handle from `.loop()` pauses or stops the whole loop. function tweenLoopContinuation(tween) { // count every iteration that went by, a finite loop ends once they run out const passed = tweenPassed(tween); if (tween.loopRemaining <= passed) return false; // Infinity never runs out return tweenNextIteration(tween, passed, () => tweenLoopContinuation(tween)); } // Continuation for pingPong: swaps start and end on the same tween each iteration, true if it started another. function tweenPingPongContinuation(tween) { // swap the ends once for each iteration that went by, but when they run out the last one keeps its // direction, so it ends on the end it really reached and a restart plays that way again const passed = tweenPassed(tween); const done = tween.loopRemaining <= passed; // Infinity never runs out const swaps = done ? max(tween.loopRemaining - 1, 0) : passed; if (swaps & 1) { const tmp = tween.start; tween.start = tween.end; tween.end = tmp; } if (done) { // the completion gave the other end, give the one it finished on if (swaps & 1) tween.callback(tween.interp(0)); return false; } return tweenNextIteration(tween, passed, () => tweenPingPongContinuation(tween)); } /** Engine plugin hook: advance every active tween by the appropriate delta. * The engine calls it with no arguments on every fixed update, so it can run * more than once in a rendered frame, and on paused updates too, where only * real time tweens move. May also be called with `(gameDelta, realDelta)` to drive tweens without the engine * loop, for headless tests or an engine in manual step that is not stepped; in a running game such a call adds to * the engine's own update, and a delta of 0 or less does nothing. * @param {number} [gameDelta] - Game-time delta in seconds; default: game time since the tween's last engine update * @param {number} [realDelta] - Real-time delta in seconds; default: real time since the tween's last engine update * @memberof TweenSystem */ function tweenUpdate(gameDelta, realDelta) { // Engine path: each tween moves by the time since its own last update, so one made // this update, before or after this call, first moves on the next, like a Timer const enginePath = gameDelta === undefined; if (!enginePath && realDelta === undefined) { // Manual path with one arg: real and game advance together. realDelta = gameDelta; } // Move the tweens that were active when this update began, each once: a callback // may stop any tween, even all of them, and one that is stopped is skipped; a tween // made or started again during the update, like the next turn of a loop, moves on // from the next update. Newest first, as the list has always been walked. // a callback that calls tweenUpdate itself gets a list of its own, the outer update is still walking this one const list = tweenUpdateList.length ? [] : tweenUpdateList, pass = ++tweenUpdatePass; let kept = 0; for (const t of tweenActive) if (t.active) list.push(tweenActive[kept++] = t); else t.listed = false; // stopped since the last update, out of the list now tweenActive.length = kept; // the list is let go however the walk ends: a callback that throws must not leave it held, or every // update after would take it for an update still going and make a list of its own let failed; try { for (let i = list.length; i--;) { const t = list[i]; // one that throws does not stop the others: its error comes out once they have all moved try { tweenStep(t, pass, enginePath, gameDelta, realDelta); } catch (error) { failed ??= {error}; } } } finally { list.length = 0; } if (failed) throw failed.error; } // move one tween by an update, as tweenUpdate walks them function tweenStep(t, pass, enginePath, gameDelta, realDelta) { // stopped, or started again by a callback this update, or during an update a callback ran inside it, which // counts on from this one if (!t.active || t.activePass >= pass) return; let dt; if (enginePath) { // a paused tween keeps count too, so it does not jump when resumed dt = t.useRealTime ? timeReal - t.lastTimeReal : time - t.lastTime; t.lastTime = time; t.lastTimeReal = timeReal; } else dt = t.useRealTime ? realDelta : gameDelta; if (t.target?.destroyed) { t.stop(); return; } // its object is gone, paused or not if (t.paused || dt <= 0) return; t.life -= dt; if (t.life > 1e-9) // the engine's deltas add up a rounding error short of the duration { tweenCall(t, t.life, pass); } else { // Completion: fire end value, remove from active, start the next iteration // of a loop or pingPong, or when there is none it has completed, fire onComplete tweenCall(t, 0, pass); // one whose last call throws is ended all the same if (!t.active || t.activePass >= pass) return; // stopped or restarted by its own callback, the run it was on ends without completing tweenDeactivate(t); const next = t.thenCallback; t.thenCallback = undefined; if (!(next && next()) && t.onComplete) t.onComplete(); } } /** Stop every active tween, ending loops too, without calling their then-callbacks. * Useful for resets on level transitions or when changing scenes. * @memberof TweenSystem */ function tweenStopAll() { for (const t of tweenActive) t.thenCallback = undefined, t.active = t.listed = false; tweenActive.length = 0; } // Register with the engine so tweens auto-advance. engineAddPlugin(tweenUpdate); /** * LittleJS Scene System Plugin * - A game's states, like a title, the game and game over, as scene objects * - setScene leaves the current scene, destroys the objects and enters the next * - Objects with the persistent flag set outlive every switch * - Pause is the engine's paused flag, a scene's update stops with gameUpdate * @namespace SceneSystem */ /////////////////////////////////////////////////////////////////////////////// /** A scene, any object with the hooks it needs, each optional and called as a method so this is the scene * @typedef {Object} Scene * @property {function():void} [enter] - Called when the scene starts, after the old one left and the objects were destroyed * @property {function():void} [leave] - Called before the next scene starts, while the scene is current and its objects are still there * @property {function():void} [update] - Called each update after gameUpdate, not while paused or at time scale 0 * @property {function():void} [render] - Called before gameRender, to draw under the objects * @property {function():void} [renderPost] - Called after gameRenderPost, to draw over the game, while paused too; plugins made after the first setScene draw over it * @memberof SceneSystem */ let sceneCurrent, sceneLeaving = false, scenePluginAdded = false; /** Leave the current scene, destroy every object that is not persistent, and enter the next scene * - Setting the current scene again restarts it, and no scene leaves the game with none * - The switch happens at once, it can be called from anywhere but a scene's leave * @param {Scene} [scene] - The scene to enter * @memberof SceneSystem */ function setScene(scene) { ASSERT(!sceneLeaving, 'setScene can not be called from a scene leave'); if (!scenePluginAdded) { scenePluginAdded = true; engineAddPlugin(sceneUpdate, sceneRenderPost, undefined, undefined, sceneRender); } if (sceneCurrent) { // a leave that throws still lets the next switch through sceneLeaving = true; try { sceneCurrent.leave?.(); } finally { sceneLeaving = false; } } engineObjectsDestroy(); sceneCurrent = scene; scene?.enter?.(); } /** Get the current scene * @return {Scene|undefined} * @memberof SceneSystem */ function getScene() { return sceneCurrent; } // the plugin hooks, update skips the frozen ticks that skip gameUpdate function sceneUpdate() { paused || !timeScale || sceneCurrent?.update?.(); } function sceneRender() { sceneCurrent?.render?.(); } function sceneRenderPost() { sceneCurrent?.renderPost?.(); } /** * LittleJS Parallax Plugin * - A background layer that follows the camera by a part of its movement, so it looks far away * - Repeats across the view, so a level of any width has a background * - Draws mountains by default, or anything a game draws into it * @namespace Parallax */ /////////////////////////////////////////////////////////////////////////////// /** * A background layer with parallax: an image that follows the camera by a part of its movement and repeats across * the view, drawn once into a canvas by a function, mountains when none is given * - A far layer has a parallax near 1 and a low renderOrder, a near one a smaller parallax and a higher renderOrder * - The image should meet itself at its left and right edges to repeat without a seam, as the mountains do * @memberof Parallax * @extends CanvasLayer * @example * // three ranges of mountains, the far ones lighter and slower * for (let i = 3; i--;) * new ParallaxLayer(vec2(0, i*2), vec2(40, 20), .9 - i*.2, -1e3 + i, * parallaxMountains(hsl(.6, .3, .7 - i*.2), hsl(.6, .5, .2), i)); * * // or draw your own image into the layer * new ParallaxLayer(vec2(), vec2(40, 20), .5, -1e3, (context, size)=> * { * context.fillStyle = '#fff'; * context.fillRect(size.x/2 - 20, size.y/2 - 20, 40, 40); * }); */ class ParallaxLayer extends CanvasLayer { /** Create a parallax layer and draw its image * @param {Vector2} [pos] - Where the middle of the image is in the world when the camera is there too * @param {Vector2} [size] - World size of one copy of the image * @param {number|Vector2} [parallax] - How much of the camera's movement it follows, 0 stays with the world * and 1 with the screen, a Vector2 to follow x and y by different amounts * @param {number} [renderOrder] - Low to draw behind the game, far layers lowest * @param {function(OffscreenCanvasRenderingContext2D, Vector2, ParallaxLayer): void} [drawFunction] - Draws * the image, given the canvas context, its size in pixels and the layer; mountains when not given; called * once when the layer is made and on redraw(), not each frame, so it draws with the canvas 2D context, not * the engine's draws like drawTile * @param {Vector2} [canvasSize] - Size of the image in pixels */ constructor(pos=vec2(), size=vec2(32, 16), parallax=.5, renderOrder=-1e3, drawFunction=parallaxMountains(), canvasSize=vec2(512, 256)) { ASSERT(isNumber(parallax) || isVector2(parallax), 'parallax must be a number or a Vector2'); ASSERT(typeof drawFunction == 'function', 'drawFunction must be a function'); super(pos, size, 0, renderOrder, canvasSize); this.castShadow = false; // a backdrop, it would throw huge shadows in the light system, a sun's above all /** @property {Vector2} - How much of the camera's movement it follows on each axis, 0 stays with the world * and 1 with the screen */ this.parallax = isVector2(parallax) ? /** @type {Vector2} */ (parallax).copy() : vec2(/** @type {number} */ (parallax)); /** @property {boolean} - Repeat the image across the view, left and right */ this.wrapX = true; /** @property {boolean} - Repeat the image up and down the view */ this.wrapY = false; /** @property {number} - How much the camera's zoom changes its size on the screen: 1 like the world, 0 not * at all, the same on the screen at any zoom as it is at zoomScale */ this.zoomFollow = 1; /** @property {number} - The camera scale its size is given for, used when zoomFollow is under 1 */ this.zoomScale = cameraScale; /** @property {function(OffscreenCanvasRenderingContext2D, Vector2, ParallaxLayer): void} - Draws the * image, redraw() after changing it */ this.drawFunction = drawFunction; this.redraw(); } /** Draw the image again with drawFunction, after changing it or what it draws from */ redraw() { const context = this.context; if (!context) return; // headless const size = vec2(this.canvas.width, this.canvas.height); context.clearRect(0, 0, size.x, size.y); this.drawFunction(context, size, this); this.updateWebGL(); } // how much smaller it is drawn than its size, from the camera's zoom and how much of it the layer follows zoomFactor() { return this.zoomFollow < 1 && cameraScale > 0 ? (this.zoomScale / cameraScale) ** (1 - this.zoomFollow) : 1; } /** World size one copy of the image is drawn at now, its size unless zoomFollow is under 1 * @return {Vector2} */ getDrawSize() { return this.size.scale(this.zoomFactor()); } /** Where each copy of the image is drawn now, in the world: one place, or with wrapping every place that * shows in the view * @return {Array} */ getDrawPositions() { // its place seen from the camera is cut down by the parallax, and by the zoom it does not follow const zoom = this.zoomFactor(), size = this.size.scale(zoom), p = this.parallax; const offset = this.pos.subtract(cameraPos); const center = cameraPos.add(vec2(offset.x * (1 - p.x) * zoom, offset.y * (1 - p.y) * zoom)); // the copies that reach into the view; a turned camera sees as far as its corner each way const view = mainCanvasSize.scale(.5 / (cameraScale || 1)); const reach = cameraAngle ? vec2(view.length()) : view; const range = (wrap, axis)=> { if (!wrap || !(size[axis] > 0)) return [0, 0]; const from = cameraPos[axis] - reach[axis] - size[axis] / 2 - center[axis]; const to = cameraPos[axis] + reach[axis] + size[axis] / 2 - center[axis]; const first = ceil(from / size[axis]); return [first, min(floor(to / size[axis]), first + 99)]; // a tiny image does not draw without end }; const [x0, x1] = range(this.wrapX, 'x'), [y0, y1] = range(this.wrapY, 'y'), positions = []; for (let y = y0; y <= y1; ++y) for (let x = x0; x <= x1; ++x) positions.push(vec2(center.x + x * size.x, center.y + y * size.y)); return positions; } // Render the layer, called automatically by the engine render() { const size = this.getDrawSize(); for (const pos of this.getDrawPositions()) this.draw(pos, size, this.color, 0, this.mirror, this.additiveColor, false); } } /////////////////////////////////////////////////////////////////////////////// // the ridge of a range of mountains: a height for each column of an image and one more, the last the same as the // first so the image repeats with no step; it wanders about the middle, turning now and then function parallaxRidge(width, height, random) { const pointiness = .2, levelness = .005, slopeRange = height / 256; // how often it turns, how hard it is const ridge = new Float32Array(width + 1), middle = height / 2; // pulled level, how steep it gets let y = middle, slope = random.float(-slopeRange, slopeRange); for (let x = 0; x <= width; ++x) { ridge[x] = y; y += slope -= (y - middle) * levelness; if (random.float() < pointiness) slope = random.float(-slopeRange, slopeRange); } // take out the difference between its ends, a little from each column, and keep it inside the image const drift = ridge[width] - ridge[0]; for (let x = 0; x <= width; ++x) ridge[x] = clamp(ridge[x] - drift * x / width, 0, height); return ridge; } /** A draw function for a ParallaxLayer: a range of mountains across the image, shaded from the color of their * peaks down to the color of their feet, that repeats with no seam * @param {Color} [topColor] - Color at the peaks * @param {Color} [bottomColor] - Color at the bottom of the image * @param {number} [seed] - The same seed gives the same mountains, random when not given * @return {function(OffscreenCanvasRenderingContext2D, Vector2): void} * @memberof Parallax */ function parallaxMountains(topColor=hsl(.6, .2, .6), bottomColor=hsl(.6, .3, .25), seed) { return (context, size)=> { const w = size.x, h = size.y; const random = new RandomGenerator(seed === undefined ? randInt(1e9) + 1 : seed * 7919 + 1); const ridge = parallaxRidge(w, h, random); // a strip one pixel wide with the shading, stretched down each column from its peak const strip = createCanvasContext(1, h); for (let i = h; i--;) { strip.fillStyle = topColor.lerp(bottomColor, i / h).toString(); strip.fillRect(0, i, 1, 1); } for (let x = w; x--;) ridge[x] < h && context.drawImage(strip.canvas, 0, 0, 1, h, x, ridge[x], 1, h - ridge[x]); }; } /** * LittleJS PathFinder Plugin * - Grid-based A* pathfinder with two-pass smoothing for natural-looking paths * - Works directly on a TileCollisionLayer, or override isWalkable/getCost for any grid * - Debug visualization via engine debug primitives (stripped in release builds) * - Port of frankforce.com pathFindingBase.cpp (2018) * @namespace PathFinding */ /////////////////////////////////////////////////////////////////////////////// // Diagonal step cost — pre-computed for the A* expansion inner loop. const PATHFINDER_DIAGONAL_COST = Math.SQRT2; // Shared 1x1 size vector for per-tile debugRect calls. debugRect copies the // argument internally, so reusing one instance is safe. const PATHFINDER_TILE_VEC = vec2(1); /////////////////////////////////////////////////////////////////////////////// /** A single grid cell tracked by the pathfinder. Allocated once per cell at * PathFinder construction; reset (not reallocated) at the start of every * findPath call. * @memberof PathFinding */ class PathFinderNode { /** Make the node of a grid cell, as PathFinder does for each * @param {number} x - Tile x * @param {number} y - Tile y */ constructor(x, y) { /** @property {Vector2} - Tile coords (integer) */ this.pos = vec2(x, y); /** @property {Vector2} - World-space center of this tile (set by buildNodeData) */ this.posWorld = vec2(); /** @property {boolean} - True if this cell is passable (set by buildNodeData) */ this.walkable = false; /** @property {number} - Extra cost added to A* G-score for stepping on this cell */ this.cost = 0; /** @property {number} - A* G-score: actual cost from start to this node */ this.g = 0; /** @property {number} - A* F-score: G + heuristic */ this.f = 0; /** @property {number} - A* heuristic: the estimated cost left to the goal, breaks ties between equal F */ this.h = 0; /** @property {PathFinderNode|null} - Parent for path reconstruction * @type {PathFinderNode|null} */ this.parent = null; /** @property {boolean} - In the A* open list */ this.isOpen = false; /** @property {boolean} - In the A* closed list */ this.isClosed = false; /** @property {number} - Where it is in the A* open list's heap, while open */ this.heapIndex = 0; } /** Clear what a search left on this node, keeping walkable and cost */ resetSearch() { this.g = this.f = this.h = 0; this.parent = null; this.isOpen = this.isClosed = false; } /** Reset per-search state and walkability (called by buildNodeData) */ reset() { this.walkable = false; this.cost = 0; this.resetSearch(); } /** True if walkable and not blocked by cost * @return {boolean} */ isClear() { return this.walkable && this.cost === 0; } } /////////////////////////////////////////////////////////////////////////////// /** Grid pathfinder using A* with two optional smoothing passes * @memberof PathFinding * @example * // Tile-layer driven (most common): * const pf = new PathFinder(myTileCollisionLayer); * const path = pf.findPath(player.pos, mousePos); * * // Bare grid with custom walkability: * const gridFinder = new PathFinder(vec2(50, 50)); * gridFinder.isWalkable = (x, y) => myGrid[y*50 + x] === 0; */ class PathFinder { /** Make a path finder over a tile layer or a grid * @param {TileCollisionLayer|Vector2} source - Either a TileCollisionLayer * (size and walkability auto-derived) or a Vector2 grid size (user * overrides isWalkable). */ constructor(source) { // Accept a Vector2 size, or a TileCollisionLayer or any object with its size and getCollisionData if (isVector2(source)) { /** @property {Vector2} - Grid dimensions in tiles * @type {Vector2} */ this.size = /** @type {Vector2} */ (source).floor(); /** @property {TileCollisionLayer|undefined} - Tile layer driving walkability, if any * @type {TileCollisionLayer|undefined} */ this.tileLayer = undefined; } else { const layer = /** @type {TileCollisionLayer} */ (source); ASSERT(layer && isVector2(layer.size) && typeof layer.getCollisionData === 'function', 'PathFinder requires a Vector2 size or a TileCollisionLayer'); this.size = layer.size; this.tileLayer = layer; } // Tunables (public, freely re-assignable). /** @property {number} - A* heuristic multiplier (1 = admissible, higher = greedier) */ this.heuristicWeight = 1; /** @property {number|undefined} - Most A* expansions before giving up, undefined for the number of cells, * so a search always finishes; a lower one caps the time a search takes, see searchGaveUp * @type {number|undefined} */ this.maxLoop = undefined; /** @property {boolean} - True when the last search stopped at maxLoop with no path, so it gave up rather * than that there is no way through */ this.searchGaveUp = false; /** @property {boolean} - If true, post-process paths with two-pass smoothing */ this.smoothPath = true; /** @property {boolean} - If true, draw debug visualization during findPath */ this.debug = false; /** @property {number} - Debug primitive lifetime in seconds (0 disables drawing) */ this.debugTime = 1; /** @property {Array} - Flat row-major array of size.x*size.y nodes * @type {Array} */ this.nodes = new Array(this.size.x * this.size.y); for (let y = 0; y < this.size.y; ++y) for (let x = 0; x < this.size.x; ++x) this.nodes[x + y * this.size.x] = new PathFinderNode(x, y); // Whether buildNodeData has run, a search without a rebuild builds it the first time. /** @private */ this.nodeDataBuilt = false; // Scratch Vector2 reused to avoid allocations in the isWalkable hot path. /** @private */ this.collisionScratch = vec2(); // The nodes the last search changed, reset before the next one so a // search without a rebuild starts as fresh as one after it. /** @type {Array} * @private */ this.searchNodes = []; } /** Default walkability: if a tile layer was provided, returns true when the * cell has no solid (positive) collision data, so negative data is open * like it is to the engine's collision; otherwise returns true. Override on * the instance or via a subclass. * @param {number} x - Tile x * @param {number} y - Tile y * @return {boolean} */ isWalkable(x, y) { if (!this.tileLayer) return true; return !(this.tileLayer.getCollisionData(this.collisionScratch.set(x, y)) > 0); } /** Default extra cost for stepping on a cell. Returns 0 (free) by default. * Override to add cost-weighted terrain (mud, swamp, etc). * @param {number} x - Tile x * @param {number} y - Tile y * @return {number} */ getCost(x, y) { return 0; } /** Get the node at tile coords, or null if out of bounds * @param {number} x * @param {number} y * @return {PathFinderNode|null} */ getNode(x, y) { if (x < 0 || y < 0 || x >= this.size.x || y >= this.size.y) return null; return this.nodes[x + y * this.size.x]; } /** Convert a world-space position to integer tile coords (no clamping) * @param {Vector2} worldPos * @return {Vector2} */ worldToTile(worldPos) { const ox = this.tileLayer ? this.tileLayer.pos.x : 0; const oy = this.tileLayer ? this.tileLayer.pos.y : 0; return vec2(floor(worldPos.x - ox), floor(worldPos.y - oy)); } /** Convert integer tile coords to the world-space center of that tile * @param {number} x * @param {number} y * @return {Vector2} */ tileToWorld(x, y) { const ox = this.tileLayer ? this.tileLayer.pos.x : 0; const oy = this.tileLayer ? this.tileLayer.pos.y : 0; return vec2(x + 0.5 + ox, y + 0.5 + oy); } /** Reset all nodes and re-populate walkable / cost / posWorld from the * current isWalkable / getCost overrides. Called at the start of * findPath; call it directly before searches made with rebuild=false. */ buildNodeData() { this.nodeDataBuilt = true; const w = this.size.x; const h = this.size.y; const ox = this.tileLayer ? this.tileLayer.pos.x : 0; const oy = this.tileLayer ? this.tileLayer.pos.y : 0; for (let y = 0; y < h; ++y) for (let x = 0; x < w; ++x) { const node = this.nodes[x + y * w]; node.reset(); const walkable = !!this.isWalkable(x, y); const cost = walkable ? max(0, this.getCost(x, y)) : 0; node.walkable = walkable; node.cost = cost; node.posWorld.set(x + 0.5 + ox, y + 0.5 + oy); if (this.debug && this.debugTime > 0) { if (!walkable) debugRect(node.posWorld, PATHFINDER_TILE_VEC, rgb(1, 0, 0, 0.25), this.debugTime, 0, false, false); else if (cost > 0) debugRect(node.posWorld, PATHFINDER_TILE_VEC, rgb(1, 0, 0, min(0.2, cost * 0.05)), this.debugTime, 0, false, false); } } } /** Core A* search loop. Expects buildNodeData() to have been called first. * Marks node.parent for path reconstruction. Returns true if endNode was * reached; false on disconnected goal or maxLoop exhaustion, which sets searchGaveUp. * @param {PathFinderNode} startNode * @param {PathFinderNode} endNode * @return {boolean} * @private */ aStarSearch(startNode, endNode) { ASSERT(startNode && endNode, 'aStarSearch needs both endpoints'); ASSERT(startNode !== endNode, 'aStarSearch: start and end must differ — caller should handle trivial case'); ASSERT(startNode.walkable && endNode.walkable, 'aStarSearch: endpoints must be walkable'); // Undo what the last search changed, so one without a rebuild starts // from the same state; after buildNodeData these are fresh already. const searchNodes = this.searchNodes; for (const n of searchNodes) n.resetSearch(); searchNodes.length = 0; searchNodes.push(startNode); // The open list is a binary heap with the smallest f score on top, so a big map searches quickly. // Equal scores go to the node nearer the goal, so open ground is // crossed nearly straight instead of widening in a band of ties; // the path is just as short, only which of equal paths can change. // Scores are sums of diagonals, so equal is within a hair. const openList = []; const isBetter = (a, b)=> a.f < b.f - 1e-9 || a.f < b.f + 1e-9 && a.h < b.h; const siftUp = (node)=> { // a new node, or one whose score went down, moves up past the ones it now beats let i = node.heapIndex; while (i) { const parent = (i - 1) >> 1; if (!isBetter(node, openList[parent])) break; (openList[i] = openList[parent]).heapIndex = i; i = parent; } (openList[i] = node).heapIndex = i; }; const popBest = ()=> { // take the top, then the last node sinks down from the top to where it belongs const best = openList[0], last = openList.pop(); if (last !== best) { let i = 0; for (;;) { const left = 2*i + 1, right = left + 1; if (left >= openList.length) break; const child = right < openList.length && isBetter(openList[right], openList[left]) ? right : left; if (!isBetter(openList[child], last)) break; (openList[i] = openList[child]).heapIndex = i; i = child; } (openList[i] = last).heapIndex = i; } return best; }; startNode.isOpen = true; startNode.heapIndex = openList.length; siftUp(startNode); const maxLoop = this.maxLoop ?? this.size.x * this.size.y; let loopCount = 0; this.searchGaveUp = false; while (openList.length > 0) { const current = openList[0]; if (current === endNode) break; if (++loopCount > maxLoop) { // the goal may be found already, waiting its turn, and then the path to it comes back this.searchGaveUp = !endNode.parent; break; } // Move current from open to closed. popBest(); current.isOpen = false; current.isClosed = true; if (this.debug && this.debugTime > 0) debugRect(current.posWorld, PATHFINDER_TILE_VEC, rgb(1, 1, 1, 0.05), this.debugTime, 0, false, false); // Expand all 8 neighbors. for (let dy = -1; dy <= 1; ++dy) for (let dx = -1; dx <= 1; ++dx) { if (dx === 0 && dy === 0) continue; const neighbor = this.getNode(current.pos.x + dx, current.pos.y + dy); if (!neighbor || !neighbor.walkable || neighbor.isClosed) continue; let stepCost = 1; if (dx !== 0 && dy !== 0) { // Diagonal step: refuse if either cardinal neighbor is // blocked. Prevents cutting through walls at corners. // (Costed-but-walkable cardinals do not block — diagonal // movement around expensive terrain is standard A*.) const card1 = this.getNode(current.pos.x + dx, current.pos.y); if (!card1 || !card1.walkable) continue; const card2 = this.getNode(current.pos.x, current.pos.y + dy); if (!card2 || !card2.walkable) continue; stepCost = PATHFINDER_DIAGONAL_COST; } const tentativeG = current.g + stepCost + neighbor.cost; const wasOpen = neighbor.isOpen; if (!wasOpen) { neighbor.isOpen = true; neighbor.heapIndex = openList.length; openList.push(neighbor); searchNodes.push(neighbor); } else if (tentativeG >= neighbor.g) { continue; } // Best path so far through neighbor — record it. neighbor.parent = current; neighbor.g = tentativeG; // Octile heuristic — tightest admissible distance for an // 8-connected grid with cardinal cost 1 and diagonal cost √2. const adx = abs(endNode.pos.x - neighbor.pos.x); const ady = abs(endNode.pos.y - neighbor.pos.y); const h = max(adx, ady) + (Math.SQRT2 - 1) * min(adx, ady); neighbor.h = h; neighbor.f = neighbor.g + h * this.heuristicWeight; siftUp(neighbor); // its place in the heap, new or with a lower score now } } return endNode.parent !== null; } /** Find the clear (walkable, zero-cost) node closest to the given world * position. Spirals outward in expanding boxes until a clear node is * found or the search range is exhausted. Useful for snapping a click * or NPC spawn position to the nearest open tile. * * By default, calls `buildNodeData()` first so it works correctly on a * fresh PathFinder. If you're calling it many times in a row with * unchanged walkability, pass `rebuild=false` and call `buildNodeData()` * once externally to avoid redundant work. * @param {Vector2} worldPos * @param {number} [searchRange] - Max box-radius in tiles * @param {boolean} [rebuild] - Whether to call buildNodeData first * @return {PathFinderNode|null} */ getNearestClearNode(worldPos, searchRange = 10, rebuild = true) { ASSERT(isVector2(worldPos), 'worldPos must be a Vector2'); if (rebuild || !this.nodeDataBuilt) this.buildNodeData(); // a grid never built has nothing to walk yet return pathFinderNearestNode(this, worldPos, searchRange, (node)=> node.isClear()); } /** Smooth a node path by removing redundant turns and tightening corners * where a grid-aligned diagonal is clear. Modifies the path in place. * Stays on the grid — does not introduce off-tile-center points. * Port of ShortenPath() in pathFinding.cpp. * @param {PathFinderNode[]} path * @private */ smoothPathCorners(path) { if (path.length <= 2) return; let i = 1; while (i < path.length - 1) { const prev = path[i - 1]; const node = path[i]; const next = path[i + 1]; const dx = next.pos.x - prev.pos.x; const dy = next.pos.y - prev.pos.y; const lenSq = dx * dx + dy * dy; // stepDx,stepDy is the prev-to-node step and stepDxNext,stepDyNext the node-to-next step; the 135° // case uses them to tell mostly vertical from mostly horizontal, the straight case to tell a line // from a bump const stepDx = node.pos.x - prev.pos.x; const stepDy = node.pos.y - prev.pos.y; const stepDxNext = next.pos.x - node.pos.x; const stepDyNext = next.pos.y - node.pos.y; if (lenSq === 1) { // 45° angle — middle node is off the straight line. Drop it. if (this.debug && this.debugTime > 0) debugCircle(node.posWorld, 0.3, rgb(0.5, 0, 0.5, 0.5), this.debugTime, false, false); path.splice(i, 1); i = max(1, i - 1); continue; } else if (lenSq === 2) { // 90° corner. Check the alternative-diagonal cell. if (this.debug && this.debugTime > 0) debugCircle(node.posWorld, 0.3, rgb(1, 0, 0, 0.5), this.debugTime, false, false); let sx, sy; if (prev.pos.y === node.pos.y && next.pos.x === node.pos.x) { sx = prev.pos.x; sy = next.pos.y; } else { sx = next.pos.x; sy = prev.pos.y; } const shortcut = this.getNode(sx, sy); if (shortcut && shortcut.isClear()) { path.splice(i, 1); i = max(1, i - 1); continue; } } else if (lenSq === 5) { // 135° angle (a knight's-move offset). Try to relocate the // middle node to whichever of two candidate cells is closer // to prev-of-prev, and only if the corner cut is also clear. if (this.debug && this.debugTime > 0) debugCircle(node.posWorld, 0.3, rgb(1, 1, 0, 0.5), this.debugTime, false, false); const prevPrev = i >= 2 ? path[i - 2] : prev; let s1x, s1y, s2x, s2y; if (stepDx === 0 || stepDxNext === 0) { // mostly vertical s1x = next.pos.x; s1y = node.pos.y; s2x = prev.pos.x; s2y = node.pos.y; } else { // mostly horizontal s1x = node.pos.x; s1y = next.pos.y; s2x = node.pos.x; s2y = prev.pos.y; } const dd1x = s1x - prevPrev.pos.x; const dd1y = s1y - prevPrev.pos.y; const dd2x = s2x - prevPrev.pos.x; const dd2y = s2y - prevPrev.pos.y; const dist1Sq = dd1x * dd1x + dd1y * dd1y; const dist2Sq = dd2x * dd2x + dd2y * dd2y; const useFirst = dist1Sq < dist2Sq; const sx = useFirst ? s1x : s2x, sy = useFirst ? s1y : s2y; const shortcut = this.getNode(sx, sy); if (shortcut && shortcut !== node && shortcut.isClear()) { // Also check the cut-corner cell is clear. const ccx = next.pos.x + s2x - s1x; const ccy = next.pos.y + s2y - s1y; const cutCorner = this.getNode(ccx, ccy); if (cutCorner && cutCorner.isClear()) { path[i] = shortcut; i = max(1, i - 1); continue; } } } else if (lenSq === 4 || lenSq === 8) { // Straight line or a 1-cell bump. if (this.debug && this.debugTime > 0) debugCircle(node.posWorld, 0.3, rgb(0, 1, 0, 0.5), this.debugTime, false, false); if (stepDx === stepDxNext && stepDy === stepDyNext) { // Truly straight — nothing to do, advance. ++i; continue; } else { // Bump — try to flatten via the in-line cell. let sx, sy; if (prev.pos.y === next.pos.y) { sx = node.pos.x; sy = prev.pos.y; } else { sx = prev.pos.x; sy = node.pos.y; } const shortcut = this.getNode(sx, sy); if (shortcut && shortcut.isClear()) { path[i] = shortcut; i = max(1, i - 1); continue; } } } ++i; } } /** Smooth a node path via line-of-sight ("string pulling"). Walks the * input path collapsing runs of nodes into straight segments whenever * isLineClear permits, so the result can leave grid centers and cut * cleanly across open spaces. * * A node with a cost is kept, and a shortcut only runs between clear * nodes: isLineClear passes only through clear cells, so a straight line * it accepts costs no more than the grid path it replaces. * * Replaces the port of ShortenPath2() in pathFinding.cpp, which could * add a segment it had not checked. * @param {PathFinderNode[]} path * @private */ smoothPathStringPull(path) { if (path.length <= 2) return; // Greedy: from each kept node, jump to the furthest corner with a clear // line to it, then on along the straight run past it as far as one is // clear, or else the next node. Every segment is one isLineClear // accepted or one the path already had, so none can cross a wall. // Corners first, so a winding path costs a check per corner and not // per cell of it. const original = path.slice(), last = original.length - 1; const corners = []; for (let i = 1; i < last; ++i) { const a = original[i - 1].pos, b = original[i].pos, c = original[i + 1].pos; if ((b.x - a.x) * (c.y - a.y) !== (b.y - a.y) * (c.x - a.x)) corners.push(i); } corners.push(last); path.length = 0; path.push(original[0]); for (let k = 0; k < last;) { let j = k + 1; if (original[k].isClear()) // isLineClear needs both ends clear { const clear = (i)=> original[i].isClear() && this.isLineClear(original[k].pos, original[i].pos); let c = corners.length - 1, next = last + 1; // next is the corner past the one found while (c >= 0 && corners[c] > k + 1 && !clear(corners[c])) next = corners[c--]; j = c >= 0 && corners[c] > k + 1 ? corners[c] : k + 1; for (let i = next - 1; i > j; --i) if (clear(i)) { j = i; break; } } path.push(original[j]); k = j; } } /** Drop any middle node that lies exactly on the line through its two * neighbors. Backstop for the smoothing passes — the corners pass * intentionally keeps truly-straight runs, and the string-pulling pass * falls back to the next node where no longer line is clear, so it can * leave 3+ collinear nodes in some edge cases. * @param {PathFinderNode[]} path * @private */ dropCollinearNodes(path) { // one pass, a node kept only where the way turns from the last one kept if (path.length < 3) return; let kept = 1; for (let i = 1; i < path.length - 1; ++i) { const a = path[kept - 1], b = path[i], c = path[i + 1]; if ((b.pos.x - a.pos.x) * (c.pos.y - a.pos.y) !== (b.pos.y - a.pos.y) * (c.pos.x - a.pos.x)) path[kept++] = b; } path[kept++] = path[path.length - 1]; path.length = kept; } /** Lookup helper: true when the node at tile coords (x, y) is in-bounds * and clear (walkable, zero-cost). Used by isLineClear's hot path. * @param {number} x * @param {number} y * @return {boolean} * @private */ isNodeClear(x, y) { const n = this.getNode(x, y); return n !== null && n.isClear(); } /** Check that the line between two tile-coord endpoints stays entirely * inside walkable, zero-cost cells. Stricter than just sampling along * the line — it also checks the diagonal-corner-adjacent cells so the * line can never "scrape past" a wall corner. * * Both endpoints must themselves be clear (asserted in debug). Port of * CheckLine() in pathFinding.cpp. * @param {Vector2} startPos - Tile coords * @param {Vector2} endPos - Tile coords * @return {boolean} * @private */ isLineClear(startPos, endPos) { ASSERT(isVector2(startPos) && isVector2(endPos), 'isLineClear needs Vector2 endpoints'); ASSERT(this.isNodeClear(startPos.x, startPos.y) && this.isNodeClear(endPos.x, endPos.y), 'isLineClear endpoints must be in-bounds and clear'); const dx = endPos.x - startPos.x; const dy = endPos.y - startPos.y; const adx = abs(dx); const ady = abs(dy); const sx = sign(dx); const sy = sign(dy); let x = startPos.x; let y = startPos.y; if (ady === adx) { // Pure diagonal. while (x !== endPos.x) { if (x !== startPos.x) { if (!this.isNodeClear(x, y)) return false; if (!this.isNodeClear(x, y - sy)) return false; } if (!this.isNodeClear(x, y + sy)) return false; x += sx; y += sy; } if (!this.isNodeClear(endPos.x, endPos.y - sy)) return false; } else if (ady < adx) { // Mostly horizontal. if (dy === 0) { // Purely horizontal. x += sx; while (x !== endPos.x) { if (!this.isNodeClear(x, y)) return false; x += sx; } } else { let lastY = startPos.y; while (x !== endPos.x) { y = startPos.y + Math.trunc((dy * (x - startPos.x)) / dx); if (lastY !== y) { if (!this.isNodeClear(x - sx, y + sy)) return false; if (!this.isNodeClear(x, y - sy)) return false; } lastY = y; if (x !== startPos.x) { if (!this.isNodeClear(x, y)) return false; } y += sy; if (!this.isNodeClear(x, y)) return false; x += sx; } const finalY = endPos.y - sy; if (!this.isNodeClear(endPos.x, finalY)) return false; } } else { // Mostly vertical. if (dx === 0) { y += sy; while (y !== endPos.y) { if (!this.isNodeClear(x, y)) return false; y += sy; } } else { let lastX = startPos.x; while (y !== endPos.y) { x = startPos.x + Math.trunc((dx * (y - startPos.y)) / dy); if (lastX !== x) { if (!this.isNodeClear(x + sx, y - sy)) return false; if (!this.isNodeClear(x - sx, y)) return false; } lastX = x; if (y !== startPos.y) { if (!this.isNodeClear(x, y)) return false; } x += sx; if (!this.isNodeClear(x, y)) return false; y += sy; } const finalX = endPos.x - sx; if (!this.isNodeClear(finalX, endPos.y)) return false; } } return true; } /** Find a path from startPos to endPos in world space. Returns an array * of world-space Vector2 points; empty array if no path exists. * * Start and end are snapped to the nearest walkable tile (a costed one * counts), within 10 tiles. Intermediate points are tile centers unless the * string-pulling smoothing pass moves them off-grid. * * By default, calls `buildNodeData()` first, which asks isWalkable and * getCost about every cell. When finding many paths with unchanged * walkability, pass `rebuild=false` and call `buildNodeData()` once * externally; the paths found are the same. * @param {Vector2} startPos - World-space start * @param {Vector2} endPos - World-space end * @param {boolean} [rebuild] - Whether to call buildNodeData first * @return {Vector2[]} */ findPath(startPos, endPos, rebuild = true) { ASSERT(isVector2(startPos) && isVector2(endPos), 'findPath needs Vector2 endpoints'); this.searchGaveUp = false; if (rebuild || !this.nodeDataBuilt) this.buildNodeData(); // a grid never built has nothing to walk yet // rebuild=false because we just built — avoid redundant work per snap. // the ends go to the nearest cell that can be walked, whatever it costs to cross const walkable = (node)=> node.walkable; const startNode = pathFinderNearestNode(this, startPos, 10, walkable); const endNode = pathFinderNearestNode(this, endPos, 10, walkable); if (!startNode || !endNode) return []; // Trivial case: start and end snapped to the same tile. if (startNode === endNode) return [startNode.posWorld.copy()]; if (!this.aStarSearch(startNode, endNode)) return []; // Walk back from endNode via parent pointers, then reverse — cheaper // than unshifting on every step. const nodePath = []; for (let n = endNode; n; n = n.parent) nodePath.push(n); nodePath.reverse(); if (this.smoothPath) { this.smoothPathCorners(nodePath); this.smoothPathStringPull(nodePath); this.dropCollinearNodes(nodePath); } // Convert to world-space Vector2 path. Return copies, not live node // references — callers shouldn't be able to mutate the grid. const result = nodePath.map(n => n.posWorld.copy()); if (this.debug && this.debugTime > 0 && result.length > 0) { for (let i = 1; i < result.length; ++i) debugLine(result[i - 1], result[i], RED, 0.1, this.debugTime, false); for (const p of result) debugCircle(p, 0.5, rgb(1, 0, 0, 0.3), this.debugTime, false, false); debugCircle(result[0], 0.5, rgb(0, 1, 0, 0.5), this.debugTime, false, false); debugCircle(result[result.length - 1], 0.5, rgb(0, 1, 0, 0.5), this.debugTime, false, false); } return result; } } // the node nearest a world position that passes a test, within a range of tiles, or null: the rings of cells around // the position are searched outward until the next ring cannot hold anything nearer than the best found, since the // best of one ring is not always the nearest, a cell one ring out can be closer function pathFinderNearestNode(finder, worldPos, searchRange, test) { const center = finder.worldToTile(worldPos); const centerX = center.x, centerY = center.y; let nearest = null, nearestDistSq = 0; for (let offset = 0; offset <= searchRange; ++offset) { // every cell of this ring is more than offset - .5 away along one axis const bound = max(0, offset - .5); if (nearest && bound * bound >= nearestDistSq) break; // only the ring itself, the inside was searched already: its top and bottom rows whole, its two ends between, // in the same row by row order a full walk of the square visits them in, so ties pick the same node for (let dy = -offset; dy <= offset; ++dy) for (let dx = -offset, step = abs(dy) === offset ? 1 : 2*offset; dx <= offset; dx += step) { const node = finder.getNode(centerX + dx, centerY + dy); if (!node || !test(node)) continue; const ddx = node.posWorld.x - worldPos.x; const ddy = node.posWorld.y - worldPos.y; const distSq = ddx * ddx + ddy * ddy; if (!nearest || distSq < nearestDistSq) { nearest = node; nearestDistSq = distSq; } } } return nearest; } /** * LittleJS 3D Math Plugin * - Vector3 and Matrix4 for 3D games and plugins * - Right handed, Y up, angles in radians * - Used by the Render3D plugin, but has no rendering dependencies * @namespace Math3D */ /////////////////////////////////////////////////////////////////////////////// /** * Create a 3D vector, can take 0, 1, 2 or 3 numbers * - vec3() is zero, vec3(s) fills all three, vec3(x, y) sets z to 0 * @param {number} [x] * @param {number} [y] * @param {number} [z] * @return {Vector3} * @memberof Math3D */ function vec3(x=0, y, z) { return y === undefined ? new Vector3(x, x, x) : new Vector3(x, y, z === undefined ? 0 : z); } /** * Check if the object is a valid Vector3 * @param {any} v * @return {boolean} * @memberof Math3D */ function isVector3(v) { return v instanceof Vector3 && v.isValid(); } // debug check that a value is a usable Vector3, stripped in release like the 2D one function ASSERT_VECTOR3_VALID(v) { ASSERT(isVector3(v), 'Vector3 is invalid.', v); } /** * Returns a random Vector3 of a given length, pointing any direction evenly, or within a cone around +Y * @param {number} [length] * @param {number} [coneAngle] - Half angle of the cone around +Y in radians, PI is every direction * @return {Vector3} * @memberof Math3D */ function randVector3(length=1, coneAngle=PI) { // a random height on the sphere is uniform over its surface, then a random turn around Y const y = rand(cos(coneAngle), 1), s = (1 - y * y) ** .5, a = rand(2 * PI); return new Vector3(s * cos(a) * length, y * length, s * sin(a) * length); } /** * Returns a random Vector3 inside a sphere, spread evenly through its volume, the 3D twin of randInCircle * @param {number} [radius] * @param {number} [minRadius] - Leave a hollow middle this big * @return {Vector3} * @memberof Math3D */ function randInSphere(radius=1, minRadius=0) { // the volume inside a radius grows with its cube, so that is what has to come out even if (radius <= 0) return new Vector3; const ratio = clamp(minRadius / radius); return randVector3(radius * rand(ratio**3, 1) ** (1/3)); } /** * 3D Vector object, right handed with Y up * - Methods return new vectors except set and setFrom * @memberof Math3D * @example * const a = vec3(1, 2, 3); * const b = a.add(vec3(0, 1, 0)).normalize(); */ class Vector3 { /** Create a 3D vector * @param {number} [x] * @param {number} [y] * @param {number} [z] */ constructor(x=0, y=0, z=0) { ASSERT(isNumber(x) && isNumber(y) && isNumber(z), 'Vector3 components must be numbers'); /** @property {number} - X axis location */ this.x = x; /** @property {number} - Y axis location */ this.y = y; /** @property {number} - Z axis location */ this.z = z; } /** Sets values of this vector and returns self * @param {number} [x] * @param {number} [y] * @param {number} [z] * @return {Vector3} */ set(x=0, y=0, z=0) { this.x = x; this.y = y; this.z = z; ASSERT_VECTOR3_VALID(this); return this; } /** Copies the values of another vector into this one and returns self * @param {Vector3} v * @return {Vector3} */ setFrom(v) { return this.set(v.x, v.y, v.z); } /** Returns a new vector that is a copy of this * @return {Vector3} */ copy() { return new Vector3(this.x, this.y, this.z); } /** Returns a copy of this vector plus the vector passed in * @param {Vector3} v * @return {Vector3} */ add(v) { return new Vector3(this.x + v.x, this.y + v.y, this.z + v.z); } /** Returns a copy of this vector minus the vector passed in * @param {Vector3} v * @return {Vector3} */ subtract(v) { return new Vector3(this.x - v.x, this.y - v.y, this.z - v.z); } /** Returns a copy of this vector times the vector passed in * @param {Vector3} v * @return {Vector3} */ multiply(v) { return new Vector3(this.x * v.x, this.y * v.y, this.z * v.z); } /** Returns a copy of this vector divided by the vector passed in * @param {Vector3} v * @return {Vector3} */ divide(v) { return new Vector3(this.x / v.x, this.y / v.y, this.z / v.z); } /** Returns a copy of this vector scaled by the number passed in * @param {number} s * @return {Vector3} */ scale(s) { return new Vector3(this.x * s, this.y * s, this.z * s); } /** Returns the length of this vector * @return {number} */ length() { return this.lengthSquared()**.5; } /** Returns the length of this vector squared * @return {number} */ lengthSquared() { return this.x**2 + this.y**2 + this.z**2; } /** Returns a copy of this vector reflected by a surface normal * @param {Vector3} normal - Surface normal, should be normalized * @param {number} [restitution] - How much to bounce, 1 is a perfect bounce, 0 slides along the surface * @return {Vector3} */ reflect(normal, restitution=1) { return this.subtract(normal.scale((1 + restitution) * this.dot(normal))); } /** Returns the distance from this vector to the vector passed in * @param {Vector3} v * @return {number} */ distance(v) { return this.distanceSquared(v)**.5; } /** Returns the distance squared from this vector to the vector passed in * @param {Vector3} v * @return {number} */ distanceSquared(v) { return (this.x - v.x)**2 + (this.y - v.y)**2 + (this.z - v.z)**2; } /** Returns a new vector in the same direction with the length passed in, zero stays zero * @param {number} [length] * @return {Vector3} */ normalize(length=1) { const l = this.length(); return l ? this.scale(length/l) : new Vector3; } /** Returns a new vector clamped to the length passed in * @param {number} [length] * @return {Vector3} */ clampLength(length=1) { const l = this.length(); return l > length ? this.scale(length/l) : this.copy(); } /** Returns the dot product of this vector and the vector passed in * @param {Vector3} v * @return {number} */ dot(v) { return this.x*v.x + this.y*v.y + this.z*v.z; } /** Returns a vector at right angles to both this and the one passed in * @param {Vector3} v * @return {Vector3} */ cross(v) { return new Vector3( this.y*v.z - this.z*v.y, this.z*v.x - this.x*v.z, this.x*v.y - this.y*v.x); } /** Returns a new vector interpolated between this and the vector passed in, percent is clamped to 0-1 * @param {Vector3} v * @param {number} percent * @return {Vector3} */ lerp(v, percent) { ASSERT_VECTOR3_VALID(v); return this.add(v.subtract(this).scale(clamp(percent))); } /** Returns a new vector turned around an axis, counter clockwise when the axis points at you * @param {Vector3} axis - Unit length * @param {number} angle - Radians * @return {Vector3} */ rotate(axis, angle) { ASSERT_VECTOR3_VALID(axis); // unlike Vector2.rotate this takes an axis first // Rodrigues' formula: the part along the axis stays, the rest turns const c = cos(angle), s = sin(angle), d = axis.dot(this) * (1 - c); return this.scale(c).add(axis.cross(this).scale(s)).add(axis.scale(d)); } /** Returns a new vector turned around the X axis, the way a positive pitch in rotation3D turns things * @param {number} angle - Radians * @return {Vector3} */ rotateX(angle) { const c = cos(angle), s = sin(angle); return new Vector3(this.x, this.y*c - this.z*s, this.y*s + this.z*c); } /** Returns a new vector turned around the Y axis, the way a positive yaw in rotation3D turns things * @param {number} angle - Radians * @return {Vector3} */ rotateY(angle) { const c = cos(angle), s = sin(angle); return new Vector3(this.x*c + this.z*s, this.y, this.z*c - this.x*s); } /** Returns a new vector turned around the Z axis, the way a positive roll in rotation3D turns things * @param {number} angle - Radians * @return {Vector3} */ rotateZ(angle) { const c = cos(angle), s = sin(angle); return new Vector3(this.x*c - this.y*s, this.x*s + this.y*c, this.z); } /** Returns a new vector with the absolute value of each component * @return {Vector3} */ abs() { return new Vector3(abs(this.x), abs(this.y), abs(this.z)); } /** Returns a new vector with each component floored * @return {Vector3} */ floor() { return new Vector3(floor(this.x), floor(this.y), floor(this.z)); } /** Returns a new vector with each component rounded * @return {Vector3} */ round() { return new Vector3(round(this.x), round(this.y), round(this.z)); } /** Returns a new vector snapped down to a grid, grid is the number of steps per unit like Vector2.snap * @param {number} grid - Snap steps per unit, 2 snaps to halves * @return {Vector3} */ snap(grid) { ASSERT_NUMBER_VALID(grid); return new Vector3(floor(this.x*grid)/grid, floor(this.y*grid)/grid, floor(this.z*grid)/grid); } /** Returns this point transformed by a matrix, translation included * @param {Matrix4} matrix * @return {Vector3} */ transform(matrix) { return matrix.transformPoint(this); } /** Returns this direction transformed by a matrix, rotation and scale only * @param {Matrix4} matrix * @return {Vector3} */ transformDirection(matrix) { return matrix.transformDirection(this); } /** Checks if this is a valid vector * @return {boolean} */ isValid() { return isNumber(this.x) && isNumber(this.y) && isNumber(this.z); } /** Returns a string representation of this vector for debugging * @param {number} [digits] - Number of digits to display * @return {string} */ toString(digits=3) { if (!this.isValid()) return `(${this.x},${this.y},${this.z})`; // show the bad values instead of throwing const f = (v)=> (v < 0 ? '' : ' ') + v.toFixed(digits); return `(${f(this.x)},${f(this.y)},${f(this.z)} )`; } } /////////////////////////////////////////////////////////////////////////////// // scratch for multiply, nothing keeps a reference to it const matrix4Scratch = new Float32Array(16); /** * 4x4 transform matrix for moving, rotating and scaling points in 3D * - Static builders like Matrix4.translation return a new matrix * - Methods on a matrix change it in place and return it, so calls can chain * - a.multiply(b) means b happens first, then a * - Stored the way WebGL wants it, so it can be sent to a shader as is * @memberof Math3D * @example * const m = buildMatrix(vec3(0, 1, 0), vec3(0, PI/2, 0)); // rotate then move up * const p = m.transformPoint(vec3(1, 0, 0)); */ class Matrix4 { /** Create a matrix, identity by default * @param {Float32Array|Array} [m] - 16 column major values */ constructor(m) { /** @property {Float32Array} - The 16 column major values */ this.m = new Float32Array(16); ASSERT(!m || m.length == 16, 'Matrix4 takes 16 values, use copy() to duplicate a matrix'); if (m) this.m.set(m); else this.m[0] = this.m[5] = this.m[10] = this.m[15] = 1; } /** Returns a new identity matrix * @return {Matrix4} */ static identity() { return new Matrix4; } /** Returns a new translation matrix * @param {Vector3} v * @return {Matrix4} */ static translation(v) { ASSERT_VECTOR3_VALID(v); const r = new Matrix4; r.m[12] = v.x; r.m[13] = v.y; r.m[14] = v.z; return r; } /** Returns a rotation matrix, rolled first, then pitched, then yawed * @param {Vector3} euler - vec3(pitch, yaw, roll) in radians * @param {Matrix4} [matrix] - Written into instead of a new one, for a loop that builds many * @return {Matrix4} */ static rotation(euler, matrix=new Matrix4) { ASSERT_VECTOR3_VALID(euler); matrix4RotationAt(matrix.m, 0, euler); return matrix; } /** Returns a new scale matrix * @param {Vector3} v * @return {Matrix4} */ static scaling(v) { ASSERT_VECTOR3_VALID(v); const r = new Matrix4; r.m[0] = v.x; r.m[5] = v.y; r.m[10] = v.z; return r; } /** Returns a new perspective projection, camera looks down -Z * @param {number} fov - Vertical field of view in radians * @param {number} aspect - Width divided by height * @param {number} near - Closest visible distance * @param {number} far - Furthest visible distance, Infinity is allowed * @return {Matrix4} */ static perspective(fov, aspect, near, far) { ASSERT(near > 0 && far > near, 'a perspective projection needs 0 < near < far, or nothing is visible', near, far); const f = 1 / tan(fov/2); const r = new Matrix4; const m = r.m; m[0] = f / aspect; m[5] = f; m[10] = far == Infinity ? -1 : (far + near) / (near - far); // the infinite case is the limit of the formula m[11] = -1; m[14] = far == Infinity ? -2 * near : 2 * far * near / (near - far); m[15] = 0; return r; } /** Returns a new orthographic projection, camera looks down -Z * @param {number} left - Edge of the visible box * @param {number} right - Edge of the visible box * @param {number} bottom - Edge of the visible box * @param {number} top - Edge of the visible box * @param {number} near - Closest visible distance * @param {number} far - Furthest visible distance, Infinity is not allowed here * @return {Matrix4} */ static orthographic(left, right, bottom, top, near, far) { // an infinite far plane has no orthographic form: every depth would land on the near plane, // and the formula below works out to NaN, which quietly clips the whole scene away ASSERT(far > near && far != Infinity, 'an orthographic projection needs a real far plane past near, Infinity is perspective only', near, far); const r = new Matrix4; const m = r.m; m[0] = 2 / (right - left); m[5] = 2 / (top - bottom); m[10] = -2 / (far - near); m[12] = -(right + left) / (right - left); m[13] = -(top + bottom) / (top - bottom); m[14] = -(far + near) / (far - near); return r; } /** Returns the transform of something at eye turned to face target * - Invert it to get a view matrix for a camera there * @param {Vector3} eye * @param {Vector3} target * @param {Vector3} [up] * @return {Matrix4} */ static lookAt(eye, target, up=vec3(0, 1, 0)) { let z = eye.subtract(target).normalize(); if (!z.lengthSquared()) z = vec3(0, 0, 1); // eye is on the target, face -Z let x = up.cross(z).normalize(); // up is along the view direction, pick another; looking straight down or up keeps +X as the right axis, // as three.js does and as a view tilting there from the +Z side ends up if (!x.lengthSquared()) x = (abs(z.y) > .99 ? vec3(0, 0, z.y > 0 ? -1 : 1) : vec3(0, 1, 0)).cross(z).normalize(); const y = z.cross(x); return new Matrix4([x.x, x.y, x.z, 0, y.x, y.y, y.z, 0, z.x, z.y, z.z, 0, eye.x, eye.y, eye.z, 1]); } /** Returns a new matrix that is a copy of this * @return {Matrix4} */ copy() { return new Matrix4(this.m); } /** Multiply this matrix by another and return this, the other happens first * @param {Matrix4} matrix * @return {Matrix4} */ multiply(matrix) { const a = this.m, b = matrix.m, r = matrix4Scratch; for (let j = 0; j < 4; ++j) for (let i = 0; i < 4; ++i) r[j*4 + i] = a[i]*b[j*4] + a[4 + i]*b[j*4 + 1] + a[8 + i]*b[j*4 + 2] + a[12 + i]*b[j*4 + 3]; this.m.set(r); return this; } /** Append a translation, returns self * @param {Vector3} v * @return {Matrix4} */ translate(v) { return this.multiply(Matrix4.translation(v)); } /** Append a rotation, returns self * @param {Vector3} euler - vec3(pitch, yaw, roll) in radians * @return {Matrix4} */ rotate(euler) { return this.multiply(Matrix4.rotation(euler)); } /** Append a scale, returns self * @param {Vector3} v * @return {Matrix4} */ scale(v) { return this.multiply(Matrix4.scaling(v)); } /** Transpose this matrix in place, returns self * @return {Matrix4} */ transpose() { const m = this.m; for (let i = 0; i < 4; ++i) for (let j = i + 1; j < 4; ++j) { const t = m[i*4 + j]; m[i*4 + j] = m[j*4 + i]; m[j*4 + i] = t; } return this; } /** Flip this matrix so it undoes itself, returns this and does nothing if it cannot be inverted * @return {Matrix4} */ invert() { const m = this.m; const [a00, a01, a02, a03, a10, a11, a12, a13, a20, a21, a22, a23, a30, a31, a32, a33] = m; const b00 = a00*a11 - a01*a10, b01 = a00*a12 - a02*a10, b02 = a00*a13 - a03*a10; const b03 = a01*a12 - a02*a11, b04 = a01*a13 - a03*a11, b05 = a02*a13 - a03*a12; const b06 = a20*a31 - a21*a30, b07 = a20*a32 - a22*a30, b08 = a20*a33 - a23*a30; const b09 = a21*a32 - a22*a31, b10 = a21*a33 - a23*a31, b11 = a22*a33 - a23*a32; let det = b00*b11 - b01*b10 + b02*b09 + b03*b08 - b04*b07 + b05*b06; if (!det) return this; det = 1 / det; m[0] = (a11*b11 - a12*b10 + a13*b09) * det; m[1] = (a02*b10 - a01*b11 - a03*b09) * det; m[2] = (a31*b05 - a32*b04 + a33*b03) * det; m[3] = (a22*b04 - a21*b05 - a23*b03) * det; m[4] = (a12*b08 - a10*b11 - a13*b07) * det; m[5] = (a00*b11 - a02*b08 + a03*b07) * det; m[6] = (a32*b02 - a30*b05 - a33*b01) * det; m[7] = (a20*b05 - a22*b02 + a23*b01) * det; m[8] = (a10*b10 - a11*b08 + a13*b06) * det; m[9] = (a01*b08 - a00*b10 - a03*b06) * det; m[10] = (a30*b04 - a31*b02 + a33*b00) * det; m[11] = (a21*b02 - a20*b04 - a23*b00) * det; m[12] = (a11*b07 - a10*b09 - a12*b06) * det; m[13] = (a00*b09 - a01*b07 + a02*b06) * det; m[14] = (a31*b01 - a30*b03 - a32*b00) * det; m[15] = (a20*b03 - a21*b01 + a22*b00) * det; return this; } /** Transform a point, translation included * @param {Vector3} v * @return {Vector3} */ transformPoint(v) { const m = this.m; return new Vector3( m[0]*v.x + m[4]*v.y + m[8]*v.z + m[12], m[1]*v.x + m[5]*v.y + m[9]*v.z + m[13], m[2]*v.x + m[6]*v.y + m[10]*v.z + m[14]); } /** Transform a direction, rotation and scale only * @param {Vector3} v * @return {Vector3} */ transformDirection(v) { const m = this.m; return new Vector3( m[0]*v.x + m[4]*v.y + m[8]*v.z, m[1]*v.x + m[5]*v.y + m[9]*v.z, m[2]*v.x + m[6]*v.y + m[10]*v.z); } /** Returns the translation part of this matrix * @return {Vector3} */ getTranslation() { return new Vector3(this.m[12], this.m[13], this.m[14]); } /** Returns the determinant of the rotation and scale part: negative when the matrix mirrors, 0 when it flattens a * shape and has no inverse * @return {number} */ determinant() { const m = this.m; return m[0]*(m[5]*m[10] - m[6]*m[9]) - m[4]*(m[1]*m[10] - m[2]*m[9]) + m[8]*(m[1]*m[6] - m[2]*m[5]); } /** Returns the scale part of this matrix, the length of each axis; a mirroring matrix shows as a negative x * @return {Vector3} */ getScale() { const m = this.m; const x = hypot(m[0], m[1], m[2]), y = hypot(m[4], m[5], m[6]), z = hypot(m[8], m[9], m[10]); return new Vector3(this.determinant() < 0 ? -x : x, y, z); } /** Returns the rotation part of this matrix as vec3(pitch, yaw, roll), the angles Matrix4.rotation builds it from * - The scale is divided out first, so it works on a full transform * - A matrix with shear, from a scaled parent with a turned child, has no exact answer and gets the nearest * @return {Vector3} */ getRotation() { // the axes at unit length, see Matrix4.rotation for which element is which const m = this.m, s = this.getScale(); const sx = s.x || 1, sy = s.y || 1, sz = s.z || 1; const m1 = m[1] / sx, m5 = m[5] / sy, m8 = m[8] / sz, m9 = m[9] / sz, m10 = m[10] / sz; // the pitch from its cosine as well as its sine, which stays exact near straight up where the sine alone // loses its precision const c = hypot(m8, m10), pitch = atan2(-m9, c); if (c > 1e-9) return new Vector3(pitch, atan2(m8, m10), atan2(m1, m5)); // straight up or down: yaw and roll turn about the same axis, so the roll is zero and yaw takes it all return new Vector3(pitch, atan2(m[4] / sy * -m9, m[0] / sx), 0); } /** Returns a string representation of this matrix for debugging * @return {string} */ toString() { const m = this.m, f = (i)=> m[i].toFixed(2).padStart(7); let s = ''; for (let row = 0; row < 4; ++row) s += `[${f(row)} ${f(4 + row)} ${f(8 + row)} ${f(12 + row)} ]\n`; return s; } } /////////////////////////////////////////////////////////////////////////////// /** * Build a transform for an object from its position, rotation and scale * - A point is scaled first, then rotated, then moved, which is what you want for a game object * @param {Vector3} [pos] * @param {Vector3} [rotation] - vec3(pitch, yaw, roll) in radians * @param {Vector3} [scale] * @param {Matrix4} [matrix] - Written into instead of a new one, for a loop that builds many * @return {Matrix4} * @memberof Math3D */ function buildMatrix(pos, rotation, scale, matrix=new Matrix4) { ASSERT(matrix instanceof Matrix4, 'the matrix to write into must be a Matrix4'); matrix4Compose(matrix.m, 0, pos, rotation, scale); return matrix; } // the rotation matrix of euler angles written into 16 floats of an array at k function matrix4RotationAt(m, k, euler) { const cx = cos(euler.x), sx = sin(euler.x); const cy = cos(euler.y), sy = sin(euler.y); const cz = cos(euler.z), sz = sin(euler.z); // R = Ry * Rx * Rz written out, column major, every element set so a reused matrix comes out clean m[k] = cy*cz + sy*sx*sz; m[k+1] = cx*sz; m[k+2] = -sy*cz + cy*sx*sz; m[k+3] = 0; m[k+4] = -cy*sz + sy*sx*cz; m[k+5] = cx*cz; m[k+6] = sy*sz + cy*sx*cz; m[k+7] = 0; m[k+8] = sy*cx; m[k+9] = -sx; m[k+10] = cy*cx; m[k+11] = 0; m[k+12] = m[k+13] = m[k+14] = 0; m[k+15] = 1; } // the transform buildMatrix makes, written into 16 floats of an array at k, so an instance buffer takes it straight in // the rotation columns are scaled and the position dropped in, instead of multiplying three matrices, each value // worked out whole and stored once; InstancedMesh3D.setTransforms writes the same expressions for many at a time function matrix4Compose(m, k, pos, rotation, scale) { ASSERT(!pos || isVector3(pos), 'pos must be a Vector3', pos); ASSERT(!scale || isVector3(scale), 'scale must be a Vector3', scale); const scaleX = scale ? scale.x : 1, scaleY = scale ? scale.y : 1, scaleZ = scale ? scale.z : 1; // an object that is not turned at all is most of a big scene, and identity is what the six // trig calls would have worked out to anyway if (rotation && (rotation.x || rotation.y || rotation.z)) { ASSERT_VECTOR3_VALID(rotation); const cx = cos(rotation.x), sx = sin(rotation.x); const cy = cos(rotation.y), sy = sin(rotation.y); const cz = cos(rotation.z), sz = sin(rotation.z); // R = Ry * Rx * Rz written out, column major, as matrix4RotationAt, each column times its scale m[k] = (cy*cz + sy*sx*sz) * scaleX; m[k+1] = cx*sz * scaleX; m[k+2] = (-sy*cz + cy*sx*sz) * scaleX; m[k+4] = (-cy*sz + sy*sx*cz) * scaleY; m[k+5] = cx*cz * scaleY; m[k+6] = (sy*sz + cy*sx*cz) * scaleY; m[k+8] = sy*cx * scaleZ; m[k+9] = -sx * scaleZ; m[k+10] = cy*cx * scaleZ; } else { m[k] = scaleX; m[k+1] = 0; m[k+2] = 0; m[k+4] = 0; m[k+5] = scaleY; m[k+6] = 0; m[k+8] = 0; m[k+9] = 0; m[k+10] = scaleZ; } m[k+3] = m[k+7] = m[k+11] = 0; m[k+15] = 1; m[k+12] = pos ? pos.x : 0, m[k+13] = pos ? pos.y : 0, m[k+14] = pos ? pos.z : 0; } /////////////////////////////////////////////////////////////////////////////// /** * Ray3D - A start point and a direction, what screenToRay returns and the raycast helpers take * - The direction need not be unit length, the distances that come back are in units of it * @memberof Math3D * @example * const ray = render3D.screenToRay(mousePosScreen); * const distance = raycastPlane(ray, vec3(), vec3(0, 1, 0)); * if (distance !== undefined) * ball.pos3D = ray.getPosition(distance); */ class Ray3D { /** Create a ray * @param {Vector3} [origin] * @param {Vector3} [direction] - Defaults to -Z, forward */ constructor(origin=vec3(), direction=vec3(0, 0, -1)) { ASSERT_VECTOR3_VALID(origin); ASSERT_VECTOR3_VALID(direction); /** @property {Vector3} - Where the ray starts */ this.origin = origin; /** @property {Vector3} - Which way it goes */ this.direction = direction; } /** Returns the point a distance along the ray * @param {number} distance - What the raycast helpers return * @return {Vector3} */ getPosition(distance) { return this.origin.add(this.direction.scale(distance)); } /** Returns a new ray that is a copy of this * @return {Ray3D} */ copy() { return new Ray3D(this.origin.copy(), this.direction.copy()); } } /////////////////////////////////////////////////////////////////////////////// // 3D collision helpers, none of them change anything that is passed in // Boxes sit centered on pos and take a full size, like drawRect, upright unless given a rotation, an Euler vec3 like // rotation3D, which turns them about their center // Cylinders stand up the Y axis, centered on pos, with a full height // Names that could be mistaken for 2D functions get a 3D suffix // whether a rotation turns anything, a missing one does not const isTurned3D = (rotation)=> !!rotation && !!(rotation.x || rotation.y || rotation.z); // a turned box's three unit axes, the columns of its rotation function boxAxes3D(rotation) { const m = buildMatrix(undefined, rotation).m; return [vec3(m[0], m[1], m[2]), vec3(m[4], m[5], m[6]), vec3(m[8], m[9], m[10])]; } // a point in a turned box's own space, from its center, and a vector from that space back to the world's function boxLocal3D(point, pos, axes) { const d = point.subtract(pos); return vec3(d.dot(axes[0]), d.dot(axes[1]), d.dot(axes[2])); } const boxWorld3D = (v, axes)=> axes[0].scale(v.x).add(axes[1].scale(v.y)).add(axes[2].scale(v.z)); /** * Check if a point is inside a box, boundary is inclusive * @param {Vector3} point * @param {Vector3} pos - Center of the box * @param {Vector3} size - Full size of the box * @param {Vector3} [rotation] - How the box is turned, upright when left out * @return {boolean} * @memberof Math3D */ function isPointInBox3D(point, pos, size, rotation) { if (isTurned3D(rotation)) return isPointInBox3D(boxLocal3D(point, pos, boxAxes3D(rotation)), vec3(), size); return abs(point.x - pos.x) <= size.x/2 && abs(point.y - pos.y) <= size.y/2 && abs(point.z - pos.z) <= size.z/2; } /** * Check if two boxes are overlapping, touching edges do not overlap * @param {Vector3} posA * @param {Vector3} sizeA - Full size of box A * @param {Vector3} posB * @param {Vector3} [sizeB] - Full size of box B, zero for a point * @param {Vector3} [rotationA] - How box A is turned, upright when left out * @param {Vector3} [rotationB] - How box B is turned * @return {boolean} * @memberof Math3D */ function isOverlapping3D(posA, sizeA, posB, sizeB=vec3(), rotationA, rotationB) { if (isTurned3D(rotationA) || isTurned3D(rotationB)) return !!collideBoxBox3D(posA, sizeA, posB, sizeB, rotationA, rotationB); const d = posA.subtract(posB); return abs(d.x) < (sizeA.x + sizeB.x)/2 && abs(d.y) < (sizeA.y + sizeB.y)/2 && abs(d.z) < (sizeA.z + sizeB.z)/2; } /** * Returns the vector to move sphere A by so it no longer overlaps sphere B, or undefined * @param {Vector3} posA * @param {number} radiusA * @param {Vector3} posB * @param {number} radiusB * @return {Vector3|undefined} * @memberof Math3D */ function collideSphereSphere(posA, radiusA, posB, radiusB) { const d = posA.subtract(posB); const r = radiusA + radiusB; const dist = d.length(); if (dist >= r) return undefined; if (!dist) return vec3(0, r, 0); // coincident centers, push straight up return d.normalize(r - dist); } /** * Returns the vector to move a sphere out of a box, or undefined * @param {Vector3} pos - Sphere center * @param {number} radius * @param {Vector3} boxPos * @param {Vector3} boxSize - Full size of the box * @param {Vector3} [boxRotation] - How the box is turned, upright when left out * @return {Vector3|undefined} * @memberof Math3D */ function collideSphereBox(pos, radius, boxPos, boxSize, boxRotation) { if (isTurned3D(boxRotation)) return collideSphereOrientedBox3D(pos, radius, boxPos, boxSize, boxAxes3D(boxRotation)); const h = boxSize.scale(.5); const closest = vec3( clamp(pos.x, boxPos.x - h.x, boxPos.x + h.x), clamp(pos.y, boxPos.y - h.y, boxPos.y + h.y), clamp(pos.z, boxPos.z - h.z, boxPos.z + h.z)); const d = pos.subtract(closest), distSq = d.lengthSquared(); if (distSq) return distSq >= radius*radius ? undefined : d.normalize(radius - distSq**.5); // center is inside the box, push out along the axis of least penetration const offset = pos.subtract(boxPos); return pushOutAxis3D(offset, h.x - abs(offset.x), h.y - abs(offset.y), h.z - abs(offset.z), radius); } /** * Returns the vector to move a sphere back inside an axis aligned box, or undefined when it is all inside * - The inside out twin of collideSphereBox, for keeping things in a room or an arena * - A sphere too big for the box on some axis is held at the middle of it on that axis * @param {Vector3} pos - Sphere center * @param {number} radius * @param {Vector3} boxPos * @param {Vector3} boxSize - Full size of the box * @return {Vector3|undefined} * @memberof Math3D */ function collideSphereInBox(pos, radius, boxPos, boxSize) { // the box shrunk by the radius is everywhere the center can be, so clamp the center into it const x = max(0, boxSize.x/2 - radius), y = max(0, boxSize.y/2 - radius), z = max(0, boxSize.z/2 - radius); const push = vec3( clamp(pos.x, boxPos.x - x, boxPos.x + x) - pos.x, clamp(pos.y, boxPos.y - y, boxPos.y + y) - pos.y, clamp(pos.z, boxPos.z - z, boxPos.z + z) - pos.z); return push.lengthSquared() ? push : undefined; } // the axis with the smallest penetration, pointing the way d does, with extra distance added function pushOutAxis3D(d, penX, penY, penZ, extra=0) { const s = (v)=> v >= 0 ? 1 : -1; // sign() gives 0 on a tie, which would be no push if (penX <= penY && penX <= penZ) return vec3(s(d.x)*(penX + extra), 0, 0); if (penY <= penZ) return vec3(0, s(d.y)*(penY + extra), 0); return vec3(0, 0, s(d.z)*(penZ + extra)); } /** * Returns the vector to move a sphere out of a vertical cylinder, or undefined * @param {Vector3} pos - Sphere center * @param {number} radius * @param {Vector3} cylinderPos * @param {number} cylinderRadius * @param {number} cylinderHeight - Full height along Y * @return {Vector3|undefined} * @memberof Math3D */ function collideSphereCylinder(pos, radius, cylinderPos, cylinderRadius, cylinderHeight) { const halfHeight = cylinderHeight/2; const offsetX = pos.x - cylinderPos.x; const offsetZ = pos.z - cylinderPos.z; const offsetY = pos.y - cylinderPos.y; const radialDist = (offsetX**2 + offsetZ**2)**.5; const radialScale = radialDist ? min(radialDist, cylinderRadius)/radialDist : 0; // pull the point onto the wall, or the axis const closest = vec3( cylinderPos.x + offsetX*radialScale, clamp(pos.y, cylinderPos.y - halfHeight, cylinderPos.y + halfHeight), cylinderPos.z + offsetZ*radialScale); const d = pos.subtract(closest), distSq = d.lengthSquared(); if (distSq) return distSq >= radius*radius ? undefined : d.normalize(radius - distSq**.5); // center is inside the cylinder, push out through the nearer surface const sidePen = cylinderRadius - radialDist; const capPen = halfHeight - abs(offsetY); if (sidePen <= capPen) { const dir = radialDist ? vec3(offsetX/radialDist, 0, offsetZ/radialDist) : vec3(1, 0, 0); return dir.scale(sidePen + radius); } return vec3(0, (offsetY >= 0 ? 1 : -1)*(capPen + radius), 0); } /** * Returns the vector to move box A by so it no longer overlaps box B, the shortest way out, or undefined * - The 3D twin of collideBoxBox * - Turned boxes are tested by the separating axis test, the push is along the direction they overlap least on * @param {Vector3} posA * @param {Vector3} sizeA - Full size of box A * @param {Vector3} posB * @param {Vector3} sizeB - Full size of box B * @param {Vector3} [rotationA] - How box A is turned, upright when left out * @param {Vector3} [rotationB] - How box B is turned * @return {Vector3|undefined} * @memberof Math3D */ function collideBoxBox3D(posA, sizeA, posB, sizeB, rotationA, rotationB) { if (isTurned3D(rotationA) || isTurned3D(rotationB)) return collideOrientedBoxes3D(posA, sizeA, boxAxes3D(rotationA), posB, sizeB, boxAxes3D(rotationB)); const d = posA.subtract(posB); const overlapX = (sizeA.x + sizeB.x)/2 - abs(d.x); const overlapY = (sizeA.y + sizeB.y)/2 - abs(d.y); const overlapZ = (sizeA.z + sizeB.z)/2 - abs(d.z); if (overlapX <= 0 || overlapY <= 0 || overlapZ <= 0) return undefined; return pushOutAxis3D(d, overlapX, overlapY, overlapZ); } // the push to move a sphere out of a box turned to these axes: in the box's own space it is upright, and the push // goes back out turned function collideSphereOrientedBox3D(pos, radius, boxPos, boxSize, axes) { const push = collideSphereBox(boxLocal3D(pos, boxPos, axes), radius, vec3(), boxSize); return push && boxWorld3D(push, axes); } // the world's axes, for an upright box among turned ones const BOX_WORLD_AXES = Object.freeze([vec3(1, 0, 0), vec3(0, 1, 0), vec3(0, 0, 1)]); // the shortest push to move box A out of box B by the separating axis test: each box's three face directions and // the nine across an edge of each; a gap on any of them is no touch, and the push is along the least overlap, from // B to A; an edge direction has to overlap clearly less to win, so a box lying on another leaves by the face function collideOrientedBoxes3D(posA, sizeA, axesA, posB, sizeB, axesB) { const d = posA.subtract(posB), ha = sizeA.scale(.5), hb = sizeB.scale(.5); const reach = (h, axes, n)=> h.x * abs(axes[0].dot(n)) + h.y * abs(axes[1].dot(n)) + h.z * abs(axes[2].dot(n)); let push, least = Infinity; const separated = (axis, weight)=> { const lengthSquared = axis.lengthSquared(); if (lengthSquared < 1e-12) return false; // parallel edges give no direction to test const n = axis.scale(lengthSquared ** -.5), along = d.dot(n); const overlap = reach(ha, axesA, n) + reach(hb, axesB, n) - abs(along); if (overlap <= 0) return true; if (overlap * weight < least) least = overlap * weight, push = n.scale(along < 0 ? -overlap : overlap); return false; }; for (const axis of [...axesA, ...axesB]) if (separated(axis, 1)) return; for (const a of axesA) for (const b of axesB) if (separated(a.cross(b), 1.05)) return; return push; } /** * Returns the distance along the ray to the first intersection with a sphere, or undefined * - The hit is ray.getPosition(distance), a direction that is not unit length scales the distance * - A ray starting inside the sphere is already there, so it gets back 0 * @param {Ray3D} ray * @param {Vector3} pos - Sphere center * @param {number} radius * @return {number|undefined} * @memberof Math3D */ function raycastSphere(ray, pos, radius) { // in numbers, as a pick runs it for every object const {origin, direction} = ray; const x = origin.x - pos.x, y = origin.y - pos.y, z = origin.z - pos.z; const c = x*x + y*y + z*z - radius*radius; if (c < 0) return 0; // origin is inside the sphere, even for a ray of no length, as raycastBox gives const a = direction.dot(direction); if (!a) return undefined; const b = 2*(x*direction.x + y*direction.y + z*direction.z); const discriminant = b*b - 4*a*c; if (discriminant < 0) return undefined; const t = (-b - discriminant**.5)/(2*a); return t >= 0 ? t : undefined; } /** * Returns the distance along the ray to a plane, or undefined if parallel or behind * - The hit is ray.getPosition(distance), a direction that is not unit length scales the distance * @param {Ray3D} ray * @param {Vector3} planePos * @param {Vector3} planeNormal * @return {number|undefined} * @memberof Math3D */ function raycastPlane(ray, planePos, planeNormal) { const {origin, direction} = ray; const denominator = direction.dot(planeNormal); if (abs(denominator) < 1e-9) return undefined; const t = planePos.subtract(origin).dot(planeNormal)/denominator; return t < 0 ? undefined : t; } /** * Returns the distance along the ray to the first intersection with a box, or undefined * - The hit is ray.getPosition(distance), a direction that is not unit length scales the distance * - A ray starting inside the box is already there, so it gets back 0 * @param {Ray3D} ray * @param {Vector3} pos - Center of the box * @param {Vector3} size - Full size of the box * @param {Vector3} [rotation] - How the box is turned, upright when left out * @return {number|undefined} * @memberof Math3D */ function raycastBox(ray, pos, size, rotation) { if (isTurned3D(rotation)) { // the ray in the box's own space, where the box is upright; turning keeps the distances const axes = boxAxes3D(rotation), d = ray.direction; const direction = vec3(d.dot(axes[0]), d.dot(axes[1]), d.dot(axes[2])); return raycastBox(new Ray3D(boxLocal3D(ray.origin, pos, axes), direction), vec3(), size); } const {origin, direction} = ray; const h = size.scale(.5); const boxMin = pos.subtract(h), boxMax = pos.add(h); let tMin = 0, tMax = Infinity; for (const axis of 'xyz') { const o = origin[axis], d = direction[axis]; const mn = boxMin[axis], mx = boxMax[axis]; if (!d) { if (o < mn || o > mx) return undefined; // ray is parallel to this slab and outside it continue; } let t0 = (mn - o)/d; let t1 = (mx - o)/d; if (t0 > t1) [t0, t1] = [t1, t0]; tMin = max(tMin, t0); tMax = min(tMax, t1); if (tMin > tMax) return undefined; } return tMin; } /** * LittleJS 3D Rendering Plugin * - Adds a 3D scene that draws into the same WebGL canvas as the 2D game * - Call new Render3DPlugin() in gameInit, then move render3D.camera and make EngineObject3D objects * - EngineObject3D is an EngineObject with a 3D position, rotation and mesh * - The 3D scene draws under the 2D sprites, so HUD and text land on top * - Lighting is the sun plus ambient, with optional extra lights, fog and shadows * - Any object or draw can bring its own Shader, a mainImage snippet the lighting then applies to, and a * mainNormal in it bends the normal the lighting uses * - Meshes and the basic builders are in render3dMesh.js, EngineObject3D, instancing and lights in * render3dObject.js, both right after this one; the other builders, terrain, particles, camera controls and the * OBJ loader are in the Render3D Extras plugin, which goes after those * - Requires the Math3D plugin * @namespace Render3D */ /////////////////////////////////////////////////////////////////////////////// /** Global Render3D plugin object * @type {Render3DPlugin} * @memberof Render3D */ let render3D; // vertex format: position xyz, normal xyz, uv, rgba bytes const RENDER3D_VERTEX_FLOATS = 9; const RENDER3D_VERTEX_BYTES = RENDER3D_VERTEX_FLOATS * 4; // per draw values the shaders read as vertex attributes: the model matrix columns (4-7), the tint (11) and the // uv rect (12); constants for one draw, one per instance for a batch; the shader derives the normal matrix const RENDER3D_INSTANCE_FLOATS = 24; const RENDER3D_INSTANCE_BYTES = RENDER3D_INSTANCE_FLOATS * 4; // an InstancedMesh3D keeps them apart: 16 floats of matrix, and 8 of tint and uv rect uploaded only when they change const RENDER3D_MATRIX_FLOATS = 16; const RENDER3D_COLOR_FLOATS = 8; const RENDER3D_INSTANCE_ATTRIBS = [[4, 4, 0], [5, 4, 16], [6, 4, 32], [7, 4, 48], [11, 4, 64], [12, 4, 80]]; const RENDER3D_VERTEX_INPUTS = 'layout(location=0) in vec3 p;layout(location=1) in vec3 n;layout(location=2) in vec2 t;layout(location=3) in vec4 c;' + 'layout(location=4) in vec4 m0;layout(location=5) in vec4 m1;layout(location=6) in vec4 m2;layout(location=7) in vec4 m3;' + 'layout(location=11) in vec4 tint;layout(location=12) in vec4 uvRect;'; const RENDER3D_MAX_STREAM_VERTS = 32768; const RENDER3D_MAX_LIGHTS = 8; // Light3D objects per frame, the shader loops over this many // strip order, frozen, and typed as the plain array the uv parameters take const RENDER3D_QUAD_UVS = /** @type {Array} */ (Object.freeze([vec2(0, 0), vec2(0, 1), vec2(1, 0), vec2(1, 1)].map(uv=> Object.freeze(uv)))); const RENDER3D_FULL_UV_RECT = Object.freeze({x:0, y:0, w:1, h:1}); const RENDER3D_DEFAULT_NORMAL = Object.freeze(vec3(0, 1, 0)); const RENDER3D_DEFAULT_UV = Object.freeze(vec2()); const RENDER3D_SHADOW_COLOR = Object.freeze(hsl(0, 0, 0, .5)); const RENDER3D_IDENTITY = new Matrix4; // never modified let render3DShadowCut; // the cut last sent to the shadow shader, see render3DSetDrawUniforms /////////////////////////////////////////////////////////////////////////////// // Private helpers // a fog fade range is off (both 0) or ends past where it starts const render3DFogFadeValid = (start, end)=> !end || start < end; // outward normal of a triangle or a quad given its corners in loop order, // from the diagonals so a collapsed corner still works function render3DFaceNormal(a, b, c, d=a) { const n = c.subtract(a).cross(d.subtract(b)); return n.lengthSquared() ? n.normalize() : RENDER3D_DEFAULT_NORMAL; } // a quad's corners in loop order as a strip, the one place that knows the order function render3DQuadStrip(a, b, c, d) { return [a, b, d, c]; } // per corner values (colors, uvs) into strip order, a single value passes through function render3DQuadValues(v) { return isArray(v) ? render3DQuadStrip(...v) : v; } // 3D draws are only valid during the pass with a live shader function render3DCanDraw() { if (!render3D.program) return false; ASSERT(render3D.isRendering, '3D draws are only valid during the 3D pass, draw from an EngineObject3D or render3D.onRenderOpaque'); return render3D.isRendering; } // the draw state fields a batch is drawn under; lights and fog are not captured, they are read live at flush // each is an accessor on the plugin over render3D.drawState, and a real change moves render3D.stateVersion, so a // batch checks that one number at each draw while the state holds; the three functions below write the fields out // by hand for speed, so a new field goes in all four, in drawState and gets an accessor; emissiveMapColor is // compared by its rgb, so two objects with equal colors batch /** The draw state's values, behind render3D's accessors, and the rgb emissiveMapColor had when it was set * @typedef {Object} Render3DDrawState * @property {boolean} blend * @property {boolean} additive * @property {boolean} depthTest * @property {boolean} depthWrite * @property {boolean} cullBackFaces * @property {boolean} mirrored * @property {boolean} lighting * @property {number} emissive * @property {boolean} receiveShadow * @property {number} specular * @property {boolean} pixelated * @property {Shader|undefined} shader * @property {TextureInfo|undefined} normalMap * @property {number} normalScale * @property {number} shininess * @property {number} reflectivity * @property {TextureInfo|undefined} emissiveMap * @property {Color} emissiveMapColor * @property {CubeMap|undefined} environmentMap * @property {number} emissiveR * @property {number} emissiveG * @property {number} emissiveB * @memberof Render3D */ const RENDER3D_STATE_FIELDS = ['blend', 'additive', 'depthTest', 'depthWrite', 'cullBackFaces', 'mirrored', 'lighting', 'emissive', 'receiveShadow', 'specular', 'pixelated', 'shader', 'normalMap', 'normalScale', 'shininess', 'reflectivity', 'emissiveMap', 'emissiveMapColor', 'environmentMap']; // a copy of the draw state in one fixed shape, the fields of RENDER3D_STATE_FIELDS written out, and the version // it was taken at function render3DCaptureBatchState() { const r = render3D, d = r.drawState; return {blend: d.blend, additive: d.additive, depthTest: d.depthTest, depthWrite: d.depthWrite, cullBackFaces: d.cullBackFaces, mirrored: d.mirrored, lighting: d.lighting, emissive: d.emissive, receiveShadow: d.receiveShadow, specular: d.specular, pixelated: d.pixelated, shader: d.shader, normalMap: d.normalMap, normalScale: d.normalScale, shininess: d.shininess, reflectivity: d.reflectivity, emissiveMap: d.emissiveMap, emissiveMapColor: (d.emissiveMapColor || WHITE).copy(), // the caller may change it environmentMap: d.environmentMap, version: r.stateVersion}; } // put a captured draw state back, written out the same way; the transparent stage does this for every queued draw function render3DApplyBatchState(s) { const r = render3D; r.blend = s.blend, r.additive = s.additive, r.depthTest = s.depthTest, r.depthWrite = s.depthWrite, r.cullBackFaces = s.cullBackFaces, r.mirrored = s.mirrored, r.lighting = s.lighting, r.emissive = s.emissive, r.receiveShadow = s.receiveShadow, r.specular = s.specular, r.pixelated = s.pixelated, r.shader = s.shader, r.normalMap = s.normalMap, r.normalScale = s.normalScale, r.shininess = s.shininess, r.reflectivity = s.reflectivity, r.emissiveMap = s.emissiveMap, r.emissiveMapColor = s.emissiveMapColor, r.environmentMap = s.environmentMap; } // true when the current draw state differs from a captured one, so a pending batch must flush first; it runs for // every instance drawn, so while nothing was set since the capture it checks the version alone function render3DStateChanged(s) { const r = render3D; if (s.version === r.stateVersion) return false; const d = r.drawState, c = s.emissiveMapColor; if (d.blend !== s.blend || d.additive !== s.additive || d.depthTest !== s.depthTest || d.depthWrite !== s.depthWrite || d.cullBackFaces !== s.cullBackFaces || d.mirrored !== s.mirrored || d.lighting !== s.lighting || d.emissive !== s.emissive || d.receiveShadow !== s.receiveShadow || d.specular !== s.specular || d.pixelated !== s.pixelated || d.shader !== s.shader || d.normalMap !== s.normalMap || d.normalScale !== s.normalScale || d.shininess !== s.shininess || d.reflectivity !== s.reflectivity || d.emissiveMap !== s.emissiveMap || d.environmentMap !== s.environmentMap || d.emissiveR !== c.r || d.emissiveG !== c.g || d.emissiveB !== c.b) return true; s.version = r.stateVersion; // set and set back to what it was: the batch goes on, checking the version again return false; } // whether a sphere is inside the view, or the shadow map's box during the shadow pass, without a vector function render3DSphereVisible(x, y, z, radius) { const planes = render3D.shadowPass && !render3D.depthPass ? render3D.shadowPlanes : render3D.frustumPlanes; for (let i = 0; i < planes.length; ++i) { const p = planes[i]; if (p[0]*x + p[1]*y + p[2]*z + p[3] < -radius) return false; } return true; } // run a function with some draw state fields overridden, restored afterward even on a throw function render3DWithState(fields, fn) { const r = render3D, saved = {}; for (const key in fields) saved[key] = r[key], r[key] = fields[key]; try { return fn(); } finally { Object.assign(r, saved); } } // which side of the 2D scene an object draws on, its own flag or the plugin default function render3DIsAfter2D(o) { return !!((o.drawOwner ? render3DSetting(o, 'renderAfter2D') : o.renderAfter2D) ?? render3D.renderAfter2D); } // a size given as a number or a vec3 /** @param {Vector3|number} size * @return {Vector3} */ function render3DSize3(size) { return isNumber(size) ? vec3(/** @type {number} */ (size)) : /** @type {Vector3} */ (size); } // a size given as a number or a vec2 /** @param {Vector2|number} size * @return {Vector2} */ function render3DSize2(size) { return isNumber(size) ? vec2(/** @type {number} */ (size)) : /** @type {Vector2} */ (size); } // a transform given as a matrix, or as a vec3 for one that only moves there /** @param {Matrix4|Vector3} matrix * @return {Matrix4} */ function render3DMatrix(matrix) { if (matrix instanceof Vector3) return buildMatrix(matrix); ASSERT(matrix instanceof Matrix4, 'takes a Matrix4, or a Vector3 for a position'); return matrix; } // the matrix that keeps normals pointing out when an object is scaled unevenly function render3DNormalMatrix(matrix) { return matrix.copy().invert().transpose(); } // whether a matrix mirrors, its determinant negative, so what it moves reads the other way round function render3DMirrors(matrix) { return matrix.determinant() < 0; } // a column of a matrix as a direction: 0 is the right axis, 4 up, 8 back function render3DAxis(m, i) { return vec3(m[i], m[i+1], m[i+2]); } // a matrix turning by angle around a unit axis through the origin, counter clockwise when the axis points at you function render3DAxisRotation(axis, angle) { const x = vec3(1, 0, 0).rotate(axis, angle), y = vec3(0, 1, 0).rotate(axis, angle), z = vec3(0, 0, 1).rotate(axis, angle); return new Matrix4([x.x, x.y, x.z, 0, y.x, y.y, y.z, 0, z.x, z.y, z.z, 0, 0, 0, 0, 1]); } // the largest axis scale of a matrix, how much it grows a bounding sphere function render3DMaxScale(m) { return max(m[0]*m[0] + m[1]*m[1] + m[2]*m[2], m[4]*m[4] + m[5]*m[5] + m[6]*m[6], m[8]*m[8] + m[9]*m[9] + m[10]*m[10]) ** .5; } // how far a matrix at offset k can move a point that is one unit from its origin, for a bounding sphere: the // longest axis when the axes are square to each other, more when they are not, as a turned child under a parent // scaled on one axis leaves them; the axes' dot products bound the largest stretch by their largest row sum function render3DMaxStretch(m, k=0) { return render3DMaxStretchSquared(m, k) ** .5; } // the same, squared, for bounds that grow many times before they are read function render3DMaxStretchSquared(m, k=0) { const xx = m[k]*m[k] + m[k+1]*m[k+1] + m[k+2]*m[k+2]; const yy = m[k+4]*m[k+4] + m[k+5]*m[k+5] + m[k+6]*m[k+6]; const zz = m[k+8]*m[k+8] + m[k+9]*m[k+9] + m[k+10]*m[k+10]; const xy = abs(m[k]*m[k+4] + m[k+1]*m[k+5] + m[k+2]*m[k+6]); const xz = abs(m[k]*m[k+8] + m[k+1]*m[k+9] + m[k+2]*m[k+10]); const yz = abs(m[k+4]*m[k+8] + m[k+5]*m[k+9] + m[k+6]*m[k+10]); return max(xx + xy + xz, yy + xy + yz, zz + xz + yz); } // a quad as a strip from its center and half axes, the same corner order as render3DQuadStrip function render3DQuadAxes(center, right, up) { return [center.subtract(right).add(up), center.subtract(right).subtract(up), center.add(right).add(up), center.add(right).subtract(up)]; } // set the draw state for an object's render3D, or the defaults for the stage callbacks function render3DSetObjectState(o) { const r = render3D; const transparent = o?.transparent; const p = o?.drawOwner ? render3DPartSettings(o) : o; // a part draws with its owner's settings const emissive = p?.emissive || 0; ASSERT(isNumber(emissive) && emissive >= 0, 'emissive must be a number, 0 or more', emissive); r.lighting = true; r.emissive = emissive; r.additive = !!p?.additive; r.specular = p?.specular || 0; const shininess = p?.shininess ?? 16, reflectivity = p?.reflectivity || 0; ASSERT(isNumber(shininess) && shininess > 0, 'shininess must be a number above 0', shininess); ASSERT(isNumber(reflectivity) && reflectivity >= 0 && reflectivity <= 1, 'reflectivity must be 0 to 1', reflectivity); r.shininess = shininess; r.reflectivity = reflectivity; r.normalMap = p?.normalMap || undefined; r.normalScale = p?.normalScale ?? 1; r.emissiveMap = p?.emissiveMap || undefined; r.emissiveMapColor = p?.emissiveMapColor || WHITE; ASSERT(!p?.environment || p.environment instanceof CubeMap, 'environment must be a CubeMap'); r.environmentMap = p?.environment || undefined; r.receiveShadow = !p || p.receiveShadow; r.cullBackFaces = r.mirrored = false; // each mesh sets these as it draws r.pixelated = !!p?.pixelated; ASSERT(!p?.shader || p.shader instanceof Shader, 'shader must be a Shader, not the snippet itself'); r.shader = p?.shader || undefined; // null is no shader too, so it batches with none r.depthTest = true; if (r.shadowPass) r.blend = !!transparent; // the depth shader cuts a see through caster by the alpha it would blend with } // draw objects each with the draw state set from its own flags, then reset to the defaults function render3DDrawObjects(objects) { for (const o of objects) { render3DSetObjectState(o); o.render3D(); } render3DSetObjectState(); } // add a draw of a mesh to its batch; a batch is one mesh under one texture and draw state, so a change flushes it, // and under one winding: a mirroring transform, one with a negative determinant, turns it around function render3DInstance(mesh, matrix, tileInfo, color) { const k = render3DInstanceSlot(mesh, render3DTextureOf(tileInfo), render3DMirrors(matrix)), data = mesh.instanceData; data.set(matrix.m, k); // the tint; this batch is never blended, so in the shadow pass the depth shader cuts it by its texture alpha // alone, a see through caster draws blended instead and is cut by its tint alpha too data[k+16] = color.r, data[k+17] = color.g, data[k+18] = color.b, data[k+19] = color.a; const uv = render3DGetTileUVs(tileInfo); data[k+20] = uv.x; data[k+21] = uv.y; data[k+22] = uv.w; data[k+23] = uv.h; } // make room for one more instance of a mesh under a texture and the current draw state, flushing a batch that // differs first, and return where its 24 floats go in mesh.instanceData: the matrix, the tint and the uv rect // drawMesh passes whether its matrix mirrors, and its batch then culls by the mesh and winds by that at the flush, // so it never sets the draw state for them, which would move the state's version at every draw; drawBillboard // passes that its batch is unlit, for the same reason function render3DInstanceSlot(mesh, textureInfo, mirrored, unlit=false) { const r = render3D; if (mesh.instanceCount && (mesh.instanceTextureInfo !== textureInfo || mesh.instanceMirrored !== mirrored || mesh.instanceUnlit !== unlit || render3DStateChanged(mesh.instanceState))) render3DFlushInstances(mesh); if (!mesh.instanceCount) { mesh.instanceTextureInfo = textureInfo; mesh.instanceMirrored = mirrored; mesh.instanceUnlit = unlit; mesh.instanceState = render3DCaptureBatchState(); r.instanceMeshes.push(mesh); } // room for one more, doubling as the batch grows const data = mesh.instanceData, k = mesh.instanceCount++ * RENDER3D_INSTANCE_FLOATS; if (!data || data.length < k + RENDER3D_INSTANCE_FLOATS) { const grown = new Float32Array(max(64 * RENDER3D_INSTANCE_FLOATS, data ? data.length * 2 : 0)); data && grown.set(data); mesh.instanceData = grown; } return k; } // draw the pending batches, or just one mesh's, each as a single instanced call function render3DFlushInstances(only) { const r = render3D, gl = glContext; for (const mesh of only ? [only] : r.instanceMeshes) { const count = mesh.instanceCount; mesh.instanceCount = 0; if (!count || !mesh.buffer) continue; // the per instance values on top of the constant attributes, then the mesh under them // a ring of buffers with fresh storage each time, so the driver never waits for a draw still reading one const buffers = r.instanceBuffers, buffer = buffers[r.instanceBufferIndex = (r.instanceBufferIndex + 1) % buffers.length]; gl.bindBuffer(gl.ARRAY_BUFFER, buffer); gl.bufferData(gl.ARRAY_BUFFER, mesh.instanceData, gl.DYNAMIC_DRAW, 0, count * RENDER3D_INSTANCE_FLOATS); const state = mesh.instanceState; if (mesh.instanceMirrored !== undefined) // a drawMesh batch: culled by its mesh, wound by its matrices state.cullBackFaces = !mesh.doubleSided, state.mirrored = mesh.instanceMirrored; if (mesh.instanceUnlit) // a batch of sprites state.lighting = false; render3DDrawInstanced(mesh, buffer, count, mesh.instanceTextureInfo, state); } if (!only) r.instanceMeshes.length = 0; else { const i = r.instanceMeshes.indexOf(only); i < 0 || r.instanceMeshes.splice(i, 1); } } // draw a mesh count times from a buffer that holds the per instance values, under a draw state // the arrays are turned on with their instance divisor for this one call and both are turned off after: a single // draw reads these slots as constant attributes, and in Firefox a draw that reads a constant through a slot whose // divisor is set leaves the next batch on that slot reading the wrong values // colorBuffer holds the tint and uv rect apart from the matrices, as an InstancedMesh3D keeps them function render3DDrawInstanced(mesh, buffer, count, textureInfo, state, colorBuffer) { const gl = glContext, matrixBytes = RENDER3D_MATRIX_FLOATS * 4; for (const [location, size, offset] of RENDER3D_INSTANCE_ATTRIBS) { const inColors = colorBuffer && offset >= matrixBytes; const stride = !colorBuffer ? RENDER3D_INSTANCE_BYTES : inColors ? RENDER3D_COLOR_FLOATS * 4 : matrixBytes; gl.bindBuffer(gl.ARRAY_BUFFER, inColors ? colorBuffer : buffer); gl.vertexAttribPointer(location, size, gl.FLOAT, false, stride, inColors ? offset - matrixBytes : offset); gl.enableVertexAttribArray(location); gl.vertexAttribDivisor(location, 1); } render3DSetDrawUniforms(RENDER3D_IDENTITY, textureInfo, WHITE, RENDER3D_FULL_UV_RECT, state); render3DBindMesh(mesh); gl.drawElementsInstanced(gl.TRIANGLES, mesh.bufferCount, mesh.indexType, 0, count); for (const [location] of RENDER3D_INSTANCE_ATTRIBS) { gl.disableVertexAttribArray(location); gl.vertexAttribDivisor(location, 0); } ++drawCount; primitiveCount += mesh.bufferCount / 3 * count; } // forget the pending batches, for a frame that threw or a lost context function render3DClearInstances() { for (const mesh of render3D.instanceMeshes) mesh.instanceCount = 0; render3D.instanceMeshes.length = 0; } // the live objects drawn on one side of the 2D scene function render3DLayerObjects(after2D) { return /** @type {Array} */ (engineObjects.filter(o=> !o.destroyed && o instanceof EngineObject3D && render3DIsAfter2D(o) === after2D)); } // the Light3D objects the shader gets this frame: directional lights light the whole scene so they come first, // then the point lights nearest the camera function render3DCollectLights() { // a light switched off by its radius or its alpha is left out, so it cannot take one of the few slots const lights = /** @type {Array} */ (engineObjects.filter(o=> !o.destroyed && o instanceof Light3D && o.color.a > 0 && o.intensity > 0 && (o.directional || o.radius > 0))); if (lights.length > RENDER3D_MAX_LIGHTS) { // distances cached once, getWorldPos3D walks the parent chain and the sort asks many times const cameraPos = render3D.camera.pos, distances = new Map; for (const light of lights) distances.set(light, light.directional ? -1 : light.getWorldPos3D().distanceSquared(cameraPos)); lights.sort((a, b)=> distances.get(a) - distances.get(b)); // the light that casts the shadows keeps a slot however far it is, in place of the farthest const caster = render3DShadowCaster(); if (caster && lights.indexOf(caster) >= RENDER3D_MAX_LIGHTS) lights[RENDER3D_MAX_LIGHTS - 1] = caster; lights.length = RENDER3D_MAX_LIGHTS; } return lights; } // the spotlight that casts the shadows in place of the sun: render3D.shadowLight while it is a light that is on // and has a cone, undefined for the sun function render3DShadowCaster() { const light = render3D.shadowLight; return light && !light.destroyed && !light.directional && light.coneAngle > 0 && light.radius > 0 && light.intensity > 0 && light.color.a > 0 ? light : undefined; } // the widest cone a shadow map can look down, to each side; the light that casts the shadows lights no wider, or // what is past the map's edge would be lit with nothing to shade it const RENDER3D_SHADOW_CONE_MAX = 1.35; // a light's cone as the four numbers the shader gets: the way it shines, scaled so that its dot with the way to // a point, less the fourth number, is 0 at the edge of the cone and 1 where its fade starts; a light with no cone // gets numbers that make it 1 every way function render3DLightCone(light) { const widest = light === render3DShadowCaster() ? RENDER3D_SHADOW_CONE_MAX : PI; const angle = light.directional ? 0 : min(light.coneAngle, widest); if (!(angle > 0)) return [0, 0, 0, -1]; const outer = cos(angle), inner = cos(angle * (1 - clamp(light.coneSoftness))); const k = 1 / max(inner - outer, 1e-4), forward = light.getForward3D(); return [forward.x * k, forward.y * k, forward.z * k, outer * k]; } // unit circle directions for a number of sides, [cos, sin, cos, sin, ...] including the closing point, cached const render3DCircleCache = new Map; function render3DCircle(sides) { sides |= 0; let circle = render3DCircleCache.get(sides); if (!circle) { circle = new Float32Array(sides * 2 + 2); for (let i = 0; i <= sides; ++i) { const a = i / sides * 2 * PI; circle[i*2] = cos(a), circle[i*2 + 1] = sin(a); } render3DCircleCache.set(sides, circle); } return circle; } // the rotation that points -Z along a direction, as vec3(pitch, yaw, 0); a zero direction keeps the current one function render3DLookRotation(direction, current) { const d = direction.normalize(); if (!d.lengthSquared()) return current; if (abs(d.x) + abs(d.z) < 1e-9) // straight up or down has no yaw of its own return vec3(d.y > 0 ? PI / 2 : -PI / 2, current.y, 0); return vec3(Math.asin(clamp(d.y, -1, 1)), atan2(-d.x, -d.z), 0); } /////////////////////////////////////////////////////////////////////////////// /** * Render3D Plugin - The 3D renderer, camera, lights, shadows and fog * - There is one of these, in the global render3D * - It draws the 3D scene before gameRender, so 2D drawing lands on top * - Set renderAfter2D to draw the 3D scene over the 2D scene instead * - Settings like lighting and specular are read as each thing draws * - Every object sets them from its own flags, so you rarely touch them * @memberof Render3D * @example * new Render3DPlugin; * render3D.camera.pos = vec3(0, 5, 10); * render3D.camera.lookAt(vec3()); * new EngineObject3D(vec3(), buildBox()); */ class Render3DPlugin { /** Create the global 3D renderer, call in gameInit */ constructor() { ASSERT(!render3D, 'Render3D plugin already initialized'); render3D = this; // the draw state, read at each draw through the accessors below the constructor /** @type {Render3DDrawState} */ this.drawState = {blend: false, additive: false, depthTest: true, depthWrite: true, cullBackFaces: false, mirrored: false, lighting: true, emissive: 0, receiveShadow: true, specular: 0, pixelated: false, shader: undefined, normalMap: undefined, normalScale: 1, shininess: 16, reflectivity: 0, emissiveMap: undefined, emissiveMapColor: WHITE, environmentMap: undefined, emissiveR: 1, emissiveG: 1, emissiveB: 1}; /** @property {number} - Goes up when a draw state field changes, so a batch sees at a glance that none did */ this.stateVersion = 0; ASSERT(Object.keys(render3DCaptureBatchState()).join() === [...RENDER3D_STATE_FIELDS, 'version'].join(), 'the batch state functions must list RENDER3D_STATE_FIELDS'); /** @property {Camera3D} - The camera */ this.camera = new Camera3D; // lights and fog /** @property {Vector3} - Direction toward the sun, where its light comes from, like a directional Light3D; * read at each draw, and any length will do, the shading and the shadows normalize it themselves; * the sun is the one light that casts shadows */ this.sunDirection = vec3(-.3, 1, .5); /** @property {Color} - Sunlight color */ this.sunColor = WHITE.copy(); /** @property {Color} - Ambient light color, from above when ambientGroundColor is set */ this.ambientColor = hsl(0, 0, .3); /** @property {Color|undefined} - Ambient light from below: set, the ambient blends from this on faces pointing * down to ambientColor on faces pointing up, the way a sky and a ground light a scene; setSky sets both from * its colors * @type {Color|undefined} */ this.ambientGroundColor = undefined; /** @property {Color|undefined} - Fog color, uses canvasClearColor when undefined * @type {Color|undefined} */ this.fogColor = undefined; /** @property {number} - Distance from the camera where fog starts */ this.fogStart = 0; /** @property {number} - Distance from the camera where fog is total, 0 disables fog */ this.fogEnd = 0; /** @property {boolean} - Measure fog by depth along the view instead of distance from the camera, so fog is * even across the screen like a classic depth fog; the fade below is measured the same way */ this.fogDepth = false; /** @property {number} - Where see through draws start to fade to transparent, measured like the fog */ this.fogFadeStart = 0; /** @property {number} - Where see through draws are fully transparent, 0 turns the fade off; distant things * melt into a sky or backdrop drawn behind them instead of into the fog color */ this.fogFadeEnd = 0; /** @property {Vector3} - Added to the velocity3D of every object with a mass each frame, scaled by its * gravityScale; sync2D objects use the 2D gravity */ this.gravity = vec3(); /** @property {number|HeightMap|function(number, number): number} - Floor for objects with a softShadow: a * height, a HeightMap, or (x, z) => y * @type {number|HeightMap|function(number, number): number} */ this.softShadowHeight = 0; /** @property {boolean} - Default for every builder's smooth argument: true for smooth vertex normals, false for * flat faces */ this.smoothShading = false; /** @property {boolean} - Draw the camera's depth into a texture each frame for post processing: * PostProcessPlugin hands it to its shader as iChannel2, read with sceneDepth(uv); off by default and free * when off, on it draws the solid objects of the default layer once more, depth only */ this.depthTexture = false; // shadows /** @property {boolean} - Cast real shadows from the sun, off by default and free when off */ this.shadows = false; /** @property {number} - How far a sound played with playSound is heard when it was made with no range of * its own, in world units; further than the 2D soundDefaultRange, a 3D world is bigger */ this.soundDefaultRange = 100; /** @property {number} - Size of the shadow map in pixels, bigger is sharper and slower */ this.shadowMapSize = 1024; /** @property {Light3D|undefined} - A spotlight, a Light3D with a coneAngle, to cast the shadows in place * of the sun, a flashlight in the dark: the shadow map looks down its cone, as far as its radius, and the * sun still lights the scene but casts none; undefined for the sun, as is a light that is off or has no cone * @type {Light3D|undefined} */ this.shadowLight = undefined; /** @property {number} - World size the shadow map covers around shadowCenter, smaller is sharper; it is a * square facing the light, so it turns as the light does, and about 1.5 times an area's width covers it from * any angle */ this.shadowRange = 40; /** @property {Vector3|undefined} - Center of the shadowed area, read each frame, undefined follows the camera * @type {Vector3|undefined} */ this.shadowCenter = undefined; /** @property {number} - Stops surfaces shadowing themselves, raise for speckles, lower if shadows drift off; * a share of the shadow map's depth, twice shadowRange for the sun and a spotlight's radius, so the gap behind * a caster grows with the range as the map's texels do, and the speckles they make stay away */ this.shadowBias = .003; /** @property {number} - How much to blur the shadow edges */ this.shadowSoftness = 1; // the pass /** @property {Function|undefined} - Draw solid world here, it runs again for shadows so only draw in it * @type {Function|undefined} */ this.onRenderOpaque = undefined; /** @property {Function|undefined} - Draw see through things here, like glows, billboards and soft shadows * @type {Function|undefined} */ this.onRenderTransparent = undefined; /** @property {Mesh|undefined} - Sky dome from buildSky or setSky, drawn around the camera behind everything * @type {Mesh|undefined} */ this.sky = undefined; /** @property {CubeMap|undefined} - A cube map drawn as the sky, behind everything, in place of the sky dome; * an orthographic camera looks the same way through every pixel, so it sees one color of it; fog fades to * fogColor, not to the sky box, so with fog set fogColor to its horizon's color * @type {CubeMap|undefined} */ this.skyBox = undefined; /** @property {CubeMap|undefined} - The world around, what reflective surfaces reflect: an object's * reflectivity says how much and its shininess how sharp, the same shininess that tightens its highlight: * 10000 a mirror, 1000 polished, 10 a wide blur; undefined reflects the sky's colors as setSky gave them; a cube map holds GPU * memory until its dispose(), so one made again and again, as for a sky that changes, disposes the one it * replaces, or a captured one is captured into again * @type {CubeMap|undefined} */ this.environment = undefined; /** @property {boolean} - Draw the 3D scene on top of the 2D scene instead of under it */ this.renderAfter2D = false; /** @property {boolean} - Draw see through things far to near so they blend correctly */ this.sortTransparent = true; /** @property {boolean} - Skip meshes whose bounding sphere is outside the view */ this.frustumCulling = true; /** @property {boolean} - Draw every use of a mesh in the opaque stage as one instanced call, mesh.instanced * overrides it per mesh */ this.instancing = true; /** @property {boolean} - Sample textures through mipmaps so they do not shimmer in the distance, false uses * each texture's own filtering like 2D */ this.mipmaps = true; /** @property {number} - Anisotropic filtering for textures seen at an angle, 1 to 16, 1 is off; needs mipmaps */ this.anisotropy = 4; // shared meshes, every object using one draws in the same batch /** @property {Mesh} - A box of size 1 that drawBox uses, for any object that is a box; set the object's scale3D * and color instead of editing the mesh, which would change every box that uses it */ this.boxMesh = buildBox(); /** @property {Mesh} - A smooth sphere of diameter 1 that drawSphere uses, shared the same way as boxMesh */ this.sphereMesh = buildSphere(1, 16, 8, true); /** @property {Mesh} - A flat square of size 1 facing +Y, seen from above only, for floors, water and decals; * stand it up with the object's rotation3D, and size it with scale3D */ this.planeMesh = buildGrid(); this.planeMesh.doubleSided = false; /** @property {Mesh} - The same square seen and lit from both sides, for signs, cards and leaves */ this.planeMeshDoubleSided = buildGrid(); /** @property {Mesh} - A square of size 1 facing +Z with the tile across it, the corners in the order * drawBillboard writes them; a ParticleEmitter3D draws its particles as instances of it, each with its own matrix */ this.billboardMesh = new Mesh().addStrip([vec3(-.5, .5, 0), vec3(-.5, -.5, 0), vec3(.5, .5, 0), vec3(.5, -.5, 0)], vec3(0, 0, 1), RENDER3D_QUAD_UVS); this.billboardMesh.doubleSided = true; // read only /** @property {boolean} - True while the 3D pass is running, 3D draws are only valid then */ this.isRendering = false; /** @property {boolean} - True while the shadow map is being drawn, draws go to the depth only shader */ this.shadowPass = false; /** @property {Matrix4} - This frame's view matrix */ this.viewMatrix = new Matrix4; /** @property {Matrix4} - This frame's projection matrix */ this.projectionMatrix = new Matrix4; /** @property {Matrix4} - This frame's combined view projection */ this.viewProjection = new Matrix4; /** @property {Matrix4} - This frame's light view projection for the shadow map */ this.shadowMatrix = new Matrix4; this.gelAxes = [1, 0, 0, 1]; // the shadow light's right and up in its map's, for its gel /** @property {Vector3} - Camera right axis this frame */ this.cameraRight = vec3(1, 0, 0); /** @property {Vector3} - Camera up axis this frame */ this.cameraUp = vec3(0, 1, 0); /** @property {Vector3} - Camera forward axis this frame */ this.cameraForward = vec3(0, 0, -1); this.cameraBack = vec3(0, 0, 1); // its opposite, the normal of camera facing draws // internal state /** @type {Array>} */ this.frustumPlanes = []; // the view as six inward planes [x, y, z, w] /** @type {Array>} */ this.shadowPlanes = []; // the shadow map's box as six planes /** @type {WebGLProgram|undefined} */ this.program = undefined; // the main program, undefined when not available /** @type {WebGLProgram|undefined} */ this.currentProgram = undefined; // the program in use during a pass, a Shader's or the main one this.lightCount = 0; // Light3D objects sent this pass /** @type {WebGLProgram|undefined} */ this.shadowShader = undefined; /** @type {WebGLVertexArrayObject|undefined} */ this.vao = undefined; /** @type {Set} */ this.cubeCaptures = new Set; // the cube maps capture asked to draw in the next pass /** @type {CubeMap|undefined} */ this.capturingCube = undefined; // the cube map being drawn, which nothing may read meanwhile /** @type {WebGLFramebuffer|undefined} */ this.captureFramebuffer = undefined; /** @type {WebGLRenderbuffer|undefined} */ this.captureDepth = undefined; this.captureDepthSize = 0; /** @type {WebGLProgram|undefined} */ this.skyBoxProgram = undefined; // draws the sky box, made on its first draw /** @type {WebGLVertexArrayObject|undefined} */ this.skyBoxVao = undefined; // an empty one, its triangle comes from gl_VertexID /** @type {WebGLTexture|undefined} */ this.whiteTexture = undefined; // 1x1 white for untextured draws /** @type {Map} */ this.samplers = new Map; // how textures are filtered in 3D, by wrap and hard edge, see render3DSampler /** @type {string|undefined} */ this.samplerKey = undefined; // the settings the samplers were made for, they are rebuilt when it changes /** @type {WebGLTexture|undefined} */ this.shadowTexture = undefined; /** @type {WebGLFramebuffer|undefined} */ this.shadowFramebuffer = undefined; this.shadowTextureSize = 0; /** @type {WebGLTexture|undefined} */ this.cameraDepthTexture = undefined; // the camera's depth, drawn when depthTexture is on /** @type {WebGLFramebuffer|undefined} */ this.cameraDepthFramebuffer = undefined; this.cameraDepthWidth = 0; this.cameraDepthHeight = 0; this.depthPass = false; // drawing the camera's depth, a shadow pass seen from the camera this.contextGeneration = 0; // counts context losses, a mesh uploaded under an older one uploads again /** @type {WeakMap>} */ this.uniforms = new WeakMap; // uniform locations by program, weak so a freed one goes with it /** @type {Object>} */ this.uniformValues = {}; // last values sent for the cached vec4 uniforms this.shadowMapDrawn = false; // the shadow map is drawn by the first pass of the frame this.passIsDefault = true; // the running pass is the default layer, the only one shadowed this.lightPositions = new Float32Array(RENDER3D_MAX_LIGHTS * 4); // Light3D uniforms, filled each pass this.lightColors = new Float32Array(RENDER3D_MAX_LIGHTS * 4); this.lightCones = new Float32Array(RENDER3D_MAX_LIGHTS * 4); // each light's cone, see render3DLightCone this.shadowLightIndex = -1; // which of the lights sent casts the shadows, -1 for the sun this.shadowDepthBias = 0; // the bias as the shadow lookup takes it, set with the shadow matrix // the stream of immediate mode draws /** @type {WebGLBuffer|undefined} */ this.streamBuffer = undefined; /** @type {Array} */ this.instanceBuffers = []; // the per instance values of the batches being drawn, used in turn this.instanceBufferIndex = 0; /** @type {Array} */ this.instanceMeshes = []; // meshes with a batch pending this stage /** @type {Array>} */ this.attribValues = []; // last values sent for the cached constant attributes this.streamData = new ArrayBuffer(RENDER3D_MAX_STREAM_VERTS * RENDER3D_VERTEX_BYTES); this.streamFloats = new Float32Array(this.streamData); this.streamInts = new Uint32Array(this.streamData); this.streamCount = 0; /** @type {TextureInfo|undefined} */ this.streamTileInfo = undefined; /** @type {Object|undefined} */ this.streamState = undefined; // captured state the pending batch was drawn under, render3DCaptureBatchState's this.streamUnlit = false; // the pending batch is drawn unlit whatever that state says, billboards are /** @type {Mesh|undefined} */ this.capture = undefined; // the mesh a bake is filling /** @type {Array<{distance: number, state: Object, draw: function(): void}>|undefined} */ this.transparentQueue = undefined; // draws queued during the transparent stage, replayed far to near render3DInitGL(); engineAddPlugin(undefined, render3DRender, render3DContextLost, render3DContextRestored, render3DPreRender); } /////////////////////////////////////////////////////////////////////////// // Draw state: each field an accessor over drawState, whose setter moves stateVersion only on a real change /** Apply lighting, when false draws plain vertex color times texture and casts no shadow; * off for billboards, lines, ribbons and soft discs, an object sets emissive instead * @return {boolean} */ get lighting() { return this.drawState.lighting; } set lighting(v) { const d = this.drawState; d.lighting === v || (d.lighting = v, ++this.stateVersion); } /** How much a surface lights itself, set per object by its emissive * @return {number} */ get emissive() { return this.drawState.emissive; } set emissive(v) { const d = this.drawState; d.emissive === v || (d.emissive = v, ++this.stateVersion); } /** Additive blending instead of alpha, in the transparent stage * @return {boolean} */ get additive() { return this.drawState.additive; } set additive(v) { const d = this.drawState; d.additive === v || (d.additive = v, ++this.stateVersion); } /** Test against the depth buffer, reset to true before each object and callback; a draw * with it off goes over what was drawn before it and under what is drawn after, by render order, which * ends the batch of meshes before it, so one per object costs a draw per object * @return {boolean} */ get depthTest() { return this.drawState.depthTest; } set depthTest(v) { const d = this.drawState; d.depthTest === v || (d.depthTest = v, ++this.stateVersion); } /** Write to the depth buffer, owned by the stages: on for opaque, off for transparent * @return {boolean} */ get depthWrite() { return this.drawState.depthWrite; } set depthWrite(v) { const d = this.drawState; d.depthWrite === v || (d.depthWrite = v, ++this.stateVersion); } /** Skip back faces, set by drawMesh from each mesh: off for strips so they show from * both sides * @return {boolean} */ get cullBackFaces() { return this.drawState.cullBackFaces; } set cullBackFaces(v) { const d = this.drawState; d.cullBackFaces === v || (d.cullBackFaces = v, ++this.stateVersion); } /** The transform mirrors the draw, so the other winding is the front, set by drawMesh * from each transform * @return {boolean} */ get mirrored() { return this.drawState.mirrored; } set mirrored(v) { const d = this.drawState; d.mirrored === v || (d.mirrored = v, ++this.stateVersion); } /** Strength of the highlight where the sun and the Light3D objects reflect, 0 is none and * 1 adds a light's full color at its brightest; shininess sets its size * @return {number} */ get specular() { return this.drawState.specular; } set specular(v) { const d = this.drawState; d.specular === v || (d.specular = v, ++this.stateVersion); } /** The highlight's exponent, how small and sharp it is: 4 is broad like rubber, 100 sharp * like polished metal; set from each object's shininess * @return {number} */ get shininess() { return this.drawState.shininess; } set shininess(v) { const d = this.drawState; d.shininess === v || (d.shininess = v, ++this.stateVersion); } /** Normal map for the next draws, set from each object's normalMap * @return {TextureInfo|undefined} */ get normalMap() { return this.drawState.normalMap; } set normalMap(v) { const d = this.drawState; d.normalMap === v || (d.normalMap = v, ++this.stateVersion); } /** How strongly the normal map bends the surface, set from each object's normalScale * @return {number} */ get normalScale() { return this.drawState.normalScale; } set normalScale(v) { const d = this.drawState; d.normalScale === v || (d.normalScale = v, ++this.stateVersion); } /** How much the surface reflects the sky, 0 to 1, set from each object's reflectivity * @return {number} */ get reflectivity() { return this.drawState.reflectivity; } set reflectivity(v) { const d = this.drawState; d.reflectivity === v || (d.reflectivity = v, ++this.stateVersion); } /** Emissive map for the next draws, set from each object's emissiveMap * @return {TextureInfo|undefined} */ get emissiveMap() { return this.drawState.emissiveMap; } set emissiveMap(v) { const d = this.drawState; d.emissiveMap === v || (d.emissiveMap = v, ++this.stateVersion); } /** The cube map the next draws reflect in place of render3D.environment, set from each object's environment * @return {CubeMap|undefined} */ get environmentMap() { return this.drawState.environmentMap; } set environmentMap(v) { const d = this.drawState; d.environmentMap === v || (d.environmentMap = v, ++this.stateVersion); } /** Multiplies the emissive map, set from each object's emissiveMapColor; compared by its rgb, so a Color * changed in place is seen when it is set again, and undefined is white * @return {Color} */ get emissiveMapColor() { return this.drawState.emissiveMapColor; } set emissiveMapColor(v) { const d = this.drawState, c = v || WHITE; d.emissiveMapColor = v; if (c.r !== d.emissiveR || c.g !== d.emissiveG || c.b !== d.emissiveB) d.emissiveR = c.r, d.emissiveG = c.g, d.emissiveB = c.b, ++this.stateVersion; } /** Custom Shader for the next draws, set from each object's shader; undefined draws * with the plugin's own * @return {Shader|undefined} */ get shader() { return this.drawState.shader; } set shader(v) { const d = this.drawState; d.shader === v || (d.shader = v, ++this.stateVersion); } /** Darken by the shadow map when shadows are on, turn it off for things that should * stay lit inside a shadow * @return {boolean} */ get receiveShadow() { return this.drawState.receiveShadow; } set receiveShadow(v) { const d = this.drawState; d.receiveShadow === v || (d.receiveShadow = v, ++this.stateVersion); } /** Keep texture pixels hard edged, no mipmaps and no blending between them, set per * object by pixelated * @return {boolean} */ get pixelated() { return this.drawState.pixelated; } set pixelated(v) { const d = this.drawState; d.pixelated === v || (d.pixelated = v, ++this.stateVersion); } /** Blending on, set by the stages * @return {boolean} */ get blend() { return this.drawState.blend; } set blend(v) { const d = this.drawState; d.blend === v || (d.blend = v, ++this.stateVersion); } /////////////////////////////////////////////////////////////////////////// // Matrices and picking /** Rebuild the view and projection matrices from the camera, called automatically each frame * @param {number} [aspect] - Width over height, defaults to the main canvas */ updateMatrices(aspect=mainCanvasSize.y ? mainCanvasSize.x / mainCanvasSize.y : 1) { const camera = this.camera; if (camera.align2D) camera.update2D(); const cameraMatrix = camera.getMatrix(); this.viewMatrix = cameraMatrix.copy().invert(); this.projectionMatrix = camera.getProjectionMatrix(aspect); this.viewProjection = this.projectionMatrix.copy().multiply(this.viewMatrix); const m = cameraMatrix.m; this.cameraRight = render3DAxis(m, 0); this.cameraUp = render3DAxis(m, 4); this.cameraBack = render3DAxis(m, 8); // the normal of anything facing the camera this.cameraForward = this.cameraBack.scale(-1); this.frustumPlanes = render3DFrustumPlanes(this.viewProjection); } /** Where a world point lands on screen as -1 to 1 across and up, with z as depth * - Uses this frame's camera, call updateMatrices first if the camera just moved * @param {Vector3} pos * @return {Vector3|undefined} - undefined when behind the camera or closer than the near plane */ worldToClip(pos) { const m = this.viewProjection.m; const w = m[3]*pos.x + m[7]*pos.y + m[11]*pos.z + m[15]; const z = (m[2]*pos.x + m[6]*pos.y + m[10]*pos.z + m[14]) / w; if (w <= 0 || z < -1) return; // behind the camera, or in front of the near plane return vec3( (m[0]*pos.x + m[4]*pos.y + m[8]*pos.z + m[12]) / w, (m[1]*pos.x + m[5]*pos.y + m[9]*pos.z + m[13]) / w, z); } /** Project a world point to screen space pixels, same space as mousePosScreen * - The opposite of screenToRay, and it takes the same canvas so the pair agree * @param {Vector3} pos * @param {Vector2} [canvasSize] - Defaults to the main canvas size, as in screenToRay; * the projection is whatever updateMatrices last built, which screenToRay does for its canvas * @return {Vector2|undefined} - undefined when behind the camera or closer than the near plane */ worldToScreen(pos, canvasSize=mainCanvasSize) { const clip = this.worldToClip(pos); if (!clip) return; return vec2((clip.x + 1) / 2 * canvasSize.x, (1 - clip.y) / 2 * canvasSize.y); } /** Get the world ray under a screen position, for clicking on things in 3D * - Uses the camera where it is right now, so it is fine to call from gameUpdate * - It brings the view matrices up to date for that canvas, so worldToScreen stays its exact opposite * @param {Vector2} screenPos - Same space as mousePosScreen * @param {Vector2} [canvasSize] - Defaults to the main canvas size * @return {Ray3D} - Starts at the camera with a unit direction, or on the near plane when orthographic */ screenToRay(screenPos, canvasSize=mainCanvasSize) { // a canvas with no size stands in as 1x1, rather than dividing by zero const width = canvasSize.x || 1, height = canvasSize.y || 1; const aspect = width / height, camera = this.camera; // bring the matrices up to date for this canvas, so worldToScreen and this agree on where things are this.updateMatrices(aspect); const clipX = screenPos.x / width * 2 - 1; const clipY = 1 - screenPos.y / height * 2; // the screen offset moves a parallel ray's origin, or bends a perspective ray's direction const h = camera.orthographic ? camera.orthographic / 2 : tan(camera.fov / 2); const offset = this.cameraRight.scale(clipX * h * aspect).add(this.cameraUp.scale(clipY * h)); // a parallel ray starts on the near plane, which an orthographic camera may put behind it, like three.js return camera.orthographic ? new Ray3D(camera.pos.add(offset).add(this.cameraForward.scale(camera.near)), this.cameraForward.copy()) : new Ray3D(camera.pos.copy(), this.cameraForward.add(offset).normalize()); } /** Where a screen position lands on a flat ground plane, for top down games; use HeightMap.raycast for terrain * @param {Vector2} screenPos - Same space as mousePosScreen * @param {number} [groundHeight] - World height of the ground plane * @param {Vector2} [canvasSize] - Defaults to the main canvas size, as in screenToRay * @return {Vector3|undefined} - undefined when the ray misses the plane */ screenToGround(screenPos, groundHeight=0, canvasSize=mainCanvasSize) { const ray = this.screenToRay(screenPos, canvasSize); const t = raycastPlane(ray, vec3(0, groundHeight, 0), RENDER3D_DEFAULT_NORMAL); return t === undefined ? undefined : ray.getPosition(t); } /** Find the nearest object under a screen position or along a ray, for clicking on things * - A mesh is hit on its triangles, the ones that face the ray as they are drawn, both sides of a doubleSided * mesh; a sprite is hit as the quad it draws, and a height map or a voxel map on its surface * - engineObjectsRaycast3D is the other half of this, every object along a ray instead of the nearest * @param {Vector2|Ray3D} from - A screen position like mousePosScreen, or a ray to look along * @param {Array} [objects] - Defaults to every object; only those with a mesh or a sprite count * @return {{object: EngineObject3D, distance: number}|undefined} */ pick(from, objects=engineObjects) { const ray = from instanceof Ray3D ? from : this.screenToRay(from); let nearest; for (const o of objects) { const distance = render3DRaycastObject(ray, o); if (distance !== undefined && (!nearest || distance < nearest.distance)) nearest = {object: /** @type {EngineObject3D} */ (o), distance}; // only a 3D object has a distance } return nearest; } /** Play a sound at a 3D position, quieter with distance from the camera and panned by its side, like Sound.play * with a 2D position; a sound made with no range of its own is heard to soundDefaultRange, 100, and one given * a range keeps it * @param {Sound} sound * @param {Vector3} pos3D * @param {number} [volume] * @param {number} [pitch] * @param {number} [randomnessScale] - How much to scale pitch randomness * @param {boolean} [loop] * @param {boolean} [paused] - Start it paused * @return {SoundInstance|undefined} - undefined when out of range or sound is off */ playSound(sound, pos3D, volume=1, pitch=1, randomnessScale=1, loop=false, paused=false) { // the 3D range fade and pan, Sound.play with no position does the rest ASSERT(sound instanceof Sound, 'sound must be a Sound'); ASSERT(isVector3(pos3D), 'pos3D must be a vec3'); // a sound with no range of its own is heard further in 3D than in 2D const offset = pos3D.subtract(this.camera.pos); const range = sound.rangeIsDefault ? this.soundDefaultRange : sound.range; if (range) { const distance = offset.length(); if (distance >= range) return; // out of range, at it too, where the fade is silent const taperRange = range * sound.taper; if (distance > taperRange) volume *= percent(distance, range, taperRange); } const instance = sound.play(undefined, volume, pitch, randomnessScale, loop, paused); instance?.setPan(offset.normalize().dot(this.cameraRight)); return instance; } /** Play a sound on a loop at a 3D position, the same as playSound with loop on * - Its volume and pan are set when it starts, change or stop it through the SoundInstance returned * @param {Sound} sound * @param {Vector3} pos3D * @param {number} [volume] * @param {number} [pitch] * @param {number} [randomnessScale] - How much to scale pitch randomness * @return {SoundInstance|undefined} - undefined when out of range or sound is off */ playSoundLoop(sound, pos3D, volume=1, pitch=1, randomnessScale=1) { return this.playSound(sound, pos3D, volume, pitch, randomnessScale, true); } /** Is any part of a sphere on screen this frame, the test that skips meshes the camera cannot see * - While the shadow map is drawing it tests the shadow area instead * @param {Vector3} center * @param {number} radius * @return {boolean} */ isSphereVisible(center, radius) { return render3DSphereVisible(center.x, center.y, center.z, radius); } /////////////////////////////////////////////////////////////////////////// // Meshes and the stream /** Draw a mesh with the current draw state, batched with its other uses in the opaque stage when instancing is on * @param {Mesh} mesh * @param {Matrix4|Vector3} [matrix] - Object transform, or just a position to draw it at * @param {TileInfo|TextureInfo} [tileInfo] - Texture, mesh uvs map across the tile or the whole texture * @param {Color} [color] - Tint * @return {void} */ drawMesh(mesh, matrix=RENDER3D_IDENTITY, tileInfo, color=WHITE) { matrix = render3DMatrix(matrix); ASSERT(!tileInfo || tileInfo instanceof TileInfo || tileInfo instanceof TextureInfo, 'tileInfo must be a TileInfo or TextureInfo, it comes before color'); ASSERT(isColor(color), 'color must be a Color'); if (this.capture) return void this.capture.combine(mesh, matrix, color); if (this.transparentQueue) { // the queue replays later, so it keeps copies of what a caller may reuse, like a scratch matrix const m = matrix.copy(), c = color.copy(); return this.queueTransparent(m.getTranslation(), ()=> this.drawMesh(mesh, m, tileInfo, c)); } if (!render3DCanDraw()) return; if (this.shadowPass && !this.lighting) return; // unlit things cast no shadow render3DMeshUpload(mesh); if (!mesh.bufferCount) return; const m = matrix.m; if (this.frustumCulling && !render3DSphereVisible(m[12], m[13], m[14], mesh.radius * render3DMaxStretch(m))) return; // the mesh says whether its back faces can be skipped, and a mirroring transform, one with a negative // determinant, turns the winding around so the other one is its front; the stage draws a batch at its // end, which keys on those itself if (!this.blend && this.depthTest && (mesh.instanced ?? this.instancing)) render3DInstance(mesh, matrix, tileInfo, color); else { const cullBackFaces = this.cullBackFaces, mirrored = this.mirrored; this.cullBackFaces = !mesh.doubleSided; this.mirrored = render3DMirrors(matrix); this.flush(); render3DFlushBeforeOverlay(); render3DSetDrawUniforms(matrix, tileInfo, color); render3DBindMesh(mesh); glContext.drawElements(glContext.TRIANGLES, mesh.bufferCount, mesh.indexType, 0); ++drawCount; primitiveCount += mesh.bufferCount / 3; this.cullBackFaces = cullBackFaces, this.mirrored = mirrored; } } /** Draw a triangle strip, batched into the stream with the current draw state * - Strip order: the first three points make a triangle, then each point makes another with the two before it * - List the first three points counter clockwise as seen from the front, or the face points away * and may vanish when back faces are culled * - inside a bake the strip goes into the mesh instead, in the transparent stage it is queued for sorting * and the arrays are read when the queue replays, so leave them unchanged until the stage ends * @param {Array} points - In strip order * @param {Vector3|Array} [normals] - One for all or one per point, default up * @param {Vector2|Array} [uvs] - One for all or one per point, 0-1 across the tile * @param {Color|Array} [colors] - One for all or one per point, vertex colors come before the texture * @param {TileInfo|TextureInfo} [tileInfo] - Texture for this strip * @return {void} */ drawStrip(points, normals, uvs, colors, tileInfo) { if (this.capture) { this.capture.addStrip(points, normals, uvs, colors); return; } if (this.transparentQueue) { // sort by the center of the strip let x = 0, y = 0, z = 0; for (const p of points) x += p.x, y += p.y, z += p.z; return this.queueTransparent(vec3(x, y, z).scale(1 / points.length), ()=> this.drawStrip(points, normals, uvs, colors, tileInfo)); } ASSERT(isArray(points) && points.length > 2, 'strip needs at least 3 points'); const n = points.length, count = render3DStripCount(n); const uvRect = render3DBeginStrip(count, tileInfo); if (!uvRect) return; // the tile rect is applied to each uv now, so the whole texture maps at flush const floats = this.streamFloats, ints = this.streamInts; const normalArray = isArray(normals), uvArray = isArray(uvs), colorArray = isArray(colors); const rgba = colorArray ? 0 : (colors || WHITE).rgbaInt(); for (let k = 0; k < count; ++k) { const i = render3DStripIndex(k, n), p = points[i]; const uv = uvArray ? uvs[i] : uvs || RENDER3D_DEFAULT_UV; render3DWriteVertex(floats, ints, this.streamCount++ * RENDER3D_VERTEX_FLOATS, p.x, p.y, p.z, normalArray ? normals[i] : normals || RENDER3D_DEFAULT_NORMAL, uvRect.x + uv.x * uvRect.w, uvRect.y + uv.y * uvRect.h, colorArray ? colors[i].rgbaInt() : rgba); } } /** Draw a strip with lighting off, for camera facing shapes where the light direction means nothing * @param {Array} points - Strip order * @param {Vector3|Array} [normals] * @param {Vector2|Array} [uvs] * @param {Color|Array} [colors] * @param {TileInfo|TextureInfo} [tileInfo] */ drawStripUnlit(points, normals, uvs, colors, tileInfo) { render3DWithState({lighting: false}, ()=> this.drawStrip(points, normals, uvs, colors, tileInfo)); } /** Draw the pending stream vertices as one strip with the state they were drawn under, called automatically when needed */ flush() { if (!this.streamCount || !render3DCanDraw()) return; const gl = glContext; if (this.streamUnlit) this.streamState.lighting = false; // its own copy of the state render3DSetDrawUniforms(RENDER3D_IDENTITY, this.streamTileInfo, WHITE, RENDER3D_FULL_UV_RECT, this.streamState); render3DBindVertexBuffer(this.streamBuffer); gl.bufferSubData(gl.ARRAY_BUFFER, 0, this.streamFloats, 0, this.streamCount * RENDER3D_VERTEX_FLOATS); gl.drawArrays(gl.TRIANGLE_STRIP, 0, this.streamCount); ++drawCount; primitiveCount += this.streamCount; this.streamCount = 0; } /** Build a mesh once out of draw calls, instead of redrawing the shapes every frame * - Call the same drawStrip, drawQuad and drawBox calls inside, and get a mesh back * - Strips inside a bake ignore their tileInfo, the finished mesh picks the texture when it draws * - drawMesh, drawBox and drawSphere copy their mesh in, moved and tinted, their tileInfo dropped too * - The mesh skips its back faces like any, set doubleSided when what was drawn is open * @param {Function} drawFunction * @return {Mesh} */ bake(drawFunction) { this.flush(); ASSERT(!this.capture, 'bake cannot be nested'); const mesh = this.capture = new Mesh; try { drawFunction(); } finally { this.capture = undefined; } return mesh; } /////////////////////////////////////////////////////////////////////////// // The stages, run by the pass /** Draw a layer's objects, solid ones first and see through ones after, called automatically * - The main layer also draws the sky, the render callbacks and the debug shapes * @param {Array} objects * @param {boolean} [isDefault] */ renderStages(objects, isDefault=true) { const opaque = [], transparent = []; for (const o of objects) (o.transparent || render3DSetting(o, 'additive') ? transparent : opaque).push(o); isDefault && (this.skyBox && this.skyBox !== this.capturingCube || this.sky) && this.drawSky(); // opaque: no blending, depth writes on, by render order this.blend = false; this.depthWrite = true; const byOrder = (a, b)=> (a.drawOwner ? render3DSetting(a, 'renderOrder') : a.renderOrder) - (b.drawOwner ? render3DSetting(b, 'renderOrder') : b.renderOrder); opaque.sort(byOrder); transparent.sort(byOrder); render3DDrawObjects(opaque); isDefault && this.onRenderOpaque?.(); this.flush(); render3DFlushInstances(); // transparent: blending on, depth writes off, every draw queued then replayed far to near this.blend = true; this.depthWrite = false; this.transparentQueue = this.sortTransparent ? [] : undefined; try { render3DDrawObjects(transparent); for (const o of objects) if (o.softShadow) { // the shadow grows with the object, by the same scale picking and culling // measure it at, so one size set once holds however the object is scaled const m = render3DObjectMatrix(o); this.drawSoftShadow(m.getTranslation(), o.softShadow * render3DMaxScale(m.m), this.softShadowHeight); } isDefault && this.onRenderTransparent?.(); } finally { this.flushTransparentQueue(); } isDefault && !this.capturingCube && render3DRenderDebug(); this.flush(); // leave the fields at the opaque defaults for anything reading them outside the pass render3DSetObjectState(); this.blend = false; this.depthWrite = true; } /** Queue a draw for the transparent stage, replayed far to near with the current draw state, or draw it now when * sorting is off * - The draw runs later, so it should hold copies of any values the caller may change before then * @param {Vector3} pos - Where the draw is, for sorting * @param {function(): void} draw * @return {void} */ queueTransparent(pos, draw) { if (!this.transparentQueue) { draw(); return; } // sort by depth along the view, not distance, so an orthographic camera orders them right too const f = this.cameraForward, c = this.camera.pos; const distance = (pos.x - c.x)*f.x + (pos.y - c.y)*f.y + (pos.z - c.z)*f.z; this.transparentQueue.push({distance, state: render3DCaptureBatchState(), draw}); } /** Draw the queued transparent draws far to near with the state each was drawn under, called automatically at the * end of the transparent stage */ flushTransparentQueue() { const queue = this.transparentQueue; if (!queue) return; this.transparentQueue = undefined; queue.sort((a, b)=> b.distance - a.distance); // each draw under the state it was queued with, and the state left as it was found const state = render3DCaptureBatchState(); try { for (const item of queue) render3DApplyBatchState(item.state), item.draw(); } finally { render3DApplyBatchState(state); } } /** Draw render3D.skyBox, or render3D.sky around the camera, unlit, unfogged and behind everything, called * automatically by the pass */ drawSky() { this.flush(); if (this.skyBox && this.skyBox !== this.capturingCube && render3DDrawSkyBox(this.skyBox)) return; // a plain return, so the method is typed void and a subclass may override it so if (!this.sky) return; // a sky box that could not draw, with no dome to draw instead // the dome only has to sit between the clip planes, the pass draws it first with no depth test; // a far plane at Infinity has no midpoint, so put it a long way out instead const {near, far} = this.camera; const radius = far == Infinity ? near * 1e4 : (near + far) / 2; render3DWithState({lighting: false, blend: false, depthTest: false, depthWrite: false, fogEnd: 0, shader: undefined, emissiveMap: undefined}, ()=> this.drawMesh(this.sky, buildMatrix(this.camera.pos, undefined, vec3(radius)))); } /** Rebuild the light's view projection around the shadow center, called automatically each frame shadows are on */ updateShadowMatrix() { const caster = render3DShadowCaster(); if (caster) { // a spotlight's map looks down its cone with perspective, from just in front of it out to its radius const pos = caster.getWorldPos3D(), forward = caster.getForward3D(); const up = abs(forward.y) > .99 ? vec3(0, 0, 1) : vec3(0, 1, 0); // the near plane stays in front of the far one however small the light const far = caster.radius, near = min(max(far / 500, .02), far / 2); const fov = min(caster.coneAngle, RENDER3D_SHADOW_CONE_MAX) * 2 + .1; const view = Matrix4.lookAt(pos, pos.add(forward), up); this.shadowMatrix = Matrix4.perspective(fov, 1, near, far).multiply(view.copy().invert()); // the map's own right and up are not the light's, which a gel is upright by: the four numbers that // turn the one into the other const v = view.m, l = render3DObjectMatrix(caster).m; const right = vec3(l[0], l[1], l[2]).normalize(), lightUp = vec3(l[4], l[5], l[6]).normalize(); const mapRight = vec3(v[0], v[1], v[2]), mapUp = vec3(v[4], v[5], v[6]); this.gelAxes = [right.dot(mapRight), right.dot(mapUp), lightUp.dot(mapRight), lightUp.dot(mapUp)]; this.shadowPlanes = render3DFrustumPlanes(this.shadowMatrix); // depth is not even with perspective: the lookup divides this by the distance squared, which makes // the bias the same distance in the world near the light and far from it this.shadowDepthBias = this.shadowBias * far * far * near / (far - near); return; } this.shadowDepthBias = this.shadowBias; // the sun's map is flat, its depth even ASSERT(this.shadowRange > 0, 'shadowRange must be positive'); const range = this.shadowRange > 0 ? this.shadowRange : 1, half = range / 2; const toSun = this.sunDirection.normalize(); const center = this.shadowCenter || this.camera.pos.add(this.cameraForward.scale(half * .8)); const view = Matrix4.lookAt(center.add(toSun.scale(range)), center).invert(); // move the light's view in whole pixel steps so shadow edges do not crawl as the camera moves const texel = range / (this.shadowTextureSize || this.shadowMapSize), m = view.m; // no texture in headless mode m[12] = round(m[12] / texel) * texel; m[13] = round(m[13] / texel) * texel; this.shadowMatrix = Matrix4.orthographic(-half, half, -half, half, 0, range * 2).multiply(view); this.shadowPlanes = render3DFrustumPlanes(this.shadowMatrix); } /** Build a sky dome, set it as the sky, and light the scene by it: the fog takes the horizon color, and the * ambient light comes from the top color above and the bottom color below, both at the ambient strength * @param {Color} [topColor] - Straight up * @param {Color} [horizonColor] - Level with the camera * @param {Color} [bottomColor] - Straight down, defaults to the horizon color * @param {number} [ambient] - How much of the sky colors lights the scene as ambient, 0 for none * @return {Mesh} - The dome, also in render3D.sky */ setSky(topColor=hsl(.6, .8, .55), horizonColor=hsl(.6, 1, .9), bottomColor=horizonColor, ambient=.5) { this.sky?.dispose(); this.sky = buildSky(topColor, horizonColor, bottomColor); this.fogColor = horizonColor.copy(); this.ambientColor = topColor.scale(ambient, 1); this.ambientGroundColor = bottomColor.scale(ambient, 1); return this.sky; } /** Set where fog starts and ends, and its color * @param {number} fogStart - Distance from the camera where fog starts * @param {number} fogEnd - Distance where fog is total, 0 disables fog * @param {Color} [fogColor] - Leaves the color alone when not passed, setSky sets it to the horizon */ setFog(fogStart, fogEnd, fogColor) { this.fogStart = fogStart; this.fogEnd = fogEnd; if (fogColor) this.fogColor = fogColor.copy(); } /////////////////////////////////////////////////////////////////////////// // Immediate mode shapes /** Draw a box, untextured, for blocking out a scene without meshes or objects * @param {Vector3} pos - Center * @param {Vector3|number} [size] - Full size, a number for a cube * @param {Color} [color] * @param {Vector3} [rotation] - vec3(pitch, yaw, roll) */ drawBox(pos, size=1, color=WHITE, rotation) { this.drawMesh(this.boxMesh, buildMatrix(pos, rotation, render3DSize3(size)), undefined, color); } /** Draw a sphere, untextured and smooth shaded * @param {Vector3} pos - Center * @param {number} [size] - Diameter * @param {Color} [color] */ drawSphere(pos, size=1, color=WHITE) { this.drawMesh(this.sphereMesh, buildMatrix(pos, undefined, vec3(size)), undefined, color); } /** Draw a flat square that always faces the camera, unlit so it keeps its own colors * - Draw it from onRenderTransparent or a transparent object so it can fade * @param {Vector3} pos - Center * @param {Vector2} [size] - World units * @param {TileInfo|TextureInfo} [tileInfo] * @param {Color} [color] * @param {number} [angle] - Rotation in the camera plane, counter clockwise * @param {boolean} [upright] - Stand on world up and only turn to face the camera, for sprites on the ground * @return {void} */ drawBillboard(pos, size=vec2(1), tileInfo, color=WHITE, angle=0, upright=false) { if (this.capture) // the mesh keeps the color, so it gets its own, the particles reuse theirs return this.drawStripUnlit(render3DBillboardCorners(pos, size, angle, upright), this.cameraBack, RENDER3D_QUAD_UVS, color.copy(), tileInfo); if (this.transparentQueue) // sort by the exact position, a shadow under it sorts by the floor { const p = pos.copy(), s = size.copy(), c = color.copy(); // copies, the queue replays later return this.queueTransparent(p, ()=> this.drawBillboard(p, s, tileInfo, c, angle, upright)); } // unlit on screen, in the shadow map the object's flag decides; the batch is told, the draw state is not // set, which would move its version at every sprite const unlit = !this.shadowPass; if (!this.blend && this.depthTest && this.instancing) { // an opaque sprite is one more instance of the shared quad, as a mesh's uses are: every sprite of a // sheet is one instanced draw at the end of the stage, with nothing written per corner; the shader // drops its see through texels, so it needs no sorting if (!render3DCanDraw() || this.shadowPass && !this.lighting) return; // unlit things cast no shadow if (this.frustumCulling && !render3DSphereVisible(pos.x, pos.y, pos.z, hypot(size.x, size.y) / 2)) return; const quad = this.billboardMesh, cb = this.cameraBack, uv = render3DGetTileUVs(tileInfo); render3DMeshUpload(quad); // the quad's half axes, doubled for its unit square const a = render3DBillboardAxes(size, angle, upright); const k = render3DInstanceSlot(quad, render3DTextureOf(tileInfo), false, unlit), data = quad.instanceData; data[k] = a[0] * 2; data[k+1] = a[1] * 2; data[k+2] = a[2] * 2; data[k+3] = 0; data[k+4] = a[3] * 2; data[k+5] = a[4] * 2; data[k+6] = a[5] * 2; data[k+7] = 0; data[k+8] = cb.x; data[k+9] = cb.y; data[k+10] = cb.z; data[k+11] = 0; data[k+12] = pos.x; data[k+13] = pos.y; data[k+14] = pos.z; data[k+15] = 1; data[k+16] = color.r; data[k+17] = color.g; data[k+18] = color.b; data[k+19] = color.a; data[k+20] = uv.x; data[k+21] = uv.y; data[k+22] = uv.w; data[k+23] = uv.h; return; } // one that blends is drawn in its sorted place: the quad's six stream vertices written straight in with no // vectors made, for sprites that blend, and for particles when instancing is off const uvRect = render3DBeginStrip(6, tileInfo, unlit); if (!uvRect) return; const a = render3DBillboardAxes(size, angle, upright); const rx = a[0], ry = a[1], rz = a[2], ux = a[3], uy = a[4], uz = a[5]; const x = pos.x, y = pos.y, z = pos.z, n = this.cameraBack, rgba = color.rgbaInt(); const u0 = uvRect.x, v0 = uvRect.y, u1 = u0 + uvRect.w, v1 = v0 + uvRect.h; const floats = this.streamFloats, ints = this.streamInts, stride = RENDER3D_VERTEX_FLOATS; let j = this.streamCount * stride; this.streamCount += 6; // the corners in strip order with the repeats: top left twice, bottom left, top right, bottom right twice render3DWriteVertex(floats, ints, j, x - rx + ux, y - ry + uy, z - rz + uz, n, u0, v0, rgba); render3DWriteVertex(floats, ints, j += stride, x - rx + ux, y - ry + uy, z - rz + uz, n, u0, v0, rgba); render3DWriteVertex(floats, ints, j += stride, x - rx - ux, y - ry - uy, z - rz - uz, n, u0, v1, rgba); render3DWriteVertex(floats, ints, j += stride, x + rx + ux, y + ry + uy, z + rz + uz, n, u1, v0, rgba); render3DWriteVertex(floats, ints, j += stride, x + rx - ux, y + ry - uy, z + rz - uz, n, u1, v1, rgba); render3DWriteVertex(floats, ints, j += stride, x + rx - ux, y + ry - uy, z + rz - uz, n, u1, v1, rgba); } /** Draw a quad from four corners in loop order, counter clockwise seen from the front, a is the top left of the texture * @param {Vector3} a * @param {Vector3} b * @param {Vector3} c * @param {Vector3} d * @param {TileInfo|TextureInfo} [tileInfo] * @param {Color|Array} [color] - One for all or one per corner */ drawQuad(a, b, c, d, tileInfo, color=WHITE) { if (this.transparentQueue) // the queue replays later, so it keeps copies of what a caller may reuse { a = a.copy(), b = b.copy(), c = c.copy(), d = d.copy(); color = isArray(color) ? color.map(k=> k.copy()) : color.copy(); } this.drawStrip(render3DQuadStrip(a, b, c, d), render3DFaceNormal(a, b, c, d), RENDER3D_QUAD_UVS, render3DQuadValues(color), tileInfo); } /** Draw a triangle, counter clockwise from outside is the front * @param {Vector3} a * @param {Vector3} b * @param {Vector3} c * @param {Color} [color] */ drawTriangle(a, b, c, color=WHITE) { if (this.transparentQueue) // the queue replays later, so it keeps copies of what a caller may reuse a = a.copy(), b = b.copy(), c = c.copy(), color = color.copy(); this.drawStrip([a, b, c], render3DFaceNormal(a, b, c), undefined, color); } /** Draw a line as a camera facing ribbon, unlit * @param {Vector3} posA * @param {Vector3} posB * @param {number} [width] * @param {Color} [color] */ drawLine(posA, posB, width=.1, color=WHITE) { this.drawRibbon([posA, posB], width, undefined, color); } /** Draw a ribbon along a path, unlit and visible from both sides; width and color can change along it * - The texture runs along the length, u from the first point to the last * - A path that ends where it starts is a loop, and joins with no seam * @param {Array} points - Center line in order, at least two * @param {number|Array} [width] - Full width, one for all or one per point * @param {TileInfo|TextureInfo} [tileInfo] * @param {Color|Array} [color] - One for all or one per point * @param {Vector3|Array} [side] - Direction across the ribbon, one for all or one per point, default * faces the camera */ drawRibbon(points, width=.1, tileInfo, color=WHITE, side) { const count = points.length; ASSERT(count > 1, 'a ribbon needs at least two points'); ASSERT(!tileInfo || tileInfo instanceof TileInfo || tileInfo instanceof TextureInfo, 'tileInfo must be a TileInfo or TextureInfo, it comes before color'); const strip = [], uvs = tileInfo ? [] : undefined, colors = [], forward = this.cameraForward; let across = vec3(1, 0, 0); // kept from the last point where the direction vanishes // a loop's two ends take their direction across the join, so they meet edge to edge const loop = count > 2 && points[0].distanceSquared(points[count - 1]) < 1e-12; for (let i = 0; i < count; ++i) { const p = points[i]; const w = isArray(width) ? width[i] : width; let c = isArray(color) ? color[i] : color; if (this.transparentQueue) // the queue replays later, so it keeps a copy of a color a caller may reuse c = c.copy(); const s = side && (isArray(side) ? side[i] : side); // across the path in the camera plane unless a side is given const next = points[i < count - 1 ? i + 1 : loop ? 1 : i]; const last = points[i > 0 ? i - 1 : loop ? count - 2 : i]; const dir = s || next.subtract(last).cross(forward); if (dir.lengthSquared() > 1e-12) across = dir.normalize(); const half = across.scale(w / 2); strip.push(p.add(half), p.subtract(half)); uvs?.push(vec2(i / (count - 1), 0), vec2(i / (count - 1), 1)); colors.push(c, c); } render3DWithState({lighting: false, cullBackFaces: false}, ()=> this.drawStrip(strip, forward.scale(-1), uvs, colors, tileInfo)); } /** Draw a disc that fades to transparent at the rim, unlit, for glows, puffs and sky dots * @param {Vector3} pos - Center * @param {number} [size] - Diameter * @param {Color} [color] * @param {Vector3} [normal] - Facing direction, faces the camera by default * @param {number} [sides] * @return {void} */ drawSoftDisc(pos, size=1, color=WHITE, normal=this.cameraBack, sides=16) { render3DAssertBlending(); if (this.transparentQueue && !this.capture) { const p = pos.copy(), c = color.copy(), n = normal.copy(); // copies, the queue replays later return this.queueTransparent(p, ()=> this.drawSoftDisc(p, size, c, n, sides)); } // basis in the disc's plane const n = normal.normalize(); const helper = abs(n.y) < .9 ? vec3(0, 1, 0) : vec3(1, 0, 0); const u = helper.cross(n).normalize(), w = u.cross(n); render3DDrawSoftDisc(size / 2, color, sides, n, (c, s, r)=> vec3(pos.x + (u.x * c + w.x * s) * r, pos.y + (u.y * c + w.y * s) * r, pos.z + (u.z * c + w.z * s) * r)); } /** Draw a soft round shadow on the ground under something, much cheaper than a real shadow * - Draw it from onRenderTransparent or from a transparent object * @param {Vector3} pos - Position of the thing casting the shadow * @param {number} [size] - Diameter * @param {number|HeightMap|function(number, number): number} [floorHeight] - Height of the ground, a HeightMap, or * (x, z) => y to follow terrain * @param {Color} [color] * @param {number} [lift] - How far above the ground to draw, raise it if the shadow cuts into rough ground * @return {void} */ drawSoftShadow(pos, size=1, floorHeight=0, color=RENDER3D_SHADOW_COLOR, lift=.02) { render3DAssertBlending(); // a HeightMap is in the extras plugin, so it is known by its getHeight rather than its class const heightMap = /** @type {HeightMap} */ (floorHeight); const height = /** @type {function(number, number): number} */ (isNumber(floorHeight) ? ()=> floorHeight : heightMap.getHeight ? (x, z)=> heightMap.getHeight(x, z) : floorHeight); if (this.transparentQueue && !this.capture) // sort from the floor, under whatever casts it { const p = pos.copy(), c = color.copy(); // copies, the queue replays later return this.queueTransparent(vec3(p.x, height(p.x, p.z) + lift, p.z), ()=> this.drawSoftShadow(p, size, floorHeight, c, lift)); } render3DDrawSoftDisc(size / 2, color, 16, RENDER3D_DEFAULT_NORMAL, (c, s, r)=> { const x = pos.x + c * r, z = pos.z + s * r; return vec3(x, height(x, z) + lift, z); }); } } function render3DAssertBlending() { const r = render3D; ASSERT(r.blend || r.capture || r.shadowPass || !r.isRendering, 'soft discs and shadows need blending: set the object transparent or draw from onRenderTransparent'); } // draw the three rings of a soft disc as unlit strips, pointAt(cos, sin, radius) gives the world point function render3DDrawSoftDisc(radius, color, sides, normal, pointAt) { const alpha = [1, .9, .7, 0], circle = render3DCircle(sides); // alpha by ring, center to rim for (let k = 0; k < 3; ++k) { const points = [], colors = []; const c0 = color.withAlpha(color.a * alpha[k]), c1 = color.withAlpha(color.a * alpha[k+1]); const r0 = radius * k / 3, r1 = radius * (k + 1) / 3; for (let i = 0; i <= sides; ++i) { const c = circle[i*2], s = circle[i*2 + 1]; points.push(pointAt(c, s, r1), pointAt(c, s, r0)); colors.push(c1, c0); } render3D.drawStripUnlit(points, normal, undefined, colors); } } /////////////////////////////////////////////////////////////////////////////// /** * Camera3D - Position, rotation and lens for the 3D view * - Looks down its -Z axis, rotation is vec3(pitch, yaw, roll) * @memberof Render3D */ class Camera3D { /** Create a camera, looking down -Z from z=10 by default */ constructor() { /** @property {Vector3} - World position */ this.pos = vec3(0, 0, 10); /** @property {Vector3} - Euler rotation, vec3(pitch, yaw, roll) in radians */ this.rotation = vec3(); /** @property {number} - Vertical field of view in radians */ this.fov = PI/3; /** @property {number} - Near clip distance */ this.near = .1; /** @property {number} - Far clip distance, Infinity is allowed for a perspective view */ this.far = 1e3; /** @property {number} - Visible height in world units for an orthographic view, 0 is perspective */ this.orthographic = 0; /** @property {boolean} - Line the 3D camera up with the 2D camera, so 3D things at z=0 sit on the 2D sprites; * it lines up with the 2D view of the main canvas, whatever canvas size a screenToRay is given */ this.align2D = false; /** @property {number} - The z of the plane align2D lines up with the 2D view, the camera sitting its * distance in front of it */ this.align2DZ = 0; } /** Returns the camera's world transform * @return {Matrix4} */ getMatrix() { return buildMatrix(this.pos, this.rotation); } /** Returns the view matrix, world to camera space * @return {Matrix4} */ getViewMatrix() { return this.getMatrix().invert(); } /** Returns the projection matrix * @param {number} aspect - Width over height * @return {Matrix4} */ getProjectionMatrix(aspect) { const h = this.orthographic / 2, w = h * aspect; return h ? Matrix4.orthographic(-w, w, -h, h, this.near, this.far) : Matrix4.perspective(this.fov, aspect, this.near, this.far); } /** Returns the direction the camera looks * @return {Vector3} */ getForward() { return render3DAxis(this.getMatrix().m, 8).scale(-1); } /** Returns the camera's right axis * @return {Vector3} */ getRight() { return render3DAxis(this.getMatrix().m, 0); } /** Returns the camera's up axis * @return {Vector3} */ getUp() { return render3DAxis(this.getMatrix().m, 4); } /** Point the camera at a target, sets pitch and yaw and clears roll * @param {Vector3} target */ lookAt(target) { this.rotation = render3DLookRotation(target.subtract(this.pos), this.rotation); } /** Put the camera on an orbit around a target, looking at it * @param {Vector3} target * @param {number} distance * @param {number} yaw - Radians around Y * @param {number} [pitch] - Radians above the horizon */ orbit(target, distance, yaw, pitch=.5) { const r = cos(pitch) * distance; this.pos = target.add(vec3(sin(yaw) * r, sin(pitch) * distance, cos(yaw) * r)); // straight down has no yaw of its own to look along, so the orbit's yaw is used, and a top down view turns this.rotation = render3DLookRotation(target.subtract(this.pos), vec3(0, yaw, 0)); } /** Chase a target from an offset, easing toward it, and look at it * @param {Vector3} target * @param {Vector3} offset - Where to sit relative to the target * @param {number} [percent] - How far to move toward the spot each call, 1 snaps */ follow(target, offset, percent=1) { this.pos = this.pos.lerp(target.add(offset), percent); this.lookAt(target); } /** Line the 3D camera up with the 2D camera, called automatically when align2D is set * @param {number} [canvasHeight] - Defaults to the main canvas height */ update2D(canvasHeight=mainCanvasSize.y) { const halfHeight = canvasHeight / 2 / cameraScale; // half visible height in world units const distance = halfHeight / tan(this.fov/2); // a zoomed out 2D camera sits a long way back, far enough to fall past the far plane and // clip the whole scene away, which looks like nothing rendering at all ASSERT(!canvasHeight || distance < this.far, 'align2D needs this camera distance to match the 2D view, raise camera.far past it', distance); this.orthographic &&= halfHeight * 2; // an orthographic camera stays orthographic and shows the same height this.pos = vec3(cameraPos.x, cameraPos.y, this.align2DZ + distance); this.rotation = vec3(0, 0, -cameraAngle); // 2D angles turn the other way } } /////////////////////////////////////////////////////////////////////////////// // GL setup, shaders and the frame hooks // the four attributes of a 36 byte vertex at the locations the shaders declare: location, size, type, normalize, byte offset /** @type {Array<[number, number, number, boolean, number]>} */ const RENDER3D_ATTRIBS = [[0, 3, 5126, false, 0], [1, 3, 5126, false, 12], [2, 2, 5126, false, 24], [3, 4, 5121, true, 32]]; // the vertex shader, shared by the plugin's program and every Shader's // attributes: p position, n normal, t uv, c color, at fixed slots the depth shader also uses // uniforms: viewProj, lightViewProj; the model matrix, the tint and the uv rect are vertex attributes, see // RENDER3D_VERTEX_INPUTS; L is the mesh's own uv for a Shader's localUV // the normal matrix comes from the model matrix here: each column over its squared length, which is the inverse // transpose of any rotation and scale, mirrored or not, and skips a 3x3 inverse per draw on the CPU; a sheared // matrix, one built by multiplying rotations with scales between them, gets normals that are only close const RENDER3D_VERTEX_SOURCE = '#version 300 es\n' + 'precision highp float;' + 'uniform mat4 viewProj,lightViewProj;' + RENDER3D_VERTEX_INPUTS + 'out vec3 P,N;out vec2 T,L;out vec4 C,S;' + 'void main(){' + 'vec4 w=mat4(m0,m1,m2,m3)*vec4(p,1.);' + 'gl_Position=viewProj*w;' + 'P=w.xyz;' + 'vec3 c0=m0.xyz,c1=m1.xyz,c2=m2.xyz;' + 'N=mat3(c0/max(dot(c0,c0),1e-20),c1/max(dot(c1,c1),1e-20),c2/max(dot(c2,c2),1e-20))*n;' + 'T=uvRect.xy+t*uvRect.zw;' + 'L=t;' + 'C=c*tint;' + 'S=lightViewProj*w;' + '}'; // the names a Shader's snippet can use in 3D, over the plugin's own uniforms and varyings const RENDER3D_SNIPPET_NAMES = 'uniform float iTime;uniform vec3 iResolution;\n' + '#define iChannel0 tex\n' + '#define localUV L\n' + '#define worldPos P\n' + '#define worldNormal N\n' + '#define sunDirection (-lightDir.xyz)\n' + '#define sunColor lightColor.rgb\n' + '#define ambientColor ambientFog.rgb\n' + '#define ambientGroundColor ambientGround.rgb\n' + '#define lightCount extraLightCount\n' + '#define lights extraLights\n' + '#define lightColors extraLightColors\n'; // the fragment shader; given a Shader's snippet, its mainImage replaces the texture sample and all else is the same, // and a mainNormal in it bends the normal after the normal map, before all the lighting reads it // uniforms: lightDir (xyz the way the sunlight travels, w = emissive, 1 or more skips the lighting), // lightColor (the sun's rgb, a = specular), ambientFog (rgb, a = fogEnd), fogColor (rgb, a = fogStart), // fogFade (x = fogFadeStart, y = fogFadeEnd, 0 skips the fade, z = 1 measures fog along the view), // cameraPos, cameraDir (the view's forward), tex, shadowMap, shadowParams (x = shadows on, y = bias, z = blur step in texture space, // w = how the draw finishes: 1 opaque and alpha tested, 0 blended, -1 additive), // materialParams (x = normal map scale, 0 skips it, y = shininess, z = reflectivity), emissiveTint (rgb, a = the // emissive map is on), skyTop, skyHorizon, skyBottom (what a reflection shows), normalTex, emissiveTex function render3DFragmentSource(fragmentCode) { const hasNormal = /\bmainNormal\s*\(/.test(fragmentCode || ''); return '#version 300 es\n' + 'precision highp float;' + 'uniform vec4 lightDir,lightColor,ambientFog,ambientGround,fogColor,shadowParams,fogFade;' + 'uniform vec4 extraLights[' + RENDER3D_MAX_LIGHTS + '],extraLightColors[' + RENDER3D_MAX_LIGHTS + '];' + 'uniform vec4 extraLightCones[' + RENDER3D_MAX_LIGHTS + '];' + 'uniform int extraLightCount;' + 'uniform vec3 cameraPos,cameraDir;' + 'uniform vec4 materialParams,emissiveTint,skyTop,skyHorizon,skyBottom,gelAxes;' + 'uniform sampler2D tex,normalTex,emissiveTex,gelTex;' + 'uniform samplerCube envMap;' + // the environment, when envParams.x is 1, envParams.y its last mipmap, z the // level a shininess of -1 would read, log2(size * .45), and w the level a turn of one radian a pixel reads 'uniform vec4 envParams;' + 'uniform bool premultipliedTexture;' + // is the texture a render target, which holds premultiplied color 'uniform highp sampler2DShadow shadowMap;' + 'in vec3 P,N;in vec2 T,L;in vec4 C,S;' + 'out vec4 o;' + // the shadow at this fragment, 0 to 1: the light's depth map with a 3x3 blur, outside the map is lit; the // sun's map is flat and S.w is 1, a spotlight's has perspective and S.w is the distance from it, where the // bias shrinks by its square to stay the same distance in the world 'float shadow(){' + 'if(shadowParams.x<=0.||S.w<=0.)return 1.;' + 'vec3 q=S.xyz/S.w*.5+.5;' + 'if(any(greaterThanEqual(abs(q-.5),vec3(.5))))return 1.;' + 'q.z-=shadowParams.y/(S.w*S.w);' + 'float s=0.;' + 'for(int x=-1;x<=1;++x)for(int y=-1;y<=1;++y)' + 's+=texture(shadowMap,vec3(q.xy+vec2(x,y)*shadowParams.z,q.z));' + 'return s/9.;}' + // the gel the shadow light shines through, at this fragment's place in its view, upright as it looks out; // white with no gel, so a light without one is exactly as before 'vec3 gel(){' + 'if(S.w<=0.)return vec3(1);' + 'vec2 g=S.xy/S.w,u=vec2(dot(gelAxes.xy,g),dot(gelAxes.zw,g))*.5+.5;' + 'return texture(gelTex,vec2(u.x,1.-u.y)).rgb;}' + // the normal map's normal here, in the frame that the position and texture coordinate change along across the // screen, so a mesh needs no tangents; green points up the image and v runs down it, so up is -v; // a mesh with no texture coordinates has no frame and keeps its own normal 'vec3 normalMapNormal(vec3 n){' + 'vec3 p1=dFdx(P),p2=dFdy(P);vec2 t1=dFdx(T),t2=dFdy(T);' + 'vec3 a=cross(p2,n),b=cross(n,p1),u=a*t1.x+b*t2.x,v=a*t1.y+b*t2.y;' + 'float k=max(dot(u,u),dot(v,v));' + 'if(k<=0.)return n;' + 'vec3 m=texture(normalTex,T).xyz*2.-1.;' + 'm.xy*=materialParams.x;' + 'return normalize((u*m.x-v*m.y)*inversesqrt(k)+n*m.z);}' + // a snippet's mainImage is declared here and written after main, so a define in it can not reach main (fragmentCode ? 'void mainImage(out vec4,vec2);' : '') + (hasNormal ? 'void mainNormal(inout vec3);' : '') + 'void main(){' + (fragmentCode ? 'vec4 t;mainImage(t,T);' : 'vec4 t=texture(tex,T);') + 'if(premultipliedTexture&&t.a>0.)t.rgb/=t.a;' + // back to straight color, what the lighting and blend expect 'vec4 c=C*t;' + 'float e=lightDir.w;' + 'if(e<1.){' + 'vec3 n=dot(N,N)>0.?normalize(N):vec3(0,1,0);' + 'if(!gl_FrontFacing)n=-n;' + // only a double sided mesh shows a back face, light it on the side that is seen 'if(materialParams.x!=0.)n=normalMapNormal(n);' + (hasNormal ? 'mainNormal(n);n=normalize(n);' : '') + 'float nl=dot(n,-lightDir.xyz);' + // the shadow is the sun's, or one spotlight's: shadowParams.x is 1 for the sun, 2 and up for that Light3D 'float sh=shadow(),s=shadowParams.x<1.5?sh:1.;' + 'int si=int(shadowParams.x+.5)-2;' + // the ambient: one color, or blended from the ground color below to the sky color above by the way the face points 'vec3 l=(ambientGround.a>0.?mix(ambientGround.rgb,ambientFog.rgb,n.y*.5+.5):ambientFog.rgb)+lightColor.rgb*max(nl,0.)*s;' + // the Light3D objects: a point light falls off with distance, a directional one does not and carries the // direction toward it in xyz, marked by a negative radius; each adds its own highlight when there is a strength 'vec3 eye=lightColor.a>0.?normalize(cameraPos-P):vec3(0),sp=vec3(0);' + 'for(int i=0;i<' + RENDER3D_MAX_LIGHTS + ';++i){' + 'if(i>=extraLightCount)break;' + 'vec4 L=extraLights[i];' + 'bool directional=L.w<0.;' + 'vec3 v=directional?L.xyz:L.xyz-P;' + 'float d=length(v);' + 'float a=directional?1.:max(0.,1.-d/L.w);' + 'v/=max(d,1e-6);' + 'float ln=dot(n,v);' + 'vec3 lc=extraLightColors[i].rgb*extraLightColors[i].a*a*a;' + // a spotlight's cone: full where its fade starts, nothing at its edge, smooth between; 1 with no cone 'vec4 K=extraLightCones[i];' + 'float k=clamp(dot(K.xyz,-v)-K.w,0.,1.);' + 'lc*=k*k*(3.-2.*k);' + 'if(i==si)lc*=sh*gel();' + 'l+=lc*max(0.,ln);' + 'if(lightColor.a>0.)sp+=lc*pow(max(dot(reflect(-v,n),eye),0.),materialParams.y)*step(0.,ln);' + '}' + 'c.rgb*=l*(1.-e)+e;' + // lit, blended toward its own color by how emissive it is // specular: the sun's only where its light hits and out of shadow, then the Light3D highlights, // skipped entirely when the strength is zero 'if(lightColor.a>0.){' + 'vec3 r=reflect(lightDir.xyz,n);' + 'c.rgb+=lightColor.rgb*pow(max(dot(r,eye),0.),materialParams.y)*lightColor.a*step(0.,nl)*s*(1.-e)+sp*lightColor.a*(1.-e);' + '}' + // the sky along the reflected view, more at a glancing angle (Schlick's Fresnel); sky light, so the sun's // shadow does not dim it 'if(materialParams.z>0.){' + 'vec3 w=normalize(P-cameraPos),q=reflect(w,n);' + 'float f=materialParams.z+(1.-materialParams.z)*pow(clamp(1.-dot(n,-w),0.,1.),5.);' + // the environment when there is one, blurred more the lower the shininess, a mirror at a shininess of // 10000 and toward its last mipmap at 1, or by how far the reflection turns from one pixel to the next when // that is more, so a small mirror reads the blur of what its pixels cover instead of sparkling 'c.rgb=mix(c.rgb,envParams.x>0.?textureLod(envMap,q,clamp(max(envParams.z-.5*log2(materialParams.y+2.),' + 'envParams.w+log2(max(max(length(dFdx(q)),length(dFdy(q))),1e-9))),0.,envParams.y)).rgb:' + 'q.y>0.?mix(skyHorizon.rgb,skyTop.rgb,q.y):mix(skyHorizon.rgb,skyBottom.rgb,-q.y),f);' + '}}else c.rgb*=e;' + // fully emissive: its own color, or brighter, with no lighting to work out // the emissive map adds its light on top, lit or not 'if(emissiveTint.a>0.)c.rgb+=texture(emissiveTex,T).rgb*emissiveTint.rgb;' + 'if(ambientFog.a>0.||fogFade.y>0.){' + 'float z=fogFade.z>0.?dot(P-cameraPos,cameraDir):distance(cameraPos,P);' + 'if(ambientFog.a>0.)' + 'c.rgb=mix(c.rgb,shadowParams.w<0.?vec3(0):fogColor.rgb,smoothstep(fogColor.a,ambientFog.a,z));' + // see through draws fade out with distance after the fog color, so they melt into what is behind them; // an opaque draw stays solid 'if(fogFade.y>0.&&shadowParams.w<=0.)c.a*=clamp((fogFade.y-z)/(fogFade.y-fogFade.x),0.,1.);' + '}' + // an opaque draw drops see through texels, as the shadow map does, at the end, after the normal map's // derivatives and the texture samples, which need the pixels around them, and stays opaque otherwise 'if(shadowParams.w>0.&&t.a<.5)discard;' + 'o=vec4(c.rgb,shadowParams.w>0.?1.:c.a);' + '}' + (fragmentCode ? '\n' + RENDER3D_SNIPPET_NAMES + fragmentCode + '\n' : ''); } // a Shader's 3D program, compiled the first time a draw needs it function render3DShaderProgram(shader) { ASSERT(shader instanceof Shader, 'render3D.shader must be a Shader, not the snippet itself'); shader.program3D || glShaderTrack(shader); // one let go of and drawn again is kept for a lost context again const program = shader.program3D ||= glCreateProgram(RENDER3D_VERTEX_SOURCE, render3DFragmentSource(shader.fragmentCode)); return glFailedPrograms.has(program) ? render3D.program : program; // one that did not build draws as with none } // make a program current for the pass and send it the pass uniforms: the matrices, the camera and the lights, // plus the time and canvas size for a Shader's program; the per draw uniform cache starts over function render3DUseProgram(program) { const gl = glContext, r = render3D; gl.useProgram(r.currentProgram = program); r.uniformValues = {}; gl.uniformMatrix4fv(render3DUniform('viewProj'), false, r.viewProjection.m); gl.uniformMatrix4fv(render3DUniform('lightViewProj'), false, r.shadowMatrix.m); gl.uniform1i(render3DUniform('tex'), 0); gl.uniform1i(render3DUniform('shadowMap'), 1); gl.uniform1i(render3DUniform('normalTex'), 2); gl.uniform1i(render3DUniform('emissiveTex'), 3); gl.uniform1i(render3DUniform('gelTex'), 4); gl.uniform1i(render3DUniform('envMap'), 5); gl.uniform4fv(render3DUniform('gelAxes'), r.gelAxes); const c = r.camera.pos, d = r.cameraForward; gl.uniform3f(render3DUniform('cameraPos'), c.x, c.y, c.z); gl.uniform3f(render3DUniform('cameraDir'), d.x, d.y, d.z); gl.uniform1i(render3DUniform('extraLightCount'), r.lightCount); if (r.lightCount) { gl.uniform4fv(render3DUniform('extraLights'), r.lightPositions, 0, r.lightCount * 4); gl.uniform4fv(render3DUniform('extraLightColors'), r.lightColors, 0, r.lightCount * 4); gl.uniform4fv(render3DUniform('extraLightCones'), r.lightCones, 0, r.lightCount * 4); } if (program !== r.program) { gl.uniform1f(render3DUniform('iTime'), time); const face = r.capturingCube?.size; // in a capture the face is the screen gl.uniform3f(render3DUniform('iResolution'), face || glCanvas.width, face || glCanvas.height, 1); } } function render3DInitGL() { if (headlessMode) return; if (!glEnable || !glContext) { // a device whose WebGL could not draw, which fell back to Canvas2D, is not helped by setting glEnable console.warn(!glDeviceFailed ? 'Render3DPlugin: WebGL not enabled, construct the plugin in gameInit with glEnable set' : 'Render3DPlugin: this device can not draw WebGL, so nothing 3D is drawn'); return; } const gl = glContext, r = render3D; glFlush(); // a pending 2D batch draws now, while the engine's own buffer, vertex array and program are bound r.uniforms = new WeakMap; r.uniformValues = {}; render3DShadowCut = undefined; // the shadow shader is new too r.attribValues = []; // a fresh context has its own attribute defaults, so nothing sent before it counts // the shader, see RENDER3D_VERTEX_SOURCE and render3DFragmentSource r.program = glCreateProgram(RENDER3D_VERTEX_SOURCE, render3DFragmentSource()); // the depth only shader for the shadow map, same vertex layout; see through pixels cast nothing, so sprites and // cut out textures cast their outline, and a blended object faded below half its alpha casts nothing, as it // draws; an opaque one draws solid whatever its tint alpha, and casts so r.shadowShader = glCreateProgram( '#version 300 es\n' + 'precision highp float;' + 'uniform mat4 viewProj;' + RENDER3D_VERTEX_INPUTS + 'out vec2 T;out float A;' + 'void main(){T=uvRect.xy+t*uvRect.zw;A=c.a*tint.a;gl_Position=viewProj*mat4(m0,m1,m2,m3)*vec4(p,1.);}' , '#version 300 es\n' + 'precision highp float;' + 'uniform sampler2D tex;' + 'uniform float cut;' + // 1 for a blended batch, cut by its tint and vertex alpha too, 0 by the texture only 'in vec2 T;in float A;' + 'void main(){if(texture(tex,T).a*mix(1.,A,cut)<.5)discard;}' ); // a program that did not build in a release build would fail every draw: 3D stays off, said once, since the pass // returns when there is no program, and a lost context that comes back tries again if (glFailedPrograms.has(r.program) || glFailedPrograms.has(r.shadowShader)) { console.error('Render3DPlugin: its shaders did not build on this device, so nothing 3D is drawn'); r.program = r.shadowShader = undefined; return; } // the vertex array object with the attributes enabled once, pointers are set per buffer by render3DBindVertexBuffer // the per instance attributes get their divisor only while a batch has them on, see render3DDrawInstanced r.vao = gl.createVertexArray(); gl.bindVertexArray(r.vao); for (const [location] of RENDER3D_ATTRIBS) gl.enableVertexAttribArray(location); // the stream buffer r.streamBuffer = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, r.streamBuffer); gl.bufferData(gl.ARRAY_BUFFER, r.streamData.byteLength, gl.DYNAMIC_DRAW); r.streamCount = 0; r.instanceBuffers = [gl.createBuffer(), gl.createBuffer(), gl.createBuffer()]; // white texture for untextured draws, and a one texel shadow map that keeps the shadow sampler valid until shadows // are on r.whiteTexture = glCreateTexture(); r.samplers = new Map; r.samplerKey = undefined; render3DUpdateShadowMap(1); // hand the engine back its own buffer, vertex array and program, the 2D batch was flushed before they changed gl.bindBuffer(gl.ARRAY_BUFFER, glArrayBuffer); glSetInstancedMode(true); } /////////////////////////////////////////////////////////////////////////////// /** * CubeMap - Six square images all around a point, the world far away: what reflective surfaces reflect as * render3D.environment, and the sky as render3D.skyBox * - makeCubeMap paints one from a function of direction, loadCubeMap loads six images * - Made with no faces it is drawn from the scene: capture(pos3D) draws everything around that point into it in the * next frame's 3D pass, six views of the whole scene, so capture once for a still scene or now and then for one * that moves * - Its faces are in WebGL's order and lay out, +x, -x, +y, -y, +z, -z: WebGL's cube is left handed, so in this right * handed world a face seen from the middle is mirrored; loadCubeMap turns a sky box set's images to it * @memberof Render3D * @example * render3D.environment = render3D.skyBox = makeCubeMap(64, (d)=> hsl(.6, .8, .4 + d.y * .4)); * @example * // a chrome ball that reflects the scene around it, captured once * const ball = new EngineObject3D(vec3(0, 1, 0), render3D.sphereMesh, undefined, BLACK); * ball.reflectivity = 1; * ball.shininess = 1e4; * render3D.environment = new CubeMap(128); * render3D.environment.capture(ball.pos3D); */ class CubeMap { /** Make a cube map from its six faces, as makeCubeMap and loadCubeMap do, or with none to capture the scene into * @param {number} size - Pixels a side of each face * @param {Array} [faces] - +x, -x, * +y, -y, +z and -z, RGBA pixels row by row or images; none for a cube map drawn by capture */ constructor(size, faces) { ASSERT(isNumber(size) && size >= 1 && size % 1 === 0, 'a cube map\'s size must be a whole positive number', size); ASSERT(faces === undefined || isArray(faces) && faces.length === 6, 'a cube map has six faces'); ASSERT(!faces || faces.every((face)=> face instanceof Uint8Array ? face.length === size * size * 4 : face.width === size && face.height === size), 'each face of a cube map is size by size, RGBA or an image'); /** @property {number} - Pixels a side of each face */ this.size = size; /** @property {Array|undefined} - * Its faces, kept to upload again after a lost context, undefined for one drawn by capture * @type {Array|undefined} */ this.faces = faces; /** @property {Vector3|undefined} - Where it was last captured from, and is captured from again after a lost * context * @type {Vector3|undefined} */ this.capturePos = undefined; /** @type {WebGLTexture|undefined} */ this.glTexture = undefined; // made by the first pass that uses it this.contextGeneration = -1; } /** Draw the scene around a point into a cube map made with no faces, in the next frame's 3D pass * - Every object of the layer render3D.renderAfter2D picks is drawn, with the sky and the render callbacks; the * camera's near and far planes, the lights and the fog are the scene's * - It is six more draws of the scene, so capture once for a still scene, or every few frames for a moving one * - A closed mesh around the point, like the ball that reflects it, is not seen from inside, so it hides nothing * - While it is drawn, what would reflect it reflects the sky's colors in its place * - It is seen from one point, so it suits what is near that point best, give each mirror its own with * obj.environment * @param {Vector3} pos3D - The point it is seen from */ capture(pos3D) { ASSERT(!this.faces, 'only a cube map made with no faces can capture the scene'); ASSERT(isVector3(pos3D), 'capture needs a Vector3'); this.capturePos = pos3D.copy(); render3D.cubeCaptures.add(this); } /** Free its texture, which is made again if it is used after */ dispose() { this.glTexture && glContext?.deleteTexture(this.glTexture); this.glTexture = undefined; render3D?.cubeCaptures.delete(this); } } /** Make a cube map by asking a function the color of each direction * - The function gets a unit Vector3 for each pixel of each face and gives a Color * - It runs size * size * 6 times, so a size of 64 or 128 makes one at once, larger takes a moment * @param {number} size - Pixels a side of each face * @param {function(Vector3): Color} colorOf - The color seen looking that way * @return {CubeMap} * @example * // a sky, blue above and pale at the horizon, with a sun * const sun = vec3(1, 1, -1).normalize(); * render3D.environment = makeCubeMap(64, (d)=> hsl(.6, .7, .9 - max(d.y, 0) * .5).lerp(WHITE, max(0, d.dot(sun)) ** 64)); * @memberof Render3D */ function makeCubeMap(size, colorOf) { ASSERT(isNumber(size) && size >= 1 && size % 1 === 0, 'makeCubeMap needs a whole size', size); ASSERT(typeof colorOf === 'function', 'makeCubeMap needs a function of direction'); const faces = []; for (let face = 0; face < 6; ++face) { const data = new Uint8Array(size * size * 4); for (let y = 0; y < size; ++y) for (let x = 0; x < size; ++x) { const color = colorOf(render3DCubeDirection(face, (x + .5) / size * 2 - 1, (y + .5) / size * 2 - 1)); const i = (x + y * size) * 4; data[i] = clamp(color.r) * 255 + .5 | 0; data[i+1] = clamp(color.g) * 255 + .5 | 0; data[i+2] = clamp(color.b) * 255 + .5 | 0; data[i+3] = clamp(color.a) * 255 + .5 | 0; } faces.push(data); } return new CubeMap(size, faces); } /** Load a cube map from six square images of one size * - The images are a sky box set as three.js's CubeTextureLoader takes them, and show the same: each face seen from * inside as its image is drawn * @param {Array} sources - The images of +x, -x, +y, -y, +z and -z, as a sky box set names them right, * left, top, bottom, front and back * @return {Promise} * @memberof Render3D */ async function loadCubeMap(sources) { ASSERT(isArray(sources) && sources.length === 6, 'loadCubeMap takes six images, +x, -x, +y, -y, +z and -z'); const images = await Promise.all(sources.map((src)=> new Promise((resolve, reject)=> { const image = new Image; image.crossOrigin = 'anonymous'; image.onload = ()=> resolve(image); image.onerror = ()=> reject(new Error('loadCubeMap could not load ' + src)); image.src = src; }))); const size = images[0].width; if (!images.every((image)=> image.width === size && image.height === size)) throw new Error('loadCubeMap needs six square images of one size'); // a sky box set is drawn for WebGL's cube, which is left handed: in this right handed world each face would show // mirrored, so as three.js does each is drawn mirrored, and +x and -x trade places, and every face is then seen // from inside as its image shows const faces = [1, 0, 2, 3, 4, 5].map((i)=> { const context = createCanvasContext(size); context.scale(-1, 1); context.drawImage(images[i], -size, 0); return context.canvas; }); return new CubeMap(size, faces); } // the direction of a point on a cube map's face, s and t from -1 to 1 across and down the face as WebGL lays it out function render3DCubeDirection(face, s, t) { const d = face === 0 ? vec3(1, -t, -s) : face === 1 ? vec3(-1, -t, s) : face === 2 ? vec3(s, 1, t) : face === 3 ? vec3(s, -1, -t) : face === 4 ? vec3(s, -t, 1) : vec3(-s, -t, -1); return d.normalize(); } // a cube map's texture, uploaded with its mipmaps the first time and again after a lost context, bound to the // cube map target of the active unit; undefined with no cube map or no WebGL function render3DCubeTexture(cube) { const gl = glContext; if (!cube || !gl) return; if (cube.glTexture && cube.contextGeneration === render3D.contextGeneration) return cube.glTexture; const texture = cube.glTexture = gl.createTexture(); cube.contextGeneration = render3D.contextGeneration; gl.bindTexture(gl.TEXTURE_CUBE_MAP, texture); gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false); // a face's first row is its top, as the cube's faces are laid out if (!cube.faces) { // drawn by a capture: empty faces, black until the capture, which happens again when the context was lost; // a size the device can not make falls back to the largest it can, as the shadow map's does cube.size = min(cube.size, gl.getParameter(gl.MAX_CUBE_MAP_TEXTURE_SIZE), gl.getParameter(gl.MAX_RENDERBUFFER_SIZE)); for (let i = 0; i < 6; ++i) gl.texImage2D(gl.TEXTURE_CUBE_MAP_POSITIVE_X + i, 0, gl.RGBA, cube.size, cube.size, 0, gl.RGBA, gl.UNSIGNED_BYTE, null); cube.capturePos && render3D.cubeCaptures.add(cube); } else cube.faces.forEach((face, i)=> face instanceof Uint8Array ? gl.texImage2D(gl.TEXTURE_CUBE_MAP_POSITIVE_X + i, 0, gl.RGBA, cube.size, cube.size, 0, gl.RGBA, gl.UNSIGNED_BYTE, face) : gl.texImage2D(gl.TEXTURE_CUBE_MAP_POSITIVE_X + i, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, face)); gl.texParameteri(gl.TEXTURE_CUBE_MAP, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR); gl.texParameteri(gl.TEXTURE_CUBE_MAP, gl.TEXTURE_MAG_FILTER, gl.LINEAR); gl.texParameteri(gl.TEXTURE_CUBE_MAP, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); gl.texParameteri(gl.TEXTURE_CUBE_MAP, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); gl.generateMipmap(gl.TEXTURE_CUBE_MAP); // the blur of a rough surface's reflection return texture; } // the transform of a camera at pos looking out through a cube map face, 90 degrees across: right, up and forward are // the face's directions toward its right edge, toward its last row and through its middle, from the same table // render3DCubeDirection reads, so a captured face lays out as an uploaded one; up is down the face, as a // framebuffer's rows go up function render3DCubeFaceMatrix(face, pos) { const [right, up, forward] = [ [vec3(0, 0, -1), vec3(0, -1, 0), vec3(1, 0, 0)], [vec3(0, 0, 1), vec3(0, -1, 0), vec3(-1, 0, 0)], [vec3(1, 0, 0), vec3(0, 0, 1), vec3(0, 1, 0)], [vec3(1, 0, 0), vec3(0, 0, -1), vec3(0, -1, 0)], [vec3(1, 0, 0), vec3(0, -1, 0), vec3(0, 0, 1)], [vec3(-1, 0, 0), vec3(0, -1, 0), vec3(0, 0, -1)]][face]; return new Matrix4([right.x, right.y, right.z, 0, up.x, up.y, up.z, 0, -forward.x, -forward.y, -forward.z, 0, pos.x, pos.y, pos.z, 1]); } // draw the cube maps asked for by capture, each face a view of the layer's objects from its point, with the sky and // the render callbacks, into a framebuffer of its own; the camera, the matrices and the frame's viewport are put // back after, and the environment unit is bound again, since a texture can not be read while it is drawn function render3DCaptureCubes(objects) { const gl = glContext, r = render3D, camera = r.camera; const captures = [...r.cubeCaptures]; r.cubeCaptures.clear(); if (!r.captureFramebuffer) { r.captureFramebuffer = gl.createFramebuffer(); r.captureDepth = gl.createRenderbuffer(); r.captureDepthSize = 0; } // a camera looking out through each face in turn, its matrix the face's const view = new Camera3D; view.fov = PI / 2; view.near = camera.orthographic || !(camera.near > 0) ? .1 : camera.near; // an orthographic camera's may be behind it view.far = camera.far; let faceMatrix = new Matrix4; view.getMatrix = ()=> faceMatrix.copy(); gl.bindFramebuffer(gl.FRAMEBUFFER, r.captureFramebuffer); try { for (const cube of captures) { const texture = render3DCubeTexture(cube), size = cube.size; r.cubeCaptures.delete(cube); // a texture made just now asks again, it is drawn here if (r.captureDepthSize !== size) { gl.bindRenderbuffer(gl.RENDERBUFFER, r.captureDepth); gl.renderbufferStorage(gl.RENDERBUFFER, gl.DEPTH_COMPONENT24, size, size); gl.framebufferRenderbuffer(gl.FRAMEBUFFER, gl.DEPTH_ATTACHMENT, gl.RENDERBUFFER, r.captureDepth); r.captureDepthSize = size; } // nothing reads the texture while it is drawn: the environment unit lets it go and the shader reflects // the sky's colors in its place, and it is not drawn as the sky box r.capturingCube = cube; gl.activeTexture(gl.TEXTURE0 + 5); gl.bindTexture(gl.TEXTURE_CUBE_MAP, null); gl.activeTexture(gl.TEXTURE0); render3DBoundEnvironment = null; gl.viewport(0, 0, size, size); view.pos = cube.capturePos; r.camera = view; for (let face = 0; face < 6; ++face) { gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_CUBE_MAP_POSITIVE_X + face, texture, 0); ASSERT(gl.checkFramebufferStatus(gl.FRAMEBUFFER) == gl.FRAMEBUFFER_COMPLETE, 'cube map capture framebuffer is incomplete'); faceMatrix = render3DCubeFaceMatrix(face, cube.capturePos); r.updateMatrices(1); if (face < 2 || face > 3) { // a side face's up is world down, as the cube lays its rows out, so sprites and particles, built // on the camera's right and up, would stand on their heads: half a turn about forward puts them // on world up, and leaves forward and the sort the same r.cameraRight = r.cameraRight.scale(-1); r.cameraUp = r.cameraUp.scale(-1); } const c = canvasClearColor; gl.clearColor(c.r, c.g, c.b, 1); gl.depthMask(true); gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT); render3DUseProgram(r.program); r.renderStages(objects, true); } gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, null, 0); gl.bindTexture(gl.TEXTURE_CUBE_MAP, texture); gl.generateMipmap(gl.TEXTURE_CUBE_MAP); // the blur of a rough surface's reflection gl.bindTexture(gl.TEXTURE_CUBE_MAP, null); } } finally { r.capturingCube = undefined; r.camera = camera; r.updateMatrices(); gl.bindFramebuffer(gl.FRAMEBUFFER, null); gl.viewport(0, 0, glCanvas.width, glCanvas.height); gl.activeTexture(gl.TEXTURE0 + 5); gl.bindTexture(gl.TEXTURE_CUBE_MAP, render3DCubeTexture(r.environment) || null); gl.activeTexture(gl.TEXTURE0); render3DBoundEnvironment = r.environment || null; } } // draw a cube map as the sky: one triangle over the whole screen, each pixel the cube's color the way it looks, // with its own program and an empty vertex array, the pass's state put back after; false when the program did not // build, in a release build, so the dome draws in its place function render3DDrawSkyBox(cube) { const gl = glContext, r = render3D; r.skyBoxProgram ||= glCreateProgram( '#version 300 es\nprecision highp float;out vec2 v;' + 'void main(){v=vec2(gl_VertexID&1,gl_VertexID>>1)*4.-1.;gl_Position=vec4(v,0,1);}', '#version 300 es\nprecision highp float;uniform mat4 inverseViewProj;uniform samplerCube sky;in vec2 v;' + 'out vec4 o;void main(){vec4 a=inverseViewProj*vec4(v,-1,1),b=inverseViewProj*vec4(v,0,1);' + 'o=vec4(texture(sky,b.xyz/b.w-a.xyz/a.w).rgb,1);}'); // the way through this pixel, near plane to beyond if (glFailedPrograms.has(r.skyBoxProgram)) return false; r.skyBoxVao ||= gl.createVertexArray(); const depthTest = gl.isEnabled(gl.DEPTH_TEST), blend = gl.isEnabled(gl.BLEND), cull = gl.isEnabled(gl.CULL_FACE); const depthWrite = gl.getParameter(gl.DEPTH_WRITEMASK); gl.useProgram(r.skyBoxProgram); r.currentProgram = undefined; // the next draw picks its program again gl.activeTexture(gl.TEXTURE6); gl.bindTexture(gl.TEXTURE_CUBE_MAP, render3DCubeTexture(cube)); gl.bindSampler(6, null); gl.uniform1i(glUniformLocation(r.skyBoxProgram, 'sky'), 6); // the view turned but not moved: the way through a pixel does not depend on where the camera is, and two points // near the origin keep their difference, where points a long way out lose it to float precision const turn = r.viewMatrix.copy(); turn.m[12] = turn.m[13] = turn.m[14] = 0; gl.uniformMatrix4fv(glUniformLocation(r.skyBoxProgram, 'inverseViewProj'), false, r.projectionMatrix.copy().multiply(turn).invert().m); gl.disable(gl.DEPTH_TEST); gl.disable(gl.BLEND); gl.disable(gl.CULL_FACE); gl.depthMask(false); gl.bindVertexArray(r.skyBoxVao); gl.drawArrays(gl.TRIANGLES, 0, 3); gl.bindVertexArray(r.vao); gl.bindTexture(gl.TEXTURE_CUBE_MAP, null); gl.activeTexture(gl.TEXTURE0); depthTest && gl.enable(gl.DEPTH_TEST); blend && gl.enable(gl.BLEND); cull && gl.enable(gl.CULL_FACE); gl.depthMask(depthWrite); return true; } function render3DContextLost() { const r = render3D; r.program = r.currentProgram = r.shadowShader = r.vao = r.streamBuffer = r.whiteTexture = undefined; r.skyBoxProgram = r.skyBoxVao = r.captureFramebuffer = r.captureDepth = undefined; r.captureDepthSize = 0; for (const shader of glShaderObjects) shader.program3D = undefined; // compiled again by the next draw r.lightCount = 0; r.instanceBuffers = []; r.samplers = new Map; r.samplerKey = undefined; render3DClearInstances(); r.shadowFramebuffer = r.shadowTexture = undefined; r.shadowTextureSize = 0; r.cameraDepthFramebuffer = r.cameraDepthTexture = undefined; r.cameraDepthWidth = r.cameraDepthHeight = 0; r.streamCount = 0; ++r.contextGeneration; // every uploaded mesh is stale now, the soft dot is a TextureInfo the engine restores } function render3DContextRestored() { render3DInitGL(); } // a uniform location, looked up once per program function render3DUniform(name, program=render3D.currentProgram) { const u = render3D.uniforms; let cache = u.get(program); cache || u.set(program, cache = {}); return cache[name] ??= glContext.getUniformLocation(program, name); } // the model matrix, the tint and the uv rect as constant attributes for one draw function render3DDrawAttribs(m, tint, uvRect) { const gl = glContext; gl.vertexAttrib4f(4, m[0], m[1], m[2], m[3]); gl.vertexAttrib4f(5, m[4], m[5], m[6], m[7]); gl.vertexAttrib4f(6, m[8], m[9], m[10], m[11]); gl.vertexAttrib4f(7, m[12], m[13], m[14], m[15]); render3DAttrib4f(11, tint.r, tint.g, tint.b, tint.a); render3DAttrib4f(12, uvRect.x, uvRect.y, uvRect.w, uvRect.h); } // set a constant vec4 attribute only when its value changed since the last time function render3DAttrib4f(location, x, y, z, w) { const values = render3D.attribValues, last = values[location] ||= [NaN, NaN, NaN, NaN]; // kept, not made per draw if (last[0] === x && last[1] === y && last[2] === z && last[3] === w) return; last[0] = x, last[1] = y, last[2] = z, last[3] = w; glContext.vertexAttrib4f(location, x, y, z, w); } // textures in 3D shrink into the distance far more than sprites do, so the pass samples them through their mipmaps; // a sampler sets the filtering for the 3D pass only and leaves the engine's textures as they are for 2D; they are // made as textures need them, and all dropped when the settings change function render3DUpdateSamplers() { const r = render3D, key = tilesPixelated + ' ' + r.anisotropy; if (r.samplerKey === key) return; r.samplerKey = key; for (const sampler of r.samplers.values()) glContext.deleteSampler(sampler); // the set being replaced, a lost context empties this first r.samplers.clear(); } // the sampler for a texture's wrap modes, smooth or hard edged, made the first time it is needed; pixelated is the // draw's, texturePixelated the texture's own, which a smooth texture in a pixel art game sets false function render3DSampler(wrap, pixelated, texturePixelated=tilesPixelated) { const gl = glContext, r = render3D, [wrapS, wrapT] = glWrapModes(wrap); const key = wrapS * 1e5 + wrapT * 4 + (pixelated ? 1 : 0) + (texturePixelated ? 2 : 0); // the modes are 5 digit numbers let sampler = r.samplers.get(key); if (sampler) return sampler; sampler = gl.createSampler(); const sharp = pixelated || texturePixelated; gl.samplerParameteri(sampler, gl.TEXTURE_MAG_FILTER, sharp ? gl.NEAREST : gl.LINEAR); gl.samplerParameteri(sampler, gl.TEXTURE_MIN_FILTER, pixelated ? gl.NEAREST : texturePixelated ? gl.NEAREST_MIPMAP_LINEAR : gl.LINEAR_MIPMAP_LINEAR); gl.samplerParameteri(sampler, gl.TEXTURE_WRAP_S, wrapS); gl.samplerParameteri(sampler, gl.TEXTURE_WRAP_T, wrapT); const anisotropy = gl.getExtension('EXT_texture_filter_anisotropic'); if (anisotropy && !pixelated) { const most = gl.getParameter(anisotropy.MAX_TEXTURE_MAX_ANISOTROPY_EXT); gl.samplerParameterf(sampler, anisotropy.TEXTURE_MAX_ANISOTROPY_EXT, clamp(r.anisotropy, 1, most)); } r.samplers.set(key, sampler); return sampler; } // bind the texture of a tile or texture, white when there is none or it is not loaded, with the 3D sampler that // matches its wrap mode; the first time a texture is used in 3D it gets its mipmaps; on unit 0, the color texture, // or another unit, the material maps, after which unit 0 is active again function render3DBindTexture(tileInfo, state=render3D, unit=0) { const gl = glContext, r = render3D; const textureInfo = render3DTextureOf(tileInfo); const texture = textureInfo?.glTexture || r.whiteTexture; unit && gl.activeTexture(gl.TEXTURE0 + unit); gl.bindTexture(gl.TEXTURE_2D, texture); if (texture === r.whiteTexture || !r.mipmaps && !state.pixelated) gl.bindSampler(unit, null); // the texture's own filtering, as in 2D; the white texel needs no mipmaps // or anisotropy, and filtering it that way costs every untextured fragment else { gl.bindSampler(unit, render3DSampler(textureInfo?.wrap, state.pixelated, textureInfo?.pixelated ?? tilesPixelated)); glUpdateMipmaps(texture); // drawn into since its mipmaps were made if (!state.pixelated && !glMipmappedTextures.has(texture)) // a hard edged draw never reads them { glMipmappedTextures.add(texture); // the core makes them again when the texture changes gl.generateMipmap(gl.TEXTURE_2D); } } unit && gl.activeTexture(gl.TEXTURE0); } // the material's uniforms and maps for a draw: the normal map on unit 2 and the emissive map on unit 3, white when // unused and bound only when they change, and the sky a reflection shows, the colors setSky was given or the // ambient ones; with nothing set every part of it is skipped in the shader, and a map with no GL texture yet, or // one freed, counts as none, since the white texture in its place would bend every normal or light the surface function render3DSetMaterialUniforms(state) { const r = render3D, loaded = (map)=> render3DTextureOf(map)?.glTexture ? map : undefined; const normalMap = state.normalScale ? loaded(state.normalMap) : undefined, emissiveMap = loaded(state.emissiveMap); render3DUniform4f('materialParams', normalMap ? state.normalScale : 0, max(state.shininess, 1e-3), state.reflectivity, 0); const ec = state.emissiveMapColor || WHITE; emissiveMap ? render3DUniform4f('emissiveTint', ec.r, ec.g, ec.b, 1) : render3DUniform4f('emissiveTint', 0, 0, 0, 0); render3DBindMap(2, normalMap, state); render3DBindMap(3, emissiveMap, state); if (state.reflectivity > 0) { // the draw's own environment or the scene's, never the cube map a capture is drawing let environment = state.environmentMap || r.environment; environment === r.capturingCube && (environment = undefined); render3DBindEnvironment(environment); // a rough reflection is blurred over half of sqrt(2 / (shininess + 2)), the spread Blinn-Phong and Beckmann // give a shininess, narrower than the Phong highlight the shader draws, and a texel of mipmap // L spans (PI/2) / size * 2^L, so the shader reads level log2(size * .45) - log2(shininess + 2) // / 2: the same blur for any size of map; it stops at 4 by 4 a face, where sampling across the edges still // blends neighboring faces const size = environment ? environment.size : 1; // a turn of one radian a pixel covers size / (PI/2) texels of a face, log2 of that the level that blurs it render3DUniform4f('envParams', environment ? 1 : 0, max(0, log2(size) - 2), log2(size * .45), log2(size * 2 / PI)); const sky = r.sky && render3DSkyColors.get(r.sky), a = r.ambientColor, g = r.ambientGroundColor || a; const top = sky ? sky[0] : a, bottom = sky ? sky[2] : g; render3DUniform4f('skyTop', top.r, top.g, top.b, 1); render3DUniform4f('skyBottom', bottom.r, bottom.g, bottom.b, 1); sky ? render3DUniform4f('skyHorizon', sky[1].r, sky[1].g, sky[1].b, 1) : render3DUniform4f('skyHorizon', (a.r + g.r) / 2, (a.g + g.g) / 2, (a.b + g.b) / 2, 1); } } // the cube map bound to the environment unit, 5, so a draw binds only when it changes; null for none, undefined when // not known, forgotten at the start and end of each pass and around a capture let render3DBoundEnvironment; // bind a draw's environment to unit 5 when it is not bound already; none leaves the unit as it is, its reflection // reads the sky's colors function render3DBindEnvironment(cube) { const gl = glContext; if (!cube || cube === render3DBoundEnvironment || !gl) return; gl.activeTexture(gl.TEXTURE0 + 5); gl.bindTexture(gl.TEXTURE_CUBE_MAP, render3DCubeTexture(cube) || null); gl.activeTexture(gl.TEXTURE0); render3DBoundEnvironment = cube; } // what each material map unit has bound, index 2 and 3 the maps and 4 and 5 whether each is pixelated, forgotten at // the start and end of each pass let render3DBoundMaps = []; // bind a material map to its unit, or white for none, only when it or its filtering changed since the last draw function render3DBindMap(unit, map, state) { const bound = render3DBoundMaps, pixelated = !!map && state.pixelated; if (bound[unit] === map && bound[unit + 2] === pixelated) return; bound[unit] = map, bound[unit + 2] = pixelated; render3DBindTexture(map, state, unit); } // send a vec4 uniform of the main shader only when its value changed since the last send function render3DUniform4f(name, x, y, z, w) { const values = render3D.uniformValues, last = values[name] ||= [NaN, NaN, NaN, NaN]; // kept, not made per draw if (last[0] === x && last[1] === y && last[2] === z && last[3] === w) return; last[0] = x, last[1] = y, last[2] = z, last[3] = w; glContext.uniform4f(render3DUniform(name), x, y, z, w); } // send an int uniform of the main shader only when its value changed since the last send function render3DUniform1i(name, x) { const values = render3D.uniformValues; if (values[name] === x) return; values[name] = x; glContext.uniform1i(render3DUniform(name), x); } // bind a vertex buffer and point the attributes at it function render3DBindVertexBuffer(buffer) { const gl = glContext; gl.bindBuffer(gl.ARRAY_BUFFER, buffer); for (const a of RENDER3D_ATTRIBS) gl.vertexAttribPointer(a[0], a[1], a[2], a[3], RENDER3D_VERTEX_BYTES, a[4]); } // a mesh's vertices and its triangle indices, ready for drawElements function render3DBindMesh(mesh) { render3DBindVertexBuffer(mesh.buffer); glContext.bindBuffer(glContext.ELEMENT_ARRAY_BUFFER, mesh.indexBuffer); } // the texture a draw samples: a tile's texture, a TextureInfo as it is, or undefined for white function render3DTextureOf(tileInfo) { return tileInfo instanceof TileInfo ? tileInfo.textureInfo : tileInfo; } // where a tile sits in its texture, pulled in slightly at the edges so neighbors do not bleed in // the level's solid geometry in 3D, the height maps and voxel maps that objects with collideLevel collide with, as // tileCollisionLayers is in 2D; each joins when made and leaves when destroyed const render3DLevel = []; // take one of the level's parts out of it, when it is destroyed function render3DLevelLeave(o) { const i = render3DLevel.indexOf(o); i >= 0 && render3DLevel.splice(i, 1); } // the level's parts are looked up in world space, so they stay upright and unscaled at the root function render3DLevelAssertPlaced(o) { ASSERT(!o.parent && !o.rotation3D.lengthSquared() && o.scale3D.x === 1 && o.scale3D.y === 1 && o.scale3D.z === 1, 'a height map or voxel map stays upright and unscaled at the root, its lookups are in world space'); } // this returns one shared object, so read it before calling again const render3DTileUVRect = {x:0, y:0, w:1, h:1}; // the tiles of objects made from a whole TextureInfo, they cover all of it even after the texture is resized const render3DWholeTiles = new WeakSet; function render3DGetTileUVs(tileInfo) { // a headless tile has no texture, and a whole texture's tile covers it at any size if (!(tileInfo instanceof TileInfo) || !tileInfo.textureInfo || render3DWholeTiles.has(tileInfo)) return RENDER3D_FULL_UV_RECT; const inv = tileInfo.textureInfo.sizeInverse, rect = render3DTileUVRect; const bleedX = inv.x * tileInfo.bleed, bleedY = inv.y * tileInfo.bleed; rect.x = tileInfo.pos.x * inv.x + bleedX; rect.y = tileInfo.pos.y * inv.y + bleedY; rect.w = tileInfo.size.x * inv.x - 2*bleedX; rect.h = tileInfo.size.y * inv.y - 2*bleedY; return rect; } // set the per draw uniforms and gl state for a draw, in the shadow pass only the per draw attributes, the texture // and the depth shader's cut // tileInfo may be a TileInfo, a TextureInfo, or undefined for the white texture // state is the plugin's current fields, or the captured state of a stream batch function render3DSetDrawUniforms(matrix, tileInfo, tint, uvRect, state=render3D) { const gl = glContext, r = render3D; // the per draw values are constant vertex attributes, a batch turns on a per instance array over them uvRect ||= render3DGetTileUVs(tileInfo); render3DDrawAttribs(matrix.m, tint, uvRect); render3DBindTexture(tileInfo, state); if (r.shadowPass) { // the shadow map needs only how to cut: by the alpha it blends with, or the texture's as an opaque draw const cut = state.blend ? 1 : 0; if (render3DShadowCut !== cut) glContext.uniform1f(render3DUniform('cut', r.shadowShader), render3DShadowCut = cut); return; } // the program: a Shader's own, compiled by its first draw, or the plugin's; switching sends the pass uniforms const program = state.shader ? render3DShaderProgram(state.shader) : r.program; program === r.currentProgram || render3DUseProgram(program); // blending, matches the engine's 2D blend functions if (state.blend) { gl.enable(gl.BLEND); const destBlend = state.additive ? gl.ONE : gl.ONE_MINUS_SRC_ALPHA; gl.blendFuncSeparate(gl.SRC_ALPHA, destBlend, gl.ONE, destBlend); } else gl.disable(gl.BLEND); // depth and culling state.depthTest ? gl.enable(gl.DEPTH_TEST) : gl.disable(gl.DEPTH_TEST); gl.depthMask(state.depthWrite); state.cullBackFaces ? gl.enable(gl.CULL_FACE) : gl.disable(gl.CULL_FACE); gl.frontFace(state.mirrored ? gl.CCW : gl.CW); // the pass's strips read clockwise, a mirror turns that around // lights, fog and shadows are scene state read at draw time, sent only when they change // the shader takes the way the sunlight travels, away from the sun const s = r.sunDirection, sl = -(s.length() || 1), lc = r.sunColor, ac = r.ambientColor, fc = r.fogColor || canvasClearColor; render3DUniform4f('lightDir', s.x / sl, s.y / sl, s.z / sl, state.lighting ? state.emissive : 1); render3DUniform4f('lightColor', lc.r, lc.g, lc.b, state.specular); render3DUniform4f('ambientFog', ac.r, ac.g, ac.b, r.fogEnd); const gc = r.ambientGroundColor; // its alpha says whether the ground color is on gc ? render3DUniform4f('ambientGround', gc.r, gc.g, gc.b, 1) : render3DUniform4f('ambientGround', 0, 0, 0, 0); render3DUniform4f('fogColor', fc.r, fc.g, fc.b, r.fogEnd ? min(r.fogStart, r.fogEnd - 1e-3) : r.fogStart); ASSERT(render3DFogFadeValid(r.fogFadeStart, r.fogFadeEnd), 'fogFadeStart must be less than fogFadeEnd', r.fogFadeStart, r.fogFadeEnd); render3DUniform4f('fogFade', r.fogFadeStart, r.fogFadeEnd, r.fogDepth ? 1 : 0, 0); // how the fragment shader finishes: 1 drops see through texels and keeps the draw opaque, // 0 blends them away instead, and -1 is additive, which has to fade into fog differently const blendMode = state.blend ? (state.additive ? -1 : 0) : 1; const receives = r.shadows && r.passIsDefault && state.receiveShadow ? 1 : 0; // 1 the sun's shadow, 2 and up the shadow of that Light3D, the spotlight that casts them const shadowOf = receives && r.shadowLightIndex >= 0 ? 2 + r.shadowLightIndex : receives; render3DUniform4f('shadowParams', shadowOf, r.shadowDepthBias, r.shadowSoftness / r.shadowTextureSize, blendMode); // a render target's texture holds premultiplied color, the blend writes it that way, so the shader undoes it const textureInfo = render3DTextureOf(tileInfo); render3DUniform1i('premultipliedTexture', +!!(textureInfo?.glTexture && glPremultipliedTextures.has(textureInfo.glTexture))); render3DSetMaterialUniforms(state); } // the six flat sides of the camera's visible box, each as [x, y, z, w] facing inward // a point is inside when x*px + y*py + z*pz + w is zero or more function render3DFrustumPlanes(matrix) { const m = matrix.m, planes = []; for (let i = 0; i < 3; ++i) for (const sign of [1, -1]) { const p = [m[3] + sign * m[i], m[7] + sign * m[4+i], m[11] + sign * m[8+i], m[15] + sign * m[12+i]]; const l = hypot(p[0], p[1], p[2]) || 1; planes.push(p.map(v=> v / l)); } return planes; } // the preRender hook, before gameRender: the layer under the 2D scene function render3DPreRender() { const r = render3D; editor3DCameraBegin(); // debug builds draw with the free camera or the 3D editor's, the game's is back after r.updateMatrices(); r.shadowMapDrawn = false; render3DRenderPass(false); } // the render hook, after gameRenderPost: the layer on top of the 2D scene function render3DRender() { render3DRenderPass(true); editor3DCameraEnd(); } // one 3D pass for the objects of a layer: take over the gl state, draw the shadow map once a frame and the stages, hand // the state back // the layer matching render3D.renderAfter2D is the default and always runs, the other only when an object asks for it function render3DRenderPass(after2D) { const gl = glContext, r = render3D; if (!r.program || !glEnable) return; // headless, gl disabled, or context lost render3DUpdateSamplers(); ASSERT(!r.fogEnd || r.fogStart < r.fogEnd, 'fogStart must be less than fogEnd'); ASSERT(!glRenderTarget, 'the 3D pass needs the canvas depth buffer, it can not draw into a render target'); const isDefault = after2D === !!r.renderAfter2D, objects = render3DLayerObjects(after2D); if (!isDefault && !objects.length) return; r.passIsDefault = isDefault; // the 2D sprites drawn so far go under this layer, and a batch a plugin left pending before the layer under // the 2D scene draws now, with the engine's own gl state, not after the pass with its own glFlush(); // a previous frame that threw must not leave anything pending r.streamCount = 0; r.capture = r.transparentQueue = undefined; render3DClearInstances(); // the Light3D objects, a directional one sends the direction toward it, from the origin, and a negative radius // gathered before the gl state is taken over, so an assert here leaves nothing to hand back const lights = render3DCollectLights(); r.lightCount = lights.length; const caster = render3DShadowCaster(); r.shadowLightIndex = caster ? lights.indexOf(caster) : -1; const positions = r.lightPositions, colors = r.lightColors; lights.forEach((light, i)=> { const p = light.directional ? light.getWorldPos3D().normalize() : light.getWorldPos3D(); ASSERT(!light.directional || p.lengthSquared(), 'a directional light shines from its position toward the origin, so it cannot sit on the origin'); const c = light.color, k = i * 4; positions[k] = p.x, positions[k+1] = p.y, positions[k+2] = p.z; positions[k+3] = light.directional ? -1 : max(0, light.radius); // a negative radius marks a direction colors[k] = c.r, colors[k+1] = c.g, colors[k+2] = c.b, colors[k+3] = c.a * light.intensity; r.lightCones.set(render3DLightCone(light), k); }); // take over the gl state gl.bindVertexArray(r.vao); // the leading repeat on every strip shifts the triangles by one, which flips // which way they read, so tell WebGL that clockwise is the front here gl.frontFace(gl.CW); // every program's shadow sampler is on unit 1, where a 2D plugin like PostProcessPlugin or LightSystemPlugin // may have left its own texture, which fails every draw; the shadow map goes back there even with shadows off gl.activeTexture(gl.TEXTURE1); gl.bindTexture(gl.TEXTURE_2D, r.shadowTexture || null); // the material maps' units start white, a 2D plugin may have left its own textures there, and the shadow // light's gel goes on its unit for the pass, white when it has none render3DSetMapUnits(r.whiteTexture); const gel = render3DTextureOf(render3DShadowCaster()?.gel)?.glTexture; if (gel) { gl.activeTexture(gl.TEXTURE4); gl.bindTexture(gl.TEXTURE_2D, gel); gl.activeTexture(gl.TEXTURE0); } gl.depthMask(true); gl.clear(gl.DEPTH_BUFFER_BIT); r.isRendering = true; try { // the shadow map from the light once a frame, then the stages sample it if (r.shadows && !r.shadowMapDrawn) { render3DRenderShadowMap(); r.shadowMapDrawn = true; } // the camera's depth for post processing, from the default layer r.depthTexture && isDefault && render3DRenderDepth(); // the cube maps asked for by capture, drawn with this frame's shadow map isDefault && r.cubeCaptures.size && render3DCaptureCubes(objects); render3DUseProgram(r.program); // after the shadow map, so the light matrix it sends is this frame's r.renderStages(objects, isDefault); } finally { // hand the state back to the engine's 2D batching, even when a draw threw r.isRendering = false; r.currentProgram = undefined; // the engine's 2D program takes over below r.streamCount = 0; r.capture = r.transparentQueue = undefined; gl.disable(gl.DEPTH_TEST); gl.disable(gl.CULL_FACE); gl.depthMask(true); gl.frontFace(gl.CCW); gl.bindSampler(0, null); // back to the textures' own filtering for 2D render3DSetMapUnits(null); // the material maps leave no texture or sampler behind for 2D if (glActiveTexture) gl.bindTexture(gl.TEXTURE_2D, glActiveTexture); // ARRAY_BUFFER is not part of VAO state in WebGL2, so bindVertexArray alone would not restore it gl.bindBuffer(gl.ARRAY_BUFFER, glArrayBuffer); glSetInstancedMode(true); } } // put a texture on the material map units and the gel's with no sampler, white at the start of the pass and none at // its end, and forget what the map cache thought was bound; unit 0 is active after function render3DSetMapUnits(texture) { const gl = glContext; for (const unit of [2, 3, 4]) { gl.activeTexture(gl.TEXTURE0 + unit); gl.bindTexture(gl.TEXTURE_2D, texture); gl.bindSampler(unit, null); } // the environment cube on unit 5 for the pass, emptied at its end with the others gl.activeTexture(gl.TEXTURE0 + 5); gl.bindTexture(gl.TEXTURE_CUBE_MAP, texture && render3DCubeTexture(render3D.environment) || null); gl.bindSampler(5, null); render3DBoundEnvironment = texture && render3D.environment || null; gl.activeTexture(gl.TEXTURE0); render3DBoundMaps = []; } // create the shadow map depth texture and framebuffer at a size, or keep them when the size matches function render3DUpdateShadowMap(size) { const gl = glContext, r = render3D; ASSERT(size > 0, 'shadowMapSize must be positive'); size = glClampTextureSize(size); // a size the device does not have falls back to the largest it has if (r.shadowTexture && r.shadowTextureSize === size) return; r.shadowTexture && gl.deleteTexture(r.shadowTexture); r.shadowFramebuffer && gl.deleteFramebuffer(r.shadowFramebuffer); const texture = r.shadowTexture = gl.createTexture(); gl.activeTexture(gl.TEXTURE1); gl.bindTexture(gl.TEXTURE_2D, texture); gl.texImage2D(gl.TEXTURE_2D, 0, gl.DEPTH_COMPONENT24, size, size, 0, gl.DEPTH_COMPONENT, gl.UNSIGNED_INT, null); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); // smooth filtering softens shadow edges for free gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_COMPARE_MODE, gl.COMPARE_REF_TO_TEXTURE); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_COMPARE_FUNC, gl.LEQUAL); gl.activeTexture(gl.TEXTURE0); const framebuffer = r.shadowFramebuffer = gl.createFramebuffer(); gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer); gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.DEPTH_ATTACHMENT, gl.TEXTURE_2D, texture, 0); gl.drawBuffers([gl.NONE]); // depth only gl.readBuffer(gl.NONE); ASSERT(gl.checkFramebufferStatus(gl.FRAMEBUFFER) == gl.FRAMEBUFFER_COMPLETE, 'shadow map framebuffer is incomplete, try a smaller shadowMapSize'); gl.bindFramebuffer(gl.FRAMEBUFFER, null); r.shadowTextureSize = size; } // draw the lit opaque casters from the light into the shadow map with the depth only shader function render3DRenderShadowMap() { const gl = glContext, r = render3D; render3DUpdateShadowMap(r.shadowMapSize | 0); r.updateShadowMatrix(); // the map can not be read while it is drawn gl.activeTexture(gl.TEXTURE1); gl.bindTexture(gl.TEXTURE_2D, null); gl.activeTexture(gl.TEXTURE0); gl.bindFramebuffer(gl.FRAMEBUFFER, r.shadowFramebuffer); gl.viewport(0, 0, r.shadowTextureSize, r.shadowTextureSize); gl.clear(gl.DEPTH_BUFFER_BIT); gl.useProgram(r.shadowShader); gl.uniformMatrix4fv(render3DUniform('viewProj', r.shadowShader), false, r.shadowMatrix.m); gl.enable(gl.DEPTH_TEST); gl.depthMask(true); gl.disable(gl.BLEND); gl.disable(gl.CULL_FACE); r.shadowPass = true; try { // see through objects cast only when textured, their alpha cuts the shadow out const casters = render3DLayerObjects(!!r.renderAfter2D).filter(o=> render3DSetting(o, 'castShadow') && !render3DSetting(o, 'additive') && (!o.transparent || o.tileInfo)); render3DDrawObjects(casters); r.onRenderOpaque?.(); r.flush(); render3DFlushInstances(); } finally { // back to the frame with the map ready to sample r.shadowPass = false; gl.bindFramebuffer(gl.FRAMEBUFFER, null); // backing store size, mainCanvasSize is css pixels gl.viewport(0, 0, glCanvas.width, glCanvas.height); gl.activeTexture(gl.TEXTURE1); gl.bindTexture(gl.TEXTURE_2D, r.shadowTexture); gl.activeTexture(gl.TEXTURE0); } } // the camera's depth texture and its framebuffer at a size, made again when the canvas changes size; depth values // are not filtered, so it is read texel by texel function render3DUpdateCameraDepth(width, height) { const gl = glContext, r = render3D; if (r.cameraDepthTexture && r.cameraDepthWidth === width && r.cameraDepthHeight === height) return; r.cameraDepthTexture && gl.deleteTexture(r.cameraDepthTexture); r.cameraDepthFramebuffer && gl.deleteFramebuffer(r.cameraDepthFramebuffer); const texture = r.cameraDepthTexture = gl.createTexture(); gl.bindTexture(gl.TEXTURE_2D, texture); gl.texImage2D(gl.TEXTURE_2D, 0, gl.DEPTH_COMPONENT24, width, height, 0, gl.DEPTH_COMPONENT, gl.UNSIGNED_INT, null); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); gl.bindTexture(gl.TEXTURE_2D, null); const framebuffer = r.cameraDepthFramebuffer = gl.createFramebuffer(); gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer); gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.DEPTH_ATTACHMENT, gl.TEXTURE_2D, texture, 0); gl.drawBuffers([gl.NONE]); // depth only gl.readBuffer(gl.NONE); ASSERT(gl.checkFramebufferStatus(gl.FRAMEBUFFER) == gl.FRAMEBUFFER_COMPLETE, 'camera depth framebuffer is incomplete'); gl.bindFramebuffer(gl.FRAMEBUFFER, null); r.cameraDepthWidth = width, r.cameraDepthHeight = height; } // draw the camera's depth for post processing: the solid objects from the camera with the shadow map's depth only // shader, into a texture the size of the canvas; what is see through is cut by its alpha as it is for shadows function render3DRenderDepth() { const gl = glContext, r = render3D, width = glCanvas.width, height = glCanvas.height; render3DUpdateCameraDepth(width, height); gl.bindFramebuffer(gl.FRAMEBUFFER, r.cameraDepthFramebuffer); gl.viewport(0, 0, width, height); gl.clear(gl.DEPTH_BUFFER_BIT); gl.useProgram(r.shadowShader); gl.uniformMatrix4fv(render3DUniform('viewProj', r.shadowShader), false, r.viewProjection.m); gl.enable(gl.DEPTH_TEST); gl.depthMask(true); gl.disable(gl.BLEND); gl.disable(gl.CULL_FACE); r.shadowPass = r.depthPass = true; try { const solids = render3DLayerObjects(!!r.renderAfter2D).filter(o=> !render3DSetting(o, 'additive') && (!o.transparent || o.tileInfo)); render3DDrawObjects(solids); r.onRenderOpaque?.(); r.flush(); render3DFlushInstances(); } finally { r.shadowPass = r.depthPass = false; gl.bindFramebuffer(gl.FRAMEBUFFER, null); gl.viewport(0, 0, width, height); } } /////////////////////////////////////////////////////////////////////////////// // Strips: every strip repeats its first point once at the start and its last // point once at the end. Those repeats make flat triangles with no area, which // are invisible, and they let one strip run straight into the next. // An odd count gets one more repeat at the end. Triangles in a strip alternate // which way they face, so keeping the count even keeps every strip facing out. // make room in the stream for a strip of count vertices under the current state and texture, flushing a batch that // differs first; returns the uv rect to map the vertices with, or undefined when nothing can be drawn; unlit draws // the batch unlit whatever the state says, so a billboard does not set the state and set it back at every draw function render3DBeginStrip(count, tileInfo, unlit=false) { const r = render3D; if (!render3DCanDraw()) return; if (r.shadowPass && !r.lighting) return; // unlit things cast no shadow ASSERT(count <= RENDER3D_MAX_STREAM_VERTS, 'strip is too large for the stream, bake it into a mesh'); if (count > RENDER3D_MAX_STREAM_VERTS) return; const textureInfo = render3DTextureOf(tileInfo); if (r.streamCount && (textureInfo !== r.streamTileInfo || r.streamUnlit !== unlit || render3DStateChanged(r.streamState) || r.streamCount + count > RENDER3D_MAX_STREAM_VERTS)) r.flush(); render3DFlushBeforeOverlay(); if (!r.streamCount) r.streamState = render3DCaptureBatchState(); r.streamUnlit = unlit; r.streamTileInfo = textureInfo; return render3DGetTileUVs(tileInfo); } // a draw that is not depth tested goes over what is already drawn, so the batches drawn before it go first, or they // would draw at the end of the stage and cover it whatever its render order; a stream open with the same state is // drawn too, so each such draw splits the batches: one draw per object for meshes each with an overlay function render3DFlushBeforeOverlay() { const r = render3D; if (r.depthTest || r.shadowPass || !r.instanceMeshes.length) return; r.flush(); render3DFlushInstances(); } // upload a mesh that is new, changed, or from before the context was lost function render3DMeshUpload(mesh) { if (!mesh.buffer || mesh.dirty || mesh.contextGeneration !== render3D.contextGeneration) mesh.upload(); } // an object's size3D in the world, grown by its scale and its parents', the size its sprite is drawn at and that // collecting, picking and collision measure it at function render3DWorldSize(o, m) { const s = o.size3D; return vec3(s.x * hypot(m[0], m[1], m[2]), s.y * hypot(m[4], m[5], m[6]), s.z * hypot(m[8], m[9], m[10])); } // the surface normal of a height function at x, z, from the slope across a cell each way, clamped to the edges so a // border sample leans the same as its neighbor; buildGrid and HeightMap both light their slopes by it, and it makes // one vector, a big terrain has millions of vertices function render3DSlopeNormal(heightFunction, x, z, ex, ez, halfX, halfZ) { const x0 = max(x - ex, -halfX), x1 = min(x + ex, halfX), z0 = max(z - ez, -halfZ), z1 = min(z + ez, halfZ); const dx = (heightFunction(x1, z) - heightFunction(x0, z)) / (x1 - x0 || 1); const dz = (heightFunction(x, z1) - heightFunction(x, z0)) / (z1 - z0 || 1); const s = 1 / hypot(dx, 1, dz); return vec3(-dx * s, s, -dz * s); } // the half axes of a camera facing quad of a size turned by an angle, right then up, in one shared array // so a particle costs no vectors; read it before calling again const render3DBillboardAxesScratch = new Float64Array(6); function render3DBillboardAxes(size, angle, upright) { const r = render3D.cameraRight, u = render3D.cameraUp; let rx = r.x, ry = r.y, rz = r.z, ux = u.x, uy = u.y, uz = u.z; if (upright) { // an upright quad stands on world up and only turns to face the camera const l = hypot(rx, rz); // a rolled camera has no flat right rx = l ? rx / l : 1, ry = 0, rz = l ? rz / l : 0; ux = 0, uy = 1, uz = 0; } const c = cos(angle), s = sin(angle), w = size.x / 2, h = size.y / 2, a = render3DBillboardAxesScratch; a[0] = (rx * c + ux * s) * w, a[1] = (ry * c + uy * s) * w, a[2] = (rz * c + uz * s) * w; a[3] = (ux * c - rx * s) * h, a[4] = (uy * c - ry * s) * h, a[5] = (uz * c - rz * s) * h; return a; } // the four corners of a camera facing quad in strip order function render3DBillboardCorners(pos, size, angle, upright) { const a = render3DBillboardAxes(size, angle, upright); const rx = a[0], ry = a[1], rz = a[2], ux = a[3], uy = a[4], uz = a[5]; return [ vec3(pos.x - rx + ux, pos.y - ry + uy, pos.z - rz + uz), vec3(pos.x - rx - ux, pos.y - ry - uy, pos.z - rz - uz), vec3(pos.x + rx + ux, pos.y + ry + uy, pos.z + rz + uz), vec3(pos.x + rx - ux, pos.y + ry - uy, pos.z + rz - uz)]; } /* * LittleJS 3D Mesh Plugin * - Mesh, the geometry the renderer draws: triangle strips or indexed lists, uploaded to the GPU on their first draw * - The basic builders, buildBox, buildSphere, buildGrid, buildLathe and buildSky, and the helpers that pack a mesh * - Goes after the Render3D plugin, everything here is part of its Render3D namespace */ /////////////////////////////////////////////////////////////////////////////// // how many vertices a strip of n points takes with its repeats, and which point vertex k of it is function render3DStripCount(n) { return n + 2 + (n & 1); } function render3DStripIndex(k, n) { return k < 1 ? 0 : k <= n ? k - 1 : n - 1; } // walk a strip's vertices with the repeats applied, calling back with (point, normal, uv, color) // normals, uvs and colors may be one value for all points, an array per point, or undefined function render3DForEachStripVertex(points, normals, uvs, colors, callback) { ASSERT(isArray(points) && points.length > 2, 'strip needs at least 3 points'); const n = points.length, count = render3DStripCount(n); const normalArray = isArray(normals), uvArray = isArray(uvs), colorArray = isArray(colors); for (let k = 0; k < count; ++k) { const i = render3DStripIndex(k, n); callback(points[i], normalArray ? normals[i] : normals || RENDER3D_DEFAULT_NORMAL, uvArray ? uvs[i] : uvs || RENDER3D_DEFAULT_UV, colorArray ? colors[i] : colors || WHITE); } } // write one vertex into a packed buffer at float index j function render3DWriteVertex(floats, ints, j, x, y, z, n, u, v, rgba) { floats[j] = x; floats[j+1] = y; floats[j+2] = z; floats[j+3] = n.x; floats[j+4] = n.y; floats[j+5] = n.z; floats[j+6] = u; floats[j+7] = v; ints[j+8] = rgba; } // turn every triangle of an index list the other way round, in place: the authoring form reads counter clockwise // from the front and the pass draws clockwise, as a strip's triangles come out after its leading repeat function render3DFlipTriangles(indices) { for (let t = 0; t < indices.length; t += 3) { const b = indices[t+1]; indices[t+1] = indices[t+2], indices[t+2] = b; } return indices; } // the triangles of a strip of count entries as an index list, given which vertex each entry maps to and which place // it is at: the strip's triangle i is (i-2, i-1, i), its odd ones read the other way as the GPU reads a strip, and a // triangle with two corners in one place has no area, it is a join between pieces or a sliver at a pole, and is left out function render3DStripTriangles(count, remap, place) { const indices = []; for (let i = 2; i < count; ++i) { const a = i & 1 ? i - 1 : i - 2, b = i & 1 ? i - 2 : i - 1; if (place[a] != place[b] && place[b] != place[i] && place[a] != place[i]) indices.push(remap[a], remap[b], remap[i]); } return indices; } // a mesh's packed vertex data for the GPU, one vertex per entry of a layout, which is the strip index of each, // written into data when given, the buffer an earlier call returned for the same layout; it measures the mesh's // radius and box as it goes, as computeRadius does, over the vertices once instead of every strip entry function render3DMeshVertexData(mesh, vertices, data=new ArrayBuffer(vertices.length * RENDER3D_VERTEX_BYTES)) { const count = vertices.length; const floats = new Float32Array(data), ints = new Uint32Array(data); let r = 0, x0 = Infinity, y0 = Infinity, z0 = Infinity, x1 = -Infinity, y1 = -Infinity, z1 = -Infinity; for (let j = 0; j < count; ++j) { // a hand built mesh may leave normals, uvs and colors empty const i = vertices[j], p = mesh.points[i], uv = mesh.uvs[i] || RENDER3D_DEFAULT_UV; const x = p.x, y = p.y, z = p.z; render3DWriteVertex(floats, ints, j * RENDER3D_VERTEX_FLOATS, x, y, z, mesh.normals[i] || RENDER3D_DEFAULT_NORMAL, uv.x, uv.y, (mesh.colors[i] || WHITE).rgbaInt()); r = max(r, x*x + y*y + z*z); x0 = min(x0, x), y0 = min(y0, y), z0 = min(z0, z); x1 = max(x1, x), y1 = max(y1, y), z1 = max(z1, z); } mesh.radius = r ** .5; mesh.bounds = count ? {min: vec3(x0, y0, z0), max: vec3(x1, y1, z1)} : {min: vec3(), max: vec3()}; return data; } // reorder a convex polygon's points, counter clockwise from outside, into one triangle strip function render3DPolygonStrip(points) { const strip = [points[0]]; for (let i = 1, j = points.length - 1; i <= j; ++i, --j) { strip.push(points[i]); if (i !== j) strip.push(points[j]); } return strip; } // turn every triangle of a mesh the other way round, the normals left alone: an index list reads each one in the // other order, and a strip gets one extra point at each end, which flips every triangle and keeps the count even function render3DFlipWinding(mesh) { if (mesh.indices) return void render3DFlipTriangles(mesh.indices); for (const key of ['points', 'normals', 'uvs', 'colors']) { const a = mesh[key]; if (a.length) a.unshift(a[0]), a.push(a[a.length - 1]); } mesh.vertexKeys = undefined; } /////////////////////////////////////////////////////////////////////////////// // frees the GPU buffer of a mesh that is garbage collected without dispose, some time after it goes; it holds the // buffer and its context, never the mesh, or the mesh could not be collected, and dispose unregisters the mesh const render3DMeshBuffers = typeof FinalizationRegistry == 'undefined' ? undefined : new FinalizationRegistry(({buffer, indexBuffer, generation})=> { if (generation !== render3D?.contextGeneration || !glContext) return; glContext.deleteBuffer(buffer); glContext.deleteBuffer(indexBuffer); }); // the points at one place, to a hundred thousandth, which smooth normals are summed by: each point's group, numbered // from 0, found by a number made from its place and checked against the place, so no text is made for a point function render3DPlaceGroups(points) { const groups = new Int32Array(points.length), first = new Map, next = [], places = []; for (let i = 0; i < points.length; ++i) { const p = points[i], x = round(p.x * 1e5), y = round(p.y * 1e5), z = round(p.z * 1e5); const hash = Math.imul(x, 73856093) ^ Math.imul(y, 19349663) ^ Math.imul(z, 83492791); let group = first.get(hash); while (group !== undefined && !(places[group*3] === x && places[group*3+1] === y && places[group*3+2] === z)) group = next[group]; if (group === undefined) { group = next.length; next.push(first.get(hash)); first.set(hash, group); places.push(x, y, z); } groups[i] = group; } return {groups, count: next.length}; } // the face normals meeting at each place added up, each weighted by its corner angle so a cube corner averages its // three faces evenly however they are cut into triangles; of a strip, every other triangle turned back, with no // indices, or of the index list; in numbers, with no vector made for a triangle or a corner function render3DSmoothNormalSums(points, indices) { const strip = !indices, triangles = strip ? max(points.length - 2, 0) : indices.length / 3 | 0; const {groups, count} = render3DPlaceGroups(points); const sums = new Float64Array(count * 3), touched = new Uint8Array(count); let nx = 0, ny = 0, nz = 0; const addCorner = (a, b, c, group)=> { // the angle at corner a, then the face normal scaled by it added to the sum at a's place const ux = b.x - a.x, uy = b.y - a.y, uz = b.z - a.z, vx = c.x - a.x, vy = c.y - a.y, vz = c.z - a.z; const dot = ux*vx + uy*vy + uz*vz, lengths = (ux**2 + uy**2 + uz**2)**.5 * (vx**2 + vy**2 + vz**2)**.5; const angle = Math.acos(clamp(dot / (lengths || 1), -1, 1)), s = group * 3; sums[s] += nx * angle, sums[s+1] += ny * angle, sums[s+2] += nz * angle; touched[group] = 1; }; for (let t = 0; t < triangles; ++t) { const i0 = strip ? t : indices[t*3], i1 = strip ? t + 1 : indices[t*3+1], i2 = strip ? t + 2 : indices[t*3+2]; const a = points[i0], b = points[i1], c = points[i2]; const ux = b.x - a.x, uy = b.y - a.y, uz = b.z - a.z, vx = c.x - a.x, vy = c.y - a.y, vz = c.z - a.z; const cx = uy*vz - uz*vy, cy = uz*vx - ux*vz, cz = ux*vy - uy*vx; const lengthSquared = cx**2 + cy**2 + cz**2; if (!lengthSquared) continue; // no area, like a triangle joining two strips const k = (strip && !(t & 1) ? -1 : 1) / lengthSquared**.5; nx = cx * k, ny = cy * k, nz = cz * k; addCorner(a, b, c, groups[i0]); addCorner(b, c, a, groups[i1]); addCorner(c, a, b, groups[i2]); } return {groups, sums, touched}; } /** * Mesh - Triangles with positions, normals, uvs and colors, uploaded once and drawn by matrix * - Build with addStrip, addQuad, combine or the shape builders, then render each frame * - Its back faces are skipped unless doubleSided is set, which the open builders like buildGrid do for you * - Two forms: a triangle strip, what the builders make, or an indexed list of triangles over their own vertices, * what addTriangles and the model loaders make; upload sends the GPU an indexed list either way, see getTriangles, * so a strip's joins between its pieces cost nothing to draw, and toIndexed turns a strip mesh into the list form * - The GPU buffer is created lazily on first render and dropped by dispose, or freed once the mesh is garbage * collected, so dispose is only needed to free it right away, like for a mesh rebuilt often * @memberof Render3D * @example * const mesh = buildLathe([[0, -1], [1, 0], [0, 1]], 4); // octahedron * mesh.render(buildMatrix(vec3(0, 1, 0)), undefined, RED); */ class Mesh { /** Create an empty mesh */ constructor() { /** @property {Array} - Vertex positions, in strip order or one per vertex of an indexed mesh * @type {Array} */ this.points = []; /** @property {Array} - Vertex normals * @type {Array} */ this.normals = []; /** @property {Array} - Vertex texture coords, 0-1 across the tile * @type {Array} */ this.uvs = []; /** @property {Array} - Vertex colors * @type {Array} */ this.colors = []; /** @property {WebGLBuffer|undefined} - GPU vertex buffer, created by upload * @type {WebGLBuffer|undefined} */ this.buffer = undefined; /** @property {WebGLBuffer|undefined} - GPU index buffer, the triangles, created by upload * @type {WebGLBuffer|undefined} */ this.indexBuffer = undefined; /** @property {number} - Indices in the GPU index buffer, three per triangle */ this.bufferCount = 0; this.indexType = 0; // gl.UNSIGNED_SHORT, or UNSIGNED_INT past 65535 vertices /** @property {boolean} - The mesh changed and needs uploading again, set it yourself if you edit the arrays */ this.dirty = false; /** @property {boolean|undefined} - Draw every use of this mesh in the opaque stage as one instanced call, * undefined follows render3D.instancing * @type {boolean|undefined} */ this.instanced = undefined; /** @property {boolean} - Draw both sides, each lit as the side that is seen; off skips the faces pointing away, * which is faster and right for closed shapes, the open builders like buildGrid and buildRibbon turn it on */ this.doubleSided = false; /** @property {boolean} - The values change often but the shape never does, for a water surface or a cloth: set * once, the mesh keeps its GPU layout and a dirty upload only rewrites the vertices into the buffer it has; * the strip must keep the same points in the same order, a new point count asserts; the layout is decided by * the first upload, so strip entries equal then stay one vertex and triangles with no area then stay dropped, * set vertexKeys or give it distinct values at the start, not a flat grid of one color or points all in one place */ this.dynamicDraw = false; /** @property {Array|undefined} - The mesh as an indexed triangle list instead of a strip: the arrays * hold each vertex once and this says how they join, three vertex numbers per triangle, counter clockwise seen * from the front like a strip's first triangle; addTriangles and the loaders fill it, toIndexed turns a strip * mesh into this form * @type {Array|undefined} */ this.indices = undefined; /** @property {Int32Array|undefined} - Which strip entries are one vertex, set by a builder that knows, one * whole number per entry with equal numbers meaning the same vertex; upload skips its search for them, then * drops the keys, since an edit after that may tell the entries apart; adding geometry or recomputing normals * drops them too * @type {Int32Array|undefined} */ this.vertexKeys = undefined; /** @type {{vertices: Array, pointCount: number, data: ArrayBuffer}|undefined} */ // the strip index of each GPU vertex, the point count and packed data of the last upload, for a dynamicDraw mesh this.vertexLayout = undefined; // the layout's data already holds the mesh as it is, with its radius and box, written by a glTF skin as it // bent the mesh, so the next upload sends it without packing it again this.vertexDataPacked = false; this.instanceCount = 0; // draws waiting in this mesh's batch, with their values, texture and draw state /** @type {Float32Array|undefined} */ this.instanceData = undefined; /** @property {number} - Bounding sphere radius around the origin, for culling and picking, computed by upload */ this.radius = 0; /** @property {{min: Vector3, max: Vector3}|undefined} - Bounding box, for picking, measured with the radius * @type {{min: Vector3, max: Vector3}|undefined} */ this.bounds = undefined; this.contextGeneration = 0; // the context the buffer belongs to, see render3D.contextGeneration } /** Number of vertices in the mesh * @return {number} */ get vertexCount() { return this.points.length; } /** Add a triangle strip, joined to the previous one by invisible flat triangles so one mesh holds many strips * - Strip order: the first three points make a triangle, then each point makes another with the two before it * - List the first three points counter clockwise as seen from the front, or the face points away * and may vanish when back faces are culled * @param {Array} points - Strip order * @param {Vector3|Array} [normals] - One for all or one per point, default up * @param {Vector2|Array} [uvs] - One for all or one per point, default zero * @param {Color|Array} [colors] - One for all or one per point, default white * @return {Mesh} */ addStrip(points, normals, uvs, colors) { if (this.indices) { // an indexed mesh takes the strip as the triangles it makes const part = new Mesh().addStrip(points, normals, uvs, colors).toIndexed(); return this.addTriangles(part.points, part.indices, part.normals, part.uvs, part.colors); } render3DForEachStripVertex(points, normals, uvs, colors, (p, n, uv, c)=> { this.points.push(p); this.normals.push(n); this.uvs.push(uv); this.colors.push(c); }); this.vertexKeys = undefined; // the new entries have no keys this.dirty = true; return this; } /** Add triangles over their own vertices, the indexed form a model file comes in * - The mesh becomes indexed: a strip mesh is turned into triangles first, and strips added later join as triangles * - List each triangle counter clockwise as seen from the front, like a strip's first triangle * @param {Array} points - Each vertex once * @param {Array} indices - Three vertex numbers per triangle, into points * @param {Vector3|Array} [normals] - One for all or one per point, default up * @param {Vector2|Array} [uvs] - One for all or one per point, default zero * @param {Color|Array} [colors] - One for all or one per point, default white * @return {Mesh} */ addTriangles(points, indices, normals, uvs, colors) { ASSERT(isArray(points) && isArray(indices) && indices.length % 3 === 0, 'addTriangles takes points and three indices per triangle'); ASSERT(indices.every(i=> i >= 0 && i < points.length && i % 1 === 0), 'an index points past the vertices given'); this.toIndexed(); const offset = this.points.length, normalArray = isArray(normals), uvArray = isArray(uvs), colorArray = isArray(colors); for (let i = 0; i < points.length; ++i) { this.points.push(points[i]); this.normals.push(normalArray ? normals[i] : normals || RENDER3D_DEFAULT_NORMAL); this.uvs.push(uvArray ? uvs[i] : uvs || RENDER3D_DEFAULT_UV); this.colors.push(colorArray ? colors[i] : colors || WHITE); } for (const i of indices) this.indices.push(i + offset); this.dirty = true; return this; } /** Turn a strip mesh into the indexed form, each distinct vertex once and the real triangles over them, in place * - An indexed mesh is left as it is; the builders make strips and a loader makes this, and either draws the same * @return {Mesh} */ toIndexed() { if (this.indices) return this; const {vertices, indices} = this.getTriangles(); this.points = vertices.map(i=> this.points[i]); this.normals = vertices.map(i=> this.normals[i] || RENDER3D_DEFAULT_NORMAL); this.uvs = vertices.map(i=> this.uvs[i] || RENDER3D_DEFAULT_UV); this.colors = vertices.map(i=> this.colors[i] || WHITE); // the list upload sends reads clockwise, the pass draws it that way; the authoring form is counter clockwise this.indices = render3DFlipTriangles(indices); this.vertexKeys = undefined; this.dirty = true; return this; } /** Add a flat quad from four corners in loop order, counter clockwise seen from the front, a is the top left of the texture * @param {Vector3} a * @param {Vector3} b * @param {Vector3} c * @param {Vector3} d * @param {Color|Array} [color] - One for all or one per corner * @param {Array} [uvs] - One per corner, default across the tile * @return {Mesh} */ addQuad(a, b, c, d, color, uvs) { return this.addStrip(render3DQuadStrip(a, b, c, d), render3DFaceNormal(a, b, c, d), uvs ? render3DQuadValues(uvs) : RENDER3D_QUAD_UVS, render3DQuadValues(color)); } /** Append another mesh transformed by a matrix, for building one shape out of several * @param {Mesh} mesh * @param {Matrix4|Vector3} [matrix] - Transform, or just a position to move it to * @param {Color} [color] - Multiplies the appended vertex colors * @return {Mesh} */ combine(mesh, matrix=RENDER3D_IDENTITY, color=WHITE) { matrix = render3DMatrix(matrix); // most parts only need moving into place const normalMatrix = render3DNormalMatrix(matrix), mirrors = render3DMirrors(matrix); let part = mesh, order; if (this.indices || mesh.indices) { // one of them is indexed, so both are: the part as a copy if it is a strip this.toIndexed(); part = mesh.indices ? mesh : new Mesh().combine(mesh).toIndexed(); const offset = this.points.length, indices = part.indices; // the count read once, a mesh combined with itself grows as it is read; a mirror turns every triangle the // other way round for (let t = 0, n = indices.length; t < n; t += 3) this.indices.push(indices[t] + offset, indices[t + (mirrors ? 2 : 1)] + offset, indices[t + (mirrors ? 1 : 2)] + offset); } else if (mirrors && part.points.length) { // a strip reads the other way round with one more point at each end, as render3DFlipWinding does const last = part.points.length - 1; order = [0, ...part.points.keys(), last]; } for (let j = 0, count = order ? order.length : part.points.length; j < count; ++j) { const i = order ? order[j] : j; this.points.push(matrix.transformPoint(part.points[i])); this.normals.push(normalMatrix.transformDirection(part.normals[i] || RENDER3D_DEFAULT_NORMAL).normalize()); this.uvs.push((part.uvs[i] || RENDER3D_DEFAULT_UV).copy()); this.colors.push((part.colors[i] || WHITE).multiply(color)); } this.doubleSided ||= mesh.doubleSided; // an open part leaves the whole mesh open this.vertexKeys = undefined; // the new entries have no keys this.dirty = true; return this; } /** Returns a new mesh: this one and its mirror image across the plane through the origin facing axis * - For modeling half a shape against that plane, a part that crosses it overlaps its image * @param {Vector3} [axis] - Faces the mirror plane, vec3(1,0,0) mirrors across x * @return {Mesh} */ mirror(axis=vec3(1, 0, 0)) { ASSERT(isVector3(axis) && axis.lengthSquared() > 0, 'mirror needs an axis'); const n = axis.normalize(), a = -2 * n.x, b = -2 * n.y, c = -2 * n.z; const reflect = new Matrix4([1 + a * n.x, b * n.x, c * n.x, 0, a * n.y, 1 + b * n.y, c * n.y, 0, a * n.z, b * n.z, 1 + c * n.z, 0, 0, 0, 0, 1]); return new Mesh().combine(this).combine(this, reflect); // combine turns the image's faces the right way out } /** Returns a new mesh of count copies of this one, each turned further around an axis through the origin * @param {number} count - Copies, spaced evenly around the whole turn * @param {Vector3} [axis] - Up by default * @return {Mesh} */ spin(count, axis=vec3(0, 1, 0)) { ASSERT(count >= 1 && count % 1 === 0, 'spin count must be a whole number, 1 or more'); ASSERT(isVector3(axis) && axis.lengthSquared() > 0, 'spin needs an axis'); const mesh = new Mesh, unit = axis.normalize(); for (let i = 0; i < count; ++i) mesh.combine(this, render3DAxisRotation(unit, i / count * 2 * PI)); return mesh; } /** Returns a new mesh of everything in this mesh or the other, see subtract * @param {Mesh} mesh - Closed, as the builders make them apart from the open ones like buildGrid * @param {Matrix4|Vector3} [matrix] - Places the other mesh, or just a position to move it to * @return {Mesh} */ union(mesh, matrix) { return render3DMeshCSG(this, mesh, matrix, 0); } /** Returns a new mesh of this one with the other cut out of it, CSG with BSP trees * - Both must be closed, every edge shared by two triangles, as the builders make them apart from the open * ones like buildGrid and buildRibbon; the result is closed and indexed, and neither mesh changes * - The faces a cut makes come from the other mesh's surface, turned to face out, with its normals, uvs and * colors, so a smooth cylinder drills a round hole * - Parts that overlap must be joined with union to be one solid, not with combine, mirror or spin, which * leave them overlapping, and CSG then gives a wrong shape * - Cuts split the triangles near them, so the result has more: a few thousand triangles take tens to a few * hundred milliseconds, so build shapes this way at load time, not every frame; joining several cutters * with union and cutting once is quicker than cutting with each in turn * - Details closer than about 1e-4 are made one, so build a very small part larger and scale it after * @param {Mesh} mesh - Closed, as the builders make them apart from the open ones like buildGrid * @param {Matrix4|Vector3} [matrix] - Places the other mesh, or just a position to move it to * @return {Mesh} * @example * const wall = buildBox(vec3(4, 3, .5)).subtract(buildBox(vec3(1, 2, 1)), vec3(0, -.5, 0)); // a doorway */ subtract(mesh, matrix) { return render3DMeshCSG(this, mesh, matrix, 1); } /** Returns a new mesh of only what is in both this mesh and the other, see subtract * @param {Mesh} mesh - Closed, as the builders make them apart from the open ones like buildGrid * @param {Matrix4|Vector3} [matrix] - Places the other mesh, or just a position to move it to * @return {Mesh} */ intersect(mesh, matrix) { return render3DMeshCSG(this, mesh, matrix, 2); } /** Scale every uv, so a whole texture repeats across the mesh when its TextureInfo wraps * @param {Vector2|number} scale - Repeats across and up, a number for both * @return {Mesh} */ scaleUVs(scale) { const s = render3DSize2(scale); // new vectors, builders share uv objects between faces this.uvs = this.uvs.map(uv=> vec2(uv.x * s.x, uv.y * s.y)); this.dirty = true; return this; } /** Move, turn or scale every vertex in place, normals follow along * @param {Matrix4|Vector3} matrix - Transform, or just an offset to move by * @return {Mesh} */ transform(matrix) { matrix = render3DMatrix(matrix); const normalMatrix = render3DNormalMatrix(matrix); for (let i = 0; i < this.points.length; ++i) { this.points[i] = matrix.transformPoint(this.points[i]); // a mesh built by hand may have no normals yet, and then there is nothing to turn this.normals[i] &&= normalMatrix.transformDirection(this.normals[i]).normalize(); } render3DMirrors(matrix) && render3DFlipWinding(this); // a mirror would leave the faces pointing in this.dirty = true; return this; } /** Turn the mesh inside out so it is lit and drawn from within, for rooms and domes * @return {Mesh} */ flipNormals() { render3DFlipWinding(this); this.normals = this.normals.map(n=> n.scale(-1)); this.dirty = true; return this; } /** Set every vertex color * @param {Color} color * @return {Mesh} */ setColor(color) { // one per point, not one per color already there, so a mesh built by hand with no // colors gets them instead of quietly staying white this.colors = this.points.map(()=> color); this.dirty = true; return this; } /** Measure the axis aligned box around the vertices * @return {{min: Vector3, max: Vector3}} */ getBounds() { if (!this.points.length) return {min: vec3(), max: vec3()}; const lo = vec3(Infinity), hi = vec3(-Infinity); for (const p of this.points) { lo.x = min(lo.x, p.x); lo.y = min(lo.y, p.y); lo.z = min(lo.z, p.z); hi.x = max(hi.x, p.x); hi.y = max(hi.y, p.y); hi.z = max(hi.z, p.z); } return {min: lo, max: hi}; } /** Move the mesh so the center of its bounds is on the origin * @return {Mesh} */ center() { const bounds = this.getBounds(); return this.transform(bounds.min.add(bounds.max).scale(-.5)); } /** Scale the mesh evenly so its largest extent is a size, for loaded models of unknown units * @param {number} [size] * @return {Mesh} */ fit(size=1) { const bounds = this.getBounds(); const extent = bounds.max.subtract(bounds.min); const scale = size / (max(extent.x, extent.y, extent.z) || 1); return this.transform(Matrix4.scaling(vec3(scale))); } /** Measure the bounding sphere around the origin into radius, called by upload * @return {number} */ computeRadius() { let r = 0; for (const p of this.points) r = max(r, p.lengthSquared()); this.bounds = this.getBounds(); // measured with it, a pick tests a mesh's box after its sphere return this.radius = r ** .5; } /** Derive normals from the triangles, of the strip or of the index list * @param {boolean} [smooth] - Round the lighting across faces instead of giving each face a hard edge; flat * normals on an indexed mesh give every corner its own vertex * @return {Mesh} */ computeNormals(smooth=false) { if (this.indices) { // the triangles are listed: smooth normals add up around each position, weighted by the corner angle // like the strip's, so vertices split apart at one place smooth back together and a mesh can go flat and // smooth again; flat ones need a vertex per corner, so the vertices are split up first if (smooth) { const {groups, sums, touched} = render3DSmoothNormalSums(this.points, this.indices); this.normals = this.points.map((p, i)=> { const s = groups[i] * 3, x = sums[s], y = sums[s+1], z = sums[s+2]; const lengthSquared = x**2 + y**2 + z**2, k = 1 / lengthSquared**.5; return touched[groups[i]] && lengthSquared ? vec3(x * k, y * k, z * k) : RENDER3D_DEFAULT_NORMAL; }); this.dirty = true; return this; } const split = (a)=> this.indices.map(i=> a[i]); this.points = split(this.points), this.normals = split(this.normals); this.uvs = split(this.uvs), this.colors = split(this.colors); this.indices = this.indices.map((_, i)=> i); const points = this.points, indices = this.indices; const normals = points.map(()=> RENDER3D_DEFAULT_NORMAL); for (let t = 0; t < indices.length; t += 3) { const a = points[indices[t]], b = points[indices[t+1]], c = points[indices[t+2]]; const cross = b.subtract(a).cross(c.subtract(a)); if (cross.lengthSquared()) // its own three corners normals[indices[t]] = normals[indices[t+1]] = normals[indices[t+2]] = cross.normalize(); } this.normals = normals; this.dirty = true; return this; } // smooth normals are the sums at each place made unit length, or straight up for a place with none const points = this.points, n = points.length; if (smooth) { const {groups, sums, touched} = render3DSmoothNormalSums(points); this.normals = points.map((p, i)=> { const s = groups[i] * 3, x = sums[s], y = sums[s+1], z = sums[s+2], l = (x**2 + y**2 + z**2)**.5; return !touched[groups[i]] ? vec3(0, 1, 0) : l ? vec3(x * (1/l), y * (1/l), z * (1/l)) : vec3(); }); this.vertexKeys = undefined; this.dirty = true; return this; } // the outward normal of each triangle in the strip const faceNormals = []; for (let i = 0; i + 2 < n; ++i) { const a = points[i], b = points[i+1], c = points[i+2]; const normal = b.subtract(a).cross(c.subtract(a)); // triangles in a strip alternate which way they wind, so every other one is flipped back // a zero normal means a flat triangle joining two strips, so skip it faceNormals.push(normal.lengthSquared() ? normal.normalize(i & 1 ? 1 : -1) : undefined); } // then hand those to the vertices: every triangle writes its own three corners, so the only vertices left // with the default are the repeats at the ends of a strip, which no triangle with any area uses const normals = points.map(()=> RENDER3D_DEFAULT_NORMAL); faceNormals.forEach((f, i)=> f && (normals[i] = normals[i+1] = normals[i+2] = f)); this.normals = normals; this.vertexKeys = undefined; // flat normals tell entries at one place apart this.dirty = true; return this; } /** Pack the vertices and create the GPU buffer, called automatically by render * @return {Mesh} */ upload() { // the packing below measures the radius and box, with nothing to pack to they are measured here if (!render3D?.program || !glContext) return this.computeRadius(), this; const gl = glContext, layout = this.vertexLayout; // no layout when dynamicDraw was turned on after an upload, the next upload makes one if (this.dynamicDraw && layout && this.buffer && this.contextGeneration === render3D.contextGeneration) { // the layout of the last upload stands, only the values are written again into the buffer it has, // packed into the same memory each time so a mesh uploaded every frame makes no garbage ASSERT(layout.pointCount === this.points.length, 'a dynamicDraw mesh keeps its shape, the same points in the same order; ' + 'for a new shape make a new mesh or turn dynamicDraw off', this.points.length); if (layout.pointCount === this.points.length) { gl.bindBuffer(gl.ARRAY_BUFFER, this.buffer); gl.bufferSubData(gl.ARRAY_BUFFER, 0, this.vertexDataPacked ? layout.data : render3DMeshVertexData(this, layout.vertices, layout.data)); gl.bindBuffer(gl.ARRAY_BUFFER, glArrayBuffer); this.dirty = this.vertexDataPacked = false; return this; } } this.dispose(); const {vertices, indices} = this.getTriangles(), count = vertices.length, wide = count > 65535; const data = render3DMeshVertexData(this, vertices); this.vertexLayout = this.dynamicDraw ? {vertices, pointCount: this.points.length, data} : undefined; this.vertexKeys = undefined; // used once: an edit after this may tell entries apart this.buffer = gl.createBuffer(); this.indexBuffer = gl.createBuffer(); this.bufferCount = indices.length; this.indexType = wide ? gl.UNSIGNED_INT : gl.UNSIGNED_SHORT; this.dirty = this.vertexDataPacked = false; gl.bindBuffer(gl.ARRAY_BUFFER, this.buffer); gl.bufferData(gl.ARRAY_BUFFER, data, this.dynamicDraw ? gl.DYNAMIC_DRAW : gl.STATIC_DRAW); gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, this.indexBuffer); gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, wide ? new Uint32Array(indices) : new Uint16Array(indices), gl.STATIC_DRAW); this.contextGeneration = render3D.contextGeneration; const buffers = {buffer: this.buffer, indexBuffer: this.indexBuffer, generation: this.contextGeneration}; render3DMeshBuffers?.register(this, buffers, this); gl.bindBuffer(gl.ARRAY_BUFFER, glArrayBuffer); // the engine's 2D batch writes through this binding return this; } /** The mesh as an indexed triangle list, what upload sends to the GPU: the strip's real triangles over its * distinct vertices, the joins between its pieces dropped and every triangle facing the way it did in the strip * - Vertices are compared to a millionth, so two at one place with the same normal, uv and color are one * @return {{vertices: Array, indices: Array}} - vertices are strip indices, one per distinct * vertex; indices are the triangles, three per triangle, into vertices */ getTriangles() { const count = this.points.length, vertices = []; if (this.indices) { // already a list: every vertex as it is, and the triangles read the way the pass draws, clockwise for (let i = 0; i < count; ++i) vertices.push(i); return {vertices, indices: render3DFlipTriangles(this.indices.slice())}; } const remap = new Int32Array(count), place = new Int32Array(count), keys = this.vertexKeys; if (keys && keys.length === count) { // a builder said which entries are one vertex: the first entry with a key stands for every entry with it, // and is their place, so nothing is searched const first = new Int32Array(count).fill(-1); for (let i = 0; i < count; ++i) { const key = keys[i]; ASSERT(key >= 0 && key < count, 'vertexKeys must be whole numbers below the entry count', key); const j = first[key]; if (j < 0) first[key] = i, remap[i] = vertices.length, vertices.push(i); else remap[i] = remap[j]; place[i] = first[key]; } return {vertices, indices: render3DStripTriangles(count, remap, place)}; } // each vertex as nine whole numbers, its values in millionths and its color, so vertices hash and compare as // numbers; a hash table over all nine finds the distinct vertices and one over the first three the places, // each slot holding a vertex index plus one and a taken slot moving on to the next const values = new Float64Array(count * 9); let size = 1; while (size < count * 2) size *= 2; const mask = size - 1, seen = new Int32Array(size), places = new Int32Array(size); const mix = (h, v)=> Math.imul(h ^ v, 0x9e3779b1) >>> 0; for (let i = 0; i < count; ++i) { const p = this.points[i], n = this.normals[i] || RENDER3D_DEFAULT_NORMAL; const uv = this.uvs[i] || RENDER3D_DEFAULT_UV, k = i * 9; const x = values[k] = round(p.x * 1e6), y = values[k+1] = round(p.y * 1e6), z = values[k+2] = round(p.z * 1e6); values[k+3] = round(n.x * 1e6), values[k+4] = round(n.y * 1e6), values[k+5] = round(n.z * 1e6); values[k+6] = round(uv.x * 1e6), values[k+7] = round(uv.y * 1e6); values[k+8] = (this.colors[i] || WHITE).rgbaInt(); let h = mix(mix(mix(0x811c9dc5, x), y), z); // the place, shared with a vertex there that has another normal or uv, as at a lathe's poles for (let slot = h & mask;; slot = (slot + 1) & mask) { const o = places[slot] - 1; if (o < 0) { places[slot] = i + 1, place[i] = i; break; } if (values[o*9] === x && values[o*9+1] === y && values[o*9+2] === z) { place[i] = o; break; } } // the whole vertex for (let m = 3; m < 9; ++m) h = mix(h, values[k+m]); for (let slot = h & mask;; slot = (slot + 1) & mask) { const o = seen[slot] - 1; if (o < 0) { seen[slot] = i + 1, remap[i] = vertices.length, vertices.push(i); break; } let same = true; for (let m = 0; same && m < 9; ++m) same = values[o*9+m] === values[k+m]; if (same) { remap[i] = remap[o]; break; } } } return {vertices, indices: render3DStripTriangles(count, remap, place)}; } /** Draw the mesh with the current draw state, batched with its other uses in the opaque stage * @param {Matrix4|Vector3} [matrix] - Object transform, or just a position to draw it at * @param {TileInfo|TextureInfo} [tileInfo] - Texture, mesh uvs map across the tile or the whole texture * @param {Color} [color] - Tint */ render(matrix, tileInfo, color) { render3D?.drawMesh(this, matrix, tileInfo, color); } /** Delete the GPU buffer now, the CPU arrays stay so the mesh can be rendered again * - Optional, the buffer is freed anyway once the mesh is garbage collected, this frees it right away */ dispose() { if (!this.buffer) return; render3DMeshBuffers?.unregister(this); // freed here, so not again when the mesh is collected // a buffer from a context that was lost is gone with it, and the new context refuses to delete it if (this.contextGeneration === render3D?.contextGeneration) glContext?.deleteBuffer(this.buffer), glContext?.deleteBuffer(this.indexBuffer); this.buffer = this.indexBuffer = undefined; this.bufferCount = 0; } } /////////////////////////////////////////////////////////////////////////////// // Shape builders, all centered on the origin so buildMatrix does placement // Sizes are full sizes like buildBox and the 2D drawCircle, sides go around an axis and rings along it /** * Spin a flat outline around the Y axis to make a round shape, like a vase or a wheel * - profile is [[radius, y], ...] from bottom to top * - A profile that ends where it starts makes a closed ring like a donut * - An end left open, with a radius and no cap, makes the mesh doubleSided so its inside shows * - An end on the axis smooth shades as a round pole like a sphere's when its segment is within 45 degrees of * level, and as a point like a cone's tip when it is steeper * @param {Array>} profile * @param {number} [sides] - Around the axis * @param {boolean} [smooth] - Defaults to render3D.smoothShading * @param {boolean} [capped] - Close the ends that have a radius with flat discs * @return {Mesh} * @memberof Render3D * @example * const vase = buildLathe([[0, -1], [.8, -.3], [.9, .2], [.4, .6], [0, 1]], 12); */ function buildLathe(profile, sides=16, smooth=render3D?.smoothShading, capped=true) { ASSERT(isArray(profile) && profile.length > 1, 'lathe profile needs at least 2 points'); sides |= 0; ASSERT(sides > 2, 'lathe needs at least 3 sides'); const mesh = new Mesh; const rings = profile.length; const point = (i, a)=> vec3(sin(a) * profile[i][0], profile[i][1], cos(a) * profile[i][0]); // 2D outward normal of each profile segment, in (radius, y) space const segmentNormal = (i)=> { const [r0, y0] = profile[i], [r1, y1] = profile[i+1]; const n = vec2(y1 - y0, r0 - r1); return n.length() ? n.normalize() : vec2(1, 0); }; // vertex normal: average of the adjacent segment normals, across the seam when the profile is closed, // and a closed profile needs no caps const closed = rings > 2 && abs(profile[0][0] - profile[rings-1][0]) < 1e-9 && abs(profile[0][1] - profile[rings-1][1]) < 1e-9; const segmentLength = (i)=> hypot(profile[i+1][0] - profile[i][0], profile[i+1][1] - profile[i][1]); const vertexNormal = (i)=> { // an open end on the axis is a pole and points along it, like a sphere's, when the surface there is // within 45 degrees of level; a steeper one is a point like a cone's tip and takes its side's normal if (!closed && (!i || i == rings - 1) && abs(profile[i][0]) < 1e-9) { const up = i ? 1 : -1; if (segmentNormal(i ? i - 1 : 0).y * up > Math.SQRT1_2 - 1e-9) return vec2(0, up); } // otherwise the neighbors weighted by their length, so a short band does not tilt a long wall let n = vec2(); const add = (s)=> n = n.add(segmentNormal(s).scale(segmentLength(s))); if (i > 0) add(i - 1); else if (closed) add(rings - 2); if (i < rings - 1) add(i); else if (closed) add(0); return n.length() ? n.normalize() : vec2(1, 0); }; const normal3D = (n, a)=> vec3(sin(a) * n.x, n.y, cos(a) * n.x); // v runs along the profile by arc length const lengths = [0]; for (let i = 1; i < rings; ++i) lengths[i] = lengths[i-1] + hypot(profile[i][0] - profile[i-1][0], profile[i][1] - profile[i-1][1]); const total = lengths[rings - 1] || 1; const v = (i)=> 1 - lengths[i] / total; for (let i = 0; i + 1 < rings; ++i) { if (smooth) { // one ribbon around the ring pair, top point then bottom point per column const points = [], normals = [], uvs = []; const n0 = vertexNormal(i), n1 = vertexNormal(i + 1); // a point on the axis is one per column, each drawn by the face beside it, so its normal turns half // a side toward the middle of that face; a pole's points along the axis and does not turn const half = PI / sides; const turn1 = abs(profile[i + 1][0]) < 1e-9 ? -half : 0, turn0 = abs(profile[i][0]) < 1e-9 ? half : 0; for (let j = 0; j <= sides; ++j) { const a = j / sides * 2 * PI, u = j / sides; points.push(point(i + 1, a), point(i, a)); normals.push(normal3D(n1, a + turn1), normal3D(n0, a + turn0)); uvs.push(vec2(u, v(i + 1)), vec2(u, v(i))); } mesh.addStrip(points, normals, uvs); } else { // one quad per side with its face normal const n = segmentNormal(i); for (let j = 0; j < sides; ++j) { const a0 = j / sides * 2 * PI, a1 = (j + 1) / sides * 2 * PI; const u0 = j / sides, u1 = (j + 1) / sides; mesh.addStrip( [point(i + 1, a0), point(i, a0), point(i + 1, a1), point(i, a1)], normal3D(n, (a0 + a1) / 2), [vec2(u0, v(i + 1)), vec2(u0, v(i)), vec2(u1, v(i + 1)), vec2(u1, v(i))]); } } } // flat discs close the ends that have a radius, a hard edge even when the sides are smooth if (capped && !closed) for (const [i, up] of /** @type {Array<[number, boolean]>} */ ([[0, false], [rings - 1, true]])) { if (abs(profile[i][0]) < 1e-9) continue; // a pole has no cap const points = [], uvs = []; for (let j = 0; j < sides; ++j) { const a = (up ? j : -j) / sides * 2 * PI; // counter clockwise seen from outside points.push(point(i, a)); uvs.push(vec2(sin(a) * .5 + .5, cos(a) * .5 + .5)); } mesh.addStrip(render3DPolygonStrip(points), vec3(0, up ? 1 : -1, 0), render3DPolygonStrip(uvs)); } // an end left open shows the inside, so it is seen from both sides mesh.doubleSided = !closed && !capped && (abs(profile[0][0]) > 1e-9 || abs(profile[rings-1][0]) > 1e-9); return mesh; } /** * Build a sphere centered on the origin * @param {number} [size] - Diameter * @param {number} [sides] - Around * @param {number} [rings] - Top to bottom * @param {boolean} [smooth] - Defaults to render3D.smoothShading * @return {Mesh} * @memberof Render3D */ function buildSphere(size=1, sides=16, rings=8, smooth=render3D?.smoothShading) { ASSERT(rings > 1, 'sphere needs at least 2 rings'); const profile = []; for (let i = 0; i <= rings; ++i) { const a = i / rings * PI - PI/2; profile.push([cos(a) * size / 2, sin(a) * size / 2]); } return buildLathe(profile, sides, smooth); } // a box with its edges cut by a bevel of size t: each face shrunk by t, a strip along each edge around a quarter // circle, and an eighth of a sphere at each corner; one segment makes them flat, a chamfer function render3DBevelBox(half, t, segments, faces) { const mesh = new Mesh, inner = half.subtract(vec3(t)), n = max(1, segments | 0); // a triangle facing out, since the box is convex around the origin, flat shaded or with the normals of the // round, and uvs from the face it faces most, as a plain box maps that face const triangle = (a, b, c, na, nb, nc)=> { let normal = b.subtract(a).cross(c.subtract(a)); if (normal.lengthSquared() < 1e-20) return; // an edge or face with no size if (normal.dot(a.add(b).add(c)) < 0) [b, c, nb, nc, normal] = [c, b, nc, nb, normal.scale(-1)]; normal = normal.normalize(); let face = faces[0]; for (const f of faces) if (f[0].dot(normal) > face[0].dot(normal)) face = f; const [, r, u] = face, hr = abs(half.dot(r)), hu = abs(half.dot(u)); const uv = (p)=> vec2(.5 + p.dot(r) / (2 * hr), .5 - p.dot(u) / (2 * hu)); mesh.addStrip([a, b, c], n > 1 && na ? [na, nb, nc] : normal, [uv(a), uv(b), uv(c)]); }; // the faces, shrunk by the bevel for (const [normal, r, u] of faces) { const [a, b, c, d] = render3DQuadAxes(normal.multiply(half), r.multiply(inner), u.multiply(inner)); triangle(a, b, c); triangle(c, b, d); } // the edges, each a strip around a quarter circle from one face to the next const axes = [vec3(1, 0, 0), vec3(0, 1, 0), vec3(0, 0, 1)]; for (let k = 0; k < 3; ++k) for (const si of [-1, 1]) for (const sj of [-1, 1]) { const n1 = axes[(k + 1) % 3].scale(si), n2 = axes[(k + 2) % 3].scale(sj); const middle = n1.multiply(inner).add(n2.multiply(inner)), along = axes[k].multiply(inner); const start = middle.subtract(along), end = middle.add(along); const dir = (m)=> n1.scale(cos(m / n * PI / 2)).add(n2.scale(sin(m / n * PI / 2))); for (let m = 0; m < n; ++m) { const d0 = dir(m), d1 = dir(m + 1); const a = start.add(d0.scale(t)), b = end.add(d0.scale(t)); const c = start.add(d1.scale(t)), d = end.add(d1.scale(t)); triangle(a, b, c, d0, d0, d1); triangle(c, b, d, d1, d0, d1); } } // the corners, an eighth of a sphere in rows from one face's axis down to the edge between the other two for (const sx of [-1, 1]) for (const sy of [-1, 1]) for (const sz of [-1, 1]) { const nx = vec3(sx, 0, 0), ny = vec3(0, sy, 0), nz = vec3(0, 0, sz), corner = inner.multiply(vec3(sx, sy, sz)); const dir = (i, j)=> { const polar = i / n * PI / 2, around = i ? j / i * PI / 2 : 0; return nz.scale(cos(polar)).add(nx.scale(cos(around) * sin(polar))).add(ny.scale(sin(around) * sin(polar))); }; const point = (i, j)=> corner.add(dir(i, j).scale(t)); for (let i = 0; i < n; ++i) { for (let j = 0; j <= i; ++j) triangle(point(i, j), point(i + 1, j), point(i + 1, j + 1), dir(i, j), dir(i + 1, j), dir(i + 1, j + 1)); for (let j = 0; j < i; ++j) triangle(point(i, j), point(i + 1, j + 1), point(i, j + 1), dir(i, j), dir(i + 1, j + 1), dir(i, j + 1)); } } return mesh; } /** * Build a box centered on the origin, six flat faces with uvs covering each face * - bevel cuts its edges and corners: 1 segment is a flat chamfer, more round them, and the biggest bevel, half * the smallest side, rounds a cube into a ball * @param {Vector3|number} [size] - Full size, a number for a cube * @param {number} [bevel] - Size of the cut on each edge, clamped to half the smallest side * @param {number} [bevelSegments] - Steps around each edge, 1 for a flat chamfer * @return {Mesh} * @memberof Render3D */ function buildBox(size=1, bevel=0, bevelSegments=1) { ASSERT(isNumber(bevel) && bevel >= 0, 'bevel must be a number, 0 or more'); const half = render3DSize3(size).scale(.5); // each face: normal, right axis, up axis (right cross up = normal) const faces = [ [vec3(0, 0, 1), vec3(1, 0, 0), vec3(0, 1, 0)], [vec3(0, 0, -1), vec3(-1, 0, 0), vec3(0, 1, 0)], [vec3(1, 0, 0), vec3(0, 0, -1), vec3(0, 1, 0)], [vec3(-1, 0, 0), vec3(0, 0, 1), vec3(0, 1, 0)], [vec3(0, 1, 0), vec3(1, 0, 0), vec3(0, 0, -1)], [vec3(0, -1, 0), vec3(1, 0, 0), vec3(0, 0, 1)], ]; const t = min(bevel, half.x, half.y, half.z); if (t > 0) return render3DBevelBox(half, t, bevelSegments, faces); const mesh = new Mesh; for (const [n, r, u] of faces) { const center = n.multiply(half); const right = r.multiply(half), up = u.multiply(half); mesh.addStrip(render3DQuadAxes(center, right, up), n, RENDER3D_QUAD_UVS); } return mesh; } /** * Build a heightfield grid in the XZ plane centered on the origin * - smooth rounds the lighting across cells and colors each corner * - flat lights and colors each cell on its own, so a checkerboard stays crisp * - doubleSided, a sheet seen from both sides; turn it off for ground only ever seen from above * - One cell is a plain square, render3D.planeMesh and planeMeshDoubleSided are shared ones * @param {Vector2|number} [size] - World size along X and Z, a number for a square * @param {Vector2|number} [segments] - Cells along X and Z, a number for both * @param {Color|function(number, number): Color} [color] - One Color for the whole grid, or (x, z) => Color * @param {function(number, number): number} [heightFunction] - (x, z) => y, default flat * @param {boolean} [smooth] - Defaults to render3D.smoothShading with a heightFunction; a flat grid is flat shaded, * since its light is even anyway and smoothing would only blend its cell colors; pass smooth to blend a gradient * @return {Mesh} * @memberof Render3D * @example * const ground = buildGrid(vec2(20), 10, (x, z)=> (floor(x / 2) + floor(z / 2)) & 1 ? GRAY : WHITE); // 2 unit checks */ function buildGrid(size=vec2(1), segments=1, color, heightFunction, smooth=heightFunction && render3D?.smoothShading) { heightFunction ||= ()=> 0; size = render3DSize2(size); segments = render3DSize2(segments); ASSERT(segments.x > 0 && segments.y > 0 && segments.x % 1 === 0 && segments.y % 1 === 0, 'grid segments must be whole numbers above zero'); const mesh = new Mesh; const segmentsX = segments.x, segmentsZ = segments.y; const cellX = size.x / segmentsX, cellZ = size.y / segmentsZ; const halfX = size.x / 2, halfZ = size.y / 2, ex = cellX / 2, ez = cellZ / 2; const px = (i)=> i * cellX - halfX, pz = (j)=> j * cellZ - halfZ; const colorAt = /** @type {function(number, number): Color} */ (color); const cellColor = (i, j)=> !color ? WHITE : isColor(color) ? /** @type {Color} */ (color) : colorAt(px(i), pz(j)); // a big terrain has millions of vertices, so each one is made once, shared by the rows above and below it const row = (j)=> { const points = [], normals = [], uvs = [], colors = [], z = pz(j); for (let i = 0; i <= segmentsX; ++i) { const x = px(i); points.push(vec3(x, heightFunction(x, z), z)); uvs.push(vec2(i / segmentsX, j / segmentsZ)); if (!smooth) continue; normals.push(render3DSlopeNormal(heightFunction, x, z, ex, ez, halfX, halfZ)); colors.push(cellColor(i, j)); } return {points, normals, uvs, colors}; }; let above = row(0); for (let j = 0; j < segmentsZ; ++j) { const below = row(j + 1); if (smooth) { // one ribbon per row with vertex normals from the slope const points = [], normals = [], uvs = [], colors = []; for (let i = 0; i <= segmentsX; ++i) { points.push(above.points[i], below.points[i]); normals.push(above.normals[i], below.normals[i]); uvs.push(above.uvs[i], below.uvs[i]); colors.push(above.colors[i], below.colors[i]); } mesh.addStrip(points, normals, uvs, colors); } else { // one quad per cell with its face normal and one color sampled at its center for (let i = 0; i < segmentsX; ++i) mesh.addQuad(above.points[i], below.points[i], below.points[i+1], above.points[i+1], cellColor(i + .5, j + .5), [above.uvs[i], below.uvs[i], below.uvs[i+1], above.uvs[i+1]]); } above = below; } if (smooth) { // the grid knows which strip entries are one vertex, each shared by the rows above and below it and by the // repeats at the ends of its ribbons, so the upload of a big terrain skips searching millions of entries const n = 2 * (segmentsX + 1) + 2, keys = mesh.vertexKeys = new Int32Array(mesh.points.length); const id = (i, j)=> j * (segmentsX + 1) + i; for (let j = 0; j < segmentsZ; ++j) { const start = j * n; keys[start] = id(0, j); // the leading repeat for (let i = 0; i <= segmentsX; ++i) keys[start + 1 + 2*i] = id(i, j), keys[start + 2 + 2*i] = id(i, j + 1); keys[start + n - 1] = id(segmentsX, j + 1); // the trailing repeat } } mesh.doubleSided = true; // a sheet, seen from both sides; terrain seen only from above can turn it off return mesh; } /** * Build a sky dome: a sphere colored by direction, wound to be seen from inside * - set it as render3D.sky and the pass draws it around the camera behind everything * @param {Color} [topColor] - Straight up * @param {Color} [horizonColor] - Level with the camera * @param {Color} [bottomColor] - Straight down, what a camera looking at the ground sees past its edge; defaults to the * horizon color * @param {number} [sides] - Around * @param {number} [rings] - Top to bottom * @return {Mesh} * @memberof Render3D */ function buildSky(topColor=hsl(.6, .8, .55), horizonColor=hsl(.6, 1, .9), bottomColor=horizonColor, sides=16, rings=8) { // a sphere turned inside out so it is seen from within, each point colored by how high it is const mesh = buildSphere(2, sides, rings, true).flipNormals(); mesh.colors = mesh.points.map(p=> p.y < 0 ? horizonColor.lerp(bottomColor, -p.y) : horizonColor.lerp(topColor, p.y)); render3DSkyColors.set(mesh, [topColor.copy(), horizonColor.copy(), bottomColor.copy()]); return mesh; } // the top, horizon and bottom colors of each sky dome buildSky made, which a reflection shows while that dome is // render3D.sky, whether setSky set it or the game did const render3DSkyColors = new WeakMap; /* * LittleJS 3D Object Plugin * - EngineObject3D, an EngineObject with a 3D transform and a mesh, its collision and its level collision * - InstancedMesh3D, one mesh drawn many times in one call, and Light3D and DirectionalLight3D * - Goes after the Render3D Mesh plugin, everything here is part of the Render3D namespace */ /////////////////////////////////////////////////////////////////////////////// /** * EngineObject3D - An EngineObject with a 3D transform and a mesh * - Set pos3D, rotation3D and scale3D instead of the 2D pos, size and angle * - Gets update, children, timers, destroy and renderOrder from EngineObject * - velocity3D is added to pos3D each frame, slowed by damping, and render3D.gravity pulls it once it has a mass * - Objects face -Z, the same way the camera does, so lookAt turns them to face a point * - The 2D pos and velocity are still there but nothing draws them * - These inherited fields are 2D only and do nothing here: angle, angleVelocity, additiveColor, drawSize and mirror; * damping, angleDamping, clampSpeed, friction and groundObject work as in 2D, on velocity3D and angleVelocity3D * - The inherited shader works here as in 2D, and with emissive at 1 its snippet does its own lighting * - Set sync2D for a 2D game with 3D looks, pos and angle then drive pos3D and rotation3D, * which is the one way those 2D fields reach a 3D object * - setCollision takes the same flags as in 2D, but the solid collision happens in 3D against size3D * - The solid box turns with rotation3D, so a turned wall or a ramp collides as it looks; resting on one no steeper * than groundAngle stands there * - Its tile and raycast halves are 2D only so they default off here, and a child sits solid collision out * - A sync2D object collides in 2D instead, which needs the 2D size set as well as size3D * - setMesh swaps the mesh and frees the old one, for text and terrain that get built again * - addChild attaches the 3D transform, and pos3D becomes an offset from the parent; attach keeps the child where * it is and works the offset out, and removeChild leaves it where it was in the world * - The 2D offset arguments of addChild do nothing here, set the child's pos3D * @extends EngineObject * @memberof Render3D * @example * class Spinner extends EngineObject3D * { * constructor(pos) { super(pos, buildBox(), undefined, RED); } * update() { this.rotation3D.y += .02; } * } */ class EngineObject3D extends EngineObject { /** Create a 3D object and add it to the object list * @param {Vector3} [pos3D] - World space position * @param {Mesh} [mesh] - Mesh to draw, undefined draws nothing * @param {TileInfo|TextureInfo} [tileInfo] - Texture, mesh uvs map across the tile; a whole TextureInfo becomes * the tile that covers it * @param {Color} [color] - Tint */ constructor(pos3D=vec3(), mesh, tileInfo, color=WHITE) { ASSERT(!tileInfo || tileInfo instanceof TileInfo || tileInfo instanceof TextureInfo, 'tileInfo must be a TileInfo or TextureInfo, it comes before color'); // a whole texture is stored as the tile that covers it, with no padding or bleed to trim // the edges, so this is always a TileInfo like the 2D one and the object stays an EngineObject if (tileInfo instanceof TextureInfo) render3DWholeTiles.add(tileInfo = new TileInfo(vec2(), tileInfo.size, tileInfo, 0, 0)); // the 2D pos starts where the object is, so turning on sync2D keeps it there super(vec2(pos3D.x, pos3D.y), vec2(), tileInfo, 0, color); ASSERT(isVector3(pos3D), 'pos3D must be a vec3'); ASSERT(!mesh || mesh instanceof Mesh, 'mesh must be a Mesh or undefined'); this.mass = 0; // static: no 2D physics, and no 3D gravity until a mass is set /** @property {Vector3} - World space position, local to the parent when attached to an EngineObject3D */ this.pos3D = pos3D.copy(); /** @property {Vector3} - Rotation vec3(pitch, yaw, roll) in radians, local to the parent when attached to an * EngineObject3D */ this.rotation3D = vec3(); /** @property {Vector3} - Scale, local to the parent when attached to an EngineObject3D */ this.scale3D = vec3(1); /** @property {Vector3} - Added to pos3D each frame by the engine before update, like the 2D velocity, no super * call needed; damping and render3D.gravity act on it once the object has a mass */ this.velocity3D = vec3(); /** @property {Vector3} - Added to rotation3D each frame by the engine before update, slowed by angleDamping */ this.angleVelocity3D = vec3(); /** @property {Mesh|undefined} - Mesh to draw * @type {Mesh|undefined} */ this.mesh = mesh; /** @property {Vector3} - Size for solid collision and the collect and callback helpers, and of the sprite when * there is a tileInfo and no mesh; starts at the size of the mesh's box, or 1 with no mesh, and setMesh leaves * it as it is; the box is centered on pos3D, so center() a mesh whose origin is not its middle, like a model * standing on its feet, or set size3D; scale3D and any parent's scale grow it, so drawing and picking agree */ // a shared mesh measured since it last changed is not walked again for each object made from it const bounds = mesh && mesh.points.length ? !mesh.dirty && mesh.bounds || mesh.getBounds() : undefined; this.size3D = bounds ? bounds.max.subtract(bounds.min) : vec3(1); /** @property {number} - Diameter of a soft shadow drawn under the object on render3D.softShadowHeight, 0 for * none; scale3D and a parent's scale grow it, so set it once for the unscaled object */ this.softShadow = 0; /** @property {boolean} - A sprite stands on world up instead of tilting toward the camera */ this.upright = false; /** @property {boolean} - Keep this object's texture pixels hard edged, for pixel art that should not blur or bleed */ this.pixelated = false; /** @property {boolean} - Copy the 2D pos and angle into pos3D and rotation3D each frame, for 2D games with 3D * looks; set mass to use 2D physics, and pos3D.z stays yours to set or move with velocity3D.z */ this.sync2D = false; /** @property {boolean} - Draw in the transparent stage, blended and sorted far to near with depth writes off; * on for a sprite */ this.transparent = !mesh && !!tileInfo; /** @property {boolean} - Additive blending, in the transparent stage */ this.additive = false; /** @property {number} - How much it lights itself: 0 is lit as normal, 1 is its own color with no shading, for * lamps and glowing things, between is partly self lit, and above 1 is brighter than its color, for bloom */ this.emissive = 0; /** @property {number} - Strength of the highlight where the sun and the Light3D objects reflect, 0 is none and * 1 adds a light's full color at its brightest; shininess sets its size */ this.specular = 0; /** @property {number} - The highlight's exponent, how small and sharp it is: 4 is broad like rubber, 16 the * default, 100 sharp like polished metal; shows only with specular above 0 */ this.shininess = 16; /** @property {TextureInfo|undefined} - A normal map that bends the surface at each texel so it catches the * light like bumps and grooves, green pointing up the image as OpenGL and glTF have it; read at the color * texture's coordinates, see normalMapFromHeight to make one in code * @type {TextureInfo|undefined} */ this.normalMap = undefined; /** @property {number} - How strongly the normal map bends the surface, 0 turns it off, as glTF's scale */ this.normalScale = 1; /** @property {number} - How much it reflects the sky, 0 none and 1 a mirror of it; the edges seen at a * glancing angle reflect more either way, as water and glass do */ this.reflectivity = 0; /** @property {TextureInfo|undefined} - A texture of where it glows, added on top of the lit surface so it * shows in the dark, like lit windows; read at the color texture's coordinates * @type {TextureInfo|undefined} */ this.emissiveMap = undefined; /** @property {Color} - Multiplies the emissive map, as glTF's emissiveFactor */ this.emissiveMapColor = WHITE; /** @property {CubeMap|undefined} - What it reflects in place of render3D.environment, as a mirror captures * the scene from its own middle * @type {CubeMap|undefined} */ this.environment = undefined; /** @property {boolean} - Draw into the shadow map when render3D.shadows is on; sprites and cut out textures * cast their outline, an object faded below half its alpha casts nothing, a see through one casts only when * textured, and additive objects never cast */ this.castShadow = true; /** @property {boolean} - Collide as the sphere that fits size3D instead of as the size3D box, so it rolls * around corners */ this.collideAsSphere3D = false; /** @property {number} - The steepest slope it stands on, in radians from level, PI/4 by default: resting on * a solid within this of flat sets groundObject and holds it still, steeper it slides down */ this.groundAngle = PI / 4; /** @property {boolean} - Darkened by the shadow map when render3D.shadows is on */ this.receiveShadow = true; /** @property {boolean|undefined} - Draw this object over the 2D scene, undefined uses render3D.renderAfter2D * @type {boolean|undefined} */ this.renderAfter2D = undefined; /** @property {Matrix4|undefined} - The transform from its parent, used in place of pos3D, rotation3D and * scale3D when set, for one they cannot hold like a glTF pose with shear; read every frame it is set * @type {Matrix4|undefined} */ this.localMatrix = undefined; // the world transform, kept up to date by render3DObjectMatrix; getMatrix returns a copy this.worldMatrix = new Matrix4; this.matrixBuilt = new Float64Array(9).fill(NaN); // the position, rotation and scale it was built from this.matrixVersion = 0; // counts the rebuilds, so a child knows when its parent's changed /** @type {EngineObject3D|undefined} */ this.matrixParent = undefined; // the parent it was built under, and that parent's version then this.matrixParentVersion = 0; this.movePass = engineObjectsUpdateCount; // the engine pass a child last moved in, so a refresh is not a step this.collideFrom = vec3(); // where it was when the pass it collided in began, see render3DCollideFrom this.collideFromPass = -1; /** @type {EngineObject3D|undefined} */ this.drawOwner = undefined; // the object this one is drawn as a part of, see render3DShareSettings } /** Move by the 3D velocities and push out of solids, called automatically each frame before update, like the 2D physics * - update runs once every object has moved and collided, so bounce off anything else there, it lands before the draw * - Override this and call super to change how the object moves itself * - A sync2D object runs the 2D physics as well, and collides there instead */ updatePhysics() { // a sync2D object collides in 2D, which measures the 2D size, and that starts at zero on a 3D object ASSERT(!this.sync2D || !this.collideSolidObjects || (this.size.x && this.size.y), 'a sync2D object collides in 2D, so give it a 2D size as well as a size3D', this.size); if (this.sync2D) super.updatePhysics(); ASSERT(this.damping >= 0 && this.damping <= 1, 'damping must be 0 to 1, the fraction of velocity kept each frame', this.damping); ASSERT(this.angleDamping >= 0 && this.angleDamping <= 1, 'angleDamping must be 0 to 1, the fraction of angular velocity kept each frame', this.angleDamping); ASSERT(isNumber(this.groundAngle) && this.groundAngle >= 0 && this.groundAngle < PI / 2, 'groundAngle must be 0 to less than PI/2, a slope from level', this.groundAngle); // what it stands on is found again each frame, by the level and by the solids it rests on; a sync2D // object's is the 2D physics' const ground = this.groundObject; this.sync2D || (this.groundObject = undefined); // each axis within objectMaxSpeed, as in 2D, after gravity so no move is past it; with the push back to the // side it came from, a move that overlaps a solid at all is stopped on its side, so a fast object goes through // only one thinner than its move less its own size, as in 2D, while neither is turned; only for what collides, // anything else moves as fast as it is told // nothing of collision while setEnablePhysicsSolver is off, as in 2D, the game moves them its own way const solve = enablePhysicsSolver && !this.sync2D; const cap = this.clampSpeed && solve && (this.collideSolidObjects || this.collideLevel && this.mass) ? objectMaxSpeed : undefined; // 0 holds it still, as in 2D // a moving object keeps out of the level, the height maps and voxel maps, from where it was before it moved, // and a solid it hits sends it back to the side it came from const oldPos = (this.collideLevel || this.collideSolidObjects) && this.mass && !this.sync2D ? this.pos3D.copy() : undefined; this.collideSolidObjects && !this.sync2D && render3DCollideFrom(this); render3DMove(this, cap); if (ground && this.mass && !this.sync2D) { // sliding on what it stood on slows by friction, the less grippy of the two, relative to that one's own // speed so a moving platform carries it, as in 2D const friction = max(this.friction, ground.friction), v = this.velocity3D; const moving = ground instanceof EngineObject3D ? ground.velocity3D : undefined; const gx = moving?.x ?? 0, gz = moving?.z ?? 0; v.x = gx + (v.x - gx) * friction, v.z = gz + (v.z - gz) * friction; } oldPos && this.collideLevel && solve && render3DCollideLevel(this, oldPos, ground); // the engine only runs this for objects that own where they are, a child rides along with its parent if (this.collideSolidObjects && solve) render3DCollideSolid(this); } /** Move a child by its own velocities, bring a sync2D object's pos3D up to its 2D pos, then update the children, * called automatically each frame * @param {boolean} [updateChildren] - Also update the children's transforms */ updateTransforms(updateChildren=true) { if (!paused) { // a child is never given updatePhysics, so it moves here, as an offset from its parent: once per // engine pass, since addChild, attach and a game bring the transforms up to date too if (this.parent && this.movePass !== engineObjectsUpdateCount) { this.movePass = engineObjectsUpdateCount; render3DMove(this); } } // placed from its parent first, so a sync2D object copies where it is now, then its children from it super.updateTransforms(false); if (!paused && this.sync2D) this.pos3D.x = this.pos.x, this.pos3D.y = this.pos.y, this.rotation3D.z = -this.angle; if (updateChildren) for (const child of this.children) child.updateTransforms(); } /** Set how this object collides, the same flags as in 2D * - Solid collision happens in 3D here, against size3D boxes or spheres; a child sits it out * - The boxes turn with rotation3D, against height maps and voxel maps an object is still its upright box * - A sync2D object collides in 2D instead, against the 2D size, so set that as well as size3D * @param {boolean} [collideSolidObjects] - Take part in solid collision * @param {boolean} [isSolid] - Block other objects, a pair where neither one blocks passes through; * blocking needs collideSolidObjects, so isSolid on its own is not allowed * @param {boolean} [collideLevel] - Collide with the level, the height maps and voxel maps, or the 2D tile layers * for a sync2D object * @param {boolean} [collideRaycast] - Raycasts, 2D only; 3D has render3D.pick and engineObjectsRaycast3D */ setCollision(collideSolidObjects=true, isSolid=true, collideLevel=true, collideRaycast=false) { super.setCollision(collideSolidObjects, isSolid, collideLevel, collideRaycast); } /** Called by a VoxelMap to ask whether a block stops this object, a hook to let one through or react to it * @param {number} type - The block's type, 1 to 255 * @param {Vector3} cell - The block's cell in the map * @return {boolean} - true to be stopped by it, every block stops it by default */ collideWithVoxel(type, cell) { return true; } /** Returns the world position * @return {Vector3} */ getWorldPos3D() { return render3DObjectMatrix(this).getTranslation(); } /** Returns the direction the object faces, its -Z axis in the world * @return {Vector3} */ getForward3D() { return render3DAxis(render3DObjectMatrix(this).m, 8).normalize(-1); } /** Returns the object's right axis in the world * @return {Vector3} */ getRight3D() { return render3DAxis(render3DObjectMatrix(this).m, 0).normalize(); } /** Returns the object's up axis in the world * @return {Vector3} */ getUp3D() { return render3DAxis(render3DObjectMatrix(this).m, 4).normalize(); } /** Returns a copy of the object's world transform, the parent's included when attached to an EngineObject3D * - The object keeps its matrix and rebuilds it only when its position, rotation or scale changed, so this is * cheap to call * @return {Matrix4} */ getMatrix() { return render3DObjectMatrix(this).copy(); } /** How rough the surface is, 0 a mirror to 1 matte, as glTF and three.js's MeshStandardMaterial have it: another * way to set shininess, which is what is kept, shininess = 2 / roughness^4 - 2 and at least 1; it sets both the * highlight and how blurred a reflection is; read back it is the same up to about .9, above which shininess is 1 * and it reads .9 * @return {number} */ get roughness() { return (2 / (this.shininess + 2)) ** .25; } set roughness(roughness) { ASSERT(isNumber(roughness) && roughness >= 0, 'roughness must be a number, 0 or more', roughness); this.shininess = max(1, 2 / max(roughness, 1e-3) ** 4 - 2); // none is a mirror, still a finite number } /** Turn the object so its -Z axis points at a world space target, sets pitch and yaw and clears roll * @param {Vector3} target */ lookAt(target) { // rotation3D is local to the parent, so a child has to aim at the target from the parent's point of view const parent = this.parent instanceof EngineObject3D ? this.parent : undefined; const local = parent ? parent.getMatrix().invert().transformPoint(target) : target; this.rotation3D = render3DLookRotation(local.subtract(this.pos3D), this.rotation3D); } /** Attaches a child, its pos3D, rotation3D and scale3D taken as the offset from this one; returns child for chaining * - The 2D offset arguments do nothing for an EngineObject3D child, set its pos3D * @param {EngineObject} child * @param {Vector2} [localPos] * @param {number} [localAngle] * @return {EngineObject} The child object attached */ addChild(child, localPos, localAngle) { // addChild brings the child's transform up to date, which is not a step: it already moved this pass as // a root, or moves in the next one, so it must not move by its velocity again here if (child instanceof EngineObject3D) child.movePass = engineObjectsUpdateCount; return super.addChild(child, localPos, localAngle); } /** Attaches a child without moving it: its pos3D, rotation3D and scale3D become what they have to be under this * parent to keep its world transform, where addChild takes them as the offset; returns child for chaining * - A parent scaled unevenly and a child turned under it make a shear, which those three values cannot hold, * so the child comes out as close as they can get; a uniform scale is exact * @param {EngineObject} child * @return {EngineObject} The child object attached */ attach(child) { if (!(child instanceof EngineObject3D)) return super.attach(child); // a 2D child only has the 2D transform to keep child.parent?.removeChild(child); // keeps its world values, so its own matrix is its world matrix const local = render3DObjectMatrix(this).copy().invert().multiply(render3DObjectMatrix(child)); super.attach(child); child.pos3D = local.getTranslation(); child.rotation3D = local.getRotation(); child.scale3D = local.getScale(); if (child.localMatrix) child.localMatrix = local; // a matrix given whole stays whole, shear and all return child; } /** Removes a child from this one, it stays where it is in the world: its pos3D, rotation3D and scale3D become * its world values, with the same shear caveat as attach; a child being destroyed is let go as it is * @param {EngineObject} child */ removeChild(child) { if (child instanceof EngineObject3D && !child.destroyed) render3DTakeWorld(child, render3DObjectMatrix(child)); if (child instanceof EngineObject3D && child.drawOwner === this) child.drawOwner = undefined; // a part taken off draws with its own settings super.removeChild(child); } /** Draw a different mesh and free the GPU buffer of the one it replaces * - For a mesh built again when something changes, like a score, a rebuilt terrain or a loaded model * - A mesh another object is still drawing is left alone, since builders are often shared * - Freeing one held somewhere else only costs it an upload, the points it was built from stay * @param {Mesh} [mesh] - The mesh to draw from now on, undefined to draw nothing * @return {Mesh|undefined} - The mesh passed in */ setMesh(mesh) { ASSERT(!mesh || mesh instanceof Mesh, 'mesh must be a Mesh or undefined'); const old = this.mesh; this.mesh = mesh; // nothing to free and nothing to look for when it was never uploaded if (old && old !== mesh && old.buffer && !engineObjects.some(o=> /** @type {EngineObject3D} */ (o).mesh === old)) old.dispose(); return mesh; } /** 2D rendering is skipped, the mesh is drawn by render3D during the 3D pass */ render() {} /** Draw the object in 3D, called by the 3D pass with the draw state set from this object's flags, draws the mesh by default * @return {void} */ render3D() { // an opaque draw comes out solid however low its alpha is, so a fade with no flag looks like nothing happened ASSERT(this.transparent || render3DSetting(this, 'additive') || this.color.a >= 1, // a part, its owner's 'an object that fades needs its transparent flag, an opaque draw ignores the color alpha', this.color); // the matrix the object keeps, rebuilt only when it moved, the same one for the shadow pass and the main pass const matrix = render3DObjectMatrix(this); if (this.mesh) render3D.drawMesh(this.mesh, matrix, this.tileInfo, this.color); else if (this.tileInfo) { // a sprite, at the world size the collect, pick and solid collision helpers measure it at const m = matrix.m, size = render3DWorldSize(this, m); render3D.drawBillboard(vec3(m[12], m[13], m[14]), vec2(size.x, size.y), this.tileInfo, this.color, this.rotation3D.z, this.upright); } } } // the draw settings an object hands to the parts it draws through, each with its default: a glTF model's parts, an // attached prefab's, a voxel map's see through blocks const RENDER3D_PART_SETTINGS = {emissive: 0, additive: false, specular: 0, shininess: 16, reflectivity: 0, normalMap: undefined, normalScale: 1, emissiveMap: undefined, emissiveMapColor: WHITE, receiveShadow: true, castShadow: true, pixelated: false, shader: undefined, environment: undefined, renderAfter2D: undefined, renderOrder: 0}; // make a part draw with its owner's settings: it draws with the owner's where the owner set it away from the default, // and its own otherwise, so a model's unlit or rough part keeps what its file gave it; a link the renderer follows // where it reads them (render3DSetting), so a setting changed later is seen, before the stage is chosen too, and the // part keeps its own values as plain properties, which keeps its shape as fast as any object's function render3DShareSettings(part, owner) { part.drawOwner = owner; } // the value of a draw setting an object draws with: for a part, its owner's where that is set, through every owner // up, and its own otherwise function render3DSetting(o, name) { const owner = o.drawOwner; if (owner) { const value = render3DSetting(owner, name); if (value !== RENDER3D_PART_SETTINGS[name]) return value; } return o[name]; } // a part's draw settings gathered for the draw state, in one object kept for it, so nothing is made per draw const render3DPartScratch = {...RENDER3D_PART_SETTINGS}; function render3DPartSettings(o) { for (const name in RENDER3D_PART_SETTINGS) render3DPartScratch[name] = render3DSetting(o, name); return render3DPartScratch; } // make an object's own transform a world one, for an object leaving its parent: its position, rotation and scale // from the world matrix, and a matrix given whole, shear and all function render3DTakeWorld(o, world) { o.pos3D = world.getTranslation(); o.rotation3D = world.getRotation(); o.scale3D = world.getScale(); if (o.localMatrix) o.localMatrix = world.copy(); } // an object's world matrix, the one it keeps: rebuilt only when its position, rotation or scale changed since the // last build, or its parent's matrix did, so an object that stands still costs nine compares a frame instead of // the trig and a new matrix; the matrix returned is the object's own, read it and never change it const render3DLocalMatrix = new Matrix4; function render3DObjectMatrix(o) { const parent = o.parent instanceof EngineObject3D ? o.parent : undefined; const parentMatrix = parent && render3DObjectMatrix(parent); // the parent first, so its version is current const p = o.pos3D, r = o.rotation3D, s = o.scale3D, k = o.matrixBuilt, local = o.localMatrix; if (local) { // a matrix given whole is taken as it is each time, it can change in place if (parent) { o.worldMatrix.m.set(parentMatrix.m); o.worldMatrix.multiply(local); o.matrixParentVersion = parent.matrixVersion; } else o.worldMatrix.m.set(local.m); k[0] = NaN; // built from the matrix, so going back to pos3D builds again o.matrixParent = parent; ++o.matrixVersion; } else if (k[0] !== p.x || k[1] !== p.y || k[2] !== p.z || k[3] !== r.x || k[4] !== r.y || k[5] !== r.z || k[6] !== s.x || k[7] !== s.y || k[8] !== s.z || o.matrixParent !== parent || parent && o.matrixParentVersion !== parent.matrixVersion) { k[0] = p.x, k[1] = p.y, k[2] = p.z, k[3] = r.x, k[4] = r.y, k[5] = r.z, k[6] = s.x, k[7] = s.y, k[8] = s.z; if (parent) { // the parent's world matrix times the local one buildMatrix(p, r, s, render3DLocalMatrix); o.worldMatrix.m.set(parentMatrix.m); o.worldMatrix.multiply(render3DLocalMatrix); o.matrixParentVersion = parent.matrixVersion; } else buildMatrix(p, r, s, o.worldMatrix); o.matrixParent = parent; ++o.matrixVersion; } return o.worldMatrix; } // move an object by its 3D velocities, each slowed by its damping, an object with mass falling with render3D.gravity function render3DMove(o, cap) { // the vectors change in place, as the 2D object's do: this runs for every object every frame // a sync2D object's damping is the 2D physics', its 3D velocities are its own to set const p = o.pos3D, v = o.velocity3D, r = o.rotation3D, a = o.angleVelocity3D; const d = o.sync2D ? 1 : o.damping, e = o.sync2D ? 1 : o.angleDamping; // damped first and gravity added after, the order EngineObject.updatePhysics uses, // so the same mass, damping and gravity fall the same way in both v.x *= d, v.y *= d, v.z *= d; if (o.mass && !o.sync2D) // a 2D driven object gets the 2D gravity instead { const g = render3D.gravity, s = o.gravityScale; v.x += g.x * s, v.y += g.y * s, v.z += g.z * s; } if (cap !== undefined) // the speed cap of what collides, after gravity, as in 2D v.x = clamp(v.x, -cap, cap), v.y = clamp(v.y, -cap, cap), v.z = clamp(v.z, -cap, cap); p.x += v.x, p.y += v.y, p.z += v.z; r.x += a.x *= e, r.y += a.y *= e, r.z += a.z *= e; } // keep an object that moved out of the level's solid geometry, clearing what it stood on for the level to set again // ground is what it stood on last frame, which a height map keeps it on going down a slope function render3DCollideLevel(o, oldPos, ground) { for (const level of render3DLevel) level.destroyed || level.levelCollide3D(o, oldPos, level === ground); } // where a solid object is in the world and what it collides as: the sphere that fits size3D, or the size3D box, // each grown by the object's scale // only objects that own where they are take part, so pos3D is already world space, and however the object is // turned its axes come out as long as its scale makes them; building the transform to read that back off it // costs six trig calls and a matrix for every pair tested, which is the whole cost of a crowded scene function render3DSolidShape(o) { ASSERT(!o.parent, 'a child rides along with its parent, it has no world pos3D of its own to collide with'); const s = o.size3D, k = o.scale3D; const kx = abs(k.x), ky = abs(k.y), kz = abs(k.z); if (o.collideAsSphere3D) return {pos: o.pos3D.copy(), radius: max(s.x, s.y, s.z) / 2 * max(kx, ky, kz)}; // a turned box has its axes, a solid is never a child so its rotation is the world's; an upright one has none, and // neither does a sprite, whose rotation turns how it faces the camera, not its box const axes = !render3DIsSprite(o) && isTurned3D(o.rotation3D) ? render3DSolidAxes(o) : undefined; return {pos: o.pos3D.copy(), size: vec3(s.x * kx, s.y * ky, s.z * kz), axes}; } // a turned solid's three axes, worked out when its rotation changes and kept until then, so the pairs it is tested // against each frame do not each work them out again const render3DSolidAxesCache = new WeakMap; function render3DSolidAxes(o) { const r = o.rotation3D, kept = render3DSolidAxesCache.get(o); if (kept && kept.x === r.x && kept.y === r.y && kept.z === r.z) return kept.axes; const axes = boxAxes3D(r); render3DSolidAxesCache.set(o, {x: r.x, y: r.y, z: r.z, axes}); return axes; } // how far a solid shape can reach from its own center, for a quick reject before the exact test // it has to be the shape's own radius, or a wider one: a box reaches to its corner, and a sphere // takes the largest scale the same way render3DSolidShape does, or the reject would skip real touches function render3DSolidReach(o) { const s = o.size3D, k = o.scale3D; const kx = abs(k.x), ky = abs(k.y), kz = abs(k.z); if (o.collideAsSphere3D) return max(s.x, s.y, s.z) / 2 * max(kx, ky, kz); const x = s.x * kx, y = s.y * ky, z = s.z * kz; return (x*x + y*y + z*z) ** .5 / 2; // written out, it is asked for every pair each frame } // what it takes to move shape a clear of shape b, whichever pair of shapes they are, or undefined for no touch function render3DSolidPush(a, b) { // a turned box is tested by its kept axes, the upright pairs as they always were const sphereBox = (sphere, box)=> box.axes ? collideSphereOrientedBox3D(sphere.pos, sphere.radius, box.pos, box.size, box.axes) : collideSphereBox(sphere.pos, sphere.radius, box.pos, box.size); if (!a.size) // a is a sphere return b.size ? sphereBox(a, b) : collideSphereSphere(a.pos, a.radius, b.pos, b.radius); if (!b.size) // only b is, so push b out of a and turn it around { const push = sphereBox(b, a); return push && push.scale(-1); } return a.axes || b.axes ? collideOrientedBoxes3D(a.pos, a.size, a.axes ?? BOX_WORLD_AXES, b.pos, b.size, b.axes ?? BOX_WORLD_AXES) : collideBoxBox3D(a.pos, a.size, b.pos, b.size); } // whether a touching pair resolves: both hear about it, and the answer is kept for the frame, so a pair met again as // one object is settled after being pushed is not asked twice; an object's own turn asks again, as it always has, // unless a settle asked that pair already this pass (render3DSettleAsked), so a wall hears about it once a frame function render3DCollideAsk(a, b, push, useKept=true) { const kept = useKept || render3DSettleAsked(a, b) ? engineObjectsCollidePairAnswer(a, b) : undefined; if (kept !== undefined) return kept; const resolveA = a.collideWithObject(b, push), resolveB = b.collideWithObject(a, push.scale(-1)); const resolve = !!(resolveA && resolveB); engineObjectsCollidePairAdd(a, b, resolve); engineObjectsCollidePairAdd(b, a, resolve); useKept && render3DSettleAsked(a, b, true); return resolve; } // the pairs a settle asked this pass, each object to the others; asked with mark set, it marks the pair const render3DSettleAskedPairs = {pass: -1, pairs: new Map}; function render3DSettleAsked(a, b, mark=false) { const kept = render3DSettleAskedPairs; if (kept.pass !== engineObjectsUpdateCount) { if (!mark) return false; kept.pass = engineObjectsUpdateCount; kept.pairs.clear(); } if (mark) { for (const [one, other] of [[a, b], [b, a]]) { const set = kept.pairs.get(one); set ? set.add(other) : kept.pairs.set(one, new Set([other])); } return true; } return !!kept.pairs.get(a)?.has(b); } // where a solid was when this pass of the engine began, taken at the start of its own updatePhysics, or the first time // a pair needs it if that comes first, as for one pushed or a mass 0 one not updated yet, so a // pair resolved in the turn of either one knows where both came from function render3DCollideFrom(o) { if (o.collideFromPass !== engineObjectsUpdateCount) { o.collideFromPass = engineObjectsUpdateCount; o.collideFrom.set(o.pos3D.x, o.pos3D.y, o.pos3D.z); } return o.collideFrom; } // a push that would carry a on the way it is going relative to b, out the far side past b's middle, as a fast object // goes through a thin wall or two movers through each other, is turned back to the side a came from, as 2D resolves // from where an object was; both go by where they were when the pass began, so it is the same in either one's turn function render3DPushFrom(shapeA, shapeB, a, b, push) { const aFrom = render3DCollideFrom(a), bFrom = render3DCollideFrom(b), pa = a.pos3D, pb = b.pos3D; const mx = pa.x - aFrom.x - pb.x + bFrom.x, my = pa.y - aFrom.y - pb.y + bFrom.y, mz = pa.z - aFrom.z - pb.z + bFrom.z; if (push.x * mx + push.y * my + push.z * mz <= 0) return push; return render3DSolidPushBack(shapeA, shapeB, aFrom, bFrom) ?? push; } // the solids that do not move, mass 0, in the order of the engine's list, gathered once a pass for the settles, which // would otherwise walk every collider for each push between two movers const render3DFixed = {pass: -1, list: []}; function render3DFixedSolids() { if (render3DFixed.pass !== engineObjectsUpdateCount) { render3DFixed.pass = engineObjectsUpdateCount; render3DFixed.list = engineObjectsCollide.filter((b)=> !b.mass && b instanceof EngineObject3D && !b.parent && !b.sync2D); } return render3DFixed.list; } // push an object pushed by another out of the solids that do not move, mass 0, back to the side it came from; // they take the touch as any pair does, so a one way platform still lets it through; not the one that pushed it, which // already did, and a push of a rounding error is none, or a box on a turned ramp would be pushed along its slant; // from is where it was before the push function render3DSettleFixed(o, pusher, from) { if (o.destroyed || !o.collideSolidObjects) return; let shape = render3DSolidShape(o); const reachO = render3DSolidReach(o); for (const b of render3DFixedSolids()) { // a pair where neither is solid passes through, as in the pair loop; a solid against one that is not still meets if (b === o || b === pusher || b.mass || b.destroyed || !o.isSolid && !b.isSolid) continue; const p = shape.pos, q = b.pos3D, reach = reachO + render3DSolidReach(b); const dx = p.x - q.x, dy = p.y - q.y, dz = p.z - q.z; if (dx*dx + dy*dy + dz*dz > reach*reach) continue; const shapeB = render3DSolidShape(b); let push = render3DSolidPush(shape, shapeB); if (!push || push.lengthSquared() < 1e-12) continue; push = render3DPushFrom(shape, shapeB, o, b, push); if (!render3DCollideAsk(o, b, push)) continue; // standing as in a pair: a push off a face within its groundAngle of up holds it there, turned straight up const lengthSquared = push.lengthSquared(), up = push.y / lengthSquared ** .5; if (up > 0 && o.mass && up >= cos(o.groundAngle) && render3DOnFace(push, shapeB)) { o.groundObject = b; if (push.x || push.z) push = vec3(0, lengthSquared / push.y, 0); } o.pos3D = o.pos3D.add(push); shape = render3DSolidShape(o); const normal = push.normalize(); if (o.velocity3D.dot(normal) < 0) o.velocity3D = o.velocity3D.reflect(normal, o.restitution); } // and out of the level, the voxel and height maps, from where it was before the push, so a move of the game's own, // as a collision callback that sends it away, is not swept back across the level if (o.collideLevel && o.mass) render3DCollideLevel(o, from, undefined); } // what moves shape a back clear of shape b to the side it came from: along an axis the two were clear on where they // came from, the least of those, as 2D resolves it from where the object was; undefined when they overlapped on every // axis there too, or a box is turned, which keeps the least push function render3DSolidPushBack(a, b, aFrom, bFrom) { if (a.axes || b.axes) return; const half = (shape)=> shape.size ? shape.size.scale(.5) : vec3(shape.radius); const halfA = half(a), halfB = half(b); let axis, amount = Infinity, side = 0; for (const k of ['x', 'y', 'z']) { const reach = halfA[k] + halfB[k], was = aFrom[k] - bFrom[k]; // it overlapped on this axis before it moved as well; one resting against it touches, which is clear, give or // take the rounding of the push that put it there if (abs(was) < reach - 1e-6) continue; const s = sign(was), need = reach - s * (a.pos[k] - b.pos[k]); if (need > 0 && need < amount) axis = k, amount = need, side = s; } if (!axis) return; const push = vec3(); push[axis] = side * amount; return push; } // a sprite, a tile with no mesh, which faces the camera however it is turned const render3DIsSprite = (o)=> !o.mesh && !!o.tileInfo; // whether a push leaves a box by one of its faces, along one of its axes, not by an edge or a corner; a sphere has // no face to stand on function render3DOnFace(push, shape) { if (!shape.size) return false; const n = push.normalize(), axes = shape.axes ?? BOX_WORLD_AXES; return max(abs(n.dot(axes[0])), abs(n.dot(axes[1])), abs(n.dot(axes[2]))) > 1 - 1e-6; } // push a solid object out of the solids before it in the engine's list of them, so each pair is resolved once: // the ones after it update later and test against it then, and an object that is not in the list yet, because it // turned collision on this frame, tests them all itself and is not tested back // one pair per test is half the work of the 2D solver, which tests both directions; the difference only shows // when a collideWithObject destroys some third object, whose own turn then finds the pair already gone function render3DCollideSolid(a) { let shapeA = render3DSolidShape(a); const reachA = render3DSolidReach(a); for (const b of engineObjectsCollide) { if (b === a) break; // a child is part of its parent if (b.destroyed || !(b instanceof EngineObject3D) || b.parent || b.sync2D) continue; if (!a.isSolid && !b.isSolid) continue; // neither one blocks, so they pass through each other // the pairs nowhere near each other are almost all of them in a scene of any size, so // settle those with one distance check instead of building a shape for each const p = shapeA.pos, q = b.pos3D, reach = reachA + render3DSolidReach(b); const dx = p.x - q.x, dy = p.y - q.y, dz = p.z - q.z; if (dx*dx + dy*dy + dz*dz > reach*reach) continue; const shapeB = render3DSolidShape(b); let push = render3DSolidPush(shapeA, shapeB); if (!push) continue; push = render3DPushFrom(shapeA, shapeB, a, b, push); // both objects hear about it, and either one can take the touch over if (!render3DCollideAsk(a, b, push, false)) continue; // standing: resting on a box's face within the upper one's groundAngle of level holds it there, the push // turned straight up, as far as it takes to leave the surface, so it does not creep down a ramp; only what // moves stands, and a sphere, an edge or a corner is nothing to stand on, what rests there rolls off const lengthSquared = push.lengthSquared(), up = push.y / lengthSquared ** .5; const aStands = up > 0 && a.mass && up >= cos(a.groundAngle) && render3DOnFace(push, shapeB); const bStands = up < 0 && b.mass && -up >= cos(b.groundAngle) && render3DOnFace(push, shapeA); if (aStands || bStands) { aStands ? a.groundObject = b : b.groundObject = a; if (push.x || push.z) // one straight up already is used as it is push = vec3(0, lengthSquared / push.y, 0); } // heavier objects move less, mass 0 stays put; then bounce apart when moving toward each other const total = a.mass + b.mass; const weightA = !a.mass ? 0 : !b.mass ? 1 : b.mass / total; const weightB = !b.mass ? 0 : !a.mass ? 1 : a.mass / total; const fromA = a.pos3D.copy(), fromB = b.pos3D.copy(); // where the push moves them from, for the settles a.pos3D = a.pos3D.add(push.scale(weightA)); b.pos3D = b.pos3D.subtract(push.scale(weightB)); // mass 0 keeps its velocity too, so a moving platform keeps moving, and what hits it bounces by its own // restitution as it would off a static wall const normal = push.normalize(); if (weightA && a.velocity3D.dot(normal) < 0) a.velocity3D = a.velocity3D.reflect(normal, a.restitution); if (weightB && b.velocity3D.dot(normal) > 0) b.velocity3D = b.velocity3D.reflect(normal, b.restitution); // the settles come after the bounce, so a wall's push is the last word on the velocity if (weightA && weightB) { // a met the solids before it in the list already, the fixed ones among them, so a push from something // that moves is settled against those now, and b takes what a could not move, as if a were fixed const pushed = a.pos3D.copy(); render3DSettleFixed(a, b, fromA); b.pos3D = b.pos3D.add(a.pos3D.subtract(pushed)); } if (weightB) { // b had its turn already, so a push into a wall is settled against the fixed solids now, or it would end // the frame in the wall, and a hard enough shove would carry it through; then a takes what b could not // move, as a pusher stops against a crate held by a wall, and is settled itself, squeezed at worst const pushed = b.pos3D.copy(); render3DSettleFixed(b, a, fromB); const backX = b.pos3D.x - pushed.x, backY = b.pos3D.y - pushed.y, backZ = b.pos3D.z - pushed.z; if (weightA && (backX || backY || backZ)) { a.pos3D = a.pos3D.add(vec3(backX, backY, backZ)); render3DSettleFixed(a, b, fromA); } } if (weightA) shapeA = render3DSolidShape(a); // it moved, so the next solid must be tested against where it is now } } /** * Collect the EngineObject3D objects whose boxes overlap a sphere or a box, the 3D twin of engineObjectsCollect * - Each object is its size3D box at its world position, turned as it is turned; lights, emitters and trails have no * size and are never collected * @param {Vector3} pos - Center of the area * @param {Vector3|number} size - Diameter of a sphere if a number, 0 for a point, full size of a box if a Vector3 * @param {Array} [objects] - Defaults to every object * @param {boolean} [testCenters] - Test only each object's center, a little faster, and ignores object sizes * @return {Array} * @memberof Render3D */ function engineObjectsCollect3D(pos, size, objects=engineObjects, testCenters=false) { // a size of 0 is a point, tested against each box, since a sphere of no size could never hit const radiusSquared = typeof size === 'number' && size > 0 ? (size/2)**2 : undefined; const box = radiusSquared ? undefined : typeof size === 'number' ? vec3() : render3DSize3(size); const collected = []; for (const o of objects) { if (!(o instanceof EngineObject3D) || o.destroyed) continue; // the box in world space, scaled by the object and its parents const m = render3DObjectMatrix(o).m, s = o.size3D; if (!(s.x || s.y || s.z)) continue; const center = vec3(m[12], m[13], m[14]); const worldSize = testCenters ? vec3() : render3DWorldSize(o, m); // a turned box is tested as turned, its axes read off the matrix, its columns with the scale taken out const turned = !testCenters && !render3DIsSprite(o) && (m[1] || m[2] || m[4] || m[6] || m[8] || m[9]); const axis = (i)=> vec3(m[i], m[i+1], m[i+2]).normalize(); let axes = turned ? [axis(0), axis(4), axis(8)] : undefined; if (axes && (abs(axes[0].dot(axes[1])) > 1e-6 || abs(axes[0].dot(axes[2])) > 1e-6 || abs(axes[1].dot(axes[2])) > 1e-6)) { // a turned child of an unevenly scaled parent is sheared, its edges no longer square, which the box // tests do not take; the upright box around it stands in, which may take a little more but never misses const e = axes.map((a, i)=> a.scale(i ? i > 1 ? worldSize.z / 2 : worldSize.y / 2 : worldSize.x / 2)); const reach = (c)=> 2 * (abs(e[0][c]) + abs(e[1][c]) + abs(e[2][c])); worldSize.set(reach('x'), reach('y'), reach('z')); axes = undefined; } let hit; if (box && axes) hit = !!collideOrientedBoxes3D(pos, box, BOX_WORLD_AXES, center, worldSize, axes); else if (box) hit = isOverlapping3D(pos, box, center, worldSize); else if (axes) hit = !!collideSphereOrientedBox3D(pos, radiusSquared ** .5, center, worldSize, axes); else { // a sphere against the nearest point of the box const dx = max(abs(pos.x - center.x) - worldSize.x/2, 0); const dy = max(abs(pos.y - center.y) - worldSize.y/2, 0); const dz = max(abs(pos.z - center.z) - worldSize.z/2, 0); hit = dx*dx + dy*dy + dz*dz < radiusSquared; } hit && collected.push(o); } return collected; } // how far along a ray an object is hit, or undefined for a miss; a mesh is hit on its triangles, in its own space, // and a sprite as the quad it draws const render3DRaycastCenter = vec3(); function render3DRaycastObject(ray, o) { if (o.destroyed || !(o instanceof EngineObject3D)) return; // a height map or voxel map is hit on its surface, not its box if (o instanceof HeightMap || o instanceof VoxelMap) return o.levelRaycast3D(ray); if (!(o.mesh || o.tileInfo)) return; if (o instanceof InstancedMesh3D) return; // its instances are not objects, and its one sphere is not a thing to hit const matrix = render3DObjectMatrix(o), mesh = o.mesh; if (!mesh) return render3DRaycastSprite(ray, o, matrix); // a mesh that changed since it was measured is measured again, an upload may not have come yet const radius = (mesh.dirty || !mesh.radius ? mesh.computeRadius() : mesh.radius) * render3DMaxStretch(matrix.m); if (!(radius > 0)) return; // nothing to hit // its place read from the matrix into a vector kept for this, a pick tests every object and most miss const m = matrix.m, center = render3DRaycastCenter.set(m[12], m[13], m[14]); const distance = raycastSphere(ray, center, radius); if (distance === undefined) return; // the sphere and then the box in the mesh's own space are quick rejects, and a ray that meets the box is tested // against the triangles; the direction is not made unit length, so the distance holds in the world; // a mesh flattened to nothing on an axis has no inverse, it is hit as a disc like a sprite if (!matrix.determinant()) return render3DRaycastDisc(ray, center, radius); const inverse = matrix.copy().invert(), bounds = mesh.bounds || mesh.getBounds(); const local = new Ray3D(inverse.transformPoint(ray.origin), inverse.transformDirection(ray.direction)); const hit = raycastBox(local, bounds.min.add(bounds.max).scale(.5), bounds.max.subtract(bounds.min)); if (hit === undefined || !ray.direction.lengthSquared()) return hit; // a ray of no length is where it starts, inside the box return render3DRaycastMesh(local, mesh); } // how far along a ray, in the mesh's own space, its nearest triangle is hit, or undefined: the triangles that face // the ray as the pass draws them, clockwise from the front, and the back faces too of a doubleSided mesh; a strip's // triangle i is (i-2, i-1, i), the odd ones read the other way, and a join between its pieces has no area and is // never hit; nothing is kept between calls, so a mesh that changed is hit as it is now function render3DRaycastMesh(ray, mesh) { const points = mesh.points, indices = mesh.indices, both = mesh.doubleSided; const ox = ray.origin.x, oy = ray.origin.y, oz = ray.origin.z; const dx = ray.direction.x, dy = ray.direction.y, dz = ray.direction.z; const count = indices ? indices.length / 3 | 0 : points.length - 2; let nearest; for (let i = 0; i < count; ++i) { // the corners clockwise from the front: an indexed list is written the other way round let a, b, c; if (indices) a = points[indices[i*3]], b = points[indices[i*3+2]], c = points[indices[i*3+1]]; else a = points[i & 1 ? i + 1 : i], b = points[i & 1 ? i : i + 1], c = points[i + 2]; // Moller and Trumbore: the determinant is the ray along the face's normal, below zero from the front const e1x = b.x - a.x, e1y = b.y - a.y, e1z = b.z - a.z; const e2x = c.x - a.x, e2y = c.y - a.y, e2z = c.z - a.z; const hx = dy * e2z - dz * e2y, hy = dz * e2x - dx * e2z, hz = dx * e2y - dy * e2x; const det = e1x * hx + e1y * hy + e1z * hz; if (both ? !det : !(det < 0)) continue; const sx = ox - a.x, sy = oy - a.y, sz = oz - a.z; const u = (sx * hx + sy * hy + sz * hz) / det; if (!(u >= 0 && u <= 1)) continue; const qx = sy * e1z - sz * e1y, qy = sz * e1x - sx * e1z, qz = sx * e1y - sy * e1x; const v = (dx * qx + dy * qy + dz * qz) / det; if (!(v >= 0 && u + v <= 1)) continue; const t = (e2x * qx + e2y * qy + e2z * qz) / det; if (t >= 0 && !(t >= nearest)) nearest = t; } return nearest; } // a sprite is the quad drawBillboard draws, its size3D grown by the x and y scale as the draw does, facing the camera // as it was last drawn; where the ray meets its plane, inside its half axes function render3DRaycastSprite(ray, o, matrix) { const center = matrix.getTranslation(), worldSize = render3DWorldSize(o, matrix.m), size = vec2(worldSize.x, worldSize.y); if (!(size.x > 0 && size.y > 0)) return; // nothing to hit if (!ray.direction.lengthSquared()) // a ray of no length is where it starts return render3DRaycastDisc(ray, center, hypot(size.x, size.y) / 2); const a = render3DBillboardAxes(size, o.rotation3D.z, o.upright); const right = vec3(a[0], a[1], a[2]), up = vec3(a[3], a[4], a[5]); const t = raycastPlane(ray, center, right.cross(up)); if (t === undefined) return; // behind, or seen edge on const p = ray.getPosition(t).subtract(center); return abs(p.dot(right)) <= right.lengthSquared() && abs(p.dot(up)) <= up.lengthSquared() ? t : undefined; } // a mesh flattened to nothing, or a sprite for a ray of no length: a disc facing the ray at its center's depth function render3DRaycastDisc(ray, center, radius) { const d = ray.direction, oc = center.subtract(ray.origin), dd = d.dot(d); const t = dd ? oc.dot(d) / dd : 0; // its depth along the ray, in the ray's own units return t >= 0 && oc.subtract(d.scale(t)).lengthSquared() <= radius*radius ? t : undefined; } /** * Collect every EngineObject3D a ray passes through, nearest first, the 3D twin of engineObjectsRaycast * - The ray has no end, so everything along it counts however far away it is * - Use render3D.pick for the nearest one on its own, with the distance to it * @param {Ray3D} ray - From render3D.screenToRay, or any ray * @param {Array} [objects] - Defaults to every object; only those with a mesh or a sprite count * @return {Array} * @memberof Render3D */ function engineObjectsRaycast3D(ray, objects=engineObjects) { const hits = []; for (const o of objects) { const distance = render3DRaycastObject(ray, o); if (distance !== undefined) // only a 3D object has a distance hits.push({o: /** @type {EngineObject3D} */ (o), distance}); } return hits.sort((a, b)=> a.distance - b.distance).map(hit=> hit.o); } /** * Call a function for each EngineObject3D whose box overlaps a sphere or a box * - An object destroyed by an earlier callback is skipped * @param {Vector3} pos - Center of the area * @param {Vector3|number} size - Diameter of a sphere if a number, 0 for a point, full size of a box if a Vector3 * @param {function(EngineObject3D): void} callback * @param {Array} [objects] - Defaults to every object * @param {boolean} [testCenters] - Test only each object's center, see engineObjectsCollect3D * @memberof Render3D */ function engineObjectsCallback3D(pos, size, callback, objects=engineObjects, testCenters=false) { for (const o of engineObjectsCollect3D(pos, size, objects, testCenters)) o.destroyed || callback(o); } /////////////////////////////////////////////////////////////////////////////// /** * InstancedMesh3D - Many copies of one mesh drawn as one call, with their transforms kept on the GPU * - For big sets that mostly stay put: an instance costs nothing per frame until it changes, so a hundred thousand * trees cost what one tree does; objects and drawMesh batch by themselves too, but rebuild their batch every frame * - setTransformAt, setMatrixAt and setColorAt change one instance, and only the changed range uploads before the * next draw; the matrices and the colors are kept apart, so moving instances uploads 64 bytes each * - For many instances moving every frame, setTransforms places a run of them from arrays of positions, rotations * and scales in one loop, the fastest way; or write matrixData directly and call markDirtyRange * - The instances are in world space; the object's own pos3D, rotation3D and scale3D do not move them * - The whole set is culled by one bounding sphere around the origin, worked out when culling reads it, or set by hand * with radius; it casts and receives shadows like any object * - The object's flags cover the whole set, one emissive, one tileInfo, one shader; only the colors are per instance * - A mirrored instance, one with a negative scale, shows its inside unless the mesh is doubleSided * - A transparent set draws in one go in the transparent stage, its instances are not sorted against each other; * the set sorts against other transparent draws by the object's position, so put pos3D at its middle * - pick, the raycast and the collect helpers do not see the instances, test them yourself from matrixData * @extends EngineObject3D * @memberof Render3D * @example * const forest = new InstancedMesh3D(treeMesh, 1000); * for (let i = 0; i < 1000; ++i) * forest.setTransformAt(i, randomGroundPos(), vec3(0, rand(2*PI), 0)); */ class InstancedMesh3D extends EngineObject3D { /** Create a set of instances of a mesh, each at the origin in the object's color until it is set * @param {Mesh} mesh * @param {number} count - How many instances there is room for, all of them draw until count is lowered * @param {TileInfo|TextureInfo} [tileInfo] - Texture for all of them * @param {Color} [color] - The color they start with */ constructor(mesh, count, tileInfo, color=WHITE) { super(vec3(), mesh, tileInfo, color); ASSERT(mesh instanceof Mesh, 'an InstancedMesh3D needs a Mesh'); ASSERT(count >= 1, 'an InstancedMesh3D needs room for at least one instance'); this.size3D = vec3(); // not a solid thing to pick or collect /** @property {number} - How many instances draw, the first ones, up to the count it was made with */ this.count = count; /** @property {number} - How many instances it was made with */ this.maxCount = count; /** @property {Float32Array} - Each instance's matrix, 16 floats, what moving them uploads; edit it directly and * call markDirty for the instances changed */ this.matrixData = new Float32Array(count * RENDER3D_MATRIX_FLOATS); /** @property {Float32Array} - Each instance's color and uv rect, 8 floats, uploaded only when they change; edit * it directly and call markColorDirty for the instances changed */ this.colorData = new Float32Array(count * RENDER3D_COLOR_FLOATS); /** @property {number} - First instance whose matrix uploads before the next draw */ this.dirtyStart = 0; /** @property {number} - One past the last instance whose matrix uploads, nothing when it is not past dirtyStart */ this.dirtyEnd = count; /** @property {number} - First instance whose color uploads before the next draw */ this.colorDirtyStart = 0; /** @property {number} - One past the last instance whose color uploads */ this.colorDirtyEnd = count; // the farthest any instance's position has been from the origin, and the largest scale any has had, both // squared, and the instances set since radius last took them in: setting one does no bounds work at all this.reachSquared = 0; this.scaleSquared = 0; this.boundsStart = 0; this.boundsEnd = count; /** @type {number|undefined} */ this.fixedRadius = undefined; // a radius set by hand, see radius this.buffer = undefined; // the GPU copy of matrixData /** @type {WebGLBuffer|undefined} */ this.colorBuffer = undefined; // and of colorData this.bufferGeneration = -1; // the context they were made under this.uvTileInfo = tileInfo; // the tile the uv rects were written for const uv = render3DGetTileUVs(tileInfo), matrices = this.matrixData, colors = this.colorData; for (let i = 0; i < count; ++i) { matrices.set(RENDER3D_IDENTITY.m, i * RENDER3D_MATRIX_FLOATS); const k = i * RENDER3D_COLOR_FLOATS; colors[k] = color.r; colors[k+1] = color.g; colors[k+2] = color.b; colors[k+3] = color.a; colors[k+4] = uv.x; colors[k+5] = uv.y; colors[k+6] = uv.w; colors[k+7] = uv.h; } } /** Place an instance by its position, rotation and scale, what buildMatrix takes, written straight in with no * matrix made; in world space * @param {number} i * @param {Vector3} [pos] * @param {Vector3} [rotation] - Euler angles in radians * @param {Vector3} [scale] */ setTransformAt(i, pos, rotation, scale) { ASSERT(i >= 0 && i < this.maxCount, 'instance index out of range'); matrix4Compose(this.matrixData, i * RENDER3D_MATRIX_FLOATS, pos, rotation, scale); this.markDirty(i); } /** Place a run of instances from arrays indexed by instance, plain or typed, as a game keeps them: each is placed * as setTransformAt places it, all in one loop with nothing made, the fastest way to move many every frame; * in world space * @param {number} start - First instance * @param {number} count - How many * @param {ArrayLike} x - Positions * @param {ArrayLike} y * @param {ArrayLike} z * @param {ArrayLike} [rx] - Euler angles in radians, all three or none for upright * @param {ArrayLike} [ry] * @param {ArrayLike} [rz] * @param {ArrayLike} [sx] - Scales, all three or none for 1 * @param {ArrayLike} [sy] * @param {ArrayLike} [sz] */ setTransforms(start, count, x, y, z, rx, ry, rz, sx, sy, sz) { ASSERT(start >= 0 && count >= 0 && start + count <= this.maxCount, 'instance range out of range'); ASSERT(!rx === !ry && !rx === !rz && !sx === !sy && !sx === !sz, 'rotations and scales come in threes'); const m = this.matrixData, end = start + count; for (let i = start; i < end; ++i) { // matrix4Compose's expressions, so a run placed here is the same as each placed by setTransformAt const k = i * RENDER3D_MATRIX_FLOATS; const scaleX = sx ? sx[i] : 1, scaleY = sy ? sy[i] : 1, scaleZ = sz ? sz[i] : 1; if (rx) { const cx = cos(rx[i]), sinX = sin(rx[i]); const cy = cos(ry[i]), sinY = sin(ry[i]); const cz = cos(rz[i]), sinZ = sin(rz[i]); m[k] = (cy*cz + sinY*sinX*sinZ) * scaleX; m[k+1] = cx*sinZ * scaleX; m[k+2] = (-sinY*cz + cy*sinX*sinZ) * scaleX; m[k+4] = (-cy*sinZ + sinY*sinX*cz) * scaleY; m[k+5] = cx*cz * scaleY; m[k+6] = (sinY*sinZ + cy*sinX*cz) * scaleY; m[k+8] = sinY*cx * scaleZ; m[k+9] = -sinX * scaleZ; m[k+10] = cy*cx * scaleZ; } else { m[k] = scaleX; m[k+1] = 0; m[k+2] = 0; m[k+4] = 0; m[k+5] = scaleY; m[k+6] = 0; m[k+8] = 0; m[k+9] = 0; m[k+10] = scaleZ; } m[k+3] = m[k+7] = m[k+11] = 0; m[k+15] = 1; m[k+12] = x[i], m[k+13] = y[i], m[k+14] = z[i]; } count && this.markDirtyRange(start, end); } /** Place an instance, in world space * @param {number} i * @param {Matrix4} matrix */ setMatrixAt(i, matrix) { ASSERT(i >= 0 && i < this.maxCount, 'instance index out of range'); const data = this.matrixData, k = i * RENDER3D_MATRIX_FLOATS, m = matrix.m; for (let j = 0; j < 16; ++j) data[k+j] = m[j]; // for 16 floats a loop beats set's call this.markDirty(i); } /** The matrix of an instance * @param {number} i * @return {Matrix4} */ getMatrixAt(i) { ASSERT(i >= 0 && i < this.maxCount, 'instance index out of range'); const k = i * RENDER3D_MATRIX_FLOATS, matrix = new Matrix4; matrix.m.set(this.matrixData.subarray(k, k + 16)); return matrix; } /** Color an instance * @param {number} i * @param {Color} color */ setColorAt(i, color) { ASSERT(i >= 0 && i < this.maxCount, 'instance index out of range'); ASSERT(isColor(color), 'color must be a Color'); const data = this.colorData, k = i * RENDER3D_COLOR_FLOATS; data[k] = color.r; data[k+1] = color.g; data[k+2] = color.b; data[k+3] = color.a; this.markColorDirty(i); } /** Note that an instance's matrix changed, so it uploads before the next draw and the bounds take it in when * they are read; setTransformAt and setMatrixAt call this, and so must an edit made straight to matrixData * @param {number} i */ markDirty(i) { if (i < this.dirtyStart) this.dirtyStart = i; if (i >= this.dirtyEnd) this.dirtyEnd = i + 1; if (i < this.boundsStart) this.boundsStart = i; if (i >= this.boundsEnd) this.boundsEnd = i + 1; } /** Note that a run of instances' matrices changed, for code that writes matrixData directly, the lowest level * way to move many: they upload before the next draw and the bounds take them in when they are read * @param {number} start - First instance * @param {number} end - One past the last */ markDirtyRange(start, end) { ASSERT(start >= 0 && end <= this.maxCount && start <= end, 'instance range out of range'); if (start < this.dirtyStart) this.dirtyStart = start; if (end > this.dirtyEnd) this.dirtyEnd = end; if (start < this.boundsStart) this.boundsStart = start; if (end > this.boundsEnd) this.boundsEnd = end; } /** Note that an instance's color changed, so it uploads before the next draw; setColorAt calls this, and so must * an edit made straight to colorData * @param {number} i */ markColorDirty(i) { if (i < this.colorDirtyStart) this.colorDirtyStart = i; if (i >= this.colorDirtyEnd) this.colorDirtyEnd = i + 1; } /** Radius of the sphere around the origin that holds every instance set so far, for culling: the farthest * instance, and the mesh's size at the largest scale; it only grows, and takes in the instances set since it * was last read, so with frustum culling off it is never worked out. Set it to fix the sphere, which a big set * that is culled can use to skip that work, and set it to undefined to go back to the bounds * @return {number} */ get radius() { if (this.fixedRadius !== undefined) return this.fixedRadius; if (this.boundsEnd > this.boundsStart) { // the bounds grow to hold where each is now and how big, read back from its matrix, squared const d = this.matrixData; let reachSquared = this.reachSquared, scaleSquared = this.scaleSquared; for (let i = this.boundsStart; i < this.boundsEnd; ++i) { const k = i * RENDER3D_MATRIX_FLOATS, x = d[k+12], y = d[k+13], z = d[k+14]; const reach = x*x + y*y + z*z, scale = render3DMaxStretchSquared(d, k); if (reach > reachSquared) reachSquared = reach; if (scale > scaleSquared) scaleSquared = scale; } this.reachSquared = reachSquared, this.scaleSquared = scaleSquared; this.boundsStart = Infinity, this.boundsEnd = 0; } const mesh = this.mesh, meshRadius = mesh.radius || mesh.computeRadius(); // the mesh measured once return this.reachSquared ** .5 + meshRadius * this.scaleSquared ** .5; } set radius(radius) { this.fixedRadius = radius; } /** Draws every instance as one call, uploading the ones that changed first * @return {void} */ render3D() { const r = render3D, gl = glContext, mesh = this.mesh; ASSERT(this.count >= 0 && this.count <= this.maxCount, 'count must be within the count it was made with'); if (r.transparentQueue) // sorted as one thing with the other transparent draws return r.queueTransparent(this.getWorldPos3D(), ()=> this.render3D()); if (!mesh || !this.count || !render3DCanDraw()) return; if (r.shadowPass && !r.lighting) return; // unlit things cast no shadow render3DMeshUpload(mesh); if (!mesh.bufferCount) return; if (r.frustumCulling && !render3DSphereVisible(0, 0, 0, this.radius)) return; // the uv rect is the object's tile for every instance, rewritten when the tile changes if (this.uvTileInfo !== this.tileInfo) { const uv = render3DGetTileUVs(this.tileInfo), data = this.colorData; for (let k = 4; k < data.length; k += RENDER3D_COLOR_FLOATS) data[k] = uv.x, data[k+1] = uv.y, data[k+2] = uv.w, data[k+3] = uv.h; this.uvTileInfo = this.tileInfo; this.colorDirtyStart = 0, this.colorDirtyEnd = this.maxCount; } // the GPU copies: all of each under a fresh context, otherwise just the changed ranges if (!this.buffer || this.bufferGeneration !== r.contextGeneration) { this.bufferGeneration = r.contextGeneration; gl.bindBuffer(gl.ARRAY_BUFFER, this.buffer = gl.createBuffer()); gl.bufferData(gl.ARRAY_BUFFER, this.matrixData, gl.DYNAMIC_DRAW); gl.bindBuffer(gl.ARRAY_BUFFER, this.colorBuffer = gl.createBuffer()); gl.bufferData(gl.ARRAY_BUFFER, this.colorData, gl.DYNAMIC_DRAW); } else { const upload = (buffer, data, floats, start, end)=> { if (end <= start) return; gl.bindBuffer(gl.ARRAY_BUFFER, buffer); gl.bufferSubData(gl.ARRAY_BUFFER, start * floats * 4, data, start * floats, (end - start) * floats); }; upload(this.buffer, this.matrixData, RENDER3D_MATRIX_FLOATS, this.dirtyStart, this.dirtyEnd); upload(this.colorBuffer, this.colorData, RENDER3D_COLOR_FLOATS, this.colorDirtyStart, this.colorDirtyEnd); } this.dirtyStart = this.colorDirtyStart = Infinity, this.dirtyEnd = this.colorDirtyEnd = 0; // one draw under the object's state, the mesh setting the culling as drawMesh does r.flush(); render3DFlushBeforeOverlay(); // as in drawMesh, what was drawn before goes under it const cullBackFaces = r.cullBackFaces, tileInfo = this.tileInfo; r.cullBackFaces = !mesh.doubleSided; render3DDrawInstanced(mesh, this.buffer, this.count, render3DTextureOf(tileInfo), r, this.colorBuffer); r.cullBackFaces = cullBackFaces; } /** Destroy the set and free its GPU buffer * @param {boolean} [immediate] */ destroy(immediate) { if (this.buffer && this.bufferGeneration === render3D?.contextGeneration) glContext?.deleteBuffer(this.buffer), glContext?.deleteBuffer(this.colorBuffer); this.buffer = this.colorBuffer = undefined; super.destroy(immediate); } } /////////////////////////////////////////////////////////////////////////////// /** * Light3D - A light that is an EngineObject3D, so it can move, follow a parent or be destroyed like anything else * - A point light: it lights what is near it and fades out by its radius, DirectionalLight3D shines from far away * - A coneAngle makes it a spotlight: it shines along its own forward, turned by rotation3D or by what it is * attached to, inside the cone, fading over the part of it coneSoftness says * - The sun, render3D.sunDirection, casts the shadows; a spotlight can cast them in its place, see * render3D.shadowLight; the other lights light and make highlights without one * - Only the 8 lights nearest the camera are used each frame * - radius is where the light fades out, and it fades fast, so a small radius wants a higher intensity * - intensity multiplies the color, above 1 for a light brighter than white * - radius is a world distance, so scale3D does not change it * - An alpha, an intensity or a radius of 0 switches it off, and a light that is off takes none of those slots * - Draws nothing but its glow, when it has one; add a small emissive mesh if the lamp itself should be seen * - addFlare gives it a lens flare, see LensFlare3D * @extends EngineObject3D * @memberof Render3D * @example * const torch = new Light3D(vec3(0, 3, 0), 10, hsl(.1, 1, .65)); * torch.addFlare(); // light in the lens when the torch is in view */ class Light3D extends EngineObject3D { /** Create a point light * @param {Vector3} [pos3D] - Where it is * @param {number} [radius] - Distance where the light fades to nothing * @param {Color} [color] - Light color, its alpha fades it * @param {number} [intensity] - Brightness, multiplies the color, above 1 is brighter than white */ constructor(pos3D=vec3(), radius=5, color=WHITE, intensity=1) { super(pos3D, undefined, undefined, color); ASSERT(radius >= 0, 'light radius cannot be negative, 0 is an off switch like an alpha of 0'); ASSERT(intensity >= 0, 'light intensity cannot be negative, 0 is an off switch'); this.size3D = vec3(); // not a solid thing to pick or collect /** @property {number} - Distance where the light fades to nothing */ this.radius = radius; /** @property {number} - Brightness, multiplies the color, above 1 is brighter than white */ this.intensity = intensity; /** @property {boolean} - Shine from far away, from its position toward the origin, instead of out from its * position with a falloff; DirectionalLight3D sets it */ this.directional = false; /** @property {number} - Makes it a spotlight: the angle in radians from its forward out to the edge of its * cone, so the beam is twice this across; 0 for a light that shines every way */ this.coneAngle = 0; /** @property {number} - How much of the cone is its fading edge: 0 a hard edge, .2 by default, the outer * fifth, 1 fading all the way from the middle of the beam */ this.coneSoftness = .2; /** @property {number} - Size of a soft hazy glow drawn over the light, like a lamp at night, 0 for none; it * is added onto what is behind it, and what is in front of the light hides it */ this.glow = 0; /** @property {number} - How fast the glow fades from its middle: 1 by default, .5 a wide haze, 2 a tight * bright core */ this.glowFalloff = 1; /** @property {TextureInfo|TileInfo|undefined} - A gel: a picture the light shines through, like a stained glass * window or the leaves of a tree, cast along its cone in its colors, upright as the light looks out, the whole * texture of a TileInfo; only the spotlight that casts the shadows has one, with render3D.shadows on and it * as render3D.shadowLight, and only on what takes its shadows: an object with receiveShadow off is lit * without the gel; its alpha is not read, see through panes are dark * @type {TextureInfo|TileInfo|undefined} */ this.gel = undefined; this.additive = true; // the glow is added on, in the transparent stage; a light with none draws nothing /** @type {LensFlare3D|undefined} */ this.flareObject = undefined; } /** Give the light a lens flare, made with the arguments of LensFlare3D, in place of the one it had * @param {number} [size] - Scales every part of it * @param {number} [count] - How many ghosts there are, besides the glow at the light * @param {number} [intensity] - How bright it is * @param {number} [saturation] - How colorful the ghosts are * @param {Color} [color] - Tints the flare, with the light's own color * @return {LensFlare3D} - The flare, to change: light.addFlare().shapes = ['hex'] */ addFlare(size, count, intensity, saturation, color) { const flare = new LensFlare3D(size, count, intensity, saturation, color); this.flare = flare; return flare; } /** The light's lens flare, undefined for none: addFlare makes it, or set a LensFlare3D of your own, and * destroying the flare takes it away * - The flare is the light's: it is attached to the light as its child, so it stays through a scene change * when the light does, and is destroyed with the light or when another takes its place * @type {LensFlare3D|undefined} */ get flare() { return this.flareObject && !this.flareObject.destroyed ? this.flareObject : undefined; } set flare(flare) { const old = this.flare; // a flare that was destroyed on its own is no flare if (flare === old) return; old && old.destroy(); if (flare) { flare.light = this; flare.parent || this.addChild(flare); } this.flareObject = flare || undefined; } /** Destroy the light, and its flare with it * @param {boolean} [immediate] */ destroy(immediate) { this.flare = undefined; super.destroy(immediate); } /** Draw the glow, a quad facing the camera with a soft round glow on it, pulled toward the camera by half its * size so a lamp at the light does not cut into it, but never past the camera */ render3D() { if (!(this.glow > 0) || this.directional) return; const r = render3D, c = this.color, pos = render3DObjectMatrix(this).getTranslation(); const toCamera = r.camera.pos.subtract(pos), distance = toCamera.length(); const at = distance ? pos.add(toCamera.scale(min(this.glow, distance) / 2 / distance)) : pos; const color = rgb(c.r, c.g, c.b, c.a * min(this.intensity, 1)), texture = engineGlowTexture(this.glowFalloff); // a browser that can not make the glow's texture draws no glow, not a plain square if (texture || !glContext) r.drawBillboard(at, vec2(this.glow), texture, color); } } /////////////////////////////////////////////////////////////////////////////// /** * DirectionalLight3D - A Light3D that shines from far away with no falloff, like sunlight * - It shines from its position toward the origin, like a three.js DirectionalLight: only the direction to it * counts, so moving it or its parent swings the light around; parent it to a sun in the sky and it follows * - It cannot sit on the origin, since that leaves no direction * - It casts no shadow: the sun, render3D.sunDirection, does, or a spotlight set as render3D.shadowLight * @extends Light3D * @memberof Render3D * @example * const fill = new DirectionalLight3D(vec3(-1, 1, 1), hsl(.6, .5, .3)); // from the back left and above */ class DirectionalLight3D extends Light3D { /** Create a directional light * @param {Vector3} [pos3D] - Where it shines from, toward the origin * @param {Color} [color] - Light color, its alpha fades it * @param {number} [intensity] - Brightness, multiplies the color, above 1 is brighter than white */ constructor(pos3D=vec3(0, 1, 0), color=WHITE, intensity=1) { super(pos3D, 0, color, intensity); this.directional = true; } } /* * LittleJS 3D Extras Plugin * - Things built on the 3D renderer that it does not need in order to draw: the rest of the shape builders, * HeightMap terrain, the camera controls, ParticleEmitter3D and Trail3D, and the OBJ loader * - Requires the Render3D plugin and goes after it, everything here is part of its Render3D namespace */ /////////////////////////////////////////////////////////////////////////////// // Helpers used only here // gap between lines of 3D text, as a share of the character height; flat text can let lines touch // the way the 2D font does, but extruded glyphs seen from an angle then overlap the line below const RENDER3D_TEXT_LEADING = 1.3; // let go of the parent but stay where and as the object was in the world, which removeChild keeps by itself; a // destroyed parent has already let go, so the world matrix last built with it in stands in, scale and turn as well function render3DDetach(o) { if (o.parent) o.parent.removeChild(o); else if (o.matrixParent) render3DTakeWorld(o, o.worldMatrix); } // a soft white dot for untextured particles, made once from a canvas, undefined headless or without a canvas let render3DSoftDotTexture; function render3DSoftDot() { if (render3DSoftDotTexture || !glContext || !canvasAvailable()) return render3DSoftDotTexture; const size = 32, context = createCanvasContext(size); const gradient = context.createRadialGradient(size / 2, size / 2, 0, size / 2, size / 2, size / 2); for (const [stop, alpha] of [[0, 1], [.33, .9], [.67, .7], [1, 0]]) // the same falloff as a soft disc gradient.addColorStop(stop, 'rgba(255,255,255,' + alpha + ')'); context.fillStyle = gradient; context.fillRect(0, 0, size, size); return render3DSoftDotTexture = new TextureInfo(context.canvas); } /** Make a normal map from a height at each pixel, for bumps and grooves with no image file: the slope at each pixel * from its neighbors' heights, taken around the edges so the map tiles; set it as an object's normalMap * @param {Vector2} size - In pixels * @param {function(number, number): number} heightFunction - The height 0 to 1 at a pixel, x across and y down * @param {number} [strength] - How steep the slopes are: a height change of 1 over one pixel leans the normal * by strength * @return {TextureInfo} - Wraps; headless it has no image * @memberof Render3D */ function normalMapFromHeight(size, heightFunction, strength=1) { ASSERT(isVector2(size) && size.x >= 1 && size.y >= 1, 'normalMapFromHeight size must be a Vector2 of pixels'); const width = size.x | 0, height = size.y | 0; if (!canvasAvailable()) { // headless, nothing to draw into; the right size so what reads it still works const textureInfo = new TextureInfo(undefined, false, true); textureInfo.size = vec2(width, height), textureInfo.sizeInverse = vec2(1 / width, 1 / height); return textureInfo; } const context = createCanvasContext(width, height); const pixels = render3DNormalMapPixels(width, height, heightFunction, strength); context.putImageData(new ImageData(pixels, width, height), 0, 0); return new TextureInfo(context.canvas, true, true); } // the pixels of a normal map made from heights, rgba with the top row first: each normal leans away from the uphill // side, green up the image as OpenGL has it, and a flat height is (128, 128, 255); rounded before they are stored, // since the clamped array would round a flat 127.5 to even function render3DNormalMapPixels(width, height, heightFunction, strength) { const heights = new Float32Array(width * height), pixels = new Uint8ClampedArray(width * height * 4); for (let y = 0; y < height; ++y) for (let x = 0; x < width; ++x) heights[y * width + x] = heightFunction(x, y); const h = (x, y)=> heights[mod(y, height) * width + mod(x, width)]; for (let y = 0; y < height; ++y) for (let x = 0; x < width; ++x) { const dx = (h(x+1, y) - h(x-1, y)) / 2 * strength, dy = (h(x, y+1) - h(x, y-1)) / 2 * strength; const k = 1 / hypot(dx, dy, 1), i = (y * width + x) * 4; pixels[i] = round((-dx * k * .5 + .5) * 255); pixels[i+1] = round((dy * k * .5 + .5) * 255); pixels[i+2] = round((k * .5 + .5) * 255); pixels[i+3] = 255; } return pixels; } /////////////////////////////////////////////////////////////////////////////// // The rest of the shape builders: buildLathe, buildSphere, buildBox, buildGrid and buildSky live with the // renderer, since it hands those out itself // lathe profile points rounding the corner at [r, y] where two sides leave it along the unit directions d1 and d2, // from t along d1 to t along d2; one segment is a flat chamfer, its points doubled so the lathe shades both of its // edges hard, more follow the circle that touches both sides function render3DBevelProfile(corner, d1, d2, t, segments) { const p1 = [corner[0] + d1[0] * t, corner[1] + d1[1] * t]; const p2 = [corner[0] + d2[0] * t, corner[1] + d2[1] * t]; segments = max(1, segments | 0); if (segments === 1) return [p1, p1, p2, p2]; // the circle's center is on the line halfway between the sides, as far from each as its radius const half = Math.acos(clamp(d1[0] * d2[0] + d1[1] * d2[1], -1, 1)) / 2; const bx = d1[0] + d2[0], by = d1[1] + d2[1], reach = t / cos(half) / hypot(bx, by); const cx = corner[0] + bx * reach, cy = corner[1] + by * reach, radius = t * tan(half); const start = atan2(p1[1] - cy, p1[0] - cx); const turn = mod(atan2(p2[1] - cy, p2[0] - cx) - start + PI, 2 * PI) - PI; // the short way round const points = [p1]; for (let i = 1; i < segments; ++i) { const a = start + turn * i / segments; points.push([cx + cos(a) * radius, cy + sin(a) * radius]); } points.push(p2); return points; } /** * Build a cylinder standing on the Y axis, centered on the origin * @param {number} [size] - Diameter * @param {number} [height] * @param {number} [sides] - Around * @param {boolean} [smooth] - Defaults to render3D.smoothShading * @param {boolean} [capped] - Close the ends * @param {number} [bevel] - Size of the cut on the top and bottom rims, clamped to the radius and half the height * @param {number} [bevelSegments] - Steps around each rim, 1 for a flat chamfer * @return {Mesh} * @memberof Render3D */ function buildCylinder(size=1, height=1, sides=16, smooth=render3D?.smoothShading, capped=true, bevel=0, bevelSegments=1) { ASSERT(isNumber(bevel) && bevel >= 0, 'bevel must be a number, 0 or more'); const r = size / 2, h = height / 2, t = min(bevel, r, h); if (!(t > 0)) return buildLathe([[r, -h], [r, h]], sides, smooth, capped); return buildLathe([...render3DBevelProfile([r, -h], [-1, 0], [0, 1], t, bevelSegments), ...render3DBevelProfile([r, h], [0, -1], [-1, 0], t, bevelSegments)], sides, smooth, capped); } /** * Build a cone standing on the Y axis, centered on the origin, the point up * @param {number} [size] - Diameter of the base * @param {number} [height] * @param {number} [sides] - Around * @param {boolean} [smooth] - Defaults to render3D.smoothShading * @param {boolean} [capped] - Close the base * @param {number} [bevel] - Size of the cut on the base rim, clamped to the radius and half the slanted side * @param {number} [bevelSegments] - Steps around the rim, 1 for a flat chamfer * @return {Mesh} * @memberof Render3D */ function buildCone(size=1, height=1, sides=16, smooth=render3D?.smoothShading, capped=true, bevel=0, bevelSegments=1) { ASSERT(isNumber(bevel) && bevel >= 0, 'bevel must be a number, 0 or more'); const r = size / 2, h = height / 2, slant = hypot(r, height), t = min(bevel, r, slant / 2); if (!(t > 0)) return buildLathe([[r, -h], [0, h]], sides, smooth, capped); const up = [-r / slant, height / slant]; // along the slanted side from the rim toward the point return buildLathe([...render3DBevelProfile([r, -h], [-1, 0], up, t, bevelSegments), [0, h]], sides, smooth, capped); } /** * Build a capsule standing on the Y axis, centered on the origin: a cylinder with a half sphere on each end * @param {number} [size] - Diameter * @param {number} [height] - Total height including the rounded ends, at least the size * @param {number} [sides] - Around * @param {number} [rings] - On each end * @param {boolean} [smooth] - Defaults to render3D.smoothShading * @return {Mesh} * @memberof Render3D */ function buildCapsule(size=1, height=1, sides=16, rings=4, smooth=render3D?.smoothShading) { // the rounded ends alone are already the size tall, so a shorter capsule is only a sphere ASSERT(height >= size, 'a capsule is at least as tall as it is wide, the ends take up the size', size, height); const profile = [], r = size / 2, straight = max(0, height - size) / 2; for (let i = 0; i <= rings; ++i) { const a = i / rings * PI / 2; profile.push([r * sin(a), -straight - r * cos(a)]); } for (let i = 0; i <= rings; ++i) { const a = i / rings * PI / 2; profile.push([r * cos(a), straight + r * sin(a)]); } return buildLathe(profile, sides, smooth); } /** * Build a donut lying flat around the Y axis * @param {number} [size] - Diameter of the whole donut, outside edge to outside edge * @param {number} [tubeSize] - Diameter of the tube * @param {number} [sides] - Around the ring * @param {number} [tubeSides] - Around the tube * @param {boolean} [smooth] - Defaults to render3D.smoothShading * @return {Mesh} * @memberof Render3D */ function buildTorus(size=1, tubeSize=.3, sides=16, tubeSides=8, smooth=render3D?.smoothShading) { ASSERT(tubeSize <= size, 'the tube must fit inside the torus'); const profile = [], radius = (size - tubeSize) / 2, tubeRadius = tubeSize / 2; for (let i = 0; i <= tubeSides; ++i) { const a = i / tubeSides * 2 * PI; profile.push([radius + tubeRadius * cos(a), tubeRadius * sin(a)]); } return buildLathe(profile, sides, smooth); } /** * Build a lit ribbon along a path, for roads, tracks and walls * - Each segment is a flat quad, the sides are across the path in the plane of the up vector * - doubleSided, so it is seen and lit from below as well * @param {Array} points - Center line in order * @param {number|Array} [width] - Full width, one for all or one per point * @param {Color|Array} [color] - One for all or one per point * @param {boolean} [closed] - Join the last point back to the first * @param {Vector3} [up] - Which way the ribbon faces * @return {Mesh} * @memberof Render3D * @example * const road = buildRibbon(trackPoints, 8, GRAY, true); // a loop of road */ function buildRibbon(points, width=1, color=WHITE, closed=false, up=vec3(0, 1, 0)) { ASSERT(isArray(points) && points.length > 1, 'ribbon needs at least 2 points'); const mesh = new Mesh, count = points.length, edges = []; let across = (abs(up.y) < .9 ? vec3(0, 1, 0) : vec3(1, 0, 0)).cross(up).normalize(); // anything across up for (let i = 0; i < count; ++i) { // across the path, from the tangent through this point; a step along up keeps the last across const next = points[closed ? (i + 1) % count : min(i + 1, count - 1)]; const last = points[closed ? (i + count - 1) % count : max(i - 1, 0)]; const dir = next.subtract(last).cross(up); if (dir.lengthSquared() > 1e-12) across = dir.normalize(); const half = across.scale((isArray(width) ? width[i] : width) / 2); edges.push([points[i].subtract(half), points[i].add(half)]); } for (let i = 0; i + 1 < count + (closed ? 1 : 0); ++i) { const j = (i + 1) % count, a = edges[i], b = edges[j]; const c = isArray(color) ? [color[i], color[i], color[j], color[j]] : color; mesh.addQuad(a[0], a[1], b[1], b[0], c); // counter clockwise seen from above } mesh.doubleSided = true; // a flat strip, seen from both sides return mesh; } /** * Build a hull from a row of diamond shaped slices along Z, for ships, planes and cars * - Each slice is [z, width, top, bottom, sideHeight] * - sideHeight is 0 to 1 and puts the side corners between the bottom and the top * - List the slices nose first, with the nose at the largest z * @param {Array>} stations * @return {Mesh} * @memberof Render3D * @example * const hull = buildLoft([[1.2, .4, .2, -.1], [0, 1.4, .5, -.4], [-1, 1, .3, -.3]]); */ function buildLoft(stations) { ASSERT(isArray(stations) && stations.length > 1, 'loft needs at least 2 stations'); // the caps and the winding both assume the nose leads, so the other order turns the hull inside out ASSERT(stations[0][0] > stations[stations.length-1][0], 'loft stations go nose first, from the largest z to the smallest'); const mesh = new Mesh; // section points: left, top, right, bottom, wound clockwise seen from +z /** @type {function(Array): Array} */ const section = ([z, w, t, b, m=.5])=> [vec3(-w / 2, lerp(b, t, m), z), vec3(0, t, z), vec3(w / 2, lerp(b, t, m), z), vec3(0, b, z)]; for (let i = 0; i + 1 < stations.length; ++i) { const s1 = section(stations[i]), s2 = section(stations[i + 1]); for (let k = 0; k < 4; ++k) mesh.addQuad(s1[k], s1[(k + 1) % 4], s2[(k + 1) % 4], s2[k]); } const tail = section(stations[stations.length - 1]), nose = section(stations[0]); mesh.addQuad(tail[0], tail[1], tail[2], tail[3]); mesh.addQuad(nose[3], nose[2], nose[1], nose[0]); return mesh; } /** * Turn a sprite into a 3D block model by giving its pixels thickness * - A pixel counts as solid when it is more than half opaque * - Each pixel keeps its own color, so white art takes the object's tint * - Runs of matching pixels merge into one face, and side walls appear only at the sprite's edges * - A texture's pixels are read once and kept, so redrawing a canvas texture will not change what this builds * - Pixels can also be an array of rows, each a Color, a truthy value for white, or a falsy value for empty * @param {TileInfo|Array>} pixels - A tile from a loaded texture, or rows of pixels, * each a Color (empty when see through), a truthy value for white or a falsy value for empty * @param {Vector2} [size] - World width and height of the whole tile, centered like buildBox * @param {number} [depth] - Thickness along Z * @return {Mesh} * @memberof Render3D * @example * new EngineObject3D(vec3(), buildExtrude(tile(3, 16), vec2(2), .5)); // a chunky version of tile 3 */ function buildExtrude(pixels, size=vec2(1), depth=1) { let rows = /** @type {Array>} */ (pixels), width, height; if (pixels instanceof TileInfo) { // colors for the tile's pixels only, undefined where alpha is half or less const image = render3DReadPixels(pixels.textureInfo), data = image.data; const x0 = pixels.pos.x | 0, y0 = pixels.pos.y | 0; width = pixels.size.x | 0, height = pixels.size.y | 0; rows = []; for (let y = 0; y < height; ++y) { const row = rows[y] = []; for (let x = 0; x < width; ++x) { const k = ((y0 + y) * image.width + x0 + x) * 4; row.push(data[k + 3] > 127 ? rgb(data[k] / 255, data[k + 1] / 255, data[k + 2] / 255) : undefined); } } } else { ASSERT(isArray(pixels) && pixels.length, 'pixels must be a TileInfo or rows of pixels'); height = rows.length, width = rows[0].length; } // the color of a solid pixel, undefined outside or where it is empty const solid = (x, y)=> { if (x < 0 || y < 0 || x >= width || y >= height) return; const c = /** @type {Color} */ (rows[y] && rows[y][x]); // or a truthy value for white if (!c) return; return isColor(c) ? (c.a > .5 ? c : undefined) : WHITE; // a see through Color is empty too }; const same = (a, b)=> a === b || !!a && !!b && a.rgbaInt() === b.rgbaInt(); // call emit(start, end, color) for each run of same colored pixels, colorAt(i) undefined breaks the run const runs = (count, colorAt, emit)=> { let start = 0, color; for (let i = 0; i <= count; ++i) { const c = i < count ? colorAt(i) : undefined; if (same(c, color)) continue; if (color) emit(start, i, color); start = i, color = c; } }; // pixel edges in world space, y runs down the image const mesh = new Mesh, sx = size.x / width, sy = size.y / height, hz = depth / 2; const px = x=> x * sx - size.x / 2, py = y=> size.y / 2 - y * sy; const quad = (origin, right, up, normal, color)=> mesh.addStrip(render3DQuadAxes(origin.add(right.scale(.5)).add(up.scale(.5)), right.scale(.5), up.scale(.5)), normal, RENDER3D_QUAD_UVS, color); const X = vec3(1, 0, 0), Y = vec3(0, 1, 0), Z = vec3(0, 0, 1); for (let y = 0; y < height; ++y) { // front and back faces along each row runs(width, x=> solid(x, y), (a, b, c)=> { const w = X.scale((b - a) * sx), h = Y.scale(sy); quad(vec3(px(a), py(y + 1), hz), w, h, Z, c); quad(vec3(px(b), py(y + 1), -hz), w.scale(-1), h, Z.scale(-1), c); }); // walls facing up and down where the pixel above or below is empty runs(width, x=> solid(x, y - 1) ? undefined : solid(x, y), (a, b, c)=> quad(vec3(px(a), py(y), hz), X.scale((b - a) * sx), Z.scale(-depth), Y, c)); runs(width, x=> solid(x, y + 1) ? undefined : solid(x, y), (a, b, c)=> quad(vec3(px(a), py(y + 1), -hz), X.scale((b - a) * sx), Z.scale(depth), Y.scale(-1), c)); } for (let x = 0; x < width; ++x) { // walls facing left and right where the pixel beside is empty runs(height, y=> solid(x - 1, y) ? undefined : solid(x, y), (a, b, c)=> quad(vec3(px(x), py(b), -hz), Z.scale(depth), Y.scale((b - a) * sy), X.scale(-1), c)); runs(height, y=> solid(x + 1, y) ? undefined : solid(x, y), (a, b, c)=> quad(vec3(px(x + 1), py(b), hz), Z.scale(-depth), Y.scale((b - a) * sy), X, c)); } return mesh; } /** * Build a mesh of extruded text from an image font, the engine font by default so it needs no assets * - Each glyph is extruded once per font and reused, the block is centered and faces +Z * - Newlines stack downward, spaced a little wider than the character height so the sides do not collide * - Every call builds a new mesh, dispose the old one when text changes often * - Glyphs are white in the engine font, so the object's color tints the text * @param {string|number} text * @param {number} [size] - Character height in world units * @param {number} [depth] - Thickness along Z * @param {ImageFont} [font] - Defaults to engineImageFont * @return {Mesh} * @memberof Render3D * @example * new EngineObject3D(vec3(0, 2, 0), buildText3D('HELLO'), undefined, YELLOW); */ function buildText3D(text, size=1, depth=.2, font=engineImageFont) { ASSERT(font instanceof ImageFont, 'font must be an ImageFont, the engine font loads before gameInit'); const tileInfo = font.tileInfo; let glyphs = render3DGlyphCache.get(font); // unit sized, scaled when combined glyphs || render3DGlyphCache.set(font, glyphs = new Map); const charSize = vec2(size * tileInfo.size.x / tileInfo.size.y, size); const mesh = new Mesh, lines = textLines(text); // a Windows line ending too lines.forEach((line, j)=> { const y = ((lines.length - 1) / 2 - j) * charSize.y * RENDER3D_TEXT_LEADING; const characters = textCharacters(line); // as ImageFont reads them, an emoji one box for (let i = 0; i < characters.length; ++i) { const charCode = textCharacterCode(characters, i); const index = charCode < 32 || charCode > 127 ? 95 : charCode - 32; // like ImageFont if (!index) continue; // space let glyph = glyphs.get(index); if (!glyph) { const pos = font.getGlyphPos(index); // where ImageFont finds it glyphs.set(index, glyph = buildExtrude(new TileInfo(pos, tileInfo.size, tileInfo.textureInfo))); } const x = (i - (characters.length - 1) / 2) * charSize.x; mesh.combine(glyph, buildMatrix(vec3(x, y, 0), undefined, vec3(charSize.x, charSize.y, depth))); } }); return mesh; } /////////////////////////////////////////////////////////////////////////////// /** * HeightMap - Terrain from a grid of heights: an object that draws itself, and that objects with collideLevel stand on * - heights is a 2D array [row][column] of 0 to 1 values * - Row 0 is the far edge at -Z and column 0 is the left edge at -X * - It can be an image instead, where the red channel is the height * - colors is an optional 2D array of Colors or an image, sampled per vertex * - images are read through a canvas, so they must be same origin or loaded with crossOrigin set * - pos3D is the center of the map, its grid spans mapSize on X and Z around it, and a full value is height above it * - getHeight, getNormal, getColor and raycast are in world space, with the map's position taken off * - It stays upright and unscaled, its lookups do not turn with it * @extends EngineObject3D * @memberof Render3D * @example * const terrain = new HeightMap(heightImage, vec2(100, 100), 10, colorImage); * const y = terrain.getHeight(x, z); // stand things on it, or give them collideLevel */ class HeightMap extends EngineObject3D { /** Create a height map from an array or an image, it draws itself and joins the level's collision * @param {Array>|HTMLImageElement|HTMLCanvasElement|OffscreenCanvas|TextureInfo} heights * @param {Vector2} [mapSize] - World size along X and Z * @param {number} [height] - World height of a full value * @param {Array>|HTMLImageElement|HTMLCanvasElement|OffscreenCanvas|TextureInfo} [colors] * @param {Vector3} [pos3D] - Center of the map * @param {boolean} [smooth] - Defaults to render3D.smoothShading */ constructor(heights, mapSize=vec2(1), height=1, colors, pos3D=vec3(), smooth=render3D?.smoothShading) { super(pos3D); if (!isArray(heights)) heights = render3DImageToArray(heights, (r)=> r / 255); if (colors && !isArray(colors)) colors = render3DImageToArray(colors, (r, g, b, a)=> rgb(r / 255, g / 255, b / 255, a / 255)); ASSERT(isArray(heights) && heights.length > 1 && isArray(heights[0]) && heights[0].length > 1, 'height map needs at least 2 rows and 2 columns'); ASSERT(mapSize.x > 0 && mapSize.y > 0, 'height map size must be positive, a zero size has nowhere to look things up'); /** @property {Array>} - Heights 0-1 as [row][column], rows along Z, rebuild() after changing them */ this.heights = heights; /** @property {Array>|undefined} - Vertex colors as [row][column], undefined for white * @type {Array>|undefined} */ this.colors = /** @type {Array>|undefined} */ (colors); /** @property {Vector2} - World size along X and Z */ this.mapSize = mapSize.copy(); /** @property {number} - World height of a full value */ this.height = height; /** @property {boolean} - Smooth shading, rebuild() after changing it */ this.smooth = !!smooth; this.size3D = vec3(mapSize.x, height, mapSize.y); this.rebuild(); render3DLevel.push(this); } /** Number of rows, along Z * @return {number} */ get rows() { return this.heights.length; } /** Number of columns, along X * @return {number} */ get columns() { return this.heights[0].length; } /** World height at a position, exactly the height of the mesh buildMesh draws there, clamped at the edges * @param {number|Vector3} x - X, or a position to take X and Z from * @param {number} [z] * @return {number} */ getHeight(x, z) { if (x instanceof Vector3) z = x.z, x = x.x; // a position works as well as its two numbers, its own y is ignored const p = this.pos3D; x -= p.x, z -= p.z; const columns = this.columns, rows = this.rows, h = this.heights; const u = clamp((x / this.mapSize.x + .5) * (columns - 1), 0, columns - 1); const v = clamp((z / this.mapSize.y + .5) * (rows - 1), 0, rows - 1); const i = min(floor(u), columns - 2), j = min(floor(v), rows - 2); const fu = u - i, fv = v - j; // each cell is two triangles split from (i, j+1) to (i+1, j), the same split buildGrid's quads use const a = h[j][i], b = h[j+1][i], c = h[j+1][i+1], d = h[j][i+1]; const height = fu + fv <= 1 ? a + fu * (d - a) + fv * (b - a) : c + (1 - fu) * (b - c) + (1 - fv) * (d - c); return height * this.height + p.y; } /** Surface normal at a position, from the slope across a sample * @param {number|Vector3} x - X, or a position to take X and Z from * @param {number} [z] * @return {Vector3} */ getNormal(x, z) { if (x instanceof Vector3) z = x.z, x = x.x; const p = this.pos3D, size = this.mapSize; const ex = size.x / (this.columns - 1) / 2, ez = size.y / (this.rows - 1) / 2; const local = (lx, lz)=> this.getHeight(lx + p.x, lz + p.z); return render3DSlopeNormal(local, x - p.x, z - p.z, ex, ez, size.x / 2, size.y / 2); } /** Color of the nearest sample to a position, white when there are no colors * @param {number|Vector3} x - X, or a position to take X and Z from * @param {number} [z] * @return {Color} */ getColor(x, z) { if (x instanceof Vector3) z = x.z, x = x.x; const c = this.colors; if (!c) return WHITE; x -= this.pos3D.x, z -= this.pos3D.z; const columns = c[0].length, rows = c.length; const i = clamp(round((x / this.mapSize.x + .5) * (columns - 1)), 0, columns - 1); const j = clamp(round((z / this.mapSize.y + .5) * (rows - 1)), 0, rows - 1); return c[j][i]; } /** Distance along a ray to where it crosses the terrain surface, or undefined for a miss * - Exact: the ground is flat inside each triangle, so the ray is checked between each grid line and * cell diagonal it crosses, and a hill it only grazes is still hit * - A ray that starts under the ground crosses on its way out, so the hit is still on the surface * @param {Ray3D} ray - From screenToRay, or any ray * @return {number|undefined} */ raycast(ray) { // in the map's own space, its position taken off the ray const p = this.pos3D, origin = ray.origin.subtract(p), direction = ray.direction; const size = this.mapSize, height = this.height, length = direction.length(); if (!length) return; // clip to the box around the terrain, from where the ray enters it to where it leaves the map's footprint const start = raycastBox(new Ray3D(origin, direction), vec3(0, height / 2, 0), vec3(size.x, abs(height) + 1e-3, size.y)); if (start === undefined || !(size.x > 0 && size.y > 0)) return; let end = start + hypot(size.x, size.y, height) / length; if (direction.x) end = min(end, (sign(direction.x) * size.x / 2 - origin.x) / direction.x); if (direction.z) end = min(end, (sign(direction.z) * size.y / 2 - origin.z) / direction.z); // the grid lines and cell diagonals it crosses, in grid units u across the columns and v across the rows, // each linear along the ray; between two of them the ground under it is one flat triangle const scaleU = (this.columns - 1) / size.x, scaleV = (this.rows - 1) / size.y; const u0 = (origin.x / size.x + .5) * (this.columns - 1), u1 = direction.x * scaleU; const v0 = (origin.z / size.y + .5) * (this.rows - 1), v1 = direction.z * scaleV; const breaks = [start, end]; for (const [f0, f1] of [[u0, u1], [v0, v1], [u0 + v0, u1 + v1]]) { if (!f1) continue; const a = f0 + f1 * start, b = f0 + f1 * end; for (let k = ceil(min(a, b)); k <= max(a, b); ++k) breaks.push((k - f0) / f1); } breaks.sort((a, b)=> a - b); // how far above the ground the ray is, straight along each piece, so a crossing is solved exactly; // one that starts under the ground finds where it comes out, the surface it breaks through const above = (at)=> { const q = origin.add(direction.scale(at)); return q.y + p.y - this.getHeight(q.x + p.x, q.z + p.z); }; // touching the surface counts as a hit too, where it starts, at the map's edge, or grazing it from below const touching = (d)=> abs(d) <= 1e-9; let a = start, da = above(a); if (touching(da)) return start; const startUnder = da <= 0; for (const b of breaks) { if (b <= a || b > end) continue; const db = above(b); if (touching(db)) return b; if (db <= 0 !== startUnder) return da === db ? b : a + (b - a) * da / (da - db); a = b, da = db; } } /** Build the terrain mesh, one vertex per sample, centered on the map's own origin * @param {boolean} [smooth] - Defaults to render3D.smoothShading * @return {Mesh} */ buildMesh(smooth=render3D?.smoothShading) { // flat shading colors each cell from its center, halfway between two samples where rounding could pick // either, so it is nudged a thousandth of a cell back to make it the cell's first corner every time const p = this.pos3D, size = this.mapSize; const nudgeX = smooth ? 0 : size.x / (this.columns - 1) / 1e3; const nudgeZ = smooth ? 0 : size.y / (this.rows - 1) / 1e3; return buildGrid(size, vec2(this.columns - 1, this.rows - 1), this.colors && ((x, z)=> this.getColor(x - nudgeX + p.x, z - nudgeZ + p.z)), (x, z)=> this.getHeight(x + p.x, z + p.z) - p.y, smooth); } /** Make the mesh again from the heights and colors, after changing them or smooth */ rebuild() { this.setMesh(this.buildMesh(this.smooth)); } /** How far along a ray the surface is, for picking, see raycast * @param {Ray3D} ray * @return {number|undefined} * @ignore */ levelRaycast3D(ray) { return this.raycast(ray); } /** Where a short move goes under the surface, for a particle's move in one frame: the part of the move made * before it, 0 to 1, and the surface normal there; undefined when it stays above, starts under, or is off the map * @param {Vector3} from * @param {Vector3} to * @return {{distance: number, normal: Vector3}|undefined} * @ignore */ levelSegment3D(from, to) { // a move above the highest the ground goes misses it, which is most particles most of the time const top = this.pos3D.y + max(this.height, 0); if (from.y > top && to.y > top) return undefined; if (from.y < this.getHeight(from.x, from.z)) return undefined; // under from the start, let go // the ground is flat in each triangle, not between the ends, so a ridge both ends are above is still met: // the exact raycast along the move, which walks every triangle it crosses, the first one it goes under const distance = this.raycast(new Ray3D(from, to.subtract(from))); // over the move, and not a touch where it starts on the surface and leaves it if (distance === undefined || distance > 1 || !distance && to.y >= this.getHeight(to.x, to.z)) return undefined; return {distance, normal: this.getNormal(from.x + (to.x - from.x) * distance, from.z + (to.z - from.z) * distance)}; } /** Keep an object above the ground, called by the engine for each object with collideLevel * @param {EngineObject3D} o * @param {Vector3} oldPos - Where it was before it moved * @param {boolean} [wasOn] - It stood on this map last frame * @ignore */ levelCollide3D(o, oldPos, wasOn) { const p = o.pos3D, m = this.pos3D, size = this.mapSize; if (abs(p.x - m.x) > size.x / 2 || abs(p.z - m.z) > size.y / 2) return; // off the map const half = o.size3D.y * abs(o.scale3D.y) / 2; let ground = this.getHeight(p.x, p.z); // only the ends of a move are kept above the ground, so a ridge it goes over is met by a sweep, as a // particle's move is, a quarter of its height up from its foot, from where it was to where it would stand: a // bump lower than that is walked over as before, and it goes up on the near side of anything taller const lift = half / 2, endFoot = max(p.y - half, ground) + lift; const hit = oldPos && this.levelSegment3D(vec3(oldPos.x, oldPos.y - half + lift, oldPos.z), vec3(p.x, endFoot, p.z)); if (hit && hit.distance) { p.x = oldPos.x + (p.x - oldPos.x) * hit.distance; p.z = oldPos.z + (p.z - oldPos.z) * hit.distance; ground = this.getHeight(p.x, p.z); } else { // one that stood on it and is not rising keeps to it going downhill, as far down as it moved across, so // it stays grounded down a slope as steep as 45 degrees instead of falling in small hops const follow = wasOn && o.velocity3D.y <= 0 ? hypot(p.x - oldPos.x, p.z - oldPos.z) : 0; if (p.y - half > ground + follow) return; } p.y = ground + half; const v = o.velocity3D; if (v.y < 0) v.y *= -max(o.restitution, this.restitution); o.groundObject = this; } /** Keeps an eye on its placement, called automatically each frame */ update() { super.update(); render3DLevelAssertPlaced(this); } /** Destroy the map, it leaves the level's collision * @param {boolean} [immediate] */ destroy(immediate) { render3DLevelLeave(this); super.destroy(immediate); } } // read an image's pixel bytes through the engine's work canvas, as {data, width, height} function render3DImageData(image) { if (image instanceof TextureInfo) image = image.image; ASSERT(image && image.width && image.height, 'image is not loaded'); ASSERT(workReadCanvas, 'reading an image needs a canvas, pass arrays in headless mode'); const width = image.width, height = image.height; workReadCanvas.width = width; workReadCanvas.height = height; workReadContext.drawImage(image, 0, 0); return workReadContext.getImageData(0, 0, width, height); } // read an image into a 2D array [row][column], sample is called with (r, g, b, a) bytes for each pixel function render3DImageToArray(image, sample) { const {data, width, height} = render3DImageData(image); const rows = []; for (let y = 0; y < height; ++y) { const row = rows[y] = []; for (let x = 0; x < width; ++x) { const k = (y * width + x) * 4; row.push(sample(data[k], data[k+1], data[k+2], data[k+3])); } } return rows; } // extruded glyph meshes by font, and the pixel bytes of a texture, read once per image const render3DGlyphCache = new WeakMap, render3DPixelCache = new WeakMap; function render3DReadPixels(textureInfo) { const image = textureInfo.image; let pixels = render3DPixelCache.get(image); if (!pixels) render3DPixelCache.set(image, pixels = render3DImageData(image)); return pixels; } /////////////////////////////////////////////////////////////////////////////// /** * CameraControl3D - Drag to turn the camera around a point, roll the wheel to zoom * - An EngineObject3D, so move its pos3D to follow something, or parent it to an object * - Destroy it to hand the camera back, and it stops driving the camera * - Set persistent to keep it when engineObjectsDestroy clears out a level * - Every part of it is a field, so a game can change the buttons, speeds and limits * @extends EngineObject3D * @memberof Render3D * @example * new CameraControl3D(vec3(0, 1, 0), 15); // look at a point from 15 units away */ class CameraControl3D extends EngineObject3D { /** Create a camera control, it drives render3D.camera every frame * @param {Vector3} [target] - The point to look at, its pos3D * @param {number} [distance] - How far the camera sits from the target * @param {number} [pitch] - Angle above the horizon, PI/2 looks straight down, clamped to pitchRange * @param {number} [idleSpin] - Turned each frame while not dragging, 0 holds still */ constructor(target=vec3(), distance=10, pitch=.4, idleSpin=0) { super(target); this.size3D = vec3(); // not a solid thing to pick or collect /** @property {number} - How far the camera sits from the target */ this.distance = distance; /** @property {number} - Angle above the horizon */ this.pitch = pitch; /** @property {number} - Turned each frame while not dragging */ this.idleSpin = idleSpin; /** @property {number} - Angle around the target, dragging changes it */ this.yaw = 0; /** @property {number} - Mouse button that turns the camera, 0 is left and 2 is right */ this.dragButton = 0; /** @property {number} - How far dragging a pixel turns the camera */ this.dragSpeed = .01; /** @property {number} - How much one wheel notch zooms, 0 turns zooming off */ this.zoomSpeed = .1; /** @property {Vector2} - Closest and furthest the wheel can zoom to */ this.zoomRange = vec2(distance/4, distance*3); /** @property {Vector2} - Lowest and highest pitch, so it cannot tip over the top, widened to hold the pitch given */ this.pitchRange = vec2(min(-.2, pitch), max(1.4, pitch)); } /** Read the mouse and put the camera on its orbit, called automatically each frame */ update() { if (mouseIsDown(this.dragButton)) { // the scene follows the drag this.yaw -= mouseDeltaScreen.x * this.dragSpeed; this.pitch += mouseDeltaScreen.y * this.dragSpeed; } else this.yaw += this.idleSpin; this.pitch = clamp(this.pitch, this.pitchRange.x, this.pitchRange.y); if (this.zoomSpeed && mouseWheel) this.distance = clamp(this.distance * (1 + sign(mouseWheel) * this.zoomSpeed), this.zoomRange.x, this.zoomRange.y); render3D.camera.orbit(this.getWorldPos3D(), this.distance, this.yaw, this.pitch); } /** Camera controls draw nothing */ render3D() {} } /////////////////////////////////////////////////////////////////////////////// /** * FirstPersonCamera3D - Look around with the mouse and move with the keys, with the camera at its position * - Click to capture the mouse so looking needs no button held, Esc lets it go; holding the button looks too, for touch * - WASD or the arrow keys walk level, or move the way it looks when fly is set * - An EngineObject3D that moves by velocity3D, so give it a size3D and call setCollision to walk into solid * objects instead of through them; walking keeps velocity3D.y, so render3D.gravity can pull it down * - Starts from wherever render3D.camera is, so it can take over from another camera without a jump * - As the child of an EngineObject3D, like a player on a ship, its yaw, pitch and walking are relative to the parent, * so it turns and moves with it * - Destroy it to hand the camera back * @extends EngineObject3D * @memberof Render3D * @example * const player = new FirstPersonCamera3D(vec3(0, 1.5, 5)); * player.size3D = vec3(1); // bump into solid objects * player.collideAsSphere3D = true; * player.setCollision(); */ class FirstPersonCamera3D extends EngineObject3D { /** Create a first person camera, it drives render3D.camera every frame * @param {Vector3} [pos3D] - Where the eye is, defaults to where the camera is now * @param {number} [yaw] - Radians around Y, defaults to the camera's * @param {number} [pitch] - Radians up from level, defaults to the camera's */ constructor(pos3D=render3D.camera.pos, yaw=render3D.camera.rotation.y, pitch=render3D.camera.rotation.x) { super(pos3D); this.size3D = vec3(); // not a solid thing to pick or collect until it is given a size this.mass = 1; // so solids push it out, and render3D.gravity pulls on it /** @property {number} - Angle around Y, the mouse turns it */ this.yaw = yaw; /** @property {number} - Angle up from level, the mouse tilts it */ this.pitch = pitch; /** @property {number} - World units per frame at full speed */ this.moveSpeed = .1; /** @property {number} - How far a pixel of mouse movement turns the view */ this.lookSpeed = .003; /** @property {Vector2} - Lowest and highest pitch */ this.pitchRange = vec2(-1.5, 1.5); /** @property {boolean} - Move the way it looks, up and down included, instead of walking level */ this.fly = false; /** @property {boolean} - Capture the mouse on a click, so looking needs no button held */ this.lockPointer = true; /** @property {number} - Speed of a jump in world units a frame, 0 for none; Space or gamepad button 0 jumps * while it stands on something, a height map, a voxel map or a solid, see groundObject */ this.jumpSpeed = 0; /** @property {number} - How far above its position the eye is, in its own space, so with a size3D the eye can * sit toward the top of the body instead of its middle; keep it under half the body's height, or the eye is * outside the body and sees through a ceiling it stands under */ this.eyeHeight = 0; } /** Move by velocity3D, and fall by render3D.gravity unless flying, called automatically each frame */ updatePhysics() { // flying moves the way it looks and nothing else, so gravity does not pull while fly is on; the scale set // on it is put back, for walking const gravityScale = this.gravityScale; if (this.fly) this.gravityScale = 0; super.updatePhysics(); this.gravityScale = gravityScale; } /** Read the mouse and keys and put the camera at the eye, called automatically each frame */ update() { // only a root moves by its own physics, a child follows its parent, so gravity would still pull one that flies ASSERT(!this.fly || !this.parent, 'a flying FirstPersonCamera3D moves on its own, it cannot be a child'); // a click captures the mouse, then it looks around while captured or while a button is held if (this.lockPointer && mouseWasPressed(0)) pointerLockRequest(); if (pointerLockIsActive() || mouseIsDown(0)) { this.yaw -= mouseDeltaScreen.x * this.lookSpeed; this.pitch -= mouseDeltaScreen.y * this.lookSpeed; } this.pitch = clamp(this.pitch, this.pitchRange.x, this.pitchRange.y); // the keys move it level, or the way it looks when flying, and walking keeps its fall const input = keyDirection(); const move = vec3(input.x, 0, -input.y).clampLength(1).scale(this.moveSpeed) .rotateX(this.fly ? this.pitch : 0).rotateY(this.yaw); this.velocity3D = this.fly ? move : vec3(move.x, this.velocity3D.y, move.z); // a jump from the ground, level collision sets what it stands on if (this.jumpSpeed && !this.fly && this.groundObject && (keyWasPressed('Space') || gamepadWasPressed(0))) this.velocity3D.y = this.jumpSpeed; // the camera sits at the eye, eyeHeight above where this frame's physics left it, looking the way it does in // its parent's space, since a child's velocity3D moves it in that space too const rotation = vec3(this.pitch, this.yaw, 0); render3D.camera.pos = render3DObjectMatrix(this).transformPoint(vec3(0, this.eyeHeight, 0)); render3D.camera.rotation = this.parent instanceof EngineObject3D ? this.parent.getMatrix().multiply(Matrix4.rotation(rotation)).getRotation() : rotation; } /** Let go of the mouse and stop driving the camera * @param {boolean} [immediate] */ destroy(immediate) { this.lockPointer && pointerLockIsActive() && pointerLockExit(); super.destroy(immediate); } /** Camera controls draw nothing */ render3D() {} } /////////////////////////////////////////////////////////////////////////////// // a particle is this many floats of its emitter's particleData: position, velocity, start color, end color, start // and end size, life, age, angle, spin, and how many trail points it has in trailData const RENDER3D_PARTICLE_FLOATS = 21; // the position, color and size of the particle being drawn, shared by every emitter so the draw loop makes no objects const render3DParticlePos = vec3(), render3DParticleColor = new Color, render3DParticleSize = vec2(); // where a particle was and is, for the level collision const render3DParticleFrom = vec3(), render3DParticleTo = vec3(); // the emitters inside a particle callback now, whose nested calls each get a view of their own const render3DParticlesCalling = new Set; // a particle as a ParticleEmitter3D's callbacks see it, set from the particle for each call /** @param {ParticleEmitter3D} emitter * @return {Particle3D} */ function render3DParticleView(emitter) { return {emitter, pos: vec3(), velocity: vec3(), age: 0, lifeTime: 0, scale: 1, destroyed: false, destroy() { this.destroyed = true; }}; } /** * A particle as a ParticleEmitter3D's callbacks see it: one object the emitter reuses, set from the particle for each * call and written back after it, so copy what you keep * @typedef {Object} Particle3D * @property {ParticleEmitter3D} emitter - The emitter it is in * @property {Vector3} pos - Where it is, change it to move it * @property {Vector3} velocity - How far it moves each frame * @property {number} age - Seconds it has lived * @property {number} lifeTime - Seconds it lives * @property {number} scale - How much the emitter grows its effect, from its scale3D and its parents', as the 2D * particle's scale * @property {boolean} destroyed - Set by destroy * @property {function(): void} destroy - End it this update, the destroy callback gets it * @memberof Render3D */ /** * @callback Particle3DCallback - A function a ParticleEmitter3D calls with one of its particles * @param {Particle3D} particle * @memberof Render3D */ /** * @callback Particle3DCollideCallback - Decides whether a particle stops where it hits the level, a filter as in 2D * @param {Particle3D} particle * @param {EngineObject3D} level - The HeightMap or VoxelMap it hit * @param {Vector3} pos - Where it hit * @return {boolean|void} - true to stop it there; a callback that returns nothing lets it pass through * @memberof Render3D */ /** * ParticleEmitter3D - Spawns camera facing particles, the 3D twin of ParticleEmitter * - Each particle is a flat square facing the camera, with a soft round dot when no tile is given * - Set trailTime to draw each particle as a streak along where it has been, for sparks * - Set angleSpeed to tumble them in the camera plane, which the 2D emitter takes as an argument * - Particles shoot out along the emitter's own up axis, turned by rotation3D * - emitConeAngle spreads them, PI sprays in every direction * - Speeds are per frame and sizes are world units, the same as the 2D emitter * - scale3D, its own or a parent's, grows the whole effect: the spawn area, the sizes, the speed and the fall * - gravity here is its own number added to velocity y each frame, so an effect keeps its own fall wherever it is * used, the same as the 2D emitter's gravity; gravityScale adds a share of render3D.gravity on top, as the 2D * emitter's gravityScale adds the engine's gravity * - An emitter with an emitTime destroys itself once its last particle is gone, like the 2D emitter * - Callbacks as the 2D emitter's: particleCreateCallback, particleUpdateCallback, particleCollideCallback and * particleDestroyCallback, each given a Particle3D, one object the emitter reuses for every particle and call * - collideLevel, off by default, has particles hit the height maps and voxel maps, bouncing by restitution and * sliding by friction * @extends EngineObject3D * @memberof Render3D * @example * // fire: a stream upward, yellow fading to transparent red, additive * new ParticleEmitter3D(vec3(), .5, 0, 100, .3, undefined, hsl(.12, 1, .6), hsl(.08, 1, .5), hsl(0, 1, .5, 0), hsl(0, 1, .25, 0), 1, .5, 1.5, .05, .95, 0, .3, .2, true); */ class ParticleEmitter3D extends EngineObject3D { /** Create a particle emitter * @param {Vector3} [pos3D] - World space position of the emitter * @param {number|Vector3} [emitSize] - Spawn area, a number for a sphere diameter or a vec3 for a box * @param {number} [emitTime] - How long to keep emitting, 0 is forever * @param {number} [emitRate] - Particles per second, 0 does not emit * @param {number} [emitConeAngle] - Half angle around the emit direction, PI is every direction * @param {TileInfo|TextureInfo} [tileInfo] - Tile to render particles with, or a whole texture, undefined is untextured * @param {Color} [colorStartA] - Color at start of life, randomized between the start colors * @param {Color} [colorStartB] * @param {Color} [colorEndA] - Color at end of life, randomized between the end colors * @param {Color} [colorEndB] * @param {number} [particleTime] - How long particles live in seconds * @param {number} [sizeStart] - Particle size at start of life * @param {number} [sizeEnd] - Particle size at end of life * @param {number} [speed] - Spawn speed in world units per frame * @param {number} [damping] - Per frame velocity multiplier, 1 is none * @param {number} [gravity] - Per frame change to velocity y, negative pulls down; its own number, and * gravityScale adds a share of render3D.gravity on top * @param {number} [fadeRate] - Fraction of life spent fading, half in and half out * @param {number} [randomness] - Extra randomness applied to speed, size and life * @param {boolean} [additive] - Additive blending */ constructor(pos3D=vec3(), emitSize=0, emitTime=0, emitRate=100, emitConeAngle=PI, tileInfo, colorStartA=WHITE, colorStartB=WHITE, colorEndA=CLEAR_WHITE, colorEndB=CLEAR_WHITE, particleTime=.5, sizeStart=.1, sizeEnd=1, speed=.1, damping=1, gravity=0, fadeRate=.1, randomness=.2, additive=false) { super(pos3D, undefined, tileInfo); this.transparent = true; this.castShadow = false; this.size3D = vec3(); // not a solid thing to pick or collect /** @property {number|Vector3} - Spawn area, a number for a sphere diameter or a vec3 for a box */ this.emitSize = emitSize; /** @property {boolean} - Flatten the spawn area across the way it emits, its own up: a sphere becomes a disc * and a box a flat rectangle, for rain from a sheet of sky or flames from a patch of ground */ this.emitFlat = false; /** @property {number} - How long to keep emitting, 0 is forever; raised while its particles are still alive, * it emits again for the added time */ this.emitTime = emitTime; /** @property {number} - Particles per second, 0 does not emit */ this.emitRate = emitRate; /** @property {number} - Half angle around the emit direction, PI is every direction */ this.emitConeAngle = emitConeAngle; /** @property {Color} - Color at start of life, randomized between the start colors */ this.colorStartA = colorStartA.copy(); /** @property {Color} - Color at start of life, randomized between the start colors */ this.colorStartB = colorStartB.copy(); /** @property {Color} - Color at end of life, randomized between the end colors */ this.colorEndA = colorEndA.copy(); /** @property {Color} - Color at end of life, randomized between the end colors */ this.colorEndB = colorEndB.copy(); /** @property {number} - How long particles live in seconds */ this.particleTime = particleTime; /** @property {number} - Particle size at start of life */ this.sizeStart = sizeStart; /** @property {number} - Particle size at end of life */ this.sizeEnd = sizeEnd; /** @property {number} - Spawn speed in world units per frame */ this.speed = speed; /** @property {number} - Per frame velocity multiplier */ this.damping = damping; /** @property {number} - Per frame change to velocity y, its own number and not render3D.gravity */ this.gravity = gravity; /** @property {number} - Share of render3D.gravity added each frame on top of its own gravity, 0 by default so * an effect keeps its own fall wherever it is used; 1 falls with the world, like debris */ this.gravityScale = 0; /** @property {number} - Fraction of life spent fading, half in and half out */ this.fadeRate = fadeRate; /** @property {number} - Extra randomness applied to speed, size and life */ this.randomness = randomness; /** @property {boolean} - Additive blending */ this.additive = additive; /** @property {number} - Seconds of each particle's path to draw as a ribbon behind it, 0 draws billboards; * with engineVariableStep it keeps trailTime * frameRate updates of path */ this.trailTime = 0; /** @property {number} - Radians per frame each particle turns in the camera plane, either way; 0 is no spin */ this.angleSpeed = 0; /** @property {number} - Per frame multiplier on that spin, 1 keeps it */ this.angleDamping = 1; /** @property {Float32Array} - The live particles, 21 floats each: position, velocity, start and end color, start * and end size, life, age, angle, spin, and trail point count; the emitter owns them, nothing else needs to */ this.particleData = new Float32Array(64 * RENDER3D_PARTICLE_FLOATS); /** @property {number} - How many particles are alive, the first that many of particleData */ this.particleCount = 0; /** @property {Float32Array|undefined} - The trail points of every particle, trailMax per particle oldest first, when trailTime is set * @type {Float32Array|undefined} */ this.trailData = undefined; /** @property {number} - Trail points kept per particle, from trailTime */ this.trailMax = 0; /** @property {boolean} - Particles hit the level, the height maps and voxel maps, bouncing by restitution and * sliding along by friction; off by default, it tests each particle's move against the level every frame */ this.collideLevel = false; /** @property {number} - How much a particle grips where it lands, 0 to 1: its speed along the surface is * cut by this much on each hit, on top of the friction, 1 stops it there */ this.stick = 0; /** @property {Particle3DCallback|undefined} - Called with each particle as it is made * @type {Particle3DCallback|undefined} */ this.particleCreateCallback = undefined; /** @property {Particle3DCallback|undefined} - Called with each particle each update, after it moves * @type {Particle3DCallback|undefined} */ this.particleUpdateCallback = undefined; /** @property {Particle3DCollideCallback|undefined} - Decides if a particle stops where it hits the level, * with collideLevel on; a callback that returns nothing lets it through * @type {Particle3DCollideCallback|undefined} */ this.particleCollideCallback = undefined; /** @property {Particle3DCallback|undefined} - Called with each particle as it goes, its life over or destroyed * @type {Particle3DCallback|undefined} */ this.particleDestroyCallback = undefined; /** @property {(function(ParticleEmitter3D): void)|undefined} - Called once with the emitter when it * finishes, as the 2D emitter's: its last particle gone, not when destroyed immediately * @type {(function(ParticleEmitter3D): void)|undefined} */ this.finishCallback = undefined; /** @property {Particle3D} - The particle the callbacks get, one object for every particle and call * @type {Particle3D} */ this.particleView = render3DParticleView(this); /** @property {Vector3|undefined} - Where the emitter was at its last update, for when its parent is destroyed * @type {Vector3|undefined} */ this.worldPos3D = undefined; this.emitTimeBuffer = 1; // the first particle comes at once, as the 2D emitter's does this.emitElapsed = 0; // seconds of its emit time it has emitted for, counted by its updates // where it was last update, and how far back toward it the particle being made starts, 0 to 1: a moving // emitter spreads an update's particles along its move, as the 2D emitter does /** @type {Vector3|undefined} */ this.emitFrom = undefined; this.emitBehind = 0; } /** Spawn new particles, move the live ones, and go away when done */ update() { // one transform for the frame: where the emitter is, and how big the effect it makes is const matrix = render3DObjectMatrix(this); // the object's own, read only this.emitFrom = this.worldPos3D; this.worldPos3D = matrix.getTranslation(); // remembered for when the parent is destroyed const scale = render3DMaxScale(matrix.m); this.particleView.scale = scale; // the callbacks read it from the view instead of working it out each time // emit until the emit time is up, then wait for the last particle and go away if (!this.emitTime || this.emitElapsed < this.emitTime) { // a rate of zero is an emitter fed by hand, and the global scale only quiets it, // neither is a reason to stop counting down the emit time; the part of this update inside the emit time, // counted by its own updates, as the 2D emitter does, so a one shot gives the same count on any frame const rate = this.emitRate * particleEmitRateScale; const step = this.emitTime ? min(timeDelta, this.emitTime - this.emitElapsed) : timeDelta; this.emitElapsed += timeDelta; if (rate > 0 && rate < Infinity) { // the update that ends the emit time adds a hair, as the steps' sum comes out a rounding under it this.emitTimeBuffer += rate * step + (this.emitTime && this.emitElapsed >= this.emitTime ? 1e-6 : 0); // spread along the move since the last update, the last one where it is now; a jump further than // anything travels in an update, twice objectMaxSpeed, a teleport or a respawn, spreads nothing const travelled = this.emitFrom && this.worldPos3D.distance(this.emitFrom) <= 2 * objectMaxSpeed; const count = floor(this.emitTimeBuffer); try { for (let i = count; i--; --this.emitTimeBuffer) { this.emitBehind = travelled ? i / count : 0; this.emitParticle(); } } finally { this.emitBehind = 0; } // a create callback that throws leaves no offset for the next } } else if (!this.particleCount) this.destroy(); // the trail storage follows trailTime at the fixed rate, a change starts every trail over, so a varying // timeDelta can not; with engineVariableStep a trail keeps that many updates const trailMax = this.trailTime ? max(1, round(this.trailTime * frameRate)) : 0; if (trailMax !== this.trailMax) { this.trailMax = trailMax; this.trailData = trailMax ? new Float32Array(this.particleData.length / RENDER3D_PARTICLE_FLOATS * trailMax * 3) : undefined; for (let k = 20; k < this.particleData.length; k += RENDER3D_PARTICLE_FLOATS) this.particleData[k] = 0; } // move the particles and drop the dead ones, all in the typed array: this runs for every particle every frame // a callback can emit, which grows the arrays when they are full, so they are read again after each one const F = RENDER3D_PARTICLE_FLOATS; let data = this.particleData, trail = this.trailData; const reread = ()=> { data = this.particleData, trail = this.trailData; }; // its own fall and a share of the world's, grown by the scale: a bigger effect has to fall faster to keep the // same arc const damping = this.damping, angleDamping = this.angleDamping, g = render3D.gravity, share = this.gravityScale; const gravityX = g.x * share * scale, gravityY = (this.gravity + g.y * share) * scale; const gravityZ = g.z * share * scale; const collideLevel = this.collideLevel && enablePhysicsSolver && render3DLevel.length; // as 2D particles const updateCallback = this.particleUpdateCallback, destroyCallback = this.particleDestroyCallback; for (let i = this.particleCount; i--;) { // damped first and gravity added after, the order the 2D particle uses, so the same // damping and gravity give the same arc in both const k = i * F; const vx = data[k+3] = data[k+3] * damping + gravityX, vy = data[k+4] = data[k+4] * damping + gravityY; const vz = data[k+5] = data[k+5] * damping + gravityZ; data[k] += vx, data[k+1] += vy, data[k+2] += vz; data[k+18] += data[k+19] *= angleDamping; // true when its collide callback destroyed it, it has had its last callback but the destroy one const gone = collideLevel && this.particleCollide(k, data[k] - vx, data[k+1] - vy, data[k+2] - vz); collideLevel && reread(); const t = i * trailMax * 3; if (trailMax) { // remember where it has been, oldest first; a full trail drops its oldest point let n = data[k+20]; if (n === trailMax) trail.copyWithin(t, t + 3, t + n * 3), --n; const j = t + n * 3; trail[j] = data[k], trail[j+1] = data[k+1], trail[j+2] = data[k+2]; data[k+20] = n + 1; } updateCallback && !gone && (this.particleCall(updateCallback, k), reread()); if ((data[k+17] += timeDelta) >= data[k+16]) { // dead: the last particle takes its slot, trail and all, order does not matter destroyCallback && (this.particleCall(destroyCallback, k), reread()); const last = --this.particleCount, kl = last * F; if (i !== last) { data.copyWithin(k, kl, kl + F); trailMax && trail.copyWithin(t, last * trailMax * 3, (last + 1) * trailMax * 3); } } } } /** Stop emitting, and go away once the particles already out have finished like the 2D emitter's do * @param {boolean} [immediate] */ destroy(immediate) { if (immediate || this.destroyed) return super.destroy(immediate); if (!this.particleCount) { super.destroy(immediate); this.finishCallback?.(this); // after, so one that throws is not called again return; } this.emitTime = -1; // stops emitting, and update destroys it once the particles are gone render3DDetach(this); // the particles are in world space, they no longer need the parent } /** Spawn one particle now */ emitParticle() { const random = ()=> rand(1 - this.randomness, 1 + this.randomness); const matrix = render3DObjectMatrix(this); // the object's own, read only // the whole effect grows with the emitter, not just the area the particles start in const scale = render3DMaxScale(matrix.m); // spawn offset: inside a box or a sphere, or flat across the emitter's up, a flat rectangle or a disc const size = this.emitSize, box = /** @type {Vector3} */ (size), flat = this.emitFlat; let offset; if (isVector3(size)) offset = vec3(rand(-.5, .5) * box.x, flat ? 0 : rand(-.5, .5) * box.y, rand(-.5, .5) * box.z); else if (flat) { const disc = randInCircle(/** @type {number} */ (size) / 2); offset = vec3(disc.x, 0, disc.y); } else offset = randInSphere(/** @type {number} */ (size) / 2); // direction inside the cone around local +Y const direction = matrix.transformDirection(randVector3(1, this.emitConeAngle)).normalize(); const pos = matrix.transformPoint(offset), speed = this.speed * random() * scale; const from = this.emitFrom, behind = this.emitBehind; // back along its move, as the update spreads them if (from && behind) { const here = this.worldPos3D; pos.x += (from.x - here.x) * behind, pos.y += (from.y - here.y) * behind, pos.z += (from.z - here.z) * behind; } const colorStart = randColor(this.colorStartA, this.colorStartB, true), colorEnd = randColor(this.colorEndA, this.colorEndB, true); // room for one more, doubling as the set grows, the trails along with it const F = RENDER3D_PARTICLE_FLOATS, k = this.particleCount++ * F; if (k + F > this.particleData.length) { const grown = new Float32Array(this.particleData.length * 2); grown.set(this.particleData); this.particleData = grown; if (this.trailData) { const trails = new Float32Array(this.trailData.length * 2); trails.set(this.trailData); this.trailData = trails; } } const data = this.particleData; data[k] = pos.x, data[k+1] = pos.y, data[k+2] = pos.z; data[k+3] = direction.x * speed, data[k+4] = direction.y * speed, data[k+5] = direction.z * speed; data[k+6] = colorStart.r, data[k+7] = colorStart.g, data[k+8] = colorStart.b, data[k+9] = colorStart.a; data[k+10] = colorEnd.r, data[k+11] = colorEnd.g, data[k+12] = colorEnd.b, data[k+13] = colorEnd.a; data[k+14] = this.sizeStart * random() * scale; data[k+15] = this.sizeEnd * random() * scale; data[k+16] = this.particleTime * random(); // life data[k+17] = 0; // age // a spinning particle starts anywhere and turns either way, one that is not stays at zero data[k+18] = this.angleSpeed ? rand(2*PI) : 0; data[k+19] = this.angleSpeed ? this.angleSpeed * random() * randSign() : 0; data[k+20] = 0; // trail points this.particleView.scale = scale; // the scale it was made at, emitted before the first update too this.particleCreateCallback && this.particleCall(this.particleCreateCallback, k); } // call a particle callback with the particle at k: the emitter's one Particle3D is set from its numbers, and // what the callback changes is written back; a particle it destroys has lived its life, the update removes it /** @private */ particleCall(callback, k, level, pos) { // a callback that emits runs the create callback inside itself, which gets a view of its own so this one // keeps its particle; what the callback changes is written to the arrays as they are after it, since an // emit into a full emitter grows them const nested = render3DParticlesCalling.has(this); const view = nested ? render3DParticleView(this) : this.particleView, data = this.particleData; view.scale = this.particleView.scale; // a nested view's too view.pos.set(data[k], data[k+1], data[k+2]); view.velocity.set(data[k+3], data[k+4], data[k+5]); view.age = data[k+17], view.lifeTime = data[k+16], view.destroyed = false; render3DParticlesCalling.add(this); let result; try { result = callback(view, level, pos); } finally { nested || render3DParticlesCalling.delete(this); } const out = this.particleData; out[k] = view.pos.x, out[k+1] = view.pos.y, out[k+2] = view.pos.z; out[k+3] = view.velocity.x, out[k+4] = view.velocity.y, out[k+5] = view.velocity.z; out[k+16] = view.lifeTime, out[k+17] = view.age; if (view.destroyed) out[k+17] = max(out[k+17], out[k+16]); // its life lived: a step short of it can round to just under return result; } // stop the particle at k where its last move went into the level, from where it was: back at the surface, a hair // off it, its speed into it turned around by restitution and its speed along it kept by friction, unless the // collide callback lets it through; one that starts inside is let go, as a 2D one is; true when the callback // destroyed it /** @private */ particleCollide(k, x, y, z) { let data = this.particleData; const from = render3DParticleFrom.set(x, y, z); const to = render3DParticleTo.set(data[k], data[k+1], data[k+2]); let hit, level; for (const l of render3DLevel) { const h = l.destroyed ? undefined : l.levelSegment3D(from, to); if (h && (!hit || h.distance < hit.distance)) hit = h, level = l; } if (!hit) return false; const point = from.lerp(to, hit.distance); const callback = this.particleCollideCallback; if (callback && (!this.particleCall(callback, k, level, point.copy()) || this.particleView.destroyed)) return this.particleView.destroyed; // let through, or destroyed by the callback data = this.particleData; // as the callback left it, an emit may have grown it const n = hit.normal, v = vec3(data[k+3], data[k+4], data[k+5]), into = n.scale(v.dot(n)); // the larger friction of the two, as objects take it, then the emitter's own grip, which a level can not undo const restitution = max(this.restitution, level.restitution); const friction = max(this.friction, level.friction) * (1 - clamp(this.stick)); const out = v.subtract(into).scale(friction).subtract(into.scale(restitution)), p = point.add(n.scale(1e-3)); data[k] = p.x, data[k+1] = p.y, data[k+2] = p.z; data[k+3] = out.x, data[k+4] = out.y, data[k+5] = out.z; return false; } /** Draw the particles, as flat squares or as streaks when trailTime is set * - The whole emitter sorts as one thing, its particles are not sorted against each other * - With render3D.instancing on the squares go out as one instanced draw of render3D.billboardMesh * @return {void} */ render3D() { const r = render3D; if (r.transparentQueue) return r.queueTransparent(this.getWorldPos3D(), ()=> this.render3D()); const fade = this.fadeRate / 2, texture = this.tileInfo || render3DSoftDot(); // no dot headless const color = render3DParticleColor, size = render3DParticleSize; // shared, so a particle makes no objects // the instanced path: one matrix per particle, its right and up axes the quad's size long, facing the camera, // written straight into the batch; the batch draws at the end so the order of the transparent stage holds const quad = r.billboardMesh, instanced = texture && r.instancing && !r.capture && render3DCanDraw(); let data, textureInfo, uv, lit; if (instanced) { render3DMeshUpload(quad); textureInfo = render3DTextureOf(texture); uv = render3DGetTileUVs(texture); lit = r.lighting; r.lighting = r.shadowPass && lit; // unlit on screen, in the shadow map the object's flag decides r.cullBackFaces = r.mirrored = false; } const cb = r.cameraBack; const F = RENDER3D_PARTICLE_FLOATS, particles = this.particleData, trailMax = this.trailMax, pos = render3DParticlePos; try { for (let i = 0, count = this.particleCount; i < count; ++i) { const p = i * F, t = particles[p+17] / particles[p+16]; const alpha = t < fade ? t / fade : t > 1 - fade ? (1 - t) / fade : 1; color.r = particles[p+6] + (particles[p+10] - particles[p+6]) * t; color.g = particles[p+7] + (particles[p+11] - particles[p+7]) * t; color.b = particles[p+8] + (particles[p+12] - particles[p+8]) * t; color.a = (particles[p+9] + (particles[p+13] - particles[p+9]) * t) * alpha; const s = size.x = size.y = lerp(particles[p+14], particles[p+15], t), angle = particles[p+18]; const trailCount = particles[p+20]; if (trailCount > 1) { // a ribbon from the tail to the head, the tail thins and fades out const trail = this.trailData, points = [], widths = [], colors = []; for (let j = 0; j < trailCount; ++j) { const f = (j + 1) / trailCount, q = (i * trailMax + j) * 3; points.push(vec3(trail[q], trail[q+1], trail[q+2])); widths.push(s * f); colors.push(color.scale(1, f)); } r.drawRibbon(points, widths, this.tileInfo, colors); } else if (instanced) { // the quad's half axes, turned as drawBillboard turns them, doubled for billboardMesh's unit square const a = render3DBillboardAxes(size, angle, false), k = render3DInstanceSlot(quad, textureInfo); data = quad.instanceData; data[k] = a[0] * 2; data[k+1] = a[1] * 2; data[k+2] = a[2] * 2; data[k+3] = 0; data[k+4] = a[3] * 2; data[k+5] = a[4] * 2; data[k+6] = a[5] * 2; data[k+7] = 0; data[k+8] = cb.x; data[k+9] = cb.y; data[k+10] = cb.z; data[k+11] = 0; data[k+12] = particles[p]; data[k+13] = particles[p+1]; data[k+14] = particles[p+2]; data[k+15] = 1; data[k+16] = color.r, data[k+17] = color.g, data[k+18] = color.b, data[k+19] = color.a; data[k+20] = uv.x; data[k+21] = uv.y; data[k+22] = uv.w; data[k+23] = uv.h; } else { pos.x = particles[p], pos.y = particles[p+1], pos.z = particles[p+2]; if (texture) r.drawBillboard(pos, size, texture, color, angle); else r.drawSoftDisc(pos, s, color, undefined, 8); // no canvas for the dot, headless } } } finally { if (instanced) { r.lighting = lit; r.flush(); // whatever the stream holds from before this emitter draws first render3DFlushInstances(quad); } } } } /////////////////////////////////////////////////////////////////////////////// /** * Trail3D - A ribbon through where the object has been, thinning and fading with age * - Records its world position each frame it moves, so parent it to something that moves or set pos3D yourself * - The samples are world space, so width is a world width and scale3D does nothing to the ribbon * - Drawn unlit in the transparent stage, dies down on its own once the object stops * @extends EngineObject3D * @memberof Render3D * @example * const trail = new Trail3D(vec3(), 1, .3, undefined, hsl(.08, 1, .5), hsl(0, 1, .5, 0), true); * ball.addChild(trail); // follows the ball */ class Trail3D extends EngineObject3D { /** Create a trail * @param {Vector3} [pos3D] * @param {number} [lifeTime] - Seconds the ribbon takes to thin and fade from head to tail, * Infinity keeps every sample at full width and never drops one, so it grows as long as the object moves * @param {number} [width] - Width at the head, it thins to nothing at the tail * @param {TileInfo|TextureInfo} [tileInfo] - Tile or whole texture stretched along the trail, undefined is untextured * @param {Color} [color] - Color at the head * @param {Color} [colorEnd] - Color at the tail * @param {boolean} [additive] - Additive blending */ constructor(pos3D=vec3(), lifeTime=1, width=.2, tileInfo, color=WHITE, colorEnd=CLEAR_WHITE, additive=false) { super(pos3D, undefined, tileInfo, color); this.transparent = true; this.additive = additive; this.castShadow = false; this.size3D = vec3(); // not a solid thing to pick or collect this.finishing = false; // set by destroy, the ribbon fades out then goes away /** @property {number} - Seconds the ribbon takes to thin and fade from head to tail, Infinity never drops a sample */ this.lifeTime = lifeTime; /** @property {number} - Width at the head */ this.width = width; /** @property {Color} - Color at the tail */ this.colorEnd = colorEnd.copy(); /** @property {Vector3|undefined} - Direction across the ribbon, recorded with each sample, undefined faces the camera * @type {Vector3|undefined} */ this.side = undefined; /** @property {Array<{pos: Vector3, side: Vector3|undefined, time: number}>} - Recorded samples, oldest first * @type {Array<{pos: Vector3, side: Vector3|undefined, time: number}>} */ this.samples = []; } /** Forget the trail so far, for when the object teleports */ clear() { this.samples.length = 0; } /** Stop recording, and go away once the ribbon has faded * @param {boolean} [immediate] */ destroy(immediate) { if (immediate || !this.samples.length || this.destroyed || this.lifeTime == Infinity) return super.destroy(immediate); this.finishing = true; render3DDetach(this); // the samples are in world space, they no longer need the parent } /** Record the position when it moved and drop old samples, called automatically each frame */ update() { const samples = this.samples; if (!this.finishing) { const pos = this.worldPos3D = this.getWorldPos3D(), last = samples[samples.length - 1]; if (!last || pos.distanceSquared(last.pos) > 1e-8) samples.push({pos, side: this.side?.copy(), time}); } while (samples.length && time - samples[0].time > this.lifeTime) samples.shift(); this.finishing && !samples.length && this.destroy(); } /** Draw the ribbon * @return {void} */ render3D() { const samples = this.samples; if (samples.length < 2) return; const points = [], widths = [], colors = [], sides = this.side ? [] : undefined; for (const s of samples) { const age = clamp((time - s.time) / this.lifeTime); points.push(s.pos); widths.push(this.width * (1 - age)); colors.push(this.color.lerp(this.colorEnd, age)); sides?.push(s.side); } render3D.drawRibbon(points, widths, this.tileInfo, colors, sides); } } /////////////////////////////////////////////////////////////////////////////// // lens flare // the shapes a flare is made of, white on clear on one smooth texture made the first time it is asked for: a soft // glow, a flat disc with a brighter rim, a ring, a hexagon like the blades of a lens, a streak across, and a star // of six rays; undefined headless or without a canvas const render3DFlareShapes = ['glow', 'disc', 'ring', 'hex', 'streak', 'star']; let render3DFlareTiles; function render3DFlareTile(shape) { if (!render3DFlareTiles) { if (headlessMode || !glContext || !canvasAvailable()) return; const cell = 128, shapes = render3DFlareShapes, context = createCanvasContext(cell * shapes.length, cell); // each shape is how see-through it is from its middle out const alpha = { glow: (t)=> engineGlowAlpha(t, 1), disc: (t)=> t < .8 ? .4 + .1 * t : t < .92 ? .48 + (t - .8) * 3 : (1 - t) / .08 * .84, ring: (t)=> max(0, 1 - abs(t - .86) / .12), hex: (t)=> t < .8 ? .4 + .1 * t : t < .92 ? .48 + (t - .8) * 3 : (1 - t) / .08 * .84, streak: (t)=> (1 - t) ** 1.5, }; // a round gradient from the middle of the cell out, squashed for a ray and cut to a path for the hexagon const fill = (shape, r, path)=> { const steps = 32, gradient = context.createRadialGradient(0, 0, 0, 0, 0, r); for (let k = 0; k <= steps; ++k) gradient.addColorStop(k / steps, 'rgba(255,255,255,' + clamp(alpha[shape](k / steps)).toFixed(4) + ')'); context.fillStyle = gradient; context.beginPath(); path ? path() : context.arc(0, 0, r, 0, 2 * PI); context.fill(); }; const ray = (r, angle)=> { context.save(); context.rotate(angle); context.scale(1, .07); fill('streak', r); context.restore(); }; shapes.forEach((shape, i)=> { const r = cell / 2 - 2; context.save(); context.translate(i * cell + cell / 2, cell / 2); if (shape == 'hex') { // a gradient is round, so the hexagon is set pixel by pixel, by how far each is toward a side const image = context.createImageData(cell, cell), data = image.data, apothem = r * cos(PI / 6); for (let k = 0; k < cell * cell; ++k) { const x = abs(k % cell + .5 - cell / 2), y = abs((k / cell | 0) + .5 - cell / 2); const t = max(y, x * cos(PI / 6) + y / 2) / apothem; data[k * 4] = data[k * 4 + 1] = data[k * 4 + 2] = 255; data[k * 4 + 3] = t < 1 ? clamp(alpha.hex(t)) * 255 : 0; } context.putImageData(image, i * cell, 0); } else if (shape == 'streak') ray(r, 0); else if (shape == 'star') { for (let k = 3; k--;) ray(r, k * PI / 3 + PI / 2); context.scale(.3, .3); fill('glow', r); } else fill(shape, r); context.restore(); }); const texture = new TextureInfo(context.canvas, true, false, false); // smooth even in a pixel art game render3DFlareTiles = new Map(shapes.map((shape, i)=> [shape, new TileInfo(vec2(i * cell, 0), vec2(cell), texture)])); } return render3DFlareTiles.get(shape) || render3DFlareTiles.get('glow'); } /** * A part of a lens flare * @typedef {Object} LensFlareElement * @property {number} at - Where along the line: 0 the sun, 1 the middle of the screen, 2 as far past it * @property {number|Vector2} size - How big across, as a part of the screen's height, a vector for a part wider * than it is tall * @property {Color} color - Its color, the alpha how bright * @property {string} [shape] - glow, disc, ring, hex, streak or star, a glow when left out * @property {TileInfo} [tileInfo] - A tile of the game's own to draw in place of a shape, best white on clear * @property {number} [angle] - How far it is turned, in radians * @memberof Render3D */ /** * LensFlare3D - The sun's lens flare, the old kind: a glow at the sun and a row of discs and rings of different * sizes along the line from the sun through the middle of the screen * - Make one and it shows, over the 3D scene and under what the game draws after, a HUD; destroy it to take it away * - It follows render3D.sunDirection, and fades out as the sun leaves the screen or goes behind something * - A Light3D gets one of its own with light.addFlare() * - flareSize, count, intensity and saturation set its look, seed picks another arrangement, and its color tints it, * with the sun's own color; shapes says what its ghosts are, glowSize and ghostSize how big its parts are; or * give it elements of your own, which may be tiles of the game's * - visible is how much of the sun shows, 0 to 1, eased over fadeTime, there for a game to read; it is 0 while the * sun is too far off the screen for the flare to show, about a seventh of the screen past its edge, where nothing * is tested * - What hides the sun is found with a ray from the camera, against the level and every object that is not see * through, each on the triangles of its mesh, see render3D.pick; turn it off with occlusion * - It needs WebGL, and it draws nothing in the shadow of renderAfter2D * @extends EngineObject3D * @memberof Render3D * @example * new LensFlare3D; // the sun flares * new LensFlare3D(1.5, 10, .7, 0); // bigger, 10 ghosts, dimmer, all one color */ class LensFlare3D extends EngineObject3D { /** Create the sun's lens flare * @param {number} [size] - Scales every part of it, 1 by default * @param {number} [count] - How many ghosts there are along the line, besides the glow at the sun * @param {number} [intensity] - How bright it is * @param {number} [saturation] - How colorful the ghosts are, 0 for all the flare's own color, 1 a rainbow * @param {Color} [color] - Tints the whole flare, with the sun's color */ constructor(size=1, count=7, intensity=1, saturation=1, color=WHITE) { super(vec3(), undefined, undefined, color); this.size3D = vec3(); // not a thing to pick or collect /** @property {number} - Scales every part of the flare */ this.flareSize = size; /** @property {number} - How many ghosts there are along the line, besides the glow at the sun */ this.count = count; /** @property {number} - How bright it is */ this.intensity = intensity; /** @property {number} - How colorful the ghosts are, 0 for all the flare's own color, 1 a rainbow */ this.saturation = saturation; /** @property {number} - Picks the arrangement of the ghosts, another seed is another flare */ this.seed = 1; /** @property {Array|undefined} - The shapes the ghosts are picked from, glow, disc, ring, hex, * streak or star: ['hex'] makes every ghost a hexagon, and a shape listed twice is picked twice as often; * discs, rings and glows when not set * @type {Array|undefined} */ this.shapes = undefined; /** @property {number} - Scales the glow at the sun, 0 for none */ this.glowSize = 1; /** @property {number} - Scales the ghosts */ this.ghostSize = 1; /** @property {Array|undefined} - The parts of the flare, to set your own in place of the * ones made from count, seed, saturation, shapes, glowSize and ghostSize * @type {Array|undefined} */ this.elements = undefined; /** @property {Light3D|undefined} - A light the flare is of in place of the sun, a lamp or a spotlight: the * flare is at the light and in its color, smaller from farther than the light reaches, hidden by what is * in front of the light but its lamp, a mesh around it whose surface is near it, and a spotlight's * shows from inside its beam only; a DirectionalLight3D's is far * away where it shines from, like the sun's; the flare is destroyed when its light is; light.addFlare * sets this * @type {Light3D|undefined} */ this.light = undefined; /** @property {boolean} - Fade out when something is between the camera and the sun */ this.occlusion = true; /** @property {number} - Seconds the flare takes to fade out or in when the sun is hidden or shows again */ this.fadeTime = .15; /** @property {number} - How much of the sun shows, 0 hidden, off the screen or behind the camera to 1 in * plain view, eased; the first update sets it at once, so a sun hidden from the start never shows */ this.visible = 0; this.visibleFound = false; // the first update has found it this.renderOrder = 1e9; // over the game's sprites, it is light in the lens this.madeKey = ''; /** @type {Array} */ this.made = []; } /** The parts of the flare: the elements set by hand, or the ones made from count, seed, saturation, shapes, * glowSize and ghostSize, a glow and a core at the sun and the ghosts, made again when one of those changes * @return {Array} */ getElements() { if (this.elements) return this.elements; const shapes = this.shapes?.length ? this.shapes : undefined, glow = this.glowSize, ghost = this.ghostSize; const key = [this.count, this.seed, this.saturation, glow, ghost, shapes?.length, shapes].join(); if (key !== this.madeKey) { const random = new RandomGenerator(this.seed * 7919 + 1), s = clamp(this.saturation); /** @type {Array} */ const made = this.made = glow > 0 ? [ {at: 0, size: .7 * glow, color: hsl(0, 0, 1, .5), shape: 'glow'}, {at: 0, size: .25 * glow, color: hsl(0, 0, 1, .9), shape: 'glow'}] : []; for (let i = 0; i < this.count; ++i) { // spread along the line, each a place of its own, the far ones bigger const at = (i + random.float(.2, .8)) / max(this.count, 1) * 1.9 + .2; const pick = random.float(), shape = shapes ? shapes[min(pick * shapes.length | 0, shapes.length - 1)] : pick < .5 ? 'disc' : pick < .8 ? 'ring' : 'glow'; made.push({at, size: random.float(.04, .1) * (1 + at) * ghost, shape, color: hsl(random.float(), s * .9, .6, random.float(.15, .4))}); } this.madeKey = key; } return this.made; } /** What the flare is of, seen from the camera: the way to it, how far it is, Infinity for the sun and for a * directional light, which shines from its place toward the origin, and a point to find it on the screen by; * undefined with no direction, or a light that is gone or at the camera * @return {{direction: Vector3, distance: number, pos: Vector3}|undefined} * @ignore */ flareSource() { const camera = render3D.camera.pos, light = this.light; if (light?.destroyed) return; if (!light || light.directional) { const from = light ? light.getWorldPos3D() : render3D.sunDirection; if (!from.lengthSquared()) return; const direction = from.normalize(); return {direction, distance: Infinity, pos: camera.add(direction.scale(100))}; } const pos = light.getWorldPos3D(), offset = pos.subtract(camera), distance = offset.length(); return distance ? {direction: offset.scale(1 / distance), distance, pos} : undefined; } /** How the flare would show with nothing in the way: where its source is on the screen, how strong it is there, * fading as it leaves the screen, its tint and the height its sizes are parts of; undefined when it would not * show at all, behind the camera, off the screen, or of a light that is off or seen from outside its cone * @return {{sun: Vector2, center: Vector2, strength: number, tint: Color, height: number}|undefined} * @ignore */ flareLook() { const source = this.flareSource(), center = mainCanvasSize.scale(.5); const sun = source && render3D.worldToScreen(source.pos); if (!sun || !center.x || !center.y) return; // it fades as the sun leaves the screen, gone .3 of half the screen past the edge const off = max(abs(sun.x - center.x) / center.x, abs(sun.y - center.y) / center.y); const strength = clamp((1.3 - off) / .5) * this.intensity; if (!(strength > 0)) return; let tint = this.color.multiply(render3D.sunColor), height = mainCanvasSize.y * this.flareSize; const light = this.light; if (light) { // a light's flare is the light's color, as bright as the light up to 1, and only from inside its cone const cone = render3DLightCone(light), d = source.direction; const inCone = clamp(-(cone[0] * d.x + cone[1] * d.y + cone[2] * d.z) - cone[3]); const c = light.color, bright = c.a * clamp(light.intensity) * inCone; if (!(bright > 0)) return; tint = this.color.multiply(rgb(c.r, c.g, c.b, bright)); if (!light.directional) height *= min(1, light.radius / source.distance); // smaller from farther than it reaches } return height > 0 ? {sun, center, strength, tint, height} : undefined; } /** Where the sun, or the flare's light, is on the screen, in pixels like mousePosScreen, undefined when it is * behind the camera * @return {Vector2|undefined} */ getScreenPos() { const source = this.flareSource(); return source && render3D.worldToScreen(source.pos); } /** The parts of the flare as they are drawn now: each one's place on the screen, its size in pixels, a vector * when the element's is, and its color, dimmed by how much of the sun shows; empty when there is nothing to draw * @return {Array<{pos: Vector2, size: number|Vector2, color: Color, shape: string|undefined, * tileInfo: TileInfo|undefined, angle: number}>} */ getScreenElements() { // one made while the game is paused, as the 3D editor makes them, has had no update to find it, and while // paused nothing updates it, so it is found at once each time it is drawn, as the camera turns if (!this.visibleFound || paused) { this.visibleFound = false; this.updateVisible(0); } const look = this.visible > 0 && this.flareLook(); if (!look) return []; const {sun, center, tint, height} = look, strength = look.strength * this.visible; return this.getElements().map((e)=> { const c = e.color.multiply(tint); // along the line and past the middle, a lerp would stop there const size = typeof e.size == 'number' ? e.size * height : e.size.scale(height); return {pos: sun.add(center.subtract(sun).scale(e.at)), size, shape: e.shape, tileInfo: e.tileInfo, angle: e.angle || 0, color: rgb(c.r, c.g, c.b, c.a * strength)}; }); } /** Is something between the camera and the sun, or the flare's light: the level, or an object that is not see * through, hit on its triangles as render3D.pick hits it, so a mesh the camera is inside hides nothing unless * it is doubleSided * @return {boolean} */ isHidden() { const source = this.flareSource(); if (!source) return true; const ray = new Ray3D(render3D.camera.pos, source.direction), reach = source.distance; const blockers = engineObjects.filter((o)=> o !== this && o instanceof EngineObject3D && !o.destroyed && !o.transparent && !render3DSetting(o, 'additive')); // a voxel map says block by block what is see through: glass, water and leaves let the sun by, and the // ray goes on to the blocks behind them const maps = /** @type {Array} */ (typeof VoxelMap == 'undefined' ? [] : blockers.filter((o)=> o instanceof VoxelMap)); for (const map of maps) if (map.raycast(ray, reach, (type)=> !map.blockType(type).seeThrough)) return true; // a light's lamp, a mesh around the light, does not hide it: one whose box, in its own space, the light is in, // hit close to the light, as a shade or a globe is, and small, its thinnest side no more than twice that; a // room or a whole level holds its lights too, and its walls do hide them from outside, even one close behind const inside = (o)=> { if (!o.mesh || o instanceof HeightMap) return false; const matrix = render3DObjectMatrix(o), b = o.mesh.bounds || o.mesh.getBounds(); const m = matrix.m, scale = (k)=> hypot(m[k], m[k + 1], m[k + 2]); // each axis's own stretch const thinnest = min((b.max.x - b.min.x) * scale(0), (b.max.y - b.min.y) * scale(4), (b.max.z - b.min.z) * scale(8)); if (!matrix.determinant() || thinnest > near * 2) return false; const p = matrix.copy().invert().transformPoint(source.pos); return p.x >= b.min.x && p.x <= b.max.x && p.y >= b.min.y && p.y <= b.max.y && p.z >= b.min.z && p.z <= b.max.z; }; const near = this.light && reach < Infinity ? min(2, this.light.radius * .4) : 0; let candidates = blockers.filter((o)=> !maps.some((map)=> map === o)); for (let tries = 5; tries--;) { const hit = render3D.pick(ray, candidates); if (!hit || hit.distance >= reach) return false; if (!(reach - hit.distance <= near && inside(hit.object))) return true; candidates = candidates.filter((o)=> o !== hit.object); // its lamp, look past it } return true; // lamps on lamps, take it as hidden } /** Ease visible toward whether the sun shows, called automatically each frame; a flare that would not show * wherever the sun is, off the screen or its light off, looks for nothing in the way */ update() { super.update(); if (this.light?.destroyed) return this.destroy(); // the flare of a light that is gone this.updateVisible(timeDelta); } /** Ease visible toward whether the sun shows over some seconds; the first time it is found at once, from the * camera as it is, its matrices made, by the first update or the first draw, whichever comes first * @param {number} seconds * @ignore */ updateVisible(seconds) { this.visibleFound || render3D.updateMatrices(); const shows = !!this.flareLook() && !(this.occlusion && this.isHidden()); const step = this.fadeTime > 0 && this.visibleFound ? seconds / this.fadeTime : 1; this.visible = clamp(this.visible + (shows ? step : -step)); this.visibleFound = true; } /** Draw the flare over the 3D scene, added onto it, called automatically in the 2D pass */ render() { if (headlessMode || !glEnable) return; const elements = this.getScreenElements(); if (!elements.length) return; setAdditiveBlendMode(true); for (const e of elements) { const tileInfo = e.tileInfo || render3DFlareTile(e.shape); const size = typeof e.size == 'number' ? vec2(e.size) : e.size; tileInfo && drawTile(e.pos, size, tileInfo, e.color, e.angle, false, undefined, true, true); } setAdditiveBlendMode(false); } /** A flare has nothing to draw in the 3D pass */ render3D() {} } /////////////////////////////////////////////////////////////////////////////// // OBJ meshes /** * Parse Wavefront OBJ text into a Mesh * - Reads v, vt, vn and f lines with convex polygons of any size, materials and groups are ignored * - Normals come from the file when every corner of a face has one, otherwise from the face * - Use mesh.center() and mesh.fit(size) to bring a model of unknown units to the origin * - Back faces are skipped like any mesh, set doubleSided for a model with open walls or single sided parts * @param {string} text * @param {boolean} [smooth] - Compute smooth normals for the faces the file gives none, defaults to render3D.smoothShading * @return {Mesh} * @memberof Render3D * @example * new EngineObject3D(vec3(), parseOBJ(objText).center().fit(4)); */ function parseOBJ(text, smooth=render3D?.smoothShading) { const positions = [], normals = [], uvs = []; const points = [], vertexNormals = [], vertexUVs = [], indices = [], seen = new Map; const fromFile = []; // which vertices have a normal from the file, the rest are smoothed when smooth is on let missingNormals = false, face = 0; // OBJ indices count from 1, and a negative one counts back from the end of the list so far const index = (s, list)=> { const i = parseInt(s); return i < 0 ? list.length + i : i - 1; }; const lookup = (s, list)=> list[index(s, list)]; // any line end, a line carried on by a backslash at its end, and a comment after what a line holds for (const line of text.replace(/\\\r?\n/g, ' ').split(/\r\n?|\n/)) { const parts = line.replace(/#.*/, '').trim().split(/\s+/); switch (parts[0]) { case 'v': positions.push(vec3(+parts[1], +parts[2], +parts[3])); break; case 'vn': normals.push(vec3(+parts[1], +parts[2], +parts[3])); break; case 'vt': uvs.push(vec2(+parts[1], 1 - (+parts[2] || 0))); break; // v runs up and is 0 left out case 'f': { const corners = parts.slice(1).map(c=> c.split('/')); if (corners.length < 3) break; const facePoints = corners.map(c=> lookup(c[0], positions)); ASSERT(facePoints.every(isVector3), 'OBJ face uses a vertex index the file does not have', line); if (!facePoints.every(isVector3)) break; // a release build leaves the face out const hasNormals = corners.every(c=> c[2]); missingNormals ||= !hasNormals; const faceNormal = hasNormals ? undefined : render3DFaceNormal(facePoints[0], facePoints[1], facePoints[2], facePoints[3]); // a corner is one vertex with the same position, uv and normal; without file normals the face's own // normal keeps its corners apart, unless they will be smoothed, when the position and uv are enough const ids = corners.map((c, i)=> { // by the vertices they are, a negative index means another vertex as the lists grow const key = index(c[0], positions) + '/' + (c[1] ? index(c[1], uvs) : '') + '/' + (hasNormals ? index(c[2], normals) : smooth ? '' : 'f' + face); let id = seen.get(key); if (id === undefined) { seen.set(key, id = points.length); points.push(facePoints[i]); vertexUVs.push(c[1] ? lookup(c[1], uvs) : RENDER3D_DEFAULT_UV); vertexNormals.push(hasNormals ? lookup(c[2], normals) : faceNormal); fromFile.push(hasNormals); } return id; }); // a fan around the second corner, counter clockwise as the file lists them; that cuts a quad along // the same diagonal the strip form did, so a smoothed model shades the same as before for (let k = 2; k < ids.length; ++k) indices.push(ids[1], ids[k], ids[(k + 1) % ids.length]); ++face; } } } const mesh = new Mesh().addTriangles(points, indices, vertexNormals, vertexUVs); if (missingNormals && smooth) { // smooth the faces the file gave no normals, and keep the normals it did give const given = mesh.normals; mesh.computeNormals(true); fromFile.forEach((f, i)=> f && (mesh.normals[i] = given[i])); } return mesh; } /** * Fetch and parse an OBJ file * @param {string} url * @param {boolean} [smooth] - Compute smooth normals when the file has none, defaults to render3D.smoothShading * @return {Promise} * @memberof Render3D * @example * const mesh = await loadOBJ('ship.obj'); // in an async gameInit */ async function loadOBJ(url, smooth=render3D?.smoothShading) { const response = await loadFetch(url, 'loadOBJ'); if (!response.ok) throw new Error(`loadOBJ: could not load ${url}, ${response.status} ${response.statusText}`); const text = await response.text(), mesh = parseOBJ(text, smooth); // a mesh of nothing draws nothing, so it says why, a web page being what a dev server sends for a mistyped path mesh.points.length || console.warn('loadOBJ: ' + url + ' has no faces' + (loadIsWebPage(text) ? ', it is a web page, so the path may be wrong' : '')); return mesh; } /////////////////////////////////////////////////////////////////////////////// // CSG: union, subtract and intersect of closed meshes with BSP trees, after Evan Wallace's csg.js (MIT license); // the trees are walked with stacks, so a deep one can not overflow the call stack const RENDER3D_CSG_EPSILON = 1e-5; // closer than this to a plane counts as on it // a vertex of a CSG polygon, its values lerped where a cut splits an edge class Render3DCSGVertex { constructor(pos, normal, uv, color) { this.pos = pos; this.normal = normal; this.uv = uv; this.color = color; } lerp(v, t) { return new Render3DCSGVertex(this.pos.lerp(v.pos, t), this.normal.lerp(v.normal, t), this.uv.lerp(v.uv, t), this.color.lerp(v.color, t)); } flipped() { return new Render3DCSGVertex(this.pos, this.normal.scale(-1), this.uv, this.color); } } // a convex polygon, its vertices counter clockwise from the front, and the plane it lies on class Render3DCSGPolygon { constructor(vertices, plane) { this.vertices = vertices; this.plane = plane; } flipped() { const plane = {normal: this.plane.normal.scale(-1), w: -this.plane.w}; return new Render3DCSGPolygon(this.vertices.map(v=> v.flipped()).reverse(), plane); } } // a node of a BSP tree: a plane, the polygons that lie on it, and the trees in front of and behind it class Render3DCSGNode { constructor() { this.plane = undefined; this.front = undefined; this.back = undefined; this.polygons = []; } } // the plane through three points, facing the side they run counter clockwise from, undefined when they are in a // line, or so nearly that the plane would lean any way function render3DCSGPlane(a, b, c) { const n = b.subtract(a).cross(c.subtract(a)); if (n.lengthSquared() < 1e-18) return; const normal = n.normalize(); return {normal, w: normal.dot(a)}; } // sort a polygon against a plane into the lists, cutting it in two when it spans the plane function render3DCSGSplit(plane, polygon, coplanarFront, coplanarBack, front, back) { const COPLANAR = 0, FRONT = 1, BACK = 2, SPANNING = 3; const {normal, w} = plane, vertices = polygon.vertices, types = []; let polygonType = COPLANAR; for (const v of vertices) { const t = normal.dot(v.pos) - w; const type = t < -RENDER3D_CSG_EPSILON ? BACK : t > RENDER3D_CSG_EPSILON ? FRONT : COPLANAR; polygonType |= type; types.push(type); } if (polygonType === COPLANAR) (normal.dot(polygon.plane.normal) > 0 ? coplanarFront : coplanarBack).push(polygon); else if (polygonType === FRONT) front.push(polygon); else if (polygonType === BACK) back.push(polygon); else { const f = [], b = []; for (let i = 0; i < vertices.length; ++i) { const j = (i + 1) % vertices.length, ti = types[i], tj = types[j], vi = vertices[i], vj = vertices[j]; if (ti !== BACK) f.push(vi); if (ti !== FRONT) b.push(vi); if ((ti | tj) === SPANNING) { const v = vi.lerp(vj, (w - normal.dot(vi.pos)) / normal.dot(vj.pos.subtract(vi.pos))); f.push(v); b.push(v); } } f.length >= 3 && front.push(new Render3DCSGPolygon(f, polygon.plane)); b.length >= 3 && back.push(new Render3DCSGPolygon(b, polygon.plane)); } } // every node of a tree function render3DCSGNodes(root) { const nodes = [], stack = [root]; while (stack.length) { const node = stack.pop(); nodes.push(node); node.front && stack.push(node.front); node.back && stack.push(node.back); } return nodes; } // add polygons to a tree, each node splitting what reaches it by its plane function render3DCSGBuild(root, polygons) { const stack = [[root, polygons]]; while (stack.length) { const [node, list] = stack.pop(); if (!list.length) continue; node.plane ||= list[0].plane; const front = [], back = []; for (const polygon of list) render3DCSGSplit(node.plane, polygon, node.polygons, node.polygons, front, back); front.length && stack.push([node.front ||= new Render3DCSGNode, front]); back.length && stack.push([node.back ||= new Render3DCSGNode, back]); } } // turn the solid a tree bounds inside out, its planes facing the other way function render3DCSGInvert(root) { for (const node of render3DCSGNodes(root)) { node.plane &&= {normal: node.plane.normal.scale(-1), w: -node.plane.w}; [node.front, node.back] = [node.back, node.front]; } } // the parts of the polygons outside the solid a tree bounds function render3DCSGClipPolygons(root, polygons) { const result = [], stack = [[root, polygons]]; while (stack.length) { const [node, list] = stack.pop(); if (!node.plane) { for (const polygon of list) result.push(polygon); continue; } const front = [], back = []; for (const polygon of list) render3DCSGSplit(node.plane, polygon, front, back, front, back); if (node.front) stack.push([node.front, front]); else for (const polygon of front) result.push(polygon); node.back && stack.push([node.back, back]); // behind a leaf is inside the solid, and goes } return result; } // the polygons split into the parts inside the box around the other polygons and the parts beyond it, a polygon // that reaches into the box cut at its faces, so the cuts made inside it stay inside it; a part on a face, within // the epsilon, counts as inside function render3DCSGNear(polygons, polygonsOther) { const lo = vec3(Infinity), hi = vec3(-Infinity); for (const polygon of polygonsOther) for (const {pos} of polygon.vertices) { lo.x = min(lo.x, pos.x); lo.y = min(lo.y, pos.y); lo.z = min(lo.z, pos.z); hi.x = max(hi.x, pos.x); hi.y = max(hi.y, pos.y); hi.z = max(hi.z, pos.z); } // the box's faces as planes facing out, a part in front of any of them is beyond it const near = [], far = []; const faces = [[vec3(1, 0, 0), hi.x], [vec3(-1, 0, 0), -lo.x], [vec3(0, 1, 0), hi.y], [vec3(0, -1, 0), -lo.y], [vec3(0, 0, 1), hi.z], [vec3(0, 0, -1), -lo.z]]; const e = RENDER3D_CSG_EPSILON; for (const polygon of polygons) { // wholly beyond a face, as most of a mesh cut many times is, it is far as it is: splitting it at the faces // before would only cut it into parts that all end up far too let x0 = Infinity, y0 = Infinity, z0 = Infinity, x1 = -Infinity, y1 = -Infinity, z1 = -Infinity; for (const {pos} of polygon.vertices) { x0 = min(x0, pos.x); y0 = min(y0, pos.y); z0 = min(z0, pos.z); x1 = max(x1, pos.x); y1 = max(y1, pos.y); z1 = max(z1, pos.z); } if (x0 > hi.x + e || x1 < lo.x - e || y0 > hi.y + e || y1 < lo.y - e || z0 > hi.z + e || z1 < lo.z - e) { far.push(polygon); continue; } let inside = [polygon]; for (const [normal, w] of faces) { const next = []; for (const part of inside) render3DCSGSplit({normal, w}, part, next, next, far, next); inside = next; } for (const part of inside) near.push(part); } return [near, far]; } // a function giving a point the first point it was given within distance of it, or the point itself when there is // none, through a grid of cells that size, so points closer than distance come out as one function render3DCSGPlacer(distance) { const cells = new Map, cellKey = (x, y, z)=> x + ',' + y + ',' + z; return (p)=> { const cx = floor(p.x / distance), cy = floor(p.y / distance), cz = floor(p.z / distance); for (let x = cx - 1; x <= cx + 1; ++x) for (let y = cy - 1; y <= cy + 1; ++y) for (let z = cz - 1; z <= cz + 1; ++z) for (const q of cells.get(cellKey(x, y, z)) || []) if (q.distanceSquared(p) < distance ** 2) return q; const k = cellKey(cx, cy, cz); cells.has(k) || cells.set(k, []); cells.get(k).push(p); return p; }; } // whether an indexed mesh is closed around an inside: the areas of a closed surface, each facing out, add up to // nothing, and an opening or a face turned the wrong way leaves an area over; CSG run on its own results can leave // gaps a few epsilons wide, which come to next to nothing, so a sliver of the whole is allowed function render3DMeshIsClosed(mesh) { const points = mesh.points, indices = mesh.indices; let x = 0, y = 0, z = 0, total = 0; for (let t = 0; t < indices.length; t += 3) { const a = points[indices[t]], n = points[indices[t + 1]].subtract(a).cross(points[indices[t + 2]].subtract(a)); x += n.x, y += n.y, z += n.z; total += n.length(); } return total > 0 && hypot(x, y, z) <= total * 1e-4; } // the triangles of a closed mesh placed by a matrix, as CSG polygons function render3DCSGPolygons(mesh, matrix) { ASSERT(mesh instanceof Mesh, 'CSG takes a Mesh'); ASSERT(!mesh.doubleSided, 'CSG needs closed meshes, a doubleSided one like buildGrid has no inside'); const list = new Mesh().combine(mesh, matrix).toIndexed(); ASSERT(!list.points.length || render3DMeshIsClosed(list), 'CSG needs closed meshes, every edge shared by two triangles'); const vertex = (i)=> new Render3DCSGVertex(list.points[i], list.normals[i], list.uvs[i], list.colors[i]); const polygons = []; for (let t = 0; t < list.indices.length; t += 3) { const i = list.indices[t], j = list.indices[t + 1], k = list.indices[t + 2]; const plane = render3DCSGPlane(list.points[i], list.points[j], list.points[k]); plane && polygons.push(new Render3DCSGPolygon([vertex(i), vertex(j), vertex(k)], plane)); // no area, no face } return polygons; } // a mesh from CSG polygons: points close together made one, and each edge given the points other polygons have // along it, so neighbors meet at every point with no crack between them; each polygon then as a fan of triangles function render3DCSGMesh(polygons) { // points within the epsilon made one, the first found standing for the rest; moving one any farther would take // it off its plane by more than the epsilon, and the next cut through it would split it again; a point on an // edge, whose ends may each have moved that far, is looked for a few times that far from its line const reach = RENDER3D_CSG_EPSILON * 4, placer = render3DCSGPlacer(RENDER3D_CSG_EPSILON), placed = new Set; const cellKey = (x, y, z)=> x + ',' + y + ',' + z; const place = (p)=> { const q = placer(p); placed.add(q); // polygons share points, so one can be placed more than once return q; }; const loops = polygons.map(polygon=> { const loop = []; for (const v of polygon.vertices) { const pos = place(v.pos); if (loop.length && loop[loop.length - 1].pos === pos) continue; // a sliver edge closed up loop.push(new Render3DCSGVertex(pos, v.normal, v.uv, v.color)); } loop.length > 1 && loop[0].pos === loop[loop.length - 1].pos && loop.pop(); return loop; }); // the places in a coarser grid, sized so a cell holds a few, to find the ones lying along each edge const places = [...placed]; let lo = vec3(Infinity), hi = vec3(-Infinity); for (const p of places) { lo = vec3(min(lo.x, p.x), min(lo.y, p.y), min(lo.z, p.z)); hi = vec3(max(hi.x, p.x), max(hi.y, p.y), max(hi.z, p.z)); } const span = places.length ? max(hi.x - lo.x, hi.y - lo.y, hi.z - lo.z) : 0; const size = max(span / Math.cbrt(places.length || 1), reach), grid = new Map; const gridKey = (p)=> cellKey(floor((p.x - lo.x) / size), floor((p.y - lo.y) / size), floor((p.z - lo.z) / size)); for (const p of places) { const k = gridKey(p); grid.has(k) || grid.set(k, []); grid.get(k).push(p); } const onEdge = (a, b)=> { // the places strictly between a and b, within reach of the line, in order from a const found = [], ab = b.subtract(a), length2 = ab.lengthSquared(); const x0 = floor((min(a.x, b.x) - lo.x - reach) / size), x1 = floor((max(a.x, b.x) - lo.x + reach) / size); const y0 = floor((min(a.y, b.y) - lo.y - reach) / size), y1 = floor((max(a.y, b.y) - lo.y + reach) / size); const z0 = floor((min(a.z, b.z) - lo.z - reach) / size), z1 = floor((max(a.z, b.z) - lo.z + reach) / size); for (let x = x0; x <= x1; ++x) for (let y = y0; y <= y1; ++y) for (let z = z0; z <= z1; ++z) for (const p of grid.get(cellKey(x, y, z)) || []) { if (p === a || p === b) continue; const t = p.subtract(a).dot(ab) / length2; if (t <= 0 || t >= 1) continue; if (a.add(ab.scale(t)).distanceSquared(p) < reach ** 2) found.push([t, p]); } return found.sort((f, g)=> f[0] - g[0]); }; // the mesh, each vertex once: vertices at one place that are the same in every value are shared const mesh = new Mesh, shared = new Map; mesh.indices = []; const vertexIndex = (v)=> { const n = v.normal.lengthSquared() ? v.normal.normalize() : RENDER3D_DEFAULT_NORMAL, c = v.color; const key = [n.x, n.y, n.z, v.uv.x, v.uv.y, c.r, c.g, c.b, c.a].map(x=> round(x * 1e6)).join(); let atPlace = shared.get(v.pos), i; atPlace || shared.set(v.pos, atPlace = new Map); if ((i = atPlace.get(key)) === undefined) { atPlace.set(key, i = mesh.points.length); mesh.points.push(v.pos.copy()); mesh.normals.push(n); mesh.uvs.push(v.uv.copy()); mesh.colors.push(c.copy()); } return i; }; // each loop with the points along its edges put in, as a fan for (const loop of loops) { if (loop.length < 3) continue; const full = []; for (let i = 0; i < loop.length; ++i) { const a = loop[i], b = loop[(i + 1) % loop.length]; full.push(a); for (const [t, p] of onEdge(a.pos, b.pos)) { const v = a.lerp(b, t); full.push(new Render3DCSGVertex(p, v.normal, v.uv, v.color)); } } // a polygon with no area, its points in a line, is no surface, and its neighbors have its points along // their edges, so it goes const n = full.length, pos = (i)=> full[i % n].pos; let area = vec3(); for (let j = 1; j < n - 1; ++j) area = area.add(pos(j).subtract(pos(0)).cross(pos(j + 1).subtract(pos(0)))); if (area.lengthSquared() < 1e-18) continue; // cut off one corner at a time, one that makes a triangle with some area with its neighbors and leaves the // rest some area too, so the points put in along the sides make no flat triangles; areas add, so what is // left is the whole less the corner const ring = full.map(v=> ({pos: v.pos, id: vertexIndex(v)})); const earArea = (i)=> { const a = ring[(i + ring.length - 1) % ring.length].pos, b = ring[i].pos, c = ring[(i + 1) % ring.length].pos; return b.subtract(a).cross(c.subtract(a)); }; while (ring.length > 3) { let i = ring.findIndex((_, i)=> { const ear = earArea(i); return ear.lengthSquared() >= 1e-18 && area.subtract(ear).lengthSquared() >= 1e-18; }); i < 0 && (i = 0); // no corner leaves area, a sliver, cut off the first area = area.subtract(earArea(i)); mesh.indices.push(ring[(i + ring.length - 1) % ring.length].id, ring[i].id, ring[(i + 1) % ring.length].id); ring.splice(i, 1); } mesh.indices.push(ring[0].id, ring[1].id, ring[2].id); } mesh.dirty = true; return mesh; } // a new mesh of a and b placed by matrix: 0 their union, 1 a with b cut out, 2 what is in both // - csg.js's steps with the polygons kept in lists: the trees only tell inside from outside, and only the polygons // that reach into the other mesh's box are cut, one beyond it is all outside and is kept or dropped whole, so a // shape cut again and again only splits where each cut is // - the clip twice through the same tree, with a flip between, drops the one of two coplanar faces that would // double up function render3DMeshCSG(a, b, matrix, operation) { const polygonsA = render3DCSGPolygons(a), polygonsB = render3DCSGPolygons(b, matrix); const [nearA, farA] = render3DCSGNear(polygonsA, polygonsB), [nearB, farB] = render3DCSGNear(polygonsB, polygonsA); const treeA = new Render3DCSGNode, treeB = new Render3DCSGNode; render3DCSGBuild(treeA, polygonsA); render3DCSGBuild(treeB, polygonsB); const clip = render3DCSGClipPolygons, flip = (list)=> list.map(polygon=> polygon.flipped()); let polygons; if (operation === 0) { // each outside the other polygons = [...farA, ...clip(treeB, nearA), ...farB, ...flip(clip(treeA, flip(clip(treeA, nearB))))]; } else if (operation === 1) { // a outside b, and b inside a turned to face out render3DCSGInvert(treeA); polygons = [...farA, ...flip(clip(treeB, flip(nearA))), ...clip(treeA, flip(clip(treeA, nearB)))]; } else { // each inside the other render3DCSGInvert(treeA); render3DCSGInvert(treeB); polygons = [...flip(clip(treeB, flip(nearA))), ...flip(clip(treeA, flip(clip(treeA, nearB))))]; } return render3DCSGMesh(polygons); } /* * LittleJS 3D Voxels Plugin * - VoxelMap: a grid of blocks drawn with the faces between them left out, that objects with collideLevel collide * with, the 3D twin of the 2D tile layers * - Requires the Render3D plugin and goes after it, everything here is part of its Render3D namespace */ /////////////////////////////////////////////////////////////////////////////// // cells along each side of a chunk, each chunk its own mesh, built again only when something in or beside it changes const RENDER3D_VOXEL_CHUNK = 16; // each face of a block: its normal, then the right and up axes across it, right cross up is the normal, as buildBox // has them, in the order setBlockType takes six faces: +x, -x, +y, -y, +z, -z const RENDER3D_VOXEL_FACES = [ [vec3( 1, 0, 0), vec3(0, 0, -1), vec3(0, 1, 0)], [vec3(-1, 0, 0), vec3(0, 0, 1), vec3(0, 1, 0)], [vec3(0, 1, 0), vec3(1, 0, 0), vec3(0, 0, -1)], [vec3(0, -1, 0), vec3(1, 0, 0), vec3(0, 0, 1)], [vec3(0, 0, 1), vec3(1, 0, 0), vec3(0, 1, 0)], [vec3(0, 0, -1), vec3(-1, 0, 0), vec3(0, 1, 0)], ]; // the shade of a corner by how many solid blocks crowd it, from 0, closed in, to 3, open const RENDER3D_VOXEL_SHADES = [.55, .7, .85, 1].map(v=> Object.freeze(rgb(v, v, v))); // each face's corners about a cell's center, as render3DQuadAxes gives them: top left, bottom left, top right, bottom // right; then the sides of the face each corner is toward, for its shading, and the corners in strip order, or split // along the other diagonal const RENDER3D_VOXEL_CORNERS = RENDER3D_VOXEL_FACES.map(([normal, right, up])=> render3DQuadAxes(normal.scale(.5), right.scale(.5), up.scale(.5))); const RENDER3D_VOXEL_CORNER_SIDES = [[-1, 1], [-1, -1], [1, 1], [1, -1]]; const RENDER3D_VOXEL_ORDER = [0, 1, 2, 3], RENDER3D_VOXEL_FLIPPED = [1, 3, 0, 2]; /** What VoxelMap.raycast finds: how far along the ray, the block's cell and type, and the normal of the face it comes in * through * @typedef {{distance: number, cell: Vector3, normal: Vector3, type: number}} VoxelHit * @memberof Render3D */ /** * VoxelMap - A grid of blocks, a 3D tile map: it draws itself, and objects with collideLevel collide with it * - pos3D is its corner, as a 2D tile layer's is, and each cell is one world unit, so cell (x, y, z) fills * pos3D + (x..x+1, y..y+1, z..z+1); it stays upright and unscaled at the root * - A block's type is a number from 1 to 255, 0 is empty; a type shows that tile of the sheet on every face unless * setBlockType gives it its own faces, or makes it see-through or transparent * - Faces between blocks are left out, and the map is drawn in chunks of 16 cells a side, a chunk built again only * when a block in or beside it changes * - Objects with collideLevel collide with it, see EngineObject3D.collideWithVoxel, and raycast finds the block a ray * hits and the face it comes in through * @extends EngineObject3D * @memberof Render3D * @example * const map = new VoxelMap(vec3(), vec3(32, 16, 32), tile(0, 16)); * map.setBlockType(1, {top: 0, side: 1, bottom: 2}); // grass * map.setVoxel(vec3(3, 0, 5), 1); */ class VoxelMap extends EngineObject3D { /** Create a voxel map, it draws itself and joins the level's collision * @param {Vector3} [pos3D] - Its corner * @param {Vector3} [mapSize] - Cells along X, Y and Z * @param {TileInfo} [tileInfo] - The sheet's first tile, as for a TileLayer, a block's type counts tiles from it; * give the tiles a border of their own edge pixels, 2 or more, and that padding, or the blurred mipmaps of the * distance blend each tile with the ones beside it and show seams along the block edges */ constructor(pos3D=vec3(), mapSize=vec3(16), tileInfo=tile()) { super(pos3D); ASSERT(isVector3(mapSize) && mapSize.x >= 1 && mapSize.y >= 1 && mapSize.z >= 1, 'mapSize must be at least 1 cell each way'); ASSERT(tileInfo instanceof TileInfo, 'tileInfo must be a TileInfo, the first tile of the sheet'); /** @property {Vector3} - Cells along X, Y and Z */ this.mapSize = mapSize.floor(); /** @property {TileInfo} - The sheet's first tile, rebuild() after changing it or resizing its texture */ this.tileInfo = tileInfo; /** @property {Uint8Array} - The block type of each cell, x + mapSize.x * (y + mapSize.y * z), 0 empty; call * rebuild() after changing it directly */ this.data = new Uint8Array(this.mapSize.x * this.mapSize.y * this.mapSize.z); /** @property {boolean} - Darken the corners where blocks meet, rebuild() after changing it */ this.ambientOcclusion = true; this.size3D = vec3(); // not a thing to collect or push, its cells are the solid part const n = RENDER3D_VOXEL_CHUNK, s = this.mapSize; this.chunkCount = vec3(ceil(s.x / n), ceil(s.y / n), ceil(s.z / n)); /** @type {Array} */ this.chunkMeshes = []; // each chunk's solid and see-through blocks, drawn opaque /** @type {Array} */ this.chunkTransparentMeshes = []; // each chunk's transparent blocks, drawn blended /** @type {Array} */ this.chunkCenters = []; // a chunk mesh's points are around its center, which it is drawn at /** @type {Set} */ this.chunksChanged = new Set; /** @type {Array<{faces: Array, seeThrough: boolean, transparent: boolean, doubleSided: boolean}|undefined>} */ this.blockTypes = []; /** @type {Map>} */ this.tiles = new Map; // tile index to the uvs of its corners // the transparent blocks draw in the transparent stage, blended and sorted, through a child that draws them const glass = new EngineObject3D; glass.size3D = vec3(); // not a thing to collect either glass.transparent = true; glass.render3D = ()=> this.renderChunks(true); render3DShareSettings(glass, this); // it draws as the map does, its material, stage and render order this.addChild(glass); render3DLevel.push(this); } /** The block type at a cell, 0 for empty or outside the map * @param {Vector3} cell * @return {number} */ getVoxel(cell) { ASSERT(isVector3(cell), 'cell must be a vec3'); return this.voxelAt(floor(cell.x), floor(cell.y), floor(cell.z)); } /** The block type at whole number cell coordinates, 0 outside * @param {number} x * @param {number} y * @param {number} z * @return {number} * @ignore */ voxelAt(x, y, z) { const s = this.mapSize; return x < 0 || y < 0 || z < 0 || x >= s.x || y >= s.y || z >= s.z ? 0 : this.data[x + s.x * (y + s.y * z)]; } /** Set the block at a cell, 0 clears it; a cell outside the map is ignored * @param {Vector3} cell * @param {number} type - 0 to 255 */ setVoxel(cell, type) { ASSERT(isVector3(cell), 'cell must be a vec3'); ASSERT(type >= 0 && type <= 255 && type % 1 === 0, 'a block type is a whole number from 0 to 255', type); const x = floor(cell.x), y = floor(cell.y), z = floor(cell.z), s = this.mapSize; if (x < 0 || y < 0 || z < 0 || x >= s.x || y >= s.y || z >= s.z) return; const i = x + s.x * (y + s.y * z); if (this.data[i] === type) return; this.data[i] = type; // its chunk, and the chunks of the blocks around it: face to face it can show or hide their faces, and with // corner shading on, the blocks all around it take shade from it this.markChunk(x, y, z); for (let dz = -1; dz <= 1; ++dz) for (let dy = -1; dy <= 1; ++dy) for (let dx = -1; dx <= 1; ++dx) if ((this.ambientOcclusion || abs(dx) + abs(dy) + abs(dz) === 1) && this.voxelAt(x + dx, y + dy, z + dz)) this.markChunk(x + dx, y + dy, z + dz); } /** Mark the chunk holding a cell as changed, a cell outside the map has none * @param {number} x * @param {number} y * @param {number} z * @ignore */ markChunk(x, y, z) { const s = this.mapSize, c = this.chunkCount, n = RENDER3D_VOXEL_CHUNK; if (x < 0 || y < 0 || z < 0 || x >= s.x || y >= s.y || z >= s.z) return; this.chunksChanged.add(floor(x / n) + c.x * (floor(y / n) + c.y * floor(z / n))); } /** Give a block type its own faces, or make it see-through or transparent * @param {number} type - 1 to 255 * @param {number|Array|{top?: number, side: number, bottom?: number}} faces - A tile index for every face, * six in the order +x, -x, +y, -y, +z, -z, or the side's with the top and bottom's, which default to the side's * @param {{seeThrough?: boolean, transparent?: boolean, doubleSided?: boolean}} [options] - seeThrough for holes * in its texture, like leaves, so the blocks beside it keep their faces; transparent to blend, like glass or * water, drawn in the transparent stage, and see-through too; doubleSided for faces seen from inside the * block as well, like water, whose surface then shows from under it */ setBlockType(type, faces, {seeThrough=false, transparent=false, doubleSided=false}={}) { ASSERT(type >= 1 && type <= 255 && type % 1 === 0, 'a block type is a whole number from 1 to 255', type); const f = /** @type {any} */ (faces); const list = isNumber(f) ? [f, f, f, f, f, f] : isArray(f) ? f : [f.side, f.side, f.top ?? f.side, f.bottom ?? f.side, f.side, f.side]; ASSERT(list.length === 6 && list.every(isNumber), 'faces is a tile index, six of them, or {top, side, bottom}'); this.blockTypes[type] = {faces: list, seeThrough: seeThrough || transparent, transparent, doubleSided}; this.rebuild(); } /** A block type's faces and how it is seen through * @param {number} type * @return {{faces: Array, seeThrough: boolean, transparent: boolean, doubleSided: boolean}} * @ignore */ blockType(type) { return this.blockTypes[type] ||= {faces: [type, type, type, type, type, type], seeThrough: false, transparent: false, doubleSided: false}; } /** Build every chunk again, after changing data directly or ambientOcclusion */ rebuild() { this.tiles.clear(); // the tile uvs too, the texture may have changed const c = this.chunkCount; for (let i = c.x * c.y * c.z; i--;) this.chunksChanged.add(i); } /** Build the chunks that changed, the map calls it before it draws */ buildChunks() { for (const index of this.chunksChanged) this.buildChunk(index); this.chunksChanged.clear(); } /** Build one chunk's meshes, a face for each block side that shows * @param {number} index * @ignore */ buildChunk(index) { const c = this.chunkCount, n = RENDER3D_VOXEL_CHUNK, s = this.mapSize, data = this.data; const x0 = index % c.x * n, y0 = floor(index / c.x) % c.y * n, z0 = floor(index / (c.x * c.y)) * n; const x1 = min(x0 + n, s.x), y1 = min(y0 + n, s.y), z1 = min(z0 + n, s.z); const center = vec3((x0 + x1) / 2, (y0 + y1) / 2, (z0 + z1) / 2); const opaque = new Mesh, transparent = new Mesh, ao = this.ambientOcclusion; // a block that shades a corner, the see-through ones let the light by const solid = (x, y, z)=> { const t = this.voxelAt(x, y, z); return t && !this.blockType(t).seeThrough ? 1 : 0; }; const points = [], uvs = [], colors = [], shade = []; // one face at a time, addStrip copies them out for (let z = z0; z < z1; ++z) for (let y = y0; y < y1; ++y) for (let x = x0; x < x1; ++x) { const type = data[x + s.x * (y + s.y * z)]; if (!type) continue; const block = this.blockType(type), mesh = block.transparent ? transparent : opaque; const cx = x + .5 - center.x, cy = y + .5 - center.y, cz = z + .5 - center.z; for (let f = 0; f < 6; ++f) { // a face shows toward an empty cell, or a see-through block of another type const [normal, right, up] = RENDER3D_VOXEL_FACES[f]; const bx = x + normal.x, by = y + normal.y, bz = z + normal.z, next = this.voxelAt(bx, by, bz); if (next && (next === type || !this.blockType(next).seeThrough)) continue; // each corner darkened by the solid blocks beside it on the face's side, the usual voxel shading for (let k = 0; k < 4; ++k) { const [r, u] = RENDER3D_VOXEL_CORNER_SIDES[k]; const side1 = ao && solid(bx + right.x * r, by + right.y * r, bz + right.z * r); const side2 = ao && solid(bx + up.x * u, by + up.y * u, bz + up.z * u); const corner = ao && solid(bx + right.x * r + up.x * u, by + right.y * r + up.y * u, bz + right.z * r + up.z * u); shade[k] = side1 && side2 ? 0 : 3 - +side1 - +side2 - +corner; } // its corners as a strip around the chunk's center, split along the diagonal that keeps the shading // even, the other one shows a seam const corners = RENDER3D_VOXEL_CORNERS[f], quad = this.tileUVs(block.faces[f]); const order = shade[0] + shade[3] > shade[1] + shade[2] ? RENDER3D_VOXEL_FLIPPED : RENDER3D_VOXEL_ORDER; for (let k = 0; k < 4; ++k) { const i = order[k], p = corners[i]; points[k] = vec3(cx + p.x, cy + p.y, cz + p.z); uvs[k] = quad[i]; colors[k] = ao ? RENDER3D_VOXEL_SHADES[shade[i]] : WHITE; } mesh.addStrip(points, normal, uvs, colors); // a double sided block shows the face from inside too, its corners the other way round if (block.doubleSided) mesh.addStrip([points[0], points[2], points[1], points[3]], normal.scale(-1), [uvs[0], uvs[2], uvs[1], uvs[3]], [colors[0], colors[2], colors[1], colors[3]]); } } this.chunkMeshes[index]?.dispose(); this.chunkTransparentMeshes[index]?.dispose(); this.chunkMeshes[index] = opaque.points.length ? opaque : undefined; this.chunkTransparentMeshes[index] = transparent.points.length ? transparent : undefined; this.chunkCenters[index] = center; } /** The uvs of a tile's corners, top left, bottom left, top right, bottom right, the tile counted from the map's * first tile as a TileLayer counts them; kept, and shared by every face that shows it * @param {number} index * @return {Array} * @ignore */ tileUVs(index) { let uvs = this.tiles.get(index); if (!uvs) { const first = this.tileInfo; const rect = render3DGetTileUVs(first.columns ? first.frame(index) : first.index(index)); // a shared rect uvs = RENDER3D_QUAD_UVS.map(q=> Object.freeze(vec2(rect.x + q.x * rect.w, rect.y + q.y * rect.h))); this.tiles.set(index, uvs); } return uvs; } /** Whether a box hits a block that stops the object, see EngineObject3D.collideWithVoxel * @param {Vector3} pos - Center of the box in the world * @param {Vector3} size * @param {EngineObject3D} o * @return {boolean} * @ignore */ boxBlocked(pos, size, o) { // the cells the box is in along an axis, one only touching a cell's face is not in it, but a point, a box with // no size, is in the cell it is at, even on a whole number where the two ends pass each other const m = this.pos3D, s = this.mapSize, tiny = 1e-9; const cells = (center, half, corner, count)=> { const first = floor(center - half - corner + tiny), last = max(ceil(center + half - corner - tiny) - 1, first); return [max(first, 0), min(last, count - 1)]; }; const [x0, x1] = cells(pos.x, size.x / 2, m.x, s.x); const [y0, y1] = cells(pos.y, size.y / 2, m.y, s.y); const [z0, z1] = cells(pos.z, size.z / 2, m.z, s.z); for (let z = z0; z <= z1; ++z) for (let y = y0; y <= y1; ++y) for (let x = x0; x <= x1; ++x) { const type = this.data[x + s.x * (y + s.y * z)]; if (type && o.collideWithVoxel(type, vec3(x, y, z))) return true; } return false; } /** Keep an object out of the blocks, one axis at a time as 2D tiles do, called by the engine for each object with * collideLevel; a sphere collides as its box, and one moving more than about a cell a frame can pass through * - One already in blocks, as when a block is set on it, is pushed up to stand on those in its lower half when * there is room, or else left free to move out of them, but not into any it is not in already * @param {EngineObject3D} o * @param {Vector3} oldPos - Where it was before it moved * @ignore */ levelCollide3D(o, oldPos) { const k = o.scale3D, size = vec3(o.size3D.x * abs(k.x), o.size3D.y * abs(k.y), o.size3D.z * abs(k.z)); const p = o.pos3D, v = o.velocity3D, m = this.pos3D, epsilon = 1e-4; if (!this.boxBlocked(p, size, o)) return; if (this.boxBlocked(oldPos, size, o)) { // the top of the highest blocked layer from its bottom up to its middle, each layer a flat box const half = size.y / 2, flat = vec3(size.x, 0, size.z); let top; for (let y = floor(oldPos.y - half - m.y); y <= floor(oldPos.y - m.y); ++y) if (this.boxBlocked(vec3(oldPos.x, m.y + y + .5, oldPos.z), flat, o)) top = y + 1; const up = top === undefined ? undefined : vec3(oldPos.x, m.y + top + half + epsilon, oldPos.z); if (up && !this.boxBlocked(up, size, o)) { p.set(up.x, up.y, up.z); v.y = max(v.y, 0); o.groundObject = this; return; } // or else free to move out, each axis alone, stopped only by a block it would enter that it is not in // already: the cells its leading face passes into beyond those the box reached const tiny = 1e-9, place = oldPos.copy(); for (const axis of ['y', 'x', 'z']) { const move = p[axis] - place[axis], halfSize = size[axis] / 2, corner = m[axis]; if (!move) continue; const lead = (at)=> move > 0 ? ceil(at + halfSize - corner - tiny) - 1 : floor(at - halfSize - corner + tiny); const from = lead(place[axis]), to = lead(p[axis]); const first = min(from, to) + (move > 0 ? 1 : 0), last = max(from, to) - (move > 0 ? 0 : 1); const slab = place.copy(), slabSize = size.copy(); slab[axis] = corner + (first + last + 1) / 2, slabSize[axis] = last - first + 1; if (from === to || !this.boxBlocked(slab, slabSize, o)) place[axis] = p[axis]; else { // stopped; a fall stopped by a block under it stands on it if (axis === 'y' && move < 0) o.groundObject = this; v[axis] = 0; } } p.set(place.x, place.y, place.z); return; } // from where it was, each axis alone, y first so a landing wins; a blocked axis goes flush against the block // it ran into, or stays where it was, and its speed bounces by the restitution const restitution = max(o.restitution, this.restitution); const place = oldPos.copy(); for (const axis of ['y', 'x', 'z']) { const moved = place.copy(); moved[axis] = p[axis]; if (!this.boxBlocked(moved, size, o)) { place[axis] = p[axis]; continue; } const half = size[axis] / 2, move = p[axis] - oldPos[axis]; if (move) { moved[axis] = move < 0 ? m[axis] + floor(p[axis] - half - m[axis]) + 1 + half + epsilon : m[axis] + ceil(p[axis] + half - m[axis]) - 1 - half - epsilon; if (!this.boxBlocked(moved, size, o)) place[axis] = moved[axis]; } if (axis === 'y' && move < 0) o.groundObject = this; v[axis] *= -restitution; } p.set(place.x, place.y, place.z); } /** The first block a ray hits, walking the grid cell by cell: its distance along the ray, its cell, the normal of * the face it comes in through, which a new block goes against, and its type * - A ray that starts inside a block hits it at 0, its normal back along the ray * @param {Ray3D} ray - Its distance is in the ray's own units, as the other raycasts * @param {number} [maxDistance] * @param {function(number, Vector3): boolean} [test] - (type, cell) says which blocks count, every block by default * @return {VoxelHit|undefined} */ raycast(ray, maxDistance=Infinity, test=()=> true) { const s = this.mapSize, o = ray.origin.subtract(this.pos3D), d = ray.direction, axes = ['x', 'y', 'z']; if (!d.lengthSquared()) return; // where it enters the map's box, the axis of the face it comes in through, or where it starts inside it let enter = 0, exit = maxDistance, entryAxis = -1; for (let k = 0; k < 3; ++k) { const a = axes[k], dk = d[a], ok = o[a]; if (!dk) { if (ok < 0 || ok > s[a]) return; // alongside the map, never in it continue; } let t0 = -ok / dk, t1 = (s[a] - ok) / dk; if (t0 > t1) [t0, t1] = [t1, t0]; if (t0 > enter) enter = t0, entryAxis = k; exit = min(exit, t1); } if (enter > exit) return; // walk the cells, stepping across whichever cell edge comes next const start = o.add(d.scale(enter)), cell = [], step = [], next = [], delta = []; for (let k = 0; k < 3; ++k) { const a = axes[k], dk = d[a]; cell[k] = clamp(floor(start[a]), 0, s[a] - 1); step[k] = sign(dk); next[k] = dk ? enter + (cell[k] + (dk > 0 ? 1 : 0) - start[a]) / dk : Infinity; delta[k] = dk ? abs(1 / dk) : Infinity; } const normalOn = (k)=> vec3(k === 0 ? -step[0] : 0, k === 1 ? -step[1] : 0, k === 2 ? -step[2] : 0); let normal, t = enter; if (entryAxis >= 0) normal = normalOn(entryAxis); else { // starting inside, the normal faces back along the ray's strongest axis const k = abs(d.x) >= abs(d.y) && abs(d.x) >= abs(d.z) ? 0 : abs(d.y) >= abs(d.z) ? 1 : 2; normal = normalOn(k); } while (t <= maxDistance) { const type = this.data[cell[0] + s.x * (cell[1] + s.y * cell[2])]; if (type) { const at = vec3(cell[0], cell[1], cell[2]); if (test(type, at)) return {distance: t, cell: at, normal, type}; } const k = next[0] < next[1] ? (next[0] < next[2] ? 0 : 2) : (next[1] < next[2] ? 1 : 2); t = next[k]; cell[k] += step[k]; if (cell[k] < 0 || cell[k] >= s[axes[k]]) return; next[k] += delta[k]; normal = normalOn(k); } } /** How far along a ray the first block is, for picking, see raycast * @param {Ray3D} ray * @return {number|undefined} * @ignore */ levelRaycast3D(ray) { return this.raycast(ray)?.distance; } /** Where a short move goes into a block, for a particle's move in one frame: the part of the move made before it, * 0 to 1, and the normal of the face it comes in through; undefined when it hits none, or starts inside one; a * move that starts on a block's face, coming in, hits it at 0 * @param {Vector3} from * @param {Vector3} to * @return {{distance: number, normal: Vector3}|undefined} * @ignore */ levelSegment3D(from, to) { const move = to.subtract(from), hit = this.raycast(new Ray3D(from, move), 1); if (!hit) return; if (!hit.distance) { // met at its start: on a face when the point just behind the start is clear, inside a block when not const behind = from.subtract(move.normalize(1e-6)).subtract(this.pos3D); if (this.voxelAt(floor(behind.x), floor(behind.y), floor(behind.z))) return; } return {distance: hit.distance, normal: hit.normal}; } /** Keeps an eye on its placement, called automatically each frame */ update() { super.update(); render3DLevelAssertPlaced(this); } /** Draw the solid and see-through blocks, the transparent ones draw through its child */ render3D() { this.renderChunks(false); } /** Draw the chunks, each at its center, with the whole texture so each face shows its own tile * @param {boolean} transparent * @ignore */ renderChunks(transparent) { this.buildChunks(); const meshes = transparent ? this.chunkTransparentMeshes : this.chunkMeshes, p = this.pos3D; const texture = this.tileInfo.textureInfo; for (let i = 0; i < meshes.length; ++i) { const mesh = meshes[i], c = this.chunkCenters[i]; mesh && render3D.drawMesh(mesh, vec3(p.x + c.x, p.y + c.y, p.z + c.z), texture, this.color); } } /** Destroy the map, it leaves the level's collision and lets go of its meshes * @param {boolean} [immediate] */ destroy(immediate) { render3DLevelLeave(this); for (const mesh of [...this.chunkMeshes, ...this.chunkTransparentMeshes]) mesh?.dispose(); super.destroy(immediate); } } /** * 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 */ /////////////////////////////////////////////////////////////////////////////// const LEVEL3D_VERSION = 1; // the format a level's littlejs3D names /** A 3D level as it is saved, what level3DLoad takes and the 3D level editor edits * @typedef {Object} Level3D * @property {number} [littlejs3D] - The format's version, 1 * @property {Array} objects - Each {id, type, pos, rotation, scale, properties}, all but type optional * @property {Object} [scene] - The scene block: sky, ambient, sunDirection, sunColor, fog, fogColor, shadows, lensFlare, * skyBox, environment * @property {Object} [voxels] - A block map: {pos, size, blocks} * @property {Object} [terrain] - A height map: {pos, size, height, color, heights, paint} * @property {Object} [prefabs] - The level's own prefabs by name, each {objects, attached} * @memberof Level3D */ // 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 * - A level may hold a map of blocks, in a voxels block: pos, its corner, size, its cells along x, y and z, and * blocks, runs of a count and a type along x, then y, then z; it is made a VoxelMap, the first of what is * returned, see level3DVoxelSetup for its sheet * - A level may hold a terrain, in a terrain block: pos, its center, size, its size in the world along x and z, * height, how tall a full height is, color, and heights, rows of 0 to 1 from -z to +z, each from -x to +x; it * is made a HeightMap, returned with what else was made; paint, when it has it, colors its samples: colors, a * list, and cells, runs of a count and a color along the rows, 0 for the terrain's own and 1 the list's first * - A level may hold prefabs of its own, in a prefabs block, each by its name as level3DAddPrefab takes it; they * are added before its objects are made, one the game added itself keeps its place * - A level may set the scene too, in a scene block beside its objects: sky, three colors for straight up, the * horizon and straight down, ambient, how much of them lights the scene, .5 when not given, sunDirection and * sunColor, fog, its start and end, fogColor, the horizon color when not given, shadows, lensFlare, the * sun's lens flare, and skyBox and environment, each six image urls as loadCubeMap takes them, which load in * the background and are set when they have; what the block leaves out stays as the game set it, and a level * with no block changes nothing * @param {Level3D} level - The level, the level editor edits this same object * @return {Array} - 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 level3DPrefabsAdd(level.prefabs); level3DSceneApply(level.scene); const made = [], map = level3DVoxelMap = level3DVoxelsMake(level.voxels); map && made.push(map); const terrain = level3DTerrainMap = level3DTerrainMake(level.terrain); terrain && made.push(terrain); 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; } // the sheet a level's block map is made with, and what sets each one up, see level3DVoxelSetup; and the map the // level loaded last made, the one the 3D editor paints let level3DVoxelTiles, level3DVoxelSetupMap, level3DVoxelMap; /** How a level's block map is made: the sheet its blocks show tiles of, and a function to set it up * - A level's voxels block makes a VoxelMap when the level loads, with texture 0 and the default tile size unless * a sheet is given here; a block's type shows that tile of the sheet on every face; a game that has loaded no * image gets plain blocks, a color for each type * - setup is called with each map a level makes, to give block types their own faces or make them see-through * - Call it before level3DLoad; with no arguments the defaults are back * @param {TileInfo} [tileInfo] - The sheet's first tile, as for a VoxelMap * @param {function(VoxelMap): void} [setup] * @example * level3DVoxelSetup(tile(0, 16, 1), (map)=> map.setBlockType(1, {top: 0, side: 1, bottom: 2})); // grass * @memberof Level3D */ function level3DVoxelSetup(tileInfo, setup) { level3DVoxelTiles = tileInfo; level3DVoxelSetupMap = setup; } // the most cells a level's map may have, 256 each way const LEVEL3D_VOXEL_CELLS = 2 ** 24; // a map's blocks as the file keeps them, runs of a count and a type, cell by cell in the map's own order function level3DVoxelsEncode(data) { const blocks = []; for (let i = 0; i < data.length;) { let end = i + 1; while (end < data.length && data[end] === data[i]) ++end; blocks.push(end - i, data[i]); i = end; } return blocks; } // runs into a map's cells: what they do not reach is empty, a type that is not 0 to 255 is empty, and a run that // is not two numbers ends them function level3DVoxelsDecode(blocks, data) { data.fill(0); if (!isArray(blocks)) return; for (let i = 0, cell = 0; i + 1 < blocks.length && cell < data.length; i += 2) { const count = blocks[i], type = blocks[i + 1]; if (!isNumber(count) || !isNumber(type) || count < 0) return; const end = min(cell + floor(count), data.length); data.fill(type >= 0 && type <= 255 && type % 1 === 0 ? type : 0, cell, end); cell = end; } } // the corner and the size in cells of a level's voxels block, undefined when it is not one a map can be made of function level3DVoxelsShape(voxels) { if (!voxels || typeof voxels !== 'object') return; const size = level3DVector(voxels.size); if (!size || [size.x, size.y, size.z].some((n)=> n < 1 || n % 1) || size.x * size.y * size.z > LEVEL3D_VOXEL_CELLS) return; return {pos: level3DVector(voxels.pos, vec3()), size}; } // the VoxelMap of a level's voxels block, undefined when it has none or it is wrong function level3DVoxelsMake(voxels) { const shape = level3DVoxelsShape(voxels); if (!shape || typeof VoxelMap == 'undefined') return; const map = new VoxelMap(shape.pos, shape.size, level3DVoxelTiles || level3DVoxelPlainTiles()); level3DVoxelsDecode(voxels.blocks, map.data); level3DVoxelSetupMap?.(map); map.rebuild(); return map; } // the terrain the level loaded last made, the one the 3D editor sculpts, and the most samples it may have a side let level3DTerrainMap; const LEVEL3D_TERRAIN_SAMPLES = 129; // what a level's terrain block is made with: its center, its size in the world along x and z, how tall a full // height is, and its heights as rows of 0 to 1, a copy, each row as long as the first; undefined when it is not // one a HeightMap can be made of function level3DTerrainShape(terrain) { if (!terrain || typeof terrain !== 'object') return; const size = terrain.size, rows = terrain.heights, n = LEVEL3D_TERRAIN_SAMPLES; if (!isArray(size) || size.length !== 2 || !size.every((v)=> level3DFinite(v) && v > 0)) return; if (!isArray(rows) || rows.length < 2 || rows.length > n || !isArray(rows[0])) return; const columns = rows[0].length; if (columns < 2 || columns > n || !rows.every((row)=> isArray(row) && row.length === columns)) return; const heights = rows.map((row)=> row.map((v)=> level3DFinite(v) ? clamp(v) : 0)); return {pos: level3DVector(terrain.pos, vec3()), size: vec2(size[0], size[1]), height: level3DFinite(terrain.height) && terrain.height > 0 ? terrain.height : 1, heights}; } // a hex color of a level as a Color, undefined when it is not one function level3DHexColor(hex) { return typeof hex === 'string' && /^#([0-9a-f]{6}|[0-9a-f]{8})$/i.test(hex) ? new Color().setHex(hex) : undefined; } // a number of a level that is a real one, not NaN or Infinity, which JSON writes as 1e999 const level3DFinite = (v)=> typeof v === 'number' && isFinite(v); // the paint of a level's terrain block, for a terrain of so many samples: its colors as they are written and a // color for each sample, row after row, 0 for none and 1 the first of the colors; undefined when it has none or // it does not fit the terrain function level3DTerrainPaint(terrain, samples) { const paint = terrain?.paint, colors = paint?.colors, runs = paint?.cells; if (!isArray(colors) || !isArray(runs) || colors.length > 255 || !colors.every(level3DHexColor)) return; const cells = new Uint8Array(samples); let at = 0; for (let i = 0; i < runs.length; i += 2) { const count = runs[i], color = runs[i + 1]; if (!(count > 0 && count % 1 === 0 && color >= 0 && color % 1 === 0) || color > colors.length || at + count > samples) return; cells.fill(color, at, at += count); } return at === samples ? {colors: colors.slice(), cells} : undefined; } // give a terrain's map the colors of its block: its own color over the whole of it, or with paint a color for // each sample, the terrain's own where it is not painted; the caller builds the map again function level3DTerrainSetColors(map, terrain, paint=level3DTerrainPaint(terrain, map.rows * map.columns)) { const base = level3DHexColor(terrain.color) || WHITE; map.color = base, map.colors = undefined; if (!paint || !paint.cells.some((v)=> v)) return; const palette = [base, ...paint.colors.map(level3DHexColor)]; map.color = WHITE; map.colors = map.heights.map((row, r)=> row.map((v, c)=> palette[paint.cells[r * map.columns + c]] || base)); } // the sheet a level's blocks show when the game gave none with level3DVoxelSetup: the game's first image, as a // VoxelMap takes it, or, for a game that has loaded no image, plain tiles made here, a color for each type, so a // block map in a game with no art can still be seen and painted let level3DVoxelPlain; function level3DVoxelPlainTiles() { if (textureInfos[0]?.size.x || headlessMode || !canvasAvailable()) return tile(); if (!level3DVoxelPlain) { // 16 by 16 tiles of 16 pixels, each a color of its own with a darker edge, so the blocks read as blocks const context = createCanvasContext(256); for (let i = 0; i < 256; ++i) { const x = i % 16 * 16, y = (i / 16 | 0) * 16, hue = i * .618 % 1; context.fillStyle = hsl(hue, .45, .4).toString(); context.fillRect(x, y, 16, 16); context.fillStyle = hsl(hue, .5, .55).toString(); context.fillRect(x + 1, y + 1, 14, 14); } level3DVoxelPlain = new TextureInfo(context.canvas); } return tile(0, 16, level3DVoxelPlain, 0); } // the HeightMap of a level's terrain block, undefined when it has none or it is wrong function level3DTerrainMake(terrain) { const shape = level3DTerrainShape(terrain); if (!shape || typeof HeightMap == 'undefined') return; const map = new HeightMap(shape.heights, shape.size, shape.height, undefined, shape.pos); level3DTerrainSetColors(map, terrain); map.colors && map.rebuild(); return map; } // the sky domes levels made, a level's sky takes the place of the one before and disposes it; a dome the game made // itself is left whole, for the game to put back const level3DSkies = new WeakSet; // set the scene a level's scene block gives: sky as its top, horizon and bottom colors, with ambient how much of // them lights the scene, sunDirection and sunColor, fog as its start and end with fogColor, the horizon without one, // and shadows; a setting the block does not have, or has wrong, stays as it is function level3DSceneApply(scene) { // a sky box or environment an earlier scene is still loading does not land on this one's ++level3DSceneCubeLoads.skyBox, ++level3DSceneCubeLoads.environment; const r = render3D; if (!r || !scene || typeof scene !== 'object') return; const color = level3DHexColor; const sky = isArray(scene.sky) && scene.sky.length === 3 ? scene.sky.map(color) : []; if (sky.length && sky.every((c)=> c)) { // as setSky does, without disposing a dome that is the game's own const [top, horizon, bottom] = sky, ambient = level3DFinite(scene.ambient) ? scene.ambient : .5; level3DSkies.has(r.sky) && r.sky.dispose(); level3DSkies.add(r.sky = buildSky(top, horizon, bottom)); r.fogColor = horizon.copy(); r.ambientColor = top.scale(ambient, 1); r.ambientGroundColor = bottom.scale(ambient, 1); } const sun = level3DVector(scene.sunDirection); if (sun && sun.lengthSquared()) r.sunDirection = sun; r.sunColor = color(scene.sunColor) || r.sunColor; const fog = scene.fog; if (isArray(fog) && fog.length === 2 && fog.every(level3DFinite)) r.fogStart = fog[0], r.fogEnd = fog[1]; r.fogColor = color(scene.fogColor) || r.fogColor; if (typeof scene.shadows === 'boolean') r.shadows = scene.shadows; if (typeof scene.lensFlare === 'boolean') level3DSceneFlare(scene.lensFlare); level3DSceneCubeMap('skyBox', scene.skyBox); level3DSceneCubeMap('environment', scene.environment); } // the cube maps scene blocks loaded, by their six urls, the four used last, so the same ones load once; a load that // failed is not kept, and is tried again by the next scene that names it const level3DSceneCubeMaps = new Map; const level3DSceneCubeLoaded = new WeakMap; // the cube map each of their loads gave, once it has // which load each of render3D.skyBox and environment waits for: every scene applied, or the editor putting the // game's back, moves it on, and a load that finishes after is dropped const level3DSceneCubeLoads = {skyBox: 0, environment: 0}; // load the six urls a scene block gives for render3D.skyBox or environment and set it when they have loaded; a value // that is not six strings leaves it as it is, and a failed load says so and leaves it too function level3DSceneCubeMap(field, urls) { if (!isArray(urls) || urls.length !== 6 || !urls.every((url)=> typeof url === 'string')) return; const key = urls.join('\n'), load = ++level3DSceneCubeLoads[field], before = render3D[field]; // kept by the urls, newest last: one used again moves to the end, and past four the oldest goes, its texture // freed unless it is still on screen let cube = level3DSceneCubeMaps.get(key); level3DSceneCubeMaps.delete(key); level3DSceneCubeMaps.set(key, cube ||= loadCubeMap(urls)); cube.then((map)=> level3DSceneCubeLoaded.set(cube, map), ()=> {}); for (const [oldKey, old] of level3DSceneCubeMaps) { if (level3DSceneCubeMaps.size <= 4) break; // one on screen stays, so it is not loaded again and the copy on screen left behind const map = level3DSceneCubeLoaded.get(old); if (map && (map === render3D.skyBox || map === render3D.environment)) continue; level3DSceneCubeMaps.delete(oldKey); old.then((map)=> map !== render3D.skyBox && map !== render3D.environment && map.dispose(), ()=> {}); } // set when it has loaded, unless a newer scene came, or the game set the field itself meanwhile const landed = (map)=> load === level3DSceneCubeLoads[field] && render3D[field] === before && (render3D[field] = map); cube.then(landed, (error)=> { level3DSceneCubeMaps.get(key) === cube && level3DSceneCubeMaps.delete(key); console.warn(error.message); }); } // the sun's lens flare as a scene sets it: on makes one unless the sun has a flare already, the game's own or an // earlier level's, and off takes away the one a scene made, never the game's let level3DSunFlare; function level3DSceneFlare(on) { if (!on) { level3DSunFlare && !level3DSunFlare.destroyed && level3DSunFlare.destroy(); level3DSunFlare = undefined; } else if (!level3DSunHasFlare()) level3DSunFlare = new LensFlare3D; } const level3DSunHasFlare = ()=> engineObjects.some((o)=> o instanceof LensFlare3D && !o.destroyed && !o.light); // 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(level3DFinite) ? 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)) level3DHexColor(value) && (properties[key] = level3DHexColor(value)); else if (isVector3(d)) properties[key] = level3DVector(value, properties[key]); else if (isVector2(d)) isArray(value) && value.length === 2 && value.every(level3DFinite) && (properties[key] = vec2(value[0], value[1])); else if (typeof d === 'number') level3DFinite(value) && (properties[key] = value); 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) { return level3DMakeAt(object, level3DVector(object.pos, vec3()), level3DVector(object.rotation, vec3()).scale(PI / 180), level3DVector(object.scale, vec3(1))); } // make a level object's game object at a place, a rotation in radians and a scale, its own as level3DMake gives // them or the ones a prefab's instance puts it at function level3DMakeAt(object, pos, rotation, scale) { const type = level3DTypes.get(object.type); if (!type) { debug && console.warn(`level3DLoad: no type added for ${object.type}, skipped`); return; } const 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 = rotation.copy(); result.scale3D = result.scale3D.multiply(scale); } // 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]; // a prefab's instance makes its parts now that it is where it goes result instanceof Prefab3D && result.placeAt(pos); return result; } /** Make one object of a type added with level3DAddType, level3DAddMesh or level3DAddPrefab, from code, with no * level: a prefab's instance, or a plain type * @param {string} type - The type's name * @param {Vector3} [pos3D] * @param {Vector3} [rotation3D] - In radians, as an object has it * @param {Vector3} [scale3D] - Times the scale the type makes it with * @param {Object} [properties] - Over the type's defaults * @return {any} - What the type made, a Prefab3D for a prefab, undefined when there is no such type * @memberof Level3D * @example * level3DAddPrefab('House', await fetchJSON('house.json')); * level3DSpawn('House', vec3(10, 0, 0), vec3(0, PI/2, 0)); */ function level3DSpawn(type, pos3D=vec3(), rotation3D=vec3(), scale3D=vec3(1), properties) { ASSERT(isVector3(pos3D) && isVector3(rotation3D) && isVector3(scale3D), 'pos3D, rotation3D and scale3D are vec3'); return level3DMakeAt(properties ? {type, properties} : {type}, pos3D.copy(), rotation3D, scale3D); } /////////////////////////////////////////////////////////////////////////////// // prefabs: a prefab is a small level, a list of objects about its own origin, placed many times // the prefabs by name: how an instance is built, its objects, fromLevel, true for one a level's own prefabs // block added, which one the game adds takes the place of, and source, the object it was added from, which the // level editor knows a level that is a prefab by /** @type {Map, fromLevel: boolean, source: Object}>} */ const level3DPrefabs = new Map; // how many prefabs deep an instance being made is, a prefab that holds itself is stopped; and how many of those // are attached: a prefab inside an attached one is attached too, or its parts would be left behind in the world let level3DPrefabDepth = 0, level3DPrefabAttached = 0; // the prefabs being made, one inside the next, so one that holds itself is caught where it does, however many times const level3DPrefabMaking = []; /** Add a prefab: a small level, its objects placed about its own origin, to place many times under one name * - It is a type from then on: a level's object of that type, the level editor's Place list and level3DSpawn make * an instance, a Prefab3D; an instance is made of the prefab as it is then, so the level editor, where a prefab * changes, makes its instances again * - The prefab is a level as the level editor saves it, {objects: [...]}, so the editor is the prefab editor too; * only its objects are used, and they may be of other prefabs * - With attached true in it the parts are children of the instance and move with it as one body, without * collision of their own, a prefab inside it too; its handle is at the middle of the box around them and its * size3D is that box, so setCollision makes the body solid where it is seen; otherwise each part is an object of its own in the world and collides as one placed * by hand does * - Adding a name again replaces it * @param {string} name - The type its instances have in a level * @param {{objects: Array, attached?: boolean}} prefab - Its objects, about its own origin, and whether * they are attached * @memberof Level3D * @example * level3DAddPrefab('Tower', {objects: [{type: 'Box', pos: [0, 1, 0], scale: [2, 2, 2]}, * {type: 'Cylinder', pos: [0, 3, 0]}]}); * level3DLoad({objects: [{type: 'Tower', pos: [5, 0, 5]}, {type: 'Tower', pos: [-5, 0, 5], rotation: [0, 45, 0]}]}); */ function level3DAddPrefab(name, prefab) { level3DPrefabSet(name, prefab, false); } // add a prefab, the game's or a level's own function level3DPrefabSet(name, prefab, fromLevel) { ASSERT(!!prefab && typeof prefab === 'object', 'a prefab is an object, {objects: [...]}'); name = String(name); const objects = isArray(prefab?.objects) ? prefab.objects.filter((o)=> o && typeof o === 'object') : []; // a copy, what the game or the editor does to its own afterwards does not change the instances made later level3DPrefabs.set(name, {attached: !!prefab?.attached, objects: JSON.parse(JSON.stringify(objects)), fromLevel, source: prefab}); // a class of its own, so the level editor makes its instances again as it does a class's level3DTypes.set(name, {make: class extends Prefab3D { constructor(pos) { super(pos, name); } }, defaults: {}, tileInfo: undefined}); } // add a level's own prefabs, each unless the game added one of that name or it is a plain type's name function level3DPrefabsAdd(prefabs) { if (!prefabs || typeof prefabs !== 'object' || isArray(prefabs)) return; for (const name in prefabs) { const known = level3DPrefabs.get(name); if (known ? !known.fromLevel : level3DTypes.has(name)) continue; prefabs[name] && typeof prefabs[name] === 'object' && level3DPrefabSet(name, prefabs[name], true); } } /** Load a prefab from a file the level editor saved and add it * @param {string} name - The type its instances have in a level * @param {string} url * @return {Promise} * @memberof Level3D */ async function level3DLoadPrefab(name, url) { level3DAddPrefab(name, await fetchJSON(url)); } // where an instance puts one of its prefab's objects: the object's own place, turn and size inside the instance's function level3DPrefabPartTransform(instance, object) { const pos = level3DVector(object.pos, vec3()), scale = level3DVector(object.scale, vec3(1)); const rotation = level3DVector(object.rotation, vec3()).scale(PI / 180); // the two turns one after the other; the sizes multiply along each axis, which is exact for an instance sized // evenly or a part that is not turned, and as near as a box can be otherwise const turn = buildMatrix(vec3(), instance.rotation3D).multiply(buildMatrix(vec3(), rotation)); return {pos: buildMatrix(instance.pos3D, instance.rotation3D, instance.scale3D).transformPoint(pos), rotation: turn.getRotation(), scale: scale.multiply(instance.scale3D)}; } /** * An instance of a prefab, what a prefab's type makes: a handle with no shape of its own, and its parts, what the * prefab's objects made * - Parts of a prefab that is not attached are objects of their own in the world: moving, turning or sizing the * handle puts them where it now says with its next update, or call placeParts; destroying it destroys them * - Parts of an attached prefab are its children and move with it as one body * @extends EngineObject3D * @memberof Level3D */ class Prefab3D extends EngineObject3D { /** Create an instance of a prefab, made by its type: place one with a level or level3DSpawn * @param {Vector3} [pos3D] * @param {string} [prefabName] - A prefab added with level3DAddPrefab */ constructor(pos3D, prefabName='') { super(pos3D); /** @property {string} - The prefab it is an instance of */ this.prefabName = prefabName; /** @property {boolean} - Are its parts its children, moving with it as one body */ this.attached = !!level3DPrefabs.get(prefabName)?.attached || level3DPrefabAttached > 0; /** @property {Array} - What the prefab's objects made, in the prefab's order * @type {Array} */ this.parts = []; /** @property {Vector3} - From the prefab's origin to the handle, in the prefab's own space: nothing for * separate parts, and for an attached prefab the middle of the box around its parts, where its handle * is, as an object's place is the middle of its body */ this.originOffset = vec3(); /** @type {Array<{id?: number, type: string, pos?: Array, rotation?: Array, scale?: Array|number, properties?: Object}>} */ this.partObjects = []; // the prefab's object of each part this.partsPlaced = ''; // where the handle was when its parts were last placed, undefined parts not made this.partsMade = false; } /** Put the instance with its prefab's origin at a place, turned and sized as the handle is: where a level's * object or level3DSpawn says it goes; the handle of an attached prefab is then at the middle of its body * @param {Vector3} pos3D */ placeAt(pos3D) { this.pos3D = pos3D.copy(); this.placeParts(); const o = this.originOffset; if (o.x || o.y || o.z) this.pos3D = buildMatrix(pos3D, this.rotation3D, this.scale3D).transformPoint(o); } /** Put the parts where the handle is now, making them the first time; called by the handle's update when it * has moved, turned or changed size */ placeParts() { if (this.destroyed) return; this.partsMade ||= (this.makeParts(), true); this.partsPlaced = this.placeKey(); if (this.attached) return; // children follow by themselves this.parts.forEach((part, i)=> { if (!(part instanceof EngineObject3D)) return; // what a function made is where it was made const to = level3DPrefabPartTransform(this, this.partObjects[i]); part.rotation3D = to.rotation; part.scale3D = (level3DBaseScale.get(part) ?? vec3(1)).multiply(to.scale); // a prefab inside this one goes by its origin, which is not where an attached one's handle is part instanceof Prefab3D ? part.placeAt(to.pos) : part.pos3D = to.pos; }); } // the handle's place, turn and size as one text, to tell when it has changed placeKey() { const p = this.pos3D, r = this.rotation3D, s = this.scale3D; return [p.x, p.y, p.z, r.x, r.y, r.z, s.x, s.y, s.z].join(); } // make the parts, each where the instance puts it, or attached at its own place inside the instance makeParts() { const prefab = level3DPrefabs.get(this.prefabName); if (!prefab) return; if (level3DPrefabMaking.includes(this.prefabName) || level3DPrefabDepth >= 8) { debug && console.error(`level3DLoad: the prefab ${this.prefabName} holds itself, left out there`); return; } level3DPrefabMaking.push(this.prefabName); ++level3DPrefabDepth; this.attached && ++level3DPrefabAttached; try { const low = vec3(Infinity), high = vec3(-Infinity), children = []; for (const object of prefab.objects) { const local = {pos: level3DVector(object.pos, vec3()), scale: level3DVector(object.scale, vec3(1)), rotation: level3DVector(object.rotation, vec3()).scale(PI / 180)}; const at = this.attached ? local : level3DPrefabPartTransform(this, object); const part = level3DMakeAt(object, at.pos, at.rotation, at.scale); if (!part) continue; // level3DMakeAt gives an object or nothing this.parts.push(part); this.partObjects.push(object); if (!this.attached || !(part instanceof EngineObject3D)) continue; // a child rides with its parent and has no collision of its own part.setCollision(false, false, false); children.push(part); // the corners of its box as it is turned; its place is its middle, a prefab inside this one too const box = buildMatrix(part.pos3D, part.rotation3D, part.size3D.multiply(part.scale3D)); for (let i = 8; i--;) { const p = box.transformPoint(vec3(i & 1 ? .5 : -.5, i & 2 ? .5 : -.5, i & 4 ? .5 : -.5)); for (const k of ['x', 'y', 'z']) low[k] = min(low[k], p[k]), high[k] = max(high[k], p[k]); } } // an attached instance is the box around its parts, its handle at the middle of it as an object's // place is the middle of its body, so a game that makes it solid has it solid where it is seen if (children.length) { this.size3D = high.subtract(low); this.originOffset = low.add(high).scale(.5); } for (const part of children) { part.pos3D = part.pos3D.subtract(this.originOffset); render3DShareSettings(part, this); // drawn with what is set on the handle, its own otherwise this.addChild(part); } } finally { --level3DPrefabDepth; level3DPrefabMaking.pop(); this.attached && --level3DPrefabAttached; } } /** Keep the parts with the handle, called automatically each frame */ update() { super.update(); if (!this.attached && this.partsPlaced !== this.placeKey()) this.placeParts(); } /** Destroy the instance and its parts * @param {boolean} [immediate] */ destroy(immediate) { if (this.destroyed) return; for (const part of this.parts) part.parent === this || part.destroy?.(immediate); // children go with their parent super.destroy(immediate); } /** A prefab's instance has nothing of its own to draw */ render3D() {} } /////////////////////////////////////////////////////////////////////////////// // 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}); // a light, a spotlight with a cone: cone is the angle in degrees from its forward out to the edge of its beam, the // object's rotation aims it, softness is how much of the cone fades, shadows makes it the one that casts them, and // lensFlare gives it a lens flare; not flare, since a property is also set on the object, where flare is the flare level3DAddType('Light', function(pos, properties) { const light = new Light3D(pos, properties.radius, properties.color, properties.intensity); light.coneAngle = max(properties.cone, 0) * PI / 180; light.coneSoftness = properties.softness; if (properties.shadows && render3D) render3D.shadowLight = light; properties.lensFlare && light.addFlare(); return light; }, {color: WHITE, radius: 5, intensity: 1, cone: 0, softness: .2, shadows: false, lensFlare: false}); /** * LittleJS Particle Effects Plugin * - Ready made particle effects in one line, particleEffect('fire', pos), and particleEffect3D for 3D * - Every built-in effect is tuned around a one unit emitter; options.scale grows it to fit, options.hue and * options.saturation recolor it * - Effects are plain data, {name, settings, behaviors}, the format the particle designer saves: load a library the * designer saved with particleEffectsLoad and play its effects by name * - The built-in effects draw with a sheet of shapes the plugin draws itself, so they need no image * @namespace ParticleEffects */ /////////////////////////////////////////////////////////////////////////////// // settings, what an effect sets on an emitter /** The groups the settings are in, in order * @type {Array} * @memberof ParticleEffects */ const particleEffectGroups = ['Emitter', 'Particles', 'Color', 'Motion', 'Collision', 'Texture', 'Behaviors']; /** Every setting an effect has: its name, kind (number, checkbox, color or shape), default, the range the designer's * slider covers (min, max, step), the hard limits a typed value is clamped to, and whether it is set after the * emitter is made rather than passed to its constructor (extra) * @type {Array<{group:string, name:string, kind:string, value:any, min:number, max:number, step:number, * description:string, hardMin:number, hardMax:number, extra:boolean}>} * @memberof ParticleEffects */ const particleEffectSettings = []; let particleEffectSettingGroup; // the group settings are being added to // the slider covers min to max, typed values may go out to hardMin and hardMax function particleEffectAddSetting(name, kind, value, min, max, step, description, hardMin=min, hardMax=max) { // extras are not constructor arguments, the code export assigns them after const extra = ['trailScale', 'velocityInheritance', 'localSpace', 'restitution', 'friction', 'gravity'].includes(name); particleEffectSettings.push({group:particleEffectSettingGroup, name, kind, value, min, max, step, description, hardMin, hardMax, extra}); } particleEffectSettingGroup = 'Emitter'; particleEffectAddSetting('emitRate', 'number', 100, 0, 500, .1, 'Particles per second, 0 emits none', 0, 1e4); particleEffectAddSetting('emitTime', 'number', 0, 0, 5, .05, 'Seconds to emit for, 0 is forever', 0, 1e9); particleEffectAddSetting('emitSize', 'number', 0, 0, 10, .05, 'Diameter, or width when rectangular', 0, 1e9); particleEffectAddSetting('emitRect', 'checkbox', false, 0, 0, 0, 'Rectangle emitter, off is a circle'); particleEffectAddSetting('emitHeight', 'number', 0, 0, 10, .05, 'Rectangle height', 0, 1e9); particleEffectAddSetting('emitConeAngle', 'number', PI, 0, PI, .01, 'Half angle particles move in, 3.14 is all ways'); particleEffectAddSetting('angle', 'number', 0, -3.14, 3.14, .01, 'Emitter angle, 0 points up', -PI, PI); // the slider steps from its min particleEffectSettingGroup = 'Particles'; particleEffectAddSetting('particleTime', 'number', .5, 0, 5, .05, 'Seconds each particle lives', 0, 1e9); particleEffectAddSetting('sizeStart', 'number', .1, 0, 5, .01, 'Size at the start of life', 0, 1e9); particleEffectAddSetting('sizeEnd', 'number', 1, 0, 5, .01, 'Size at the end of life', 0, 1e9); particleEffectAddSetting('fadeRate', 'number', .1, 0, 1, .01, 'Share of life spent fading in and out'); particleEffectAddSetting('randomness', 'number', .2, 0, 1, .01, 'How much each particle varies'); particleEffectAddSetting('particleConeAngle', 'number', PI, 0, PI, .01, 'Half angle of start rotation, 3.14 is any'); particleEffectAddSetting('trailScale', 'number', 0, 0, 20, .1, 'Stretch along the motion, 0 is off', 0, 1e9); particleEffectAddSetting('additive', 'checkbox', false, 0, 0, 0, 'Glow with additive blending'); particleEffectSettingGroup = 'Color'; particleEffectAddSetting('colorStartA', 'color', [1,1,1,1], 0, 1, .01, 'Start color, each particle picks between A and B'); particleEffectAddSetting('colorStartB', 'color', [1,1,1,1], 0, 1, .01, 'Second start color'); particleEffectAddSetting('colorEndA', 'color', [1,1,1,0], 0, 1, .01, 'End color, each particle picks between A and B'); particleEffectAddSetting('colorEndB', 'color', [1,1,1,0], 0, 1, .01, 'Second end color'); particleEffectAddSetting('randomColorLinear', 'checkbox', true, 0, 0, 0, 'Pick along A to B, off picks each channel'); particleEffectSettingGroup = 'Motion'; particleEffectAddSetting('speed', 'number', .1, 0, .5, .005, 'Start speed, world units per frame', -1e9, 1e9); // negative goes back against the cone, as the emitter takes it particleEffectAddSetting('angleSpeed', 'number', .05, 0, .5, .005, 'Spin speed, radians per frame', -1e9, 1e9); particleEffectAddSetting('damping', 'number', 1, .8, 1, .001, 'Speed kept each frame, 1 keeps it all', 0, 1); particleEffectAddSetting('angleDamping', 'number', 1, .8, 1, .001, 'Spin kept each frame', 0, 1); particleEffectAddSetting('gravityScale', 'number', 0, -2, 2, .05, 'How much gravity pulls', -1e9, 1e9); particleEffectAddSetting('gravity', 'number', 0, -.02, .02, .0005, 'Its own fall each frame, the same in any game', -1, 1); particleEffectAddSetting('velocityInheritance', 'number', 0, 0, 1, .01, 'Share of emitter motion passed on'); particleEffectAddSetting('localSpace', 'checkbox', false, 0, 0, 0, 'Particles move with the emitter'); particleEffectSettingGroup = 'Collision'; particleEffectAddSetting('collideLevel', 'checkbox', false, 0, 0, 0, 'Hit tiles, the preview adds a floor'); particleEffectAddSetting('restitution', 'number', 0, 0, 1, .01, 'Bounce when hitting tiles'); particleEffectAddSetting('friction', 'number', .8, 0, 1, .01, 'Speed kept sliding along tiles, the larger of this and the friction of the layer, as for an object'); particleEffectSettingGroup = 'Texture'; particleEffectAddSetting('shape', 'shape', 'soft', 0, 0, 0, 'Built-in shape, none uses the tile'); particleEffectAddSetting('tileIndex', 'number', -1, -1, 63, 1, 'Tile in texture 0 when no shape, -1 is untextured', -1, 1e4); particleEffectAddSetting('tileSize', 'number', 16, 1, 128, 1, 'Tile size in texture pixels', 1, 4096); particleEffectAddSetting('tilePadding', 'number', 1, 0, 8, 1, 'Pixels of padding around each tile', 0, 64); /** The behaviors an effect can use, each a push applied to every particle every update, with a 2D and a 3D version * @type {Array<{name:string, update:(Function|undefined), update3D:(Function|undefined), min:number, max:number, * value:number, description:string}>} * @memberof ParticleEffects */ const particleEffectBehaviors = [ {name:'wobble', min:0, max:5, value:1, description:'Sway from side to side'}, {name:'swirl', min:-2, max:2, value:1, description:'Turn the direction of travel'}, {name:'turbulence', min:0, max:5, value:1, description:'Random pushes every frame'}, {name:'attract', min:-2, max:2, value:1, description:'Pull toward the emitter, negative pushes away'}, {name:'orbit', min:-2, max:2, value:1, description:'Push around the emitter, add attract to circle it'}, {name:'wind', min:-2, max:2, value:1, description:'A sideways push that grows with age'}, {name:'stick', min:0, max:1, value:1, description:'Grip on landing, pair with collideLevel'}, ].map(b=> ({...b, update: undefined, update3D: undefined})); const particleEffectBehavior = (name)=> particleEffectBehaviors.find(b=> b.name === name); /** Names of the shapes the plugin draws, for an effect's shape setting * @type {Array} * @memberof ParticleEffects */ const particleEffectShapes = ['dot', 'soft', 'glow', 'smoke', 'spark', 'square', 'triangle', 'ring', 'star', 'plus']; /////////////////////////////////////////////////////////////////////////////// // effects // a color from an [r,g,b,a] array or a Color, undefined if it is neither function particleEffectColor(value) { if (value instanceof Color) value = [value.r, value.g, value.b, value.a]; if (isArray(value) && value.length === 4 && value.every(isNumber)) return value.map(c=> clamp(c)); } /** Any input into a whole effect: missing fields take their defaults, bad ones are dropped or clamped * @param {Object} [raw] - {name, settings, behaviors}, from a library file, the designer or code * @return {{name:string, settings:Object, behaviors:Array<{name:string, strength:number}>}} * @memberof ParticleEffects */ function particleEffectSanitize(raw) { // one line of text, line breaks and control characters made spaces const text = typeof raw?.name === 'string' ? raw.name.replace(/[\x00-\x1f\x7f]+/g, ' ').trim() : ''; // whole characters, and no space left where it was cut const name = text ? textGraphemes(text).slice(0, 60).join('').trim() : 'Effect'; const input = raw?.settings && typeof raw.settings === 'object' ? {...raw.settings} : {}; // a library saved before shapes names its tile and no shape, it keeps its tile if (input.shape === undefined && isNumber(input.tileIndex)) input.shape = ''; const settings = {}; for (const setting of particleEffectSettings) { const value = input[setting.name], fallback = setting.value; if (setting.kind === 'checkbox') settings[setting.name] = typeof value === 'boolean' ? value : value === 0 || value === 1 ? !!value : fallback; else if (setting.kind === 'color') settings[setting.name] = particleEffectColor(value) || fallback.slice(); else if (setting.kind === 'shape') settings[setting.name] = value === '' || particleEffectShapes.includes(value) ? value : fallback; else if (isNumber(value)) { // an angle past half a turn comes around, a clamp would point it the other way const wraps = setting.name === 'angle' && isFinite(value) && abs(value) > PI; const clamped = clamp(wraps ? mod(value + PI, 2*PI) - PI : value, setting.hardMin, setting.hardMax); settings[setting.name] = setting.step >= 1 ? round(clamped) : clamped; } else settings[setting.name] = fallback; } // local space particles are placed relative to the emitter, the tile collision is in the world if (settings.localSpace) settings.collideLevel = false; // known behaviors once each, in table order const behaviors = []; const list = isArray(raw?.behaviors) ? raw.behaviors : []; for (const b of particleEffectBehaviors) { const found = list.find(x=> x?.name === b.name); if (!found) continue; const strength = isNumber(found.strength) ? clamp(found.strength, b.min, b.max) : b.value; behaviors.push({name:b.name, strength}); } // behaviors not known yet stay, a game may add them after the effect is loaded for (const x of list) if (typeof x?.name === 'string' && x.name && !particleEffectBehavior(x.name) && !behaviors.some(b=> b.name === x.name)) behaviors.push({name:x.name, strength: isNumber(x.strength) ? x.strength : 1}); return {name, settings, behaviors}; } /** A copy of an effect with its four colors turned around the color wheel and their saturation scaled; lightness and * alpha stay, and gray and white have no hue to turn * @param {Object} effect * @param {number} [hue] - How far around the wheel, 1 is all the way * @param {number} [saturation] - Multiplies the saturation, 0 is gray, clamped to 1 * @return {Object} * @memberof ParticleEffects */ function particleEffectRecolor(effect, hue=0, saturation=1) { effect = particleEffectSanitize(effect); // colors as channels, whatever it was given const settings = {...effect.settings}; for (const name of ['colorStartA', 'colorStartB', 'colorEndA', 'colorEndB']) { const [h, s, l, a] = new Color(...settings[name]).HSLA(); const c = new Color().setHSLA(mod(h + hue), clamp(s * saturation), l, a); settings[name] = [c.r, c.g, c.b, c.a]; } return {...effect, settings, behaviors: effect.behaviors.map(b=> ({...b}))}; } /////////////////////////////////////////////////////////////////////////////// // libraries // the effects by lower case name, the built-ins and any a game adds or loads const particleEffectsByName = new Map; /** Add effects to play by name, sanitized; one with the name of an effect already there replaces it * @param {Array|Object} effects * @memberof ParticleEffects */ function particleEffectsAdd(effects) { for (const effect of isArray(effects) ? effects : [effects]) { const clean = particleEffectSanitize(effect); particleEffectsByName.set(clean.name.toLowerCase(), clean); } } /** The effect with a name, any case, undefined if there is none * @param {string} name * @return {Object|undefined} * @memberof ParticleEffects */ function particleEffectsGet(name) { return particleEffectsByName.get(String(name).toLowerCase()); } /** A library file, or one effect, into sanitized effects; throws on bad input * @param {string} text * @return {Array} * @memberof ParticleEffects */ function particleEffectsParse(text) { return particleEffectsFromData(JSON.parse(text)); } // a library file's data, or one effect's, into sanitized effects function particleEffectsFromData(data) { const list = isArray(data?.effects) ? data.effects : data?.settings ? [data] : undefined; if (!list || !list.length) throw new Error('no effects in this file'); return list.map(particleEffectSanitize); } /** The text of a library file with these effects, what particleEffectsLoad and the designer read * @param {Array} effects * @return {string} * @memberof ParticleEffects */ function particleEffectsText(effects) { return JSON.stringify({version:1, effects}, undefined, 1); } /** Load a library file the particle designer saved and add its effects * @param {string} url * @return {Promise>} - The effects it had * @memberof ParticleEffects */ async function particleEffectsLoad(url) { const effects = particleEffectsFromData(await fetchJSON(url)); particleEffectsAdd(effects); return effects; } /////////////////////////////////////////////////////////////////////////////// // the shape sheet: every shape white on clear in a cell of one canvas, drawn the first time one is asked for let particleEffectShapeTiles; /** The tile of a built-in shape, on a sheet the plugin draws once; undefined headless, without a canvas or without * WebGL, where an effect draws untextured squares * @param {string} name - One of particleEffectShapes * @return {TileInfo|undefined} * @memberof ParticleEffects */ function particleEffectShapeTile(name) { if (!particleEffectShapeTiles) { if (headlessMode || !glContext || !canvasAvailable()) return; // drawn at 4 times 32 pixel cells, so a big soft shape stays smooth when textures are pixelated const cell = 32, r = 15, count = particleEffectShapes.length, res = 4; const context = createCanvasContext(cell * count * res, cell * res); context.scale(res, res); context.fillStyle = context.strokeStyle = '#fff'; const soft = (x, y, radius, alpha=(t)=> 1 - t)=> { // a round falloff from the middle out const g = context.createRadialGradient(x, y, 0, x, y, radius); for (let i = 0; i <= 8; ++i) g.addColorStop(i/8, 'rgba(255,255,255,' + alpha(i/8).toFixed(3) + ')'); context.fillStyle = g; context.beginPath(); context.arc(x, y, radius, 0, 2*PI); context.fill(); context.fillStyle = '#fff'; }; const draw = { dot: (x, y)=> { context.beginPath(); context.arc(x, y, r, 0, 2*PI); context.fill(); }, soft: (x, y)=> soft(x, y, r), glow: (x, y)=> soft(x, y, r, (t)=> engineGlowAlpha(t, 1)), smoke: (x, y)=> { // a lumpy puff of soft circles for (const [dx, dy, dr] of [[-4,-3,9], [4,-2,8], [0,4,9], [-5,5,6], [6,5,6]]) soft(x + dx, y + dy, dr, (t)=> (1 - t) * .7); }, spark: (x, y)=> { // a glow squeezed thin, a streak along the tile's up context.save(); context.translate(x, y); context.scale(.3, 1); soft(0, 0, r, (t)=> engineGlowAlpha(t, .6)); context.restore(); }, square: (x, y)=> context.fillRect(x - r, y - r, 2*r, 2*r), triangle: (x, y)=> { context.beginPath(); context.moveTo(x, y - r); context.lineTo(x + r, y + r); context.lineTo(x - r, y + r); context.fill(); }, ring: (x, y)=> { context.lineWidth = 3; context.beginPath(); context.arc(x, y, r - 2, 0, 2*PI); context.stroke(); }, star: (x, y)=> { context.beginPath(); for (let i = 0; i < 10; ++i) { const a = i * PI / 5 - PI/2, d = i % 2 ? r * .45 : r; context.lineTo(x + cos(a) * d, y + sin(a) * d); } context.fill(); }, plus: (x, y)=> { context.fillRect(x - r, y - 4, 2*r, 8); context.fillRect(x - 4, y - r, 8, 2*r); }, }; particleEffectShapes.forEach((name, i)=> draw[name](i * cell + cell/2, cell/2)); const texture = new TextureInfo(context.canvas, true, false, false); // smooth even in a pixel art game particleEffectShapeTiles = new Map(particleEffectShapes.map((name, i)=> [name, new TileInfo(vec2((i * cell + 1) * res, res), vec2((cell - 2) * res), texture)])); } return particleEffectShapeTiles.get(name); } /////////////////////////////////////////////////////////////////////////////// // behaviors, the 2D pushes: a distance moved grows with the particle's scale, so a bigger effect moves the same way { const b = particleEffectBehavior; b('wobble').update = (p, s)=> { // each particle on its own beat, from its randomized life, so nothing is added to it p.velocity.x += s * .003 * p.scale * sin(time*6 + p.lifeTime * 97 % 9); }; b('swirl').update = (p, s)=> { // turn the direction of travel a little each frame const a = s * .05, c = cos(a), n = sin(a), v = p.velocity; v.set(v.x*c - v.y*n, v.x*n + v.y*c); }; b('turbulence').update = (p, s)=> { p.velocity.x += rand(-1, 1) * s * .005 * p.scale; p.velocity.y += rand(-1, 1) * s * .005 * p.scale; }; b('attract').update = (p, s)=> { // pull toward the emitter by the distance, which grows with the scale already const e = p.emitter, x = e.localSpace ? 0 : e.pos.x, y = e.localSpace ? 0 : e.pos.y; p.velocity.x += (x - p.pos.x) * s * .002; p.velocity.y += (y - p.pos.y) * s * .002; }; b('orbit').update = (p, s)=> { const e = p.emitter, x = e.localSpace ? 0 : e.pos.x, y = e.localSpace ? 0 : e.pos.y; p.velocity.x -= (p.pos.y - y) * s * .002; p.velocity.y += (p.pos.x - x) * s * .002; }; b('wind').update = (p, s)=> p.velocity.x += s * .004 * p.scale * min((time - p.spawnTime) / p.lifeTime, 1); b('stick').update = (p, s)=> { // grip the ground on landing, pair with collideLevel if (!p.groundObject) return; p.velocity.x *= 1 - s; p.angleVelocity *= 1 - s; }; } /** Add a behavior effects can use by name, or replace one * @param {string} name * @param {function(Particle, number): void} update - Pushes a 2D particle, given the strength * @param {function(Particle3D, number): void} [update3D] - The same for a 3D particle, none leaves 3D alone * @param {number} [min] - Strength range the designer offers, and the range a strength is clamped to * @param {number} [max] * @param {number} [value] - Strength when first added * @param {string} [description] * @memberof ParticleEffects */ function particleEffectsAddBehavior(name, update, update3D, min=-2, max=2, value=1, description='') { const old = particleEffectBehavior(name); old && particleEffectBehaviors.splice(particleEffectBehaviors.indexOf(old), 1); particleEffectBehaviors.push({name, update, update3D, min, max, value, description}); } // the update callback that runs an effect's behaviors, in 2D or 3D, undefined when it has none for that function particleEffectUpdateCallback(behaviors, is3D) { const calls = behaviors.map(b=> [particleEffectBehavior(b.name)?.[is3D ? 'update3D' : 'update'], b.strength]) .filter(c=> c[0]); if (calls.length) return (p)=> { for (const [update, strength] of calls) update(p, strength); }; } /////////////////////////////////////////////////////////////////////////////// // 2D effects // the settings the builders set themselves instead of copying across const particleEffectIndirect = ['emitSize', 'emitRect', 'emitHeight', 'tileIndex', 'tileSize', 'tilePadding', 'shape']; // the tile an effect draws with: its shape, else its tile in texture 0 when that fits, else none function particleEffectTileInfo(s) { if (s.shape) return particleEffectShapeTile(s.shape); if (s.tileIndex < 0) return; const texture = textureInfos[0], cell = s.tileSize + s.tilePadding*2; if (!texture || !texture.size.x) return; if (s.tileIndex < floor(texture.size.x / cell) * floor(texture.size.y / cell)) return tile(s.tileIndex, s.tileSize, 0, s.tilePadding); } /** Set a 2D emitter to an effect, live, so a running one keeps its particles * @param {ParticleEmitter} emitter * @param {Object} effect * @memberof ParticleEffects */ function particleEffectApply(emitter, effect) { // an effect written by hand or changed since it was added particleEffectSet(emitter, particleEffectSanitize(effect)); } // set a 2D emitter to an effect that is sanitized already, as a play's is function particleEffectSet(emitter, effect) { const s = effect.settings, ended = emitter.emitTime < 0; // destroyed, its last particles going for (const setting of particleEffectSettings) if (!particleEffectIndirect.includes(setting.name)) emitter[setting.name] = setting.kind === 'color' ? new Color(...s[setting.name]) : s[setting.name]; ended && (emitter.emitTime = -1); emitter.emitCircle = !s.emitRect; emitter.emitSize = vec2(s.emitSize, s.emitRect ? s.emitHeight : s.emitSize); emitter.tileInfo = particleEffectTileInfo(s); emitter.renderOrder = s.additive ? 1e9 : 0; emitter.particleUpdateCallback = particleEffectUpdateCallback(effect.behaviors); } // the options a play takes besides the settings const PARTICLE_EFFECT_OPTIONS = ['hue', 'saturation', 'scale', 'angle', 'tileInfo', 'flatten']; // whether an option is a value its setting takes, as particleEffectSanitize keeps one function particleEffectOptionValid(setting, value) { return setting.kind === 'checkbox' ? typeof value === 'boolean' || value === 0 || value === 1 : setting.kind === 'color' ? !!particleEffectColor(value) : setting.kind === 'shape' ? value === '' || particleEffectShapes.includes(value) : isNumber(value); } // an effect from a name or an effect, recolored by the options function particleEffectResolve(nameOrEffect, options) { const found = typeof nameOrEffect == 'string' ? particleEffectsGet(nameOrEffect) : nameOrEffect; ASSERT(!!found, 'no particle effect named ' + nameOrEffect); if (!found) return; // a vec2 emitSize is a rectangle, as ParticleEmitter takes one, unless emitRect is false, which makes a circle as // wide as its x if (isVector2(options.emitSize)) options = {...options, emitSize: options.emitSize.x, emitHeight: options.emitSize.y, emitRect: options.emitRect ?? true}; if (debug) for (const key in options) if (!PARTICLE_EFFECT_OPTIONS.includes(key) && !particleEffectSettings.some((setting)=> setting.name === key)) { // name the ones there are, each its own argument since ASSERT cuts each at 200 characters, and the one // meant when only its case is off const names = [...new Set([...PARTICLE_EFFECT_OPTIONS, ...particleEffectSettings.map((s)=> s.name)])]; const meant = names.find((name)=> name.toLowerCase() === key.toLowerCase()); ASSERT(false, 'particleEffect: no option named ' + key + (meant ? ', did you mean ' + meant + '?' : '') + '\nOptions:', ...names); } // any setting in the options replaces the effect's own for this play, emitTime for a burst or a loop; one that is // not a value of its kind keeps the effect's own, where the sanitizing would put the library's default const settings = {...found.settings}; for (const setting of particleEffectSettings) { const value = options[setting.name]; if (value === undefined) continue; const valid = particleEffectOptionValid(setting, value); ASSERT(valid, `particleEffect: ${setting.name} must be a ${setting.kind === 'checkbox' ? 'boolean' : setting.kind}`, value); if (valid) settings[setting.name] = value; } // sanitized each time, an effect may be written by hand or changed after it was added const effect = particleEffectSanitize({...found, settings}), {hue=0, saturation=1} = options; return hue || saturation != 1 ? particleEffectRecolor(effect, hue, saturation) : effect; } /** Play an effect: a 2D emitter set to it, placed, scaled and recolored * - A continuous effect (fire, a torch) goes until destroyed or given an emitTime, a one-shot ends itself * - Any setting in the options replaces the effect's own for this play: {emitTime: .5} for a burst of a continuous * effect, {emitTime: 0, emitRate: 30} to keep a one-shot going, or speed, particleTime and the rest * - Attach it to an object with addChild to follow it * @param {string|Object} nameOrEffect - A built-in or added effect's name, or an effect * @param {Vector2} [pos] * @param {Object} [options] - What to change for this play, each left out when not wanted: * scale grows the whole effect, the built-ins fit a one unit object at 1; * hue turns its colors around the color wheel, 1 is all the way; * saturation multiplies its saturation, 0 is gray; * angle is its direction, 0 is up, the effect's own angle when not given; * tileInfo, a TileInfo or a TextureInfo, is the game's own art to draw with in place of the effect's shape, * tinted by its colors, a whole texture drawn as one tile; * and any effect setting by its name, emitTime, emitRate, speed and the rest, replaces the effect's own * @return {ParticleEmitter|undefined} - undefined when there is no such effect * @memberof ParticleEffects */ function particleEffect(nameOrEffect, pos=vec2(), options={}) { options ||= {}; // null is no options too const effect = particleEffectResolve(nameOrEffect, options); if (!effect) return; const emitter = new ParticleEmitter(pos.copy()); particleEffectSet(emitter, effect); emitter.scale = options.scale ?? 1; if (options.angle !== undefined) emitter.angle = options.angle; const tileInfo = options.tileInfo; // the game's own art in place of the shape, a whole texture as one tile if (tileInfo) emitter.tileInfo = tileInfo instanceof TextureInfo ? new TileInfo(vec2(), tileInfo.size, tileInfo) : tileInfo; return emitter; } /** An effect with a 2D emitter's settings, to save, build again, or build in 3D with particleEffect3D; its tile is * left out, since a hand made emitter's tile is its own texture and not one an effect can name, and so are its * scale, which an effect does not keep: pass it again with options.scale, and its renderOrder, which an effect takes * from additive; a finished emitter's emit time is the time it emitted for * @param {ParticleEmitter} emitter * @param {string} [name] * @return {Object} * @memberof ParticleEffects */ function particleEffectFromEmitter(emitter, name='Effect') { const settings = {}; for (const setting of particleEffectSettings) if (!particleEffectIndirect.includes(setting.name) && emitter[setting.name] !== undefined) settings[setting.name] = emitter[setting.name]; // a Color is taken as its channels by the sanitizer if (emitter.emitTime < 0) // destroyed, its last particles going: as long as it emitted, not 0, for ever settings.emitTime = emitter.emitElapsed; settings.emitRect = !emitter.emitCircle; settings.emitSize = emitter.emitSize.x; settings.emitHeight = emitter.emitSize.y; settings.shape = ''; settings.tileIndex = -1; return particleEffectSanitize({name, settings}); } /////////////////////////////////////////////////////////////////////////////// // 3D effects // the 3D pushes: a 3D particle is one view object the emitter reuses, so what differs per particle comes from its // randomized lifetime, and distances grow with the emitter's scale { const b = particleEffectBehavior; const center = (p)=> p.emitter.worldPos3D || p.emitter.pos3D; // where it was this update b('wobble').update3D = (p, s)=> p.velocity.x += s * .003 * p.scale * sin(time*6 + p.lifeTime * 97 % 9); b('swirl').update3D = (p, s)=> { const a = s * .05, c = cos(a), n = sin(a), v = p.velocity; v.set(v.x*c - v.y*n, v.x*n + v.y*c, v.z); }; b('turbulence').update3D = (p, s)=> { const k = s * .005 * p.scale; p.velocity.x += rand(-1, 1) * k, p.velocity.y += rand(-1, 1) * k, p.velocity.z += rand(-1, 1) * k; }; b('attract').update3D = (p, s)=> { const c = center(p); p.velocity.x += (c.x - p.pos.x) * s * .002; p.velocity.y += (c.y - p.pos.y) * s * .002; p.velocity.z += (c.z - p.pos.z) * s * .002; }; b('orbit').update3D = (p, s)=> { const c = center(p); p.velocity.x -= (p.pos.y - c.y) * s * .002; p.velocity.y += (p.pos.x - c.x) * s * .002; }; b('wind').update3D = (p, s)=> p.velocity.x += s * .004 * p.scale * min(p.age / p.lifeTime, 1); // stick has no 3D push: particleEffectApply3D makes it the emitter's stick, its grip on landing } /** Set a 3D emitter to an effect, live, so a running one keeps its particles; its place, scale and flatten stay * @param {ParticleEmitter3D} emitter * @param {Object} effect * @memberof ParticleEffects */ function particleEffectApply3D(emitter, effect) { // an effect written by hand or changed since it was added particleEffectSet3D(emitter, particleEffectSanitize(effect)); } // set a 3D emitter to an effect that is sanitized already, as a play's is function particleEffectSet3D(emitter, effect) { const s = effect.settings, e = emitter; // a circle is a sphere and a rectangle a box as deep as it is wide e.emitSize = s.emitRect ? vec3(s.emitSize, s.emitHeight, s.emitSize) : s.emitSize; e.emitTime = e.emitTime < 0 ? -1 : s.emitTime; // destroyed, its last particles going, stays so e.emitRate = s.emitRate, e.emitConeAngle = s.emitConeAngle; e.tileInfo = particleEffectTileInfo(s); for (const name of ['colorStartA', 'colorStartB', 'colorEndA', 'colorEndB']) e[name] = new Color(...s[name]); for (const name of ['particleTime', 'sizeStart', 'sizeEnd', 'speed', 'damping', 'gravity', 'fadeRate', 'randomness', 'additive', 'gravityScale', 'angleSpeed', 'angleDamping', 'collideLevel', 'restitution']) e[name] = s[name]; e.trailTime = s.trailScale / 60; // a stretch of speed times trailScale is a streak of that many frames e.friction = s.friction; e.stick = effect.behaviors.find(b=> b.name == 'stick')?.strength ?? 0; // in 3D the emitter grips on landing // a 2D emitter at angle a shoots along (sin a, cos a), a z turn r takes up to (-sin r, cos r), so r is -a e.rotation3D = vec3(0, 0, -s.angle); e.particleUpdateCallback = particleEffectUpdateCallback(effect.behaviors, true); } /** Play an effect in 3D: a ParticleEmitter3D set to it, placed, scaled and recolored * - The same effect data as particleEffect, so the look carries across: a circle spawn area becomes a sphere and a * rectangle a box as deep as it is wide, both flat across the way it emits with options.flatten, a disc or a * sheet; a trail becomes a streak of the same length, and the settings the 3D emitter lacks (particleConeAngle, * randomColorLinear, velocityInheritance, localSpace) are left out; the stick behavior becomes the emitter's * stick, its grip where a particle lands * @param {string|Object} nameOrEffect - A built-in or added effect's name, or an effect * @param {Vector3} [pos3D] * @param {Object} [options] - scale, hue, saturation, angle, tileInfo and any setting as particleEffect; angle turns it * about z, so 0 is up; flatten makes the spawn area flat across the way it emits * @return {ParticleEmitter3D|undefined} - undefined when there is no such effect * @memberof ParticleEffects */ function particleEffect3D(nameOrEffect, pos3D=vec3(), options={}) { options ||= {}; // null is no options too const effect = particleEffectResolve(nameOrEffect, options); if (!effect) return; const e = new ParticleEmitter3D(pos3D.copy()); particleEffectSet3D(e, effect); if (options.angle !== undefined) e.rotation3D = vec3(0, 0, -options.angle); e.scale3D = vec3(options.scale ?? 1); e.emitFlat = !!options.flatten; // a sphere a disc, a box a sheet, across the way it emits if (options.tileInfo) e.tileInfo = options.tileInfo; return e; } /////////////////////////////////////////////////////////////////////////////// // the built-in effects, each tuned around a one unit emitter; one-shots have an emitTime and end themselves /** The names of the built-in effects, in the order a gallery shows them * @type {Array} * @memberof ParticleEffects */ const particleEffectsBuiltIn = []; { const add = (name, settings, behaviors=[])=> { particleEffectsBuiltIn.push(name); particleEffectsAdd({name, settings, behaviors}); }; const clear = (h, s, l)=> hsl(h, s, l, 0); // fire add('fire', {emitRate:120, emitSize:.6, emitConeAngle:.3, shape:'glow', particleTime:.8, sizeStart:.7, sizeEnd:.15, speed:.02, angleSpeed:.02, damping:.95, gravity:.003, fadeRate:.3, randomness:.3, additive:true, colorStartA:hsl(.08,1,.6), colorStartB:hsl(.14,1,.65), colorEndA:clear(.02,1,.5), colorEndB:clear(0,1,.3)}); add('torch', {emitRate:60, emitSize:.25, emitConeAngle:.2, shape:'glow', particleTime:.6, sizeStart:.45, sizeEnd:.1, speed:.015, angleSpeed:.02, damping:.95, gravity:.003, fadeRate:.3, randomness:.3, additive:true, colorStartA:hsl(.09,1,.6), colorStartB:hsl(.13,1,.7), colorEndA:clear(.03,1,.5), colorEndB:clear(0,1,.35)}); add('smoke', {emitRate:25, emitSize:.5, emitConeAngle:.3, shape:'smoke', particleTime:3, sizeStart:.5, sizeEnd:2, speed:.01, angleSpeed:.01, damping:.99, gravity:.0004, fadeRate:.5, randomness:.3, colorStartA:hsl(0,0,.5,.5), colorStartB:hsl(0,0,.3,.5), colorEndA:clear(0,0,.2), colorEndB:clear(0,0,.4)}, [{name:'wobble', strength:.5}]); add('steam', {emitRate:30, emitSize:.4, emitConeAngle:.2, shape:'smoke', particleTime:1.5, sizeStart:.3, sizeEnd:1.5, speed:.02, damping:.98, gravity:.0008, fadeRate:.6, randomness:.3, colorStartA:hsl(0,0,1,.4), colorStartB:hsl(.6,.2,.9,.3), colorEndA:clear(0,0,1), colorEndB:clear(.6,.2,.9)}, [{name:'wobble', strength:.3}]); // impacts add('explosion', {emitRate:600, emitTime:.1, emitSize:.5, shape:'glow', particleTime:.6, sizeStart:1, sizeEnd:.1, speed:.1, damping:.9, fadeRate:.2, randomness:.5, additive:true, colorStartA:hsl(.12,1,.65), colorStartB:hsl(.06,1,.55), colorEndA:clear(0,1,.5), colorEndB:clear(0,0,.3)}); add('sparks', {emitRate:100, emitSize:.2, shape:'soft', particleTime:.6, sizeStart:.08, sizeEnd:.04, speed:.07, damping:.96, gravity:-.006, fadeRate:.1, randomness:.4, additive:true, trailScale:3, colorStartA:hsl(.17,1,.9), colorStartB:hsl(.12,1,.65), colorEndA:clear(.07,1,.5), colorEndB:clear(.03,1,.5)}); add('hit', {emitRate:400, emitTime:.05, emitSize:.2, shape:'spark', particleTime:.35, sizeStart:.5, sizeEnd:.1, speed:.08, damping:.85, fadeRate:.1, randomness:.4, additive:true, trailScale:2, colorStartA:hsl(0,0,1), colorStartB:hsl(0,1,.7), colorEndA:clear(0,1,.5), colorEndB:clear(0,1,.4)}); add('dust', {emitRate:300, emitTime:.05, emitSize:1, emitRect:true, emitHeight:.1, emitConeAngle:1.2, shape:'smoke', particleTime:.8, sizeStart:.3, sizeEnd:.8, speed:.04, damping:.9, gravity:.0005, fadeRate:.5, randomness:.4, colorStartA:hsl(.1,.3,.6,.5), colorStartB:hsl(.08,.2,.5,.5), colorEndA:clear(.1,.3,.6), colorEndB:clear(.08,.2,.5)}); add('debris', {emitRate:200, emitTime:.05, emitSize:.5, emitConeAngle:1, shape:'square', particleTime:1.5, sizeStart:.15, sizeEnd:.1, speed:.07, angleSpeed:.2, damping:.99, gravity:-.008, fadeRate:.1, randomness:.5, colorStartA:hsl(.08,.4,.35), colorStartB:hsl(0,0,.4), colorEndA:hsl(.08,.4,.3), colorEndB:hsl(0,0,.35)}); // pickups and magic add('sparkle', {emitRate:30, emitSize:1, shape:'star', particleTime:.8, sizeStart:.3, sizeEnd:0, speed:.005, angleSpeed:.05, fadeRate:.5, randomness:.3, additive:true, colorStartA:hsl(.14,1,.7), colorStartB:hsl(0,0,1), colorEndA:clear(.14,1,.7), colorEndB:clear(0,0,1)}); add('magic', {emitRate:60, emitSize:1, shape:'soft', particleTime:1.5, sizeStart:.25, sizeEnd:0, speed:.015, fadeRate:.3, randomness:.3, additive:true, colorStartA:hsl(.55,1,.65), colorStartB:hsl(.79,1,.65), colorEndA:clear(0,0,1), colorEndB:clear(0,0,1)}, [{name:'swirl', strength:.5}]); add('heal', {emitRate:30, emitSize:.8, emitConeAngle:.2, shape:'plus', particleTime:1.2, sizeStart:.25, sizeEnd:.1, speed:.015, angleSpeed:0, particleConeAngle:0, gravity:.0005, fadeRate:.4, randomness:.3, additive:true, colorStartA:hsl(.33,1,.6), colorStartB:hsl(.3,1,.75), colorEndA:clear(.33,1,.6), colorEndB:clear(.3,1,.75)}); add('poison', {emitRate:20, emitSize:.8, shape:'smoke', particleTime:2, sizeStart:.3, sizeEnd:1, speed:.006, gravity:.0002, fadeRate:.6, randomness:.4, colorStartA:hsl(.3,.8,.45,.6), colorStartB:hsl(.8,.6,.45,.6), colorEndA:clear(.3,.8,.4), colorEndB:clear(.8,.6,.4)}, [{name:'wobble', strength:.5}]); add('portal', {emitRate:80, emitSize:1, shape:'soft', particleTime:1, sizeStart:.15, sizeEnd:.05, speed:.02, fadeRate:.3, randomness:.3, additive:true, colorStartA:hsl(.75,1,.6), colorStartB:hsl(.6,1,.6), colorEndA:clear(.75,1,.6), colorEndB:clear(.6,1,.6)}, [{name:'orbit', strength:1}, {name:'attract', strength:.5}]); // weather and ambience, one unit across at scale 1, so scale them to the view add('rain', {emitRate:30, emitSize:1, emitRect:true, emitHeight:.6, angle:PI, emitConeAngle:0, shape:'soft', particleTime:.8, sizeStart:.02, sizeEnd:.02, speed:.03, gravity:-.001, fadeRate:.1, randomness:.2, trailScale:5, colorStartA:hsl(.63,1,.8,.6), colorStartB:hsl(.63,1,.8,.4), colorEndA:hsl(.63,1,.8,.6), colorEndB:hsl(.63,1,.8,.4)}); add('snow', {emitRate:6, emitSize:1, emitRect:true, emitHeight:.6, angle:PI, emitConeAngle:.3, shape:'soft', particleTime:4, sizeStart:.05, sizeEnd:.05, speed:.004, damping:.98, gravity:-.0002, fadeRate:.2, randomness:.3, colorStartA:hsl(0,0,1), colorStartB:hsl(.58,1,.9), colorEndA:hsl(0,0,1), colorEndB:hsl(.58,1,.9)}, [{name:'wobble', strength:.15}]); add('leaves', {emitRate:3, emitSize:1, emitRect:true, emitHeight:.2, angle:PI, emitConeAngle:.5, shape:'triangle', particleTime:4, sizeStart:.12, sizeEnd:.12, speed:.003, angleSpeed:.05, damping:.98, gravity:-.0001, fadeRate:.2, randomness:.4, randomColorLinear:false, colorStartA:hsl(.25,.7,.4), colorStartB:hsl(.07,.8,.5), colorEndA:hsl(.08,.6,.35), colorEndB:hsl(.05,.7,.4)}, [{name:'wobble', strength:.15}, {name:'wind', strength:.05}]); add('bubbles', {emitRate:8, emitSize:1, emitRect:true, emitHeight:.1, emitConeAngle:.2, shape:'ring', particleTime:2, sizeStart:.1, sizeEnd:.2, speed:.01, gravity:.0003, fadeRate:.2, randomness:.4, colorStartA:hsl(.55,.8,.8,.8), colorStartB:hsl(.5,.6,.9,.6), colorEndA:hsl(.55,.8,.8,.3), colorEndB:hsl(.5,.6,.9,.2)}, [{name:'wobble', strength:.3}]); add('fireflies', {emitRate:3, emitSize:1, shape:'glow', particleTime:3, sizeStart:.15, sizeEnd:.15, speed:.003, damping:.99, fadeRate:.8, randomness:.3, additive:true, colorStartA:hsl(.2,1,.6), colorStartB:hsl(.15,1,.7), colorEndA:hsl(.2,1,.6), colorEndB:hsl(.15,1,.7)}, [{name:'turbulence', strength:.3}]); // trails and shots add('trail', {emitRate:60, shape:'soft', particleTime:.5, sizeStart:.4, sizeEnd:0, speed:0, angleSpeed:0, fadeRate:.1, randomness:.2, additive:true, colorStartA:hsl(.08,1,.6), colorStartB:hsl(.12,1,.65), colorEndA:clear(.03,1,.5), colorEndB:clear(0,1,.4)}); add('muzzle', {emitRate:300, emitTime:.05, emitConeAngle:.3, shape:'glow', particleTime:.2, sizeStart:.5, sizeEnd:.1, speed:.1, damping:.8, fadeRate:.1, randomness:.3, additive:true, colorStartA:hsl(.14,1,.8), colorStartB:hsl(.1,1,.6), colorEndA:clear(.08,1,.5), colorEndB:clear(.05,1,.4)}); // other add('blood', {emitRate:300, emitTime:.1, emitConeAngle:.8, shape:'dot', particleTime:3, sizeStart:.15, sizeEnd:.1, speed:.09, gravity:-.01, fadeRate:.1, randomness:.4, collideLevel:true, restitution:.1, colorStartA:hsl(0,1,.35), colorStartB:hsl(0,1,.2), colorEndA:hsl(0,1,.25), colorEndB:hsl(0,1,.15)}, [{name:'stick', strength:1}]); add('confetti', {emitRate:200, emitTime:.1, emitSize:.2, emitConeAngle:.6, shape:'square', particleTime:3, sizeStart:.1, sizeEnd:.1, speed:.09, angleSpeed:.15, damping:.96, gravity:-.004, fadeRate:.1, randomness:.4, randomColorLinear:false, colorStartA:hsl(0,1,.5), colorStartB:hsl(.6,1,.6), colorEndA:hsl(0,1,.5), colorEndB:hsl(.6,1,.6)}, [{name:'wobble', strength:.3}]); add('splash', {emitRate:300, emitTime:.05, emitSize:.5, emitConeAngle:.6, shape:'soft', particleTime:.7, sizeStart:.12, sizeEnd:.06, speed:.08, gravity:-.008, fadeRate:.2, randomness:.4, colorStartA:hsl(.58,.8,.7,.8), colorStartB:hsl(.55,.6,.85,.8), colorEndA:hsl(.58,.8,.7,.2), colorEndB:hsl(.55,.6,.85,.2)}); } /** * LittleJS glTF Plugin * - Loads glTF 2.0 models: a .gltf with its .bin and images beside it, or a .glb with everything in one file * - A model comes back as parts, one Mesh per primitive of every node placed by the node tree, each with its * material's color and base color texture, plus everything combined into one Mesh * - Geometry: positions, normals, uvs, vertex colors and indices, and skins, the four strongest joints of a vertex * of up to eight; morph targets are not read * - Animations play through the GLTFObject that createObject makes: parts that move, turn and scale, like doors, * wheels and propellers, and skinned characters, their meshes bent by their joints each frame; play takes a * blend time to cross-fade from one animation to the next * - Materials give a base color and texture and whether they blend; glass made with KHR_materials_transmission blends too, * and a KHR_materials_unlit material comes in emissive, its own color with no shading * - A material's normal map and emissive map load too, with its normal scale and emissive factor, read at the base * color texture's uvs; roughness, metalness and occlusion maps are not loaded * - The base color texture reads the uv set its texCoord names, moved by KHR_texture_transform as gltfpack and * Blender write it * - An OPAQUE material, the default, ignores its texture's alpha as the format says: a texture only such materials * use loads with its alpha set to 1, so the 3D pass cuts no holes in it; MASK always cuts at half, alphaCutoff * is not read * - Material and vertex colors are linear in glTF and are converted to sRGB at load, the space textures are in * - glTF and LittleJS agree on the axes, y up and -z forward, on counter clockwise triangles and on uvs running down * - Requires the Render3D plugin * @namespace GLTF * @example * const model = await loadGLTF('ship.glb'); // in an async gameInit * const ship = model.createObject(vec3(0, 1, 0)); // an object with a child per part, textures and all * ship.play('fly'); // and its animation, by name or number * ship.play('land', false, 1, .3); // then another, cross-faded over .3 seconds * new EngineObject3D(vec3(), model.mesh); // or the whole thing as one mesh, still */ /////////////////////////////////////////////////////////////////////////////// /** * GLTFPart - One primitive of a model, placed where its node put it * @memberof GLTF */ class GLTFPart { /** Make a part, as the loader does for each primitive * @param {string} name @param {Mesh} mesh @param {Color} color @param {TextureInfo|undefined} textureInfo @param {boolean} transparent */ constructor(name, mesh, color, textureInfo, transparent) { /** @property {string} - The node's name, or its mesh's */ this.name = name; /** @property {Mesh} - The geometry in model space, the node transforms applied, with the vertex colors the file had; * a node resting at scale 0 is applied at scale 1 there, so an animation can grow it from nothing */ this.mesh = mesh; /** @property {Color} - The material's base color, to draw the mesh tinted with */ this.color = color; /** @property {TextureInfo|undefined} - The material's base color texture, undefined without one or without WebGL * @type {TextureInfo|undefined} */ this.textureInfo = textureInfo; /** @property {boolean} - The material blends or is glass, so the part belongs in the transparent stage */ this.transparent = transparent; /** @property {boolean} - Its texture's sampler asks for nearest filtering, hard edged pixels, as pixel art * and voxel tools export; the object createObject makes draws it pixelated */ this.pixelated = false; /** @property {number} - The node it came from, which an animation moves it with */ this.node = 0; /** @property {boolean} - The material is unlit (KHR_materials_unlit), its own color with no shading; the object * createObject makes draws it with emissive 1 */ this.unlit = false; /** @property {TextureInfo|undefined} - The material's normal map, drawn with the base color texture's uvs * @type {TextureInfo|undefined} */ this.normalMap = undefined; /** @property {number} - The normal map's strength, its scale in the file */ this.normalScale = 1; /** @property {TextureInfo|undefined} - The material's emissive map, or a white texture when it has an * emissiveFactor and no texture, so it glows all over * @type {TextureInfo|undefined} */ this.emissiveMap = undefined; /** @property {Color} - The emissiveFactor, which multiplies the emissive map */ this.emissiveMapColor = WHITE; /** @property {number} - The material's roughnessFactor, 0 a mirror to 1 matte, 1 when it has none as the * format says; the object createObject makes takes it as its roughness, which sets its shininess */ this.roughness = 1; /** @property {GLTFSkin|undefined} - For a skinned mesh, what bends it: mesh is its resting pose, and the * object createObject makes bends a copy of its own to each pose * @type {GLTFSkin|undefined} */ this.skin = undefined; // which of the file's vertices each of the mesh's is, when flat normals split them, for a skin /** @type {Array|undefined} */ this.vertexSource = undefined; } } /** * What bends a skinned part: its joints, the nodes that move it, their inverse bind matrices, and for each vertex * of its mesh four joints and four weights, with the place and normal each pose bends from * @typedef {Object} GLTFSkin * @property {Array} joints - The joints, as node numbers * @property {Array} inverseBind - Each joint's inverse bind matrix * @property {Uint16Array} vertexJoints - Four joints a vertex, as places in joints * @property {Float32Array} vertexWeights - Four weights a vertex, summing to 1 * @property {Float32Array} bindPoints - Each vertex's place as stored, x y z * @property {Float32Array} bindNormals - Each vertex's normal as stored, x y z * @memberof GLTF */ /** * GLTFAnimation - One animation of a model: keys that move, turn and scale its nodes over time * - Play it through the GLTFObject that model.createObject makes * @memberof GLTF */ class GLTFAnimation { /** Make an animation from its channels, as the loader does * @param {string} name @param {Array} channels */ constructor(name, channels) { /** @property {string} - Its name in the file, or 'animation' and its number when it has none */ this.name = name; /** @property {Array} - What it moves: for each, a node, which of its translation, rotation or scale, * the key times and values, and how to go between keys, LINEAR, STEP or CUBICSPLINE * @type {Array} */ this.channels = channels; /** @property {number} - Length in seconds, the time of its last key * @type {number} */ this.duration = channels.reduce((d, c)=> max(d, c.times[c.times.length - 1] || 0), 0); } } /** * GLTFModel - A loaded model: its parts, and everything as one mesh * @memberof GLTF */ class GLTFModel { /** Make a model from its parts, as the loader does * @param {Array} parts @param {Array} [animations] @param {Object} [nodeTree] */ constructor(parts, animations=[], nodeTree) { /** @property {Array} - One per primitive of every node that has a mesh */ this.parts = parts; /** @property {Array} - The animations, play one through createObject's GLTFObject * @type {Array} */ this.animations = animations; /** @type {{nodes: Array, parents: Array, restInverse: Array, * restPose: Array}|undefined} */ this.nodeTree = nodeTree; // each node's parent and resting place, for animation this.modelMatrix = new Matrix4; // what center, fit and transform did to the parts, animation works through it /** @property {Mesh} - Every part combined, each tinted with its material color; the texture is textureInfo, * and blending and unlit stay with the parts, which createObject draws */ this.mesh = new Mesh; for (const part of parts) // a part baked at scale 1 from a node resting at 0 goes in as it rests this.mesh.combine(part.mesh, !part.skin && nodeTree?.restPose?.[part.node] || RENDER3D_IDENTITY, part.color); // the one texture every part uses, when they all do; a part without one has no uvs into it, so a model // that mixes plain and textured parts, or uses several textures, is drawn through createObject instead const textures = new Set(parts.map(p=> p.textureInfo)); /** @property {TextureInfo|undefined} - The texture to draw mesh with, when every part uses the same one * @type {TextureInfo|undefined} */ this.textureInfo = textures.size === 1 ? textures.values().next().value : undefined; /** @type {{min: Vector3, max: Vector3}|undefined} */ this.bounds = undefined; // the box around every part, kept once measured, see getBounds } /** The box around every part, measured once and again after transform, so change the model through that * @return {{min: Vector3, max: Vector3}} */ getBounds() { const bounds = this.bounds ||= this.mesh.getBounds(); return { min: bounds.min.copy(), max: bounds.max.copy() }; // a copy, so a change to it leaves the model's } /** Move every part so the center of the model's bounds is on the origin, like Mesh.center * @return {GLTFModel} */ center() { const bounds = this.getBounds(); return this.transform(bounds.min.add(bounds.max).scale(-.5)); } /** Scale every part evenly so the model's largest extent is a size, like Mesh.fit, for models of unknown units * @param {number} [size] * @return {GLTFModel} */ fit(size=1) { const bounds = this.getBounds(), extent = bounds.max.subtract(bounds.min); return this.transform(Matrix4.scaling(vec3(size / (max(extent.x, extent.y, extent.z) || 1)))); } /** Move, turn or scale every part and the combined mesh together * @param {Matrix4|Vector3} matrix - Transform, or just an offset to move by * @return {GLTFModel} */ transform(matrix) { matrix = render3DMatrix(matrix); for (const part of this.parts) part.mesh.transform(matrix); this.mesh.transform(matrix); this.bounds = undefined; // measured again when next asked for this.modelMatrix = matrix.copy().multiply(this.modelMatrix); return this; } /** Find an animation by name or number * @param {string|number|GLTFAnimation} animation * @return {GLTFAnimation|undefined} */ getAnimation(animation) { return animation instanceof GLTFAnimation ? animation : isNumber(animation) ? this.animations[animation] : this.animations.find(a=> a.name === animation); } /** How far each part has moved from its resting place at a time in an animation, one matrix per part * - createObject's GLTFObject calls this as it plays, a game only needs it to pose something by hand * @param {GLTFAnimation} animation * @param {number} time - Seconds into it * @return {Array} */ getPose(animation, time) { if (!this.nodeTree) return this.parts.map(()=> new Matrix4); return this.partPoses(gltfNodeWorlds(this.nodeTree, gltfPoseNodes(this.nodeTree, animation, time))); } // each part's move from where it rests, with each node's place in the model from worldOf, through what center // and fit did: modelMatrix * now * rest inverse * modelMatrix inverse; a skinned part is posed by its joints // and stays where it is partPoses(worldOf) { const tree = this.nodeTree, model = this.modelMatrix, modelInverse = model.copy().invert(); return this.parts.map(part=> part.skin ? new Matrix4 : model.copy().multiply(worldOf(part.node)).multiply(tree.restInverse[part.node]).multiply(modelInverse)); } /** Free the GPU buffers of every part's mesh and of the combined mesh, and the textures, for a model that is * done with, like a level's models when the next level loads * - Destroy the objects createObject made from it first; a mesh drawn again only uploads again, but a freed * texture is gone */ dispose() { for (const part of this.parts) part.mesh.dispose(); this.mesh.dispose(); for (const textureInfo of new Set(this.parts.flatMap(p=> [p.textureInfo, p.normalMap, p.emissiveMap]))) textureInfo !== gltfWhiteTextureInfo && textureInfo?.destroyWebGLTexture(); // the white one is shared } /** Make an object at a position with a child per part, so each keeps its own texture, color and blending, and * the model's animations can play on it; the way to show a model with windows or other see through parts, * which the combined mesh draws solid, or one that moves * @param {Vector3} [pos3D] * @return {GLTFObject} - The root, move and turn it and the parts follow */ createObject(pos3D=vec3()) { return new GLTFObject(this, pos3D); } } /////////////////////////////////////////////////////////////////////////////// /** * GLTFObject - A model as an object with a child per part, which plays the model's animations * - model.createObject makes one; move, turn and scale it like any EngineObject3D and the parts follow * - play starts an animation by name or number, and the object moves its parts each frame as it runs * - The parts' meshes stay where they rest, an animation moves the child objects that draw them * @extends EngineObject3D * @memberof GLTF * @example * const door = model.createObject(vec3(0, 0, 5)); * door.play('open', false); // once, holding the last pose */ class GLTFObject extends EngineObject3D { /** Make the object and its parts, model.createObject is the usual way * @param {GLTFModel} model * @param {Vector3} [pos3D] */ constructor(model, pos3D=vec3()) { super(pos3D); // the size of the whole model, as an object made from model.mesh would have, measured once for the model const bounds = model.mesh.points.length ? model.getBounds() : undefined; if (bounds) this.size3D = bounds.max.subtract(bounds.min); /** @property {GLTFModel} - The model it shows */ this.model = model; /** @property {GLTFAnimation|undefined} - The animation playing, or the last one, undefined for none * @type {GLTFAnimation|undefined} */ this.animation = undefined; /** @property {number} - Seconds into the animation */ this.animationTime = 0; /** @property {number} - How fast it plays, 1 is as made, negative plays it backward */ this.animationSpeed = 1; /** @property {boolean} - Start again at the end, or stop there and hold the last pose */ this.animationLoop = true; /** @property {boolean} - Whether it is moving through the animation now */ this.animationPlaying = false; // what a cross-fade comes from: an animation going on as it was, or a pose held, and how far it is /** @type {{animation?: GLTFAnimation, time?: number, speed?: number, loop?: boolean, nodes?: Map}|undefined} */ this.blendFrom = undefined; this.blendTime = 0; this.blendElapsed = 0; // each node's values and place in the model as last posed, for a fade from here and for getJointMatrix, and // whether that pose was a mix, as a fade stopped part way holds it /** @type {Map|undefined} */ this.poseNodes = undefined; this.poseMixed = false; /** @type {(function(number): Matrix4)|undefined} */ this.poseWorldOf = undefined; /** @property {Array} - The child that draws each of the model's parts, in the order of * model.parts, which an animation poses; one destroyed or taken off the object is left alone * @type {Array} */ this.parts = []; for (const part of model.parts) { // a skinned part bends a mesh of its own, so objects of one model each hold their own pose const o = new EngineObject3D(vec3(), part.skin ? gltfSkinMeshCopy(part.mesh) : part.mesh, part.textureInfo, part.color); o.transparent = part.transparent; o.pixelated = part.pixelated; o.emissive = part.unlit ? 1 : 0; o.normalMap = part.normalMap, o.normalScale = part.normalScale; o.emissiveMap = part.emissiveMap, o.emissiveMapColor = part.emissiveMapColor.copy(); // its own, as color is o.roughness = part.roughness; const rest = !part.skin && model.nodeTree?.restPose?.[part.node]; if (rest) { // baked at scale 1 from a node resting at 0, it starts as it rests, where a pose would put it const modelMatrix = model.modelMatrix, m = modelMatrix.copy().multiply(rest).multiply(modelMatrix.copy().invert()); o.localMatrix = m; // whole, as a pose is o.pos3D = m.getTranslation(); o.rotation3D = m.getRotation(); o.scale3D = m.getScale(); } render3DShareSettings(o, this); // what is set on the model, the part's own material otherwise this.addChild(o); this.parts.push(o); } } /** Destroy the object and its parts, and free the meshes its skinned parts bend, which are its own * @param {boolean} [immediate] */ destroy(immediate) { if (this.destroyed) return; this.parts.forEach((o, i)=> this.model.parts[i]?.skin && o.mesh?.dispose()); super.destroy(immediate); } /** Play an animation from its start, at once or cross-faded from the pose it is in * - A play with a blend of the animation already playing goes on with it, so state code may call it each * frame; without a blend it starts the animation again * @param {string|number|GLTFAnimation} [animation] - Its name, its number in model.animations, or itself * @param {boolean} [loop] - Start again at the end, or stop there * @param {number} [speed] - 1 is as made, negative plays it backward from its end * @param {number} [blend] - Seconds to cross-fade from the pose it is in, 0 to switch at once; the animation * it comes from goes on through the fade, and a fade started during another fades from the mix there */ play(animation=0, loop=true, speed=1, blend=0) { const found = this.model.getAnimation(animation); ASSERT(found, 'the model has no animation ' + animation, this.model.animations.map(a=> a.name)); if (!found) return; if (blend > 0 && found === this.animation && this.animationPlaying) { this.animationLoop = loop, this.animationSpeed = speed; return; } const fading = this.blendFrom; this.blendFrom = undefined; if (blend > 0) { // from the animation playing, going on as it was, or from the pose held, a fade's mix included const from = this.animation && !fading && !this.poseMixed ? {animation: this.animation, time: this.animationTime, speed: this.animationPlaying ? this.animationSpeed : 0, loop: this.animationLoop} : undefined; this.blendFrom = from || {nodes: this.poseNodes || new Map}; this.blendTime = blend; this.blendElapsed = 0; } this.animation = found; this.animationLoop = loop; this.animationSpeed = speed; this.animationPlaying = true; this.setAnimationTime(speed < 0 ? found.duration : 0); } /** Stop the animation where it is, the parts hold that pose; a cross-fade going on stops too, holding the mix */ stop() { this.animationPlaying = false; this.blendFrom = undefined; } /** Put the parts where the animation has them at a time, playing or not * @param {number} time - Seconds into the animation */ setAnimationTime(time) { this.animationTime = time; const model = this.model, tree = model.nodeTree; if (!this.animation || !tree) return; // each node's values now, mixed with what a fade comes from by how far it is, eased in and out let nodes = gltfPoseNodes(tree, this.animation, time); const from = this.blendFrom; if (from) { const fromNodes = from.nodes || gltfPoseNodes(tree, from.animation, from.time); nodes = gltfPoseBlend(tree, fromNodes, nodes, smoothStep(clamp(this.blendElapsed / this.blendTime))); } const worldOf = gltfNodeWorlds(tree, nodes); this.poseNodes = nodes, this.poseWorldOf = worldOf, this.poseMixed = !!from; // its own list of the part objects, so a child removed or added does not hand a part another's pose const pose = model.partPoses(worldOf), parts = this.parts; for (let i = 0; i < pose.length && i < parts.length; ++i) { const o = parts[i], m = pose[i], skin = model.parts[i].skin; if (o.destroyed || o.parent !== this) continue; if (skin) { gltfSkinApply(skin, worldOf, model.modelMatrix, o.mesh); // bent by its joints, where it is continue; } // drawn with the whole pose, which a parent's uneven scale can shear, the parts kept for what reads them o.localMatrix = m; o.pos3D = m.getTranslation(); o.rotation3D = m.getRotation(); o.scale3D = m.getScale(); } } /** Move through the animation, and a cross-fade into it, called automatically each frame */ update() { super.update(); const animation = this.animation, from = this.blendFrom; if (!animation || !this.animationPlaying && !from) return; let t = this.animationTime; if (this.animationPlaying) { t = gltfAnimationStep(animation, t, this.animationSpeed, this.animationLoop); if (!this.animationLoop && (t >= animation.duration || t <= 0)) this.animationPlaying = false; // the end, or the start when playing backward: hold the last pose there } if (from) { this.blendElapsed += timeDelta; if (from.animation) // what it fades from goes on as it was from.time = gltfAnimationStep(from.animation, from.time, from.speed, from.loop); } this.setAnimationTime(t); if (from && this.blendElapsed >= this.blendTime) this.blendFrom = undefined; // faded all the way, the pose just set is the animation's own } /** A node's matrix in the world as the model is posed now, by its name in the file, to hang something on a * joint, a sword on a hand or a hat on a head; undefined when the model has no node of that name * @param {string} name * @return {Matrix4|undefined} */ getJointMatrix(name) { const tree = this.model.nodeTree, index = tree?.nodes?.findIndex(node=> node.name === name) ?? -1; if (index < 0) return; const worldOf = this.poseWorldOf || gltfNodeWorlds(tree, new Map); return this.getMatrix().multiply(this.model.modelMatrix).multiply(worldOf(index)); } } /////////////////////////////////////////////////////////////////////////////// /** Load a glTF or GLB model, the .bin and images of a .gltf from beside it * - A texture only OPAQUE materials use loads with its alpha set to 1, so the 3D pass cuts no holes in it * @param {string} url * @return {Promise} * @memberof GLTF */ async function loadGLTF(url) { const response = await loadFetch(url, 'loadGLTF'); if (!response.ok) throw new Error(`loadGLTF: could not load ${url}, ${response.status} ${response.statusText}`); // the files beside it are beside where it came from, after any redirect; a blob or data url has nothing // beside it, and a model in one is whole, or parsed with parseGLTF and a base of its own const from = response.url || url; let base = ''; if (!/^(blob|data):/i.test(from)) base = response.url ? new URL('.', from).href : from.slice(0, from.lastIndexOf('/') + 1); const data = await response.arrayBuffer(); try { return await parseGLTF(data, base); } catch (e) { // which file, and a web page, as a dev server sends for a mistyped path, said as one const page = loadIsWebPage(String.fromCharCode(...new Uint8Array(data, 0, min(data.byteLength, 64)))); throw new Error('loadGLTF ' + url + (page ? ' is a web page, so the path may be wrong' : ': ' + e.message)); } } /** Parse a model from GLB bytes or glTF JSON, fetching the buffers and images it refers to * - A .gltf names its .bin and image files, which are fetched from baseUrl, or found among files: what a game * has in hand, like the files dropped on the page, by their paths in the drop; with files, baseUrl is the * .gltf's own folder among them ('' or 'models/house/'), its names are read from there, and a name found nowhere * there is taken by its file name alone only when one file of the drop has it; only an http, https or blob uri * is fetched then * - A file the model needs that is not found is named in the error, and an image that can not be read is named in * a warning and left out * @param {ArrayBuffer|ArrayBufferView|Object|string} data - GLB bytes, or the glTF JSON as bytes, text or an object * @param {string} [baseUrl] - Where the .bin and image files are, with its trailing slash; loadGLTF passes the file's folder * @param {Map} [files] - The files it refers to, by their paths, in place of fetching them * @return {Promise} * @example * // the files of a drop, a .gltf with its .bin and textures, by their names * const files = new Map([...dataTransfer.files].map((file)=> [file.name, file])); * const gltf = [...files.values()].find((file)=> file.name.endsWith('.gltf')); * const model = await parseGLTF(await gltf.text(), '', files); * @memberof GLTF */ async function parseGLTF(data, baseUrl='', files) { // bytes as a typed array, a Node Buffer or a view into a bigger buffer, read as an ArrayBuffer of just them if (ArrayBuffer.isView(data)) data = data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength); let json = /** @type {any} */ (data), glbBuffer; // bytes, text or the parsed glTF, an object once parsed if (data instanceof ArrayBuffer) { const view = new DataView(data); if (data.byteLength >= 12 && view.getUint32(0, true) === 0x46546C67) // 'glTF', the binary container { if (view.getUint32(4, true) !== 2) throw new Error('only GLB version 2 is read'); for (let offset = 12; offset + 8 <= data.byteLength;) { const length = view.getUint32(offset, true), type = view.getUint32(offset + 4, true); const chunk = data.slice(offset + 8, offset + 8 + length); if (type === 0x4E4F534A) json = new TextDecoder().decode(chunk); // JSON else if (type === 0x004E4942) glbBuffer = chunk; // BIN offset += 8 + length; } } else json = new TextDecoder().decode(data); } if (typeof json == 'string') json = JSON.parse(json); if (!json.asset || !String(json.asset.version).startsWith('2')) throw new Error('only glTF 2.0 is read'); // a file problem, not a code one, so it throws in every build // compressed geometry has no plain accessors to read; the other extensions add to what is here, and can be left out for (const name of json.extensionsRequired || []) if (name === 'KHR_draco_mesh_compression' || name === 'EXT_meshopt_compression') throw new Error(`glTF with ${name} is not read, export it uncompressed`); else if (name === 'KHR_texture_basisu') throw new Error('glTF with KHR_texture_basisu textures is not read, export them as png or jpeg'); // one that only may use them has other images to fall back on, or none: said, since it loads with them left out if (json.extensionsUsed?.includes('KHR_texture_basisu') && !json.extensionsRequired?.includes('KHR_texture_basisu')) console.warn('glTF: KHR_texture_basisu textures are not read and are left out, export them as png or jpeg'); // the buffers: the GLB's own, a data uri, or a file beside the model const buffers = await Promise.all((json.buffers || []).map((buffer, i)=> { if (!buffer.uri) { ASSERT(glbBuffer && !i, 'a buffer without a uri is the GLB chunk, and only the first can be'); return glbBuffer; } return gltfFetch(buffer.uri, baseUrl, files).then(r=> r.arrayBuffer()); })); // the textures, decoded together first; none without WebGL, and a failed image only logs; the base color, normal // and emissive textures are drawn with, the roughness and occlusion maps are not loaded at all const usedTextures = new Set((json.materials || []).flatMap(m=> [m.pbrMetallicRoughness?.baseColorTexture?.index, m.normalTexture?.index, m.emissiveTexture?.index])); const normalTextures = new Set((json.materials || []).map(m=> m.normalTexture?.index)); const opaqueTextures = gltfOpaqueTextures(json); const textures = await Promise.all((json.textures || []).map(async (texture, index)=> { if (!glContext || typeof createImageBitmap == 'undefined' || !usedTextures.has(index)) return; try { // a WebP or AVIF image is named by its extension, the browser decodes those like any other const source = texture.source ?? texture.extensions?.EXT_texture_webp?.source ?? texture.extensions?.EXT_texture_avif?.source; const image = json.images[source], sampler = json.samplers?.[texture.sampler] || {}; let blob; if (image.uri) blob = await gltfFetch(image.uri, baseUrl, files).then(r=> r.blob()); else { const view = json.bufferViews[image.bufferView]; blob = new Blob([new Uint8Array(buffers[view.buffer], view.byteOffset || 0, view.byteLength)], {type: image.mimeType}); } // an OPAQUE material ignores its texture's alpha, which the 3D pass would cut holes by, so a texture only // those use has it set to 1; decoded as stored for that, and a jpeg has no alpha to set const opaque = opaqueTextures.has(index) && blob.type !== 'image/jpeg'; // a normal map is directions, not colors, so it is decoded as stored; one a material also uses as a // color, an odd file, is decoded that way too const bitmap = normalTextures.has(index) ? await createImageBitmap(blob, {colorSpaceConversion: 'none', premultiplyAlpha: 'none'}) : opaque ? await createImageBitmap(blob, {premultiplyAlpha: 'none'}).then(gltfOpaqueImage) : await createImageBitmap(blob); // REPEAT by default, and hard edged only when its sampler says NEAREST, not as the game's tiles are return new TextureInfo(bitmap, true, [sampler.wrapS ?? 10497, sampler.wrapT ?? 10497], sampler.magFilter === 9728); } catch (e) { console.warn('glTF image not loaded, left out: ' + (e?.message || e)); } })); // the parts: the scene's nodes walked with their transforms, every primitive of a node's mesh placed by it; // each node's parent and resting place are kept, so an animation can move a part from where it rests const parts = [], parents = [], restInverse = [], restPose = [], visited = new Set; // every node's parent from the children lists, so a joint outside the scene's nodes keeps its own; the walk // below sets them again for the nodes it reaches (json.nodes || []).forEach((node, i)=> (node.children || []).forEach((child)=> parents[child] ??= i)); const visit = (index, parentMatrix, parentIndex, parentRest)=> { // nodes are trees, so one reached again is a cycle or a node with two parents, a file problem if (visited.has(index)) throw new Error('glTF node ' + index + ' is reached twice, nodes must form trees'); visited.add(index); const node = json.nodes[index], local = gltfNodeMatrix(node); let matrix = parentMatrix ? parentMatrix.copy().multiply(local) : local; let rest = parentRest && parentRest.copy().multiply(local); // where it rests, when that is not where it is baked if (!node.matrix && node.scale?.includes(0)) { // resting at scale 0, like a pop in exported at its first frame, would bake its parts onto a point and // leave no inverse to pose them from; it and its children are baked at scale 1 on that axis instead, // and a pose takes them back to 0, or up from there as an animation says rest ||= matrix; const unscaled = gltfNodeMatrix({...node, scale: node.scale.map(s=> s || 1)}); matrix = parentMatrix ? parentMatrix.copy().multiply(unscaled) : unscaled; } parents[index] = parentIndex; restInverse[index] = matrix.copy().invert(); if (rest) restPose[index] = rest.copy().multiply(restInverse[index]); // from where it is baked to where it rests if (node.mesh !== undefined) { const mesh = json.meshes[node.mesh]; // a skinned mesh is placed by its joints and not by its node, as the format says, so it is read as stored const skin = node.skin !== undefined ? json.skins?.[node.skin] : undefined; for (const primitive of mesh.primitives) { const skinned = skin?.joints?.length && primitive.attributes.JOINTS_0 !== undefined && primitive.attributes.WEIGHTS_0 !== undefined; const part = gltfPart(json, buffers, textures, primitive, skinned ? new Matrix4 : matrix, node.name || mesh.name || 'part ' + parts.length); if (!part) continue; part.node = index; if (skinned) part.skin = gltfSkin(json, buffers, skin, primitive, part); parts.push(part); } } for (const child of node.children || []) visit(child, matrix, index, rest); }; let animations; try { const scene = json.scenes?.[json.scene ?? 0]; if (scene) (scene.nodes || []).forEach(i=> visit(i)); // a scene may be empty else if (json.nodes) { // no scene: every node that is not another's child is a root const children = new Set(json.nodes.flatMap(n=> n.children || [])); json.nodes.forEach((n, i)=> children.has(i) || visit(i)); } // the animations: each channel that moves a node's translation, rotation or scale, with its keys; morph // weights are not read, and a node given as a matrix cannot be animated, the format says animations = (json.animations || []).map((animation, i)=> new GLTFAnimation(animation.name || 'animation ' + i, animation.channels.filter(c=> c.target.node !== undefined && ['translation', 'rotation', 'scale'].includes(c.target.path) && !json.nodes[c.target.node].matrix).map(c=> { const sampler = animation.samplers[c.sampler]; return {node: c.target.node, path: c.target.path, interpolation: sampler.interpolation || 'LINEAR', times: gltfAccessor(json, buffers, sampler.input).data, values: gltfAccessor(json, buffers, sampler.output).data, components: c.target.path === 'rotation' ? 4 : 3}; }))); } catch (error) { // a file that fails partway leaves no model to dispose, so the textures it made go now for (const texture of textures) texture?.destroyWebGLTexture(); throw error; } // a texture none of the parts draws with, another scene's, goes now too, since dispose finds them by the parts const used = new Set(parts.flatMap(p=> [p.textureInfo, p.normalMap, p.emissiveMap])); for (const texture of textures) texture && !used.has(texture) && texture.destroyWebGLTexture(); // a skinned part rests as its joints place it, with nothing moved const nodeTree = {nodes: json.nodes, parents, restInverse, restPose}, restWorlds = gltfNodeWorlds(nodeTree, new Map); for (const part of parts) part.skin && gltfSkinApply(part.skin, restWorlds, RENDER3D_IDENTITY, part.mesh); return new GLTFModel(parts, animations, nodeTree); } // the values of each node an animation moves at a time, a Map of node to its translation, rotation and scale; // every other node keeps its own function gltfPoseNodes(tree, animation, time) { const moved = new Map; for (const channel of animation.channels) { let node = moved.get(channel.node); node || moved.set(channel.node, node = gltfNodeTRS(tree.nodes[channel.node])); gltfSample(channel, time, node[channel.path]); } return moved; } // two such sets of node values mixed by a weight, 0 all a, 1 all b: places and scales in a line, turns the short // way round; a node only one moves has its own values in the other function gltfPoseBlend(tree, a, b, weight) { const mixed = new Map, lerp3 = (p, q)=> p.map((v, i)=> v + (q[i] - v) * weight); for (const node of new Set([...a.keys(), ...b.keys()])) { const p = a.get(node) || gltfNodeTRS(tree.nodes[node]), q = b.get(node) || gltfNodeTRS(tree.nodes[node]); mixed.set(node, {translation: lerp3(p.translation, q.translation), scale: lerp3(p.scale, q.scale), rotation: gltfSlerp(p.rotation, q.rotation, weight)}); } return mixed; } // the turn between two quaternions, x y z w, a part of the way along the short way round function gltfSlerp(a, b, t) { let dot = a[0]*b[0] + a[1]*b[1] + a[2]*b[2] + a[3]*b[3], sign = 1; if (dot < 0) dot = -dot, sign = -1; // the same turn the other way round is shorter let wa = 1 - t, wb = t * sign; if (dot < .9995) { // along the arc; nearly the same turn is done in a line, where the arc's sine goes to nothing const angle = Math.acos(dot), s = sin(angle); wa = sin(wa * angle) / s, wb = sin(t * angle) / s * sign; } const out = a.map((v, i)=> v * wa + b[i] * wb), l = hypot(...out) || 1; return out.map(v=> v / l); } // each node's place in the model through its parents, with the values in moved for the nodes there, kept as found function gltfNodeWorlds(tree, moved) { const world = []; const worldOf = (i)=> { if (world[i]) return world[i]; const local = gltfNodeMatrix(moved.get(i) || tree.nodes[i]), parent = tree.parents[i]; return world[i] = parent === undefined ? local : worldOf(parent).copy().multiply(local); }; return worldOf; } // an animation's time a frame on, at a speed, around again when it loops, held at its ends when it does not function gltfAnimationStep(animation, time, speed, loop) { const duration = animation.duration, t = time + timeDelta * speed; return loop ? duration ? mod(t, duration) : 0 : clamp(t, 0, duration); } // a skinned primitive's skin: its joints, the nodes that bend it, their inverse bind matrices, and for each vertex // of the part's mesh four joints, as places in that list, and four weights made to sum to 1, with its place and // normal as stored, which each pose bends from; a mesh split for flat normals maps its corners to the file's function gltfSkin(json, buffers, skin, primitive, part) { const joints = skin.joints, mesh = part.mesh, count = mesh.points.length; const bind = skin.inverseBindMatrices !== undefined ? gltfAccessor(json, buffers, skin.inverseBindMatrices).data : undefined; const inverseBind = joints.map((_, j)=> bind ? new Matrix4(bind.subarray(j * 16, j * 16 + 16)) : new Matrix4); // a second set of four, as rigs from many tools have, is read too, and each vertex keeps its four strongest const attributes = primitive.attributes, read = (name)=> gltfAccessor(json, buffers, attributes[name]).data; const sets = [[read('JOINTS_0'), read('WEIGHTS_0')]]; attributes.JOINTS_1 !== undefined && attributes.WEIGHTS_1 !== undefined && sets.push([read('JOINTS_1'), read('WEIGHTS_1')]); const vertexJoints = new Uint16Array(count * 4), vertexWeights = new Float32Array(count * 4); const bindPoints = new Float32Array(count * 3), bindNormals = new Float32Array(count * 3); for (let v = 0; v < count; ++v) { const from = part.vertexSource ? part.vertexSource[v] : v, influences = []; for (const [fileJoints, fileWeights] of sets) for (let k = 0; k < 4; ++k) influences.push([fileJoints[from * 4 + k], fileWeights[from * 4 + k]]); sets.length > 1 && influences.sort((a, b)=> b[1] - a[1]); // the strongest four first let total = 0; for (let k = 0; k < 4; ++k) total += influences[k][1]; for (let k = 0; k < 4; ++k) { const [joint, weight] = influences[k]; vertexJoints[v * 4 + k] = joint < joints.length ? joint : 0; vertexWeights[v * 4 + k] = total ? weight / total : k ? 0 : 1; } const p = mesh.points[v], n = mesh.normals[v]; bindPoints.set([p.x, p.y, p.z], v * 3); bindNormals.set([n.x, n.y, n.z], v * 3); // vectors of its own, as each pose writes into them, where a flat normal is shared by a face's corners mesh.points[v] = p.copy(), mesh.normals[v] = n.copy(); } return {joints, inverseBind, vertexJoints, vertexWeights, bindPoints, bindNormals}; } // a skinned part's mesh copied with vectors of its own for an object to bend, its upload rewriting only the values function gltfSkinMeshCopy(mesh) { const copy = new Mesh; copy.points = mesh.points.map(p=> p.copy()); copy.normals = mesh.normals.map(n=> n.copy()); copy.uvs = mesh.uvs.slice(); copy.colors = mesh.colors.slice(); copy.indices = mesh.indices && mesh.indices.slice(); copy.doubleSided = mesh.doubleSided; copy.dynamicDraw = true; return copy; } // bend a skinned mesh to a pose: each vertex its stored place moved by its four joints, weighted, and its normal by // their normal matrices, each joint's matrix modelMatrix * its place now * its inverse bind; written into the // mesh's own vectors, so a pose makes nothing for each vertex, and once an upload has laid the mesh out for the GPU // into that data too, with the radius and box the upload would measure, so it sends the data as it is function gltfSkinApply(skin, worldOf, modelMatrix, mesh) { // each joint's point matrix, 12 numbers, then its normal matrix, 9, in one array the skin keeps const jointCount = skin.joints.length, M = skin.jointMatrices ||= new Float32Array(jointCount * 21); const m = gltfSkinScratch[0], n = gltfSkinScratch[1]; for (let j = 0; j < jointCount; ++j) { m.m.set(modelMatrix.m); m.multiply(worldOf(skin.joints[j])).multiply(skin.inverseBind[j]); n.m.set(m.m); n.invert().transpose(); const a = m.m, b = n.m, o = j * 21; M[o] = a[0], M[o+1] = a[1], M[o+2] = a[2], M[o+3] = a[4], M[o+4] = a[5], M[o+5] = a[6]; M[o+6] = a[8], M[o+7] = a[9], M[o+8] = a[10], M[o+9] = a[12], M[o+10] = a[13], M[o+11] = a[14]; M[o+12] = b[0], M[o+13] = b[1], M[o+14] = b[2], M[o+15] = b[4], M[o+16] = b[5], M[o+17] = b[6]; M[o+18] = b[8], M[o+19] = b[9], M[o+20] = b[10]; } const {vertexJoints, vertexWeights, bindPoints, bindNormals} = skin, points = mesh.points, normals = mesh.normals; // an indexed mesh's GPU vertex j is its point j, as upload lays it out const layout = mesh.vertexLayout, count = points.length; const floats = layout && mesh.indices && layout.pointCount === count ? new Float32Array(layout.data) : undefined; let r = 0, x0 = Infinity, y0 = Infinity, z0 = Infinity, x1 = -Infinity, y1 = -Infinity, z1 = -Infinity; for (let v = 0; v < count; ++v) { const v3 = v*3, v4 = v*4, px = bindPoints[v3], py = bindPoints[v3+1], pz = bindPoints[v3+2]; const qx = bindNormals[v3], qy = bindNormals[v3+1], qz = bindNormals[v3+2]; let x = 0, y = 0, z = 0, nx = 0, ny = 0, nz = 0; for (let k = 0; k < 4; ++k) { const w = vertexWeights[v4+k]; if (!w) continue; const o = vertexJoints[v4+k] * 21; x += w * (M[o]*px + M[o+3]*py + M[o+6]*pz + M[o+9]); y += w * (M[o+1]*px + M[o+4]*py + M[o+7]*pz + M[o+10]); z += w * (M[o+2]*px + M[o+5]*py + M[o+8]*pz + M[o+11]); nx += w * (M[o+12]*qx + M[o+15]*qy + M[o+18]*qz); ny += w * (M[o+13]*qx + M[o+16]*qy + M[o+19]*qz); nz += w * (M[o+14]*qx + M[o+17]*qy + M[o+20]*qz); } const p = points[v], normal = normals[v], l = (nx*nx + ny*ny + nz*nz) ** .5 || 1; p.x = x, p.y = y, p.z = z; normal.x = nx / l, normal.y = ny / l, normal.z = nz / l; if (!floats) continue; const at = v * RENDER3D_VERTEX_FLOATS; floats[at] = x, floats[at+1] = y, floats[at+2] = z; floats[at+3] = normal.x, floats[at+4] = normal.y, floats[at+5] = normal.z; r = max(r, x*x + y*y + z*z); x0 = min(x0, x), y0 = min(y0, y), z0 = min(z0, z); x1 = max(x1, x), y1 = max(y1, y), z1 = max(z1, z); } if (floats) { mesh.radius = r ** .5; mesh.bounds = count ? {min: vec3(x0, y0, z0), max: vec3(x1, y1, z1)} : {min: vec3(), max: vec3()}; mesh.vertexDataPacked = true; } mesh.dirty = true; } const gltfSkinScratch = [new Matrix4, new Matrix4]; // a joint's matrix and its normal matrix, as it is worked out // a node's translation, rotation and scale as arrays to animate, copies so the file's stay as they rest function gltfNodeTRS(node) { return {translation: [...(node.translation || [0, 0, 0])], rotation: [...(node.rotation || [0, 0, 0, 1])], scale: [...(node.scale || [1, 1, 1])]}; } // the base color textures that only OPAQUE materials use, the default mode, which the format says ignores alpha; // one a MASK or BLEND material also uses keeps its alpha for that function gltfOpaqueTextures(json) { const opaque = new Set, cut = new Set; for (const material of json.materials || []) { const index = material.pbrMetallicRoughness?.baseColorTexture?.index; if (index !== undefined) (!material.alphaMode || material.alphaMode === 'OPAQUE' ? opaque : cut).add(index); } for (const index of cut) opaque.delete(index); return opaque; } // an image decoded without premultiplied alpha, given alpha 1 all over and its colors as they are; read back through // a framebuffer, since a 2D canvas would multiply the colors by the alpha and lose those under a clear texel function gltfOpaqueImage(image) { const gl = glContext, {width, height} = image; if (gl.isContextLost()) return image; // nothing to read back through, it keeps its alpha // the room to read into first: for a huge image it can fail, and must not with the framebuffer bound const data = new Uint8ClampedArray(width * height * 4); const texture = gl.createTexture(), framebuffer = gl.createFramebuffer(), bound = gl.getParameter(gl.FRAMEBUFFER_BINDING); try { gl.bindTexture(gl.TEXTURE_2D, texture); gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image); gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer); gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, texture, 0); gl.readPixels(0, 0, width, height, gl.RGBA, gl.UNSIGNED_BYTE, data); // row 0 is the image's first row, as uploaded } finally { // the 2D renderer's own bindings back, whatever happened gl.bindFramebuffer(gl.FRAMEBUFFER, bound); gl.bindTexture(gl.TEXTURE_2D, glActiveTexture); gl.deleteFramebuffer(framebuffer); gl.deleteTexture(texture); } for (let i = 3; i < data.length; i += 4) data[i] = 255; image.close(); return createImageBitmap(new ImageData(data, width, height)); } // write a channel's value at a time into out: its keys held before the first and after the last, stepped, straight // between, or on the curve their tangents make; a rotation goes the short way round and stays a unit quaternion function gltfSample(channel, time, out) { const {times, values, components: n, interpolation} = channel, last = times.length - 1; const cubic = interpolation === 'CUBICSPLINE', stride = cubic ? 3 * n : n, at = cubic ? n : 0; // a cubic key is in, value, out // the last key at or before the time, found by halving, since a baked animation has thousands of keys let k = 0, high = last; while (k < high) { const mid = k + high + 1 >> 1; times[mid] <= time ? k = mid : high = mid - 1; } if (k >= last || time <= times[0] || interpolation === 'STEP') { for (let j = 0; j < n; ++j) out[j] = values[k * stride + at + j]; return; } const dt = times[k + 1] - times[k], t = (time - times[k]) / dt, a = k * stride, b = a + stride; if (cubic) { // hermite between the two values, with the out tangent of the first and the in tangent of the second const t2 = t * t, t3 = t2 * t; const h00 = 2*t3 - 3*t2 + 1, h10 = t3 - 2*t2 + t, h01 = -2*t3 + 3*t2, h11 = t3 - t2; for (let j = 0; j < n; ++j) out[j] = h00 * values[a + n + j] + h10 * dt * values[a + 2*n + j] + h01 * values[b + n + j] + h11 * dt * values[b + j]; } else if (n === 4) { // a rotation turns along the arc between the two, the shorter way round let dot = 0; for (let j = 0; j < 4; ++j) dot += values[a + j] * values[b + j]; const flip = dot < 0 ? -1 : 1; dot *= flip; let wa = 1 - t, wb = t * flip; if (dot < .9995) // close together the straight line is as good and does not divide by nothing { const angle = Math.acos(dot), s = sin(angle); wa = sin((1 - t) * angle) / s, wb = sin(t * angle) / s * flip; } for (let j = 0; j < 4; ++j) out[j] = wa * values[a + j] + wb * values[b + j]; } else for (let j = 0; j < n; ++j) out[j] = values[a + j] + (values[b + j] - values[a + j]) * t; if (n === 4) { const l = hypot(out[0], out[1], out[2], out[3]) || 1; for (let j = 0; j < 4; ++j) out[j] /= l; } } // fetch a uri beside the model, or decode a data uri without going out function gltfFetch(uri, baseUrl, files) { // among the files given, by its path from the model's folder, or by its name when only one file has it; a uri // of the web is still fetched, and no other kind is if (files && !/^(data|https?|blob):/i.test(uri)) { let name = uri.replace(/[?#].*$/, ''); try { name = decodeURIComponent(name); } catch {} // a path with its . and .. steps taken, the same for the keys, which may start with ./ or / or use \ const clean = (text)=> { const parts = []; for (const part of text.replace(/\\/g, '/').split('/')) if (part === '..' ? !parts.pop() : part && part !== '.' && !parts.push(part)) return; // above the top of the files return parts.join('/'); }; const path = clean(baseUrl + name); if (path === undefined) return Promise.reject(new Error('glTF needs ' + name + ', which is above the files given')); const lower = path.toLowerCase(), file = lower.slice(lower.lastIndexOf('/') + 1); const byPath = new Map([...files].map(([key, value])=> [clean(key)?.toLowerCase(), value])); const found = files.get(path) ?? byPath.get(lower), keys = [...byPath.keys()]; if (found) return Promise.resolve(new Response(found)); // what is in a folder dropped, not the folder, leaves the folder's own name out of the paths: the longest key // the path ends with, a folder and more, as a name alone is only taken when one file has it const end = keys.filter((k)=> k?.includes('/') && lower.endsWith('/' + k)).sort((a, b)=> b.length - a.length)[0]; if (end) return Promise.resolve(new Response(byPath.get(end))); const named = new Set(keys.filter((k)=> k?.slice(k.lastIndexOf('/') + 1) === file).map((k)=> byPath.get(k))); if (named.size === 1) return Promise.resolve(new Response([...named][0])); return Promise.reject(new Error('glTF needs ' + path + (named.size ? ', and more than one file has its name' : ', which is not among the files given'))); } if (uri.startsWith('data:')) { const comma = uri.indexOf(','), bytes = atob(uri.slice(comma + 1)), data = new Uint8Array(bytes.length); for (let i = 0; i < bytes.length; ++i) data[i] = bytes.charCodeAt(i); return Promise.resolve(new Response(data.buffer, {headers: {'Content-Type': uri.slice(5, uri.indexOf(';'))}})); } // resolved as a link in the model is, from the model's folder: a leading slash from the site root, a leading // // from the page's scheme, ../ up a folder; with no page to start from, a relative folder is only prefixed const page = typeof location !== 'undefined' && location.href; const base = page ? new URL(baseUrl, page) : /^[a-z][a-z0-9+.-]*:/i.test(baseUrl) ? baseUrl : undefined; const url = base ? new URL(uri, base).href : baseUrl + uri; return loadFetch(url, 'glTF').then(r=> { if (!r.ok) throw new Error('glTF needs ' + uri + ', not found at ' + url); return r; }); } // a node's local transform: its matrix, or translation, rotation quaternion and scale composed function gltfNodeMatrix(node) { if (node.matrix) return new Matrix4(node.matrix); // column major, like ours const [x, y, z, w] = node.rotation || [0, 0, 0, 1], [sx, sy, sz] = node.scale || [1, 1, 1], t = node.translation || [0, 0, 0]; return new Matrix4([ (1 - 2*(y*y + z*z)) * sx, 2*(x*y + z*w) * sx, 2*(x*z - y*w) * sx, 0, 2*(x*y - z*w) * sy, (1 - 2*(x*x + z*z)) * sy, 2*(y*z + x*w) * sy, 0, 2*(x*z + y*w) * sz, 2*(y*z - x*w) * sz, (1 - 2*(x*x + y*y)) * sz, 0, t[0], t[1], t[2], 1]); } // an accessor's values as floats, one row per element, normalized integer types brought to 0 to 1; a sparse one // starts from its bufferView, or from zeros without one, and then has the listed elements replaced function gltfAccessor(json, buffers, index) { const a = json.accessors[index], view = json.bufferViews?.[a.bufferView]; const components = {SCALAR: 1, VEC2: 2, VEC3: 3, VEC4: 4, MAT4: 16}[a.type]; const types = {5120: Int8Array, 5121: Uint8Array, 5122: Int16Array, 5123: Uint16Array, 5125: Uint32Array, 5126: Float32Array}; const Type = types[a.componentType]; if (!components || !Type) // a file problem, so it throws in every build throw new Error(`glTF accessor of ${a.type} ${a.componentType} is not read`); const size = Type.BYTES_PER_ELEMENT; const scales = /** @type {Array<[Object, number]>} */ ([[Int8Array, 127], [Uint8Array, 255], [Int16Array, 32767], [Uint16Array, 65535]]); const scale = a.normalized ? new Map(scales).get(Type) || 1 : 1; // with a buffer under it the count is bounded by the buffer, without one it is all zeros and a sparse few, // which no real model makes millions of if (!view && !(a.count <= 1 << 20)) throw new Error('glTF accessor ' + index + ' has no buffer and a count of ' + a.count); // its elements must lie in its buffer before so many floats are made for them const viewEnd = view && (buffers[view.buffer]?.byteLength ?? 0), stride = view?.byteStride || components * size; if (view && !((view.byteOffset || 0) + (a.byteOffset || 0) + (a.count - 1) * stride + components * size <= viewEnd)) throw new Error('glTF accessor ' + index + ' reaches past its buffer'); const out = new Float32Array(a.count * components); if (view) { const buffer = buffers[view.buffer], offset = (view.byteOffset || 0) + (a.byteOffset || 0); // the format keeps values on their size, a typed array can not read them off it if (offset % size || stride % size) throw new Error('glTF accessor ' + index + ' is not aligned to its ' + size + ' byte values'); if (stride === components * size) out.set(new Type(buffer, offset, a.count * components)); // packed, one view over all of it else for (let i = 0; i < a.count; ++i) // interleaved with other attributes, an element at each stride out.set(new Type(buffer, offset + i * stride, components), i * components); } const sparse = a.sparse; if (sparse) { // which elements change, and their new values packed in the accessor's own type const {indices, values} = sparse, IndexType = types[indices.componentType]; const indexView = json.bufferViews?.[indices.bufferView], valueView = json.bufferViews?.[values.bufferView]; if (!IndexType || !indexView || !valueView) throw new Error('glTF sparse accessor is missing its indices or values'); // its indices and values in their buffers and on their sizes, before typed arrays are made over them const indexAt = (indexView.byteOffset || 0) + (indices.byteOffset || 0); const valueAt = (valueView.byteOffset || 0) + (values.byteOffset || 0); if (indexAt % IndexType.BYTES_PER_ELEMENT || valueAt % size || !(indexAt + sparse.count * IndexType.BYTES_PER_ELEMENT <= (buffers[indexView.buffer]?.byteLength ?? 0)) || !(valueAt + sparse.count * components * size <= (buffers[valueView.buffer]?.byteLength ?? 0))) throw new Error('glTF sparse accessor ' + index + ' is not aligned or reaches past its buffer'); const at = new IndexType(buffers[indexView.buffer], (indexView.byteOffset || 0) + (indices.byteOffset || 0), sparse.count); const data = new Type(buffers[valueView.buffer], (valueView.byteOffset || 0) + (values.byteOffset || 0), sparse.count * components); for (let i = 0; i < sparse.count; ++i) out.set(data.subarray(i * components, (i + 1) * components), at[i] * components); } if (scale !== 1) for (let i = 0; i < out.length; ++i) out[i] = max(out[i] / scale, -1); // a signed type's lowest value is -1 too, as the format says return {data: out, components, count: a.count}; } // one primitive as a part: its attributes into a mesh, the indices as triangles, then placed and given its material function gltfPart(json, buffers, textures, primitive, matrix, name) { const mode = primitive.mode ?? 4; // triangles if (mode < 4) { LOG('glTF points and lines are not drawn:', name); return; } const attributes = primitive.attributes; ASSERT(attributes.POSITION !== undefined, 'a glTF primitive needs positions'); const read = (index, make)=> { const {data, components, count} = gltfAccessor(json, buffers, index), list = []; for (let i = 0; i < count; ++i) list.push(make(data, i * components, components)); return list; }; const points = read(attributes.POSITION, (d, k)=> vec3(d[k], d[k+1], d[k+2])); const normals = attributes.NORMAL !== undefined ? read(attributes.NORMAL, (d, k)=> vec3(d[k], d[k+1], d[k+2])) : undefined; // the uv set the base color texture names, moved by its KHR_texture_transform once here, offset + rotation * scale, // the rotation turning (u, v) to (cos u + sin v, cos v - sin u), counter-clockwise in glTF's uv space with v down, // as the extension's text says and as Khronos's sample renderer and three.js turn it, which models are made to // look right in; the GLSL sample in the extension's readme, read column major, turns the other way const material = json.materials?.[primitive.material] || {}, pbr = material.pbrMetallicRoughness || {}; const textureRef = pbr.baseColorTexture, uvTransform = textureRef?.extensions?.KHR_texture_transform; const uvAccessor = attributes['TEXCOORD_' + (uvTransform?.texCoord ?? textureRef?.texCoord ?? 0)]; const [ox, oy] = uvTransform?.offset || [0, 0], [sx, sy] = uvTransform?.scale || [1, 1], r = uvTransform?.rotation || 0; const c = cos(r), s = sin(r); const uvs = uvAccessor !== undefined ? read(uvAccessor, (d, k)=> vec2(c*sx*d[k] + s*sy*d[k+1] + ox, c*sy*d[k+1] - s*sx*d[k] + oy)) : undefined; const colors = attributes.COLOR_0 !== undefined ? read(attributes.COLOR_0, (d, k, n)=> rgb(gltfSRGB(d[k]), gltfSRGB(d[k+1]), gltfSRGB(d[k+2]), n > 3 ? d[k+3] : 1)) : undefined; let indices = primitive.indices !== undefined ? Array.from(gltfAccessor(json, buffers, primitive.indices).data) : points.map((_, i)=> i); if (mode === 5) // a strip: triangle i is the three entries up to i, the odd ones read the other way indices = indices.flatMap((_, i, s)=> i < 2 ? [] : i & 1 ? [s[i-1], s[i-2], s[i]] : [s[i-2], s[i-1], s[i]]); else if (mode === 6) // a fan around the first entry indices = indices.flatMap((_, i, s)=> i < 2 ? [] : [s[0], s[i-1], s[i]]); const mesh = new Mesh().addTriangles(points, indices, normals, uvs, colors); // flat when the file gives none, as the format says, which gives each corner a vertex of its own: which of the // file's each one is, kept for a skin const vertexSource = normals ? undefined : mesh.indices.slice(); normals || mesh.computeNormals(false); mesh.transform(matrix); const factor = pbr.baseColorFactor || [1, 1, 1, 1]; mesh.doubleSided = !!material.doubleSided; // glass is usually made with transmission, an opaque white material the light passes through, which would // draw solid white; it comes in blended instead, a faint tint of its color that lets the rest show through // only a blending material reads its alpha, an opaque or masked one is solid whatever the factor says const transmission = material.extensions?.KHR_materials_transmission?.transmissionFactor || 0; const blend = material.alphaMode === 'BLEND', alpha = (blend ? factor[3] : 1) * (1 - .8 * transmission); const texture = textureRef && json.textures?.[textureRef.index]; const part = new GLTFPart(name, mesh, rgb(gltfSRGB(factor[0]), gltfSRGB(factor[1]), gltfSRGB(factor[2]), alpha), textureRef ? textures[textureRef.index] : undefined, blend || transmission > 0); part.pixelated = json.samplers?.[texture?.sampler]?.magFilter === 9728; // NEAREST part.vertexSource = vertexSource; part.unlit = !!material.extensions?.KHR_materials_unlit; // the normal and emissive maps, read at the base color texture's uvs; the emissive texture is multiplied by the // factor, which is black by default as the format says, so a texture alone does not glow; a factor with no // texture glows all over const normalRef = material.normalTexture, emissiveRef = material.emissiveTexture; part.normalMap = normalRef && textures[normalRef.index]; part.normalScale = normalRef?.scale ?? 1; part.roughness = pbr.roughnessFactor ?? 1; // a roughness map is not read const [er, eg, eb] = material.emissiveFactor || [0, 0, 0]; if (er || eg || eb) { // KHR_materials_emissive_strength makes it that many times brighter, emission above 1 in Blender const strength = material.extensions?.KHR_materials_emissive_strength?.emissiveStrength ?? 1; part.emissiveMap = emissiveRef ? textures[emissiveRef.index] : gltfWhiteTexture(); part.emissiveMapColor = rgb(gltfSRGB(er) * strength, gltfSRGB(eg) * strength, gltfSRGB(eb) * strength); } return part; } // a 1 by 1 white texture, the emissive map of a material that glows all over with no texture; made once and never // freed, like the engine's own white texture, undefined without WebGL let gltfWhiteTextureInfo; function gltfWhiteTexture() { if (gltfWhiteTextureInfo || !glContext || !canvasAvailable()) return gltfWhiteTextureInfo; const context = createCanvasContext(1); context.fillStyle = '#fff'; context.fillRect(0, 0, 1, 1); return gltfWhiteTextureInfo = new TextureInfo(context.canvas); } // a glTF color factor or vertex color is linear, where the renderer works in sRGB like the textures, so it is // brought across at load or a mid gray material would come out nearly black; 0 and 1 stay exact function gltfSRGB(c) { return c <= .0031308 ? c * 12.92 : c >= 1 ? c : 1.055 * c ** (1 / 2.4) - .055; } /** * LittleJS Three.js Plugin * - Renders a three.js scene on a canvas behind the LittleJS canvases * - The three.js module is passed in by the user, nothing is bundled * - Keep canvasClearColor transparent so the 3D scene shows through * - Aligned camera mode locks the 3D camera to the LittleJS 2D camera * - ThreeJSObject lets LittleJS physics drive a three.js mesh * - Call new ThreeJSPlugin(THREE) in gameInit to set up * @namespace ThreeJS */ /////////////////////////////////////////////////////////////////////////////// /** Global ThreeJS plugin object * @type {ThreeJSPlugin} * @memberof ThreeJS */ let threeJS; /////////////////////////////////////////////////////////////////////////////// /** * ThreeJS Plugin - Renders a three.js scene behind the LittleJS canvas * @example * // in gameInit, with three.js loaded by the user * new ThreeJSPlugin(THREE); * threeJS.scene.add(new THREE.AmbientLight); * @memberof ThreeJS */ class ThreeJSPlugin { /** Set up the three.js rendering layer, call in gameInit * @param {Object} THREE - The three.js module, supplied by the user * @param {number} [cameraFOV] - Vertical field of view in degrees */ constructor(THREE, cameraFOV=60) { ASSERT(!threeJS, 'ThreeJS plugin already initialized'); threeJS = this; if (headlessMode) return; ASSERT(mainCanvas, 'ThreeJS plugin must be created after engineInit, call in gameInit'); ASSERT(THREE && THREE.WebGLRenderer, 'three.js module must be passed in'); /** @property {Object} - The three.js module passed into the constructor */ this.THREE = THREE; /** @property {Object} - The three.js renderer */ this.renderer = new THREE.WebGLRenderer({antialias: true}); /** @property {Object} - The three.js scene, add lights and meshes here */ this.scene = new THREE.Scene(); /** @property {Object} - The three.js perspective camera */ this.camera = new THREE.PerspectiveCamera(cameraFOV, 1, .1, 1e3); /** @property {boolean} - Lock the camera to the LittleJS 2D camera so the z=0 plane matches world space */ this.cameraAlign2D = true; /** @property {number|undefined} - Near plane of the aligned camera before any zoom out, from camera.near on the * first aligned frame; while aligned, change this rather than camera.near * @type {number|undefined} */ this.cameraNear = undefined; /** @property {number|undefined} - Far plane of the aligned camera before any zoom out, from camera.far on the * first aligned frame; while aligned, change this rather than camera.far * @type {number|undefined} */ this.cameraFar = undefined; // insert the canvas below the engine canvases and match the layout const threeCanvas = this.renderer.domElement; const rootElement = mainCanvas.parentElement; rootElement.insertBefore(threeCanvas, rootElement.firstChild); threeCanvas.style.cssText = mainCanvas.style.cssText; // composite the 3D canvas into screenshots setBackgroundCanvas(threeCanvas); // render automatically each frame after the engine renders engineAddPlugin(undefined, ()=> this.render()); } /** Position the camera so the z=0 plane exactly matches LittleJS world space, * called automatically when cameraAlign2D is set */ alignCamera2D() { const halfHeight = mainCanvasSize.y / 2 / cameraScale; // half visible height in world units const distance = halfHeight / tan(this.camera.fov/2 * PI/180); // zoomed out far enough that the z=0 plane would be past the far plane, push both planes out together // so the depth precision stays what it was, and bring them back from where they began when zoomed in again const baseNear = this.cameraNear ??= this.camera.near, baseFar = this.cameraFar ??= this.camera.far; const scale = max(1, distance * 2 / baseFar), near = baseNear * scale, far = baseFar * scale; if (near != this.camera.near || far != this.camera.far) { this.camera.near = near; this.camera.far = far; this.camera.updateProjectionMatrix(); } this.camera.position.set(cameraPos.x, cameraPos.y, distance); // reset all axes in case a free camera was used, littlejs angles are clockwise this.camera.rotation.set(0, 0, -cameraAngle); } /** Sync the canvas layout and render the scene, called automatically each frame */ render() { if (!this.renderer) return; // headless mode // keep renderer size and css in sync with the LittleJS canvas // mainCanvasSize is css pixels, three scales it by the pixel ratio const threeCanvas = this.renderer.domElement; const dpr = getCanvasPixelRatio(); const bufferSizeX = mainCanvasSize.x * dpr | 0; const bufferSizeY = mainCanvasSize.y * dpr | 0; if (threeCanvas.width != bufferSizeX || threeCanvas.height != bufferSizeY) { this.renderer.setPixelRatio(dpr); this.renderer.setSize(mainCanvasSize.x, mainCanvasSize.y, false); this.camera.aspect = mainCanvasSize.x / mainCanvasSize.y; this.camera.updateProjectionMatrix(); } if (threeCanvas.style.cssText != mainCanvas.style.cssText) threeCanvas.style.cssText = mainCanvas.style.cssText; if (this.cameraAlign2D) this.alignCamera2D(); this.renderer.render(this.scene, this.camera); } } /////////////////////////////////////////////////////////////////////////////// /** * ThreeJS Object - EngineObject that drives a three.js mesh * - LittleJS physics moves the object and the mesh follows automatically * - Destroying the object removes the mesh from the scene * @extends EngineObject * @memberof ThreeJS */ class ThreeJSObject extends EngineObject { /** Create an engine object that drives a three.js mesh * @param {Vector2} [pos] - World space position * @param {Vector2} [size] - World space size * @param {Object} [mesh] - The three.js object3d to drive * @param {number} [z] - Mesh height above the 2D plane */ constructor(pos, size, mesh, z=0) { super(pos, size); ASSERT(threeJS, 'ThreeJS plugin must be initialized first'); /** @property {Object} - The three.js object3d this object drives */ this.mesh = mesh; /** @property {number} - Mesh height above the 2D plane */ this.z = z; if (mesh) { threeJS.scene?.add(mesh); // no scene in headless mode this.syncMesh(); } } /** Update the transform and sync the mesh to it, after the parent has placed a child, and while paused too * @param {boolean} [updateChildren] - Also update the children's transforms */ updateTransforms(updateChildren=true) { super.updateTransforms(updateChildren); this.syncMesh(); } /** Copy this object's transform to the mesh */ syncMesh() { if (!this.mesh) return; this.mesh.position.set(this.pos.x, this.pos.y, this.z); this.mesh.rotation.z = -this.angle; // littlejs angles are clockwise } /** The mesh is this object's visual, the default 2D rendering is skipped */ render() {} /** Destroy this object and remove its mesh from the scene * @param {boolean} [immediate] */ destroy(immediate) { if (this.destroyed) return; // note: frequently destroyed objects should also dispose geometry and material this.mesh && threeJS.scene?.remove(this.mesh); super.destroy(immediate); } } /** * LittleJS Tweakables Plugin * - Change values live from a panel while the game runs, for tuning without a reload * - tweak('name') adds a global, or a field of an object with the object option, a number gets a slider when it * has a range, a boolean a checkbox, a Color a color picker with an alpha slider, a Vector2 or Vector3 a number * for each axis * - tweakButton adds a button that calls a function, like one to restart the level * - Press 9 while the debug overlay is open to show the panel, or set debugTweakables to show it from the start * - Changes are saved and come back after a refresh, until the value in the code itself changes * - Copy puts the changed values on the clipboard as lines of code, to paste over the values in the code * - Debug builds only, the release build has none of this code, its functions are stubs that do nothing, so the * code's values are used as they are * @namespace Tweakables */ /////////////////////////////////////////////////////////////////////////////// // the tweaks and dividers in the order they were added const tweakList = []; // the saved values by name, each with the code value it was saved over, read when the first tweak is added let tweakSaved; // the panel, made the first time it is shown, and if its rows need to be made again let tweakPanel, tweakRows, tweakRowsDirty = true; // a global's name, or a dotted path to a field on one const tweakPathRegex = /^[A-Za-z_$][\w$]*(\.[A-Za-z_$][\w$]*)*$/; /** Options for a tweak * @typedef {Object} TweakOptions * @property {number} [min] - Lowest value on the slider, the slider shows when min and max are both set * @property {number} [max] - Highest value on the slider * @property {number} [step] - Step for the slider and number box, a thousandth of the range by default * @property {string} [label] - Name to show in place of the path, and the name it is saved by with an object * @property {Object} [object] - Object the path is a field of, in place of a global, for an ES module game * @memberof Tweakables */ /** Add a value to the tweakables panel, so it can be changed while the game runs * - The path is the name of a global, or a dotted path to a field on one like 'player.speed' * - Only globals of a script can be found by name, an ES module game passes the object its values are on * - Its type comes from the value it has now: a number, boolean, Color, Vector2 or Vector3 * - Call it after the value is set, at the end of gameInit * - Adding the same path again keeps its row and brings its tweaked value back, for an object made again; * with the object option it is known by its label, so two objects with the same field need their own labels * - Does nothing in release builds * @param {string} path - Name of a global, or a dotted path to a field on one or on the object option * @param {TweakOptions} [options] * @memberof Tweakables * @example * tweak('jumpSpeed', {min: 0, max: 1}); * tweak('skyColor'); * tweak('player.size', {label: 'Player Size'}); * tweak('speed', {object: settings}); // settings.speed, in an ES module */ function tweak(path, options={}) { if (!debug) return; ASSERT(isStringLike(path) && tweakPathRegex.test(path), 'tweak path must be a name like speed or player.speed'); const {object} = options; let name = path, get, set, toCode; if (object) { // a field of the object, the object at each step is looked up again so one made again is found ASSERT(typeof object === 'object', 'tweak object must be an object'); const keys = path.split('.'), last = keys.pop(); const parent = ()=> keys.reduce((o, key)=> o?.[key], object); name = options.label ?? path; get = ()=> parent()?.[last]; set = (v)=> { parent()[last] = v; }; toCode = (v)=> `${path}: ${v},`; // as it is written in the object } else { // a global, compiled once, a missing object on the path reads as undefined get = new Function(`try { return ${path} } catch {}`); set = new Function('v', `'use strict'; ${path} = v`); toCode = (v)=> `${path} = ${v};`; } const value = get(); ASSERT(value !== undefined, object ? `tweak could not find ${path} on the object` : `tweak could not find ${path}, it must be a global of a script, an ES module game passes the object option`); try { set(value); } catch { ASSERT(false, `tweak can not change ${path}, it must not be a const or frozen`); } tweakAdd(name, get, set, toCode, options); } /** Add a button to the tweakables panel that calls a function, like one to restart the level * - Adding the same label again replaces its function * - Does nothing in release builds * @param {string} label * @param {function():void} callback * @memberof Tweakables * @example * tweakButton('Restart', restartLevel); */ function tweakButton(label, callback) { if (!debug) return; ASSERT(isStringLike(label), 'tweakButton label must be a string'); ASSERT(typeof callback === 'function', 'tweakButton callback must be a function'); const t = tweakList.find(t=> t.type === 'button' && t.label === label); if (t) t.callback = callback; else { tweakList.push({type: 'button', label, callback}); tweakRowsDirty = true; } } /** Add a divider to the tweakables panel, with a label for the tweaks after it * - Does nothing in release builds * @param {string} [label] * @memberof Tweakables */ function tweakDivider(label='') { if (!debug) return; if (label && tweakList.find(t=> t.type === 'divider' && t.label === label)) return; // added again, as a game that calls its setup again does tweakList.push({type: 'divider', label}); tweakRowsDirty = true; } /** Add the engine settings a game most often tunes to the tweakables panel: * gravity, timeScale, cameraScale and soundVolume, under an Engine divider * - They are changed through their setters, so this works in the ES module build too * - Does nothing in release builds * @memberof Tweakables */ function tweakEngineDefaults() { if (!debug) return; tweakDivider('Engine'); tweakAdd('gravity', ()=> gravity, setGravity, (v)=> `setGravity(${v});`, {min: -.05, max: .05}); tweakAdd('timeScale', ()=> timeScale, setTimeScale, (v)=> `setTimeScale(${v});`, {min: 0, max: 2}); tweakAdd('cameraScale', ()=> cameraScale, setCameraScale, (v)=> `setCameraScale(${v});`, {min: 1, max: 128, step: 1}); tweakAdd('soundVolume', ()=> soundVolume, setSoundVolume, (v)=> `setSoundVolume(${v});`, {min: 0, max: 1}); } /////////////////////////////////////////////////////////////////////////////// // tweaks function tweakAdd(name, get, set, toCode, options) { const value = get(); const type = isNumber(value) ? 'number' : typeof value === 'boolean' ? 'boolean' : isColor(value) ? 'color' : isVector2(value) ? 'vector2' : tweakIsVector3(value) ? 'vector3' : undefined; ASSERT(type, `tweak ${name} must be a number, boolean, Color, Vector2 or Vector3`); if (!type) return; // added again, the code value stays the one it had first, the value now may be a tweaked one let t = tweakList.find(t=> t.name === name); if (!t) { tweakList.push(t = {name, codeValue: tweakCopy(value)}); tweakRowsDirty = true; } Object.assign(t, {type, get, set, toCode, options}); // a saved value comes back while the code value is the one it was saved over, after an edit it is dropped tweakSaved ||= readSaveData(tweakSaveName(), {}); const saved = tweakSaved[name]; if (!saved) return; if (tweakSaveText(saved.code) === tweakSaveText(tweakToSave(t.codeValue))) set(tweakFromSave(type, saved.value)); else { delete tweakSaved[name]; writeSaveData(tweakSaveName(), tweakSaved); } } // every page keeps its own tweaks function tweakSaveName() { return 'LittleJS tweaks ' + (globalThis.location?.pathname ?? ''); } function tweakCopy(v) { return v?.copy ? v.copy() : v; } // a Vector3, a build may leave out the 3D math function tweakIsVector3(v) { return typeof Vector3 !== 'undefined' && isVector3(v); } // the value as json can hold it, and back function tweakToSave(v) { return isColor(v) ? [v.r, v.g, v.b, v.a] : isVector2(v) ? [v.x, v.y] : tweakIsVector3(v) ? [v.x, v.y, v.z] : v; } function tweakFromSave(type, v) { return type === 'color' ? rgb(...v) : type === 'vector2' ? vec2(...v) : type === 'vector3' ? vec3(...v) : v; } function tweakSaveText(v) { return JSON.stringify(v); } // remember a tweak's value, or forget it when it is back to the code value function tweakSave(t) { const value = tweakToSave(t.get()), code = tweakToSave(t.codeValue); if (tweakSaveText(value) === tweakSaveText(code)) delete tweakSaved[t.name]; else tweakSaved[t.name] = {value, code}; writeSaveData(tweakSaveName(), tweakSaved); } // put every tweak back to its code value and forget the saved ones function tweakReset() { for (const t of tweakList) t.set?.(tweakCopy(t.codeValue)); tweakSaved = {}; writeSaveData(tweakSaveName(), tweakSaved); } // a line of code for each tweak changed from its code value function tweakChangedCode() { // numbers as the code writes them, without float noise or a leading zero, // colors as hsl rounded more, which changes nothing that can be seen const number = (n, digits=6)=> String(+n.toFixed(digits)).replace(/^(-?)0\./, '$1.'); const code = (v)=> { if (isColor(v)) { const [h, s, l, a] = v.HSLA(); return `hsl(${[h, s, l, ...(a < 1 ? [a] : [])].map((n)=> number(n, 3)).join(', ')})`; } if (isVector2(v)) return `vec2(${number(v.x)}, ${number(v.y)})`; if (tweakIsVector3(v)) return `vec3(${number(v.x)}, ${number(v.y)}, ${number(v.z)})`; return isNumber(v) ? number(v) : String(v); }; return tweakList.filter(t=> t.get && tweakSaveText(tweakToSave(t.get())) !== tweakSaveText(tweakToSave(t.codeValue))).map(t=> t.toCode(code(t.get()))).join('\n'); } /////////////////////////////////////////////////////////////////////////////// // panel // shows or hides the panel each frame, and shows values the game changed itself function tweakRender() { if (headlessMode) return; if (!debugTweakables) { if (tweakPanel && tweakPanel.style.display !== 'none') { // hidden, a box it had focus in lets go of the keys const active = /** @type {HTMLElement} */ (document.activeElement); tweakPanel.contains(active) && active.blur(); tweakPanel.style.display = 'none'; } return; } tweakPanel || tweakPanelInit(); tweakPanel.style.display = ''; if (tweakRowsDirty) { tweakRowsDirty = false; tweakRows.replaceChildren(); for (const t of tweakList) t.refresh = tweakRow(t); tweakList.length || tweakElement('div', tweakRows, 'color:#888', 'Nothing to tweak, add values with tweak()'); } for (const t of tweakList) t.refresh?.(); } function tweakPanelInit() { tweakPanel = tweakElement('div', document.body, 'position:fixed;top:8px;right:8px;width:260px;max-height:calc(100% - 16px);overflow-y:auto;' + 'box-sizing:border-box;padding:8px;background:#111d;color:#eee;font:12px monospace;' + 'border-radius:4px;z-index:9999'); // a click or touch on the panel is not the game's, a mouse up still goes on so a button can let go for (const type of ['mousedown','wheel','touchstart','touchmove','touchend','touchcancel']) tweakPanel.addEventListener(type, (e)=> e.stopPropagation()); tweakElement('div', tweakPanel, 'font-weight:bold;margin-bottom:4px', 'Tweakables'); tweakRows = tweakElement('div', tweakPanel); const buttons = tweakElement('div', tweakPanel, 'display:flex;gap:6px;margin-top:8px'); const button = (text, onclick)=> tweakElement('button', buttons, 'flex:1;padding:4px;cursor:pointer', text).onclick = onclick; button('Copy', (e)=> { // lines for the changed values, to paste over the values in the code const code = tweakChangedCode(), target = /** @type {HTMLElement} */ (e.target); code && navigator.clipboard?.writeText(code).catch(()=> console.log(code)); target.textContent = code ? 'Copied' : 'No changes'; setTimeout(()=> target.textContent = 'Copy', 1e3); }); button('Reset', tweakReset); } // the row for a tweak, returns what shows its value function tweakRow(t) { const row = tweakElement('div', tweakRows, 'margin:6px 0'); if (t.type === 'divider') { row.style.cssText = 'margin:10px 0 4px;padding-top:4px;border-top:1px solid #555;color:#aaa'; row.textContent = t.label; return; } if (t.type === 'button') { // the function is looked up on click, so one added again replaces it const button = tweakElement('button', row, 'width:100%;padding:4px;cursor:pointer', t.label); button.onclick = ()=> t.callback(); return; } const label = t.options.label ?? t.name; const save = ()=> tweakSave(t); if (t.type === 'boolean') { const labelElement = tweakElement('label', row, 'display:flex;gap:6px;align-items:center;cursor:pointer'); const box = tweakElement('input', labelElement); box.type = 'checkbox'; tweakElement('span', labelElement, '', label); box.onchange = ()=> { t.set(box.checked); save(); }; return ()=> { box.checked = !!t.get(); }; } tweakElement('div', row, '', label); if (t.type === 'number') return tweakNumber(row, '', t.get, t.set, t.options, save); // a part of a vector or color is a number of its own, set as a new copy with that part changed const part = (key, options)=> tweakNumber(row, key, ()=> t.get()?.[key], (n)=> { const v = t.get().copy(); v[key] = n; t.set(v); }, options, save); if (t.type === 'vector2' || t.type === 'vector3') { const axes = (t.type === 'vector2' ? ['x', 'y'] : ['x', 'y', 'z']).map((key)=> part(key, t.options)); return ()=> axes.forEach((refresh)=> refresh()); } // a color picker has no alpha, a slider below it has const picker = tweakElement('input', row, 'width:100%;height:24px;padding:0;border:0;background:none'); picker.type = 'color'; picker.oninput = ()=> { const color = rgb().setHex(picker.value); color.a = t.get().a; t.set(color); save(); }; const alpha = part('a', {min: 0, max: 1}); return ()=> { const color = t.get(); const hex = isColor(color) && color.toString(false); hex && picker !== document.activeElement && picker.value !== hex && (picker.value = hex); alpha(); }; } // a slider when there is a range and a box to type in, returns what shows the value function tweakNumber(parent, axis, get, set, options, save) { const {min, max} = options; const hasRange = isNumber(min) && isNumber(max); const value = get(), size = abs(value); const step = options.step ?? (hasRange ? (max - min) / 1e3 : size >= 10 ? 1 : size >= 1 ? .1 : .001); const row = tweakElement('div', parent, 'display:flex;gap:4px;align-items:center'); axis && tweakElement('span', row, 'color:#888', axis); const slider = hasRange ? tweakElement('input', row, 'flex:1;min-width:0') : undefined; const box = tweakElement('input', row, 'width:80px;box-sizing:border-box;background:#222;color:#eee;border:1px solid #555' + (hasRange ? '' : ';flex:1')); if (slider) Object.assign(slider, {type: 'range', min, max, step}); Object.assign(box, {type: 'number', step}); const change = (input)=> { const v = parseFloat(input.value); if (!isFinite(v)) return; // a box part way through typing, or 1e999 set(v); save(); }; slider && (slider.oninput = ()=> change(slider)); box.oninput = ()=> change(box); return ()=> { // the one being used keeps what it has, so typing is not undone const v = get(); if (!isNumber(v)) return; for (const input of [slider, box]) input && input !== document.activeElement && (input.value === '' || +input.value !== v) && (input.value = String(v)); }; } function tweakElement(tag, parent, style='', text='') { const element = document.createElement(tag); element.style.cssText = style; element.textContent = text; parent?.appendChild(element); return element; } /////////////////////////////////////////////////////////////////////////////// // plugin debug && engineAddPlugin(undefined, tweakRender); /* * LittleJS 3D Debug Plugin * - debugBox3D, debugSphere3D, debugLine3D and debugPoint3D, drawn on top of the 3D scene like the 2D debug functions * - Debug builds only, like the 2D ones: engineRelease.js has empty stubs for these, so release builds carry none of it * - Goes after the Render3D plugin, part of its Render3D namespace */ /////////////////////////////////////////////////////////////////////////////// const RENDER3D_DEBUG_WIDTH = .05; // line width of the debug primitives let render3DDebugPrimitives = []; // each keeps the debugClear count it was made under, a debugClear since drops it // draw the live debug primitives with depth test off so they show through walls, drop the expired ones function render3DRenderDebug() { if (!render3DDebugPrimitives.length) return; render3DDebugPrimitives = render3DDebugPrimitives.filter(p=> p.clearCount === debugClearCount); render3DWithState({lighting: false, depthTest: false, receiveShadow: false, additive: false, shader: undefined, emissiveMap: undefined}, ()=> { for (const p of render3DDebugPrimitives) p.draw(); }); render3DDebugPrimitives = render3DDebugPrimitives.filter(p=> p.timer < 0); // a Timer compares as negative until it elapses } // record a debug draw for a time function render3DDebugPush(duration, draw) { ASSERT(isNumber(duration), 'duration must be a number'); debug && glEnable && render3D?.program && render3DDebugPrimitives.push({timer: new Timer(duration, true), draw, clearCount: debugClearCount}); // real time, like 2D } /** Draw a debug wireframe box * @param {Vector3} pos - Center * @param {Vector3|number} [size] - Full size, a number for a cube * @param {Color} [color] * @param {number} [time] - How long to show it, 0 is one frame * @param {Vector3} [rotation] - vec3(pitch, yaw, roll) * @memberof Render3D */ function debugBox3D(pos, size=1, color=WHITE, time=0, rotation) { const matrix = buildMatrix(pos, rotation, render3DSize3(size)); const corner = (i)=> matrix.transformPoint(vec3(i & 1 ? .5 : -.5, i & 2 ? .5 : -.5, i & 4 ? .5 : -.5)); render3DDebugPush(time, ()=> { for (let i = 0; i < 8; ++i) for (const bit of [1, 2, 4]) if (!(i & bit)) render3D.drawLine(corner(i), corner(i | bit), RENDER3D_DEBUG_WIDTH, color); }); } /** Draw a debug wireframe sphere as three rings * @param {Vector3} pos - Center * @param {number} [size] - Diameter * @param {Color} [color] * @param {number} [time] - How long to show it, 0 is one frame * @memberof Render3D */ function debugSphere3D(pos, size=1, color=WHITE, time=0) { pos = pos.copy(); // where it is now, a timed one stays put when the object moves const circle = render3DCircle(24), r = size / 2; render3DDebugPush(time, ()=> { for (const ring of [(c, s)=> vec3(c, s, 0), (c, s)=> vec3(c, 0, s), (c, s)=> vec3(0, c, s)]) { const points = []; for (let i = 0; i <= 24; ++i) points.push(pos.add(ring(circle[i*2], circle[i*2 + 1]).scale(r))); render3D.drawRibbon(points, RENDER3D_DEBUG_WIDTH, undefined, color); } }); } /** Draw a debug line * @param {Vector3} posA * @param {Vector3} posB * @param {Color} [color] * @param {number} [width] * @param {number} [time] - How long to show it, 0 is one frame * @memberof Render3D */ function debugLine3D(posA, posB, color=WHITE, width=RENDER3D_DEBUG_WIDTH, time=0) { posA = posA.copy(), posB = posB.copy(); // where they are now, a timed one stays put when the ends move render3DDebugPush(time, ()=> render3D.drawLine(posA, posB, width, color)); } /** Draw a debug point as a small cross of three lines * @param {Vector3} pos * @param {Color} [color] * @param {number} [time] - How long to show it, 0 is one frame * @param {number} [size] - Length of the cross * @memberof Render3D */ function debugPoint3D(pos, color=WHITE, time=0, size=.2) { pos = pos.copy(); // where it is now, a timed one stays put when the object moves render3DDebugPush(time, ()=> { for (const axis of [vec3(size / 2, 0, 0), vec3(0, size / 2, 0), vec3(0, 0, size / 2)]) render3D.drawLine(pos.subtract(axis), pos.add(axis), RENDER3D_DEBUG_WIDTH, color); }); } /** * LittleJS Level Editor * - Paint the game's tile layers while it is paused, then keep playing with the changes * - Press 0 while the debug overlay is open to edit, 0 again to play, or call levelEditor.open() and close() * - Edits the Tiled map the game loaded, saves it back as Tiled JSON, and autosaves every change * - Debug builds only, the release build has stubs for its names in engineRelease.js and none of its code * @namespace Editor */ /////////////////////////////////////////////////////////////////////////////// // the level editor /** * @callback EditorPlayFromCallback - Puts the player at a world position, for the level editor's Play from mouse * @param {any} pos - Where to start playing, a Vector2 from the 2D editor and a Vector3 from the 3D one * @memberof Editor */ /** * @callback EditorRestartCallback - Rebuilds the level from the map the level editor changed * @memberof Editor */ /** * @callback EditorTileCallback - What the game does when the level editor paints a tile * @param {TileLayer} layer - The layer painted * @param {Vector2} pos - The cell's position in the layer * @param {number|undefined} tile - The tile painted, undefined when erased * @memberof Editor */ /** * @template [P=Vector2] * @typedef {Object} EditorToolAt - What the level editor gives the callbacks of a tool of the game's own; P is * the type of pos, a Vector2 for a 2D tool, the default, and Vector3|undefined for a 3D one * @property {P} pos - Where the mouse is in the level: a Vector2 in the 2D editor; in the 3D one a Vector3 on * the level or the ground, undefined when the mouse is over the panel * @property {Vector2|undefined} cell - 2D: the tile cell under the mouse on the selected tile layer * @property {Ray3D|undefined} ray - 3D: the mouse's Ray3D * @property {boolean} shift - Is Shift held * @property {boolean} ctrl - Is Ctrl held * @memberof Editor */ /** * @template [P=Vector2] * @typedef {Object} EditorTool - A tool of the game's own for the level editor, see LevelEditor.addTool; P is the * type of its pos, as EditorToolAt has it * @property {string} [key] - The key that picks it, as addKey spells one * @property {string} [hint] - The hint line while it is on * @property {function(EditorToolAt

): any} [onPress] - The left button went down in the level; returning false * says the press was not the tool's * @property {function(EditorToolAt

): any} [onDrag] - Each frame it is held * @property {function(EditorToolAt

): any} [onRelease] - It was let go * @property {function(EditorToolAt

): any} [onDraw] - Each frame the tool is on, to draw its cursor or preview * @memberof Editor */ /** * @typedef {Object} EditorEdit2D - The 2D level editor's edit functions, levelEditor.edit2D * @property {Object|undefined} map - The Tiled map of the selected layer * @property {TileLayer|undefined} layer - The selected tile layer, undefined on an object layer * @property {Array} objects - A copy of the selected object layer's objects, as Tiled has them * @property {Vector2|undefined} hover - The cell under the mouse on the selected tile layer * @property {Set} selection - The selected objects' ids * @property {function(Vector2, number, ...any): boolean} paint - Set a cell of the selected tile layer * to a tile index, -1 erases, with a direction 0 to 3 and a mirror; false off the layer or with none * @property {function(function(Array): void): boolean} changeObjects - Edit a copy of the selected object * layer's objects; false when nothing changed * @property {function(): void} strokeEnd - End the edit: one undo step, and the autosave * @property {function(): void} strokeCancel - Take the edit being made back * @property {function(function(): void): void} bulk - Many paints with one redraw a layer * @property {function(...any): any} undo - Undo, or redo with true * @property {function(): string} toJSON - The map as the text Save writes * @memberof Editor */ /** * @typedef {Object} EditorEdit3D - The 3D level editor's edit functions, levelEditor.edit3D; a position, a * rotation and a scale are Vector3, the rotation in degrees * @property {Object|undefined} level - The level object being edited * @property {Array} objects - Its objects, the list in the level: read it, edit through change * @property {Set} selection - The selected objects' ids * @property {function(): Array} selected - The selected objects * @property {function(number): any} made - What the game made for an object's id * @property {function(function(Array): void): boolean} change - Edit a copy of the object list; false * when nothing changed * @property {function(string, function(any): any): boolean} changePart - Edit the level's scene, voxels, terrain * or prefabs block * @property {function(): void} strokeEnd - End the edit: one undo step, and the autosave * @property {function(): void} strokeCancel - Take the edit being made back * @property {function(string, any): number|undefined} place - Add an object of a type at a position, selected; * its id, undefined when there is no such type or the level can not be edited * @property {function(Object, ...any): void} setTransform - Write a place, a rotation and a scale on an * object of the list given to change; one left out is kept * @property {function(Object, string, any): void} setProperty - Write a property on an object of the list given * to change, left out of the file when it is the type's default * @property {function(Object): any} pos - An object's position * @property {function(Object): any} rotation - Its rotation, in degrees * @property {function(Object): any} scale - Its scale * @property {function(): any} mousePoint - Where the mouse is on the level or the ground, undefined over the panel * @property {function(...any): boolean} undo - Undo, or redo with true * @property {function(): string} toJSON - The level as the text Save writes * @memberof Editor */ /** * The level editor, open it to pause the game and edit its level, close it to play on with the changes * - One of it, levelEditor, 0 on the debug overlay opens and closes it too * - A game makes it its own: it sets the hooks, adds keys, panel buttons and tools, or extends this class and * gives its own with setLevelEditor; the same calls work in the 2D editor and the 3D one, see EDITOR.md * - In release builds LevelEditor is a stub that never opens: what a game adds is taken and never called * @memberof Editor * @example * levelEditor.onRestart = ()=> loadLevel(); // adds a Restart button that rebuilds the level * levelEditor.onTile = (layer, pos, tile)=> layer.setCollisionData(pos, tile === ladderTile ? -1 : tile ? 1 : 0); * levelEditor.addKey('k', ()=> clearEnemies(), 'K: clear the enemies'); * * // or as a class of the game's own * class MyEditor extends LevelEditor * { * onRestart() { loadLevel(); } * onDraw() { drawSpawnZones(); } * } * setLevelEditor(new MyEditor); */ class LevelEditor { constructor() { /** @property {Array|undefined} - The tiles the palette shows, in its order, for a sheet that also holds * sprites and art that are not level tiles; undefined shows every tile of the sheet * @type {Array|undefined} */ this.paletteTiles = undefined; /** @property {boolean|undefined} - Which level editor opens: true the 3D one, false the 2D one, undefined the * 3D one when a level was loaded with level3DLoad and there is a Render3DPlugin * @type {boolean|undefined} */ this.use3D = undefined; /** @property {string|undefined} - The tool of the game's own that is on, one added with addTool, undefined * while a tool of the editor's is * @type {string|undefined} */ this.tool = undefined; // what the game added: its keys by how they are spelled, its panel buttons and its tools by name /** @type {Object} */ this.keys = {}; /** @type {Array<{label: string, onClick: Function, title: string}>} */ this.buttons = []; /** @type {Object>} */ this.tools = {}; } /** What the game does when the 2D editor paints a tile, like setting its collision or its look the way the * game does when it loads the level; set it or override it, without one the collision layer gets collision 1 * where there is a tile * @param {TileLayer} layer - The layer painted * @param {Vector2} pos - The cell's position in the layer * @param {number|undefined} tile - The tile painted, undefined when erased */ onTile(layer, pos, tile) {} /** Rebuild the level from what the editor changed; set it or override it and the editor has a Restart button, * which calls it after switching to play */ onRestart() {} /** Put the player at a position, a Vector2 in the 2D editor and a Vector3 in the 3D one; set it or override it * and the editor has Play from mouse, which starts play there * @param {any} pos - A Vector2 from the 2D editor, a Vector3 from the 3D one */ onPlayFrom(pos) {} /** Called when the editor opens, to set or override */ onOpen() {} /** Called when the editor closes and the game plays on, to set or override */ onClose() {} /** Called each frame while the editor is open, to set or override */ onUpdate() {} /** Called while the editor draws the level, to draw overlays of the game's own in it, to set or override */ onDraw() {} /** Called once when the editor's panel is made, with a box in it for the game's own controls, to set or override * @param {HTMLElement} element */ onPanel(element) {} /** Called by Save with the text of the file and its name; return true when the game kept it itself, and the * editor writes no file, to set or override; it may be async * @param {string} text * @param {string} fileName * @return {boolean|void|Promise} */ onSave(text, fileName) { return false; } /** True while the editor is open, the game is paused under it * @return {boolean} */ get isOpen() { return editorIsOpen || !!editorOther?.isOpen(); } /** True when the editor in use is the 3D one, as use3D says or since a 3D level was loaded * @return {boolean} */ get is3D() { return !!editorOther?.wanted(); } /** The 2D editor's edit functions, for a key, a button or a tool of the game's own: map, the Tiled map; layer, * the selected TileLayer; objects, a copy of the selected object layer's; hover, the cell under the mouse; * selection, the selected objects' ids; paint(cell, tile, direction, mirror), a tile index, -1 erases; * changeObjects((list)=> ...); strokeEnd() and strokeCancel(); bulk(()=> ...); undo(redo); toJSON() * @return {EditorEdit2D} */ get edit2D() { return editorEdit2D; } /** The 3D editor's edit functions, undefined without the 3D plugins: level; objects; selection, a Set of ids; * selected(); made(id); change((list)=> ...); changePart(name, (part)=> ...); strokeEnd() and strokeCancel(); * place(type, pos3D); setTransform(object, pos3D, rotationDegrees, scale3D); setProperty(object, name, value); * pos(object), rotation(object) and scale(object); mousePoint(); undo(redo); toJSON() * @return {EditorEdit3D|undefined} */ get edit3D() { return editorOther?.edit; } /** Add a key of the game's own to the editor, the 2D and the 3D one * - Escape and 0 are the editor's own, to play and to exit, and Ctrl is the one modifier: Shift is given to * the action * @param {string} key - A letter or digit, 'k', or a key's name in any case, 'Delete', 'F2', 'Enter', 'Space'; * 'ctrl+k' with Ctrl or Cmd * @param {function(boolean): any} action - Called with whether Shift is held; returning false says it did * nothing, and the key is left to the browser * @param {string} [helpLine] - A line for the editor's help */ addKey(key, action, helpLine) { ASSERT(typeof action == 'function', 'a key needs an action'); const spelled = editorKeySpelling(key), name = spelled.replace('ctrl+', ''); if (name === 'Escape' || name === '0' || name.length > 1 && name.includes('+')) { console.warn(`levelEditor.addKey: ${key} is not taken, ` + (name.includes('+') ? 'Ctrl is the one modifier, Shift is given to the action' : 'Escape and 0 are the editor\'s own')); return; } this.keys[spelled] = {action, help: helpLine, warned: false}; this === levelEditor && editorKeyClashes(); } /** Add a button of the game's own to the editor's panel * @param {string} label * @param {Function} onClick * @param {string} [title] - What it says when the mouse is over it */ addButton(label, onClick, title='') { this.buttons.push({label, onClick, title}); } /** Add a tool of the game's own: a button in the panel by its name, and while it is on the left button in the * level is its, not the editor's own tools' * - Each callback is given {pos, cell, ray, shift, ctrl}: pos is where the mouse is in the level, a Vector2 in * 2D and a Vector3 on the level or the ground in 3D; cell is the tile under it in 2D; ray is the mouse's * in 3D * - What the callbacks change through edit2D or edit3D is one undo: the editor ends the stroke at the release, * and takes it back when the right button or Escape ends the press * @template [P=Vector2] * @param {string} name * @param {EditorTool

} tool - {key, hint, onPress, onDrag, onRelease, onDraw}, each optional; onPress returning * false says the press was not the tool's */ addTool(name, tool) { ASSERT(!!tool && typeof tool == 'object', 'a tool is an object of callbacks'); const before = this.tools[name]; // added again, it takes the place of the first, its key too before?.key && delete this.keys[editorKeySpelling(before.key)]; this.tools[name] = tool; tool.key && this.addKey(tool.key, ()=> { this.tool = this.tool === name ? undefined : name; }); } /** Open the editor, pausing the game; until close(), Escape (the debug key) switches between playing and editing */ open() { if (editorOther?.wanted()) { editorOther.open(); return; } if (!editorSession) editorCameraScale = cameraScale; // a new session starts at the game's zoom, a return keeps the editor's editorSession = true; editorSetOpen(true); } /** Close the editor and end its session, the game carries on with the changes and Escape opens the debug * overlay again */ close() { if (editorOther?.active()) { editorOther.close(); return; } editorSession = false; editorSetOpen(false); } } /** The level editor, levelEditor.open() to edit the level, levelEditor.close() to play on with the changes * @type {LevelEditor} * @memberof Editor */ let levelEditor = new LevelEditor; /** Make a level editor of the game's own the one in use: an instance of a class that extends LevelEditor, with * the game's hooks as its methods; set it before the editor opens * @param {LevelEditor} editor * @memberof Editor */ function setLevelEditor(editor) { ASSERT(editor instanceof LevelEditor, 'a level editor extends LevelEditor'); levelEditor.isOpen && console.warn('setLevelEditor: the editor is open, close it first'); levelEditor = editor; } // the other editor, the 3D one, when its plugin is there: it says if it is the one wanted and open, opens and // closes, and has its edit functions; the 2D editor is this file /** @type {{wanted: function(): boolean, isOpen: function(): boolean, active: function(): boolean, open: Function, * close: Function, hasKey: function(string, boolean): boolean, edit: EditorEdit3D}|undefined} */ let editorOther; // if the game gave a hook: set on the editor, or a method of its own class, not the empty one of LevelEditor function editorHas(name) { const hook = levelEditor[name]; return typeof hook == 'function' && hook !== LevelEditor.prototype[name]; } // a hook or a moment of the game's, called when it is one: a game may set one to undefined to take it away function editorCall(name, ...args) { const hook = levelEditor[name]; return typeof hook == 'function' ? hook.apply(levelEditor, args) : undefined; } // the names keys have, by their lower case, so a game may spell one in any case const editorKeyNames = {}; for (const name of ['Delete', 'Backspace', 'Enter', 'Tab', 'Escape', 'Space', 'Home', 'End', 'PageUp', 'PageDown', 'Insert', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', ...[...Array(12)].map((v, i)=> 'F' + (i + 1))]) editorKeyNames[name.toLowerCase()] = name; // a key as addKey keeps it: a letter in lower case, a name as keys are named, 'ctrl+' in front with Ctrl function editorKeySpelling(key) { const text = String(key), ctrl = text.trim().toLowerCase().startsWith('ctrl+'); let name = ctrl ? text.trim().slice(5) : text; name = name === ' ' ? 'Space' : name.trim(); name = name.length === 1 ? name.toLowerCase() : editorKeyNames[name.toLowerCase()] ?? name; return (ctrl ? 'ctrl+' : '') + name; } // say once, for each key of the game's own that is one of the open editor's too, that the game's takes its place: // when the editor opens, or when the key is added while it is open, since the 2D and 3D editors' keys differ function editorKeyClashes() { if (!levelEditor.isOpen) return; for (const [spelled, key] of Object.entries(levelEditor.keys)) { const ctrl = spelled.startsWith('ctrl+'), name = spelled.slice(ctrl ? 5 : 0); // in 2D the digits 1 to 9 pick a layer const clash = levelEditor.is3D ? editorOther.hasKey(name, ctrl) : !!(ctrl ? editorCtrlKeys : editorKeys)[name] || !ctrl && name.length === 1 && name >= '1' && name <= '9'; if (key.warned || !clash) continue; key.warned = true; console.warn(`levelEditor.addKey: ${spelled} is a key of the editor's, the game's takes its place`); } } // the help lines of the game's own keys function editorGameHelpLines() { return Object.values(levelEditor.keys).map((key)=> key.help).filter((line)=> !!line); } // the tool of the game's own that is on, undefined when there is none function editorGameTool() { return levelEditor.tool !== undefined ? levelEditor.tools[levelEditor.tool] : undefined; } // the game's box of an editor's panel: a row of its tools and its buttons, made again when one was added, the // tool that is on lit; and, the first time for a level editor, onPanel is given the box for controls of its own const editorGamePanels = new WeakMap; function editorGamePanel(box) { let state = editorGamePanels.get(box); if (state?.owner !== levelEditor) { box.replaceChildren(); state = {owner: levelEditor, row: editorElement('div', box, 'display:flex;gap:4px;margin:4px 0;flex-wrap:wrap'), key: '', buttons: {}}; editorGamePanels.set(box, state); editorCall('onPanel', box); } const tools = Object.keys(levelEditor.tools); const key = tools.map((name)=> name + '\t' + (levelEditor.tools[name].hint || '')).join('\n') + '\n\n' + levelEditor.buttons.length; if (state.key !== key) { state.key = key; state.buttons = {}; const press = 'padding:3px 6px;cursor:pointer'; state.row.replaceChildren(...tools.map((name)=> { const b = state.buttons[name] = editorElement('button', undefined, press, name); b.title = levelEditor.tools[name].hint || ''; b.onclick = ()=> { levelEditor.tool = levelEditor.tool === name ? undefined : name; b.blur?.(); }; return b; }), ...levelEditor.buttons.map((button)=> { const b = editorElement('button', undefined, press, button.label); b.title = button.title; b.onclick = ()=> { button.onClick.call(levelEditor); b.blur?.(); }; return b; })); } for (const name of tools) state.buttons[name].style.outline = name === levelEditor.tool ? '2px solid #4af' : ''; } // if the editor is open, and if it was opened and not exited, while Escape switches between playing and editing let editorIsOpen = false, editorSession = false; // if switching to play puts the player at the mouse, and if the panel's Advanced section is shown let editorPlayFromMouse = false, editorAdvanced = false; // switch to play in the session, with the player at a position when Play from mouse is on and the game has a hook function editorPlay(pos) { editorSetOpen(false); editorPlayFromMouse && pos && editorHas('onPlayFrom') && levelEditor.onPlayFrom(pos.copy()); } // the game's pause and camera from before the editor opened, handed back when it closes let editorGameState; // the editor's own view, so the game's camera is left where the game had it let editorCameraPos = vec2(), editorCameraScale = 32; // the map being edited, the selected object layer's or tile layer's, a level of objects alone has no tile layer const editorRecord = ()=> (editorObjectLayer ?? editorLayer)?.record; // the layer being painted, the cell under the mouse, the last cell of a stroke, the last cell a stamp was // placed on, for a Shift line let editorLayer, editorHover, editorLastCell, editorLastPlaced; // the selected area, cells of the layer being edited from min to max, the right button's press while it is held, // and a left press that only cleared a selection, which paints nothing until let go let editorSelection, editorRightPress, editorLeftSpent; // a left drag moving the selection: the layer, the cell pressed and how far it moved, the area and its tiles as // they were, and with All Layers the ids of the objects in it, by object layer let editorSelectionDrag; // if the mouse is over the panel, and where it was last on the level, which is where the editor reads it while it is // over the panel, so a drag held across it stops at its edge and paints nothing behind it let editorMouseOnPanel = false, editorMouseScreen = vec2(); // if selection edits, Delete, Ctrl+C and Ctrl+X, act on every tile layer of the edited layer's map let editorAllLayers = false; // the grid drawn over the layer, G, and the list of keys in the panel, ? let editorGrid = true, editorHelp = false; // open or close the editor, the game is paused while it is open and carries on with the changes after function editorSetOpen(open) { if (!debug || editorIsOpen === !!open) return; editorIsOpen = !!open; if (editorIsOpen) { editorGameState = {paused, cameraPos: cameraPos.copy(), cameraScale, cameraAngle}; editorCameraPos = cameraPos.copy(); // where the game is, the zoom stays the editor's setPaused(true); setDebugOverlay(false); // out of the way of the level // the layer edited last, when it is still there, or the collision layer const layers = editorLayers(); layers.includes(editorLayer) || (editorLayer = layers.find((layer)=> layer.live?.isSolid) ?? layers.filter((layer)=> !layer.isObjects).pop()); layers.includes(editorObjectLayer) || (editorObjectLayer = editorLayer ? undefined : layers.find((layer)=> layer.isObjects)); // a level of objects alone editorCall('onOpen'); editorKeyClashes(); } else { editorStrokeEnd(); editorAutosaveFlush(); // the game plays on with what the editor has kept, the stroke just ended too editorToolHeld = false; editorSelection = editorSelectionDrag = editorRightPress = editorObjectDrag = editorObjectBox = undefined; editorObjectSelection.clear(); editorMouseOnPanel = false; // the panel hides, with no mouseleave const state = editorGameState; setPaused(state.paused); setCameraPos(state.cameraPos); setCameraScale(state.cameraScale); setCameraAngle(state.cameraAngle); editorCall('onClose'); } } // the editor's view, applied before it reads the mouse and before each render, since a game may move the // camera from gameUpdatePost, which runs while paused function editorApplyCamera() { setCameraPos(editorCameraPos); setCameraScale(editorCameraScale); setCameraAngle(0); // painting is on the grid as the level is laid out } /////////////////////////////////////////////////////////////////////////////// // tiles as Tiled gids // a tile, its quarter turns and mirror as a Tiled gid, its flips in the top bits as tileLayersLoad reads them, // 0 for an empty cell; unsigned, as Tiled writes it function editorTileToGid(tile, direction=0, mirror=false) { if (tile === undefined) return 0; const flips = tileLayersTiledFlips.findIndex(([d, m])=> d === direction && !!m === !!mirror); return ((flips << 29) | (tile + 1)) >>> 0; } // a gid back to a tile, its quarter turns and mirror, undefined for an empty cell function editorGidToTile(gid) { if (!gid) return; const [direction, mirror] = tileLayersTiledFlips[gid >>> 29]; return {tile: (gid & 0x0fffffff) - 1, direction, mirror: !!mirror}; } /////////////////////////////////////////////////////////////////////////////// // maps // every map the game loaded, and one for each layer it made in code; the editor changes a map's data in place, // so a game that loads the same map object again, as a restart does, gets the changes const editorMapList = []; // the tile layers of a map's Tiled layers in the order tileLayersLoad makes them, groups flattened function editorTileLayers(layers, list=[]) { for (const layer of layers) { if (layer.type === 'group') editorTileLayers(layer.layers || [], list); else if (!layer.type || layer.type === 'tilelayer') list.push(layer); } return list; } // the tile layers' data arrays of a map, in the same order const editorTileLayerData = (layers)=> editorTileLayers(layers).map((layer)=> layer.data); // called by tileLayersLoad before it reads a map; a map it has not seen gets a record, and its autosave is copied // in when the file is the one it was made over; a file that changed since keeps the autosave pending for the // panel to apply or drop, and one that already has the autosaved data, saved from the editor, drops it function editorMapRestore(map) { editorAutosaveFlush(); // what an earlier map has waiting is written before this one reads the autosaves if (editorMapList.some((record)=> record.map === map)) return map; // loaded again, it has the changes // a map object seen before, its record retired when its layers went, gets it back as it was, its data has the // changes already const retired = editorRetiredMaps.get(map); if (retired) { editorRetire((record)=> record.key === retired.key); editorMapList.push(retired); return map; } // a map it can not read as plain gids, an infinite one or one with typed arrays, is left to tileLayersLoad, // which asserts on what it can not load, and a layer made from it gets a map of its own when edited const data = map.layers ? editorTileLayerData(map.layers) : []; if (!map.layers || data.some((layer)=> !Array.isArray(layer))) return map; const objects = editorObjectGroups(map.layers).map((group)=> group.objects ?? []); const url = editorFetchedURLs.get(map), hash = editorMapHash(data, objects, editorMapLayout(map)); const fileName = editorFileName(url, 'level.json'); const record = {map, url, fileName, key: editorMapKey(map, url, hash), hash, original: data.map((layer)=> [...layer]), originalObjects: editorObjectsCopy(objects), originalSize: {width: map.width, height: map.height}, layers: []}; // a new copy of a map takes over from the one before, and maps whose layers are all gone step aside editorRetire((other)=> other.key === record.key || editorObjectsGone(other) || other.layers.length && other.layers.every((layer)=> layer.live.destroyed)); editorMapList.push(record); const saved = editorSaves()[record.key]; if (!saved) return map; // an autosave from before the map's shape was in the hash matches on its tiles and objects, and one from before // objects were matches on its tiles; one of a resized map is resized to first const sameFile = saved.hash === record.hash || saved.savedHash === record.hash || !saved.width && (saved.hash === editorMapHash(data, objects) || !saved.objects && saved.hash === editorMapHash(data)); if ((saved.width ?? map.width) === map.width && (saved.height ?? map.height) === map.height && editorSameData(saved.layers, data) && editorSameData(editorObjectsKept(saved.objects ?? [], objects.length), objects) && !(saved.nextobjectid > (map.nextobjectid ?? 1))) editorDiscardPending(record); else if (!sameFile || !editorSavedFits(saved, data.length, saved.width ?? map.width, saved.height ?? map.height) || !editorResizeMap(map, saved.width ?? map.width, saved.height ?? map.height) || !editorCopyData(data, saved.layers)) { record.pending = saved; console.warn(`LittleJS editor: ${record.fileName} changed since its autosaved edits, ` + 'open the editor (Esc then 0) to apply or drop them'); } else { editorRestoreObjects(map, saved, true); console.warn(`LittleJS editor: brought back unsaved edits to ${record.fileName}, ` + 'Save in the editor (Esc then 0) writes them to the file'); saved.stale && console.warn(`LittleJS editor: they are older than the last edits to ${record.fileName}, ` + 'which storage had no room for'); } return map; } // the records of maps the game moved on from, by map object, for a map it loads again const editorRetiredMaps = new WeakMap; // take records out of the list, with the undo that would change them, so an undo never writes a map // the game no longer shows; a map object loaded again gets its record back function editorRetire(isRetired) { const gone = editorMapList.filter(isRetired); if (!gone.length) return; for (const record of gone) { editorMapList.splice(editorMapList.indexOf(record), 1); record.synthetic || editorRetiredMaps.set(record.map, record); } for (const list of [editorUndoList, editorRedoList]) for (let i = list.length; i--;) list[i].some((entry)=> gone.includes(editorEntryRecord(entry))) && list.splice(i, 1); if (gone.includes(editorLayer?.record)) editorLayer = editorSelection = editorSelectionDrag = undefined; // the selection was an area of that layer if (gone.includes(editorObjectLayer?.record)) editorObjectLayer = undefined; } // called by tileLayersLoad with the layers it made, a map loaded again keeps its layer records, with the new layers function editorMapLoaded(map, tileLayers, flatLayers) { const record = editorMapList.find((record)=> record.map === map); record && tileLayers.forEach((live, i)=> { const {dataLayer: source, color} = flatLayers[i]; const layer = record.layers.find((layer)=> layer.source === source); layer ? Object.assign(layer, {live, color}) : record.layers.push({record, source, live, color}); }); } // the editor's record of a live layer; a layer the game made in code gets a map of its own, from its tiles now function editorLayerRecord(live) { for (const record of editorMapList) for (const layer of record.layers) if (layer.live === live) return layer; const {x: width, y: height} = live.size, size = live.tileInfo?.size ?? vec2(16); const data = []; for (let y = height; y--;) for (let x = 0; x < width; ++x) { const d = live.getData(vec2(x, y)); data.push(editorTileToGid(d.tile, d.direction, d.mirror)); } const source = {type: 'tilelayer', id: 1, name: 'Tile Layer 1', x: 0, y: 0, width, height, opacity: 1, visible: true, data}; const map = {type: 'map', version: '1.10', orientation: 'orthogonal', renderorder: 'right-down', infinite: false, width, height, tilewidth: size.x, tileheight: size.y, nextlayerid: 2, nextobjectid: 1, tilesets: editorTilesets(live), layers: [source]}; const record = {map, synthetic: true, fileName: 'level.json', layers: []}; const layer = {record, source, live, color: WHITE}; record.layers.push(layer); // the records of layers made in code that are gone, as a game that makes them again on a restart leaves them editorRetire((other)=> other.synthetic && other.layers.every((l)=> l.live.destroyed)); editorMapList.push(record); return layer; } // a Tiled tileset for a layer made in code, from its image function editorTilesets(live) { const tileInfo = live.tileInfo, image = tileInfo?.textureInfo?.image; if (!image || tileInfo.tiles) return []; // a tile set is packed from many images, none of them a Tiled tileset const {x: tilewidth, y: tileheight} = tileInfo.size, padding = tileInfo.padding; const columns = tileInfo.columns || floor(image.width / (tilewidth + padding*2)); const rows = floor(image.height / (tileheight + padding*2)); const file = image.src ? image.src.split('/').pop() : 'tiles.png'; return [{firstgid: 1, name: file.replace(/\.\w+$/, ''), image: file, imagewidth: image.width, imageheight: image.height, tilewidth, tileheight, margin: padding, spacing: padding*2, columns, tilecount: columns * rows}]; } // the file each json fetchJSON loaded came from, its name for saving and autosaving const editorFetchedURLs = new WeakMap; function editorJSONFetched(url, json) { json && typeof json === 'object' && editorFetchedURLs.set(json, String(url)); } // a map as Tiled JSON, everything it was loaded with kept, the tile data as the editor left it function editorMapJSON(record) { // an object layer the editor made for a first object, every object of which was taken back, is left out const map = record.map, made = map.layers.find((layer)=> editorMadeGroups.has(layer) && !layer.objects.length); if (!made) return JSON.stringify(map); return JSON.stringify({...map, layers: map.layers.filter((layer)=> layer !== made), ...(map.nextlayerid === made.id + 1 && {nextlayerid: made.id})}); } // the object layers the editor made in a map, for a map that had none const editorMadeGroups = new WeakSet; // the files picked to save maps to, kept across reloads in IndexedDB by page and map, which is where a browser // lets a page keep them; each resolves to undefined when the store can not be used let editorFileStore = { get: (key)=> editorFileStoreRequest('readonly', (store)=> store.get(key)), set: (key, handle)=> editorFileStoreRequest('readwrite', (store)=> handle ? store.put(handle, key) : store.delete(key)), }; // a request on the editor's file store, resolves to its result, or undefined if anything failed function editorFileStoreRequest(mode, request) { return new Promise((resolve)=> { try { const open = indexedDB.open('LittleJS editor', 1); open.onupgradeneeded = ()=> open.result.createObjectStore('files'); open.onerror = ()=> resolve(undefined); open.onsuccess = ()=> { const db = open.result, done = (result)=> { db.close(); resolve(result); }; try { const r = request(db.transaction('files', mode).objectStore('files')); r.onsuccess = ()=> done(r.result); r.onerror = ()=> done(undefined); } catch { done(undefined); } }; } catch { resolve(undefined); } }); } // the file a map was saved to on an earlier page load, when the browser gives permission to write it again async function editorRememberedFile(record) { const handle = await editorFileStore.get(editorFileKey(record)), mode = {mode: 'readwrite'}; try { if (handle && (await handle.queryPermission(mode) === 'granted' || await handle.requestPermission(mode) === 'granted')) return handle; } catch { } // a handle that can not ask, the picker instead } const editorFileKey = (record)=> (globalThis.location?.pathname ?? '') + ' ' + record.key; // a map as a file Save wrote is the file from then on: Reset to file goes back to it, and a reload of it has nothing // to apply; a download can not say it replaced the file, so it does not, and neither does a file of another name, a // copy, since the game still loads the one it came from; the browser gives a picked file's name and not its folder, // so a file of the same name may be a copy too, and the autosave stays, with the hash of the file the game loads and // of the file written, until a reload shows which one the game loads function editorSetBaseline(record, written) { if (record.synthetic) return; // a layer made in code has no file to load it from const data = editorTileLayerData(written.layers); const objects = editorObjectGroups(written.layers).map((group)=> group.objects ?? []); Object.assign(record, {original: data, originalObjects: objects, savedHash: editorMapHash(data, objects, editorMapLayout(written)), originalSize: {width: written.width, height: written.height}}); // an Objects layer the editor made is the file's own once a Save wrote it, so it stays when emptied after const writtenIds = new Set(editorObjectGroups(written.layers).map((group)=> group.id)); for (const group of editorObjectGroups(record.map.layers)) writtenIds.has(group.id) && editorMadeGroups.delete(group); record.pending || editorAutosave(record); // edits waiting to be applied keep their autosave } // save a map as Tiled JSON: where the browser lets a page write files, Chrome and Edge, to a file picked once and // written again on each Save after, even after a reload once the browser gives permission, or picked again with // Save As; elsewhere as a download under the name of the file it came from; resolves to how it saved, undefined // when the picker was closed; saves of a map run one at a time in the order asked, so the file ends with the last async function editorSave(record, pickAgain=false) { if (!record) return; editorStrokeEnd(); editorAutosaveFlush(); const text = editorMapJSON(record); // the map as it is now, later edits wait for a later save // its place in line is taken now, and the game's hook is asked when its turn comes, so saves are written in // the order they were asked for however long a hook takes const saved = (async ()=> { await record.saving; if (await editorCall('onSave', text, record.fileName) === true) return 'kept'; // the game kept it itself return editorSaveText(record, text, pickAgain); })(); record.saving = saved.catch(()=> {}); return saved; } // write one save of a map, the one before it done async function editorSaveText(record, text, pickAgain) { const picker = /** @type {any} */ (globalThis).showSaveFilePicker; let written = false; if (picker) { try { if (!pickAgain && !record.fileHandle) record.fileHandle = await editorRememberedFile(record); if (pickAgain || !record.fileHandle) { record.fileHandle = await picker.call(globalThis, {suggestedName: record.fileName, types: [{description: 'Tiled JSON', accept: {'application/json': ['.json']}}]}); editorFileStore.set(editorFileKey(record), record.fileHandle); } const writable = await record.fileHandle.createWritable(); await writable.write(text); await writable.close(); written = true; } catch (error) { if (error?.name === 'AbortError') return; // the picker was closed, nothing saved record.fileHandle = undefined; // a file it could not write, a download instead, and not kept editorFileStore.set(editorFileKey(record), undefined); console.warn(`LittleJS editor: ${record.fileName} could not be written, downloaded instead`, error); } } if (written) { // outside the write's catch, so an error here is its own and not a failed write that downloads a copy; // the browser gives the picked file's name but not its folder, so the name is what says it is the map's if (record.fileHandle.name === record.fileName) editorSetBaseline(record, JSON.parse(text)); return 'written'; } saveText(text, record.fileName, 'application/json'); return 'downloaded'; } /////////////////////////////////////////////////////////////////////////////// // autosave // every page keeps its own autosaves, one for each map by its key function editorSaveName() { return 'LittleJS editor ' + (globalThis.location?.pathname ?? ''); } const editorSaves = ()=> readSaveData(editorSaveName(), {}); // if the last autosave did not fit in storage, the panel says so let editorSaveFailed = false; // write the autosaves, noting whether storage took them; when it did not, the autosave it still has of this map // is an older one, and is marked so, for the reload that brings it back to say so function editorWriteSaves(saves, key, name=editorSaveName()) { try { const text = JSON.stringify(saves); editorAutosaveSize = text.length; // how much an autosave writes, which says whether the next one waits localStorage.setItem(name, text); return editorSaveFailed = false; } catch { editorSaveFailed = true; } try { const kept = readSaveData(name, {}); if (!kept[key] || kept[key].stale) return true; kept[key].stale = true; localStorage.setItem(name, JSON.stringify(kept)); } catch {} // no room for the mark either return true; } // the name of the file a level was fetched from, as it is on disk: without its folder and query, and with what // the url spells with a percent as it is written, so it is the name a picked file has function editorFileName(url, fallback) { const name = url?.split(/[?#]/)[0].split('/').pop(); if (!name) return fallback; try { return decodeURIComponent(name); } catch { return name; } } // a map's name for its autosave, the file it was fetched from without a query, or its size, layer names and data as loaded; a map with // no file can not tell a changed map from another one, so each is its own function editorMapKey(map, url, hash) { return url?.split(/[?#]/)[0] ?? `${map.width}x${map.height} ` + map.layers.map((layer)=> layer.name).join() + ' #' + hash; } // a quick hash of a map's tile data, objects and shape, to know when the file changed under its autosave function editorMapHash(data, objects=[], layout='') { let hash = 2166136261; for (const layer of data) for (const gid of layer) hash = Math.imul(hash ^ gid, 16777619); const text = (objects.length ? JSON.stringify(objects) : '') + layout; for (let i = 0; i < text.length; ++i) hash = Math.imul(hash ^ text.charCodeAt(i), 16777619); return hash >>> 0; } // a map's shape: its size, tile size, and its tile and object layers by name, in order, so a file whose tiles read // the same laid out another way is a changed file function editorMapLayout(map) { const names = (layers)=> layers.map((layer)=> layer.id + ':' + layer.name).join(); return `${map.width}x${map.height} ${map.tilewidth}x${map.tileheight} ` + names(editorTileLayers(map.layers)) + ' | ' + names(editorObjectGroups(map.layers)); } const editorSameData = (a, b)=> JSON.stringify(a) === JSON.stringify(b); // copy saved tile data into a map's layers in place, when every layer is the size it was function editorCopyData(data, saved) { if (saved.length !== data.length || saved.some((layer, i)=> layer.length !== data[i].length)) return false; saved.forEach((layer, i)=> { for (let j = layer.length; j--;) data[i][j] = layer[j]; }); return true; } // are an autosave's tiles a layer for each of the map's, each of its own size, so the map is resized only for tiles // that fit it const editorSavedFits = (saved, count, width, height)=> isArray(saved.layers) && saved.layers.length === count && saved.layers.every((layer)=> isArray(layer) && layer.length === width * height); // remember a map's tile data and objects, or forget them when they are back to the file function editorAutosave(record) { editorAutosaveWaiting.delete(record); // written now if (record.synthetic) return; // a layer made in code has no load to bring it back in, save it to a file const saves = editorSaves(), map = record.map, data = editorTileLayerData(map.layers); const objects = editorObjectGroups(map.layers).map((group)=> group.objects ?? []); const original = record.originalObjects ?? [], kept = editorObjectsKept(objects, original.length); const size = record.originalSize, sameSize = map.width === size.width && map.height === size.height; // the map as it was loaded has nothing to keep; one a Save wrote is kept until a reload shows the file has it if (sameSize && editorSameData(data, record.original) && editorSameData(kept, original) && !record.savedHash) delete saves[record.key]; else saves[record.key] = {hash: record.hash, savedHash: record.savedHash, width: map.width, height: map.height, layers: data, objects: kept, nextobjectid: map.nextobjectid}; editorWriteSaves(saves, record.key); } // a map's lists of objects with the empty ones past count left off: an Objects layer the editor made, empty again, // is not an edit, and a Save leaves it out of the file too function editorObjectsKept(objects, count) { const kept = objects.slice(); while (kept.length > count && !kept[kept.length - 1]?.length) kept.pop(); return kept; } // an autosave writing more than this many characters, a big map's, waits until the edits stop for a moment, since // writing it after every stroke would cost more than the stroke; a small map's is written at once, as always const EDITOR_AUTOSAVE_WAIT_SIZE = 1e5; let editorAutosaveSize = 0, editorAutosaveTimer; const editorAutosaveWaiting = new Set; // autosave a map after an edit: at once when its autosave is small, or a second after the last of a run of edits function editorAutosaveSoon(record) { if (editorAutosaveSize < EDITOR_AUTOSAVE_WAIT_SIZE) return editorAutosave(record); editorAutosaveWaiting.add(record); clearTimeout(editorAutosaveTimer); editorAutosaveTimer = setTimeout(editorAutosaveFlush, 1e3); } // write every autosave that is waiting, as the editor closes, the page hides, a map is saved or another loads function editorAutosaveFlush() { clearTimeout(editorAutosaveTimer); [...editorAutosaveWaiting].forEach(editorAutosave); } // paint every cell of a map's layers from a list of tile data, the tile layers of the map in order, and set its // object layers' objects from a list of them; data of another size resizes the map, which needs the Restart hook function editorPaintData(record, data, objects, width=record.map.width, height=record.map.height, keepUnknown=false) { editorStrokeEnd(); if (width !== record.map.width || height !== record.map.height) { if (!editorHas('onRestart')) return; const before = editorMapSnapshot(record); editorSetMapSnapshot(record, {width, height, layers: data, objects}, keepUnknown); editorMapChanged(record, before); return; } const all = editorTileLayerData(record.map.layers); editorBulkEdit(()=> { for (const layer of record.layers) { const gids = data[all.indexOf(layer.source.data)], {x: width, y: height} = layer.live.size; if (gids?.length === width * height) gids.forEach((gid, i)=> editorPaint(layer, vec2(i % width, height - 1 - (i / width | 0)), gid)); } // an autosave from before the file had an object layer leaves that layer as the file has it; Reset to file // empties a layer the file did not have editorObjectLayers(record).forEach((layer, i)=> objects && !(keepUnknown && i >= objects.length) && editorChangeObjects(layer, (list)=> list.splice(0, list.length, ...editorObjectsCopy(objects[i] ?? [])))); }); editorStroke ? editorStrokeEnd() : editorAutosave(record); } // the autosaved edits of a file that changed since, applied as one undo; edits that do not fit the file now are // kept waiting, with their autosave, until they are dropped, and it returns false function editorApplyPending(record) { const saved = record?.pending; if (!saved) return false; if (!editorPendingFits(record, saved)) { record.pendingUnfit = true; console.warn(`LittleJS editor: the autosaved edits to ${record.fileName} do not fit the file now, ` + 'they are kept until dropped'); return false; } record.pending = record.pendingUnfit = undefined; editorPaintData(record, saved.layers, saved.objects, saved.width, saved.height, true); return true; } // if autosaved edits fit a map now: as many tile layers, each the size they were saved at, which needs the Restart // hook when the map is another size, and no objects in object layers the map no longer has function editorPendingFits(record, saved) { const map = record.map, data = editorTileLayerData(map.layers), groups = editorObjectGroups(map.layers).length; const width = saved.width ?? map.width, height = saved.height ?? map.height; return (width === map.width && height === map.height || editorHas('onRestart')) && saved.layers?.length === data.length && saved.layers.every((layer)=> layer.length === width * height) && (saved.objects ?? []).every((objects, i)=> i < groups || !objects.length); } // drop the autosaved edits of a file that changed since function editorDiscardPending(record) { if (!record) return; record.pending = record.pendingUnfit = undefined; const saves = editorSaves(); delete saves[record.key]; editorWriteSaves(saves); } // put every layer back to the file, its size too, as one undo function editorRevert(record) { if (!record || record.synthetic) return; // a layer made in code has no file record.pending = record.pendingUnfit = undefined; const {width, height} = record.originalSize ?? record.map; editorPaintData(record, record.original, record.originalObjects, width, height); } /////////////////////////////////////////////////////////////////////////////// // resizing // change a map's size in place, at the right and top, so the tiles and objects keep their places from the bottom // left; a row added goes at the start of each layer's data, Tiled's rows run from the top, and objects past the new // edges are dropped; returns false for a size it can not have function editorResizeMap(map, width, height) { if (!(width >= 1 && height >= 1 && width % 1 === 0 && height % 1 === 0)) return false; if (width === map.width && height === map.height) return true; const oldWidth = map.width, added = height - map.height; for (const layer of editorTileLayers(map.layers)) { const old = [...layer.data]; layer.data.length = 0; for (let row = 0; row < height; ++row) for (let x = 0; x < width; ++x) { const oldRow = row - added; layer.data.push(x < oldWidth && oldRow >= 0 && old[x + oldRow * oldWidth] || 0); } layer.width = width; layer.height = height; } // an object on the map before and off it after is dropped, one that was already off it stays const {tilewidth=1, tileheight=1} = map, oldHeight = map.height; // a tile object's y is its bottom, a point's or a rectangle's its top const inside = (object, w, h)=> object.x >= 0 && object.x < w * tilewidth && (object.gid === undefined ? object.y >= 0 && object.y < h * tileheight : object.y > 0 && object.y <= h * tileheight); for (const group of editorObjectGroups(map.layers)) { group.objects &&= group.objects.filter((object)=> { const was = inside(object, oldWidth, oldHeight); object.y += added * tileheight; return !was || inside(object, width, height); }); } map.width = width; map.height = height; return true; } // a map's size, tiles and objects, for an undo function editorMapSnapshot(record) { const map = record.map, objects = editorObjectGroups(map.layers).map((group)=> group.objects ?? []); return editorObjectsCopy({width: map.width, height: map.height, layers: editorTileLayerData(map.layers), objects}); } // give a map a size, tiles and objects, the game's Restart hook makes its layers again; keepUnknown leaves an object // layer past the snapshot's as it is, as applying an autosave from before the file had it does function editorSetMapSnapshot(record, snapshot, keepUnknown=false) { const map = record.map; editorResizeMap(map, snapshot.width, snapshot.height); editorCopyData(editorTileLayerData(map.layers), snapshot.layers); editorRestoreObjects(map, snapshot); if (!keepUnknown) for (const group of editorObjectGroups(map.layers).slice(snapshot.objects.length)) group.objects = []; // an Objects layer the editor made since editorClearSelections(); } // the selections go when the map changes size, they are cells and objects of the old one function editorClearSelections() { editorSelection = editorSelectionDrag = editorLastCell = editorHover = undefined; editorObjectSelection.clear(); } // a map changed whole, the undo can put it back; it is autosaved, and the game makes its level again, in the editor function editorMapChanged(record, before) { const stroke = [{resize: record, before, after: editorMapSnapshot(record)}]; editorUndoPush(stroke); editorChanged(stroke); } // resize a map, as one undo; a game without the Restart hook can not make its layers again, so it can not function editorResize(record, width, height) { const map = record?.map; if (!map || record.synthetic || record.pending || !editorHas('onRestart')) return false; if (width === map.width && height === map.height) return false; editorStrokeEnd(); const before = editorMapSnapshot(record); if (!editorResizeMap(map, width, height)) return false; editorClearSelections(); editorMapChanged(record, before); return true; } /////////////////////////////////////////////////////////////////////////////// // cells and undo // the strokes that can be undone and redone, each a list of cells with the gid before and after, and of object // layers with their objects before and after, or a resize with the map before and after const editorUndoList = [], editorRedoList = []; const editorEntryRecord = (entry)=> entry.resize ?? (entry.layer ?? entry.objectLayer).record; let editorStroke; // during a big edit, the layers whose cell by cell redraws are held off, with the redraw each had of its own let editorHeldRedraws; // the live layers a change can only show by drawing them again whole, drawn when it is done const editorRedrawWhole = new Set; // make a big edit, a fill, clear, undo, apply or revert, with each layer's cell by cell redraws held off, a game's // tile callback's included; the stroke's end draws each layer it touched again whole function editorBulkEdit(edit) { const held = editorHeldRedraws = new Map; try { edit(); } finally { editorHeldRedraws = undefined; for (const [live, own] of held) own ? live.redrawTileData = own : delete live.redrawTileData; } } // set a cell to a gid, in the map and on the live layer, with the game's rules, and return the gid it had function editorSetCell(layer, pos, gid) { const {source, live, color} = layer; const index = pos.x + (live.size.y - 1 - pos.y) * live.size.x; const before = source.data[index]; if (before === gid && (!gid || live.destroyed || live.getData(pos).tile !== undefined)) return before; // the same, unless the layer lost the tile in play, a ghost painted with its own tile source.data[index] = gid; if (live.destroyed) return before; // a layer the game let go of, the map still has the change // a plain cell draws itself here; held draws, a game's onTile, which may change the cells around, and its own // drawing over the layer show only when the whole layer draws again, once the change is done if (editorHeldRedraws || editorHas('onTile') || live.onRedraw !== TileLayer.prototype.onRedraw) editorRedrawWhole.add(live); if (editorHeldRedraws && !editorHeldRedraws.has(live)) { editorHeldRedraws.set(live, live.hasOwnProperty('redrawTileData') ? live.redrawTileData : undefined); live.redrawTileData = ()=> {}; } const t = editorGidToTile(gid); live.setData(pos, t ? new TileLayerData(t.tile, t.direction, t.mirror, color) : new TileLayerData, true); if (editorHas('onTile')) levelEditor.onTile(live, pos.copy(), t?.tile); else if (live instanceof TileCollisionLayer && live.isSolid) live.setCollisionData(pos, gid ? 1 : 0); // as tileLayersLoad gives it return before; } // paint a cell as part of the stroke being made function editorPaint(layer, pos, gid) { const before = editorSetCell(layer, pos, gid); if (before === gid) return; (editorStroke ||= []).push({layer, pos: pos.copy(), before, after: gid}); } // an edit goes on the undo list, which keeps the last 100, as the 3D editor's does: a fill of a large layer is // an entry for every cell, and a session of them would hold millions function editorUndoPush(stroke) { editorUndoList.push(stroke); editorUndoList.length > 100 && editorUndoList.shift(); editorRedoList.length = 0; } // the stroke is done, it can be undone as one function editorStrokeEnd() { if (!editorStroke) return; editorUndoPush(editorStroke); editorChanged(editorStroke); editorStroke = undefined; } // undo the stroke being made, with no undo entry, as if it was never made function editorStrokeCancel() { const stroke = editorStroke; editorStroke = undefined; if (!stroke) return; editorBulkEdit(()=> { for (const entry of [...stroke].reverse()) entry.objectLayer ? editorSetObjects(entry.objectLayer, entry.before) : editorSetCell(entry.layer, entry.pos, entry.before); }); editorRedraw(stroke); } // undo the last stroke, or redo the last one undone function editorUndo(redo=false) { editorStrokeEnd(); const stroke = (redo ? editorRedoList : editorUndoList).pop(); if (!stroke) return; (redo ? editorUndoList : editorRedoList).push(stroke); editorBulkEdit(()=> { for (const entry of redo ? stroke : [...stroke].reverse()) { if (entry.resize) editorSetMapSnapshot(entry.resize, redo ? entry.after : entry.before); else if (entry.objectLayer) editorSetObjects(entry.objectLayer, redo ? entry.after : entry.before); else editorSetCell(entry.layer, entry.pos, redo ? entry.after : entry.before); } }); editorChanged(stroke); } // after a change the tile layers it touched that need it draw again whole, and the maps it touched are autosaved; // a resized map is made again by the game, with the editor staying open function editorChanged(stroke) { editorRedraw(stroke); for (const record of new Set(stroke.map(editorEntryRecord))) editorAutosaveSoon(record); stroke.some((entry)=> entry.resize) && editorHas('onRestart') && levelEditor.onRestart(); } // the tile layers a stroke touched that need it draw again whole function editorRedraw(stroke) { for (const layer of new Set(stroke.filter((entry)=> entry.layer).map((entry)=> entry.layer))) editorRedrawWhole.delete(layer.live) && !layer.live.destroyed && layer.live.redraw(); } /////////////////////////////////////////////////////////////////////////////// // objects // the object layers of a map's Tiled layers in order, groups flattened function editorObjectGroups(layers, list=[]) { for (const layer of layers || []) { if (layer.type === 'group') editorObjectGroups(layer.layers, list); else if (layer.type === 'objectgroup') list.push(layer); } return list; } // the editor's object layers of a map, each {record, isObjects, group, name, instances}: group is the Tiled object // layer, undefined for one not made yet, and instances what the game made for each object id; a map with no object // layer gets one to place objects in once the game has added a type, made in the map the first time it is used function editorObjectLayers(record) { if (!record || record.synthetic) return []; record.objectLayers ||= editorObjectGroups(record.map.layers).map((group)=> ({record, isObjects: true, group, name: group.name || 'Objects', instances: new Map})); if (!record.objectLayers.length && objectLayersTypes.size) record.objectLayers.push({record, isObjects: true, group: undefined, name: 'Objects', instances: new Map}); return record.objectLayers; } // a new Tiled object layer named Objects, on top of a map, with an id past every layer's, in groups too function editorNewObjectGroup(map) { const ids = (layers)=> (layers || []).flatMap((layer)=> [(layer.id | 0) + 1, ...ids(layer.layers)]); const id = max(map.nextlayerid ?? 1, ...ids(map.layers)), group = {draworder: 'topdown', id, name: 'Objects', objects: [], opacity: 1, type: 'objectgroup', visible: true, x: 0, y: 0}; map.nextlayerid = id + 1; map.layers.push(group); editorMadeGroups.add(group); return group; } // the Tiled object layer of an editor object layer, made the first time it is needed function editorObjectGroup(layer) { return layer.group ||= editorNewObjectGroup(layer.record.map); } // the next object id of a map, as Tiled counts them, and past every id the map has, for a map with no nextobjectid // or one an autosave left behind function editorNextObjectId(map) { const ids = editorObjectGroups(map.layers).flatMap((group)=> (group.objects ?? []).map((object)=> object.id)); const id = max(map.nextobjectid ?? 1, ...ids.map((id)=> id + 1)); map.nextobjectid = id + 1; return id; } // an object's world position, as objectLayersLoad places it function editorObjectPos(record, object) { const {height=0, tilewidth=1, tileheight=1} = record.map; return vec2(object.x / tilewidth, height - object.y / tileheight); } // set an object's position in the map from a world position, to a millionth of a pixel, so a place turned into // cells and back is written as it was and not a hair off function editorObjectSetPos(record, object, pos) { const {height=0, tilewidth=1, tileheight=1} = record.map; object.x = round(pos.x * tilewidth * 1e6) / 1e6; object.y = round((height - pos.y) * tileheight * 1e6) / 1e6; } // called by objectLayersLoad for each object it read, the editor keeps what the game made by the object's id function editorObjectMade(map, group, object, made) { const record = editorMapList.find((record)=> record.map === map); const layer = editorObjectLayers(record).find((layer)=> layer.group === group); layer && (made ? layer.instances.set(object.id, made) : layer.instances.delete(object.id)); } // a copy of objects as undo and autosave keep them, and an object layer's objects now const editorObjectsCopy = (value)=> JSON.parse(JSON.stringify(value)); const editorObjectList = (layer)=> editorObjectsCopy(layer.group?.objects ?? []); // set an object layer's objects to a list and bring the game's objects in line: one gone is destroyed, a new one // made, a moved one moved, one with changed properties given them; a type made by an arrow function, like a player // start, is called again when its object changes, and a class is not made again for an object the game let go of function editorSetObjects(layer, list) { const {record, instances} = layer, group = editorObjectGroup(layer); const before = new Map(group.objects.map((object)=> [object.id, object])); group.objects = editorObjectsCopy(list); const ids = new Set(group.objects.map((object)=> object.id)); for (const [id, made] of instances) { if (ids.has(id)) continue; made.destroy?.(); instances.delete(id); } for (const object of group.objects) { const old = before.get(object.id), made = instances.get(object.id); const name = object.type || object.class, type = objectLayersTypes.get(name); if (!type || old && editorSameData(old, object)) continue; if (!old || (old.type || old.class) !== name || !type.make.prototype) { // new, of a new type, or a function to call again made?.destroy?.(); const result = objectLayersMake(record.map, object); result ? instances.set(object.id, result) : instances.delete(object.id); } else if (made && !made.destroyed) { // only what the edit changed, so moving an object keeps its health and the rest of its state from play if ('pos' in made && (old.x !== object.x || old.y !== object.y)) made.pos = editorObjectPos(record, object); const was = objectLayersProperties(type, old), now = objectLayersProperties(type, object); for (const name in now) // a method's name is left out, as objectLayersMake leaves it editorSameData(was[name], now[name]) || typeof made[name] === 'function' || (made[name] = now[name]); } } } // change an object layer's objects as part of the stroke being made: change edits a copy of the list; one stroke's // changes to one layer are one undo entry, so a drag is one undo function editorChangeObjects(layer, change) { if (!layer || layer.record.pending) return false; // its autosaved edits wait to be applied or dropped first const before = editorObjectList(layer), after = editorObjectsCopy(before); change(after); if (editorSameData(before, after)) return false; editorSetObjects(layer, after); const last = editorStroke?.[editorStroke.length - 1]; if (last?.objectLayer === layer) last.after = after; else (editorStroke ||= []).push({objectLayer: layer, before, after}); return true; } // set an object's property as Tiled keeps it, only where it differs from its type's default, a color as #AARRGGBB // and a Vector2 as the string x,y function editorObjectSetProperty(object, name, value, defaultValue) { if (Number.isInteger(defaultValue) && isNumber(value)) value = round(value); // an integer stays one, as Tiled keeps an int const list = object.properties ?? [], at = list.findIndex((property)=> property.name === name), old = list[at]; const properties = list.filter((property)=> property.name !== name); const text = (v)=> isColor(v) ? v.toString() : isVector2(v) ? v.x + ',' + v.y : JSON.stringify(v); if (text(value) !== text(defaultValue)) { const hex = isColor(value) && value.toString(); const property = hex ? {name, type: 'color', value: '#' + hex.slice(7, 9) + hex.slice(1, 7)} : isVector2(value) ? {name, type: 'string', value: text(value)} : {name, type: typeof value === 'boolean' ? 'bool' : typeof value === 'string' ? 'string' : Number.isInteger(defaultValue) ? 'int' : 'float', value}; // one the map had keeps its place and its kind where the value fits it, a file stays a file, an object an object if (old?.type === 'file' && property.type === 'string' || old?.type === 'object' && property.type === 'int') property.type = old.type; at < 0 ? properties.push(property) : properties.splice(at, 0, property); } if (properties.length) object.properties = properties; else delete object.properties; } // copy saved objects into a map's object layers, making an Objects layer for any it saved that the map lacks; from // an autosave, which keeps no empty list of a layer the editor made and no Save wrote, an empty one is of a layer a // Save wrote, the file's own, which stays when empty; an undo's snapshot keeps every list, so it does not say that function editorRestoreObjects(map, saved, fromAutosave=false) { const groups = editorObjectGroups(map.layers); (saved.objects ?? []).forEach((objects, i)=> { const group = groups[i] ?? editorNewObjectGroup(map); group.objects = editorObjectsCopy(objects); fromAutosave && !objects.length && editorMadeGroups.delete(group); }); if (saved.nextobjectid > (map.nextobjectid ?? 1)) map.nextobjectid = saved.nextobjectid; } /////////////////////////////////////////////////////////////////////////////// // object editing // the object layer being edited, undefined while a tile layer is; the ids of the objects selected in it; the object // brush and the last objects copied, each a list of {type, properties, offset} placed at the mouse plus the offset; // a left drag moving the selected objects, and a right drag's box let editorObjectLayer, editorObjectSelection = new Set, editorObjectBrush, editorObjectClipboard; let editorObjectDrag, editorObjectBox; // make a layer the one being edited, a tile layer or an object layer; an object layer keeps a tile layer of its map // as editorLayer, for All Layers function editorSelectLayer(layer) { editorStrokeEnd(); levelEditor.tool = undefined; // a tool of the game's own is put down editorLastCell = editorObjectDrag = editorRightPress = editorObjectBox = undefined; // presses stay on their layer editorSelectionDrag = undefined; // a move so far is kept, the stroke ended editorObjectSelection.clear(); if (layer.isObjects) { editorObjectLayer = layer; if (editorLayer?.record !== layer.record) editorLayer = layer.record.layers[0]; // none for a level of objects alone } else { editorObjectLayer = undefined; editorLayer = layer; } } // the size of an object for picking it, what the game made it, or a cell function editorObjectSize(layer, object) { const made = layer.instances.get(object.id); return made && !made.destroyed && isVector2(made.size) ? made.size : vec2(1); } // the object under a world position, the one drawn last first function editorObjectAt(layer, pos) { const objects = layer.group?.objects ?? []; for (let i = objects.length; i--;) { const object = objects[i], center = editorObjectPos(layer.record, object); const size = editorObjectSize(layer, object); if (abs(pos.x - center.x) <= size.x / 2 && abs(pos.y - center.y) <= size.y / 2) return object; } } // the ids of the objects whose positions are inside the box between two world positions function editorObjectsIn(layer, a, b) { const low = vec2(min(a.x, b.x), min(a.y, b.y)), high = vec2(max(a.x, b.x), max(a.y, b.y)); return (layer.group?.objects ?? []).filter((object)=> { const pos = editorObjectPos(layer.record, object); return pos.x >= low.x && pos.y >= low.y && pos.x <= high.x && pos.y <= high.y; }).map((object)=> object.id); } // the brush takes an object's type and properties function editorPickObject(object) { editorObjectBrush = [{type: object.type || object.class, properties: editorObjectsCopy(object.properties ?? []), offset: vec2()}]; } // a new Tiled object for a brush's object, at a world position, with the map's next id: a copy of the object it was // copied from, its name, size, turn and the rest kept, or a point object of the brush's type and properties function editorNewObject(record, {type, properties, object}, pos) { const made = object ? editorObjectsCopy(object) : {id: 0, name: '', type, point: true, rotation: 0, visible: true, width: 0, height: 0, x: 0, y: 0, ...(properties.length && {properties: editorObjectsCopy(properties)})}; made.id = editorNextObjectId(record.map); editorObjectSetPos(record, made, pos); return made; } // place an object brush's objects at a position plus each one's offset, as one undo function editorPlaceObjects(layer, pos, brush) { editorChangeObjects(layer, (list)=> { for (const object of brush) list.push(editorNewObject(layer.record, object, pos.add(object.offset))); }); editorStrokeEnd(); } // the selected objects of the object layer being edited function editorSelectedObjects() { return (editorObjectLayer?.group?.objects ?? []).filter((object)=> editorObjectSelection.has(object.id)); } // remove the selected objects, as one undo function editorDeleteObjects() { if (!editorObjectSelection.size) return false; const selection = editorObjectSelection; editorStrokeEnd(); editorChangeObjects(editorObjectLayer, (list)=> list.splice(0, list.length, ...list.filter((object)=> !selection.has(object.id)))); editorObjectSelection = new Set; editorStrokeEnd(); return true; } // the selected objects into the brush, each a copy with its offset from the lowest corner of their positions, and // the selection cleared so the next click places them function editorCopyObjects() { const objects = editorSelectedObjects(); if (!objects.length) return false; const positions = objects.map((object)=> editorObjectPos(editorObjectLayer.record, object)); const low = vec2(min(...positions.map((p)=> p.x)), min(...positions.map((p)=> p.y))); editorObjectBrush = editorObjectClipboard = objects.map((object, i)=> ({type: object.type || object.class, offset: positions[i].subtract(low), object: editorObjectsCopy(object)})); editorObjectSelection.clear(); return true; } // copy the selected objects, then remove them, as one undo function editorCutObjects() { const selection = new Set(editorObjectSelection); if (!editorCopyObjects()) return false; editorObjectSelection = selection; return editorDeleteObjects(); } // the last copied objects back into the brush function editorPasteObjects() { if (!editorObjectClipboard) return false; editorObjectBrush = editorObjectClipboard; return true; } // the mouse on an object layer: the left places, selects and drags objects, the right picks one or box-selects, // Shift adding and Ctrl taking away function editorUpdateObjects(space) { const layer = editorObjectLayer, record = layer.record, mouse = screenToWorld(editorMouseScreen); const snap = editorSnap; const shift = keyIsDown('ShiftLeft') || keyIsDown('ShiftRight'); const ctrl = keyIsDown('ControlLeft') || keyIsDown('ControlRight') || keyIsDown('MetaLeft') || keyIsDown('MetaRight'); editorHover = undefined; // the right button: a click picks the object under the mouse, or clears the selection, a drag box-selects if (mouseWasPressed(2)) editorRightPress = {screen: mousePosScreen.copy(), world: mouse.copy(), drag: false}; const press = editorRightPress; if (press) { press.drag ||= mousePosScreen.distance(press.screen) > max(4, editorCameraScale / 2); editorObjectBox = press.drag ? {a: press.world, b: mouse} : undefined; if (!mouseIsDown(2)) { const hit = !press.drag && editorObjectAt(layer, mouse); const ids = press.drag ? editorObjectsIn(layer, press.world, mouse) : hit ? [hit.id] : []; if (press.drag || hit && (shift || ctrl)) { shift || ctrl || editorObjectSelection.clear(); for (const id of ids) ctrl ? editorObjectSelection.delete(id) : editorObjectSelection.add(id); } else if (hit) editorPickObject(hit); else editorObjectSelection.clear(); editorRightPress = editorObjectBox = undefined; } return; } // the left button: on an object selects it and starts a drag of the selection, on empty space the first click // with a selection only clears it, otherwise it places the brush if (mouseWasPressed(0) && !space) { const hit = editorObjectAt(layer, mouse); if (hit) { if (!editorObjectSelection.has(hit.id)) editorObjectSelection = new Set([hit.id]); editorObjectDrag = {start: snap(mouse), from: new Map(editorSelectedObjects().map((object)=> [object.id, {x: object.x, y: object.y}]))}; } else if (editorObjectSelection.size) editorObjectSelection.clear(); else if (editorObjectBrush) editorPlaceObjects(layer, snap(mouse), editorObjectBrush); } const drag = editorObjectDrag; if (drag && mouseIsDown(0)) { const delta = snap(mouse).subtract(drag.start); editorChangeObjects(layer, (list)=> { for (const object of list) { // from the numbers it had, in the map's own units: through cells and back they pick up a hair const from = drag.from.get(object.id), {tilewidth=1, tileheight=1} = record.map; if (from) object.x = from.x + delta.x * tilewidth, object.y = from.y - delta.y * tileheight; } }); } if (!mouseIsDown(0)) { editorObjectDrag = undefined; editorStrokeEnd(); } } /////////////////////////////////////////////////////////////////////////////// // editing // every tile layer in the game, as the editor's records, in render order, then the object layers of their maps, and // of the maps the game loaded objects alone from function editorLayers() { const tiles = engineObjects.filter((o)=> o instanceof TileLayer && !o.destroyed) .sort((a, b)=> a.renderOrder - b.renderOrder).map(editorLayerRecord); const records = [...new Set(tiles.map((layer)=> layer.record))]; for (const record of editorMapList) record.synthetic || record.layers.length || records.includes(record) || records.push(record); return [...tiles, ...records.flatMap(editorObjectLayers)]; } // if a map of objects alone has nothing left in the game, the level moved on, a marker that is no game object counts // as nothing const editorObjectsGone = (record)=> !record.layers.length && editorObjectLayers(record).every((layer)=> [...layer.instances.values()].every((made)=> !made.destroy || made.destroyed)); // the cell of a layer at a world position, undefined off the layer function editorCellAt(live, worldPos) { const pos = worldPos.subtract(live.pos).floor(); return pos.arrayCheck(live.size) ? pos : undefined; } // the cell of a layer nearest a world position, clamped onto the layer function editorCellClamped(live, worldPos) { const pos = worldPos.subtract(live.pos).floor(); return vec2(clamp(pos.x, 0, live.size.x - 1), clamp(pos.y, 0, live.size.y - 1)); } // the area between two cells, corners in any order function editorArea(a, b) { return {min: vec2(min(a.x, b.x), min(a.y, b.y)), max: vec2(max(a.x, b.x), max(a.y, b.y))}; } // the tile info a layer draws a tile with, as TileLayer.drawTileData picks it, undefined past the end of its sheet function editorTileInfo(live, tile) { return tileLayerTileInfo(live.tileInfo, tile); } /////////////////////////////////////////////////////////////////////////////// // stamps // the brush is a stamp: its size and, for each layer it paints, a grid of gids from its bottom row up, undefined // see-through; one grid paints the layer being edited, one copied from all layers has a grid for each layer of the // map, in order; the last one copied is kept for Ctrl+V let editorBrush = editorStampTile(editorTileToGid(0)), editorClipboard; // a stamp of one tile, gid 0 is the Erase brush function editorStampTile(gid) { return {width: 1, height: 1, grids: [[gid]]}; } // the gid in a cell of a layer, as the map holds it function editorGidAt(layer, cell) { return layer.source.data[cell.x + (layer.live.size.y - 1 - cell.y) * layer.live.size.x]; } // each layer a stamp paints with its grid: a stamp of one grid paints the layer being edited, one copied from all // layers paints the layers of that layer's map, as many as it has function editorStampTargets(layer, stamp) { return stamp.grids.length > 1 ? layer.record.layers.slice(0, stamp.grids.length).map((target, i)=> [target, stamp.grids[i]]) : [[layer, stamp.grids[0]]]; } // the grid of a stamp that paints a layer function editorStampGrid(layer, stamp) { return stamp.grids.length > 1 ? stamp.grids[layer.record.layers.indexOf(layer)] ?? [] : stamp.grids[0]; } // paint a stamp with its bottom left on a cell, its see-through cells and cells off the layer leave things as they are function editorPaintStamp(layer, pos, stamp=editorBrush) { if (layer.record.pending) return; // its autosaved edits wait to be applied or dropped first for (const [target, grid] of editorStampTargets(layer, stamp)) for (let y = stamp.height; y--;) for (let x = stamp.width; x--;) { const gid = grid[x + y * stamp.width], cell = pos.add(vec2(x, y)); gid === undefined || !cell.arrayCheck(target.live.size) || editorPaint(target, cell, gid); } editorLastPlaced = {layer, pos: pos.copy()}; } // paint a stamp on every cell of the line between two cells function editorPaintBetween(layer, from, to, stamp=editorBrush) { const steps = max(abs(to.x - from.x), abs(to.y - from.y)); for (let i = 0; i <= steps; ++i) editorPaintStamp(layer, from.lerp(to, steps ? i / steps : 1).floor(), stamp); } // paint a stamp along a drag from its last cell, so a fast drag leaves no gaps function editorPaintLine(layer, pos, stamp=editorBrush) { editorPaintBetween(layer, editorLastCell ?? pos, pos, stamp); editorLastCell = pos; } // a gid turned a quarter turn clockwise on screen: Tiled draws direction one that way, flipped diagonally then // horizontally, and tileLayersLoad reads it the same, so a turned stamp looks the same in both function editorGidTurn(gid) { const t = editorGidToTile(gid); return t ? editorTileToGid(t.tile, (t.direction + 1) % 4, t.mirror) : gid; } // a gid mirrored left to right, Tiled's horizontal flip: a mirror over a turn is the mirror under the turn the // other way function editorGidMirror(gid) { const t = editorGidToTile(gid); return t ? editorTileToGid(t.tile, (4 - t.direction) % 4, !t.mirror) : gid; } // a stamp's object turned a quarter clockwise with it: its place by the stamp, its body by its own rotation, which // Tiled turns clockwise about the object's place, so the place needs no other move function editorStampObjectTurn(entry, width) { const offset = vec2(entry.offset.y, width - entry.offset.x), object = entry.object; return {...entry, offset, ...(object && {object: {...object, rotation: ((object.rotation || 0) + 90) % 360}})}; } // a stamp's object mirrored left to right with it: its rotation the other way, a polygon's points mirrored about its // place, a tile object's image flipped, and a box, ellipse, text or tile, placed by a corner, placed by the corner // that becomes it, its width along its turn from the mirrored place; mirrored twice it is as it was function editorStampObjectMirror(entry, width) { const offset = vec2(width - entry.offset.x, entry.offset.y), object = entry.object; if (!object) return {...entry, offset}; const mirrored = {...object, rotation: object.rotation ? -object.rotation : 0}; const points = (list)=> list.map((p)=> ({...p, x: -p.x})); if (object.polygon || object.polyline) { object.polygon && (mirrored.polygon = points(object.polygon)); object.polyline && (mirrored.polyline = points(object.polyline)); } else { // Tiled's pixels run down, the stamp's cells up const angle = (object.rotation || 0) * PI / 180, w = object.width || 0, {x: tw, y: th} = entry.tileSize ?? vec2(1); offset.x -= w * cos(angle) / tw; offset.y -= w * sin(angle) / th; } if (object.gid) mirrored.gid = (object.gid ^ 0x80000000) >>> 0; // Tiled's flip across return {...entry, offset, object: mirrored}; } // a stamp turned a quarter turn clockwise on screen, or back, its cells, their tiles and its objects together; cell // (x, y) goes to (y, width - 1 - x), so the bottom row becomes the left column, its left end at the top function editorStampTurn(stamp, back=false) { for (let turns = back ? 3 : 1; turns--;) { const {width, height} = stamp; const objects = stamp.objects?.map((entry)=> editorStampObjectTurn(entry, width)); stamp = {width: height, height: width, grids: stamp.grids.map((grid)=> { const turned = []; for (let y = height; y--;) for (let x = width; x--;) turned[y + (width - 1 - x) * height] = editorGidTurn(grid[x + y * width]); return turned; }), ...(objects && {objects})}; } return stamp; } // a stamp mirrored left to right, its cells, their tiles and its objects together function editorStampMirror(stamp) { const {width, height} = stamp; const objects = stamp.objects?.map((entry)=> editorStampObjectMirror(entry, width)); return {width, height, grids: stamp.grids.map((grid)=> { const mirrored = []; for (let y = height; y--;) for (let x = width; x--;) mirrored[width - 1 - x + y * width] = editorGidMirror(grid[x + y * width]); return mirrored; }), ...(objects && {objects})}; } // the brush's tile when it is one tile, with its turn and mirror, undefined for the Erase brush or a bigger stamp function editorBrushTile() { const {width, height, grids} = editorBrush; return width === 1 && height === 1 && grids.length === 1 ? editorGidToTile(grids[0][0]) : undefined; } // the brush takes the tile in a cell, an empty cell gives the Erase brush function editorPick(layer, pos) { editorBrush = editorStampTile(editorGidAt(layer, pos) || 0); } // a palette slot into the brush: slot 0 is the Erase brush, slot n the palette's nth tile, keeping a one tile // brush's turn; on an object layer slot n is the nth object type function editorPalettePick(slot) { if (editorObjectLayer) { // on an object layer the palette is the types the game added, in order const type = [...objectLayersTypes.keys()][slot]; type && (editorObjectBrush = [{type, properties: [], offset: vec2()}]); return; } const t = editorBrushTile(); const tile = slot && (editorPaletteTiles(editorLayer)[slot - 1]?.tile ?? slot - 1); // every tile, in order editorBrush = editorStampTile(slot ? editorTileToGid(tile, t?.direction, t?.mirror) : 0); } /////////////////////////////////////////////////////////////////////////////// // selection // the layers the selection acts on, the one being edited, or all of its map's with All Layers on function editorSelectionLayers() { return !editorLayer ? [] : editorAllLayers ? editorLayer.record.layers : [editorLayer]; } // call back for each selected cell that is on a layer function editorSelectionCells(live, callback) { const {min: a, max: b} = editorSelection; for (let y = a.y; y <= b.y; ++y) for (let x = a.x; x <= b.x; ++x) { const cell = vec2(x, y); cell.arrayCheck(live.size) && callback(cell); } } // fill with the brush: the selection when there is one, or the cells joined to the one under the mouse that // match it exactly, on the layer being edited; a stamp repeats across it, as one undo function editorFill() { const layer = editorLayer, stamp = editorBrush, {width: w, height: h} = stamp; if (!layer || layer.live.destroyed || layer.record.pending) return; editorStrokeEnd(); // a drag held while F is pressed is an undo of its own const paint = (target, grid, cell, anchor)=> { const x = ((cell.x - anchor.x) % w + w) % w, y = ((cell.y - anchor.y) % h + h) % h; const gid = grid[x + y * w]; gid === undefined || editorPaint(target, cell, gid); }; editorBulkEdit(()=> { if (editorSelection) { for (const [target, grid] of editorStampTargets(layer, stamp)) editorSelectionCells(target.live, (cell)=> paint(target, grid, cell, editorSelection.min)); } else if (editorHover) { const grid = editorStampGrid(layer, stamp), start = editorHover; for (const cell of editorFloodCells(layer, start)) paint(layer, grid, cell, start); } }); editorStrokeEnd(); } // the cells joined through their sides to a cell, holding the same gid, turn and mirror included function editorFloodCells(layer, start) { // cells by index, x + y * width, each looked at once const {x: width, y: height} = layer.live.size, gid = editorGidAt(layer, start); const seen = new Uint8Array(width * height), cells = [], open = [start.x + start.y * width]; while (open.length) { const i = open.pop(); if (seen[i]) continue; seen[i] = 1; const x = i % width, y = i / width | 0, cell = vec2(x, y); if (editorGidAt(layer, cell) !== gid) continue; cells.push(cell); x + 1 < width && open.push(i + 1); x && open.push(i - 1); y + 1 < height && open.push(i + width); y && open.push(i - width); } return cells; } // clear the selected area, with All Layers the objects in it too, as one undo function editorClear() { if (!editorSelection) return; editorStrokeEnd(); // a drag held while Delete is pressed is an undo of its own editorBulkEdit(()=> { for (const layer of editorSelectionLayers()) layer.record.pending || editorSelectionCells(layer.live, (cell)=> editorPaint(layer, cell, 0)); if (editorAllLayers && editorLayer) { const corner = editorLayer.live.pos.add(editorSelection.min); const far = editorLayer.live.pos.add(editorSelection.max).add(vec2(1)); for (const objectLayer of editorObjectLayers(editorLayer.record)) { const ids = new Set(editorObjectsIn(objectLayer, corner, far)); ids.size && editorChangeObjects(objectLayer, (list)=> list.splice(0, list.length, ...list.filter((object)=> !ids.has(object.id)))); } } }); editorStrokeEnd(); } // the objects of a map's object layers inside a tile area, each {group, type, offset, object, tileSize}: the index of // its object layer, its offset from the area's bottom left corner, a copy of it, and the map's tile size in pixels, // which a mirror reads its width in cells by, as a stamp keeps them function editorAreaObjects(layer, area) { const corner = layer.live.pos.add(area.min), far = layer.live.pos.add(area.max).add(vec2(1)); return editorObjectLayers(layer.record).flatMap((objectLayer, group)=> { const ids = new Set(editorObjectsIn(objectLayer, corner, far)); return (objectLayer.group?.objects ?? []).filter((object)=> ids.has(object.id)).map((object)=> ({group, type: object.type || object.class, offset: editorObjectPos(layer.record, object).subtract(corner), object: editorObjectsCopy(object), tileSize: vec2(layer.record.map.tilewidth || 1, layer.record.map.tileheight || 1)})); }); } // place a stamp's objects with the stamp's bottom left on a cell of a tile layer, into its map's object layers function editorPlaceStampObjects(layer, cell, stamp) { const targets = editorObjectLayers(layer.record), corner = layer.live.pos.add(cell); targets.forEach((target, group)=> { const objects = (stamp.objects ?? []).filter((object)=> (targets[object.group] ? object.group : 0) === group); objects.length && editorChangeObjects(target, (list)=> { for (const object of objects) list.push(editorNewObject(layer.record, object, corner.add(object.offset))); }); }); } // the tiles of an area on the selection layers as a stamp, its empty cells see-through function editorSelectionStamp(area) { const width = area.max.x - area.min.x + 1, height = area.max.y - area.min.y + 1; const grids = editorSelectionLayers().map((layer)=> { const grid = []; for (let y = height; y--;) for (let x = width; x--;) { const cell = area.min.add(vec2(x, y)); grid[x + y * width] = cell.arrayCheck(layer.live.size) && editorGidAt(layer, cell) || undefined; } return grid; }); return {width, height, grids}; } // the selected area into the brush as a stamp, its empty cells see-through, with All Layers the objects in it too, // and the selection cleared so the next click paints it function editorCopy() { const area = editorSelection; if (!area || !editorLayer) return false; const stamp = editorSelectionStamp(area), objects = editorAllLayers ? editorAreaObjects(editorLayer, area) : []; if (!objects.length && stamp.grids.every((grid)=> grid.every((gid)=> gid === undefined))) return false; // nothing there, the brush stays as it was editorBrush = editorClipboard = objects.length ? {...stamp, objects} : stamp; editorSelection = undefined; return true; } // start dragging the selection from a cell in it, with All Layers the objects in it go too function editorSelectionDragStart(layer, cell) { editorStrokeEnd(); const area = editorSelection, objects = []; if (editorAllLayers) { const corner = layer.live.pos.add(area.min), far = layer.live.pos.add(area.max).add(vec2(1)); for (const objectLayer of editorObjectLayers(layer.record)) { const ids = new Set(editorObjectsIn(objectLayer, corner, far)); ids.size && objects.push({objectLayer, ids}); } } editorSelectionDrag = {layer, start: cell, delta: vec2(), area, stamp: editorSelectionStamp(area), objects}; } // move the dragged selection to a cell, as the stroke being made: taken back, then its area cleared and its tiles // and objects put down that far from where they were; letting go ends the stroke, one undo function editorSelectionDragTo(cell) { const drag = editorSelectionDrag, delta = cell.subtract(drag.start), {layer, area, stamp} = drag; if (delta.x === drag.delta.x && delta.y === drag.delta.y) return; drag.delta = delta; editorStrokeCancel(); editorSelection = {min: area.min.add(delta), max: area.max.add(delta)}; if (!delta.x && !delta.y) return; // back where it was, no change editorBulkEdit(()=> { for (const [target] of editorStampTargets(layer, stamp)) for (let y = area.min.y; y <= area.max.y; ++y) for (let x = area.min.x; x <= area.max.x; ++x) { const from = vec2(x, y); from.arrayCheck(target.live.size) && editorPaint(target, from, 0); } editorPaintStamp(layer, editorSelection.min, stamp); // whole cells added to the places as written, as an object drag does, so they come back to the digit const {tilewidth=1, tileheight=1} = layer.record.map; for (const {objectLayer, ids} of drag.objects) editorChangeObjects(objectLayer, (list)=> { for (const object of list) ids.has(object.id) && (object.x += delta.x * tilewidth, object.y -= delta.y * tileheight); }); }); editorStroke && editorRedraw(editorStroke); } // put the dragged selection back where it was, with nothing to undo function editorSelectionDragCancel() { editorStrokeCancel(); editorSelection = editorSelectionDrag.area; editorSelectionDrag = undefined; } // copy the selected area, then clear it, as one undo function editorCut() { const area = editorSelection; if (!editorCopy()) return false; editorSelection = area; editorClear(); editorSelection = undefined; return true; } // the last copied stamp back into the brush function editorPaste() { if (!editorClipboard) return false; editorBrush = editorClipboard; return true; } /////////////////////////////////////////////////////////////////////////////// // panel let editorPanel, editorPanelParts; // the list of keys ? shows const editorHelpLines = [ 'Left: paint · Shift+Left: line from the last tile', 'Right click: pick a tile · Right drag: select', 'Left drag a selection: move it · Esc or right click: put it back', 'Middle drag or Space+drag: pan · Wheel or pinch: zoom', '1-9: layer · Esc: play and edit · 0: exit the editor', 'F: fill · Delete: clear the selection', 'Ctrl+C / X / V: copy, cut, paste · Ctrl+Z / Y: undo, redo', 'R, Shift+R: turn · M: mirror · E: erase · G: grid · ?: keys', 'Objects layer: left places or selects, drag moves, right picks or box-selects, Delete removes, Ctrl+C / X / V', ]; // the hint line, for what is held and whether there is a selection function editorHint() { if (editorRecord()?.pending) return EDITOR_PENDING_HINT; if (keyIsDown('Space')) return 'Drag to pan'; if (editorGameTool()) return editorGameTool().hint || levelEditor.tool; if (editorObjectLayer) return editorObjectSelection.size ? 'Selection: drag moves · Delete removes · Ctrl+C copy · Ctrl+X cut · click to clear' : 'Left place / select · drag moves · Right pick / drag select · Delete removes'; if (editorSelection) return 'Selection: drag moves · F fill · Delete clear · Ctrl+C copy · Ctrl+X cut · click to clear'; if (keyIsDown('ShiftLeft') || keyIsDown('ShiftRight')) return 'Shift: line from the last tile'; return 'Left paint · Right pick / drag select · F fill · Space or middle drag pans · ? keys'; } // edits waiting in the panel's box for Apply or Drop: an edit of their level is refused till then, and a press on // the level says why, the box flashing and a line at the mouse for a moment; both editors use these const EDITOR_PENDING_HINT = 'The level file changed: apply your edits or drop them'; let editorPendingNudgeTime; function editorPendingNudge() { editorPendingNudgeTime = timeReal; } const editorPendingNudgeAge = ()=> editorPendingNudgeTime === undefined ? Infinity : timeReal - editorPendingNudgeTime; // the panel's box, flashing brighter after a refused press function editorPendingFlash(element) { const age = editorPendingNudgeAge(), color = age < 1.5 && (age * 4 | 0) % 2 == 0 ? '#c60' : '#630'; element.style.background === color || (element.style.background = color); } // the line at the mouse after a refused press function editorPendingNote() { editorPendingNudgeAge() < 2 && drawTextScreen('Apply or drop your autosaved edits in the panel first', mousePosScreen.add(vec2(0, -30)), 18, hsl(.1, 1, .7), 4, BLACK); } // what the brush is, in words function editorBrushLabel() { if (editorObjectLayer) { const brush = editorObjectBrush; return !brush ? 'Brush: none' : brush.length > 1 ? `Brush: ${brush.length} objects` : `Brush: ${brush[0].type}`; } const {width, height, grids} = editorBrush, t = editorBrushTile(); if (t) return `Brush: tile ${t.tile}` + (t.direction ? `, turned ${t.direction * 90}°` : '') + (t.mirror ? ', mirrored' : ''); if (width === 1 && height === 1 && grids.length === 1) return grids[0][0] === 0 ? 'Brush: Erase' : 'Brush: empty'; const objects = editorBrush.objects?.length; return `Brush: ${width}x${height} stamp` + (grids.length > 1 ? `, ${grids.length} layers` : '') + (objects ? `, ${objects} object${objects > 1 ? 's' : ''}` : ''); } function editorElement(tag, parent, style='', text='') { const element = document.createElement(tag); element.style.cssText = style; element.textContent = text; parent?.appendChild(element); return element; } // the panel, made the first time the editor opens, on the left so it can sit beside the tweakables panel function editorPanelInit() { editorPanel = editorElement('div', document.body, 'position:fixed;top:8px;left:8px;width:260px;max-height:calc(100% - 16px);overflow-y:auto;' + 'box-sizing:border-box;padding:8px;background:#111d;color:#eee;font:12px monospace;' + 'border-radius:4px;z-index:9999;user-select:none'); // a click or touch on the panel is not the game's, a mouse up still goes on so a drag can let go for (const type of ['mousedown','wheel','touchstart','touchmove','touchend','touchcancel']) editorPanel.addEventListener(type, (e)=> e.stopPropagation()); editorPanel.addEventListener('mouseenter', ()=> editorMouseOnPanel = true); editorPanel.addEventListener('mouseleave', ()=> editorMouseOnPanel = false); const row = ()=> editorElement('div', editorPanel, 'display:flex;gap:4px;margin:4px 0'); const button = (parent, text, onclick, title='')=> { const b = editorElement('button', parent, 'flex:1;padding:3px;cursor:pointer', text); b.onclick = (e)=> { onclick(e); b.blur(); }; // the keys go back to the editor b.title = title; return b; }; editorElement('div', editorPanel, 'font-weight:bold', 'Level Editor'); const top = row(); button(top, 'Play', ()=> editorPlay(editorCameraPos), 'Esc, and Esc again comes back to the editor'); const restart = button(top, 'Restart', editorRestart, 'Rebuild the level and play it'); button(top, 'Exit', ()=> levelEditor.close(), '0, then Esc opens the debug overlay again'); const undo = row(); button(undo, 'Undo', ()=> editorUndo(), 'Ctrl+Z'); button(undo, 'Redo', ()=> editorUndo(true), 'Ctrl+Y'); button(undo, 'Keys', ()=> editorHelp = !editorHelp, 'Every control, ?'); // the game's own tools, buttons and controls, near the top where the tools are const game = editorElement('div', editorPanel); // a file that changed under its autosave const pending = editorElement('div', editorPanel, 'padding:4px;margin:4px 0;background:#630;border-radius:3px'); editorElement('div', pending, '', 'The level file changed since your autosaved edits'); const pendingRow = editorElement('div', pending, 'display:flex;gap:4px;margin-top:4px'); button(pendingRow, 'Apply edits', ()=> editorApplyPending(editorRecord())); button(pendingRow, 'Drop them', ()=> editorDiscardPending(editorRecord())); const pendingUnfit = editorElement('div', pending, 'color:#fb8;margin-top:4px', 'They do not fit the file now, its layers or size changed; they are kept until dropped'); // the layers as numbered buttons, made again when layers come or go, and All Layers const layerRow = editorElement('div', editorPanel, 'display:flex;gap:4px;margin:4px 0;flex-wrap:wrap'); const allLayers = editorElement('button', editorPanel, 'width:100%;padding:3px;cursor:pointer;margin-bottom:4px'); allLayers.title = 'Delete, Ctrl+C and Ctrl+X act on every layer of the map'; allLayers.onclick = ()=> { editorAllLayers = !editorAllLayers; allLayers.blur(); }; const turns = row(); button(turns, 'Turn', ()=> editorBrush = editorStampTurn(editorBrush), 'R, Shift+R turns back'); button(turns, 'Mirror', ()=> editorBrush = editorStampMirror(editorBrush), 'M'); // the layer's tiles in a grid, a click picks one const palette = editorElement('canvas', editorPanel, 'display:block;background:#333;cursor:crosshair;margin:4px 0'); palette.onclick = (e)=> { const cell = editorPaletteCell, slot = (e.offsetY / cell | 0) * editorPaletteColumns + (e.offsetX / cell | 0); const slots = editorObjectLayer ? objectLayersTypes.size : editorPaletteTiles(editorLayer).length + 1; slot < slots && e.offsetX < editorPaletteColumns * cell && editorPalettePick(slot); }; const brush = editorElement('div', editorPanel, 'color:#ccc;margin:2px 0'); const properties = editorElement('div', editorPanel, 'margin:4px 0;padding:4px;background:#222;border-radius:3px'); const file = row(); // Save says so for a moment when it saved, Save As shows where a page can write files const saved = (b, label)=> (result)=> result && (b.textContent = 'Saved', setTimeout(()=> b.textContent = label, 1e3)); const save = button(file, 'Save', ()=> editorSave(editorRecord()).then(saved(save, 'Save')), 'Save, to the file picked the first time, or a download'); const saveAs = button(file, 'Save As', ()=> editorSave(editorRecord(), true).then(saved(saveAs, 'Save As')), 'Save As, to a file picked again'); /** @type {any} */ (globalThis).showSaveFilePicker || (saveAs.style.display = 'none'); // the Advanced section, shown by its heading: Play from mouse, the level's size, Reset to file const advancedToggle = editorElement('button', editorPanel, 'width:100%;padding:3px;cursor:pointer;margin-top:4px;text-align:left'); advancedToggle.onclick = ()=> { editorAdvanced = !editorAdvanced; advancedToggle.blur(); }; const advanced = editorElement('div', editorPanel, 'margin:4px 0;padding:4px;background:#222;border-radius:3px'); const playFromLabel = editorElement('label', advanced, 'display:flex;gap:6px;align-items:center;margin:2px 0'); playFromLabel.title = 'Escape starts play with the player at the mouse, Play at the view center'; const playFrom = editorElement('input', playFromLabel); playFrom.type = 'checkbox'; playFrom.onchange = ()=> { editorPlayFromMouse = playFrom.checked; playFrom.blur(); }; editorElement('span', playFromLabel, '', 'Play from mouse'); const sizeRow = editorElement('div', advanced, 'display:flex;gap:4px;align-items:center;margin:4px 0'); sizeRow.title = 'The level grows or shrinks at the right and top, can be undone'; editorElement('span', sizeRow, '', 'Size'); const sizeInput = ()=> { const input = editorElement('input', sizeRow, 'width:48px;background:#333;color:#eee'); input.type = 'number'; input.min = input.step = '1'; input.onkeydown = (e)=> { e.key === 'Enter' && resizeLevel(); }; // returning false would block typing return input; }; const sizeX = sizeInput(); editorElement('span', sizeRow, '', '×'); const sizeY = sizeInput(); const resize = editorElement('button', sizeRow, 'flex:1;padding:3px;cursor:pointer', 'Resize'); const resizeLevel = ()=> { // up to 1000 a side from here, editorResize takes any size from code const size = (input)=> clamp(parseInt(input.value) || 1, 1, 1e3); editorResize(editorRecord(), size(sizeX), size(sizeY)); resize.blur(); sizeX.blur(); sizeY.blur(); // the fields show the size again }; resize.onclick = resizeLevel; const reset = editorElement('button', advanced, 'width:100%;padding:3px;cursor:pointer;margin-top:4px', 'Reset to file'); reset.title = 'Put the level back to the file it was loaded from, or last saved to, can be undone'; reset.onclick = ()=> { editorRevert(editorRecord()); reset.blur(); }; const storage = editorElement('div', editorPanel, 'color:#f86;margin-top:4px', 'Autosave failed, storage is full: Save to a file'); const status = editorElement('div', editorPanel, 'color:#aaa;margin-top:4px;min-height:1em'); const hint = editorElement('div', editorPanel, 'color:#8ab;margin-top:4px'); const help = editorElement('div', editorPanel, 'color:#aaa;margin-top:4px;border-top:1px solid #444;padding-top:4px'); for (const line of editorHelpLines) editorElement('div', help, 'margin:2px 0', line); const gameHelp = editorElement('div', help); button(help, 'Close', ()=> editorHelp = false, '?'); editorPanelParts = {game, gameHelp, pendingUnfit, reset, advancedToggle, advanced, playFromLabel, playFrom, sizeRow, sizeX, sizeY, resize, restart, pending, layerRow, allLayers, turns, palette, brush, properties, status, storage, hint, help, layers: undefined}; } // the palette's cell size in pixels and how many to a row const editorPaletteCell = 30, editorPaletteColumns = 8; // the tiles a layer's palette shows, each its index and the tile info it draws with: the game's // levelEditor.paletteTiles, or every tile up to the image's edge without the blank ones at the end, a tile of one // flat color; found once for each layer and list function editorPaletteTiles(layer) { const live = layer?.live, image = live?.tileInfo?.textureInfo?.image, list = levelEditor.paletteTiles; if (list && live) { if (layer.palette?.list !== list || layer.palette.count !== live.tileInfo?.tiles?.length) layer.palette = {list, count: live.tileInfo?.tiles?.length, // a tile set fills in as it loads tiles: list.map((tile)=> ({tile, tileInfo: editorTileInfo(live, tile)})) .filter((t)=> t.tileInfo || !live.tileInfo)}; // a tile set, or a sheet, has only so many return layer.palette.tiles; } // a tile set lists its tiles, each a tile the palette offers const set = live?.tileInfo?.tiles; if (set) { if (layer.palette?.set !== set || layer.palette.tiles.length !== set.length) layer.palette = {set, tiles: set.map((tileInfo, tile)=> ({tile, tileInfo}))}; return layer.palette.tiles; } if (!image) return []; if (layer.palette?.image === image) return layer.palette.tiles; // a sheet read by its columns, a tileset with a margin or a spacing, is read with frame(), which asserts the // frame is inside the image, so a row past the image's end is found before its frame is made const tiles = [], base = live.tileInfo, columns = base.columns; for (let i = 0; i < 4096; ++i) { if (columns && base.pos.y + (i / columns | 0) * (base.size.y + base.padding*2) + base.size.y > image.height) break; const t = editorTileInfo(live, i); if (t.pos.x + t.size.x > image.width || t.pos.y + t.size.y > image.height) break; tiles.push({tile: i, tileInfo: t}); } try { // an image from a file page can not be read back, then every tile is kept const context = createCanvasContext(image.width, image.height, true); context.drawImage(image, 0, 0); const blank = (t)=> { const {pos, size} = t.tileInfo, pixels = new Uint32Array(context.getImageData(pos.x, pos.y, size.x, size.y).data.buffer); return pixels.every((p)=> p === pixels[0]); }; while (tiles.length > 1 && blank(tiles[tiles.length - 1])) tiles.pop(); } catch {} layer.palette = {image, tiles}; return tiles; } // draw the palette of object types, each its icon or the start of its name, the brush's type outlined function editorPaletteDrawObjects(canvas) { const cell = editorPaletteCell, columns = editorPaletteColumns, types = [...objectLayersTypes]; canvas.style.display = types.length ? '' : 'none'; canvas.width = columns * cell; canvas.height = ceil(types.length / columns) * cell; const context = canvas.getContext('2d'); context.imageSmoothingEnabled = false; context.font = '10px monospace'; context.textAlign = 'center'; context.textBaseline = 'middle'; types.forEach(([name, {tileInfo}], i)=> { const x = i % columns * cell, y = (i / columns | 0) * cell, image = tileInfo?.textureInfo?.image; if (image) context.drawImage(image, tileInfo.pos.x, tileInfo.pos.y, tileInfo.size.x, tileInfo.size.y, x + 2, y + 2, cell - 4, cell - 4); else { context.fillStyle = '#ccc'; const label = textGraphemes(name).slice(0, 4).join(''); // whole characters, never half an emoji context.fillText(label, x + cell / 2, y + cell / 2); } if (editorObjectBrush?.length !== 1 || editorObjectBrush[0].type !== name) return; context.strokeStyle = '#4af'; context.lineWidth = 2; context.strokeRect(x + 1, y + 1, cell - 2, cell - 2); }); } // draw the palette again, for a new layer or brush tile function editorPaletteDraw(canvas, layer) { const tiles = editorPaletteTiles(layer), cell = editorPaletteCell, columns = editorPaletteColumns; const slots = tiles.length + 1; canvas.style.display = tiles.length ? '' : 'none'; canvas.width = columns * cell; canvas.height = ceil(slots / columns) * cell; const context = canvas.getContext('2d'); context.imageSmoothingEnabled = false; // slot 0 is the Erase brush, a red cross, the tiles follow context.strokeStyle = '#e44'; context.lineWidth = 2; context.strokeRect(4, 4, cell - 8, cell - 8); context.beginPath(); context.moveTo(8, 8), context.lineTo(cell - 8, cell - 8); context.moveTo(cell - 8, 8), context.lineTo(8, cell - 8); context.stroke(); tiles.forEach(({tileInfo}, i)=> { if (!tileInfo) return; // headless, a layer with no sheet const {pos, size, textureInfo} = tileInfo; // each from its own sheet, a tile set's may be on several const slot = i + 1, x = slot % columns * cell, y = (slot / columns | 0) * cell, image = textureInfo?.image; image && context.drawImage(image, pos.x, pos.y, size.x, size.y, x + 2, y + 2, cell - 4, cell - 4); }); // the brush's slot outlined, when it is one tile the palette shows or Erase const t = editorBrushTile(), erase = !t && editorBrush.width === 1 && editorBrush.height === 1 && editorBrush.grids[0][0] === 0, shown = t ? tiles.findIndex((p)=> p.tile === t.tile) : -1; const selected = erase ? 0 : shown >= 0 ? shown + 1 : -1; if (selected < 0) return; context.strokeStyle = '#4af'; context.strokeRect(selected % columns * cell + 1, (selected / columns | 0) * cell + 1, cell - 2, cell - 2); } // while playing in an editing session, a tag in the corner says how to get back to the editor let editorTag; function editorTagUpdate() { const show = editorSession && !editorIsOpen; if (!show && !editorTag) return; editorTag ||= editorElement('div', document.body, 'position:fixed;bottom:8px;left:8px;padding:4px 8px;' + 'background:#111d;color:#eee;font:12px monospace;border-radius:4px;z-index:9999;pointer-events:none'); editorTag.textContent = `${debugKey === 'Escape' ? 'Esc' : debugKey}: edit level`; editorTag.style.display = show ? '' : 'none'; } // shows or hides the panel, and shows what changed since the last frame function editorPanelUpdate() { editorTagUpdate(); if (!editorIsOpen) { editorPanel && (editorPanel.style.display = 'none'); return; } editorPanel || editorPanelInit(); editorPanel.style.display = ''; const p = editorPanelParts, layers = editorLayers(); // the layer buttons, made again when layers came or went if (!p.layers || layers.length !== p.layers.length || layers.some((layer, i)=> layer !== p.layers[i])) { p.layers = layers; p.layerRow.replaceChildren(...layers.map((layer, i)=> { // its number key and name, cut short when the row runs out of room, the whole name in its tooltip const name = layer.isObjects ? layer.name : layer.source.name; const b = editorElement('button', undefined, 'flex:1;min-width:28px;padding:3px;cursor:pointer;' + 'white-space:nowrap;overflow:hidden;text-overflow:ellipsis', (i < 9 ? String(i + 1) : '·') + (name ? ' ' + name : '')); b.title = `${name || 'Layer ' + (i + 1)} (${layer.record.fileName})`; b.onclick = ()=> { editorSelectLayer(layer); b.blur(); }; return b; })); } layers.forEach((layer, i)=> p.layerRow.children[i].style.outline = layer === (editorObjectLayer ?? editorLayer) ? '2px solid #4af' : ''); p.allLayers.textContent = editorAllLayers ? 'All Layers: on' : 'All Layers: off'; p.brush.textContent = editorBrushLabel(); p.hint.textContent = editorHint(); p.help.style.display = editorHelp ? '' : 'none'; p.storage.style.display = editorSaveFailed ? '' : 'none'; p.restart.style.display = editorHas('onRestart') ? '' : 'none'; p.advancedToggle.textContent = editorAdvanced ? 'Advanced ▾' : 'Advanced ▸'; p.advanced.style.display = editorAdvanced ? '' : 'none'; p.playFromLabel.style.display = editorHas('onPlayFrom') ? 'flex' : 'none'; p.playFrom.checked = editorPlayFromMouse; // the level's size, shown until a field is being typed in; without the Restart hook it can not change const record = editorRecord(), map = record?.map; const canResize = editorHas('onRestart') && !record?.synthetic && !record?.pending; p.sizeRow.style.display = map ? 'flex' : 'none'; if (map && !p.sizeRow.contains(document.activeElement)) { p.sizeX.value = String(map.width); p.sizeY.value = String(map.height); } p.sizeX.disabled = p.sizeY.disabled = p.resize.disabled = !canResize; p.resize.title = canResize ? '' : 'Resizing needs levelEditor.onRestart, to make the level again'; p.turns.style.display = editorObjectLayer ? 'none' : ''; // the tile brush's, not an object layer's p.pending.style.display = record?.pending ? '' : 'none'; record?.pending && editorPendingFlash(p.pending); p.pendingUnfit.style.display = record?.pendingUnfit ? '' : 'none'; p.reset.style.display = record?.synthetic ? 'none' : ''; // a layer made in code has no file // the palette, drawn again when the layer or the brush changed, a change makes a new brush const live = editorLayer?.live; const paletteLayer = editorObjectLayer ?? editorLayer, paletteBrush = editorObjectLayer ? editorObjectBrush : editorBrush; if (p.paletteLayer !== paletteLayer || p.paletteBrush !== paletteBrush) { p.paletteLayer = paletteLayer; p.paletteBrush = paletteBrush; editorObjectLayer ? editorPaletteDrawObjects(p.palette) : editorPaletteDraw(p.palette, editorLayer); } editorPropertiesUpdate(p.properties); const t = editorHover && editorGidToTile(editorLayer.source.data[editorHover.x + (live.size.y - 1 - editorHover.y) * live.size.x]); p.status.textContent = editorHover ? `cell ${editorHover.x}, ${editorHover.y}` + (t ? ` tile ${t.tile}` : '') : ''; } /////////////////////////////////////////////////////////////////////////////// // keys and wheel // letter shortcuts, by the key's label so they follow the letter printed on any keyboard layout, each called // with whether Shift is held; one that returns false did nothing, and leaves the key to the browser const editorKeys = { r: (shift)=> editorObjectLayer ? false : editorBrush = editorStampTurn(editorBrush, shift), m: ()=> editorObjectLayer ? false : editorBrush = editorStampMirror(editorBrush), e: ()=> editorObjectLayer ? false : editorBrush = editorStampTile(0), g: ()=> { editorGrid = !editorGrid; }, '?': ()=> { editorHelp = !editorHelp; }, f: ()=> editorObjectLayer ? false : editorFill(), Delete: ()=> editorObjectLayer ? editorDeleteObjects() : editorClear(), Backspace: ()=> editorObjectLayer ? editorDeleteObjects() : editorClear(), }; const editorCtrlKeys = { z: (shift)=> editorUndo(shift), y: ()=> editorUndo(true), c: ()=> editorObjectLayer ? editorCopyObjects() : editorCopy(), x: ()=> editorObjectLayer ? editorCutObjects() : editorCut(), v: ()=> editorObjectLayer ? editorPasteObjects() : editorPaste(), }; // a field that takes typed keys, as the engine counts one: a checkbox, slider or button left with focus, as a // tweak is, does not take them function editorIsTextField(target) { const field = target?.closest?.('input,textarea,select,[contenteditable]'); return !!field && !(field.tagName === 'INPUT' && /^(button|checkbox|color|file|image|radio|range|reset|submit)$/i.test(field.type)); } // the editor's own key listener, a key typed into a field is the field's, and Alt with Ctrl is AltGr typing // a character; a letter that is not a Latin one, on a Cyrillic or Greek keyboard, goes by the key's position function editorOnKeyDown(e) { if (!editorIsOpen || e.repeat || e.altKey || editorIsTextField(e.target)) return; if (editorToolHeld) return; // the keys wait for a tool of the game's own to let go if (editorSelectionDrag && !(debugKey && e.code === debugKey)) { // a key ends a selection drag where it is, an undo of its own, so Delete or undo acts on what is there; // the debug key, Escape, is left to the update, which puts the selection back editorStrokeEnd(); editorSelectionDrag = undefined; } let key = e.key === ' ' ? 'Space' : e.key.length === 1 ? e.key.toLowerCase() : e.key; if (key.length === 1 && !/[a-z?]/.test(key) && !levelEditor.keys[key]) key = e.code?.match(/^Key([A-Z])$/)?.[1].toLowerCase() ?? key; // a key of the game's own first, it takes the place of one of the editor's const ctrl = e.ctrlKey || e.metaKey, own = levelEditor.keys[(ctrl ? 'ctrl+' : '') + key]; const action = own ? (shift)=> own.action.call(levelEditor, shift) : (ctrl ? editorCtrlKeys : editorKeys)[key]; if (action && action(e.shiftKey) !== false) e.preventDefault(); } // the wheel zooms toward the mouse by how far it moved; a trackpad pinch is a wheel with ctrlKey, which the engine // leaves alone function editorOnWheel(e) { if (!editorIsOpen || editorIsTextField(e.target)) return; const pixels = e.deltaY * (e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? 400 : 1); editorZoom(Math.exp(-pixels * (e.ctrlKey ? .01 : .002))); } // zoom by a factor, keeping the point under the mouse where it is function editorZoom(factor) { editorApplyCamera(); const before = screenToWorld(mousePosScreen); editorCameraScale = clamp(editorCameraScale * factor, 1, 1e3); editorApplyCamera(); editorCameraPos = editorCameraPos.add(before.subtract(screenToWorld(mousePosScreen))); editorApplyCamera(); } /////////////////////////////////////////////////////////////////////////////// // objects in the panel and the level // the position a click places or drags to, a cell center unless the grid is off function editorSnap(pos) { return editorGrid ? pos.floor().add(vec2(.5)) : pos.copy(); } // if the properties box has an input for a default's type, a number, boolean, string or Color const editorPropertyEditable = (value)=> ['number', 'boolean', 'string'].includes(typeof value) || isColor(value) || isVector2(value); // set a property of the one selected object, from the properties box, as one undo function editorSetSelectedProperty(name, value) { const selected = editorSelectedObjects(), object = selected[0]; const type = object && objectLayersTypes.get(object.type || object.class), defaultValue = type?.defaults[name]; if (selected.length !== 1 || !editorPropertyEditable(defaultValue) || (isColor(defaultValue) ? !isColor(value) : isVector2(defaultValue) ? !isVector2(value) : typeof value !== typeof defaultValue)) return false; editorStrokeEnd(); editorChangeObjects(editorObjectLayer, (list)=> editorObjectSetProperty(list.find((o)=> o.id === object.id), name, value, type.defaults[name])); editorStrokeEnd(); return true; } // if the game has nothing where the level puts an object: gone in play, moved in play, or not a game object, like a // player start, so the editor draws its icon there function editorObjectIsGhost(layer, object) { const made = layer.instances.get(object.id); return !made || made.destroyed || !isVector2(made.pos) || made.pos.distance(editorObjectPos(layer.record, object)) > .01; } // a world box's outline function editorDrawBox(center, size, color, width) { const half = size.scale(.5), corners = [vec2(-1, -1), vec2(1, -1), vec2(1, 1), vec2(-1, 1)] .map((c)=> center.add(c.multiply(half))); corners.forEach((c, i)=> drawLine(c, corners[(i + 1) % 4], width, color, undefined, 0, glEnable, false)); } // an object type's icon, or a box with its name when it has none function editorDrawObjectIcon(name, pos, size, alpha) { const tileInfo = objectLayersTypes.get(name)?.tileInfo; if (tileInfo) drawTile(pos, size, tileInfo, hsl(0, 0, 1, alpha), 0, undefined, undefined, glEnable, false); else { drawRect(pos, size, hsl(0, 0, 0, alpha * .6), undefined, undefined, false); drawText(name ?? '?', pos, min(size.x, size.y) * .3, hsl(0, 0, 1, alpha)); } } // the objects of the object layer being edited: each one's outline, yellow when selected, a ghost of one the game does // not have where the level puts it, the box being dragged, and the brush's objects at the mouse function editorRenderObjects(thin) { const layer = editorObjectLayer, record = layer.record; for (const object of layer.group?.objects ?? []) { const pos = editorObjectPos(record, object), size = editorObjectSize(layer, object); if (editorObjectIsGhost(layer, object)) editorDrawObjectIcon(object.type || object.class, pos, size, .5); const selected = editorObjectSelection.has(object.id); editorDrawBox(pos, size, selected ? hsl(.15, 1, .6) : hsl(.55, 1, .6, .6), thin * (selected ? 3 : 1.5)); } const box = editorObjectBox; if (box) editorDrawBox(box.a.add(box.b).scale(.5), box.b.subtract(box.a).abs(), hsl(.15, 1, .6), thin * 2); else if (editorObjectBrush && !editorObjectDrag && !editorObjectSelection.size && !mouseIsDown(1)) { const pos = editorSnap(screenToWorld(mousePosScreen)); for (const object of editorObjectBrush) editorDrawObjectIcon(object.type, pos.add(object.offset), vec2(1), .5); } } // the properties box: an input for each default of the one selected object's type, made again when the selection or // the object changes, but not while one of its inputs is being typed in function editorPropertiesUpdate(box) { const selected = editorObjectLayer ? editorSelectedObjects() : [], object = selected.length === 1 && selected[0]; const type = object && objectLayersTypes.get(object.type || object.class); const key = type ? editorObjectLayer.record.fileName + object.id + JSON.stringify(object.properties) : ''; box.style.display = key ? '' : 'none'; if (box.dataset.key === key || box.contains(document.activeElement)) return; box.dataset.key = key; box.replaceChildren(); if (!type) return; editorElement('div', box, 'color:#aaa;margin-bottom:2px', `${object.type || object.class} ${object.id}`); const values = objectLayersProperties(type, object); for (const [name, defaultValue] of Object.entries(type.defaults)) { const row = editorElement('label', box, 'display:flex;gap:6px;align-items:center;margin:2px 0'); editorElement('span', row, 'flex:1', name); const value = values[name], input = editorElement('input', row, 'width:110px;background:#222;color:#eee'); const set = (v)=> { editorSetSelectedProperty(name, v); input.blur(); }; if (typeof defaultValue === 'boolean') { input.type = 'checkbox'; input.checked = !!value; input.onchange = ()=> set(input.checked); } else if (isColor(defaultValue)) { input.type = 'color'; input.value = value.toString(false); input.onchange = ()=> { const color = rgb().setHex(input.value); color.a = value.a; set(color); }; } else if (typeof defaultValue === 'number') { input.type = 'number'; input.step = Number.isInteger(defaultValue) ? '1' : 'any'; input.value = String(value); input.onchange = ()=> { const v = parseFloat(input.value); isFinite(v) && set(v); }; // 1e999 is not one } else if (typeof defaultValue === 'string') { input.type = 'text'; input.value = String(value ?? ''); input.onchange = ()=> set(input.value); } else if (isVector2(defaultValue)) { // x and y, the row's second input for y const y = editorElement('input', row, 'width:52px;background:#222;color:#eee'); input.style.width = '52px'; for (const [field, v] of [[input, value.x], [y, value.y]]) { field.type = 'number'; field.step = 'any'; field.value = String(v); field.onchange = ()=> { // both checked before a vector is made of them, an emptied or half typed field shows its value again const px = parseFloat(input.value), py = parseFloat(y.value); if (isFinite(px) && isFinite(py)) set(vec2(px, py)); else input.value = String(value.x), y.value = String(value.y); field.blur(); }; } } else { // a type it has no input for, an array say, shown as it is input.readOnly = true; input.value = String(value); } } } /////////////////////////////////////////////////////////////////////////////// // plugin function editorUpdate() { // Escape while dragging a selection puts it back, and stays in the editor if (editorSelectionDrag && debugKey && keyWasPressed(debugKey)) { inputClearKey(debugKey); editorSelectionDragCancel(); return; } // Escape while a tool of the game's own is held takes its press back, and stays in the editor if (editorToolHeld && debugKey && keyWasPressed(debugKey)) { inputClearKey(debugKey); editorStrokeCancel(); editorToolHeld = false; return; } // in an editing session Escape, the debug key, switches between playing and editing; taken, so the debug // overlay waits till the session ends if (editorSession && debugKey && keyWasPressed(debugKey)) { inputClearKey(debugKey); if (editorIsOpen) { editorApplyCamera(); // the mouse is read through the editor's view editorPlay(screenToWorld(mousePosScreen)); } else editorSetOpen(true); return; } if (!editorIsOpen) return; editorApplyCamera(); // 0 exits the editor, the overlay's 0 does it while the overlay is open; cleared so debugKeysAlways // does not toggle it back in the same step if (!debugOverlay && keyWasPressed('Digit0')) { inputClearKey('Digit0'); levelEditor.close(); return; } // 1 to 9 pick a layer from the back; taken, so debugKeysAlways does not also flip the debug views, the overlay // keeps them while it is open for (let i = 1; !debugOverlay && i <= 9; ++i) { if (!keyWasPressed('Digit' + i) || levelEditor.keys[i]) continue; // a digit of the game's own is its inputClearKey('Digit' + i); const layer = editorLayers()[i - 1]; layer && layer !== (editorObjectLayer ?? editorLayer) && editorSelectLayer(layer); // a drag does not carry across } // the middle button, or Space with the left, drags the view const space = keyIsDown('Space'); if (mouseIsDown(1) || space && mouseIsDown(0)) editorCameraPos = editorCameraPos.subtract(screenToWorldDelta(mouseDeltaScreen)); editorApplyCamera(); editorMouseOnPanel || (editorMouseScreen = mousePosScreen.copy()); mouseWasPressed(0) && !space && !editorMouseOnPanel && editorRecord()?.pending && editorPendingNudge(); editorCall('onUpdate'); if (editorToolUpdate(space)) return; // a tool of the game's own has the mouse if (editorObjectLayer) return editorUpdateObjects(space); const layer = editorLayer?.live.destroyed ? undefined : editorLayer, mouse = screenToWorld(editorMouseScreen); editorHover = layer && !editorMouseOnPanel && editorCellAt(layer.live, mouse); // over the panel, a line starts again // a right press while dragging the selection puts it back, and is taken so it does not pick or select if (editorSelectionDrag && mouseWasPressed(2)) { inputClearKey(2); editorSelectionDragCancel(); } // the right button: a click picks the tile under the mouse, or clears the selection off the layer, a drag // selects an area; a release within half a cell of the press, or 4 pixels zoomed far out, is a click if (mouseWasPressed(2) && layer) editorRightPress = {screen: mousePosScreen.copy(), cell: editorCellClamped(layer.live, mouse), drag: false}; const press = editorRightPress; if (press && layer) { press.drag ||= mousePosScreen.distance(press.screen) > max(4, editorCameraScale / 2); // a few pixels of wobble at any zoom if (press.drag) editorSelection = editorArea(press.cell, editorCellClamped(layer.live, mouse)); if (!mouseIsDown(2)) { if (!press.drag) editorHover ? editorPick(layer, editorHover) : editorSelection = undefined; editorRightPress = undefined; } } else editorRightPress = undefined; // the left button paints; a press in the selection drags it, the first press outside it only clears it, Shift // draws a line from the last tile placed; a quick click let go before this step still reads as pressed if (mouseWasPressed(0) && !space && editorSelection) { const {min: a, max: b} = editorSelection, cell = editorHover; if (cell && !layer.record.pending && cell.x >= a.x && cell.y >= a.y && cell.x <= b.x && cell.y <= b.y) editorSelectionDragStart(layer, cell); else editorSelection = undefined; editorLeftSpent = true; } if (editorSelectionDrag && layer) editorSelectionDragTo(editorCellClamped(layer.live, mouse)); const left = mouseIsDown(0) || mouseWasPressed(0); if (!editorHover) editorLastCell = undefined; // off the layer, coming back in starts the line again else if (left && !space && !editorLeftSpent && !editorRightPress) { const last = editorLastPlaced, shift = keyIsDown('ShiftLeft') || keyIsDown('ShiftRight'); if (mouseWasPressed(0) && shift && last?.layer === layer) { editorPaintBetween(layer, last.pos, editorHover); editorLastCell = editorHover; } else editorPaintLine(layer, editorHover); if (mouseWasPressed(0) && editorBrush.objects) editorPlaceStampObjects(layer, editorHover, editorBrush); // once a click, not along the drag } if (!mouseIsDown(0)) { editorStrokeEnd(); editorLastCell = editorSelectionDrag = undefined; editorLeftSpent = false; } } // a tool of the game's own: if its press is held, and what was picked in the editor when it was turned on, since // picking a layer, a tile or an object type puts the game's tool down let editorToolHeld = false, editorToolName, editorToolPicked = []; // what a tool's callbacks are given: where the mouse is in the level, the cell under it on the selected tile // layer, and the keys held function editorToolAt() { const layer = editorObjectLayer || editorLayer?.live.destroyed ? undefined : editorLayer; const pos = screenToWorld(editorMouseScreen); return {pos, cell: layer && !editorMouseOnPanel ? editorCellAt(layer.live, pos) || undefined : undefined, ray: undefined, shift: keyIsDown('ShiftLeft') || keyIsDown('ShiftRight'), ctrl: keyIsDown('ControlLeft') || keyIsDown('ControlRight')}; } // the game's tool, when one is on: the left button is its, a press, a drag and a release one undo, the right // button takes a held press back; true when it has the mouse function editorToolUpdate(space) { const picked = [editorLayer, editorObjectLayer, editorBrush, editorObjectBrush]; if (levelEditor.tool !== editorToolName) editorToolName = levelEditor.tool, editorToolPicked = picked; // turned on, or off, by its key or button else if (picked.some((value, i)=> value !== editorToolPicked[i])) levelEditor.tool = editorToolName = undefined; // something of the editor's was picked const tool = editorGameTool(); if (!tool) { editorToolHeld = false; return false; } const at = editorToolAt(); editorHover = at.cell; editorRightPress = undefined; // a right press from before the tool was on is not the editor's to finish if (editorToolHeld && mouseWasPressed(2)) { inputClearKey(2); editorStrokeCancel(); editorToolHeld = false; } else if (mouseWasPressed(0) && !space && !editorMouseOnPanel) editorToolHeld = tool.onPress?.(at) !== false; else if (editorToolHeld && mouseIsDown(0)) tool.onDrag?.(at); if (editorToolHeld && !mouseIsDown(0)) { tool.onRelease?.(at); editorStrokeEnd(); editorToolHeld = false; } return true; } // the 2D editor's edit functions, what levelEditor.edit2D gives a game's own keys, buttons and tools const editorEdit2D = { get map() { return editorRecord()?.map; }, get layer() { return editorObjectLayer ? undefined : editorLayer?.live; }, get objects() { return editorObjectLayer ? editorObjectsCopy(editorObjectList(editorObjectLayer)) : []; }, get hover() { return editorHover && !editorObjectLayer ? editorHover.copy() : undefined; }, get selection() { return editorObjectSelection; }, paint(cell, tile, direction=0, mirror=false) { const layer = editorObjectLayer ? undefined : editorLayer, size = layer?.live.size; if (!layer || layer.live.destroyed || layer.record.pending || !isVector2(cell)) return false; const x = floor(cell.x), y = floor(cell.y); if (x < 0 || y < 0 || x >= size.x || y >= size.y) return false; editorPaint(layer, vec2(x, y), tile < 0 ? 0 : editorTileToGid(tile, direction & 3, mirror)); return true; }, changeObjects(change) { return editorChangeObjects(editorObjectLayer, change); }, strokeEnd() { editorStrokeEnd(); }, strokeCancel() { editorStrokeCancel(); }, bulk(edit) { editorBulkEdit(edit); }, undo(redo=false) { return editorUndo(redo); }, toJSON() { const record = editorRecord(); return record ? editorMapJSON(record) : ''; }, }; // switch to play and have the game rebuild its level from the changed map, when it has a hook for that function editorRestart() { if (!editorHas('onRestart')) return; editorSetOpen(false); // ends a held stroke, and hands back the game's pause and camera, Escape comes back levelEditor.onRestart(); } // called by the engine before the camera goes to WebGL, so the level is drawn with the editor's view, a game may move // the camera from gameUpdatePost, which runs while paused function editorPreRender() { editorIsOpen && editorApplyCamera(); } // the layer's edge, or the map's for a level of objects alone, a grid when zoomed in, the map's tiles the layer does not // show, and the brush under the mouse function editorRender() { headlessMode || editorPanelUpdate(); if (!headlessMode && editorIsOpen && editorPanelParts) { editorGamePanel(editorPanelParts.game); editorGameHelp(editorPanelParts.gameHelp); } if (!editorIsOpen || headlessMode) return; editorRecord()?.pending && editorPendingNote(); const layer = editorLayer?.live.destroyed ? undefined : editorLayer; if (!layer && !editorObjectLayer) return; // the area drawn, the tile layer's, or for a level of objects alone the map's, where objectLayersLoad puts it const map = editorRecord().map, live = layer?.live, source = layer?.source; const pos = live ? live.pos : vec2(), size = live ? live.size : vec2(map.width, map.height); const {x: width, y: height} = size; // the cells on screen const low = screenToWorld(vec2(0, mainCanvasSize.y)).subtract(pos); const high = screenToWorld(vec2(mainCanvasSize.x, 0)).subtract(pos); const x0 = max(0, floor(low.x)), y0 = max(0, floor(low.y)); const x1 = min(width, ceil(high.x)), y1 = min(height, ceil(high.y)); // ghosts, cells the map has that the layer does not show: markers a game made objects of, tiles broken in play const ghost = hsl(0, 0, 1, .4); for (let x = x0; live && !editorObjectLayer && x < x1; ++x) for (let y = y0; y < y1; ++y) { const t = editorGidToTile(source.data[x + (height - 1 - y) * width]); const tileInfo = t && editorTileInfo(live, t.tile); if (tileInfo && live.getData(vec2(x, y)).tile === undefined) drawTile(live.pos.add(vec2(x + .5, y + .5)), vec2(1), tileInfo, ghost, t.direction * PI/2, t.mirror, undefined, glEnable, false); } // a grid once the cells are big enough to see one const line = hsl(0, 0, 1, .12), thin = 1 / editorCameraScale; if (editorGrid && editorCameraScale >= 12) { for (let x = x0; x <= x1; ++x) drawLine(pos.add(vec2(x, y0)), pos.add(vec2(x, y1)), thin, line, undefined, 0, glEnable, false); for (let y = y0; y <= y1; ++y) drawLine(pos.add(vec2(x0, y)), pos.add(vec2(x1, y)), thin, line, undefined, 0, glEnable, false); } // the layer's edge const outline = (a, b, color, width)=> { const corners = [vec2(a.x, a.y), vec2(b.x, a.y), vec2(b.x, b.y), vec2(a.x, b.y)]; corners.forEach((c, i)=> drawLine(pos.add(c), pos.add(corners[(i + 1) % 4]), width, color, undefined, 0, glEnable, false)); }; outline(vec2(), size, hsl(.55, 1, .6, .8), thin * 2); // the selection, and the line Shift would draw if (editorSelection && !editorObjectLayer) outline(editorSelection.min, editorSelection.max.add(vec2(1)), hsl(.15, 1, .6), thin * 3); const last = editorLastPlaced, shift = keyIsDown('ShiftLeft') || keyIsDown('ShiftRight'); if (shift && editorHover && last?.layer === layer && !mouseIsDown(0) && !editorGameTool()) drawLine(live.pos.add(last.pos).add(vec2(.5)), live.pos.add(editorHover).add(vec2(.5)), thin * 2, hsl(.55, 1, .6, .6), undefined, 0, glEnable, false); // the brush where it would paint, the Erase brush's cells in red; a tool of the game's own draws its own if (editorHover && !editorGameTool()) { const grid = editorStampGrid(layer, editorBrush), {width: w, height: h} = editorBrush; for (let y = h; y--;) for (let x = w; x--;) { const gid = grid[x + y * w], t = editorGidToTile(gid); const center = live.pos.add(editorHover).add(vec2(x + .5, y + .5)); const tileInfo = t && editorTileInfo(live, t.tile); if (tileInfo) drawTile(center, vec2(1), tileInfo, hsl(0, 0, 1, .7), t.direction * PI/2, t.mirror, undefined, glEnable, false); else if (gid === 0) drawRect(center, vec2(1), hsl(0, 1, .5, .3), undefined, undefined, false); } drawRect(live.pos.add(editorHover).add(vec2(w / 2, h / 2)), vec2(w, h), hsl(.55, 1, .6, .25), undefined, undefined, false); } editorObjectLayer && editorRenderObjects(thin); } // the game's own drawing in the level, over the editor's: the editor's onDraw, and its tool's while one is on function editorRenderGame() { if (!editorIsOpen) return; editorCall('onDraw'); editorGameTool()?.onDraw?.(editorToolAt()); } // the lines of the game's own keys in a panel's help, made again when they change function editorGameHelp(element) { const lines = editorGameHelpLines(), key = lines.join('\n'); if (element.dataset.key === key) return; element.dataset.key = key; element.replaceChildren(...lines.map((line)=> editorElement('div', undefined, 'margin:2px 0', line))); } debug && engineAddPlugin(editorUpdate, ()=> { editorRender(); editorRenderGame(); }); // the editor's own listeners for letter shortcuts and the wheel, the engine's input reads keys by position if (debug && globalThis.document?.addEventListener) { document.addEventListener('keydown', editorOnKeyDown); // a page going away writes what is waiting, the last event it is sure to get document.addEventListener('visibilitychange', ()=> document.hidden && editorAutosaveFlush()); globalThis.addEventListener?.('pagehide', editorAutosaveFlush); document.addEventListener('wheel', editorOnWheel, {passive: true}); } /* * LittleJS 3D Editor Plugin * - A free camera to look around any 3D game as it runs, C on the debug overlay * - The 3D level editor, for a level loaded with level3DLoad, 0 on the debug overlay * - Debug builds only, like the 2D level editor: engineRelease.js has empty stubs for what other files call, so * release builds carry none of it * - Goes after the Render3D plugins and the 2D level editor */ /////////////////////////////////////////////////////////////////////////////// // the view: a camera of the editor's own, drawn with while the free camera or the editor is on const EDITOR3D_LOOK_SPEED = .003; // radians a pixel of mouse movement turns the view // the camera of the free camera and the editor, made at the first use /** @type {Camera3D|undefined} */ let editor3DCamera; let editor3DFreeCamera = false; // the free camera is on let editor3DFlySpeed = .2; // world units flown in a frame of 1/60, the wheel changes it let editor3DTimeLast = 0; // real time of the last update, flying goes by real time so it works while paused let editor3DWasLocked = false; // the mouse was captured last update, a lock that is lost leaves the free camera // the game's camera while the editor's is drawn with /** @type {Camera3D|undefined} */ let editor3DGameCamera; // if something draws with the editor's camera function editor3DViewOn() { return editor3DFreeCamera || editor3DIsOpen; } // the editor's camera starts as the game's: where it is, the way it looks and its field of view function editor3DCameraStart() { const from = render3D.camera, camera = editor3DCamera = new Camera3D; camera.pos = from.pos.copy(); camera.rotation = vec3(from.rotation.x, from.rotation.y, 0); camera.fov = from.fov; camera.far = from.far; // an orthographic camera's near may sit behind it, which a perspective view can not use, so it keeps its own from.orthographic || (camera.near = from.near); } // turn the free camera on or off, it needs a Render3DPlugin and takes the keyboard and mouse while it is on function editor3DSetFreeCamera(on) { on = !!on && !!render3D; if (on === editor3DFreeCamera) return; editor3DFreeCamera = on; editor3DWasLocked = false; if (on) { editor3DCameraStart(); pointerLockRequest(); } else pointerLockIsActive() && pointerLockExit(); inputCapture(editor3DViewOn()); } // fly a camera: turn is the mouse movement in pixels, move is right, up and forward from -1 to 1 function editor3DFly(camera, move, turn, seconds, fast=false) { const r = camera.rotation; camera.rotation = vec3(clamp(r.x - turn.y * EDITOR3D_LOOK_SPEED, -1.55, 1.55), r.y - turn.x * EDITOR3D_LOOK_SPEED, 0); const distance = editor3DFlySpeed * 60 * seconds * (fast ? 4 : 1); camera.pos = camera.pos.add(camera.getRight().scale(move.x * distance)) .add(vec3(0, move.y * distance, 0)) .add(camera.getForward().scale(move.z * distance)); } // the keys that fly, right, up and forward from -1 to 1 function editor3DFlyKeys() { const key = (code)=> keyIsDown(code) ? 1 : 0; return vec3(key('KeyD') - key('KeyA'), key('KeyE') - key('KeyQ'), key('KeyW') - key('KeyS')); } // run a function with render3D looking through the editor's camera, for the editor's picking and projecting function editor3DWithView(fn) { const r = render3D, camera = r.camera; r.camera = editor3DCamera; r.updateMatrices(); try { return fn(); } finally { r.camera = camera; r.updateMatrices(); } } // called by the 3D pass before it works out its matrices: the frame is drawn with the editor's camera function editor3DCameraBegin() { editor3DCameraEnd(); // a frame that threw before its End gives the game its camera back first, not the editor's if (!editor3DViewOn() || !editor3DCamera) return; const r = render3D, camera = editor3DGameCamera = r.camera; r.camera = editor3DCamera; // where the game is looking from, a box turned its way and a line along its view; the editor has enough to show if (editor3DFreeCamera) { const color = hsl(.15, 1, .6); debugBox3D(camera.pos, vec3(.5, .5, .8), color, 0, camera.rotation); debugLine3D(camera.pos, camera.pos.add(camera.getForward().scale(2)), color); } // the editor's own drawing, in this frame's 3D pass editor3DIsOpen && render3DDebugPush(0, editor3DDraw); } // called by the 3D pass after the frame: the game's camera is back, with its matrices, for the game's next update function editor3DCameraEnd() { if (!editor3DGameCamera) return; render3D.camera = editor3DGameCamera; editor3DGameCamera = undefined; render3D.updateMatrices(); } // if a captured mouse was let go of by the browser since the last look, as Chrome does on Escape, and not by the // game with pointerLockExit function editor3DLostLock(locked) { const lost = editor3DWasLocked && !locked && !inputLockLetGo; editor3DWasLocked = locked; inputLockLetGo = false; return lost; } // C turns the free camera on while the debug keys work, the level editor has its own view function editor3DFreeCameraKey() { if (!(debugOverlay || debugKeysAlways) || levelEditor.isOpen || !keyWasPressed('KeyC')) return; inputClearKey('KeyC'); editor3DSetFreeCamera(true); } // the free camera's update: C or Escape leaves, and so does a captured mouse the browser let go of, since Chrome // takes Escape for that and may not send the key; the mouse looks while it is captured or the right button is held function editor3DFreeCameraUpdate(seconds) { const locked = pointerLockIsActive(), lostLock = editor3DLostLock(locked); if (keyWasPressed('KeyC') || debugKey && keyWasPressed(debugKey) || lostLock) { inputClearKey('KeyC'); debugKey && inputClearKey(debugKey); editor3DSetFreeCamera(false); return; } editor3DFlySpeed = clamp(editor3DFlySpeed * Math.exp(-inputCaptureWheel * .2), .01, 10); const look = locked || mouseIsDown(2); editor3DFly(editor3DCamera, editor3DFlyKeys(), look ? inputCaptureDeltaScreen : vec2(), seconds, keyIsDown('ShiftLeft') || keyIsDown('ShiftRight')); } /////////////////////////////////////////////////////////////////////////////// // the math of the handles: each is dragged with the mouse's ray const EDITOR3D_UP = vec3(0, 1, 0); // how far along an axis line, from its origin, the point nearest a ray is: the two lines' closest points, solved // for the axis; a ray along the axis has no nearest point, and gives 0 function editor3DAxisDistance(ray, origin, axis) { const d = ray.direction, w = origin.subtract(ray.origin); const a = axis.dot(axis), b = axis.dot(d), c = d.dot(d), e = axis.dot(w), f = d.dot(w); const denominator = a * c - b * b; return abs(denominator) < 1e-9 ? 0 : (b * f - c * e) / denominator; } // where a ray meets the plane through a point, undefined when it runs along the plane or away from it function editor3DPlanePoint(ray, point, normal) { const distance = raycastPlane(ray, point, normal); return distance === undefined ? undefined : ray.getPosition(distance); } // the axis a ring turns around and two axes across it, for the pitch 'x', yaw 'y' or roll 'z' of a rotation: // yaw turns around world up, pitch around the side axis after the yaw, roll around the forward axis after both, // so each ring changes one number of rotation3D; a turn goes from across toward along function editor3DRingAxes(rotation, ring) { const frame = buildMatrix(vec3(), ring === 'y' ? vec3() : ring === 'x' ? vec3(0, rotation.y, 0) : vec3(rotation.x, rotation.y, 0)); const x = frame.transformDirection(vec3(1, 0, 0)), y = frame.transformDirection(vec3(0, 1, 0)); const z = frame.transformDirection(vec3(0, 0, 1)); return ring === 'x' ? {axis: x, across: y, along: z} : ring === 'y' ? {axis: y, across: z, along: x} : {axis: z, across: x, along: y}; } // the angle of a ray's point on a ring's plane, around the ring's axis from across toward along function editor3DRingAngle(ray, center, axes) { const point = editor3DPlanePoint(ray, center, axes.axis); if (!point) return; const offset = point.subtract(center); return atan2(offset.dot(axes.along), offset.dot(axes.across)); } // a value on the nearest step, a step of 0 leaves it function editor3DSnap(value, step) { return step ? round(value / step) * step : value; } // a position snapped to the grid by the low corner of its box, so its faces land on grid lines and boxes tile // with no gaps; a turned object has no such corner and goes by its center function editor3DSnapPos(pos, size, step, turned=false) { const half = turned ? vec3() : size.scale(.5), low = pos.subtract(half); return vec3(editor3DSnap(low.x, step), editor3DSnap(low.y, step), editor3DSnap(low.z, step)).add(half); } // the height of the editor's view in world units at a point's depth, handles are sized by it to keep their size // on screen function editor3DScreenScale(point) { const camera = editor3DCamera, depth = point.subtract(camera.pos).dot(camera.getForward()); return max(depth, .01) * tan(camera.fov / 2) * 2; } // how far in pixels a screen position is from the line between two world points as the view shows it, Infinity // when an end is behind the camera function editor3DSegmentDistance(screenPos, a, b) { const sa = render3D.worldToScreen(a), sb = render3D.worldToScreen(b); if (!sa || !sb) return Infinity; const ab = sb.subtract(sa), t = clamp(screenPos.subtract(sa).dot(ab) / (ab.lengthSquared() || 1), 0, 1); return screenPos.distance(sa.add(ab.scale(t))); } // the first point a ray hits: on an object, a height map or a voxel map, leaving some out, or on the ground plane // at height 0 when it hits none function editor3DSurface(ray, ignore=new Set) { let distance; for (const o of engineObjects) { if (ignore.has(o)) continue; const d = render3DRaycastObject(ray, o); if (d !== undefined && (distance === undefined || d < distance)) distance = d; } distance ??= raycastPlane(ray, vec3(), EDITOR3D_UP); return distance === undefined ? undefined : ray.getPosition(distance); } // where a box of a size stands with its bottom on a point function editor3DRest(point, size) { return vec3(point.x, point.y + size.y / 2, point.z); } // the height of the middle of a box that lands at a place across the ground: dropped from its own height over a // height, onto what is under it, so it climbs a step as tall as itself and no more; with nothing under it, it // stands at that height function editor3DLand(x, z, height, size, ignore) { const landed = editor3DDrop(vec3(x, height + size.y * 1.5 + .01, z), size, ignore); return landed ? landed.y : height + size.y / 2; } // where a box lands dropped straight down from its bottom onto what is under it, undefined with nothing there function editor3DDrop(pos, size, ignore=new Set) { const bottom = vec3(pos.x, pos.y - size.y / 2, pos.z); const point = editor3DSurface(new Ray3D(bottom, vec3(0, -1, 0)), ignore); return point && editor3DRest(point, size); } /////////////////////////////////////////////////////////////////////////////// // the level: the object the game gave level3DLoad is the source of truth, the editor changes it in place and // brings what the game made in line with it // the level being edited, the one given to level3DLoad last /** @type {Object|undefined} */ let editor3DLevel; // what the game made for each object of the level, by the object's id const editor3DInstances = new Map; // what the editor keeps for each level: {fileName, key, original, hash, pending, fileHandle, undo, redo}, the file // it came from, the file's objects and their hash for the autosave, an autosave waiting for a file that changed, // and its undo and redo lists const editor3DRecords = new WeakMap; // if the editor is open, and if it was opened and not exited, while Escape switches between playing and editing let editor3DIsOpen = false, editor3DSession = false; let editor3DGamePaused = false; // the game's pause from before the editor opened let editor3DPlayFromMouse = false; // Escape and Play hand the game a position to play from const editor3DSelection = new Set; // the ids of the selected objects // the type a click places /** @type {string|undefined} */ let editor3DBrush; // the copied objects, and the size and box offset each had when it was copied /** @type {Array|undefined} */ let editor3DClipboard; /** @type {Map} */ let editor3DClipboardBoxes = new Map; // the level's, each entry its object list and its parts, the scene and the blocks, before and after an edit let editor3DUndoList = [], editor3DRedoList = []; // the edit being made, a drag is one: the objects and the parts as they were before it, and painted when blocks // were set in the game's map and are not in the level yet /** @type {{before: Array, parts: Object, painted?: boolean, sculpted?: boolean}|undefined} */ let editor3DStroke; const editor3DCopy = (value)=> JSON.parse(JSON.stringify(value)); const editor3DSame = (a, b)=> JSON.stringify(a) === JSON.stringify(b); const editor3DRound = (v)=> round(v * 1e4) / 1e4; // as the file keeps a number // the level's objects, and the one of an id const editor3DObjects = ()=> isArray(editor3DLevel?.objects) ? editor3DLevel.objects : []; const editor3DObject = (id)=> editor3DObjects().find((o)=> o.id === id); const editor3DSelected = ()=> editor3DObjects().filter((o)=> editor3DSelection.has(o.id)); // the parts of a level beside its objects that the editor edits: its scene block and its map of blocks; each is // undone, autosaved, reset and saved as the objects are const editor3DLevelPartNames = ['scene', 'voxels', 'terrain', 'prefabs']; // the last copy made of each part, as its text and the copy: a part that has not changed since is not copied // again, so undo steps share one copy of a terrain until it is sculpted; a copy is never changed /** @type {Object} */ const editor3DPartCopies = {}; // a part of a level, undefined when it has none function editor3DLevelPart(name, level=editor3DLevel) { const part = level?.[name]; return part && typeof part === 'object' && !isArray(part) ? part : undefined; } // a copy of every part a level has, {} for a level with none; the copies are shared, see editor3DPartCopies, so // what is given is copied again before it is changed or made a level's own function editor3DLevelParts(level=editor3DLevel) { const parts = {}; for (const name of editor3DLevelPartNames) { const part = editor3DLevelPart(name, level); if (!part) continue; const text = JSON.stringify(part); if (editor3DPartCopies[name]?.text !== text) editor3DPartCopies[name] = {text, part: JSON.parse(text)}; parts[name] = editor3DPartCopies[name].part; } return parts; } const editor3DScene = ()=> editor3DLevelPart('scene'); // what a level's hash covers: its objects, and the parts it has, so a level with none hashes as before const editor3DContentHash = (objects, parts)=> editor3DHash(JSON.stringify(objects) + editor3DLevelPartNames.map((name)=> parts[name] ? JSON.stringify(parts[name]) : '').join('')); // the renderer's settings a scene block can set, as they are now, and put back as they were: what the game set // itself shows again when a level's block changes or goes function editor3DSceneState() { const r = render3D; return {sky: r.sky, ambientColor: r.ambientColor.copy(), ambientGroundColor: r.ambientGroundColor?.copy(), fogStart: r.fogStart, fogEnd: r.fogEnd, fogColor: r.fogColor?.copy(), sunDirection: r.sunDirection.copy(), sunColor: r.sunColor.copy(), shadows: r.shadows, sunFlare: !!level3DSunFlare && !level3DSunFlare.destroyed, skyBox: r.skyBox, environment: r.environment}; } function editor3DSceneRestore(state) { const r = render3D; if (!r || !state) return; r.sky !== state.sky && level3DSkies.has(r.sky) && r.sky.dispose(); // a dome a level made, not the game's r.sky = state.sky; r.ambientColor = state.ambientColor.copy(); r.ambientGroundColor = state.ambientGroundColor?.copy(); r.fogStart = state.fogStart, r.fogEnd = state.fogEnd; r.fogColor = state.fogColor?.copy(); r.sunDirection = state.sunDirection.copy(); r.sunColor = state.sunColor.copy(); r.shadows = state.shadows; level3DSceneFlare(state.sunFlare); // a flare an earlier level's scene gave the sun, never the game's own // the cube maps, and a scene's load still running for them lands no more r.skyBox = state.skyBox, r.environment = state.environment; ++level3DSceneCubeLoads.skyBox, ++level3DSceneCubeLoads.environment; } // the scene on screen as a block, to start a level's scene from: a sky only when the dome's colors are known const editor3DHex = (color)=> new Color(clamp(color.r), clamp(color.g), clamp(color.b)).toString(false); function editor3DSceneFromView() { const r = render3D, scene = {}, colors = r.sky && render3DSkyColors.get(r.sky); if (colors) { // how much of the top color the ambient light is const top = max(colors[0].r, colors[0].g, colors[0].b); const lit = max(r.ambientColor.r, r.ambientColor.g, r.ambientColor.b); scene.sky = colors.map(editor3DHex); scene.ambient = top ? round(clamp(lit / top) * 100) / 100 : .5; } const sun = r.sunDirection.normalize(); scene.sunDirection = [sun.x, sun.y, sun.z].map((v)=> round(v * 1e3) / 1e3); scene.sunColor = editor3DHex(r.sunColor); scene.fog = [editor3DRound(r.fogStart || 0), editor3DRound(r.fogEnd || 0)]; scene.fogColor = editor3DHex(r.fogColor || canvasClearColor); scene.shadows = !!r.shadows; scene.lensFlare = level3DSunHasFlare(); return scene; } // the sun's direction as two angles in degrees, for two sliders: around, from +z toward +x, and its height over // the horizon; and the direction of two angles, as the file keeps it function editor3DSunAngles(direction) { const d = level3DVector(direction, vec3(0, 1, 0)).normalize(); return [round((atan2(d.x, d.z) * 180 / PI + 360) % 360) % 360, round(Math.asin(clamp(d.y, -1, 1)) * 180 / PI)]; } function editor3DSunDirection(around, height) { const a = around * PI / 180, h = height * PI / 180; return [sin(a) * cos(h), sin(h), cos(a) * cos(h)].map((v)=> round(v * 1e3) / 1e3 + 0); } // an object's position, rotation in degrees and scale, the defaults where the level leaves them out const editor3DPos = (object)=> level3DVector(object.pos, vec3()); const editor3DRotation = (object)=> level3DVector(object.rotation, vec3()); const editor3DScale = (object)=> level3DVector(object.scale, vec3(1)); const editor3DTurned = (object)=> editor3DRotation(object).lengthSquared() > 0; // the size of an object's box: what the game made for it says, a marker is 1 unit function editor3DSize(object) { const made = editor3DInstances.get(object.id), box = editor3DPrefabBox(made); if (box) return box.size; if (!(made instanceof EngineObject3D) || !(made.mesh || made.tileInfo)) return vec3(1); const s = made.size3D, k = made.scale3D, size = vec3(s.x * abs(k.x), s.y * abs(k.y), s.z * abs(k.z)); if (!editor3DTurned(object)) return size; // a turned one's box is the box around it turned, so it lands, drops and pastes by what it covers const matrix = buildMatrix(vec3(), editor3DRotation(object).scale(PI / 180), size), high = vec3(); for (let i = 8; i--;) { const p = matrix.transformPoint(vec3(i & 1 ? .5 : -.5, i & 2 ? .5 : -.5, i & 4 ? .5 : -.5)); high.x = max(high.x, p.x), high.y = max(high.y, p.y), high.z = max(high.z, p.z); } return vec3(editor3DRound(high.x * 2), editor3DRound(high.y * 2), editor3DRound(high.z * 2)); } // the box around a prefab's instance in the world, the boxes of its parts together, as its middle and its size; // undefined for anything else, or an instance with nothing to see function editor3DPrefabBox(made) { if (!(made instanceof Prefab3D) || made.destroyed) return; const low = vec3(Infinity), high = vec3(-Infinity); for (const part of editor3DParts(made)) { if (!(part instanceof EngineObject3D) || !(part.mesh || part.tileInfo)) continue; const bounds = part.mesh && (part.mesh.bounds || part.mesh.getBounds()); const matrix = render3DObjectMatrix(part).copy().multiply(bounds ? buildMatrix(bounds.min.add(bounds.max).scale(.5), undefined, bounds.max.subtract(bounds.min)) : buildMatrix(vec3(), undefined, part.size3D)); for (let i = 8; i--;) { const p = matrix.transformPoint(vec3(i & 1 ? .5 : -.5, i & 2 ? .5 : -.5, i & 4 ? .5 : -.5)); for (const k of ['x', 'y', 'z']) low[k] = min(low[k], p[k]), high[k] = max(high[k], p[k]); } } // to the nearest part of the file's numbers, so a box worked out through a matrix reads as it was written const tidy = (v)=> vec3(editor3DRound(v.x), editor3DRound(v.y), editor3DRound(v.z)); return low.x <= high.x ? {pos: tidy(low.add(high).scale(.5)), size: tidy(high.subtract(low))} : undefined; } // from an object's place to the middle of its box: nothing for a plain object, whose place is its middle, and for // a prefab's instance how far its origin is from the middle of its parts, as it is turned and sized now function editor3DBoxOffset(object) { const box = editor3DPrefabBox(editor3DInstances.get(object.id)); return box ? box.pos.subtract(editor3DPos(object)) : vec3(); } // every object gets an id of its own, a whole number: one without, or with one already used, gets a new one function editor3DFixIds(objects) { const isObject = (o)=> !!o && typeof o === 'object', seen = new Set; let next = 1; for (const o of objects) isObject(o) && Number.isInteger(o.id) && (next = max(next, o.id + 1)); for (const o of objects) { if (!isObject(o)) continue; if (!Number.isInteger(o.id) || seen.has(o.id)) o.id = next++; seen.add(o.id); } } // a quick hash of a text, to know when the file changed under its autosave function editor3DHash(text) { let hash = 2166136261; for (let i = 0; i < text.length; ++i) hash = Math.imul(hash ^ text.charCodeAt(i), 16777619); return hash >>> 0; } // called by level3DLoad before it makes the objects: the editor takes the level, and the first time it sees it, // puts in the edits it autosaved for the file, or keeps them waiting when the file changed since function editor3DLevelLoaded(level) { editor3DAutosaveFlush(); // what the last level has waiting is written before this one reads the autosaves if (editor3DLevel !== level) { // another level, the selection and the edit being made were the last one's editor3DSelection.clear(); editor3DStroke = undefined; } if (!editor3DPrefabLevels.has(level) && editor3DPrefabStack.length) { // the game loaded a level while a prefab was open: what was open is gone with what it was opened from editor3DPrefabStack.length = 0; editor3DPrefabEdits = {}; } editor3DLevel = level; editor3DInstances.clear(); if (isArray(level.objects)) { // an entry that is not an object is dropped, in place, a game may keep the list for (let i = level.objects.length; i--;) level.objects[i] && typeof level.objects[i] === 'object' || level.objects.splice(i, 1); editor3DFixIds(level.objects); } // its blocks as the editor writes them, so the same map always reads the same, however the file wrote its runs const voxels = editor3DLevelPart('voxels', level), shape = level3DVoxelsShape(voxels); if (shape) { const data = new Uint8Array(shape.size.x * shape.size.y * shape.size.z); level3DVoxelsDecode(voxels.blocks, data); voxels.blocks = level3DVoxelsEncode(data); } const known = editor3DRecords.get(level); if (known) { // loaded again, as a restart does: its edits are in it, and its undo is its own editor3DUndoList = known.undo; editor3DRedoList = known.redo; return; } // a level from a file goes by the file, one made in code by its objects as they were loaded, so each has an // autosave of its own const url = editorFetchedURLs.get(level)?.split(/[?#]/)[0]; const original = editor3DCopy(editor3DObjects()), originalParts = editor3DLevelParts(); const hash = editor3DContentHash(original, originalParts); // sceneBase is the scene the game set, taken before the level's own block is applied const record = {fileName: editorFileName(url, 'level3D.json'), key: url ?? 'level #' + hash, original, originalParts, sceneBase: render3D ? editor3DSceneState() : undefined, hash, pending: undefined, fileHandle: undefined, undo: [], redo: []}; editor3DRecords.set(level, record); editor3DUndoList = record.undo; editor3DRedoList = record.redo; // an autosave the file already has, from a Save, goes; one of this file, or of the file a Save wrote, comes back // a prefab being edited has no autosave, and one of a level with the same objects is not its const saved = editor3DPrefabLevels.has(level) ? undefined : editor3DSaves()[record.key]; if (saved && isArray(saved.objects) && editor3DSame(saved.objects, original) && editor3DSame(editor3DLevelParts(saved), originalParts)) editor3DAutosave(level); else if ((saved?.hash === hash || saved?.savedHash === hash) && isArray(saved.objects)) { // before level3DLoad makes anything, so the scene and the map are made with the edits in them const parts = editor3DLevelParts(saved); level.objects = editor3DCopy(saved.objects); for (const name of editor3DLevelPartNames) parts[name] ? level[name] = editor3DCopy(parts[name]) : delete level[name]; saved.stale && console.warn(`LittleJS editor: the edits brought back to ${record.fileName} are older than ` + 'the last ones, which storage had no room for'); } else if (saved && isArray(saved.objects)) record.pending = saved; // one that is broken is passed by, and the next autosave writes over it } // called by level3DLoad for each object, with what its type made function editor3DObjectMade(object, made) { made && typeof made === 'object' && editor3DInstances.set(object.id, made); } // put what the game made for an object where the level has it, and still function editor3DPlaceInstance(made, object) { made.pos3D = editor3DPos(object); made.rotation3D = editor3DRotation(object).scale(PI / 180); made.scale3D = (level3DBaseScale.get(made) ?? vec3(1)).multiply(editor3DScale(object)); made.velocity3D = vec3(); made.angleVelocity3D = vec3(); made instanceof Prefab3D && made.placeAt(editor3DPos(object)); // by its origin, and its parts go with it } // make an object's game object, again when it has one function editor3DMakeInstance(object) { editor3DInstances.get(object.id)?.destroy?.(); const made = level3DMake(object); made ? editor3DInstances.set(object.id, made) : editor3DInstances.delete(object.id); } // set the level's objects to a list and bring the game's objects in line: one gone is destroyed, a new one made, // one that only moved, turned or changed size is moved where it is, and one whose type or properties changed is // made again, since its constructor takes them; a type made by an arrow function is called again function editor3DSetObjects(list) { const before = new Map(editor3DObjects().map((o)=> [o.id, o])); const objects = editor3DLevel.objects = editor3DCopy(list); const ids = new Set(objects.map((o)=> o.id)); for (const [id, made] of editor3DInstances) { if (ids.has(id)) continue; made.destroy?.(); editor3DInstances.delete(id); } for (const id of [...editor3DSelection]) ids.has(id) || editor3DSelection.delete(id); for (const object of objects) { const old = before.get(object.id), made = editor3DInstances.get(object.id); if (!level3DTypes.has(object.type) || old && editor3DSame(old, object)) continue; const onlyMoved = old && old.type === object.type && editor3DSame(old.properties, object.properties) && made instanceof EngineObject3D && !made.destroyed; onlyMoved ? editor3DPlaceInstance(made, object) : editor3DMakeInstance(object); } editor3DShadowLight(); } // the light that casts the shadows is the level's last Light that asks to, as it is after a load, whatever order // the edits made them in; a light of the game's own is left alone while no Light of the level asks function editor3DShadowLight() { if (!render3D) return; let light; for (const object of editor3DObjects()) { const made = editor3DInstances.get(object.id); if (object.type === 'Light' && object.properties?.shadows === true && made instanceof Light3D && !made.destroyed) light = made; } const now = render3D.shadowLight; if (light || !now || now.destroyed || [...editor3DInstances.values()].some((made)=> made === now)) render3D.shadowLight = light; } // change the level's objects as part of the edit being made: change edits a copy of the list; the changes until // editor3DStrokeEnd are one undo, so a drag is one undo; false when nothing changed function editor3DChange(change) { if (!editor3DLevel || editor3DRecords.get(editor3DLevel)?.pending) return false; // its autosave waits first const before = editor3DCopy(editor3DObjects()), after = editor3DCopy(before); change(after); if (editor3DSame(before, after)) return false; editor3DFixIds(after); // an object added with no id, or with one in use, gets one of its own editor3DSetObjects(after); editor3DStroke ||= {before, parts: editor3DLevelParts()}; return true; } // 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 // sets; for the blocks the game's map, brought in line function editor3DSetPart(name, part) { const level = editor3DLevel; part ? level[name] = editor3DCopy(part) : delete level[name]; if (name === 'scene') { editor3DSceneRestore(editor3DRecords.get(level)?.sceneBase); level3DSceneApply(level.scene); } else if (name === 'voxels') editor3DVoxelShow(); else if (name === 'terrain') editor3DTerrainShow(); else editor3DPrefabsShow(); } // bring the prefab types in line with the level's own prefabs, and make every instance again: a prefab the level // no longer has is no longer a type, one it has is added as level3DLoad adds it, and what a prefab makes is made // when its instance is, so each instance is made again function editor3DPrefabsShow() { if (editor3DPrefabLevels.has(editor3DLevel)) return; // inside a prefab, the prefabs are the level's const prefabs = editor3DLevelPart('prefabs') ?? {}, removed = new Set; for (const [name, prefab] of level3DPrefabs) { if (!prefab.fromLevel || prefabs[name]) continue; level3DPrefabs.delete(name); level3DTypes.delete(name); removed.add(name); } level3DPrefabsAdd(prefabs); // every object of a prefab's type, one whose type was not there when the objects were set too; an instance of a // prefab taken out leaves the game, its object waiting in the level for the prefab to come back for (const object of editor3DObjects()) { if (level3DPrefabs.has(object.type)) editor3DMakeInstance(object); else if (removed.has(object.type)) { editor3DInstances.get(object.id)?.destroy?.(); editor3DInstances.delete(object.id); } } editor3DShadowLight(); } // make the level's parts these, the ones that differ function editor3DSetParts(parts) { for (const name of editor3DLevelPartNames) editor3DSame(parts[name], editor3DLevelPart(name)) || editor3DSetPart(name, parts[name]); } // the stroke being made, started here with the level as it is when there is none: called before the level changes /** @return {{before: Array, parts: Object, painted?: boolean, sculpted?: boolean}} */ function editor3DStrokeBegin() { return editor3DStroke ||= {before: editor3DCopy(editor3DObjects()), parts: editor3DLevelParts()}; } // change a part of the level as part of the edit being made, as editor3DChange does its objects: change is given // a copy of the part, undefined when the level has none, and returns the new one, or undefined for none; false // when nothing changed function editor3DChangePart(name, change) { if (!editor3DLevel || editor3DRecords.get(editor3DLevel)?.pending) return false; // its autosave waits first const parts = editor3DLevelParts(), after = change(parts[name] && editor3DCopy(parts[name])); if (editor3DSame(parts[name], after)) return false; editor3DStrokeBegin(); editor3DSetPart(name, after); return true; } const editor3DChangeScene = (change)=> editor3DChangePart('scene', change); /////////////////////////////////////////////////////////////////////////////// // the level's map of blocks: the level's voxels block is the source of truth, the game's VoxelMap follows it; // painting sets blocks in the game's map as it goes, and the stroke's end writes them into the level // the game's map of the level's blocks, undefined when the level has none function editor3DVoxelMap() { const map = level3DVoxelMap; return map && !map.destroyed && editor3DLevelPart('voxels') ? map : undefined; } // bring the game's map in line with the level's voxels block: made, made again when its place or size changed, // its cells set, or destroyed with the block gone function editor3DVoxelShow() { const voxels = editor3DLevelPart('voxels'), shape = level3DVoxelsShape(voxels); let map = level3DVoxelMap && !level3DVoxelMap.destroyed ? level3DVoxelMap : undefined; const sameVector = (a, b)=> a.x === b.x && a.y === b.y && a.z === b.z; if (map && !(shape && sameVector(map.pos3D, shape.pos) && sameVector(map.mapSize, shape.size))) { map.destroy(); map = level3DVoxelMap = undefined; } if (!shape) return; if (!map) { level3DVoxelMap = level3DVoxelsMake(voxels); return; } level3DVoxelsDecode(voxels.blocks, map.data); map.rebuild(); } // write the game's map into the level, as a stroke of painting ends function editor3DVoxelStore() { const map = editor3DVoxelMap(), voxels = editor3DLevelPart('voxels'); map && voxels && (voxels.blocks = level3DVoxelsEncode(map.data)); } // give the level an empty map of a size in cells, centered on the origin with its bottom on the ground, as one undo function editor3DVoxelAdd(size=vec3(32, 16, 32)) { const changed = editor3DChangePart('voxels', ()=> ({pos: [-floor(size.x / 2), 0, -floor(size.z / 2)], size: [size.x, size.y, size.z], blocks: [size.x * size.y * size.z, 0]})); editor3DStrokeEnd(); return changed; } // take the level's map away, as one undo function editor3DVoxelRemove() { const changed = editor3DChangePart('voxels', ()=> undefined); editor3DStrokeEnd(); return changed; } // is a cell in a map function editor3DVoxelInside(map, cell) { const s = map.mapSize; return cell.x >= 0 && cell.y >= 0 && cell.z >= 0 && cell.x < s.x && cell.y < s.y && cell.z < s.z; } // 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 // ends; false when it is that already, or there is no such cell function editor3DVoxelSet(cell, type) { const map = editor3DVoxelMap(); if (!map || editor3DRecords.get(editor3DLevel)?.pending) return false; // its autosave waits first if (!editor3DVoxelInside(map, cell) || map.getVoxel(cell) === type) return false; editor3DStrokeBegin().painted = true; map.setVoxel(cell, type); return true; } // 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 // with, in cells from the map's corner, and the layer, the cell's place along the axis; undefined off the map function editor3DVoxelDragCell(drag, ray) { const map = editor3DVoxelMap(), axis = drag.axis, along = ray.direction[axis]; if (!map || !along) return; const distance = (map.pos3D[axis] + drag.plane - ray.origin[axis]) / along; if (distance < 0) return; const p = ray.getPosition(distance).subtract(map.pos3D), cell = vec3(floor(p.x), floor(p.y), floor(p.z)); cell[axis] = drag.layer; return editor3DVoxelInside(map, cell) ? cell : undefined; } /////////////////////////////////////////////////////////////////////////////// // the level's terrain: the level's terrain block is the source of truth, the game's HeightMap follows it; // sculpting changes the game's map as it goes, and the stroke's end writes its heights into the level // the brush: its size across in world units, how strong it is, 0 to 1, which brush it is, sculpt, flatten or // paint, and the color it paints const editor3DTerrainBrush = {size: 8, strength: .5, mode: 'sculpt', color: '#8a6a4a'}; const editor3DTerrainBrushes = ['sculpt', 'flatten', 'paint']; // the paint of the game's terrain map as it is being edited, {colors, cells} as level3DTerrainPaint gives it: // from the level, and ahead of it while a stroke paints const editor3DTerrainPaints = new WeakMap; function editor3DTerrainPaint(map) { let paint = editor3DTerrainPaints.get(map); if (!paint) { const samples = map.rows * map.columns; paint = level3DTerrainPaint(editor3DLevelPart('terrain'), samples) || {colors: [], cells: new Uint8Array(samples)}; editor3DTerrainPaints.set(map, paint); } return paint; } // the next brush of the Terrain tool function editor3DTerrainNextBrush() { const brushes = editor3DTerrainBrushes; editor3DTerrainBrush.mode = brushes[(brushes.indexOf(editor3DTerrainBrush.mode) + 1) % brushes.length]; } // the drag a press on the ground starts, by the brush and the keys held: sculpt raises, Shift lowers; flatten // brings the ground to the height pressed; paint colors it, Shift takes the paint off; Ctrl smooths with the two // that shape the ground function editor3DTerrainPress(point, shift, ctrl) { const map = editor3DTerrainMap(), brush = editor3DTerrainBrush.mode; if (!map) return; if (brush === 'paint') return {kind: 'terrain', mode: shift ? 'erase' : 'paint'}; if (ctrl) return {kind: 'terrain', mode: 'smooth'}; if (brush === 'flatten') return {kind: 'terrain', mode: 'flatten', level: clamp((point.y - map.pos3D.y) / map.height)}; return {kind: 'terrain', mode: shift ? 'lower' : 'raise'}; } // the mouse held on the ground with a terrain drag function editor3DTerrainDragTo(drag, point) { return drag.mode === 'paint' || drag.mode === 'erase' ? editor3DTerrainPaintAt(point, drag.mode === 'erase') : editor3DTerrainSculpt(point, drag.mode, editor3DSeconds, drag.level); } // paint the ground around a point with the brush's color, or take its paint off, as part of the edit being made: // every sample under the brush, in the game's map now and in the level when the stroke ends; false when nothing // changed, or when the terrain has all the colors it can keep, 255 function editor3DTerrainPaintAt(point, erase=false) { const map = editor3DTerrainMap(); if (!map || editor3DRecords.get(editor3DLevel)?.pending) return false; // its autosave waits first const paint = editor3DTerrainPaint(map), hex = editor3DInputColor(editor3DTerrainBrush.color); let color = erase ? 0 : paint.colors.indexOf(hex) + 1; if (!erase && !color && paint.colors.length >= 255) return false; const radius = editor3DTerrainBrush.size / 2, rows = map.rows, columns = map.columns; const stepX = map.mapSize.x / (columns - 1), stepZ = map.mapSize.y / (rows - 1); const x0 = map.pos3D.x - map.mapSize.x / 2, z0 = map.pos3D.z - map.mapSize.y / 2; let changed = false; for (let r = 0; r < rows; ++r) for (let c = 0; c < columns; ++c) { if (hypot(x0 + c * stepX - point.x, z0 + r * stepZ - point.z) >= radius) continue; color ||= erase ? 0 : paint.colors.push(hex); // a new color joins the list with the first sample it paints if (paint.cells[r * columns + c] === color) continue; paint.cells[r * columns + c] = color; changed = true; } if (!changed) return false; editor3DStrokeBegin().sculpted = true; level3DTerrainSetColors(map, editor3DLevelPart('terrain'), paint); map.rebuild(); return true; } // the game's map of the level's terrain, undefined when the level has none function editor3DTerrainMap() { const map = level3DTerrainMap; return map && !map.destroyed && editor3DLevelPart('terrain') ? map : undefined; } // bring the game's map in line with the level's terrain block: made, made again when its place or shape changed, // its heights and color set, or destroyed with the block gone function editor3DTerrainShow() { const terrain = editor3DLevelPart('terrain'), shape = level3DTerrainShape(terrain); let map = level3DTerrainMap && !level3DTerrainMap.destroyed ? level3DTerrainMap : undefined; const fits = map && shape && map.pos3D.distance(shape.pos) === 0 && map.mapSize.distance(shape.size) === 0 && map.height === shape.height && map.rows === shape.heights.length && map.columns === shape.heights[0].length; if (map && !fits) { map.destroy(); map = level3DTerrainMap = undefined; } if (!shape) return; if (!map) { level3DTerrainMap = level3DTerrainMake(terrain); return; } map.heights = shape.heights; editor3DTerrainPaints.delete(map); // as the level has it level3DTerrainSetColors(map, terrain); map.rebuild(); } // write the game's map into the level, as a stroke of sculpting ends, its heights to a thousandth, and its paint: // the colors still in use and runs of a count and a color along the rows, or no paint when none is left function editor3DTerrainStore() { const map = editor3DTerrainMap(), terrain = editor3DLevelPart('terrain'); if (!map || !terrain) return; // the heights changed to a thousandth, the rest as the level has them, so a stroke of paint rounds none const before = terrain.heights; terrain.heights = map.heights.map((row, z)=> row.map((v, x)=> v === before?.[z]?.[x] ? v : round(v * 1e3) / 1e3)); const paint = editor3DTerrainPaints.get(map); if (!paint) return; // nothing painted since the level's was read const used = paint.colors.map((hex, i)=> paint.cells.includes(i + 1)); const colors = paint.colors.filter((hex, i)=> used[i]), cells = []; const index = paint.colors.map((hex, i)=> used[i] ? colors.indexOf(hex) + 1 : 0); for (const v of paint.cells) { const color = v && index[v - 1]; cells[cells.length - 1] === color ? ++cells[cells.length - 2] : cells.push(1, color); } colors.length ? terrain.paint = {colors, cells} : delete terrain.paint; editor3DTerrainPaints.delete(map); // read from the level again, with its colors as they are numbered now } // give the level a flat terrain, as one undo: its size in the world, how many cells a side, and how tall a full // 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 function editor3DTerrainAdd(size=vec2(64), cells=64, height=16) { const samples = clamp(floor(cells), 1, LEVEL3D_TERRAIN_SAMPLES - 1) + 1; const changed = editor3DChangePart('terrain', ()=> ({pos: [0, editor3DRound(-height * .2), 0], size: [size.x, size.y], height, color: '#6a9955', heights: Array.from({length: samples}, ()=> Array(samples).fill(.2))})); editor3DStrokeEnd(); return changed; } // take the level's terrain away, as one undo function editor3DTerrainRemove() { const changed = editor3DChangePart('terrain', ()=> undefined); editor3DStrokeEnd(); return changed; } // sculpt the ground around a point for some seconds, as part of the edit being made: raise, lower or smooth it // under the brush, a soft circle, most in its middle and nothing at its edge, or flatten it toward a height, level, // 0 to 1; in the game's map now, in the level when the stroke ends; false when nothing changed function editor3DTerrainSculpt(point, mode, seconds, level=0) { const map = editor3DTerrainMap(); if (!map || editor3DRecords.get(editor3DLevel)?.pending) return false; // its autosave waits first const {size, strength} = editor3DTerrainBrush, radius = size / 2, heights = map.heights; const rows = map.rows, columns = map.columns; const stepX = map.mapSize.x / (columns - 1), stepZ = map.mapSize.y / (rows - 1); const x0 = map.pos3D.x - map.mapSize.x / 2, z0 = map.pos3D.z - map.mapSize.y / 2; const c0 = max(0, ceil((point.x - radius - x0) / stepX)), c1 = min(columns - 1, floor((point.x + radius - x0) / stepX)); const r0 = max(0, ceil((point.z - radius - z0) / stepZ)), r1 = min(rows - 1, floor((point.z + radius - z0) / stepZ)); // smoothing reads the ground as it was, so the order the samples are done in does not show const old = mode === 'smooth' ? heights.map((row, r)=> r >= r0 - 1 && r <= r1 + 1 ? row.slice() : row) : heights; const rate = strength * 10 * seconds; // world units of height at full strength, 10 a second let changed = false; for (let r = r0; r <= r1; ++r) for (let c = c0; c <= c1; ++c) { const distance = hypot(x0 + c * stepX - point.x, z0 + r * stepZ - point.z); if (distance >= radius) continue; const falloff = .5 + .5 * cos(PI * distance / radius), h = heights[r][c]; let v; if (mode === 'smooth') { // toward the average of the samples beside it let sum = 0, count = 0; for (const [dr, dc] of [[-1, 0], [1, 0], [0, -1], [0, 1]]) old[r + dr]?.[c + dc] !== undefined && (sum += old[r + dr][c + dc], ++count); v = h + (sum / count - h) * min(1, rate * falloff); } else if (mode === 'flatten') v = h + (level - h) * min(1, rate * falloff); else v = clamp(h + (mode === 'lower' ? -1 : 1) * rate * falloff / map.height); if (v === h) continue; heights[r][c] = v; changed = true; } if (!changed) return false; editor3DStrokeBegin().sculpted = true; map.rebuild(); return true; } // what the Blocks tool does to a cell: place puts the current type in an empty one, paint gives a block the // current type, remove empties it function editor3DVoxelApply(cell, mode) { const map = editor3DVoxelMap(), has = map ? map.getVoxel(cell) : 0; return mode === 'place' ? !has && editor3DVoxelSet(cell, editor3DBlockType) : mode === 'paint' ? !!has && editor3DVoxelSet(cell, editor3DBlockType) : editor3DVoxelSet(cell, 0); } // 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 // same layer; undefined with no cell there function editor3DVoxelPress(ray, mode) { const target = editor3DVoxelTarget(ray, mode); if (!target) return; const {cell, axis, plane, side} = target; if (editor3DBlockBox) { // a box grows out from the face when placing, and into the blocks when removing or repainting const drag = {kind: 'blocks', mode, axis, plane, layer: cell[axis], last: cell, start: cell, grow: mode === 'place' ? side : -side, height: max(1, floor(editor3DBlockBoxHeight)), changed: new Map, region: undefined}; editor3DVoxelBoxTo(drag, cell); return drag; } editor3DVoxelApply(cell, mode); return {kind: 'blocks', mode, axis, plane, layer: cell[axis], last: cell}; } // 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 // cell of the same layer is changed, as many layers as its height, the part of it inside the map function editor3DVoxelBoxTo(drag, cell) { const map = editor3DVoxelMap(); if (!map) return; for (const [at, type] of drag.changed.values()) map.setVoxel(at, type); drag.changed.clear(); const lo = vec3(min(drag.start.x, cell.x), min(drag.start.y, cell.y), min(drag.start.z, cell.z)); const hi = vec3(max(drag.start.x, cell.x), max(drag.start.y, cell.y), max(drag.start.z, cell.z)); const far = drag.layer + drag.grow * (drag.height - 1), axis = drag.axis, s = map.mapSize; lo[axis] = clamp(min(drag.layer, far), 0, s[axis] - 1); hi[axis] = clamp(max(drag.layer, far), 0, s[axis] - 1); for (let z = lo.z; z <= hi.z; ++z) for (let y = lo.y; y <= hi.y; ++y) for (let x = lo.x; x <= hi.x; ++x) { const at = vec3(x, y, z), type = map.getVoxel(at); editor3DVoxelApply(at, drag.mode) && drag.changed.set(x + s.x * (y + s.y * z), [at, type]); } drag.region = {lo, hi}; drag.last = cell; } // change the size of the level's map in cells, as one undo: it grows and shrinks about its middle across, and at // its top, and its blocks stay where they are in the world, the ones outside the new size gone function editor3DVoxelResize(size) { const changed = editor3DChangePart('voxels', (voxels)=> { const shape = level3DVoxelsShape(voxels); const to = vec3(floor(size.x), floor(size.y), floor(size.z)); if (!shape || to.x < 1 || to.y < 1 || to.z < 1 || to.x * to.y * to.z > LEVEL3D_VOXEL_CELLS) return voxels; const from = shape.size, old = new Uint8Array(from.x * from.y * from.z); level3DVoxelsDecode(voxels.blocks, old); // how far each block moves in cells, so the corner moves the other way and the blocks stay put const dx = floor((to.x - from.x) / 2), dz = floor((to.z - from.z) / 2); const data = new Uint8Array(to.x * to.y * to.z); for (let z = 0; z < from.z; ++z) for (let y = 0; y < min(from.y, to.y); ++y) for (let x = 0; x < from.x; ++x) { const nx = x + dx, nz = z + dz; if (nx >= 0 && nz >= 0 && nx < to.x && nz < to.z) data[nx + to.x * (y + to.y * nz)] = old[x + from.x * (y + from.y * z)]; } return {...voxels, pos: [shape.pos.x - dx, shape.pos.y, shape.pos.z - dz], size: [to.x, to.y, to.z], blocks: level3DVoxelsEncode(data)}; }); editor3DStrokeEnd(); return changed; } // a drag of the Blocks tool goes on: every cell of the layer from the last one to the one under the ray function editor3DVoxelDragTo(drag, ray) { const cell = editor3DVoxelDragCell(drag, ray), last = drag.last; if (!cell) return; if (drag.changed) return editor3DVoxelBoxTo(drag, cell); const d = cell.subtract(last), steps = max(abs(d.x), abs(d.y), abs(d.z)); for (let i = 1; i <= steps; ++i) editor3DVoxelApply(vec3(round(last.x + d.x * i / steps), round(last.y + d.y * i / steps), round(last.z + d.z * i / steps)), drag.mode); drag.last = cell; } // make the type of the block under a ray the one to place; false with no block there function editor3DVoxelPick(ray) { const hit = editor3DVoxelMap()?.raycast(ray); if (!hit) return false; editor3DBlockType = hit.type; } // the cell a click is for: to place, the empty cell against the face the ray hits, or the one standing on the // map's floor where it hits no block; otherwise the block it hits; with the axis the face is across and the plane // of the face, for a drag to stay on; undefined with no such cell function editor3DVoxelTarget(ray, mode) { const map = editor3DVoxelMap(); if (!map) return; const place = mode === 'place', hit = map.raycast(ray); if (!hit) { const cell = place ? editor3DVoxelDragCell({axis: 'y', plane: 0, layer: 0}, ray) : undefined; return cell && {cell, axis: 'y', plane: 0, side: 1}; } const n = hit.normal, axis = n.x ? 'x' : n.y ? 'y' : 'z', cell = place ? hit.cell.add(n) : hit.cell; if (!editor3DVoxelInside(map, cell)) return; // the face between the block hit and the cell in front of it, and side, the way that face looks along the axis return {cell, axis, plane: cell[axis] + ((n[axis] > 0) === place ? 0 : 1), side: n[axis]}; } // end the edit being made: one undo, and the autosave function editor3DStrokeEnd() { const stroke = editor3DStroke; editor3DStroke = undefined; if (!stroke) return; stroke.painted && editor3DVoxelStore(); // the blocks painted go into the level stroke.sculpted && editor3DTerrainStore(); // and the ground sculpted const parts = editor3DLevelParts(); if (editor3DSame(stroke.before, editor3DObjects()) && editor3DSame(stroke.parts, parts)) return; editor3DUndoList.push({before: stroke.before, after: editor3DCopy(editor3DObjects()), partsBefore: stroke.parts, partsAfter: parts}); editor3DUndoList.length > 100 && editor3DUndoList.shift(); editor3DRedoList.length = 0; editor3DAutosaveSoon(); } // take the edit being made back, with nothing to undo function editor3DStrokeCancel() { const stroke = editor3DStroke; editor3DStroke = undefined; if (!stroke) return; editor3DSetObjects(stroke.before); stroke.painted && editor3DVoxelShow(); // the level never had them, the game's map goes back to it stroke.sculpted && editor3DTerrainShow(); editor3DSetParts(stroke.parts); } // undo the last edit, or redo the last one undone; false with none function editor3DUndo(redo=false) { editor3DStrokeEnd(); const entry = (redo ? editor3DRedoList : editor3DUndoList).pop(); if (!entry) return false; (redo ? editor3DUndoList : editor3DRedoList).push(entry); editor3DSetObjects(redo ? entry.after : entry.before); editor3DSetParts(redo ? entry.partsAfter : entry.partsBefore); editor3DAutosaveSoon(); return true; } // write an object's position, rotation in degrees and scale as the file keeps them: rounded, and the rotation and // scale left out when they are the default; one not given is left as it is function editor3DSetTransform(object, pos, rotation, scale) { const list = (v)=> [editor3DRound(v.x), editor3DRound(v.y), editor3DRound(v.z)]; pos && (object.pos = list(pos)); if (rotation) list(rotation).some((v)=> v) ? object.rotation = list(rotation) : delete object.rotation; if (scale) list(scale).some((v)=> v !== 1) ? object.scale = list(scale) : delete object.scale; } // set an object's property as the file keeps it, only where it differs from its type's default: a Color as a hex // string, with its alpha when it has one, and a vector as an array function editor3DSetProperty(object, name, value, defaultValue) { const encode = (v)=> isColor(v) ? v.toString(v.a < 1) : isVector3(v) ? [v.x, v.y, v.z] : isVector2(v) ? [v.x, v.y] : v; const properties = {...object.properties}; if (editor3DSame(encode(value), encode(defaultValue))) delete properties[name]; else properties[name] = encode(value); Object.keys(properties).length ? object.properties = properties : delete object.properties; } // add an object of a type at a position, selected; its id, or undefined when the level can not be edited function editor3DPlace(type, pos) { if (editor3DPrefabSelf(type)) return; // it would hold itself let id; const placed = editor3DChange((list)=> { const object = {id: id = list.reduce((next, o)=> max(next, o.id + 1), 1), type}; editor3DSetTransform(object, pos); list.push(object); }); if (!placed) return; editor3DSelection.clear(); editor3DSelection.add(id); return id; } /////////////////////////////////////////////////////////////////////////////// // prefabs: a selection made one, an instance made objects again, and a prefab's own settings // what the panel says about the last prefab action that was refused let editor3DPrefabMessage = ''; // the box around an object in the world, turned and sized as it is, as its low and high corners function editor3DWorldBox(object, low=vec3(Infinity), high=vec3(-Infinity)) { const matrix = editor3DBoxMatrix(object); for (let i = 8; i--;) { const p = matrix.transformPoint(vec3(i & 1 ? .5 : -.5, i & 2 ? .5 : -.5, i & 4 ? .5 : -.5)); for (const k of ['x', 'y', 'z']) low[k] = min(low[k], p[k]), high[k] = max(high[k], p[k]); } return {low, high}; } // is a type the prefab being edited, or one that holds it: a prefab that is open, or one the game made of the // level being edited itself, as a prefab maker does; placed there it would hold itself function editor3DPrefabSelf(type) { const names = editor3DPrefabStack.map((frame)=> frame.name); for (const [name, prefab] of level3DPrefabs) prefab.source === editor3DLevel && names.push(name); return names.some((name)=> editor3DPrefabHolds(type, name)); } // the types the Place list has, the plain ones and then the prefabs function editor3DPlaceNames() { const all = [...level3DTypes.keys()].filter((name)=> !editor3DPrefabSelf(name)); return [...all.filter((name)=> !level3DPrefabs.has(name)), ...all.filter((name)=> level3DPrefabs.has(name))]; } // does a type hold a prefab, itself or through the prefabs it holds function editor3DPrefabHolds(type, name, depth=0) { return type === name || depth < 8 && !!level3DPrefabs.get(type)?.objects.some((o)=> editor3DPrefabHolds(o.type, name, depth + 1)); } // make the selected objects a prefab of the level's own, about the bottom center of their box, and put one // instance of it where they were, as one undo; a name the level's prefabs have replaces that prefab, and every // instance of it changes; true, or why not, which the panel shows function editor3DMakePrefab(name='') { const refuse = (why)=> editor3DPrefabMessage = why; const selected = editor3DSelected(); if (editor3DPrefabStack.length) return refuse('Go back to the level to make a prefab'); if (!selected.length) return refuse('Select the objects to make a prefab of'); if (!editor3DLevel || editor3DRecords.get(editor3DLevel)?.pending) return refuse('The level can not be edited now'); name = String(name).trim(); for (let i = 1; !name; ++i) level3DTypes.has('Prefab ' + i) || (name = 'Prefab ' + i); const known = level3DPrefabs.get(name); if (known ? !known.fromLevel : level3DTypes.has(name)) return refuse(name + ' is a type the game added, pick another name'); if (selected.some((o)=> editor3DPrefabHolds(o.type, name))) return refuse(name + ' can not hold itself'); // its origin is the bottom center of the selection, so an instance stands on the ground const low = vec3(Infinity), high = vec3(-Infinity); for (const object of selected) editor3DWorldBox(object, low, high); const origin = vec3(editor3DRound((low.x + high.x) / 2), editor3DRound(low.y), editor3DRound((low.z + high.z) / 2)); const objects = selected.map((object, i)=> { const part = {...editor3DCopy(object), id: i + 1}; editor3DSetTransform(part, editor3DPos(object).subtract(origin)); return part; }); editor3DStrokeEnd(); const id = editor3DObjects().reduce((next, o)=> max(next, o.id + 1), 1); const ids = new Set(selected.map((o)=> o.id)); // the prefab first, so its type is there when the instance is made editor3DChangePart('prefabs', (prefabs={})=> ({...prefabs, [name]: prefabs[name]?.attached ? {attached: true, objects} : {objects}})); editor3DChange((list)=> { for (let i = list.length; i--;) ids.has(list[i].id) && list.splice(i, 1); const instance = {id, type: name}; editor3DSetTransform(instance, origin); list.push(instance); }); editor3DStrokeEnd(); editor3DSelection.clear(); editor3DSelection.add(id); editor3DPrefabMessage = ''; return true; } // add a prefab from the text of a file the editor saved, a level file, to the level's own prefabs under a name, // as one undo, and pick it to place; a name the level's prefabs have is replaced, as Make prefab does; true, or // why not, which the panel shows function editor3DPrefabLoad(name, text) { const refuse = (why)=> editor3DPrefabMessage = why; if (editor3DPrefabStack.length) return refuse('Go back to the level to load a prefab'); if (!editor3DLevel || editor3DRecords.get(editor3DLevel)?.pending) return refuse('The level can not be edited now'); name = String(name).trim(); if (!name) return refuse('The prefab needs a name'); let file; try { file = JSON.parse(text); } catch { return refuse(name + ' is not a JSON file'); } if (!file || typeof file !== 'object' || !isArray(file.objects)) return refuse(name + ' is not a prefab, it has no objects'); const known = level3DPrefabs.get(name); if (known ? !known.fromLevel : level3DTypes.has(name)) return refuse(name + ' is a type the game added, rename the file'); const objects = file.objects.filter((o)=> o && typeof o === 'object'); if (objects.some((o)=> editor3DPrefabHolds(o.type, name))) return refuse(name + ' can not hold itself'); editor3DStrokeEnd(); editor3DChangePart('prefabs', (prefabs={})=> ({...prefabs, [name]: file.attached ? {attached: true, objects} : {objects}})); editor3DStrokeEnd(); editor3DPickTool(editor3DTool); // a tool of the game's own is put down editor3DBrush = name; editor3DPrefabMessage = ''; return true; } // pick a prefab's file and load it, by its file name function editor3DPrefabPickFile() { const input = document.createElement('input'); input.type = 'file'; input.accept = '.json,application/json'; input.onchange = async ()=> { const file = input.files?.[0]; file && editor3DPrefabLoad(file.name.replace(/\.json$/i, ''), await file.text()); }; input.click(); } // turn the selected instances of prefabs into the objects they are made of, where they are, as one undo; false // with none selected function editor3DUnpack() { const instances = editor3DSelected().filter((o)=> editor3DInstances.get(o.id) instanceof Prefab3D); if (!instances.length) return false; editor3DStrokeEnd(); const added = []; const changed = editor3DChange((list)=> { let id = list.reduce((next, o)=> max(next, o.id + 1), 1); for (const instance of instances) { const made = editor3DInstances.get(instance.id); // by the level's own place of it, its origin, which is not where an attached one's handle is const origin = {pos3D: editor3DPos(instance), rotation3D: editor3DRotation(instance).scale(PI / 180), scale3D: editor3DScale(instance)}; for (const part of level3DPrefabs.get(made.prefabName)?.objects ?? []) { const at = level3DPrefabPartTransform(origin, part), object = {...editor3DCopy(part), id: id++}; editor3DSetTransform(object, at.pos, at.rotation.scale(180 / PI), at.scale); list.push(object); added.push(object.id); } list.splice(list.findIndex((o)=> o.id === instance.id), 1); } }); editor3DStrokeEnd(); if (!changed) return false; editor3DSelection.clear(); for (const id of added) editor3DSelection.add(id); return true; } // make a prefab of the level's own attached, its parts the children of each instance, or not, as one undo function editor3DPrefabSetAttached(name, attached) { if (editor3DPrefabStack.length) return false; // a prefab is the level's, set from the level editor3DStrokeEnd(); const changed = editor3DChangePart('prefabs', (prefabs)=> { if (!prefabs?.[name]) return prefabs; const {attached: was, ...rest} = prefabs[name]; return {...prefabs, [name]: attached ? {attached: true, ...rest} : rest}; }); editor3DStrokeEnd(); return changed; } // a prefab as its own file has it, a level file of its objects, to load with level3DLoadPrefab function editor3DPrefabJSON(name) { const prefab = level3DPrefabs.get(name); if (!prefab) return ''; return editor3DLevelJSON({...(prefab.attached ? {attached: true} : {}), objects: prefab.objects}); } /////////////////////////////////////////////////////////////////////////////// // Edit prefab: the editor on a prefab alone, the level it came from put away until Back // the prefabs opened one inside the other, each with the level it was opened from, its name, the selection there // and the ids of that level's objects that had something made for them /** @type {Array<{level: Object, name: string, selection: Array, made: Set, entered?: string}>} */ const editor3DPrefabStack = []; // the levels that are a prefab being edited, they have no autosave of their own const editor3DPrefabLevels = new WeakSet; // what was edited in prefabs of the level's own since the level was left, written into it on the way back /** @type {Object} */ let editor3DPrefabEdits = {}; // the prefabs of the game's own that were edited and are not in a file yet const editor3DPrefabDirty = new Set; // what is known of each prefab's file, by its name, so it outlasts the prefab being closed and opened again: the // save being written, for the next to wait on, what the file has since a Save, and what the prefab holds now /** @type {Map, saved?: string, content?: string}>} */ const editor3DPrefabFiles = new Map; function editor3DPrefabFile(name) { editor3DPrefabFiles.has(name) || editor3DPrefabFiles.set(name, {}); return editor3DPrefabFiles.get(name); } // say if a prefab of the game's own is in its file, now that it holds this: once a Save has written it, it is // when what it holds is what was written; before any Save it is not once it was edited function editor3DPrefabFileCheck(name, content, edited=false) { const file = editor3DPrefabFile(name); file.content = content; if (level3DPrefabs.get(name)?.fromLevel) return; // one of the level's own is saved with the level if (file.saved !== undefined) content === file.saved ? editor3DPrefabDirty.delete(name) : editor3DPrefabDirty.add(name); else if (edited) editor3DPrefabDirty.add(name); } // destroy what the level being edited has in the world, its objects' and its maps' function editor3DPrefabClear() { for (const made of editor3DInstances.values()) made?.destroy?.(); editor3DInstances.clear(); for (const map of [level3DVoxelMap, level3DTerrainMap]) map && !map.destroyed && map.destroy(); } // open the prefab of an instance, the selected one when none is given, to edit alone: its objects about its own // origin, with an undo of its own, and the level it is in put away; false when it is not a prefab's instance function editor3DPrefabEnter(id) { editor3DAutosaveFlush(); // the level's waiting autosave, before another level is the one edited const selected = editor3DSelected(); id ??= selected.length === 1 ? selected[0].id : undefined; const made = editor3DInstances.get(id), from = editor3DLevel; const prefab = made instanceof Prefab3D && level3DPrefabs.get(made.prefabName); if (!prefab || editor3DRecords.get(from)?.pending) return false; editor3DStrokeEnd(); editor3DDrag = editor3DHover = undefined; const frame = {level: from, name: made.prefabName, selection: [...editor3DSelection], made: new Set(editor3DInstances.keys()), entered: undefined}; editor3DPrefabStack.push(frame); editor3DPrefabClear(); const level = {littlejs3D: LEVEL3D_VERSION, ...(prefab.attached ? {attached: true} : {}), objects: editor3DCopy(prefab.objects)}; editor3DPrefabLevels.add(level); level3DLoad(level); // the editor takes it as it does any level frame.entered = JSON.stringify(editor3DObjects()); // as the editor has it, an edit is what differs from this return true; } // leave the prefab being edited for the level it was opened from: the prefab is what was edited, every instance // of it is made again, and back in the level itself what was edited in its own prefabs is one undo; false when // no prefab is open function editor3DPrefabBack() { const frame = editor3DPrefabStack.pop(); if (!frame) return false; editor3DStrokeEnd(); editor3DDrag = editor3DHover = undefined; const known = level3DPrefabs.get(frame.name); const objects = editor3DCopy(editor3DObjects()), changed = JSON.stringify(objects) !== frame.entered; editor3DPrefabClear(); if (changed) { // the prefab as it is now, for whatever is made next; one of the game's own waits to be saved to its file const prefab = known?.attached ? {attached: true, objects} : {objects}, fromLevel = !!known?.fromLevel; level3DPrefabSet(frame.name, prefab, fromLevel); if (fromLevel) editor3DPrefabEdits[frame.name] = prefab; } // one of the game's own is in its file or not by what it holds now, edited here or not: undone back to how it // was opened after a Save, it is not what the file has editor3DPrefabFileCheck(frame.name, JSON.stringify(objects), changed); // the level it was opened from is the editor's again, with its own undo const level = editor3DLevel = frame.level, record = editor3DRecords.get(level); editor3DUndoList = record.undo, editor3DRedoList = record.redo; editor3DStroke = undefined; if (!editor3DPrefabStack.length) { // back in the level: its own prefabs that were edited go into it, as one undo const edits = editor3DPrefabEdits; editor3DPrefabEdits = {}; Object.keys(edits).length && editor3DChangePart('prefabs', (prefabs={})=> ({...prefabs, ...edits})); editor3DStrokeEnd(); } // what the level had in the world is made again: what a class makes, and what else had something made for (const object of editor3DObjects()) { const type = level3DTypes.get(object.type); type && (type.make.prototype || frame.made.has(object.id)) && editor3DMakeInstance(object); } editor3DSceneRestore(record.sceneBase); level3DSceneApply(level.scene); editor3DVoxelShow(); editor3DTerrainShow(); editor3DShadowLight(); editor3DSelection.clear(); for (const id of frame.selection) editor3DObject(id) && editor3DSelection.add(id); return true; } // remove the selected objects, as one undo function editor3DDelete() { if (!editor3DSelection.size) return false; editor3DStrokeEnd(); editor3DChange((list)=> { for (let i = list.length; i--;) editor3DSelection.has(list[i].id) && list.splice(i, 1); }); editor3DStrokeEnd(); } // copy the selected objects function editor3DCopySelection() { const selected = editor3DSelected(); if (!selected.length) return false; editor3DClipboard = editor3DCopy(selected); // how big each was and where its box was about its place: what is cut has nothing left to measure editor3DClipboardBoxes = new Map(editor3DClipboard.map((copy, i)=> [copy, {size: editor3DSize(selected[i]), offset: editor3DBoxOffset(selected[i])}])); } // copy the selected objects and remove them function editor3DCut() { return editor3DCopySelection() === false ? false : editor3DDelete(); } // add the copied objects, moved by an offset, with new ids and selected, as one undo function editor3DPaste(offset=vec3()) { const copied = editor3DClipboard, ids = []; if (!copied?.length) return false; editor3DStrokeEnd(); const pasted = editor3DChange((list)=> { let id = list.reduce((next, o)=> max(next, o.id + 1), 1); for (const from of copied) { const object = {...editor3DCopy(from), id: id++}; editor3DSetTransform(object, editor3DPos(from).add(offset)); list.push(object); ids.push(object.id); } }); editor3DStrokeEnd(); if (!pasted) return false; editor3DSelection.clear(); ids.forEach((id)=> editor3DSelection.add(id)); } // copy the selected objects where they are and select the copies, the clipboard stays as it was function editor3DDuplicate() { const clipboard = editor3DClipboard; const done = editor3DCopySelection() === false ? false : editor3DPaste(); editor3DClipboard = clipboard; return done; } /////////////////////////////////////////////////////////////////////////////// // the session: opening, playing and closing // if the level editor is the 3D one: as levelEditor.use3D says, or when a 3D level was loaded function editor3DWanted() { return !!render3D && (levelEditor.use3D ?? !!editor3DLevel); } // put every object back where the level has it, and make one the game destroyed again, so what is on screen is // what Save writes; a type made by an arrow function made nothing to put back function editor3DRestore() { for (const object of editor3DObjects()) { const type = level3DTypes.get(object.type), made = editor3DInstances.get(object.id); if (!type) continue; // a prefab's instance that play took a part of is made again, whole const broken = (p)=> p instanceof Prefab3D && p.parts.some((part)=> part?.destroyed || broken(part)); if (made instanceof EngineObject3D && !made.destroyed && !broken(made)) editor3DPlaceInstance(made, object); else if (type.make.prototype && (!made || made.destroyed || broken(made))) editor3DMakeInstance(object); } // the blocks and the terrain as the level has them: what play dug, raised or destroyed is not the level's, // and must not be written into it by the next stroke editor3DVoxelShow(); editor3DTerrainShow(); } // open or close the editor, the session goes on: the game is paused under it and reads no input function editor3DSetOpen(open) { open = !!open; if (!debug || editor3DIsOpen === open) return; if (open) { editor3DSetFreeCamera(false); pointerLockIsActive() && pointerLockExit(); // the game's captured mouse is let go, the editor needs it editor3DLevel || editor3DLevelLoaded({}); // a new level, with nothing loaded editor3DCamera || editor3DCameraStart(); editor3DGamePaused = paused; setPaused(true); setDebugOverlay(false); // out of the way of the level editor3DIsOpen = true; editor3DRestore(); editorCall('onOpen'); editorKeyClashes(); } else { editor3DDrag = editor3DHover = undefined; editor3DMouseOnPanel = false; // the panel hides, with no mouseleave editor3DStrokeEnd(); while (editor3DPrefabBack()); // the game plays the level, not a prefab that was open editor3DAutosaveFlush(); // the game plays on with what the editor has kept editor3DToolHeld = false; editor3DIsOpen = false; setPaused(editor3DGamePaused); editorCall('onClose'); } inputCapture(editor3DViewOn()); } // open the editor, a new session starts at the game's camera and a return keeps the editor's function editor3DOpen() { editor3DSession || editor3DCameraStart(); editor3DSession = true; editor3DSetOpen(true); } // close the editor and end its session, Escape opens the debug overlay again function editor3DClose() { editor3DSession = false; editor3DSetOpen(false); } // switch to playing, from a position when Play from mouse is on function editor3DPlay(pos) { editor3DSetOpen(false); editor3DPlayFromMouse && pos && editorHas('onPlayFrom') && levelEditor.onPlayFrom(pos.copy()); } // rebuild the level through the game's hook and play it function editor3DRestart() { if (!editorHas('onRestart')) return; editor3DSetOpen(false); levelEditor.onRestart(); } // 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 // key it is on, or its name const editor3DKeyMarks = {'?': 'Slash', '/': 'Slash', ',': 'Comma', '.': 'Period', ';': 'Semicolon', "'": 'Quote', '[': 'BracketLeft', ']': 'BracketRight', '-': 'Minus', '=': 'Equal', '\\': 'Backslash', '`': 'Backquote'}; function editor3DKeyCode(name) { return name.length !== 1 ? name : editor3DKeyMarks[name] ?? (name >= '0' && name <= '9' ? 'Digit' + name : 'Key' + name.toUpperCase()); } // the 3D editor's edit functions, what levelEditor.edit3D gives a game's own keys, buttons and tools const editor3DEdit = { get level() { return editor3DLevel; }, get objects() { return editor3DObjects(); }, get selection() { return editor3DSelection; }, selected() { return editor3DSelected(); }, made(id) { return editor3DInstances.get(id); }, change(change) { return editor3DChange(change); }, changePart(name, change) { return editor3DLevelPartNames.includes(name) && editor3DChangePart(name, change); }, strokeEnd() { editor3DStrokeEnd(); }, strokeCancel() { editor3DStrokeCancel(); }, place(type, pos3D) { return level3DTypes.has(type) ? editor3DPlace(type, pos3D) : undefined; }, setTransform(object, pos3D, rotation, scale3D) { editor3DSetTransform(object, pos3D, rotation, scale3D); }, setProperty(object, name, value) { editor3DSetProperty(object, name, value, level3DTypes.get(object.type)?.defaults[name]); }, pos(object) { return editor3DPos(object); }, rotation(object) { return editor3DRotation(object); }, scale(object) { return editor3DScale(object); }, mousePoint() { return editor3DIsOpen && !editor3DMouseOnPanel ? editor3DWithView(editor3DMousePoint) : undefined; }, undo(redo=false) { return editor3DUndo(redo); }, toJSON() { return editor3DLevel ? editor3DLevelJSON() : ''; }, }; // 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 // editor's keys and edit functions through this if (debug) editorOther = {wanted: editor3DWanted, isOpen: ()=> editor3DIsOpen, active: ()=> editor3DIsOpen || editor3DSession, open: editor3DOpen, close: editor3DClose, edit: editor3DEdit, hasKey: (name, ctrl)=> !!(ctrl ? editor3DCtrlKeys : editor3DKeys)[editor3DKeyCode(name)]}; // pick a tool of the editor's own, which puts a tool of the game's down function editor3DPickTool(tool) { editor3DTool = tool; levelEditor.tool = undefined; } // 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, // since picking one of those puts the game's tool down let editor3DToolHeld = false, editor3DToolName, editor3DToolPicked = []; // what a tool's callbacks are given: where the mouse is on the level or the ground, its ray, and the keys held function editor3DToolAt(ray=render3D.screenToRay(mousePosScreen), shift=false, ctrl=false) { return {pos: editor3DMouseOnPanel ? undefined : editor3DSurface(ray), cell: undefined, ray, shift, ctrl}; } // the game's tool, when one is on: the left button is its, a press, a drag and a release one undo, the right // button takes a held press back; true when it has the mouse function editor3DToolUpdate(ray, idle, shift, ctrl) { const picked = [editor3DTool, editor3DBrush]; if (levelEditor.tool !== editor3DToolName) editor3DToolName = levelEditor.tool, editor3DToolPicked = picked; // turned on, or off, by its key or button else if (picked.some((value, i)=> value !== editor3DToolPicked[i])) levelEditor.tool = editor3DToolName = undefined; // a tool or a type of the editor's was picked const tool = editorGameTool(); if (!tool) { editor3DToolHeld = false; return false; } const at = editor3DToolAt(ray, shift, ctrl); editor3DHover = editor3DBlockHover = editor3DTerrainHover = undefined; // the editor's own cursors are put away if (editor3DToolHeld && mouseWasPressed(2)) { editor3DStrokeCancel(); editor3DToolHeld = false; } else if (idle && mouseWasPressed(0)) editor3DToolHeld = tool.onPress?.(at) !== false; else if (editor3DToolHeld && mouseIsDown(0)) tool.onDrag?.(at); if (editor3DToolHeld && !mouseIsDown(0)) { tool.onRelease?.(at); editor3DStrokeEnd(); editor3DToolHeld = false; } return true; } // 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 function editor3DDrawGame() { if (!editor3DIsOpen) return; editorCall('onDraw'); const held = (...codes)=> inputCaptureRead(()=> codes.some((code)=> keyIsDown(code))); editorGameTool()?.onDraw?.(editor3DToolAt(undefined, held('ShiftLeft', 'ShiftRight'), held('ControlLeft', 'ControlRight', 'MetaLeft', 'MetaRight'))); } /////////////////////////////////////////////////////////////////////////////// // files: the level as JSON, Save, and the autosave // a value as JSON on one line, with a space after each comma and colon, as a person would write it function editor3DJSON(value) { if (isArray(value)) return '[' + value.map(editor3DJSON).join(', ') + ']'; if (value && typeof value === 'object') return '{' + Object.entries(value).filter(([, v])=> v !== undefined) .map(([key, v])=> JSON.stringify(key) + ': ' + editor3DJSON(v)).join(', ') + '}'; return JSON.stringify(value) ?? 'null'; } // a level as the file has it: its version, what else it holds, and its objects, one to a line function editor3DLevelJSON(level=editor3DLevel) { // the level's parts after the rest, in one order, so the same level is the same file however its parts were made const {littlejs3D, objects, ...rest} = level; const lines = [`"littlejs3D": ${LEVEL3D_VERSION}`]; const keys = Object.keys(rest).filter((key)=> !editor3DLevelPartNames.includes(key)); for (const key of [...keys, ...editor3DLevelPartNames.filter((name)=> name in rest)]) lines.push(JSON.stringify(key) + ': ' + editor3DJSON(rest[key])); const list = (isArray(objects) ? objects : []).map((o)=> ' ' + editor3DJSON(o)); lines.push('"objects": [' + (list.length ? '\n' + list.join(',\n') + '\n ]' : ']')); return '{\n ' + lines.join(',\n ') + '\n}\n'; } // every page keeps its own autosaves, one for each level by its key function editor3DSaveName() { return 'LittleJS editor 3D ' + (globalThis.location?.pathname ?? ''); } const editor3DSaves = ()=> readSaveData(editor3DSaveName(), {}); // if the last autosave did not fit in storage, the panel says so let editor3DSaveFailed = false; // remember the level's objects and parts, or forget them when they are back to the file function editor3DAutosave(level=editor3DLevel, known) { // a prefab being edited is kept with the level it is in, as that level would be on going back: its own // prefabs with what is open, and what was edited on the way, in them if (editor3DPrefabLevels.has(level)) { const root = editor3DPrefabStack[0]?.level, edits = {...editor3DPrefabEdits}; editor3DPrefabStack.forEach((frame, i)=> { const open = editor3DPrefabStack[i + 1]?.level ?? editor3DLevel, known = level3DPrefabs.get(frame.name); const objects = isArray(open.objects) ? editor3DCopy(open.objects) : []; if (known?.fromLevel && JSON.stringify(objects) !== frame.entered) edits[frame.name] = known.attached ? {attached: true, objects} : {objects}; }); // with nothing edited any more, an undo back to how the prefab was, the level's autosave is the level's own // again, which takes away one of an edit that was undone and keeps the level's other edits const prefabs = {...editor3DLevelPart('prefabs', root), ...edits}; root && editor3DAutosave(Object.keys(prefabs).length ? {...root, prefabs} : root, editor3DRecords.get(root)); return; } editor3DAutosaveWaiting.delete(level); // written now const record = known ?? editor3DRecords.get(level); if (!record || record.pending) return; // edits waiting to be applied keep their autosave const saves = editor3DSaves(), objects = isArray(level.objects) ? level.objects : []; const parts = editor3DLevelParts(level); // the level as it was loaded has nothing to keep; one a Save wrote is kept until a reload shows the file has it, // since the browser gives a picked file's name and not its folder, and a file of the same name may be a copy if (editor3DSame(objects, record.original) && editor3DSame(parts, record.originalParts) && !record.savedHash) delete saves[record.key]; else saves[record.key] = {hash: record.hash, savedHash: record.savedHash, objects: editor3DCopy(objects), ...parts}; const failed = editorSaveFailed, size = editorAutosaveSize; // the 2D editor's flag and size are its own editor3DSaveFailed = editorWriteSaves(saves, record.key, editor3DSaveName()); editor3DAutosaveSize = editorAutosaveSize; editorSaveFailed = failed, editorAutosaveSize = size; } // a level whose autosave writes more than EDITOR_AUTOSAVE_WAIT_SIZE characters, a big terrain's or block map's, // waits until the edits stop for a moment, as the 2D editor's map does, since writing it after every stroke would // cost more than the stroke; a small one is written at once let editor3DAutosaveSize = 0, editor3DAutosaveTimer; const editor3DAutosaveWaiting = new Set; // autosave a level after an edit: at once when its autosave is small, or a second after the last of a run of edits; // inside a prefab at once, since its autosave is the level it is in, found through the prefabs open, which Back // changes function editor3DAutosaveSoon(level=editor3DLevel) { if (editor3DAutosaveSize < EDITOR_AUTOSAVE_WAIT_SIZE || editor3DPrefabLevels.has(level)) return editor3DAutosave(level); editor3DAutosaveWaiting.add(level); clearTimeout(editor3DAutosaveTimer); editor3DAutosaveTimer = setTimeout(editor3DAutosaveFlush, 1e3); } // write every autosave that is waiting, as the editor closes, the page hides or another level loads function editor3DAutosaveFlush() { clearTimeout(editor3DAutosaveTimer); [...editor3DAutosaveWaiting].forEach((level)=> editor3DAutosave(level)); } // put the autosaved edits of a file that changed into the level, as one undo function editor3DApplyPending() { const record = editor3DRecords.get(editor3DLevel), saved = record?.pending; if (!saved || !isArray(saved.objects)) return false; record.pending = undefined; editor3DFixIds(saved.objects); editor3DChange((list)=> { list.length = 0; list.push(...editor3DCopy(saved.objects)); }); const parts = editor3DLevelParts(saved); for (const name of editor3DLevelPartNames) editor3DChangePart(name, ()=> parts[name]); editor3DStrokeEnd(); editor3DAutosave(); } // drop the autosaved edits of a file that changed function editor3DDropPending() { const record = editor3DRecords.get(editor3DLevel); if (!record?.pending) return false; record.pending = undefined; editor3DAutosave(); } // Reset to file: put the level back as its file has it, or as Save last wrote it, as one undo; edits waiting for a // file that changed are dropped, and a level already as its file has it is left alone function editor3DRevert() { const record = editor3DRecords.get(editor3DLevel); if (!record) return false; record.pending = undefined; if (editor3DSame(editor3DObjects(), record.original) && editor3DSame(editor3DLevelParts(), record.originalParts)) return editor3DAutosave(), false; editor3DChange((list)=> { list.length = 0; list.push(...editor3DCopy(record.original)); }); for (const name of editor3DLevelPartNames) editor3DChangePart(name, ()=> record.originalParts[name] && editor3DCopy(record.originalParts[name])); editor3DStrokeEnd(); } // save the level as JSON: where the browser lets a page write files, Chrome and Edge, to a file picked once and // written again on each Save after, or picked again with Save As; elsewhere as a download; a file of the level's // own name that was written is the file from then on, for the autosave; resolves to how it saved, undefined when // the picker was closed; saves of a level run one at a time in the order asked, so the file ends with the last async function editor3DSave(pickAgain=false) { const level = editor3DLevel, record = editor3DRecords.get(level); if (!record) return; editor3DStrokeEnd(); const open = editor3DPrefabStack[editor3DPrefabStack.length - 1]; if (open) { // inside a prefab, Save writes the prefab as a file of its own, or the game keeps it itself const text = editor3DLevelJSON(level), name = open.name + '.json'; const written = JSON.stringify(editor3DObjects()); // as the file has it // its place in line is the prefab's, by its name: closed and opened again it is a new level here, and its // saves still go in the order they were asked for const file = editor3DPrefabFile(open.name), before = file.saving; const saved = (async ()=> { await before; const kept = await editorCall('onSave', text, name) === true; kept || saveText(text, name, 'application/json'); file.saved = written; // it is in its file when what it holds now is what was written: edited again while the save was // kept, or gone back from with more edits, it still waits const at = editor3DPrefabStack.indexOf(open); const now = at < 0 ? file.content : JSON.stringify((editor3DPrefabStack[at + 1]?.level ?? editor3DLevel).objects); editor3DPrefabFileCheck(open.name, now ?? written); return kept ? 'kept' : 'downloaded'; })(); file.saving = saved.catch(()=> {}); return saved; } // what is written, kept as it is now: the level can change while the file is picked and written, and those // edits are not in the file, so they stay in the autosave; the level and record are this one's, whichever is // open by then const text = editor3DLevelJSON(level); // its place in line is taken now, and the game's hook is asked when its turn comes, so saves are written in // the order they were asked for however long a hook takes const saved = (async ()=> { await record.saving; if (await editorCall('onSave', text, record.fileName) === true) return 'kept'; // the game kept it itself return editor3DSaveText(level, record, text, pickAgain); })(); record.saving = saved.catch(()=> {}); return saved; } // write one save of a level, the one before it done async function editor3DSaveText(level, record, text, pickAgain) { const file = JSON.parse(text), written = editor3DCopy(file.objects), writtenParts = editor3DLevelParts(file); const picker = /** @type {any} */ (globalThis).showSaveFilePicker; const fileKey = (globalThis.location?.pathname ?? '') + ' 3D ' + record.key; if (picker) { try { if (!pickAgain && !record.fileHandle) { const handle = await editorFileStore.get(fileKey), mode = {mode: 'readwrite'}; if (handle && (await handle.queryPermission(mode) === 'granted' || await handle.requestPermission(mode) === 'granted')) record.fileHandle = handle; } if (pickAgain || !record.fileHandle) { record.fileHandle = await picker.call(globalThis, {suggestedName: record.fileName, types: [{description: 'LittleJS 3D level', accept: {'application/json': ['.json']}}]}); editorFileStore.set(fileKey, record.fileHandle); } const writable = await record.fileHandle.createWritable(); await writable.write(text); await writable.close(); if (record.fileHandle.name === record.fileName) { record.original = written; record.originalParts = writtenParts; record.savedHash = editor3DContentHash(written, writtenParts); editor3DAutosave(level); } return 'written'; } catch (error) { if (error?.name === 'AbortError') return; // the picker was closed, nothing saved record.fileHandle = undefined; // a file it could not write, a download instead, and not kept editorFileStore.set(fileKey, undefined); } } saveText(text, record.fileName, 'application/json'); return 'downloaded'; } /////////////////////////////////////////////////////////////////////////////// // the tools: handles on the selection, dragged with the mouse const EDITOR3D_HANDLE_SIZE = .15; // a handle's length as a part of the view's height const EDITOR3D_HANDLE_REACH = 10; // how many pixels from a handle still grab it const EDITOR3D_AXES = {x: vec3(1, 0, 0), y: vec3(0, 1, 0), z: vec3(0, 0, 1)}; let editor3DTool = 'move'; // 'select', 'move', 'rotate', 'scale', 'blocks' or 'terrain' let editor3DBlockType = 1; // the block type the Blocks tool places let editor3DBlockBox = false; // the Blocks tool fills a box: the rectangle dragged, editor3DBlockBoxHeight tall let editor3DBlockBoxHeight = 1; let editor3DSeconds = 0; // how long the last step was, for a brush held down // where the mouse is on the terrain, for the Terrain tool's brush /** @type {Vector3|undefined} */ let editor3DTerrainHover; // the cell the Blocks tool would change, for its outline: {cell, mode} /** @type {{cell: Vector3, mode: string}|undefined} */ let editor3DBlockHover; let editor3DGrid = true; // moves, turns and sizes go in steps, Ctrl flips it for a drag let editor3DGroundSnap = true; // a body drag slides along what is under the mouse let editor3DLocalAxes = false; // the Move handles follow the object's own axes, not the world's let editor3DMoveStep = 1, editor3DRotateStep = 15, editor3DScaleStep = .25; // the rotate step in degrees let editor3DHelp = false; // the keys are shown let editor3DMouseOnPanel = false; // the drag being made: {kind, ...}, a 'box' drag has from and to in screen pixels /** @type {any} */ let editor3DDrag; // the handle under the mouse /** @type {any} */ let editor3DHover; // what an Alt drag orbits around /** @type {Vector3|undefined} */ let editor3DOrbitPivot; // the middle of the selection, where the handles are function editor3DSelectionCenter() { const selected = editor3DSelected(); if (selected.length) return selected.reduce((sum, o)=> sum.add(editor3DPos(o)), vec3()).scale(1 / selected.length); } // the handles of the tool on the selection, each {kind, axis, center, length, direction or axes, points}: Move has // an arrow along each world axis and a square between each pair, Rotate a ring for the pitch, yaw and roll of the // first selected object, Scale a handle along each of its own axes and a box in the middle for all three function editor3DHandles() { const center = editor3DSelectionCenter(), tool = editor3DTool, handles = []; if (!center || tool === 'select' || tool === 'blocks' || tool === 'terrain' || editorGameTool()) return handles; const length = editor3DScreenScale(center) * EDITOR3D_HANDLE_SIZE; const rotation = editor3DRotation(editor3DSelected()[0]).scale(PI / 180), frame = buildMatrix(vec3(), rotation); for (const axis of 'xyz') { if (tool === 'rotate') { const axes = editor3DRingAxes(rotation, axis), points = []; for (let i = 0; i <= 48; ++i) { const a = i / 48 * PI * 2; points.push(center.add(axes.across.scale(cos(a) * length)).add(axes.along.scale(sin(a) * length))); } handles.push({kind: 'ring', axis, center, length, axes, points}); continue; } // an arrow or scale handle starts a little out from the middle, which is left for the object's body; Move // goes along the world's axes, or with own axes on along the object's, as Scale always does const local = tool !== 'move' || editor3DLocalAxes; const way = (k)=> local ? frame.transformDirection(EDITOR3D_AXES[k]) : EDITOR3D_AXES[k]; const direction = way(axis), turn = tool === 'move' && local ? rotation : undefined; handles.push({kind: tool === 'move' ? 'arrow' : 'scale', axis, center, length, direction, turn, points: [center.add(direction.scale(length * .2)), center.add(direction.scale(length))]}); if (tool === 'move') { // the square between the other two axes moves along both; across is the axis it does not move along const [a, b] = 'xyz'.replace(axis, ''); handles.push({kind: 'plane', axis: a + b, center, length, direction, turn, across: axis, ways: [way(a), way(b)], points: [center.add(way(a).add(way(b)).scale(length * .35))]}); } } tool === 'scale' && handles.push({kind: 'scaleAll', axis: 'xyz', center, length, points: [center]}); return handles; } // the handle nearest a screen position, within reach; a square or box is a point and a little easier to take function editor3DHandleAt(screenPos) { let nearest, reach = EDITOR3D_HANDLE_REACH; for (const handle of editor3DHandles()) { // a ring seen edge on is a line on screen and the mouse's ray runs along its plane, it can not be dragged const {points, center} = handle, toward = center.subtract(editor3DCamera.pos).normalize(); if (handle.kind === 'ring' && abs(handle.axes.axis.dot(toward)) < .15) continue; // an arrow or scale handle seen end on is a dot over the object, which is left for the object's body if (points.length === 2) { const a = render3D.worldToScreen(points[0]), b = render3D.worldToScreen(points[1]); if (a && b && a.distance(b) < 12) continue; } let distance = Infinity; if (points.length === 1) { const at = render3D.worldToScreen(points[0]); at && (distance = screenPos.distance(at) - 4); } else for (let i = 1; i < points.length; ++i) distance = min(distance, editor3DSegmentDistance(screenPos, points[i-1], points[i])); if (distance < reach) reach = distance, nearest = handle; } return nearest; } // the object of the level a ray hits first: what the game made for it or a part attached to that, or a unit box // where it made nothing to hit, like a light or a player start; its id function editor3DPickAt(ray) { let nearest, id; for (const object of editor3DObjects()) { const parts = editor3DVisible(object) ? editor3DParts(editor3DInstances.get(object.id)) : []; let distance = parts.length ? undefined : raycastBox(ray, editor3DPos(object), vec3(1)); for (const part of parts) { const d = render3DRaycastObject(ray, part); if (d !== undefined && (distance === undefined || d < distance)) distance = d; } if (distance !== undefined && (nearest === undefined || distance < nearest)) nearest = distance, id = object.id; } return id; } // what the game made for an object and the parts attached to it, its children and theirs function editor3DParts(made, parts=[]) { if (made instanceof EngineObject && !made.destroyed) { parts.push(made); for (const child of made.children) editor3DParts(child, parts); // a prefab's instance and its parts are one thing, the ones that are not its children too if (made instanceof Prefab3D) for (const part of made.parts) part?.parent === made || editor3DParts(part, parts); } return parts; } // what the game made for the selected objects, with their parts, left out when the selection looks for a surface // to stand on function editor3DSelectedInstances() { return new Set(editor3DSelected().flatMap((o)=> editor3DParts(editor3DInstances.get(o.id)))); } // where each selected object is, as a drag starts from function editor3DDragStart() { return new Map(editor3DSelected().map((o)=> [o.id, {pos: editor3DPos(o), rotation: editor3DRotation(o), scale: editor3DScale(o)}])); } // the left button went down: on a handle it drags it, with a brush it places one, on an object it selects it and // may drag it, and on nothing it drags out a box to select with function editor3DPress(mouse, ray, shift) { const handle = editor3DHandleAt(mouse), from = mouse.copy(); if (handle) { const {kind, center, direction, axes} = handle; const grab = kind === 'ring' ? editor3DRingAngle(ray, center, axes) : kind === 'plane' ? editor3DPlanePoint(ray, center, direction) : kind === 'scaleAll' ? 0 : editor3DAxisDistance(ray, center, direction); editor3DDrag = {kind, handle, from, grab, start: editor3DDragStart()}; return; } if (editor3DBrush) { editor3DPlaceAt(editor3DBrush, ray); shift || (editor3DBrush = undefined); editor3DTool = 'move'; return; } const id = editor3DPickAt(ray); if (id === undefined) { editor3DDrag = {kind: 'box', from, to: from.copy(), shift}; return; } if (shift) { editor3DSelection.has(id) ? editor3DSelection.delete(id) : editor3DSelection.add(id); return; } if (!editor3DSelection.has(id)) { editor3DSelection.clear(); editor3DSelection.add(id); } // a drag of its body moves it level, at its height, so it stays under the mouse; seen from the side, where the // mouse's ray runs along that plane, it moves across the view instead, in the upright plane facing it const pos = editor3DPos(editor3DObject(id)), forward = editor3DCamera.getForward(); const side = abs(ray.direction.y) < .2 && vec3(forward.x, 0, forward.z).normalize(); const normal = side && side.lengthSquared() ? side : EDITOR3D_UP; editor3DDrag = {kind: 'body', id, from, moving: false, ignore: editor3DSelectedInstances(), normal, start: editor3DDragStart(), level: editor3DPlanePoint(ray, pos, normal) ?? pos}; } // place an object of a type where a ray lands, standing on what is there, snapped, selected, as one undo function editor3DPlaceAt(type, ray) { const point = editor3DSurface(ray); if (!point) return; editor3DStrokeEnd(); const id = editor3DPlace(type, point); if (id === undefined) return; const object = editor3DObject(id), size = editor3DSize(object), offset = editor3DBoxOffset(object); const ignore = new Set(editor3DParts(editor3DInstances.get(id))); const snapped = editor3DSnapPos(editor3DRest(point, size), size, editor3DGrid ? editor3DMoveStep : 0); const y = editor3DLand(snapped.x, snapped.z, point.y, size, ignore); // the box stands there, and the object's place is where that puts it const to = vec3(snapped.x, y, snapped.z).subtract(offset); editor3DChange((list)=> editor3DSetTransform(list.find((o)=> o.id === id), to)); editor3DStrokeEnd(); } // the mouse moved with a drag held: the selection goes from where the drag started to where the mouse says function editor3DDragTo(drag, mouse, ray, snap) { if (drag.kind === 'blocks') return editor3DVoxelDragTo(drag, ray); if (drag.kind === 'terrain') return editor3DTerrainHover && editor3DTerrainDragTo(drag, editor3DTerrainHover); if (drag.kind === 'box') { drag.to = mouse.copy(); return; } if (drag.kind === 'body' && !(drag.moving ||= mouse.distance(drag.from) > 4)) return; const {kind, handle, start, grab} = drag, first = editor3DObject(drag.id ?? [...start.keys()][0]); const was = first && start.get(first.id); if (!was) return; const size = editor3DSize(first), turned = editor3DTurned(first), step = snap ? editor3DMoveStep : 0; const offset = editor3DBoxOffset(first); // snapping and landing go by its box const snapTo = (to)=> editor3DSnapPos(to.add(offset), size, step, turned).subtract(offset).subtract(was.pos); let move, turn, factor; if (kind === 'arrow') { const d = editor3DAxisDistance(ray, handle.center, handle.direction) - grab; // an axis of its own does not follow the grid, so the move goes in steps from where it started move = handle.turn ? handle.direction.scale(step ? round(d / step) * step : d) : handle.direction.multiply(snapTo(was.pos.add(handle.direction.scale(d)))); // along its axis alone } else if (kind === 'plane') { const point = editor3DPlanePoint(ray, handle.center, handle.direction); if (!point || !grab) return; const delta = point.subtract(grab), along = (w)=> step ? round(delta.dot(w) / step) * step : delta.dot(w); move = handle.turn ? handle.ways[0].scale(along(handle.ways[0])).add(handle.ways[1].scale(along(handle.ways[1]))) : vec3(1).subtract(handle.direction).multiply(snapTo(was.pos.add(delta))); } else if (kind === 'body') { // level under the mouse, and with ground snap it lands on what is under it there, from where it started const point = editor3DPlanePoint(ray, drag.level, drag.normal); if (!point) return; const across = snapTo(was.pos.add(point.subtract(drag.level))); const y = editor3DGroundSnap ? editor3DLand(was.pos.x + offset.x + across.x, was.pos.z + offset.z + across.z, was.pos.y + offset.y - size.y / 2, size, drag.ignore) - offset.y : was.pos.y; move = vec3(across.x, y - was.pos.y, across.z); } else if (kind === 'ring') { const angle = editor3DRingAngle(ray, handle.center, handle.axes); if (angle === undefined || grab === undefined) return; turn = mod((angle - grab) * 180 / PI + 180, 360) - 180; // the short way around } else if (kind === 'scale') { if (abs(grab) < 1e-6) return; factor = vec3(1); factor[handle.axis] = max(editor3DAxisDistance(ray, handle.center, handle.direction) / grab, .01); } else // the box in the middle sizes all three, right and up is larger factor = vec3(Math.exp((mouse.x - drag.from.x - mouse.y + drag.from.y) / 200)); editor3DChange((list)=> { for (const object of list) { const s = start.get(object.id); if (!s) continue; if (move) editor3DSetTransform(object, s.pos.add(move)); if (turn !== undefined) { const rotation = s.rotation.copy(); rotation[handle.axis] = editor3DSnap(rotation[handle.axis] + turn, snap ? editor3DRotateStep : 0); editor3DSetTransform(object, undefined, rotation); } if (factor) { // snapped and kept from nothing by its size, its sign kept, so a mirrored axis stays mirrored const size = snap ? editor3DScaleStep : 0, least = size || .01; const fit = (v)=> (v < 0 ? -1 : 1) * max(editor3DSnap(abs(v), size), least); const scale = s.scale.multiply(factor); editor3DSetTransform(object, undefined, undefined, vec3(fit(scale.x), fit(scale.y), fit(scale.z))); } } }); } // the left button went up: a box selects what is inside it, by where each object's middle is on screen, and a // press that never moved clears the selection; any other drag is one undo function editor3DDragEnd(drag, mouse) { editor3DDrag = undefined; if (drag.kind !== 'box') return editor3DStrokeEnd(); drag.shift || editor3DSelection.clear(); const {from} = drag; if (mouse.distance(from) < 4) return; const low = vec2(min(from.x, mouse.x), min(from.y, mouse.y)); const high = vec2(max(from.x, mouse.x), max(from.y, mouse.y)); for (const object of editor3DObjects()) { const at = render3D.worldToScreen(editor3DPos(object)); at && at.x >= low.x && at.x <= high.x && at.y >= low.y && at.y <= high.y && editor3DSelection.add(object.id); } } // take the drag being made back, with nothing to undo function editor3DDragCancel() { editor3DDrag = undefined; editor3DStrokeCancel(); } /////////////////////////////////////////////////////////////////////////////// // the editor's camera, keys and update // the surface under a screen position, under the mouse and under the middle of the view, seen through the // editor's camera function editor3DScreenPoint(screenPos) { return editor3DWithView(()=> editor3DSurface(render3D.screenToRay(screenPos))); } function editor3DMousePoint() { return editor3DScreenPoint(mousePosScreen); } function editor3DViewPoint() { return editor3DScreenPoint(mainCanvasSize.scale(.5)); } // move the camera back along its view until the selection is in the middle and fits function editor3DFrame() { const center = editor3DSelectionCenter(), camera = editor3DCamera; if (!center) return false; const radius = editor3DSelected().reduce((r, o)=> max(r, editor3DPos(o).distance(center) + editor3DSize(o).length() / 2), 0); camera.pos = center.subtract(camera.getForward().scale(max(radius * 2.5, 3))); render3D.updateMatrices(); } // drop the selection straight down onto what is under it, each object by itself, as one undo function editor3DDropSelection() { const ignore = editor3DSelectedInstances(); if (!editor3DSelection.size) return false; editor3DStrokeEnd(); editor3DChange((list)=> { for (const object of list) { const offset = editor3DBoxOffset(object), to = editor3DSelection.has(object.id) && editor3DDrop(editor3DPos(object).add(offset), editor3DSize(object), ignore); to && editor3DSetTransform(object, to.subtract(offset)); } }); editor3DStrokeEnd(); } // paste the copied objects with their middle under the mouse, the lowest standing on what is there, snapped by // the first of them; where they were when the mouse is over nothing function editor3DPasteAtMouse() { const copied = editor3DClipboard, point = !editor3DMouseOnPanel && editor3DMousePoint(); if (!copied?.length) return false; if (!point) return editor3DPaste(); const center = copied.reduce((sum, o)=> sum.add(editor3DPos(o)), vec3()).scale(1 / copied.length); const box = (o)=> editor3DClipboardBoxes.get(o) ?? {size: editor3DSize(o), offset: editor3DBoxOffset(o)}; const bottom = copied.reduce((low, o)=> min(low, editor3DPos(o).y + box(o).offset.y - box(o).size.y / 2), Infinity); const first = copied[0], size = box(first).size, was = editor3DPos(first).add(box(first).offset); // its box's middle const snapped = editor3DSnapPos(was.add(vec3(point.x - center.x, 0, point.z - center.z)), size, editor3DGrid ? editor3DMoveStep : 0, editor3DTurned(first)); const land = editor3DLand(snapped.x, snapped.z, point.y, size) - size.y / 2; return editor3DPaste(vec3(snapped.x - was.x, land - bottom, snapped.z - was.z)); } // the keys by their position on the keyboard, each called with whether Shift is held; the tool keys are the ones // Unity, Unreal and Godot use /** @type {Object} */ const editor3DKeys = { KeyQ: ()=> { editor3DPickTool('select'); editor3DBrush = undefined; }, KeyW: ()=> { editor3DPickTool('move'); }, KeyE: ()=> { editor3DPickTool('rotate'); }, KeyR: ()=> { editor3DPickTool('scale'); }, KeyB: ()=> { editor3DPickTool('blocks'); editor3DBrush = undefined; }, KeyT: ()=> { editor3DPickTool('terrain'); editor3DBrush = undefined; }, KeyP: ()=> editor3DTool === 'blocks' && editor3DVoxelPick(render3D.screenToRay(mousePosScreen)), KeyX: ()=> editor3DTool === 'terrain' ? editor3DTerrainNextBrush() : editor3DTool === 'blocks' && (editor3DBlockBox = !editor3DBlockBox), KeyG: ()=> { editor3DGrid = !editor3DGrid; }, KeyL: ()=> { editor3DLocalAxes = !editor3DLocalAxes; }, KeyF: ()=> editor3DFrame(), End: ()=> editor3DDropSelection(), Delete: ()=> editor3DDelete(), Backspace: ()=> editor3DSelection.size ? editor3DDelete() : editor3DPrefabBack(), Enter: ()=> editor3DPrefabEnter(), Slash: ()=> { editor3DHelp = !editor3DHelp; }, }; /** @type {Object} */ const editor3DCtrlKeys = { KeyZ: (shift)=> editor3DUndo(shift), KeyY: ()=> editor3DUndo(true), KeyC: ()=> editor3DCopySelection(), KeyX: ()=> editor3DCut(), KeyV: ()=> editor3DPasteAtMouse(), KeyD: ()=> editor3DDuplicate(), KeyG: (shift)=> shift ? editor3DUnpack() : editor3DMakePrefab() === true, }; // the camera: the right button looks and the keys fly while it is held, the middle button or Space and the left // pans, Alt and the left orbits the selection, and the wheel zooms toward what is under the mouse function editor3DCameraUpdate(seconds, shift, alt) { const camera = editor3DCamera, delta = inputCaptureDeltaScreen, wheel = inputCaptureWheel; const ahead = ()=> camera.pos.add(camera.getForward().scale(10)); if (mouseIsDown(2)) { editor3DFlySpeed = clamp(editor3DFlySpeed * Math.exp(-wheel * .2), .01, 10); editor3DFly(camera, editor3DFlyKeys(), delta, seconds, shift); } else if (mouseIsDown(1) || keyIsDown('Space') && mouseIsDown(0)) { // the scene follows the mouse, at the depth of what is in the middle of the view const perPixel = editor3DScreenScale(editor3DViewPoint() ?? ahead()) / mainCanvasSize.y; camera.pos = camera.pos.subtract(camera.getRight().scale(delta.x * perPixel)) .add(camera.getUp().scale(delta.y * perPixel)); } else if (alt && mouseIsDown(0)) { if (mouseWasPressed(0) || !editor3DOrbitPivot) editor3DOrbitPivot = editor3DSelectionCenter() ?? editor3DViewPoint() ?? ahead(); const pivot = editor3DOrbitPivot, distance = camera.pos.distance(pivot); editor3DFly(camera, vec3(), delta, 0); camera.pos = pivot.subtract(camera.getForward().scale(distance)); } else if (wheel && !editor3DMouseOnPanel) { // toward what is under the mouse, a part of the way there for each notch, and never through it render3D.updateMatrices(); const ray = render3D.screenToRay(mousePosScreen), target = editor3DSurface(ray); const distance = target ? camera.pos.distance(target) : 10; camera.pos = camera.pos.add(ray.direction.scale(min(max(distance * .15, .2) * -wheel, distance - .5))); } render3D.updateMatrices(); // what follows picks and projects through the camera where it is now } // the editor's update, called while it is open with the input readable and render3D looking through its camera function editor3DEditorUpdate(seconds) { editor3DSeconds = seconds; const held = (...codes)=> codes.some((code)=> keyIsDown(code)); const shift = held('ShiftLeft', 'ShiftRight'), alt = held('AltLeft', 'AltRight'); const ctrl = held('ControlLeft', 'ControlRight', 'MetaLeft', 'MetaRight'); const mouse = mousePosScreen; // Escape, the debug key, puts a drag back, or else switches to playing; taken, so the debug overlay stays shut if (debugKey && keyWasPressed(debugKey)) { inputClearKey(debugKey); if (editor3DToolHeld) { editor3DStrokeCancel(); // a held press of the game's tool is taken back editor3DToolHeld = false; } else editor3DDrag ? editor3DDragCancel() : editor3DPlay(editor3DMousePoint()); return; } // 0 exits the editor; cleared, so debugKeysAlways does not open it again in the same step if (keyWasPressed('Digit0')) { inputClearKey('Digit0'); editor3DClose(); return; } editor3DCameraUpdate(seconds, shift, alt); const looking = mouseIsDown(2), cameraDrag = looking || mouseIsDown(1) || keyIsDown('Space') || alt; // the keys, which wait for a drag to end, and fly while the right button is held if (!editor3DDrag && !looking && !editor3DToolHeld) { // the game's own keys first, each takes the place of the editor's key of the same spelling const taken = new Set; for (const [spelled, key] of Object.entries(levelEditor.keys)) { const withCtrl = spelled.startsWith('ctrl+'), code = editor3DKeyCode(spelled.slice(withCtrl ? 5 : 0)); if (withCtrl !== ctrl) continue; taken.add(code); keyWasPressed(code) && key.action.call(levelEditor, shift); } for (const [code, action] of Object.entries(ctrl ? editor3DCtrlKeys : editor3DKeys)) taken.has(code) || keyWasPressed(code) && action(shift); } // a right press during a drag puts it back if (editor3DDrag && mouseWasPressed(2)) return editor3DDragCancel(); const ray = render3D.screenToRay(mouse), idle = !cameraDrag && !editor3DMouseOnPanel && !editor3DDrag; idle && mouseWasPressed(0) && editor3DRecords.get(editor3DLevel)?.pending && editorPendingNudge(); editor3DHover = idle ? editor3DHandleAt(mouse) : undefined; editorCall('onUpdate'); if (editor3DToolUpdate(ray, idle, shift, ctrl)) return; // a tool of the game's own has the mouse // the Blocks tool takes the mouse while the level has a map: Shift removes, Ctrl repaints // a type picked to place is placed, whichever tool is on const painting = editor3DTool === 'blocks' && !editor3DBrush && !!editor3DVoxelMap(); const mode = shift ? 'remove' : ctrl ? 'paint' : 'place'; const target = painting && idle ? editor3DVoxelTarget(ray, mode) : undefined; editor3DBlockHover = target && {cell: target.cell, mode}; // the Terrain tool takes it while the level has a terrain: held down its brush works on the ground const terrain = editor3DTool === 'terrain' && !editor3DBrush ? editor3DTerrainMap() : undefined; const reach = terrain && !cameraDrag && !editor3DMouseOnPanel ? terrain.raycast(ray) : undefined; editor3DTerrainHover = reach === undefined ? undefined : ray.getPosition(reach); if (painting) idle && mouseWasPressed(0) && (editor3DDrag = editor3DVoxelPress(ray, mode)); else if (terrain) idle && mouseWasPressed(0) && editor3DTerrainHover && (editor3DDrag = editor3DTerrainPress(editor3DTerrainHover, shift, ctrl)); else if (idle && mouseWasPressed(0)) editor3DPress(mouse, ray, shift); // a quick click, down and up before this step, is a press and its release in one const drag = editor3DDrag; if (drag) mouseIsDown(0) ? editor3DDragTo(drag, mouse, ray, editor3DGrid !== ctrl) : editor3DDragEnd(drag, mouse); } // Ctrl with a letter the editor uses is not the browser's while the editor is open, Ctrl+D would bookmark the page if (debug && globalThis.document?.addEventListener) { document.addEventListener('keydown', (e)=> { if (editor3DIsOpen && (e.ctrlKey || e.metaKey) && /^Key[ZYCXVDG]$/.test(e.code) && !editorIsTextField(e.target)) e.preventDefault(); }); // a page going away writes what is waiting, the last event it is sure to get document.addEventListener('visibilitychange', ()=> document.hidden && editor3DAutosaveFlush()); globalThis.addEventListener?.('pagehide', editor3DAutosaveFlush); } /////////////////////////////////////////////////////////////////////////////// // what the panel does to the level, and what it says // the help, every control const editor3DHelpLines = [ 'Left: select · Shift+Left: add or take away · Left drag from empty space: box select', 'Q select · W move · E rotate · R scale: drag a handle, or the object itself', 'G: grid snap, Ctrl flips it for a drag · L: move along its own axes · End: drop to the ground', 'Right button: look, and WASD and QE fly while it is held, Shift faster', 'Wheel: zoom · Middle drag or Space+drag: pan · Alt+drag: orbit · F: frame the selection', 'Pick a type, then click to place it, Shift+click keeps placing', 'B blocks: click or drag places · Shift removes · Ctrl repaints · P picks the type under the mouse', 'X box fill: a drag fills the rectangle dragged, as tall as the height in the panel', 'T terrain: hold to raise the ground · Shift lowers · Ctrl smooths · X the next brush, flatten and paint', 'Delete · Ctrl+C / X / V: copy, cut, paste · Ctrl+D: duplicate · Ctrl+Z / Y: undo, redo', 'Ctrl+G: make the selection a prefab, one thing to place many times · Ctrl+Shift+G: unpack it into its objects', 'Enter: open the selected prefab to edit it alone, every instance follows · Backspace with nothing selected: back', 'Reset to file: the level as its file has it, Restart keeps your edits, Undo brings them back', 'Esc: play and edit · 0: exit the editor · ?: keys', ]; // the hint line, for what is held and what is selected function editor3DHint() { if (editor3DRecords.get(editor3DLevel)?.pending) return EDITOR_PENDING_HINT; if (editorGameTool()) return editorGameTool().hint || levelEditor.tool; if (editor3DDrag) return editor3DDrag.kind === 'box' ? 'Let go to select what is inside' : 'Esc or right click puts it back · Ctrl flips the snap'; if (inputCaptureRead(()=> mouseIsDown(2))) return 'WASD and QE fly · Shift faster · the wheel sets the speed'; if (editor3DBrush) return `Click to place a ${editor3DBrush} · Shift keeps placing · Q puts it down`; if (editor3DTool === 'terrain') return !editor3DTerrainMap() ? 'Add a terrain in the panel to sculpt it' : {sculpt: 'Hold to raise the ground · Shift lowers · Ctrl smooths', flatten: 'Hold to flatten the ground to the height pressed · Ctrl smooths', paint: 'Hold to paint the ground · Shift takes the paint off'}[editor3DTerrainBrush.mode] + ' · X the next brush'; if (editor3DTool === 'blocks') return editor3DVoxelMap() ? (editor3DBlockBox ? 'Drag a box to fill it' : 'Click or drag places') + ' · Shift removes · Ctrl repaints · P picks a type · X box' : 'Add a block map in the panel to paint blocks'; if (editor3DSelection.size) return 'Drag a handle or the object · W move · E rotate · R scale · End drops it'; return 'Click to select · pick a type to place · the right button looks · ? keys'; } // if the properties box has an input for a default's type const editor3DPropertyEditable = (value)=> ['number', 'boolean', 'string'].includes(typeof value) || isColor(value) || isVector2(value) || isVector3(value); // set the position, rotation in degrees or scale of the one selected object, from the panel, as one undo function editor3DSetSelectedTransform(field, value) { const selected = editor3DSelected(), id = selected[0]?.id; if (selected.length !== 1 || !isVector3(value)) return false; editor3DStrokeEnd(); editor3DChange((list)=> editor3DSetTransform(list.find((o)=> o.id === id), field === 'pos' ? value : undefined, field === 'rotation' ? value : undefined, field === 'scale' ? value : undefined)); editor3DStrokeEnd(); return true; } // set a property of the one selected object, from the panel, as one undo; the value has the type of its default function editor3DSetSelectedProperty(name, value) { const selected = editor3DSelected(), id = selected[0]?.id; const defaults = selected[0] && level3DTypes.get(selected[0].type)?.defaults; const d = defaults && Object.prototype.hasOwnProperty.call(defaults, name) ? defaults[name] : undefined; const sameType = isColor(d) ? isColor(value) : isVector3(d) ? isVector3(value) : isVector2(d) ? isVector2(value) : typeof value === typeof d; if (selected.length !== 1 || !editor3DPropertyEditable(d) || !sameType) return false; editor3DStrokeEnd(); editor3DChange((list)=> editor3DSetProperty(list.find((o)=> o.id === id), name, value, d)); editor3DStrokeEnd(); return true; } /////////////////////////////////////////////////////////////////////////////// // what the editor draws const EDITOR3D_AXIS_COLORS = {x: hsl(0, .9, .55), y: hsl(.33, .9, .45), z: hsl(.6, .9, .6)}; const EDITOR3D_SELECT_COLOR = hsl(.15, 1, .6), EDITOR3D_MARKER_COLOR = hsl(.55, 1, .7); const EDITOR3D_SOLID_COLOR = hsl(.08, 1, .55); // a width in world units that shows as a number of pixels at a point function editor3DPixels(point, pixels) { return editor3DScreenScale(point) / mainCanvasSize.y * pixels; } // the 12 edges of the unit box a matrix places function editor3DDrawWire(matrix, color, pixels=2) { const corner = (i)=> matrix.transformPoint(vec3(i & 1 ? .5 : -.5, i & 2 ? .5 : -.5, i & 4 ? .5 : -.5)); const width = editor3DPixels(matrix.getTranslation(), pixels); for (let i = 0; i < 8; ++i) for (const bit of [1, 2, 4]) i & bit || render3D.drawLine(corner(i), corner(i | bit), width, color); } // a ring around a point across two axes function editor3DDrawRing(center, across, along, radius, color, pixels=2) { const points = []; for (let i = 0; i <= 32; ++i) { const a = i / 32 * PI * 2; points.push(center.add(across.scale(cos(a) * radius)).add(along.scale(sin(a) * radius))); } render3D.drawRibbon(points, editor3DPixels(center, pixels), undefined, color); } // what the game made for an object, when it has something to see and click function editor3DVisible(object) { const made = editor3DInstances.get(object.id); return made instanceof EngineObject3D && !made.destroyed && (made.mesh || made.tileInfo || editor3DPrefabBox(made)) ? made : undefined; } // 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 // marker function editor3DBoxMatrix(object) { const made = editor3DVisible(object), bounds = made?.mesh && (made.mesh.bounds || made.mesh.getBounds()); if (!made) return buildMatrix(editor3DPos(object)); const box = editor3DPrefabBox(made); if (box) return buildMatrix(box.pos, undefined, box.size); const matrix = render3DObjectMatrix(made).copy(); return bounds ? matrix.multiply(buildMatrix(bounds.min.add(bounds.max).scale(.5), undefined, bounds.max.subtract(bounds.min))) : matrix.multiply(buildMatrix(vec3(), undefined, made.size3D)); } // the ground grid at height 0 around where the camera is, a line each unit and a stronger one each 10, fading // with distance; drawn with the depth test so the level hides it, and a hair over 0 so a floor there does not function editor3DDrawGrid() { const camera = editor3DCamera.pos, reach = 40, y = .01; const x0 = round(camera.x), z0 = round(camera.z); for (let i = -reach; i <= reach; ++i) for (const across of [false, true]) { const at = (across ? z0 : x0) + i, strong = at % 10 === 0; if (!strong && abs(i) > reach / 2) continue; const fade = 1 - abs(i) / (strong ? reach : reach / 2); const a = across ? vec3(x0 - reach, y, at) : vec3(at, y, z0 - reach); const b = across ? vec3(x0 + reach, y, at) : vec3(at, y, z0 + reach); render3D.drawLine(a, b, strong ? .04 : .02, hsl(0, 0, 1, (strong ? .35 : .15) * fade)); } } // the cone a move arrow ends in, standing on Y, made the first time one is drawn let editor3DCone; const editor3DConeMesh = ()=> editor3DCone ||= buildCone(1, 1, 12); // the editor's drawing in the 3D pass: the grid, a marker for what has nothing to see, the collision shape of // what is solid and selected, the selection, the brush where it would go, and the handles function editor3DDraw() { const r = render3D; render3DWithState({depthTest: true}, editor3DDrawGrid); for (const object of editor3DObjects()) { const made = editor3DVisible(object), selected = editor3DSelection.has(object.id); made || editor3DDrawWire(editor3DBoxMatrix(object), EDITOR3D_MARKER_COLOR, 1.5); selected && editor3DDrawWire(editor3DBoxMatrix(object), EDITOR3D_SELECT_COLOR, 2.5); if (made?.collideSolidObjects && !made.parent && selected) { // what it collides as, which turns with it, where its size3D is not its mesh's const shape = render3DSolidShape(made), color = EDITOR3D_SOLID_COLOR; if (shape.size) editor3DDrawWire(buildMatrix(shape.pos, shape.axes && made.rotation3D, shape.size), color, 1.5); else for (const [across, along] of [['x', 'y'], ['y', 'z'], ['z', 'x']]) editor3DDrawRing(shape.pos, EDITOR3D_AXES[across], EDITOR3D_AXES[along], shape.radius, color, 1.5); } } // the cell the Blocks tool would change: white to place, red to remove, yellow to repaint const map = editor3DTool === 'blocks' && !editor3DMouseOnPanel ? editor3DVoxelMap() : undefined; if (map && editor3DBlockHover && !editor3DDrag) { const {cell, mode} = editor3DBlockHover; editor3DDrawWire(buildMatrix(map.pos3D.add(cell).add(vec3(.5)), undefined, vec3(1.02)), mode === 'place' ? hsl(0, 0, 1, .9) : mode === 'remove' ? hsl(0, 1, .6) : hsl(.15, 1, .6), 2.5); } // the Terrain tool's brush, a ring on the ground if (editor3DTool === 'terrain' && editor3DTerrainHover && editor3DTerrainMap()) editor3DDrawRing(editor3DTerrainHover.add(vec3(0, .05, 0)), EDITOR3D_AXES.x, EDITOR3D_AXES.z, editor3DTerrainBrush.size / 2, hsl(0, 0, 1, .9), 2.5); // the box a box drag fills const region = map && editor3DDrag?.region; if (region) { const size = region.hi.subtract(region.lo).add(vec3(1)); editor3DDrawWire(buildMatrix(map.pos3D.add(region.lo).add(size.scale(.5)), undefined, size.add(vec3(.02))), editor3DDrag.mode === 'place' ? hsl(0, 0, 1, .9) : editor3DDrag.mode === 'remove' ? hsl(0, 1, .6) : hsl(.15, 1, .6), 2.5); } // the map's edges, to see where it ends if (map) editor3DDrawWire(buildMatrix(map.pos3D.add(map.mapSize.scale(.5)), undefined, map.mapSize), hsl(.55, .6, .6, .5), 1); // the brush, a box where a click would place it const point = editor3DBrush && !editor3DMouseOnPanel && !editor3DDrag && editor3DMousePoint(); if (point) { const snapped = editor3DSnapPos(editor3DRest(point, vec3(1)), vec3(1), editor3DGrid ? editor3DMoveStep : 0); editor3DDrawWire(buildMatrix(vec3(snapped.x, point.y + .5, snapped.z)), hsl(0, 0, 1, .7)); } // the handles, the one under the mouse or being dragged in the selection's color for (const handle of editor3DHandles()) { const {kind, axis, center, length, points} = handle; const taken = (editor3DDrag?.handle ?? editor3DHover); const lit = taken && taken.kind === kind && taken.axis === axis; const color = lit ? EDITOR3D_SELECT_COLOR : EDITOR3D_AXIS_COLORS[axis[0]] ?? WHITE; const width = editor3DPixels(center, lit ? 4 : 3), tip = length * .1; if (kind === 'ring') r.drawRibbon(points, width, undefined, color); else if (kind === 'plane') { // a flat square across its two axes, in the color of the axis it does not move along const size = vec3(tip * 1.6).subtract(EDITOR3D_AXES[handle.across].scale(tip * 1.5)); r.drawBox(points[0], size, lit ? color : EDITOR3D_AXIS_COLORS[handle.across].scale(1, .7), handle.turn); } else if (kind === 'scaleAll') r.drawBox(center, tip * 1.4, lit ? color : WHITE); else if (kind === 'arrow') { // a move arrow ends in a cone pointing along it, which says move this way, where scale has a box r.drawLine(points[0], points[1], width, color); const turn = axis === 'x' ? vec3(0, 0, -PI / 2) : axis === 'z' ? vec3(PI / 2, 0, 0) : undefined; const at = points[1].add(handle.direction.scale(tip)); const cone = buildMatrix(vec3(), turn, vec3(tip * 1.4, tip * 2, tip * 1.4)); r.drawMesh(editor3DConeMesh(), buildMatrix(at, handle.turn).multiply(cone), undefined, color); } else { r.drawLine(points[0], points[1], width, color); r.drawBox(points[1], tip, color, editor3DRotation(editor3DSelected()[0]).scale(PI / 180)); } } editor3DDrawGame(); } // the editor's drawing on the 2D layer: the name of each marker, and the box being dragged out to select with function editor3DDrawLabels() { for (const object of editor3DObjects()) { if (editor3DVisible(object)) continue; const at = render3D.worldToScreen(editor3DPos(object)); at && drawTextScreen(object.type + '', at, 14, WHITE, 3, BLACK); } const drag = editor3DDrag; if (drag?.kind === 'box') { // on the main canvas, over the WebGL one, so a 3D pass drawn after the 2D scene does not cover it const {from: a, to: b} = drag, corners = [a, vec2(b.x, a.y), b, vec2(a.x, b.y)]; corners.forEach((c, i)=> drawLine(c, corners[(i + 1) % 4], 2, EDITOR3D_SELECT_COLOR, undefined, 0, false, true)); } } /////////////////////////////////////////////////////////////////////////////// // the panel, in the look of the 2D level editor's, made the first time the editor opens let editor3DPanel, editor3DPanelParts; function editor3DPanelInit() { const panel = editor3DPanel = editorElement('div', document.body, 'position:fixed;top:8px;left:8px;width:260px;max-height:calc(100% - 16px);overflow-y:auto;' + 'box-sizing:border-box;padding:8px;background:#111d;color:#eee;font:12px monospace;' + 'border-radius:4px;z-index:9999;user-select:none'); // a click or touch on the panel is not the level's, a mouse up still goes on so a drag can let go for (const type of ['mousedown','wheel','touchstart','touchmove','touchend','touchcancel']) panel.addEventListener(type, (e)=> e.stopPropagation()); panel.addEventListener('mouseenter', ()=> editor3DMouseOnPanel = true); panel.addEventListener('mouseleave', ()=> editor3DMouseOnPanel = false); const box = 'margin:4px 0;padding:4px;background:#222;border-radius:3px'; const row = (parent=panel)=> editorElement('div', parent, 'display:flex;gap:4px;margin:4px 0;flex-wrap:wrap'); const button = (parent, text, onclick, title='')=> { const b = editorElement('button', parent, 'flex:1;padding:3px;cursor:pointer', text); b.onclick = (e)=> { onclick(e); b.blur(); }; // the keys go back to the editor b.title = title; return b; }; const check = (parent, text, onchange, title='')=> { const label = editorElement('label', parent, 'display:flex;gap:6px;align-items:center;margin:2px 0'); const input = editorElement('input', label); input.type = 'checkbox'; input.onchange = ()=> { onchange(input.checked); input.blur(); }; editorElement('span', label, '', text); label.title = title; return input; }; const choice = (parent, text, values, onchange)=> { const label = editorElement('label', parent, 'display:flex;gap:3px;align-items:center;flex:1'); editorElement('span', label, '', text); const select = editorElement('select', label, 'flex:1;background:#333;color:#eee'); for (const value of values) editorElement('option', select, '', value + '').value = value + ''; select.onchange = ()=> { onchange(parseFloat(select.value)); select.blur(); }; return select; }; editorElement('div', panel, 'font-weight:bold', '3D Level Editor'); const top = row(); button(top, 'Play', ()=> editor3DPlay(editor3DViewPoint()), 'Esc, and Esc again comes back to the editor'); const restart = button(top, 'Restart', editor3DRestart, 'Rebuild the level and play it'); button(top, 'Exit', ()=> levelEditor.close(), '0, then Esc opens the debug overlay again'); const undo = row(); button(undo, 'Undo', ()=> editor3DUndo(), 'Ctrl+Z'); button(undo, 'Redo', ()=> editor3DUndo(true), 'Ctrl+Y'); button(undo, 'Keys', ()=> editor3DHelp = !editor3DHelp, 'Every control, ?'); // a file that changed under its autosave const pending = editorElement('div', panel, 'padding:4px;margin:4px 0;background:#630;border-radius:3px'); editorElement('div', pending, '', 'The level file changed since your autosaved edits'); const pendingRow = editorElement('div', pending, 'display:flex;gap:4px;margin-top:4px'); button(pendingRow, 'Apply edits', ()=> editor3DApplyPending()); button(pendingRow, 'Drop them', ()=> editor3DDropPending()); // the tools, and the snapping const tools = row(), toolButtons = {}; for (const [tool, text, key] of [['select', 'Select', 'Q'], ['move', 'Move', 'W'], ['rotate', 'Rotate', 'E'], ['scale', 'Scale', 'R'], ['blocks', 'Blocks', 'B'], ['terrain', 'Terrain', 'T']]) toolButtons[tool] = button(tools, text, ()=> { editor3DPickTool(tool); (tool === 'select' || tool === 'blocks' || tool === 'terrain') && (editor3DBrush = undefined); }, key); // the game's own tools, buttons and controls, under the editor's tools const game = editorElement('div', panel); const snap = editorElement('div', panel, box); const grid = check(snap, 'Grid snap', (on)=> editor3DGrid = on, 'G, and Ctrl flips it for a drag'); const steps = row(snap); const moveStep = choice(steps, 'Move', [1, .5, .25], (v)=> editor3DMoveStep = v); const rotateStep = choice(steps, 'Turn', [15, 45, 90], (v)=> editor3DRotateStep = v); const scaleStep = choice(steps, 'Size', [1, .5, .25], (v)=> editor3DScaleStep = v); const ground = check(snap, 'Ground snap', (on)=> editor3DGroundSnap = on, 'A drag of an object slides it along what is under the mouse'); const ownAxes = check(snap, 'Own axes', (on)=> editor3DLocalAxes = on, 'L: the Move handles go along the object\'s own axes, as it is turned, not the world\'s'); // the types, a click picks one up to place, made again when types are added editorElement('div', panel, 'color:#aaa;margin-top:4px', 'Place'); const types = row(); const prefabNote = editorElement('div', panel, 'color:#f86'); // why a prefab was not loaded const properties = editorElement('div', panel, box); // prefabs: the selection made one, or the selected instance's own const prefabBox = editorElement('div', panel, box); // the prefab that is open, and the way back const prefabOpen = editorElement('div', panel, 'padding:4px;margin:4px 0;background:#235;border-radius:3px;' + 'display:flex;gap:6px;align-items:center'); panel.insertBefore(prefabOpen, panel.firstChild); const prefabName = editorElement('span', prefabOpen, 'flex:1'); button(prefabOpen, 'Back', ()=> editor3DPrefabBack(), 'Backspace with nothing selected: back to where the prefab was opened from, every instance of it follows'); // the Blocks tool's box: a map to add, or the types to paint with const blocks = editorElement('div', panel, box); // the Terrain tool's box: a terrain to add, or the brush to sculpt with const terrainBox = editorElement('div', panel, box); // the scene the level sets: its sky, sun, fog and shadows, or none, the game's own const scene = editorElement('div', panel, box); const sceneOn = check(scene, 'Level sets the scene', (on)=> { editor3DChangeScene(()=> on ? editor3DSceneFromView() : undefined); editor3DStrokeEnd(); }, 'The sky, sun, fog and shadows are saved in the level; off leaves them to the game'); const sceneRows = editorElement('div', scene); const file = row(); // Save says so for a moment when it saved, Save As shows where a page can write files const saved = (b, label)=> (result)=> result && (b.textContent = 'Saved', setTimeout(()=> b.textContent = label, 1e3)); const save = button(file, 'Save', ()=> editor3DSave().then(saved(save, 'Save')), 'Save, to the file picked the first time, or a download'); const saveAs = button(file, 'Save As', ()=> editor3DSave(true).then(saved(saveAs, 'Save As')), 'Save As, to a file picked again'); button(file, 'Reset to file', ()=> editor3DRevert(), 'Put the level back as its file has it, or as Save last wrote it; Undo brings the edits back'); /** @type {any} */ (globalThis).showSaveFilePicker || (saveAs.style.display = 'none'); const playFrom = check(panel, 'Play from mouse', (on)=> editor3DPlayFromMouse = on, 'Escape starts play with the player at the mouse, Play at the middle of the view'); const storage = editorElement('div', panel, 'color:#f86;margin-top:4px', 'Autosave failed, storage is full: Save to a file'); const prefabUnsaved = editorElement('div', panel, 'color:#fc6;margin-top:4px'); const hint = editorElement('div', panel, 'color:#8ab;margin-top:4px'); const help = editorElement('div', panel, 'color:#aaa;margin-top:4px;border-top:1px solid #444;padding-top:4px'); for (const line of editor3DHelpLines) editorElement('div', help, 'margin:2px 0', line); const gameHelp = editorElement('div', help); button(help, 'Close', ()=> editor3DHelp = false, '?'); editor3DPanelParts = {game, gameHelp, restart, pending, toolButtons, grid, moveStep, rotateStep, scaleStep, ground, ownAxes, types, prefabBox, prefabOpen, prefabName, prefabUnsaved, prefabNote, properties, blocks, terrainBox, sceneOn, sceneRows, playFrom, storage, hint, help, typeNames: ''}; } // show the panel as the editor is now function editor3DPanelUpdate() { if (!editor3DIsOpen) { editor3DPanel && (editor3DPanel.style.display = 'none'); return; } editor3DPanel || editor3DPanelInit(); editor3DPanel.style.display = ''; const p = editor3DPanelParts, lit = '2px solid #4af'; editorGamePanel(p.game); editorGameHelp(p.gameHelp); p.restart.style.display = editorHas('onRestart') ? '' : 'none'; const pending = editor3DRecords.get(editor3DLevel)?.pending; p.pending.style.display = pending ? '' : 'none'; pending && editorPendingFlash(p.pending); for (const tool in p.toolButtons) p.toolButtons[tool].style.outline = tool === editor3DTool ? lit : ''; p.grid.checked = editor3DGrid; p.ground.checked = editor3DGroundSnap; p.ownAxes.checked = editor3DLocalAxes; p.moveStep.value = editor3DMoveStep + ''; p.rotateStep.value = editor3DRotateStep + ''; p.scaleStep.value = editor3DScaleStep + ''; p.playFrom.checked = editor3DPlayFromMouse; p.playFrom.parentElement.style.display = editorHas('onPlayFrom') ? 'flex' : 'none'; p.storage.style.display = editor3DSaveFailed ? '' : 'none'; p.hint.textContent = editor3DHint(); p.help.style.display = editor3DHelp ? '' : 'none'; // a button for each type, made again when the types changed const names = editor3DPlaceNames(), plain = names.filter((name)=> !level3DPrefabs.has(name)); const prefabs = names.slice(plain.length); const typeNames = plain.join('\n') + '\n\n' + prefabs.join('\n'); if (p.typeNames !== typeNames) { p.typeNames = typeNames; p.typeButtons = names.map((name)=> { const b = editorElement('button', undefined, 'padding:3px 6px;cursor:pointer', name); b.onclick = ()=> { editor3DBrush = editor3DBrush === name ? undefined : name; b.blur(); }; return b; }); const label = editorElement('span', undefined, 'color:#aaa;width:100%', 'Prefabs'); const load = editorElement('button', undefined, 'padding:3px 6px;cursor:pointer', 'Load prefab…'); load.title = 'Add a prefab from a file the editor saved, by its file name, to place in this level'; load.onclick = ()=> { editor3DPrefabPickFile(); load.blur(); }; p.types.replaceChildren(...p.typeButtons.slice(0, plain.length), label, ...p.typeButtons.slice(plain.length), load); } p.prefabNote.style.display = editor3DPrefabMessage && !editor3DSelection.size ? '' : 'none'; p.prefabNote.textContent = editor3DPrefabMessage; names.forEach((name, i)=> p.typeButtons[i].style.outline = name === editor3DBrush ? lit : ''); editor3DPropertiesUpdate(p.properties); editor3DPrefabBoxUpdate(p.prefabBox); p.prefabOpen.style.display = editor3DPrefabStack.length ? 'flex' : 'none'; p.prefabName.textContent = 'Editing prefab ' + editor3DPrefabStack.map((frame)=> frame.name).join(' > '); p.prefabUnsaved.style.display = editor3DPrefabDirty.size ? '' : 'none'; p.prefabUnsaved.textContent = 'Edited and not in its file: ' + [...editor3DPrefabDirty].join(', ') + '. Open it with Enter and Save'; editor3DBlocksUpdate(p.blocks); editor3DTerrainBoxUpdate(p.terrainBox); p.sceneOn.checked = !!editor3DScene(); editor3DSceneUpdate(p.sceneRows); } // the prefab box, shown with a selection: a name and a button to make it a prefab, and for one selected instance // its prefab's name, Unpack, Export, and for a prefab of the level's own whether it is attached function editor3DPrefabBoxUpdate(box) { const selected = editor3DSelected(), made = selected.length === 1 && editor3DInstances.get(selected[0].id); const instance = made instanceof Prefab3D ? made : undefined, prefab = instance && level3DPrefabs.get(instance.prefabName); const key = !selected.length ? '' : [selected.length, instance?.prefabName, prefab?.attached, prefab?.fromLevel, editor3DPrefabMessage, editor3DPrefabStack.length] + ''; box.style.display = key ? '' : 'none'; if (box.dataset.key === key || box.contains(document.activeElement)) return; box.dataset.key = key; box.replaceChildren(); if (!key) return; const field = 'background:#222;color:#eee', press = 'padding:2px 6px;cursor:pointer'; const row = ()=> editorElement('div', box, 'display:flex;gap:4px;align-items:center;margin:2px 0;flex-wrap:wrap'); if (prefab) { const line = row(); editorElement('span', line, 'flex:1;color:#aaa', 'Prefab ' + instance.prefabName); const open = editorElement('button', line, press, 'Edit'); open.title = 'Enter: open the prefab alone to edit it, every instance of it follows'; open.onclick = ()=> { editor3DPrefabEnter(); open.blur(); }; const unpack = editorElement('button', line, press, 'Unpack'); unpack.title = 'Ctrl+Shift+G: turn this instance into the objects it is made of'; unpack.onclick = ()=> { editor3DUnpack(); unpack.blur(); }; const save = editorElement('button', line, press, 'Export'); save.title = 'Save the prefab as a file of its own, to load with level3DLoadPrefab; a prefab used inside it ' + 'is named by its type, and the game adds that one too'; save.onclick = ()=> { saveText(editor3DPrefabJSON(instance.prefabName), instance.prefabName + '.json', 'application/json'); save.blur(); }; if (prefab.fromLevel && !editor3DPrefabStack.length) { const label = editorElement('label', box, 'display:flex;gap:6px;align-items:center;margin:2px 0'); label.title = 'Its parts are attached to each instance and move with it as one body, with no ' + 'collision of their own; off, each part is an object of its own and solid'; const attached = editorElement('input', label); attached.type = 'checkbox'; attached.checked = prefab.attached; attached.onchange = ()=> { editor3DPrefabSetAttached(instance.prefabName, attached.checked); attached.blur(); }; editorElement('span', label, '', 'Attached, one body'); } } const line = row(); const name = editorElement('input', line, field + ';flex:1;min-width:60px'); name.placeholder = 'prefab name'; const make = editorElement('button', line, press, 'Make prefab'); make.title = 'Ctrl+G: the selection becomes one thing to place many times, kept in the level'; make.onclick = ()=> { editor3DMakePrefab(name.value); make.blur(); }; name.onkeydown = (e)=> { e.key === 'Enter' && (editor3DMakePrefab(name.value), name.blur()); }; editor3DPrefabMessage && editorElement('div', box, 'color:#f86', editor3DPrefabMessage); } // a level's color as a color input takes it, six digits and no alpha, white for one it can not show function editor3DInputColor(hex) { return /^#[0-9a-f]{6}/i.test(hex) ? hex.slice(0, 7) : '#ffffff'; } // the terrain box, shown with the Terrain tool: without a terrain, its size, cells and height and a button to add // it; with one, the brush, its size and strength or the color it paints, the terrain's color, and a button to // take it away function editor3DTerrainBoxUpdate(box) { const map = editor3DTerrainMap(), shown = editor3DTool === 'terrain'; box.style.display = shown ? '' : 'none'; if (!shown) return; const terrain = editor3DLevelPart('terrain'), brush = editor3DTerrainBrush; const key = map ? ['terrain', terrain.color, brush.mode] + '' : 'none'; if (box.dataset.key === key || box.contains(document.activeElement)) return; box.dataset.key = key; box.replaceChildren(); const field = 'background:#222;color:#eee'; const line = (name, title='')=> { const row = editorElement('label', box, 'display:flex;gap:4px;align-items:center;margin:2px 0'); editorElement('span', row, 'flex:1', name); row.title = title; return row; }; const number = (name, title, value, most)=> { const input = editorElement('input', line(name, title), field + ';width:60px'); input.type = 'number'; input.min = '1', input.max = most + '', input.step = '1'; input.value = value + ''; return input; }; if (!map) { const size = number('Size', 'How far across it is in the world, each way', 64, 1024); const cellsRow = line('Cells', 'How many cells a side, more is finer and slower to sculpt'); const cells = editorElement('select', cellsRow, field + ';width:68px'); for (const n of [32, 64, 128]) editorElement('option', cells, '', n + '').value = n + ''; cells.value = '64'; const height = number('Height', 'How tall its highest ground can be', 16, 1024); const add = editorElement('button', box, 'width:100%;padding:3px;cursor:pointer', 'Add terrain'); add.title = 'A flat terrain, centered, its surface on the ground, to raise and dig'; add.onclick = ()=> { const read = (input, fallback)=> clamp(parseFloat(input.value) || fallback, 1, 1024); editor3DTerrainAdd(vec2(read(size, 64)), parseInt(cells.value), read(height, 16)); add.blur(); }; return; } const slider = (name, title, min, max, step, value, set)=> { const input = editorElement('input', line(name, title), 'width:120px'); input.type = 'range'; input.min = min, input.max = max, input.step = step; input.value = value + ''; input.oninput = ()=> set(parseFloat(input.value)); input.onchange = ()=> input.blur(); }; const brushes = editorElement('select', line('Brush', 'X: sculpt raises and lowers the ground, flatten brings ' + 'it to the height pressed, paint colors it'), field + ';width:120px'); for (const name of editor3DTerrainBrushes) editorElement('option', brushes, '', name[0].toUpperCase() + name.slice(1)).value = name; brushes.value = brush.mode; brushes.onchange = ()=> { brush.mode = brushes.value; brushes.blur(); }; slider('Brush size', 'How far across the brush is', 1, 32, .5, brush.size, (v)=> brush.size = v); if (brush.mode === 'paint') { const paint = editorElement('input', line('Paint', 'The color the brush paints'), field + ';width:60px'); paint.type = 'color'; paint.value = editor3DInputColor(brush.color); paint.oninput = ()=> brush.color = paint.value; paint.onchange = ()=> { brush.color = paint.value; paint.blur(); }; } else slider('Strength', 'How fast the brush works', .05, 1, .05, brush.strength, (v)=> brush.strength = v); const color = editorElement('input', line('Color', 'The color of the ground that is not painted'), field + ';width:60px'); color.type = 'color'; color.value = editor3DInputColor(terrain.color); color.oninput = ()=> editor3DChangePart('terrain', (t)=> ({...t, color: color.value})); color.onchange = ()=> { color.oninput(); editor3DStrokeEnd(); color.blur(); }; const remove = editorElement('button', box, 'margin-top:4px;padding:2px 6px;cursor:pointer', 'Remove terrain'); remove.title = 'Take the terrain out of the level, Undo brings it back'; remove.onclick = ()=> { editor3DTerrainRemove(); remove.blur(); }; } // the blocks box, shown with the Blocks tool: without a map, its size and a button to add it; with one, a tile // for each block type to paint with, and a button to take the map away; made again when what it shows changes function editor3DBlocksUpdate(box) { const map = editor3DVoxelMap(), shown = editor3DTool === 'blocks'; box.style.display = shown ? '' : 'none'; if (!shown) return; const first = map?.tileInfo, texture = first?.textureInfo; const key = map ? ['map', editor3DBlockType, texture?.size.x, map.blockTypes.length, editor3DBlockBox, map.mapSize].join() : 'none'; if (box.dataset.key === key || box.contains(document.activeElement)) return; box.dataset.key = key; box.replaceChildren(); const field = 'background:#222;color:#eee;width:46px'; if (!map) { const row = editorElement('label', box, 'display:flex;gap:4px;align-items:center;margin:2px 0'); editorElement('span', row, 'flex:1', 'Map size'); const inputs = [32, 16, 32].map((n)=> { const input = editorElement('input', row, field); input.type = 'number'; input.min = '1', input.max = '256', input.step = '1'; input.value = n + ''; return input; }); const add = editorElement('button', box, 'width:100%;padding:3px;cursor:pointer', 'Add block map'); add.title = 'An empty map of blocks, centered, its bottom on the ground'; add.onclick = ()=> { const [x, y, z] = inputs.map((i, k)=> clamp(floor(parseFloat(i.value)) || [32, 16, 32][k], 1, 256)); editor3DVoxelAdd(vec3(x, y, z)); add.blur(); }; return; } // the tiles of the sheet, a type shows the tile of its side; a click picks the type const image = texture?.image, padding = first.padding || 0; const cell = first.size.add(vec2(padding * 2)); const count = texture && cell.x && cell.y ? floor(texture.size.x / cell.x) * floor(texture.size.y / cell.y) : 0; const types = []; for (let type = 1; type < 256 && types.length < 96; ++type) (type < count || map.blockTypes[type]) && types.push(type); types.includes(editor3DBlockType) || types.push(editor3DBlockType); const grid = editorElement('div', box, 'display:flex;flex-wrap:wrap;gap:2px'); for (const type of types) { const canvas = editorElement('canvas', grid, 'width:26px;height:26px;cursor:pointer;background:#444;' + 'image-rendering:pixelated;outline:' + (type === editor3DBlockType ? '2px solid #4af' : 'none')); canvas.width = canvas.height = 26; canvas.title = 'Block ' + type; canvas.onclick = ()=> editor3DBlockType = type; // the tile of its side; read without map.blockType, which would make every type one the map has const index = map.blockTypes[type]?.faces[0] ?? type; const tile = first.columns ? first.frame(index) : first.index(index); const context = canvas.getContext('2d'); context.imageSmoothingEnabled = false; try { image && context.drawImage(image, tile.pos.x, tile.pos.y, tile.size.x, tile.size.y, 0, 0, 26, 26); // a tile with nothing on it is left out, unless it is the type picked, one in use or one the game set up const pixels = context.getImageData(0, 0, 26, 26).data; let blank = !!image; for (let i = 3; blank && i < pixels.length; i += 4) blank = !pixels[i]; blank && type !== editor3DBlockType && !map.blockTypes[type] && canvas.remove(); } catch {} // an image that can not be drawn or read, the title still says the type } // box fill: a drag fills the rectangle dragged, this many layers tall const boxRow = editorElement('label', box, 'display:flex;gap:6px;align-items:center;margin:4px 0 2px'); boxRow.title = 'X: a drag fills the whole rectangle from the press to the mouse, Shift clears it, Ctrl repaints'; const boxOn = editorElement('input', boxRow); boxOn.type = 'checkbox'; boxOn.checked = editor3DBlockBox; boxOn.onchange = ()=> { editor3DBlockBox = boxOn.checked; boxOn.blur(); }; editorElement('span', boxRow, 'flex:1', 'Box fill, height'); const boxHeight = editorElement('input', boxRow, field); boxHeight.type = 'number'; boxHeight.min = '1', boxHeight.max = '256', boxHeight.step = '1'; boxHeight.value = editor3DBlockBoxHeight + ''; boxHeight.onchange = ()=> { editor3DBlockBoxHeight = clamp(floor(parseFloat(boxHeight.value)) || 1, 1, 256); boxHeight.value = editor3DBlockBoxHeight + ''; boxHeight.blur(); }; // the map's size, to change const sizeRow = editorElement('label', box, 'display:flex;gap:4px;align-items:center;margin:2px 0'); editorElement('span', sizeRow, 'flex:1', 'Map size'); const axes = ['x', 'y', 'z'], sizes = axes.map((axis)=> { const input = editorElement('input', sizeRow, field); input.type = 'number'; input.min = '1', input.max = '256', input.step = '1'; input.value = map.mapSize[axis] + ''; return input; }); const resize = editorElement('button', box, 'padding:2px 6px;cursor:pointer;margin-right:4px', 'Resize'); resize.title = 'Change the map\'s size, its blocks stay where they are; Undo brings back what was cut off'; resize.onclick = ()=> { // a field left empty keeps the size it has const [x, y, z] = sizes.map((i, k)=> clamp(floor(parseFloat(i.value)) || map.mapSize[axes[k]], 1, 256)); editor3DVoxelResize(vec3(x, y, z)); resize.blur(); }; const remove = editorElement('button', box, 'margin-top:4px;padding:2px 6px;cursor:pointer', 'Remove map'); remove.title = 'Take the block map out of the level, Undo brings it back'; remove.onclick = ()=> { editor3DVoxelRemove(); remove.blur(); }; } // the scene box: an input for each setting of the level's scene block, made again when the block changes, but not // while one of its inputs is being used; a slider or a color changes the scene as it moves, and is one undo function editor3DSceneUpdate(box) { const scene = editor3DScene(), key = scene ? JSON.stringify(scene) : ''; box.style.display = scene ? '' : 'none'; if (box.dataset.key === key || box.contains(document.activeElement)) return; box.dataset.key = key; box.replaceChildren(); if (!scene) return; // a setting the block leaves out shows as it is on screen const view = editor3DSceneFromView(), value = (name)=> scene[name] ?? view[name]; // 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 // made again while it has the focus, so a field edited right after another must not put the other one back; // set is given the setting's value now, or what shows for one the block leaves out const change = (name, set)=> editor3DChangeScene((s={})=> ({...s, [name]: typeof set === 'function' ? set(s[name] ?? view[name]) : set})); const field = 'background:#222;color:#eee'; const line = (name, title='')=> { const row = editorElement('label', box, 'display:flex;gap:4px;align-items:center;margin:2px 0'); editorElement('span', row, 'flex:1', name); row.title = title; return row; }; // an input that changes the scene as it moves and ends the edit when let go const live = (input, read)=> { input.oninput = ()=> read(); input.onchange = ()=> { read(); editor3DStrokeEnd(); input.blur(); }; return input; }; const slider = (name, title, min, max, step, v, set)=> { const input = editorElement('input', line(name, title), 'width:120px'); input.type = 'range'; input.min = min, input.max = max, input.step = step; input.value = v + ''; return live(input, ()=> set(parseFloat(input.value))); }; const color = (name, title, hex, set)=> { const input = editorElement('input', line(name, title), field + ';width:60px'); input.type = 'color'; input.value = editor3DInputColor(hex); return live(input, ()=> set(input.value)); }; const number = (row, v, set)=> { const input = editorElement('input', row, field + ';width:56px'); input.type = 'number'; input.step = 'any'; input.min = '0'; input.value = v + ''; input.onchange = ()=> { const n = parseFloat(input.value); isFinite(n) && n >= 0 ? set(v = n) : input.value = v + ''; // one not taken shows what it has again editor3DStrokeEnd(); input.blur(); }; return input; }; const toggle = (name, title, on, set)=> { const input = editorElement('input', line(name, title)); input.type = 'checkbox'; input.checked = on; input.onchange = ()=> { set(input.checked); editor3DStrokeEnd(); input.blur(); }; }; // the sky: three colors and how much of them lights the scene, or no sky const sky = isArray(scene.sky) && scene.sky.length === 3 ? scene.sky : undefined; toggle('Sky', 'A sky dome, and light from it; off leaves the sky to the game', !!sky, (on)=> editor3DChangeScene((s={})=> { const {sky, ambient, ...rest} = s; return on ? {sky: ['#3d9be9', '#ccebff', '#ccebff'], ambient: .5, ...rest} : rest; })); if (sky) { ['Sky top', 'Sky horizon', 'Sky bottom'].forEach((name, i)=> color(name, '', sky[i], (hex)=> change('sky', (now)=> (isArray(now) && now.length === 3 ? now : sky).map((c, k)=> k === i ? hex : c)))); slider('Ambient', 'How much of the sky colors lights the scene', 0, 1, .05, isNumber(scene.ambient) ? scene.ambient : .5, (v)=> change('ambient', v)); } // the sun, as two angles const [around, height] = editor3DSunAngles(value('sunDirection')); slider('Sun around', 'Where the sun is around the scene, in degrees', 0, 359, 1, around, (v)=> change('sunDirection', (now)=> editor3DSunDirection(v, editor3DSunAngles(now)[1]))); slider('Sun height', 'How high the sun is over the horizon, in degrees', 5, 89, 1, height, (v)=> change('sunDirection', (now)=> editor3DSunDirection(editor3DSunAngles(now)[0], v))); color('Sun color', '', value('sunColor'), (hex)=> change('sunColor', hex)); // the fog, by its distances, an end of 0 for none const fog = isArray(value('fog')) ? value('fog') : [0, 0], fogRow = line('Fog start, end', 'Where the fog starts and where it is total, an end of 0 for no fog'); const fogNow = (now)=> isArray(now) && now.length === 2 ? now : fog; number(fogRow, fog[0], (v)=> change('fog', (now)=> [v, fogNow(now)[1]])); number(fogRow, fog[1], (v)=> change('fog', (now)=> [fogNow(now)[0], v])); color('Fog color', 'The sky\'s horizon color when the level has a sky and no fog color', scene.fogColor ?? (sky ? sky[1] : view.fogColor), (hex)=> change('fogColor', hex)); toggle('Shadows', 'The sun casts shadows', !!value('shadows'), (on)=> change('shadows', on)); toggle('Lens flare', 'The sun flares in the lens when it is in view', !!value('lensFlare'), (on)=> change('lensFlare', on)); // a sky box of six images, their urls in one field by commas, and whether shiny things reflect it; the // environment follows the sky box while it is the same images const urls = (list)=> isArray(list) && list.length === 6 ? list.join(', ') : ''; const skyBox = editorElement('input', line('Sky box', 'Six image urls by commas, +x, -x, +y, -y, +z and -z, a ' + 'comma and a space between data urls, loaded as a cube map for the sky; empty for none'), field + ';width:120px'); skyBox.value = urls(scene.skyBox); skyBox.onchange = ()=> { // by a comma and a space, so a data url keeps its own comma, or by commas alone when that is not six const value = skyBox.value.replace(/[,\s]+$/, ''); // a comma left at the end is not part of the last url const split = (by)=> value.split(by).map((url)=> url.trim()).filter((url)=> url); let list = split(/,\s+/); list.length === 6 || (list = split(',')); if (list.length && list.length !== 6) skyBox.value = urls(editor3DScene()?.skyBox); // not six, what it has now shows again else editor3DChangeScene((s={})=> { const {skyBox, environment, ...rest} = s, reflected = !!urls(skyBox) && urls(environment) === urls(skyBox); return {...rest, ...list.length && {skyBox: list}, ...(reflected ? list.length && {environment: [...list]} : environment && {environment})}; }); editor3DStrokeEnd(); skyBox.blur(); }; if (urls(scene.skyBox)) toggle('Reflect it', 'Shiny things reflect the sky box, as render3D.environment', urls(scene.environment) === urls(scene.skyBox), (on)=> editor3DChangeScene((s={})=> { const {environment, ...rest} = s; return on && isArray(s.skyBox) ? {...rest, environment: [...s.skyBox]} : rest; })); else if (urls(scene.environment)) // one of its own, with no sky box, can still be taken out toggle('Environment', 'The level\'s own environment, six images that shiny things reflect; off takes it out', true, (on)=> on || editor3DChangeScene((s={})=> { const {environment, ...rest} = s; return rest; })); } // the properties box: the position, rotation and scale of the one selected object and an input for each default // of its type, made again when the selection or the object changes, but not while one of its inputs is typed in function editor3DPropertiesUpdate(box) { const selected = editor3DSelected(), object = selected.length === 1 && selected[0]; const type = object && level3DTypes.get(object.type); const key = object ? JSON.stringify(object) : selected.length ? selected.length + ' objects' : ''; box.style.display = key ? '' : 'none'; if (box.dataset.key === key || box.contains(document.activeElement)) return; box.dataset.key = key; box.replaceChildren(); if (!object) { key && editorElement('div', box, 'color:#aaa', key + ' selected'); return; } editorElement('div', box, 'color:#aaa;margin-bottom:2px', `${object.type} ${object.id}`); const field = 'background:#222;color:#eee'; const line = (name)=> { const row = editorElement('label', box, 'display:flex;gap:4px;align-items:center;margin:2px 0'); editorElement('span', row, 'flex:1', name); return row; }; // the numbers of a vector, set together once every one of them reads as a number const numbers = (name, value, set)=> { const row = line(name), axes = isVector3(value) ? 'xyz' : 'xy', inputs = []; for (const axis of axes) { const input = editorElement('input', row, field + ';width:' + (axes.length > 2 ? 46 : 52) + 'px'); input.type = 'number'; input.step = 'any'; input.value = editor3DRound(value[axis]) + ''; input.onchange = ()=> { const v = inputs.map((i)=> parseFloat(i.value)); if (v.every((n)=> isFinite(n))) set(axes.length > 2 ? vec3(v[0], v[1], v[2]) : vec2(v[0], v[1])); else inputs.forEach((i, k)=> i.value = editor3DRound(value[axes[k]]) + ''); input.blur(); }; inputs.push(input); } }; numbers('pos', editor3DPos(object), (v)=> editor3DSetSelectedTransform('pos', v)); numbers('rotation', editor3DRotation(object), (v)=> editor3DSetSelectedTransform('rotation', v)); numbers('scale', editor3DScale(object), (v)=> editor3DSetSelectedTransform('scale', v)); if (!type) return; const values = level3DProperties(type, object); for (const [name, defaultValue] of Object.entries(type.defaults)) { const value = values[name]; if (isVector3(defaultValue) || isVector2(defaultValue)) { numbers(name, value, (v)=> editor3DSetSelectedProperty(name, v)); continue; } const input = editorElement('input', line(name), field + ';width:110px'); const set = (v)=> { editor3DSetSelectedProperty(name, v); input.blur(); }; if (typeof defaultValue === 'boolean') { input.type = 'checkbox'; input.checked = !!value; input.onchange = ()=> set(input.checked); } else if (isColor(defaultValue)) { input.type = 'color'; input.value = value.toString(false); input.onchange = ()=> { const color = new Color().setHex(input.value); color.a = value.a; set(color); }; } else if (typeof defaultValue === 'number') { input.type = 'number'; input.step = 'any'; input.value = value + ''; input.onchange = ()=> { const v = parseFloat(input.value); isFinite(v) && set(v); }; } else if (typeof defaultValue === 'string') { input.type = 'text'; input.value = (value ?? '') + ''; input.onchange = ()=> set(input.value); } else { // a type it has no input for, an array say, shown as it is input.readOnly = true; input.value = value + ''; } } } /////////////////////////////////////////////////////////////////////////////// // plugin function editor3DUpdate() { const seconds = clamp(timeReal - editor3DTimeLast, 0, .1); editor3DTimeLast = timeReal; if (!render3D) return; if (editor3DFreeCamera) return inputCaptureRead(()=> editor3DFreeCameraUpdate(seconds)); if (editor3DIsOpen) return inputCaptureRead(()=> editor3DWithView(()=> editor3DEditorUpdate(seconds))); // playing in an editing session, Escape, the debug key, goes back to the editor; taken, so the debug overlay // waits till the session ends; a captured mouse the browser let go of is Escape too, Chrome keeps that key const lostLock = editor3DLostLock(pointerLockIsActive()); if (editor3DSession && (lostLock || debugKey && keyWasPressed(debugKey))) { debugKey && inputClearKey(debugKey); editor3DSetOpen(true); return; } editor3DFreeCameraKey(); } function editor3DRender() { if (headlessMode) return; editor3DPanelUpdate(); if (editor3DFreeCamera) drawTextScreen('Free camera · C or Esc to leave', vec2(mainCanvasSize.x / 2, mainCanvasSize.y - 30), 20, WHITE, 4, BLACK); editor3DIsOpen && editor3DDrawLabels(); editor3DIsOpen && editor3DRecords.get(editor3DLevel)?.pending && editorPendingNote(); } debug && engineAddPlugin(editor3DUpdate, editor3DRender); debug && debugOverlayKeys.push(()=> render3D && {text: 'C: Free Camera', on: editor3DFreeCamera});