UNPKG

littlejsengine

Version:

LittleJS - Tiny and Fast HTML5 Game Engine

1,480 lines (1,297 loc) 459 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 - Release Mode * - This file is used for release builds in place of engineDebug.js * - Debug functionality is disabled to reduce size and increase performance */ let debugWatermark = 0; let debugKey = ''; const debug = 0; const debugOverlay = 0; const debugPhysics = 0; const debugParticles = 0; const debugRaycast = 0; const debugGamepads = 0; const debugMedals = 0; // debug commands are automatically removed from the final build function ASSERT (){} function LOG (){} function debugInit (){} function debugUpdate (){} function debugRender (){} function debugRenderPost (){} function debugRect (){} function debugPoly (){} function debugCircle (){} function debugPoint (){} function debugLine (){} function debugOverlap (){} function debugText (){} function debugClear (){} function debugScreenshot (){} function debugShowErrors(){} function debugVideoCaptureIsActive(){ return false; } function debugVideoCaptureStart (){} function debugVideoCaptureStop (){} function debugVideoCaptureUpdate(){} function debugProtectConstant(o){ return o; } /** * 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 value passed in * @param {number} x * @return {number} * @memberof Math */ const sign = Math.sign; /** Returns hypotenuse of values passed in * @param {...number} values * @return {number} * @memberof Math */ const hypot = Math.hypot; /** Returns log2 of value passed in * @param {number} x * @return {number} * @memberof Math */ const log2 = Math.log2; /** 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 parm 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 * @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 * @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) { const d = (valueA - valueB) % wrapSize; return d*2 % wrapSize - d; } /** Linearly interpolates between values passed in with wrapping * @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 * @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 * @param {number} percent * @return {number} * @memberof Math */ function smoothStep(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 & (value - 1)); } /** Returns the nearest power of two not less than the value * @param {number} value * @return {number} * @memberof Math */ function nearestPowerOfTwo(value) { return 2**ceil(log2(value)); } /** 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; return dx >= -sx && dx < sx && dy >= -sy && 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; } /** 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 * @return {number} - Value waving between 0 and amplitude * @memberof Math */ function wave(frequency=1, amplitude=1, t=time, offset=0) { return amplitude/2 * (1 - cos(offset + t*frequency*2*PI)); } /** * 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' && !isNaN(n); } /** * Check if object can be converted to a string (has a toString method) * - Returns true for strings, numbers, and most objects * - Returns false for null and undefined * @param {any} s * @return {boolean} * @memberof Math */ function isString(s) { return s != null && typeof s?.toString() === 'string'; } /** * Check if object is an array * @param {any} a * @return {boolean} * @memberof Math */ function isArray(a) { return Array.isArray(a); } /** * @callback LineTestFunction - Checks if a position is colliding * @param {Vector2} pos * @memberof Draw */ /** * 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 = hypot(dx, dy); if (!totalLength) return; // current integer cell we are in const pos = posStart.floor(); // 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); // move inside of tile if on positive edge const e = 1e-9; if (wasX) { if (stepX < 0) hitPos.x -= e; } else if (stepY < 0) hitPos.y -= 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) { return radius > 0 ? randVec2(radius * rand(minRadius / radius, 1)**.5) : new Vector2; } /** 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 * @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 or engine default seed */ constructor(seed = 123456789) { /** @property {number} - random seed */ 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 algorithm this.seed ^= this.seed << 13; this.seed ^= this.seed >>> 17; this.seed ^= this.seed << 5; return valueB + (valueA - valueB) * ((this.seed >>> 0) / 2**32); } /** Returns a floored seeded random value 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) { return this.float(valueA, valueB) * this.sign(); } /** Returns a random angle between -PI and PI * @return {number} */ angle() { return this.float(-PI, PI); } /** Returns a seeded vec2 with size between the two values passed in * @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 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), 'Vector2 is invalid.', v); } function ASSERT_NUMBER_VALID(n) { ASSERT(isNumber(n), 'Number is invalid.', n); } function ASSERT_VECTOR2_NORMAL(v) { ASSERT_VECTOR2_VALID(v); ASSERT(abs(v.lengthSquared()-1) < .01, 'Vector2 is not normal.', v); } /** * 2D Vector object with vector math library * - Functions do not change this so they can be chained together * @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(), 'Constructed Vector2 is invalid.', this); } /** 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 vector 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 * @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 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 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 * @param {Vector2} v - other vector * @param {number} percent * @return {Vector2} */ lerp(v, percent) { ASSERT_VECTOR2