UNPKG

littlejsengine

Version:

LittleJS - Tiny and Fast HTML5 Game Engine

1,518 lines (1,328 loc) 219 kB
// 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