UNPKG

littlejsengine

Version:

LittleJS - Tiny and Fast HTML5 Game Engine

1,456 lines (1,287 loc) 483 kB
// 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 base class engine object * - Base class object handles update, physics, collision, rendering, etc * - Engine helper classes and functions like Vector2, Color, and Timer * - Super fast rendering system for tile sheets * - Sound effects audio with zzfx and music with zzfxm * - Input processing system with gamepad and touchscreen support * - Tile layer rendering and collision system * - Particle effect system * - Medal system tracks and displays achievements * - Debug tools and debug rendering system * - Post processing effects * - Call engineInit() to start it up! * @namespace Engine */ /** Name of engine * @type {string} * @default * @memberof Engine */ const engineName = 'LittleJS'; /** Version of engine * @type {string} * @default * @memberof Engine */ const engineVersion = '1.17.11'; /** Frames per second to update * @type {number} * @default * @memberof Engine */ const frameRate = 60; /** How many seconds each frame lasts, engine uses a fixed time step * @type {number} * @default 1/60 * @memberof Engine */ const timeDelta = 1/frameRate; /** Array containing all engine objects * @type {Array<EngineObject>} * @memberof Engine */ let engineObjects = []; /** Array with only objects set to collide with other objects this frame (for optimization) * @type {Array<EngineObject>} * @memberof Engine */ let engineObjectsCollide = []; /** Current update frame, used to calculate time * @type {number} * @memberof Engine */ let frame = 0; /** Current engine time since start in seconds * @type {number} * @memberof Engine */ let time = 0; /** Actual clock time since start in seconds (not affected by pause or frame rate clamping) * @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; } // Engine internal variables let frameTimeLastMS = 0, frameTimeBufferMS = 0, averageFPS = 0; let showEngineVersion = true; /////////////////////////////////////////////////////////////////////////////// // plugin hooks const pluginList = []; class EnginePlugin { constructor(update, render, glContextLost, glContextRestored) { this.update = update; this.render = render; this.glContextLost = glContextLost; this.glContextRestored = glContextRestored; } } /** * @callback PluginCallback - Update or render function for a plugin * @memberof Engine */ /** Add a new update function for a plugin * @param {PluginCallback} [update] * @param {PluginCallback} [render] * @param {PluginCallback} [glContextLost] * @param {PluginCallback} [glContextRestored] * @memberof Engine */ function engineAddPlugin(update, render, glContextLost, glContextRestored) { // make sure plugin functions are unique ASSERT(!pluginList.find(p=> p.update === update && p.render === render && p.glContextLost === glContextLost && p.glContextRestored === glContextRestored)); const plugin = new EnginePlugin(update, render, glContextLost, glContextRestored); pluginList.push(plugin); } /////////////////////////////////////////////////////////////////////////////// // Main Engine Functions /** * @callback GameInitCallback - Called after the engine starts, can be async * @return {void|Promise<void>} * @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']) * @param {HTMLElement} [rootElement] - Root DOM element to attach canvas to, defaults to document.body * @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 */ async function engineInit(gameInit, gameUpdate, gameUpdatePost, gameRender, gameRenderPost, imageSources=[], rootElement=document.body) { showEngineVersion && console.log(`${engineName} Engine v${engineVersion}`); ASSERT(!mainContext, 'engine already initialized'); 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() { // save canvas size mainCanvasSize = vec2(mainCanvas.width, mainCanvas.height); // disable smoothing for pixel art mainContext.imageSmoothingEnabled = !tilesPixelated; // setup gl rendering if enabled glPreRender(); } // internal update loop for engine function engineUpdate(frameTimeMS=0) { // update time keeping let frameTimeDeltaMS = frameTimeMS - frameTimeLastMS; frameTimeLastMS = frameTimeMS; if (debug || debugWatermark) averageFPS = lerp(averageFPS, 1e3/(frameTimeDeltaMS||1), .05); const debugSpeedUp = debug && keyIsDown('Equal'); // + const debugSpeedDown = debug && keyIsDown('Minus'); // - if (debug) // +/- to speed/slow time frameTimeDeltaMS *= debugSpeedUp ? 10 : debugSpeedDown ? .1 : 1; timeReal += frameTimeDeltaMS / 1e3; frameTimeBufferMS += paused ? 0 : frameTimeDeltaMS; if (!debugSpeedUp) frameTimeBufferMS = min(frameTimeBufferMS, 50); // clamp min framerate let wasUpdated = false; if (paused) { // update everything except the game and objects wasUpdated = true; updateCanvas(); inputUpdate(); pluginList.forEach(plugin=>plugin.update?.()); // update object transforms even when paused for (const o of engineObjects) o.parent || o.updateTransforms(); // do post update debugUpdate(); gameUpdatePost(); inputUpdatePost(); if (debugVideoCaptureIsActive()) renderFrame(); } else { // 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 for (; frameTimeBufferMS >= 0; frameTimeBufferMS -= 1e3 / frameRate) { // increment frame and update time time = frame++ / frameRate; // update game and objects wasUpdated = true; updateCanvas(); inputUpdate(); gameUpdate(); pluginList.forEach(plugin=>plugin.update?.()); engineObjectsUpdate(); // do post update debugUpdate(); gameUpdatePost(); inputUpdatePost(); if (debugVideoCaptureIsActive()) renderFrame(); } // add the time smoothing back in frameTimeBufferMS += deltaSmooth; } if (!debugVideoCaptureIsActive()) renderFrame(); requestAnimationFrame(engineUpdate); function renderFrame() { if (headlessMode) return; // canvas must be updated before rendering if (!wasUpdated) updateCanvas(); // render the game and objects enginePreRender(); gameRender(); engineObjects.sort((a,b)=> a.renderOrder - b.renderOrder); for (const o of engineObjects) o.destroyed || o.render(); // post rendering gameRenderPost(); pluginList.forEach(plugin=>plugin.render?.()); inputRender(); debugRender(); glFlush(); debugRenderPost(); drawCount = 0; } } function updateCanvas() { if (headlessMode) return; if (canvasFixedSize.x) { // set canvas fixed size mainCanvasSize = canvasFixedSize.copy(); // 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 mainCanvasSize.x = min(innerWidth, canvasMaxSize.x); mainCanvasSize.y = min(innerHeight, canvasMaxSize.y); // responsive aspect ratio with native resolution const innerAspect = innerWidth / innerHeight; ASSERT(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); } } // clear main canvas and set size mainCanvas.width = mainCanvasSize.x; mainCanvas.height = mainCanvasSize.y; // 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 mainContext.lineJoin = 'round'; mainContext.lineCap = 'round'; } // skip setup if headless if (headlessMode) return startEngine(); // setup webgl glInit(rootElement); // setup html const 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 rootElement.style.cssText = styleRoot; 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); updateCanvas(); glPreRender(); // create offscreen canvases for image processing workCanvas = new OffscreenCanvas(64, 64); workContext = workCanvas.getContext('2d'); workReadCanvas = new OffscreenCanvas(64, 64); workReadContext = workReadCanvas.getContext('2d', { willReadFrequently: true }); // create promises for loading images 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(fontImageInit()); if (showSplashScreen) { // draw splash screen promises.push(new Promise(resolve => { let t = 0; updateSplash(); function updateSplash() { inputClear(); drawEngineLogo(t+=.01); t>1 ? resolve() : setTimeout(updateSplash, 16); } })); } // wait for all the promises to finish await Promise.all(promises); return startEngine(); async function startEngine() { // wait for gameInit to load await gameInit(); engineUpdate(); } } /** Update each engine object, remove destroyed objects, and update time * can be called manually if objects need to be updated outside of main loop * @memberof Engine */ function engineObjectsUpdate() { // get list of solid objects for physics optimization engineObjectsCollide = engineObjects.filter(o=>o.collideSolidObjects); // recursive object update function updateChildObject(o) { if (o.destroyed) return; o.update(); for (const child of o.children) updateChildObject(child); } for (const o of engineObjects) { if (o.parent || o.destroyed) continue; // update top level objects o.update(); o.updatePhysics(); for (const child of o.children) updateChildObject(child); o.updateTransforms(); } // remove destroyed objects engineObjects = engineObjects.filter(o=>!o.destroyed); } /** Destroy and remove all objects * - This can be used to clear out all objects when restarting a level * - Objects can override their destroy function to do cleanup or stick around * @param {boolean} [immediate] - should attached effects be allowed to die off? * @memberof Engine */ function engineObjectsDestroy(immediate=true) { for (const o of engineObjects) o.parent || o.destroy(immediate); engineObjects = engineObjects.filter(o=>!o.destroyed); } /** Collects all object within a given area * @param {Vector2} [pos] - Center of test area, or undefined for all objects * @param {Vector2|number} [size] - Radius of circle if float, rectangle size if Vector2 * @param {Array<EngineObject>} [objects=engineObjects] - List of objects to check * @return {Array<EngineObject>} - List of collected objects * @memberof Engine */ function engineObjectsCollect(pos, size, objects=engineObjects) { const collectedObjects = []; if (!pos) { // all objects for (const o of objects) collectedObjects.push(o); } else if (size instanceof Vector2) { // bounding box test for (const o of objects) o.isOverlapping(pos, size) && collectedObjects.push(o); } else { // circle test const sizeSquared = size*size; for (const o of objects) pos.distanceSquared(o.pos) < sizeSquared && 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 * @param {Vector2} [pos] - Center of test area, or undefined for all objects * @param {Vector2|number} [size] - Radius of circle if float, rectangle size if Vector2 * @param {ObjectCallbackFunction} [callbackFunction] - Calls this function on every object that passes the test * @param {Array<EngineObject>} [objects=engineObjects] - List of objects to check * @memberof Engine */ function engineObjectsCallback(pos, size, callbackFunction, objects=engineObjects) { engineObjectsCollect(pos, size, objects).forEach(o => callbackFunction(o)); } /** Return a list of objects intersecting a ray * @param {Vector2} start * @param {Vector2} end * @param {Array<EngineObject>} [objects=engineObjects] - List of objects to check * @return {Array<EngineObject>} - List of objects hit * @memberof Engine */ function engineObjectsRaycast(start, end, objects=engineObjects) { const hitObjects = []; for (const o of objects) { if (o.collideRaycast && isIntersecting(start, end, o.pos, o.size)) { debugRaycast && debugRect(o.pos, o.size, '#f00'); hitObjects.push(o); } } debugRaycast && debugLine(start, end, hitObjects.length ? '#f00' : '#00f', .02); return hitObjects; } /////////////////////////////////////////////////////////////////////////////// function drawEngineLogo(t) { const blackAndWhite = 0; const showName = 1; // LittleJS Logo and Splash Screen const x = mainContext; const w = mainCanvas.width = innerWidth; const h = mainCanvas.height = innerHeight; { // 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 tos screen const alpha = wave(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;i<s.length;++i) w2 += x.measureText(s[i]).width; for (let j=2;j--;) for (let i=0,X=40-w2/2;i<s.length;++i) { const w = x.measureText(s[i]).width, X2 = X+w/2; gradient(X2,Y,X2+2,Y+13,i>5?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 Debug System * - Press Esc to show debug overlay with mouse pick * - Number keys toggle debug functions * - +/- apply time scale * - Debug primitive rendering * - Save a 2d canvas as a png image * @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; /** 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'; /** True if the debug overlay is active, always false in release builds * @type {boolean} * @default * @memberof Debug */ let debugOverlay = false; // Engine internal variables not exposed to documentation let debugPrimitives = [], debugPhysics = false, debugRaycast = false, debugParticles = false, debugGamepads = false, debugMedals = false, debugTakeScreenshot; /////////////////////////////////////////////////////////////////////////////// // 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 {boolean} assert * @param {...Object} output - error message output * @memberof Debug */ function ASSERT(assert, ...output) { if (assert) return; console.assert(assert, ...output) throw new Error('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 * @param {Vector2} pos * @param {Vector2} [size=vec2(0)] * @param {Color|string} [color] * @param {number} [time] * @param {number} [angle] * @param {boolean} [fill] * @param {boolean} [screenSpace] * @memberof Debug */ function debugRect(pos, size=vec2(), color=WHITE, time=0, angle=0, fill=false, screenSpace=false) { ASSERT(isVector2(pos), 'pos must be a vec2'); ASSERT(isVector2(size), 'size must be a vec2'); ASSERT(isString(color) || isColor(color), 'color is invalid'); ASSERT(isNumber(time), 'time must be a number'); ASSERT(isNumber(angle), 'angle must be a number'); if (typeof size === 'number') size = vec2(size); // allow passing in floats if (isColor(color)) color = color.toString(); pos = pos.copy(); size = size.copy(); const timer = new Timer(time); debugPrimitives.push({pos:pos.copy(), size:size.copy(), color, timer, angle, fill, screenSpace}); } /** Draw a debug poly in world space * @param {Vector2} pos * @param {Array<Vector2>} points * @param {Color|string} [color] * @param {number} [time] * @param {number} [angle] * @param {boolean} [fill] * @param {boolean} [screenSpace] * @memberof Debug */ function debugPoly(pos, points, color=WHITE, time=0, angle=0, fill=false, screenSpace=false) { ASSERT(isVector2(pos), 'pos must be a vec2'); ASSERT(isArray(points), 'points must be an array'); ASSERT(isString(color) || isColor(color), 'color is invalid'); ASSERT(isNumber(time), 'time must be a number'); ASSERT(isNumber(angle), 'angle must be a number'); if (isColor(color)) color = color.toString(); pos = pos.copy(); points = points.map(p=>p.copy()); const timer = new Timer(time); debugPrimitives.push({pos, points, color, timer, angle, fill, screenSpace}); } /** Draw a debug circle in world space * @param {Vector2} pos * @param {number} [size] - diameter * @param {Color|string} [color] * @param {number} [time] * @param {boolean} [fill] * @param {boolean} [screenSpace] * @memberof Debug */ function debugCircle(pos, size=0, color=WHITE, time=0, fill=false, screenSpace=false) { ASSERT(isVector2(pos), 'pos must be a vec2'); ASSERT(isNumber(size), 'size must be a number'); ASSERT(isString(color) || isColor(color), 'color is invalid'); ASSERT(isNumber(time), 'time must be a number'); if (isColor(color)) color = color.toString(); pos = pos.copy(); const timer = new Timer(time); debugPrimitives.push({pos, size, color, timer, angle:0, fill, screenSpace}); } /** Draw a debug point in world space * @param {Vector2} pos * @param {Color|string} [color] * @param {number} [time] * @param {number} [angle] * @param {boolean} [screenSpace] * @memberof Debug */ function debugPoint(pos, color, time, angle, screenSpace=false) { debugRect(pos, undefined, color, time, angle, false, screenSpace); } /** Draw a debug line in world space * @param {Vector2} posA * @param {Vector2} posB * @param {Color|string} [color] * @param {number} [width] * @param {number} [time] * @param {boolean} [screenSpace] * @memberof Debug */ function debugLine(posA, posB, color, width=.1, time, screenSpace=false) { 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); debugRect(posA.add(halfDelta), size, color, time, halfDelta.angle(), true, screenSpace); } /** Draw a debug combined axis aligned bounding box in world space * @param {Vector2} posA * @param {Vector2} sizeA * @param {Vector2} posB * @param {Vector2} sizeB * @param {Color|string} [color] * @param {number} [time] * @param {boolean} [screenSpace] * @memberof Debug */ function debugOverlap(posA, sizeA, posB, sizeB, color, time, screenSpace=false) { 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 a debug axis aligned bounding box in world space * @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] * @memberof Debug */ function debugText(text, pos, size=1, color=WHITE, time=0, angle=0, font='monospace', screenSpace=false) { ASSERT(isString(text), 'text must be a string'); ASSERT(isVector2(pos), 'pos must be a vec2'); ASSERT(isNumber(size), 'size must be a number'); ASSERT(isString(color) || isColor(color), 'color is invalid'); ASSERT(isNumber(time), 'time must be a number'); ASSERT(isNumber(angle), 'angle must be a number'); ASSERT(isString(font), 'font must be a string'); if (isColor(color)) color = color.toString(); pos = pos.copy(); const timer = new Timer(time); debugPrimitives.push({text, pos, size, color, timer, angle, font, screenSpace}); } /** Clear all debug primitives in the list * @memberof Debug */ function debugClear() { debugPrimitives = []; } /** 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 = 'background-color:#111;margin:8px'; document.body.innerHTML = `<pre style=color:#f00;font-size:28px;white-space:pre-wrap>` + message; } 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() { } function debugUpdate() { if (!debug) return; if (keyWasPressed(debugKey)) // Esc debugOverlay = !debugOverlay; if (debugOverlay) { if (keyWasPressed('Digit0')) debugWatermark = !debugWatermark; if (keyWasPressed('Digit1')) debugPhysics = !debugPhysics, debugParticles = false; if (keyWasPressed('Digit2')) debugParticles = !debugParticles, debugPhysics = false; if (keyWasPressed('Digit3')) debugGamepads = !debugGamepads; if (keyWasPressed('Digit4')) debugRaycast = !debugRaycast; if (keyWasPressed('Digit5')) debugScreenshot(); } if (debugVideoCaptureIsActive()) { // control to stop video capture if (!debugOverlay || keyWasPressed('Digit6')) debugVideoCaptureStop(); } else if (debugOverlay && keyWasPressed('Digit6')) debugVideoCaptureStart(); } function debugRender() { if (debugVideoCaptureIsActive()) return; // don't show debug info when capturing video // flush any gl sprites before drawing debug info glFlush(); if (debugTakeScreenshot) { // combine canvases, remove alpha and save combineCanvases(); saveCanvas(mainCanvas); debugTakeScreenshot = 0; } 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); if (i === gamepadPrimary) debugText('Main', cornerPos.add(vec2(-stickScale*2, 0)),1, '#0f0'); // read analog sticks const stickCount = gamepadStickData[i].length; for (let j = 0; j < stickCount; j++) { 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); debugLine(drawPos, stickPos, '#f00'); debugText(j, drawPos, .3); debugPoint(stickPos, '#f00'); } 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); debugText(j, drawPos, .3); } } } let debugObject; if (debugOverlay) { // draw red rectangle around screen const cameraSize = getCameraSize(); debugRect(cameraPos, cameraSize.subtract(vec2(.1)), '#f008'); // 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 floored tile pick for tile collision drawRect(mousePos.floor().add(vec2(.5)), vec2(1), rgb(1,1,0,.5), 0, false); } } { // draw debug primitives debugContext.lineWidth = 2; 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 ? 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, p.size*scale/2, 0, 9); p.fill && debugContext.fill(); debugContext.stroke(); } debugContext.restore(); }); // remove expired primitives debugPrimitives = debugPrimitives.filter(r=>r.timer<0); } if (debugObject) { const raycastHitPos = tileCollisionRaycast(debugObject.pos, mousePos); raycastHitPos && drawRect(raycastHitPos.floor().add(vec2(.5)), vec2(1), rgb(0,1,1,.3), 0, false); drawLine(mousePos, debugObject.pos, .1, raycastHitPos ? rgb(1,0,0,.5) : rgb(0,1,0,.5), undefined, undefined, false); let debugText = 'mouse pos = ' + mousePos; if (tileCollisionLayers.length) debugText += '\nmouse collision = ' + tileCollisionGetData(mousePos); debugText += '\n\n--- object info ---\n'; debugText += debugObject.toString(); drawTextScreen(debugText, mousePosScreen, 24, rgb(), .05, undefined, 'center', 'monospace'); } { // 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'; debugContext.shadowColor = '#000'; debugContext.shadowBlur = 9; 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 Count: ' + drawCount, 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 = debugParticles ? '#f00' : '#fff'; debugContext.fillText('2: Debug Particles', x, y += h); debugContext.fillStyle = debugGamepads ? '#f00' : '#fff'; debugContext.fillText('3: Debug Gamepads', x, y += h); debugContext.fillStyle = debugRaycast ? '#f00' : '#fff'; debugContext.fillText('4: Debug Raycasts', x, y += h); debugContext.fillStyle = '#fff'; debugContext.fillText('5: Save Screenshot', x, y += h); debugContext.fillText('6: Toggle Video Capture', x, y += h); let keysPressed = ''; let mousePressed = ''; for (const i in inputData[0]) { if (!keyIsDown(i, 0)) continue; if (parseInt(i) < 3) mousePressed += i + ' ' ; else if (keyIsDown(i, 0)) 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 (keyIsDown(j, i)) buttonsPressed += j + ' ' ; } buttonsPressed && debugContext.fillText(`Gamepad ${i-1}: ` + buttonsPressed, x, y += h); } } else { debugContext.fillText(debugPhysics ? 'Debug Physics' : '', 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.restore(); } } function debugRenderPost() { if (debugVideoCaptureIsActive()) { debugVideoCaptureUpdate(); return; } if (!debugWatermark) return; // update fps display mainContext.textAlign = 'right'; mainContext.textBaseline = 'top'; mainContext.font = '1em monospace'; mainContext.fillStyle = '#000'; const text = engineName + ' v' + engineVersion + ' / ' + drawCount + ' / ' + engineObjects.length + ' / ' + averageFPS.toFixed(1) + (glEnable ? ' GL' : ' 2D') ; mainContext.fillText(text, mainCanvas.width-3, 3); mainContext.fillStyle = '#fff'; mainContext.fillText(text, mainCanvas.width-2, 2); } /////////////////////////////////////////////////////////////////////////////// // video capture - records video and audio at 60 fps using MediaRecorder API // internal variables used to capture video let debugVideoCapture, debugVideoCaptureIcon; /** Check if video capture is active * @memberof Debug */ function debugVideoCaptureIsActive() { return !!debugVideoCapture; } /** Start capturing video * @memberof Debug */ function debugVideoCaptureStart() { ASSERT(!debugVideoCaptureIsActive(), 'Already capturing video!'); if (!debugVideoCaptureIcon) { // create recording icon to show it is capturing video debugVideoCaptureIcon = document.createElement('div'); debugVideoCaptureIcon.style.position = 'absolute'; debugVideoCaptureIcon.style.padding = '9px'; debugVideoCaptureIcon.style.color = '#f00'; debugVideoCaptureIcon.style.font = '50px monospace'; document.body.appendChild(debugVideoCaptureIcon); } // show recording icon debugVideoCaptureIcon.textContent = ''; debugVideoCaptureIcon.style.display = ''; // setup captureStream to capture manually by passing 0 const stream = mainCanvas.captureStream(0); const videoTrack = stream.getVideoTracks()[0]; const captureTimer = new Timer(0, true); const chunks = []; videoTrack.applyConstraints({frameRate:frameRate}); // set up the media recorder const mediaRecorder = new MediaRecorder(stream, {mimeType:'video/webm;codecs=vp8'}); mediaRecorder.ondataavailable = (e)=> chunks.push(e.data); mediaRecorder.onstop = ()=> { const blob = new Blob(chunks, {type: 'video/webm'}); const url = URL.createObjectURL(blob); saveDataURL(url, 'capture.webm', 1e3); }; let audioStreamDestination, silentAudioSource; if (soundEnable) { // create silent audio source // fixes issue where video can not start recording without audio silentAudioSource = new ConstantSourceNode(audioContext, { offset: 0 }); silentAudioSource.connect(audioMasterGain); silentAudioSource.start(); // connect to audio master gain node audioStreamDestination = audioContext.createMediaStreamDestination(); audioMasterGain.connect(audioStreamDestination); for (const track of audioStreamDestination.stream.getAudioTracks()) stream.addTrack(track); // add audio tracks to capture stream } // start recording try { mediaRecorder.start(); } catch(e) { LOG('Video capture not supported in this browser!'); silentAudioSource?.stop(); return; } LOG('Video capture started.'); // save debug video info debugVideoCapture = { mediaRecorder, captureTimer, videoTrack, silentAudioSource, audioStreamDestination }; } /** Stop capturing video and save to disk * @memberof Debug */ function debugVideoCaptureStop() { ASSERT(debugVideoCaptureIsActive(), 'Not capturing video!'); // stop recording LOG(`Video capture ended. ${debugVideoCapture.captureTimer.get().toFixed(2)} seconds recorded.`); debugVideoCaptureIcon.style.display = 'none'; debugVideoCapture.silentAudioSource?.stop(); debugVideoCapture.mediaRecorder?.stop(); debugVideoCapture.videoTrack?.stop(); debugVideoCapture = undefined; } // update video capture, called automatically by engine function debugVideoCaptureUpdate() { ASSERT(debugVideoCaptureIsActive(), 'Not capturing video!'); // save the video frame combineCanvases(); debugVideoCapture.videoTrack.requestFrame(); debugVideoCaptureIcon.textContent = '● REC ' + formatTime(debugVideoCapture.captureTimer); } /////////////////////////////////////////////////////////////////////////////// // 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, `Cannot modify engine constant. Attempted to set constant (${obj}) property '${prop}' to '${value}'.`); }, enumerable: true }); }); } // freeze the object to prevent adding new properties return Object.freeze(obj); } /** * LittleJS Math Classes and Functions * - General purpose math library * - RandomGenerator - seeded random number generator * - Vector2 - fast, simple, easy 2D vector class * - Color - holds a rgba color with math functions * @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