littlejsengine
Version:
LittleJS - Tiny and Fast HTML5 Game Engine
1,456 lines (1,287 loc) • 483 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 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