littlejsengine
Version:
LittleJS - Tiny and Fast HTML5 Game Engine
1,433 lines (1,256 loc) • 613 kB
JavaScript
// 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.18.21';
/** 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, timescale, 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)
{
showEngineVersion && console.log(`${engineName} Engine v${engineVersion}`);
ASSERT(!mainContext, '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
if (mainContext) return;
ASSERT(isArray(imageSources), 'pass in images as array');
// ensure body exists for minimal HTML where the script runs before <body> is parsed
if (!document.body)
document.documentElement.appendChild(document.createElement('body'));
rootElement ||= document.body;
// 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;
// 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;
if (debug || debugWatermark)
averageFPS = lerp(averageFPS, 1e3/(frameTimeDeltaMS||1), .05);
const debugSpeedUp = debug && keyIsDown('Equal'); // +
const debugSpeedDown = debug && keyIsDown('Minus'); // -
const debugScale = debugSpeedUp ? 10 : debugSpeedDown ? .1 : 1;
// apply time deltas
timeReal += frameTimeDeltaMS * debugScale / 1e3;
const combinedScale = timeScale * debugScale;
frameTimeDeltaMS *= combinedScale;
frameTimeBufferMS += paused ? 0 : frameTimeDeltaMS;
if (combinedScale <= 1)
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;
primitiveCount = 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
{
// apply device pixel ratio for crisp rendering
const dpr = canvasPixelRatio ?? (devicePixelRatio || 1);
const viewWidth = innerWidth * dpr | 0;
const viewHeight = innerHeight * dpr | 0;
// get main canvas size based on window size
mainCanvasSize.x = min(viewWidth, canvasMaxSize.x);
mainCanvasSize.y = min(viewHeight, canvasMaxSize.y);
// responsive aspect ratio with native resolution
const innerAspect = viewWidth / viewHeight;
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);
}
// set CSS display size so backing store renders at viewport size
const cssW = (mainCanvasSize.x / dpr | 0) + 'px';
const cssH = (mainCanvasSize.y / dpr | 0) + 'px';
mainCanvas.style.width = cssW;
mainCanvas.style.height = cssH;
if (glCanvas)
{
glCanvas.style.width = cssW;
glCanvas.style.height = cssH;
}
}
// 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(imageFontInit());
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);
// update physics before object update
for (const o of engineObjects)
if (!o.parent && !o.destroyed)
o.updatePhysics();
// 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();
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;
}
/**
* LittleJS - Release Mode
* - Replaces engineDebug.js in production builds
* - All debug functions are stubbed out as no-ops
* - Removes ASSERT and LOG calls to reduce file size
* - Disables debug overlay, watermark, and visualizations
* - Improves performance by eliminating debug overhead
* - Significantly reduces final bundle size
*/
let debugWatermark = 0;
let debugKey = '';
const debug = 0;
const debugOverlay = 0;
const debugPhysics = 0;
const debugParticles = 0;
const debugRaycast = 0;
const debugGamepads = 0;
const debugSound = 0;
const debugPointSize = .5;
// 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
* - 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 = (...values) => Math.hypot(...values);
/** 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 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)
{
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
* @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 > 0 && !(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;
// 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;
}
/** 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
value = 2 * abs(2 * phase - 1) - 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' && !isNaN(n); }
/**
* 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() === 'string'; }
/**
* Check if object is an array
* @param {any} a
* @return {a is Array<any>}
* @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 = (dx*dx + dy*dy)**.5;
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);
// 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
* @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)
{
ASSERT(seed !== 0, 'RandomGenerator seed must be non-zero (xorshift is fixed at 0)');
/** @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)
{
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 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 a copy of this vector snapped 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