littlejsengine
Version:
LittleJS - Tiny and Fast HTML5 Game Engine
1,518 lines (1,328 loc) • 219 kB
JavaScript
// LittleJS Engine - MIT License - Copyright 2021 Frank Force
// https://github.com/KilledByAPixel/LittleJS
'use strict';
/**
* 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;
/** True if asserts are enabled
* @type {boolean}
* @default
* @memberof Debug */
const enableAsserts = 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 showWatermark = 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, downloadLink;
///////////////////////////////////////////////////////////////////////////////
// Debug helper functions
/** Asserts if the expression is false, does not do anything in release builds
* @param {boolean} assert
* @param {Object} [output]
* @memberof Debug */
function ASSERT(assert, output)
{
if (enableAsserts)
output ? console.assert(assert, output) : console.assert(assert);
}
/** Draw a debug rectangle in world space
* @param {Vector2} pos
* @param {Vector2} [size=Vector2()]
* @param {string} [color]
* @param {number} [time]
* @param {number} [angle]
* @param {boolean} [fill]
* @memberof Debug */
function debugRect(pos, size=vec2(), color='#fff', time=0, angle=0, fill=false)
{
ASSERT(typeof color == 'string', 'pass in css color strings');
debugPrimitives.push({pos, size:vec2(size), color, time:new Timer(time), angle, fill});
}
/** Draw a debug poly in world space
* @param {Vector2} pos
* @param {Array<Vector2>} points
* @param {string} [color]
* @param {number} [time]
* @param {number} [angle]
* @param {boolean} [fill]
* @memberof Debug */
function debugPoly(pos, points, color='#fff', time=0, angle=0, fill=false)
{
ASSERT(typeof color == 'string', 'pass in css color strings');
debugPrimitives.push({pos, points, color, time:new Timer(time), angle, fill});
}
/** Draw a debug circle in world space
* @param {Vector2} pos
* @param {number} [radius]
* @param {string} [color]
* @param {number} [time]
* @param {boolean} [fill]
* @memberof Debug */
function debugCircle(pos, radius=0, color='#fff', time=0, fill=false)
{
ASSERT(typeof color == 'string', 'pass in css color strings');
debugPrimitives.push({pos, size:radius, color, time:new Timer(time), angle:0, fill});
}
/** Draw a debug point in world space
* @param {Vector2} pos
* @param {string} [color]
* @param {number} [time]
* @param {number} [angle]
* @memberof Debug */
function debugPoint(pos, color, time, angle)
{
ASSERT(typeof color == 'string', 'pass in css color strings');
debugRect(pos, undefined, color, time, angle);
}
/** Draw a debug line in world space
* @param {Vector2} posA
* @param {Vector2} posB
* @param {string} [color]
* @param {number} [thickness]
* @param {number} [time]
* @memberof Debug */
function debugLine(posA, posB, color, thickness=.1, time)
{
const halfDelta = vec2((posB.x - posA.x)/2, (posB.y - posA.y)/2);
const size = vec2(thickness, halfDelta.length()*2);
debugRect(posA.add(halfDelta), size, color, time, halfDelta.angle(), true);
}
/** Draw a debug combined axis aligned bounding box in world space
* @param {Vector2} pA - position A
* @param {Vector2} sA - size A
* @param {Vector2} pB - position B
* @param {Vector2} sB - size B
* @param {string} [color]
* @memberof Debug */
function debugOverlap(pA, sA, pB, sB, color)
{
const minPos = vec2(min(pA.x - sA.x/2, pB.x - sB.x/2), min(pA.y - sA.y/2, pB.y - sB.y/2));
const maxPos = vec2(max(pA.x + sA.x/2, pB.x + sB.x/2), max(pA.y + sA.y/2, pB.y + sB.y/2));
debugRect(minPos.lerp(maxPos,.5), maxPos.subtract(minPos), color);
}
/** Draw a debug axis aligned bounding box in world space
* @param {string} text
* @param {Vector2} pos
* @param {number} [size]
* @param {string} [color]
* @param {number} [time]
* @param {number} [angle]
* @param {string} [font]
* @memberof Debug */
function debugText(text, pos, size=1, color='#fff', time=0, angle=0, font='monospace')
{
ASSERT(typeof color == 'string', 'pass in css color strings');
debugPrimitives.push({text, pos, size, color, time:new Timer(time), angle, font});
}
/** 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; }
/** Save a canvas to disk
* @param {HTMLCanvasElement} canvas
* @param {string} [filename]
* @param {string} [type]
* @memberof Debug */
function debugSaveCanvas(canvas, filename='screenshot', type='image/png')
{ debugSaveDataURL(canvas.toDataURL(type), filename); }
/** Save a text file to disk
* @param {string} text
* @param {string} [filename]
* @param {string} [type]
* @memberof Debug */
function debugSaveText(text, filename='text', type='text/plain')
{ debugSaveDataURL(URL.createObjectURL(new Blob([text], {'type':type})), filename); }
/** Save a data url to disk
* @param {string} dataURL
* @param {string} filename
* @memberof Debug */
function debugSaveDataURL(dataURL, filename)
{
downloadLink.download = filename;
downloadLink.href = dataURL;
downloadLink.click();
}
/** Show error as full page of red text
* @memberof Debug */
function debugShowErrors()
{
onunhandledrejection = (event)=>showError(event.reason);
onerror = (event, source, lineno, colno)=>
showError(`${event}\n${source}\nLn ${lineno}, Col ${colno}`);
const showError = (message)=>
{
// replace entire page with error message
document.body.style.display = '';
document.body.style.backgroundColor = '#111';
document.body.innerHTML = `<pre style=color:#f00;font-size:50px>` + message;
}
}
///////////////////////////////////////////////////////////////////////////////
// Engine debug functions (called automatically)
function debugInit()
{
// create link for saving screenshots
downloadLink = document.createElement('a');
}
function debugUpdate()
{
if (!debug)
return;
if (keyWasPressed(debugKey)) // Esc
debugOverlay = !debugOverlay;
if (debugOverlay)
{
if (keyWasPressed('Digit0'))
showWatermark = !showWatermark;
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();
}
}
function debugRender()
{
glCopyToContext(mainContext);
if (debugTakeScreenshot)
{
// combine canvases, remove alpha and save
combineCanvases();
const w = mainCanvas.width, h = mainCanvas.height;
overlayContext.fillRect(0,0,w,h);
overlayContext.drawImage(mainCanvas, 0, 0);
debugSaveCanvas(overlayCanvas);
debugTakeScreenshot = 0;
}
if (debugGamepads && gamepadsEnable && navigator.getGamepads)
{
// gamepad debug display
const gamepads = navigator.getGamepads();
for (let i = gamepads.length; i--;)
{
const gamepad = gamepads[i];
if (gamepad)
{
const stickScale = 1;
const buttonScale = .2;
const centerPos = cameraPos;
const sticks = gamepadStickData[i];
for (let j = sticks.length; j--;)
{
const drawPos = centerPos.add(vec2(j*stickScale*2, i*stickScale*3));
const stickPos = drawPos.add(sticks[j].scale(stickScale));
debugCircle(drawPos, stickScale, '#fff7',0,true);
debugLine(drawPos, stickPos, '#f00');
debugPoint(stickPos, '#f00');
}
for (let j = gamepad.buttons.length; j--;)
{
const drawPos = centerPos.add(vec2(j*buttonScale*2, i*stickScale*3-stickScale-buttonScale));
const pressed = gamepad.buttons[j].pressed;
debugCircle(drawPos, buttonScale, pressed ? '#f00' : '#fff7', 0, true);
debugText(''+j, drawPos, .2);
}
}
}
}
let debugObject;
if (debugOverlay)
{
const saveContext = mainContext;
mainContext = overlayContext;
// 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 (tileCollisionSize.x > 0 && tileCollisionSize.y > 0)
drawRect(mousePos.floor().add(vec2(.5)), vec2(1), rgb(0,0,1,.5), 0, false);
mainContext = saveContext;
//glCopyToContext(mainContext = saveContext);
}
{
// draw debug primitives
overlayContext.lineWidth = 2;
const pointSize = debugPointSize * cameraScale;
debugPrimitives.forEach(p=>
{
overlayContext.save();
// create canvas transform from world space to screen space
const pos = worldToScreen(p.pos);
overlayContext.translate(pos.x|0, pos.y|0);
overlayContext.rotate(p.angle);
overlayContext.scale(1, p.text ? 1 : -1);
overlayContext.fillStyle = overlayContext.strokeStyle = p.color;
if (p.text != undefined)
{
overlayContext.font = p.size*cameraScale + 'px '+ p.font;
overlayContext.textAlign = 'center';
overlayContext.textBaseline = 'middle';
overlayContext.fillText(p.text, 0, 0);
}
else if (p.points != undefined)
{
// poly
overlayContext.beginPath();
for (const point of p.points)
{
const p2 = point.scale(cameraScale).floor();
overlayContext.lineTo(p2.x, p2.y);
}
overlayContext.closePath();
p.fill && overlayContext.fill();
overlayContext.stroke();
}
else if (p.size == 0 || p.size.x === 0 && p.size.y === 0)
{
// point
overlayContext.fillRect(-pointSize/2, -1, pointSize, 3);
overlayContext.fillRect(-1, -pointSize/2, 3, pointSize);
}
else if (p.size.x != undefined)
{
// rect
const s = p.size.scale(cameraScale).floor();
const w = s.x, h = s.y;
p.fill && overlayContext.fillRect(-w/2|0, -h/2|0, w, h);
overlayContext.strokeRect(-w/2|0, -h/2|0, w, h);
}
else
{
// circle
overlayContext.beginPath();
overlayContext.arc(0, 0, p.size*cameraScale, 0, 9);
p.fill && overlayContext.fill();
overlayContext.stroke();
}
overlayContext.restore();
});
// remove expired primitives
debugPrimitives = debugPrimitives.filter(r=>r.time<0);
}
if (debugObject)
{
const saveContext = mainContext;
mainContext = overlayContext;
const raycastHitPos = tileCollisionRaycast(debugObject.pos, mousePos);
raycastHitPos && drawRect(raycastHitPos.floor().add(vec2(.5)), vec2(1), rgb(0,1,1,.3));
drawLine(mousePos, debugObject.pos, .1, raycastHitPos ? rgb(1,0,0,.5) : rgb(0,1,0,.5), false);
const debugText = 'mouse pos = ' + mousePos +
'\nmouse collision = ' + getTileCollisionData(mousePos) +
'\n\n--- object info ---\n' +
debugObject.toString();
drawTextScreen(debugText, mousePosScreen, 24, rgb(), .05, undefined, 'center', 'monospace');
mainContext = saveContext;
}
{
// draw debug overlay
overlayContext.save();
overlayContext.fillStyle = '#fff';
overlayContext.textAlign = 'left';
overlayContext.textBaseline = 'top';
overlayContext.font = '28px monospace';
overlayContext.shadowColor = '#000';
overlayContext.shadowBlur = 9;
let x = 9, y = -20, h = 30;
if (debugOverlay)
{
overlayContext.fillText(engineName, x, y += h);
overlayContext.fillText('Objects: ' + engineObjects.length, x, y += h);
overlayContext.fillText('Time: ' + formatTime(time), x, y += h);
overlayContext.fillText('---------', x, y += h);
overlayContext.fillStyle = '#f00';
overlayContext.fillText('ESC: Debug Overlay', x, y += h);
overlayContext.fillStyle = debugPhysics ? '#f00' : '#fff';
overlayContext.fillText('1: Debug Physics', x, y += h);
overlayContext.fillStyle = debugParticles ? '#f00' : '#fff';
overlayContext.fillText('2: Debug Particles', x, y += h);
overlayContext.fillStyle = debugGamepads ? '#f00' : '#fff';
overlayContext.fillText('3: Debug Gamepads', x, y += h);
overlayContext.fillStyle = debugRaycast ? '#f00' : '#fff';
overlayContext.fillText('4: Debug Raycasts', x, y += h);
overlayContext.fillStyle = '#fff';
overlayContext.fillText('5: Save Screenshot', x, y += h);
let keysPressed = '';
for(const i in inputData[0])
{
if (keyIsDown(i, 0))
keysPressed += i + ' ' ;
}
keysPressed && overlayContext.fillText('Keys Down: ' + keysPressed, x, y += h);
let buttonsPressed = '';
if (inputData[1])
for(const i in inputData[1])
{
if (keyIsDown(i, 1))
buttonsPressed += i + ' ' ;
}
buttonsPressed && overlayContext.fillText('Gamepad: ' + buttonsPressed, x, y += h);
}
else
{
overlayContext.fillText(debugPhysics ? 'Debug Physics' : '', x, y += h);
overlayContext.fillText(debugParticles ? 'Debug Particles' : '', x, y += h);
overlayContext.fillText(debugRaycast ? 'Debug Raycasts' : '', x, y += h);
overlayContext.fillText(debugGamepads ? 'Debug Gamepads' : '', x, y += h);
}
overlayContext.restore();
}
}
/**
* LittleJS Utility Classes and Functions
* - General purpose math library
* - Vector2 - fast, simple, easy 2D vector class
* - Color - holds a rgba color with some math functions
* - Timer - tracks time automatically
* - RandomGenerator - seeded random number generator
* @namespace Utilities
*/
/** A shortcut to get Math.PI
* @type {number}
* @default Math.PI
* @memberof Utilities */
const PI = Math.PI;
/** Returns absolute value of value passed in
* @param {number} value
* @return {number}
* @memberof Utilities */
function abs(value) { return Math.abs(value); }
/** Returns lowest of two values passed in
* @param {number} valueA
* @param {number} valueB
* @return {number}
* @memberof Utilities */
function min(valueA, valueB) { return Math.min(valueA, valueB); }
/** Returns highest of two values passed in
* @param {number} valueA
* @param {number} valueB
* @return {number}
* @memberof Utilities */
function max(valueA, valueB) { return Math.max(valueA, valueB); }
/** Returns the sign of value passed in
* @param {number} value
* @return {number}
* @memberof Utilities */
function sign(value) { return Math.sign(value); }
/** Returns first parm modulo the second param, but adjusted so negative numbers work as expected
* @param {number} dividend
* @param {number} [divisor]
* @return {number}
* @memberof Utilities */
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 Utilities */
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 Utilities */
function percent(value, valueA, valueB)
{ return (valueB-=valueA) ? clamp((value-valueA)/valueB) : 0; }
/** Linearly interpolates between values passed in using percent
* @param {number} percent
* @param {number} valueA
* @param {number} valueB
* @return {number}
* @memberof Utilities */
function lerp(percent, valueA, valueB) { return valueA + clamp(percent) * (valueB-valueA); }
/** Returns signed wrapped distance between the two values passed in
* @param {number} valueA
* @param {number} valueB
* @param {number} [wrapSize]
* @returns {number}
* @memberof Utilities */
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} percent
* @param {number} valueA
* @param {number} valueB
* @param {number} [wrapSize]
* @returns {number}
* @memberof Utilities */
function lerpWrap(percent, valueA, valueB, wrapSize=1)
{ return valueB + clamp(percent) * distanceWrap(valueA, valueB, wrapSize); }
/** Returns signed wrapped distance between the two angles passed in
* @param {number} angleA
* @param {number} angleB
* @returns {number}
* @memberof Utilities */
function distanceAngle(angleA, angleB) { return distanceWrap(angleA, angleB, 2*PI); }
/** Linearly interpolates between the angles passed in with wrapping
* @param {number} percent
* @param {number} angleA
* @param {number} angleB
* @returns {number}
* @memberof Utilities */
function lerpAngle(percent, angleA, angleB) { return lerpWrap(percent, angleA, angleB, 2*PI); }
/** Applies smoothstep function to the percentage value
* @param {number} percent
* @return {number}
* @memberof Utilities */
function smoothStep(percent) { return percent * percent * (3 - 2 * percent); }
/** Returns the nearest power of two not less then the value
* @param {number} value
* @return {number}
* @memberof Utilities */
function nearestPowerOfTwo(value) { return 2**Math.ceil(Math.log2(value)); }
/** Returns true if two axis aligned bounding boxes are overlapping
* @param {Vector2} posA - Center of box A
* @param {Vector2} sizeA - Size of box A
* @param {Vector2} posB - Center of box B
* @param {Vector2} [sizeB=(0,0)] - Size of box B, a point if undefined
* @return {boolean} - True if overlapping
* @memberof Utilities */
function isOverlapping(posA, sizeA, posB, sizeB=vec2())
{
return abs(posA.x - posB.x)*2 < sizeA.x + sizeB.x
&& abs(posA.y - posB.y)*2 < sizeA.y + sizeB.y;
}
/** 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 Utilities */
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
* @return {number} - Value waving between 0 and amplitude
* @memberof Utilities */
function wave(frequency=1, amplitude=1, t=time)
{ return amplitude/2 * (1 - Math.cos(t*frequency*2*PI)); }
/** Formats seconds to mm:ss style for display purposes
* @param {number} t - time in seconds
* @return {string}
* @memberof Utilities */
function formatTime(t) { return (t/60|0) + ':' + (t%60<10?'0':'') + (t%60|0); }
///////////////////////////////////////////////////////////////////////////////
/** 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 Math.floor(rand(valueA,valueB)); }
/** 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 randVector(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 ? randVector(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=(1,1,1,1)]
* @param {Color} [colorB=(0,0,0,1)]
* @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
* @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 */
constructor(seed)
{
/** @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 Math.floor(this.float(valueA, valueB)); }
/** Randomly returns either -1 or 1 deterministically
* @return {number} */
sign() { return this.float() > .5 ? 1 : -1; }
}
///////////////////////////////////////////////////////////////////////////////
/**
* Create a 2d vector, can take another Vector2 to copy, 2 scalars, or 1 scalar
* @param {Vector2|number} [x]
* @param {number} [y]
* @return {Vector2}
* @example
* let a = vec2(0, 1); // vector with coordinates (0, 1)
* let b = vec2(a); // copy a into b
* a = vec2(5); // set a to (5, 5)
* b = vec2(); // set b to (0, 0)
* @memberof Utilities
*/
function vec2(x=0, y)
{
return typeof x == 'number' ?
new Vector2(x, y == undefined? x : y) :
new Vector2(x.x, x.y);
}
/**
* Check if object is a valid Vector2
* @param {any} v
* @return {boolean}
* @memberof Utilities
*/
function isVector2(v) { return v instanceof Vector2; }
/**
* 2D Vector object with vector math library
* - Functions do not change this so they can be chained together
* @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());
}
/** 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(this.isValid());
return this;
}
/** 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)
{
ASSERT(isVector2(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)
{
ASSERT(isVector2(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)
{
ASSERT(isVector2(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)
{
ASSERT(isVector2(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)
{
ASSERT(!isVector2(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)
{
ASSERT(isVector2(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)
{
ASSERT(isVector2(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;
}
/** Returns the dot product of this and the vector passed in
* @param {Vector2} v - other vector
* @return {number} */
dot(v)
{
ASSERT(isVector2(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)
{
ASSERT(isVector2(v));
return this.x*v.y - this.y*v.x;
}
/** Returns the clockwise angle of this vector, up is angle 0
* @return {number} */
angle() { return Math.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)
{
this.x = length*Math.sin(angle);
this.y = length*Math.cos(angle);
return this;
}
/** Returns copy of this vector rotated by the clockwise angle passed in
* @param {number} angle
* @return {Vector2} */
rotate(angle)
{
const c = Math.cos(-angle), s = Math.sin(-angle);
return new Vector2(this.x*c - this.y*s, this.x*s + this.y*c);
}
/** Set the integer direction of this vector, corresponding to multiples of 90 degree rotation (0-3)
* @param {number} [direction]
* @param {number} [length] */
setDirection(direction, length=1)
{
direction = mod(direction, 4);
ASSERT(direction==0 || direction==1 || direction==2 || direction==3);
return vec2(direction%2 ? direction-1 ? -length : length : 0,
direction%2 ? 0 : direction ? -length : length);
}
/** 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 that has been inverted
* @return {Vector2} */
invert() { return new Vector2(this.y, -this.x); }
/** Returns a copy of this vector with each axis floored
* @return {Vector2} */
floor() { return new Vector2(Math.floor(this.x), Math.floor(this.y)); }
/** 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(isVector2(v));
return this.add(v.subtract(this).scale(clamp(percent)));
}
/** Returns true if this vector is within the bounds of an array size passed in
* @param {Vector2} arraySize
* @return {boolean} */
arrayCheck(arraySize)
{
ASSERT(isVector2(arraySize));
return this.x >= 0 && this.y >= 0 && this.x < arraySize.x && this.y < arraySize.y;
}
/** Returns this vector expressed as a string
* @param {number} digits - precision to display
* @return {string} */
toString(digits=3)
{
if (debug)
return `(${(this.x<0?'':' ') + this.x.toFixed(digits)},${(this.y<0?'':' ') + this.y.toFixed(digits)} )`;
}
/** Checks if this is a valid vector
* @return {boolean} */
isValid()
{
return typeof this.x == 'number' && !isNaN(this.x)
&& typeof this.y == 'number' && !isNaN(this.y);
}
}
///////////////////////////////////////////////////////////////////////////////
/**
* Create a color object with RGBA values, white by default
* @param {number} [r=1] - red
* @param {number} [g=1] - green
* @param {number} [b=1] - blue
* @param {number} [a=1] - alpha
* @return {Color}
* @memberof Utilities
*/
function rgb(r, g, b, a) { return new Color(r, g, b, a); }
/**
* Create a color object with HSLA values, white by default
* @param {number} [h=0] - hue
* @param {number} [s=0] - saturation
* @param {number} [l=1] - lightness
* @param {number} [a=1] - alpha
* @return {Color}
* @memberof Utilities
*/
function hsl(h, s, l, a) { return new Color().setHSLA(h, s, l, a); }
/**
* Check if object is a valid Color
* @param {any} c
* @return {boolean}
* @memberof Utilities
*/
function isColor(c) { return c instanceof Color; }
/**
* Color object (red, green, blue, alpha) with some helpful functions
* @example
* let a = new Color; // white
* let b = new Color(1, 0, 0); // red
* let c = new Color(0, 0, 0, 0); // transparent black
* let d = rgb(0, 0, 1); // blue using rgb color
* let e = hsl(.3, 1, .5); // green using hsl color
*/
class Color
{
/** Create a color with the rgba components passed in, white by default
* @param {number} [r] - red
* @param {number} [g] - green
* @param {number} [b] - blue
* @param {number} [a] - alpha*/
constructor(r=1, g=1, b=1, a=1)
{
/** @property {number} - Red */
this.r = r;
/** @property {number} - Green */
this.g = g;
/** @property {number} - Blue */
this.b = b;
/** @property {number} - Alpha */
this.a = a;
ASSERT(this.isValid());
}
/** Sets values of this color and returns self
* @param {number} [r] - red
* @param {number} [g] - green
* @param {number} [b] - blue
* @param {number} [a] - alpha
* @return {Color} */
set(r=1, g=1, b=1, a=1)
{
this.r = r;
this.g = g;
this.b = b;
this.a = a;
ASSERT(this.isValid());
return this;
}
/** Returns a new color that is a copy of this
* @return {Color} */
copy() { return new Color(this.r, this.g, this.b, this.a); }
/** Returns a copy of this color plus the color passed in
* @param {Color} c - other color
* @return {Color} */
add(c)
{
ASSERT(isColor(c));
return new Color(this.r+c.r, this.g+c.g, this.b+c.b, this.a+c.a);
}
/** Returns a copy of this color minus the color passed in
* @param {Color} c - other color
* @return {Color} */
subtract(c)
{
ASSERT(isColor(c));
return new Color(this.r-c.r, this.g-c.g, this.b-c.b, this.a-c.a);
}
/** Returns a copy of this color times the color passed in
* @param {Color} c - other color
* @return {Color} */
multiply(c)
{
ASSERT(isColor(c));
return new Color(this.r*c.r, this.g*c.g, this.b*c.b, this.a*c.a);
}
/** Returns a copy of this color divided by the color passed in
* @param {Color} c - other color
* @return {Color} */
divide(c)
{
ASSERT(isColor(c));
return new Color(this.r/c.r, this.g/c.g, this.b/c.b, this.a/c.a);
}
/** Returns a copy of this color scaled by the value passed in, alpha can be scaled separately
* @param {number} scale
* @param {number} [alphaScale=scale]
* @return {Color} */
scale(scale, alphaScale=scale)
{ return new Color(this.r*scale, this.g*scale, this.b*scale, this.a*alphaScale); }
/** Returns a copy of this color clamped to the valid range between 0 and 1
* @return {Color} */
clamp() { return new Color(clamp(this.r), clamp(this.g), clamp(this.b), clamp(this.a)); }
/** Returns a new color that is p percent between this and the color passed in
* @param {Color} c - other color
* @param {number} percent
* @return {Color} */
lerp(c, percent)
{
ASSERT(isColor(c));
return this.add(c.subtract(this).scale(clamp(percent)));
}
/** Sets this color given a hue, saturation, lightness, and alpha
* @param {number} [h] - hue
* @param {number} [s] - saturation
* @param {number} [l] - lightness
* @param {number} [a] - alpha
* @return {Color} */
setHSLA(h=0, s=0, l=1, a=1)
{
h = mod(h,1);
s = clamp(s);
l = clamp(l);
const q = l < .5 ? l*(1+s) : l+s-l*s, p = 2*l-q,
f = (p, q, t)=>
(t = mod(t,1))*6 < 1 ? p+(q-p)*6*t :
t*2 < 1 ? q :
t*3 < 2 ? p+(q-p)*(4-t*6) : p;
this.r = f(p, q, h + 1/3);
this.g = f(p, q, h);
this.b = f(p, q, h - 1/3);
this.a = a;
ASSERT(this.isValid());
return this;
}
/** Returns this color expressed in hsla format
* @return {Array<number>} */
HSLA()
{
const r = clamp(this.r);
const g = clamp(this.g);
const b = clamp(this.b);
const a = clamp(this.a);
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
const l = (max + min) / 2;
let h = 0, s = 0;
if (max != min)
{
let d = max - min;
s = l > .5 ? d / (2 - max - min) : d / (max + min);
if (r == max)
h = (g - b) / d + (g < b ? 6 : 0);
else if (g == max)
h = (b - r) / d + 2;
else if (b == max)
h = (r - g) / d + 4;
}
return [h / 6, s, l, a];
}
/** Returns a new color that has each component randomly adjusted
* @param {number} [amount]
* @param {number} [alphaAmount]
* @return {Color} */
mutate(amount=.05, alphaAmount=0)
{
return new Color
(
this.r + rand(amount, -amount),
this.g + rand(amount, -amount),
this.b + rand(amount, -amount),
this.a + rand(alphaAmount, -alphaAmount)
).clamp();
}
/** Returns this color expressed as a hex color code
* @param {boolean} [useAlpha] - if alpha should be included in result
* @return {string} */
toString(useAlpha = true)
{
const toHex = (c)=> ((c=clamp(c)*255|0)<16 ? '0' : '') + c.toString(16);
return '#' + toHex(this.r) + toHex(this.g) + toHex(this.b) + (useAlpha ? toHex(this.a) : '');
}
/** Set this color from a hex code
* @param {string} hex - html hex code
* @return {Color} */
setHex(hex)
{
ASSERT(typeof hex == 'string' && hex[0] == '#');
ASSERT([4,5,7,9].includes(hex.length), 'Invalid hex');
if (hex.length < 6)
{
const fromHex = (c)=> clamp(parseInt(hex[c],16)/15);
this.r = fromHex(1);
this.g = fromHex(2),
this.b = fromHex(3);
this.a = hex.length == 5 ? fromHex(4) : 1;
}
else
{
const fromHex = (c)=> clamp(parseInt(hex.slice(c,c+2),16)/255);
this.r = fromHex(1);
this.g = fromHex(3),
this.b = fromHex(5);
this.a = hex.length == 9 ? fromHex(7) : 1;
}
ASSERT(this.isValid());
return this;
}
/** Returns this color expressed as 32 bit RGBA value
* @return {number} */
rgbaInt()
{
const r = clamp(this.r)*255|0;
const g = clamp(this.g)*255<<8;
const b = clamp(this.b)*255<<16;
const a = clamp(this.a)*255<<24;
return r + g + b + a;
}
/** Checks if this is a valid color
* @return {boolean} */
isValid()
{
return typeof this.r == 'number' && !isNaN(this.r)
&& typeof this.g == 'number' && !isNaN(this.g)
&& typeof this.b == 'number' && !isNaN(this.b)
&& typeof this.a == 'number' && !isNaN(this.a);
}
}
///////////////////////////////////////////////////////////////////////////////
// default colors
/** Color - White #ffffff
* @type {Color}
* @memberof Utilities */
const WHITE = rgb();
/** Color - Black #000000
* @type {Color}
* @memberof Utilities */
const BLACK = rgb(0,0,0);
/** Color - Gray #808080
* @type {Color}
* @memberof Utilities */
const GRAY = rgb(.5,.5,.5);
/** Color - Red #ff0000
* @type {Color}
* @memberof Utilities */
const RED = rgb(1,0,0);
/** Color - Orange #ff8000
* @type {Color}
* @memberof Utilities */
const ORANGE = rgb(1,.5,0);
/** Color - Yellow #ffff00
* @type {Color}
* @memberof Utilities */
const YELLOW = rgb(1,1,0);
/** Color - Green #00ff00
* @type {Color}
* @memberof Utilities */
const GREEN = rgb(0,1,0);
/** Color - Cyan #00ffff
* @type {Color}
* @memberof Utilities */
const CYAN = rgb(0,1,1);
/** Color - Blue #0000ff
* @type {Color}
* @memberof Utilities */
const BLUE = rgb(0,0,1);
/** Color - Purple #8000ff
* @type {Color}
* @memberof Utilities */
const PURPLE = rgb(.5,0,1);
/** Color - Magenta #ff00ff
* @type {Color}
* @memberof Utilities */
const MAGENTA = rgb(1,0,1);
///////////////////////////////////////////////////////////////////////////////
/**
* Timer object tracks how long has passed since it was set
* @example
* let a = new Timer; // creates a timer that is not set
* a.set(3); // sets the timer to 3 seconds
*
* let b = new Timer(1); // creates a timer with 1 second left
* b.unset(); // unset the timer
*/
class Timer
{
/** Create a timer object set time passed in
* @param {number} [timeLeft] - How much time left before the timer elapses in seconds */
constructor(timeLeft) { this.time = timeLeft == undefined ? undefined : time + timeLeft; this.setTime = timeLeft; }
/** Set the timer with seconds passed in
* @param {number} [timeLeft] - How much time left before the timer is elapsed in seconds */
set(timeLeft=0) { this.time = time + timeLeft; this.setTime = timeLeft; }
/** Unset the timer */
unset() { this.time = undefined; }
/** Returns true if set
* @return {boolean} */
isSet() { return this.time != undefined; }
/** Returns true if set and has not elapsed
* @return {boolean} */
active() { return time < this.time; }
/** Returns true if set and elapsed
* @return {boolean} */
elapsed() { return time >= this.time; }
/** Get how long since elapsed, returns 0 if not set (returns negative if currently active)
* @return {number} */
get() { return this.isSet()? time - this.time : 0; }
/** Get percentage elapsed based on time it was set to, returns 0 if not set
* @return {number} */
getPercent() { return this.isSet()? 1-percent(this.time - time, 0, this.setTime) : 0; }
/** Returns this timer expressed as a string
* @return {string} */
toString() { if (debug) { return this.isSet() ? Math.abs(this.get()) + ' seconds ' + (this.get()<0 ? 'before' : 'after' ) : 'unset'; }}
/** Get how long since elapsed, returns 0 if not set (returns negative if currently active)
* @return {number} */
valueOf() { return this.get(); }
}
/**
* LittleJS Engine Settings
* - All settings for the engine are here
* @namespace Settings
*/
///////////////////////////////////////////////////////////////////////////////
// Camera settings
/** Position of camera in world space
* @type {Vector2}
* @default Vector2()
* @memberof Settings */
let cameraPos = vec2();
/** Scale of camera in world space
* @type {number}
* @default
* @memberof Settings */
let cameraScale = 32;
///////////////////////////////////////////////////////////////////////////////
// Display settings
/** The max size of the canvas, centered if window is larger
* @type {Vector2}
* @default Vector2(1920,1080)
* @memberof Settings */
let canvasMaxSize = vec2(1920, 1080);
/** Fixed size of the canvas, if enabled canvas size never changes
* - you may also need to set mainCanvasSize if using screen space coords in startup
* @type {Vector2}
* @default Vector2()
* @memberof Settings */
let canvasFixedSize = vec2();
/** Use nearest neighbor scaling algorithm for canvas for more pixelated look
* - Must be set before startup to take effect
* - If enabled sets css image-rendering:pixelated
* @type {boolean}
* @default
* @memberof Settings */
let canvasPixelated = true;
/** Disables texture filtering for crisper pixel art
* @type {boolean}
* @default
* @memberof Settings */
let tilesPixelated = true;
/** Default font used for text rendering
* @type {string}
* @default
* @memberof Settings */
let fontDefault = 'arial';
/** Enable to show the LittleJS splash screen be shown on startup
* @type {boolean}
* @default
* @memberof Settings */
let showSplashScreen = false;
/** Disables all rendering, audio, and input for servers
* @type {boolean}
* @default
* @memberof Settings */
let headlessMode = false;
///////////////////////////////////////////////////////////////////////////////
// WebGL settings
/** Enable webgl rendering, webgl can be disabled and removed from build (with some features disabled)
* @type {boolean}
* @default
* @memberof Settings */
let glEnable = true;
/** Fixes slow rendering in some browsers by not compositing the WebGL canvas
* @type {boolean}
* @default
* @memberof Settings */
let glOverlay = true;
///////////////////////////////////////////////////////////////////////////////
// Tile sheet settings
/** Default size of tiles in pixels
* @type {Vector2}
* @default Vector2(16,16)
* @memberof Settings */
let tileSizeDefault = vec2(16);
/** How many pixels smaller to draw tiles to prevent bleeding from neighbors
* @type {number}
* @default
* @memberof Settings */
let tileFixBleedScale = 0;
///////////////////////////////////////////////////////////////////////////////
// Object settings
/** Enable physics solver for collisions between objects
* @type {boolean}
* @default
* @memberof Settings */
let enablePhysicsSolver = true;
/** Default object mass for collision calculations (how heavy objects are)
* @type {number}
* @default
* @memberof Settings */
let objectDefaultMass = 1;
/** How much to slow velocity by