littlejsengine
Version:
LittleJS - Tiny and Fast HTML5 Game Engine
4 lines (3 loc) • 244 kB
JavaScript
// LittleJS Engine - MIT License - Copyright 2021 Frank Force
// https://github.com/KilledByAPixel/LittleJS
"use strict";const engineName="LittleJS";const engineVersion="1.18.21";const frameRate=60;const timeDelta=1/frameRate;let engineObjects=[];let engineObjectsCollide=[];let frame=0;let time=0;let timeReal=0;let paused=false;function getPaused(){return paused}function setPaused(isPaused=true){paused=isPaused}let frameTimeLastMS=0,frameTimeBufferMS=0,averageFPS=0;let showEngineVersion=true;const pluginList=[];class EnginePlugin{constructor(update,render,glContextLost,glContextRestored){this.update=update;this.render=render;this.glContextLost=glContextLost;this.glContextRestored=glContextRestored}}function engineAddPlugin(update,render,glContextLost,glContextRestored){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)}async function engineInit(gameInit,gameUpdate,gameUpdatePost,gameRender,gameRenderPost,imageSources=[],rootElement){showEngineVersion&&console.log(`${engineName} Engine v${engineVersion}`);ASSERT(!mainContext,"engine already initialized");if(mainContext)return;ASSERT(isArray(imageSources),"pass in images as array");if(!document.body)document.documentElement.appendChild(document.createElement("body"));rootElement||=document.body;gameInit||=()=>{};gameUpdate||=()=>{};gameUpdatePost||=()=>{};gameRender||=()=>{};gameRenderPost||=()=>{};function enginePreRender(){mainCanvasSize=vec2(mainCanvas.width,mainCanvas.height);mainContext.imageSmoothingEnabled=!tilesPixelated;glPreRender()}function engineUpdate(frameTimeMS=0){let frameTimeDeltaMS=frameTimeMS-frameTimeLastMS;if(!frameTimeLastMS)frameTimeDeltaMS=0;frameTimeLastMS=frameTimeMS;if(debug||debugWatermark)averageFPS=lerp(averageFPS,1e3/(frameTimeDeltaMS||1),.05);const debugSpeedUp=debug&&keyIsDown("Equal");const debugSpeedDown=debug&&keyIsDown("Minus");const debugScale=debugSpeedUp?10:debugSpeedDown?.1:1;timeReal+=frameTimeDeltaMS*debugScale/1e3;const combinedScale=timeScale*debugScale;frameTimeDeltaMS*=combinedScale;frameTimeBufferMS+=paused?0:frameTimeDeltaMS;if(combinedScale<=1)frameTimeBufferMS=min(frameTimeBufferMS,50);let wasUpdated=false;if(paused){wasUpdated=true;updateCanvas();inputUpdate();pluginList.forEach(plugin=>plugin.update?.());for(const o of engineObjects)o.parent||o.updateTransforms();debugUpdate();gameUpdatePost();inputUpdatePost();if(debugVideoCaptureIsActive())renderFrame()}else{let deltaSmooth=0;if(frameTimeBufferMS<0&&frameTimeBufferMS>-9){deltaSmooth=frameTimeBufferMS;frameTimeBufferMS=0}for(;frameTimeBufferMS>=0;frameTimeBufferMS-=1e3/frameRate){time=frame++/frameRate;wasUpdated=true;updateCanvas();inputUpdate();gameUpdate();pluginList.forEach(plugin=>plugin.update?.());engineObjectsUpdate();debugUpdate();gameUpdatePost();inputUpdatePost();if(debugVideoCaptureIsActive())renderFrame()}frameTimeBufferMS+=deltaSmooth}if(!debugVideoCaptureIsActive())renderFrame();requestAnimationFrame(engineUpdate);function renderFrame(){if(headlessMode)return;if(!wasUpdated)updateCanvas();enginePreRender();gameRender();engineObjects.sort((a,b)=>a.renderOrder-b.renderOrder);for(const o of engineObjects)o.destroyed||o.render();gameRenderPost();pluginList.forEach(plugin=>plugin.render?.());inputRender();debugRender();glFlush();debugRenderPost();drawCount=0;primitiveCount=0}}function updateCanvas(){if(headlessMode)return;if(canvasFixedSize.x){mainCanvasSize=canvasFixedSize.copy();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{const dpr=canvasPixelRatio??(devicePixelRatio||1);const viewWidth=innerWidth*dpr|0;const viewHeight=innerHeight*dpr|0;mainCanvasSize.x=min(viewWidth,canvasMaxSize.x);mainCanvasSize.y=min(viewHeight,canvasMaxSize.y);const innerAspect=viewWidth/viewHeight;ASSERT(canvasMinAspect<=canvasMaxAspect);if(canvasMaxAspect&&innerAspect>canvasMaxAspect){const w=mainCanvasSize.y*canvasMaxAspect|0;mainCanvasSize.x=min(w,canvasMaxSize.x)}else if(innerAspect<canvasMinAspect){const h=mainCanvasSize.x/canvasMinAspect|0;mainCanvasSize.y=min(h,canvasMaxSize.y)}const cssW=(mainCanvasSize.x/dpr|0)+"px";const cssH=(mainCanvasSize.y/dpr|0)+"px";mainCanvas.style.width=cssW;mainCanvas.style.height=cssH;if(glCanvas){glCanvas.style.width=cssW;glCanvas.style.height=cssH}}mainCanvas.width=mainCanvasSize.x;mainCanvas.height=mainCanvasSize.y;if(canvasClearColor.a>0&&!glEnable){mainContext.fillStyle=canvasClearColor.toString();mainContext.fillRect(0,0,mainCanvasSize.x,mainCanvasSize.y);mainContext.fillStyle=BLACK.toString()}mainContext.lineJoin="round";mainContext.lineCap="round"}if(headlessMode)return startEngine();glInit(rootElement);const styleRoot="margin:0;"+"overflow:hidden;"+"background:#000;"+"user-select:none;"+"-webkit-user-select:none;"+"touch-action:none;"+"-webkit-touch-callout:none";rootElement.style.cssText=styleRoot;mainCanvas=rootElement.appendChild(document.createElement("canvas"));drawContext=mainContext=mainCanvas.getContext("2d");inputInit();audioInit();debugInit();const styleCanvas="position:absolute;"+"top:50%;left:50%;transform:translate(-50%,-50%)";mainCanvas.style.cssText=styleCanvas;if(glCanvas)glCanvas.style.cssText=styleCanvas;setCanvasPixelated(canvasPixelated);updateCanvas();glPreRender();workCanvas=new OffscreenCanvas(64,64);workContext=workCanvas.getContext("2d");workReadCanvas=new OffscreenCanvas(64,64);workReadContext=workReadCanvas.getContext("2d",{willReadFrequently:true});const promises=imageSources.map((src,i)=>loadTexture(i,src));if(!imageSources.length)promises.push(loadTexture(0));promises.push(imageFontInit());if(showSplashScreen){promises.push(new Promise(resolve=>{let t=0;updateSplash();function updateSplash(){inputClear();drawEngineLogo(t+=.01);t>1?resolve():setTimeout(updateSplash,16)}}))}await Promise.all(promises);return startEngine();async function startEngine(){await gameInit();engineUpdate()}}function engineObjectsUpdate(){engineObjectsCollide=engineObjects.filter(o=>o.collideSolidObjects);for(const o of engineObjects)if(!o.parent&&!o.destroyed)o.updatePhysics();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;o.update();for(const child of o.children)updateChildObject(child);o.updateTransforms()}engineObjects=engineObjects.filter(o=>!o.destroyed)}function engineObjectsDestroy(immediate=true){for(const o of engineObjects)o.parent||o.destroy(immediate);engineObjects=engineObjects.filter(o=>!o.destroyed)}function engineObjectsCollect(pos,size,objects=engineObjects){const collectedObjects=[];if(!pos){for(const o of objects)collectedObjects.push(o)}else if(size instanceof Vector2){for(const o of objects)o.isOverlapping(pos,size)&&collectedObjects.push(o)}else{const sizeSquared=size*size;for(const o of objects)pos.distanceSquared(o.pos)<sizeSquared&&collectedObjects.push(o)}return collectedObjects}function engineObjectsCallback(pos,size,callbackFunction,objects=engineObjects){engineObjectsCollect(pos,size,objects).forEach(o=>callbackFunction(o))}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}let debugWatermark=0;let debugKey="";const debug=0;const debugOverlay=0;const debugPhysics=0;const debugParticles=0;const debugRaycast=0;const debugGamepads=0;const debugSound=0;const debugPointSize=.5;function ASSERT(){}function LOG(){}function debugInit(){}function debugUpdate(){}function debugRender(){}function debugRenderPost(){}function debugRect(){}function debugPoly(){}function debugCircle(){}function debugPoint(){}function debugLine(){}function debugOverlap(){}function debugText(){}function debugClear(){}function debugScreenshot(){}function debugShowErrors(){}function debugVideoCaptureIsActive(){return false}function debugVideoCaptureStart(){}function debugVideoCaptureStop(){}function debugVideoCaptureUpdate(){}function debugProtectConstant(o){return o}const PI=Math.PI;const abs=Math.abs;const floor=Math.floor;const ceil=Math.ceil;const round=Math.round;const min=Math.min;const max=Math.max;const sign=x=>Math.sign(x);const hypot=(...values)=>Math.hypot(...values);const log2=x=>Math.log2(x);const sin=Math.sin;const cos=Math.cos;const tan=Math.tan;const atan2=Math.atan2;function mod(dividend,divisor=1){return(dividend%divisor+divisor)%divisor}function clamp(value,min=0,max=1){return value<min?min:value>max?max:value}function percent(value,valueA,valueB){return(valueB-=valueA)?clamp((value-valueA)/valueB):0}function lerp(valueA,valueB,percent){return valueA+clamp(percent)*(valueB-valueA)}function percentLerp(value,percentA,percentB,lerpA,lerpB){return lerp(lerpA,lerpB,percent(value,percentA,percentB))}function distanceWrap(valueA,valueB,wrapSize=1){ASSERT(wrapSize>0,"distanceWrap wrapSize must be > 0");const d=(valueA-valueB)%wrapSize;return d*2%wrapSize-d}function lerpWrap(valueA,valueB,percent,wrapSize=1){return valueA+clamp(percent)*distanceWrap(valueB,valueA,wrapSize)}function distanceAngle(angleA,angleB){return distanceWrap(angleA,angleB,2*PI)}function lerpAngle(angleA,angleB,percent){return lerpWrap(angleA,angleB,percent,2*PI)}function smoothStep(percent){return percent*percent*(3-2*percent)}function isPowerOfTwo(value){return value>0&&!(value&value-1)}function nearestPowerOfTwo(value){return 2**ceil(log2(value))}function isOverlapping(posA,sizeA,posB,sizeB=vec2()){const dx=(posA.x-posB.x)*2;const dy=(posA.y-posB.y)*2;const sx=sizeA.x+sizeB.x;const sy=sizeA.y+sizeB.y;return abs(dx)<sx&&abs(dy)<sy}function isIntersecting(start,end,pos,size){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}function oscillate(frequency=1,amplitude=1,t=time,offset=0,type=0){const phase=mod(offset+t*frequency,1);let value;if(type===1)value=2*abs(2*phase-1)-1;else if(type===2)value=phase<.5?-1:1;else if(type===3)value=2*phase-1;else value=-cos(phase*2*PI);return amplitude/2*(value+1)}function isNumber(n){return typeof n==="number"&&!isNaN(n)}function isStringLike(s){return s!=null&&typeof s?.toString()==="string"}function isArray(a){return Array.isArray(a)}function lineTest(posStart,posEnd,testFunction,normal){ASSERT(isVector2(posStart),"posStart must be a vec2");ASSERT(isVector2(posEnd),"posEnd must be a vec2");ASSERT(typeof testFunction==="function","testFunction must be a function");ASSERT(!normal||isVector2(normal),"normal must be a vec2");const dx=posEnd.x-posStart.x;const dy=posEnd.y-posStart.y;const totalLength=(dx*dx+dy*dy)**.5;if(!totalLength)return;const pos=posStart.floor();const dirX=dx/totalLength;const dirY=dy/totalLength;const stepX=sign(dirX);const stepY=sign(dirY);const tDeltaX=dirX?abs(1/dirX):Infinity;const tDeltaY=dirY?abs(1/dirY):Infinity;const nextGridX=stepX>0?pos.x+1:pos.x;const nextGridY=stepY>0?pos.y+1:pos.y;const tMaxX=dirX?(nextGridX-posStart.x)/dirX:Infinity;const tMaxY=dirY?(nextGridY-posStart.y)/dirY:Infinity;let t=0,tX=tMaxX,tY=tMaxY,wasX=tDeltaX<tDeltaY;while(t<totalLength){if(testFunction(pos)){const hitPos=vec2(posStart.x+dirX*t,posStart.y+dirY*t);const e=1e-9;const hitPosFloor=hitPos.floor();if(hitPosFloor.x<pos.x)hitPos.x=pos.x;else if(hitPosFloor.x>pos.x)hitPos.x=pos.x+1-e;if(hitPosFloor.y<pos.y)hitPos.y=pos.y;else if(hitPosFloor.y>pos.y)hitPos.y=pos.y+1-e;if(normal)wasX?normal.set(-stepX,0):normal.set(0,-stepY);return hitPos}if(wasX=tX<tY){pos.x+=stepX;t=tX;tX+=tDeltaX}else{pos.y+=stepY;t=tY;tY+=tDeltaY}}}function rand(valueA=1,valueB=0){return valueB+Math.random()*(valueA-valueB)}function randInt(valueA,valueB=0){return floor(rand(valueA,valueB))}function randBool(chance=.5){return rand()<chance}function randSign(){return randInt(2)*2-1}function randVec2(length=1){return(new Vector2).setAngle(rand(2*PI),length)}function randInCircle(radius=1,minRadius=0){if(radius<=0)return new Vector2;const ratio=clamp(minRadius/radius);return randVec2(radius*rand(ratio*ratio,1)**.5)}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))}class RandomGenerator{constructor(seed=123456789){ASSERT(seed!==0,"RandomGenerator seed must be non-zero (xorshift is fixed at 0)");this.seed=seed}float(valueA=1,valueB=0){this.seed^=this.seed<<13;this.seed^=this.seed>>>17;this.seed^=this.seed<<5;return valueB+(valueA-valueB)*((this.seed>>>0)/2**32)}int(valueA,valueB=0){return floor(this.float(valueA,valueB))}bool(chance=.5){return this.float()<chance}sign(){return this.float()>.5?1:-1}floatSign(valueA=1,valueB=0){const lo=min(valueA,valueB);const hi=max(valueA,valueB);const d=hi-lo;const e=this.float(d*2);return e<d?lo+e:d-lo-e}angle(){return this.float(-PI,PI)}vec2(valueA=1,valueB=0){return vec2(this.float(valueA,valueB),this.float(valueA,valueB))}randColor(colorA=new Color,colorB=new Color(0,0,0,1),linear=false){return linear?colorA.lerp(colorB,this.float()):new Color(this.float(colorA.r,colorB.r),this.float(colorA.g,colorB.g),this.float(colorA.b,colorB.b),this.float(colorA.a,colorB.a))}mutateColor(color,amount=.05,alphaAmount=0){ASSERT_NUMBER_VALID(amount);ASSERT_NUMBER_VALID(alphaAmount);return new Color(color.r+this.float(amount,-amount),color.g+this.float(amount,-amount),color.b+this.float(amount,-amount),color.a+this.float(alphaAmount,-alphaAmount)).clamp()}}function vec2(x=0,y){return new Vector2(x,y??x)}function isVector2(v){return v instanceof Vector2&&v.isValid()}function ASSERT_VECTOR2_VALID(v){ASSERT(isVector2(v),"Vector2 is invalid.",v)}function ASSERT_NUMBER_VALID(n){ASSERT(isNumber(n),"Number is invalid.",n)}function ASSERT_VECTOR2_NORMAL(v){ASSERT_VECTOR2_VALID(v);ASSERT(abs(v.lengthSquared()-1)<.01,"Vector2 is not normal.",v)}class Vector2{constructor(x=0,y=0){this.x=x;this.y=y;ASSERT(this.isValid(),"Constructed Vector2 is invalid.",this)}set(x=0,y=0){this.x=x;this.y=y;ASSERT_VECTOR2_VALID(this);return this}setFrom(v){return this.set(v.x,v.y)}copy(){return new Vector2(this.x,this.y)}add(v){return new Vector2(this.x+v.x,this.y+v.y)}subtract(v){return new Vector2(this.x-v.x,this.y-v.y)}multiply(v){return new Vector2(this.x*v.x,this.y*v.y)}divide(v){return new Vector2(this.x/v.x,this.y/v.y)}scale(s){return new Vector2(this.x*s,this.y*s)}length(){return this.lengthSquared()**.5}lengthSquared(){return this.x**2+this.y**2}distance(v){return this.distanceSquared(v)**.5}distanceSquared(v){return(this.x-v.x)**2+(this.y-v.y)**2}normalize(length=1){const l=this.length();return l?this.scale(length/l):new Vector2(0,length)}clampLength(length=1){const l=this.length();return l>length?this.scale(length/l):this.copy()}dot(v){return this.x*v.x+this.y*v.y}cross(v){return this.x*v.y-this.y*v.x}reflect(normal,restitution=1){return this.subtract(normal.scale((1+restitution)*this.dot(normal)))}angle(){return atan2(this.x,this.y)}setAngle(angle=0,length=1){ASSERT_NUMBER_VALID(angle);ASSERT_NUMBER_VALID(length);this.x=length*sin(angle);this.y=length*cos(angle);return this}rotate(angle){ASSERT_NUMBER_VALID(angle);const c=cos(-angle),s=sin(-angle);return new Vector2(this.x*c-this.y*s,this.x*s+this.y*c)}setDirection(direction,length=1){ASSERT_NUMBER_VALID(direction);ASSERT_NUMBER_VALID(length);direction=mod(direction,4);ASSERT(direction===0||direction===1||direction===2||direction===3,"Vector2.setDirection() direction must be an integer between 0 and 3.");this.x=direction%2?direction-1?-length:length:0;this.y=direction%2?0:direction?-length:length;return this}direction(){return abs(this.x)>abs(this.y)?this.x<0?3:1:this.y<0?2:0}abs(){return new Vector2(abs(this.x),abs(this.y))}floor(){return new Vector2(floor(this.x),floor(this.y))}snap(grid){ASSERT_NUMBER_VALID(grid);return new Vector2(floor(this.x*grid)/grid,floor(this.y*grid)/grid)}mod(divisor=1){return new Vector2(mod(this.x,divisor),mod(this.y,divisor))}area(){return abs(this.x*this.y)}lerp(v,percent){ASSERT_VECTOR2_VALID(v);ASSERT_NUMBER_VALID(percent);const p=clamp(percent);return new Vector2(v.x*p+this.x*(1-p),v.y*p+this.y*(1-p))}arrayCheck(arraySize){return this.x>=0&&this.y>=0&&this.x<arraySize.x&&this.y<arraySize.y}toString(digits=3){ASSERT_NUMBER_VALID(digits);if(this.isValid())return`(${(this.x<0?"":" ")+this.x.toFixed(digits)},${(this.y<0?"":" ")+this.y.toFixed(digits)} )`;else return`(${this.x}, ${this.y})`}isValid(){return isNumber(this.x)&&isNumber(this.y)}}function rgb(r,g,b,a){return new Color(r,g,b,a)}function hsl(h,s,l,a){return(new Color).setHSLA(h,s,l,a)}function isColor(c){return c instanceof Color&&c.isValid()}function ASSERT_COLOR_VALID(c){ASSERT(isColor(c),"Color is invalid.",c)}class Color{constructor(r=1,g=1,b=1,a=1){this.r=r;this.g=g;this.b=b;this.a=a;ASSERT(this.isValid(),"Constructed Color is invalid.",this)}set(r=1,g=1,b=1,a=1){this.r=r;this.g=g;this.b=b;this.a=a;ASSERT_COLOR_VALID(this);return this}setFrom(c){return this.set(c.r,c.g,c.b,c.a)}setAlpha(a=1){this.a=a;ASSERT_COLOR_VALID(this);return this}copy(){return new Color(this.r,this.g,this.b,this.a)}withAlpha(a=1){return new Color(this.r,this.g,this.b,a)}add(c){return new Color(this.r+c.r,this.g+c.g,this.b+c.b,this.a+c.a)}subtract(c){return new Color(this.r-c.r,this.g-c.g,this.b-c.b,this.a-c.a)}multiply(c){return new Color(this.r*c.r,this.g*c.g,this.b*c.b,this.a*c.a)}divide(c){return new Color(this.r/c.r,this.g/c.g,this.b/c.b,this.a/c.a)}scale(scale,alphaScale=scale){return new Color(this.r*scale,this.g*scale,this.b*scale,this.a*alphaScale)}clamp(){return new Color(clamp(this.r),clamp(this.g),clamp(this.b),clamp(this.a))}lerp(c,percent){ASSERT_COLOR_VALID(c);ASSERT_NUMBER_VALID(percent);const p=clamp(percent);return new Color(c.r*p+this.r*(1-p),c.g*p+this.g*(1-p),c.b*p+this.b*(1-p),c.a*p+this.a*(1-p))}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_COLOR_VALID(this);return this}HSLA(){const r=clamp(this.r);const g=clamp(this.g);const b=clamp(this.b);const a=clamp(this.a);const maxC=max(r,g,b);const minC=min(r,g,b);const l=(maxC+minC)/2;let h=0,s=0;if(maxC!==minC){let d=maxC-minC;s=l>.5?d/(2-maxC-minC):d/(maxC+minC);if(r===maxC)h=(g-b)/d+(g<b?6:0);else if(g===maxC)h=(b-r)/d+2;else if(b===maxC)h=(r-g)/d+4}return[h/6,s,l,a]}mutate(amount=.05,alphaAmount=0){ASSERT_NUMBER_VALID(amount);ASSERT_NUMBER_VALID(alphaAmount);return new Color(this.r+rand(amount,-amount),this.g+rand(amount,-amount),this.b+rand(amount,-amount),this.a+rand(alphaAmount,-alphaAmount)).clamp()}toString(useAlpha=true){if(debug&&!this.isValid())return"#000";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):"")}setHex(hex){ASSERT(isStringLike(hex),"Color hex code must be a string");ASSERT(hex[0]==="#","Color hex code must start with #");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_COLOR_VALID(this);return this}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}isValid(){return isNumber(this.r)&&isNumber(this.g)&&isNumber(this.b)&&isNumber(this.a)}}const WHITE=debugProtectConstant(rgb());const CLEAR_WHITE=debugProtectConstant(rgb(1,1,1,0));const BLACK=debugProtectConstant(rgb(0,0,0));const CLEAR_BLACK=debugProtectConstant(rgb(0,0,0,0));const GRAY=debugProtectConstant(rgb(.5,.5,.5));const RED=debugProtectConstant(rgb(1,0,0));const ORANGE=debugProtectConstant(rgb(1,.5,0));const YELLOW=debugProtectConstant(rgb(1,1,0));const GREEN=debugProtectConstant(rgb(0,1,0));const CYAN=debugProtectConstant(rgb(0,1,1));const BLUE=debugProtectConstant(rgb(0,0,1));const PURPLE=debugProtectConstant(rgb(.5,0,1));const MAGENTA=debugProtectConstant(rgb(1,0,1));class Timer{constructor(timeLeft,useRealTime=false){ASSERT(timeLeft===undefined||isNumber(timeLeft),"Constructed Timer is invalid.",timeLeft);this.useRealTime=useRealTime;const globalTime=this.getGlobalTime();this.time=timeLeft===undefined?undefined:globalTime+timeLeft;this.setTime=timeLeft}set(timeLeft=0){ASSERT(isNumber(timeLeft),"Timer is invalid.",timeLeft);const globalTime=this.getGlobalTime();this.time=globalTime+timeLeft;this.setTime=timeLeft}setUseRealTime(useRealTime=true){ASSERT(!this.isSet(),"Cannot change global time setting while timer is set.");this.useRealTime=useRealTime}unset(){this.time=undefined}isSet(){return this.time!==undefined}active(){return this.getGlobalTime()<this.time}elapsed(){return this.getGlobalTime()>=this.time}get(){return this.isSet()?this.getGlobalTime()-this.time:0}getPercent(){if(!this.isSet())return 0;if(!this.setTime)return 1;return 1-percent(this.time-this.getGlobalTime(),0,this.setTime)}getSetTime(){return this.isSet()?this.setTime:0}getGlobalTime(){return this.useRealTime?timeReal:time}toString(){return this.isSet()?abs(this.get())+" seconds "+(this.get()<0?"before":"after"):"unset"}valueOf(){return this.get()}}function formatTime(t){const signStr=t<0?"-":"";t=abs(t)|0;return signStr+(t/60|0)+":"+(t%60<10?"0":"")+t%60}async function fetchJSON(url){const response=await fetch(url);if(!response.ok)throw new Error(`Failed to fetch JSON from ${url}: ${response.status} ${response.statusText}`);return response.json()}function saveText(text,filename="text",type="text/plain"){saveDataURL(URL.createObjectURL(new Blob([text],{type:type})),filename)}function saveCanvas(canvas,filename="screenshot",type="image/png"){if(canvas instanceof OffscreenCanvas){const saveCanvas=document.createElement("canvas");saveCanvas.width=canvas.width;saveCanvas.height=canvas.height;saveCanvas.getContext("2d").drawImage(canvas,0,0);saveDataURL(saveCanvas.toDataURL(type),filename)}else saveDataURL(canvas.toDataURL(type),filename)}function saveDataURL(url,filename="download",revokeTime){ASSERT(isStringLike(url),"saveDataURL requires url string");ASSERT(isStringLike(filename),"saveDataURL requires filename string");const link=document.createElement("a");link.download=filename;link.href=url;link.click();if(revokeTime!==undefined)setTimeout(()=>URL.revokeObjectURL(url),revokeTime)}function shareURL(title,url,callback){ASSERT(isStringLike(title),"shareURL requires title string");ASSERT(isStringLike(url),"shareURL requires url string");navigator.share?.({title:title,url:url}).then(()=>callback?.())}function readSaveData(saveName,defaultSaveData){ASSERT(isStringLike(saveName),"loadData requires saveName string");let loadedData={};try{const data=localStorage[saveName];if(data){try{loadedData=JSON.parse(data)}catch{LOG("readSaveData: corrupt JSON for",saveName,"— using defaults")}}}catch{LOG("readSaveData: localStorage unavailable — using defaults")}return{...defaultSaveData,...loadedData}}function writeSaveData(saveName,saveData){ASSERT(isStringLike(saveName),"saveData requires saveName string");try{localStorage[saveName]=JSON.stringify(saveData)}catch{LOG("writeSaveData: failed to write",saveName)}}function noiseHash(i){let h=(i|0)^2654435769;h=Math.imul(h^h>>>16,2246822507);h=Math.imul(h^h>>>13,3266489909);h^=h>>>16;return(h>>>0)/2**32}function noise1D(x){const i=floor(x);return lerp(noiseHash(i),noiseHash(i+1),smoothStep(x-i))}function noise2D(x,y){const ix=floor(x),iy=floor(y);const fx=smoothStep(x-ix),fy=smoothStep(y-iy);const h=(a,b)=>noiseHash(a+b*374761393);return lerp(lerp(h(ix,iy),h(ix+1,iy),fx),lerp(h(ix,iy+1),h(ix+1,iy+1),fx),fy)}let cameraPos=vec2();let cameraAngle=0;let cameraScale=32;let timeScale=1;let canvasColorTiles=true;let canvasClearColor=CLEAR_BLACK;let canvasMaxSize=vec2(1920,1080);let canvasMinAspect=0;let canvasMaxAspect=0;let canvasFixedSize=vec2();let canvasPixelated=false;let tilesPixelated=true;let canvasPixelRatio=1;let fontDefault="arial";let showSplashScreen=false;let headlessMode=false;let glEnable=true;let glCircleSides=32;let tileDefaultSize=vec2(16);let tileDefaultPadding=0;let tileDefaultBleed=0;let enablePhysicsSolver=true;let objectDefaultMass=1;let objectDefaultDamping=1;let objectDefaultAngleDamping=1;let objectDefaultRestitution=0;let objectDefaultFriction=.8;let objectMaxSpeed=1;let gravity=vec2();let particleEmitRateScale=1;let gamepadsEnable=true;let gamepadDirectionEmulateStick=true;let inputWASDEmulateDirection=true;let touchInputEnable=true;let touchGamepadEnable=false;let touchGamepadPassthrough=false;let touchGamepadCenterButtonSize=0;let touchGamepadButtonCount=4;let touchGamepadLeftStick=true;let touchGamepadLeftButtonCount=0;let touchGamepadRightStick=false;let touchGamepadAnalog=true;let touchGamepadFloating=false;let touchGamepadSize=100;let touchGamepadAlpha=.3;let touchGamepadDisplayTime=3;let touchGamepadVibration=0;let vibrateEnable=true;let soundEnable=true;let soundVolume=.3;let soundDefaultRange=40;let soundDefaultTaper=.7;function setCameraPos(pos){cameraPos=pos.copy()}function setCameraAngle(angle){cameraAngle=angle}function setCameraScale(scale){cameraScale=scale}function setTimeScale(scale){timeScale=scale}function setCanvasColorTiles(colorTiles){canvasColorTiles=colorTiles}function setCanvasClearColor(color){canvasClearColor=color.copy()}function setCanvasMaxSize(size){canvasMaxSize=size.copy()}function setCanvasMinAspect(aspect){canvasMinAspect=aspect}function setCanvasMaxAspect(aspect){canvasMaxAspect=aspect}function setCanvasFixedSize(size){canvasFixedSize=size.copy()}function setCanvasPixelated(pixelated){canvasPixelated=pixelated;if(mainCanvas)mainCanvas.style.imageRendering=pixelated?"pixelated":"";if(glCanvas)glCanvas.style.imageRendering=pixelated?"pixelated":""}function setTilesPixelated(pixelated){tilesPixelated=pixelated}function setCanvasPixelRatio(pixelRatio){canvasPixelRatio=pixelRatio}function setFontDefault(font){fontDefault=font}function setShowSplashScreen(show){showSplashScreen=show}function setHeadlessMode(headless){headlessMode=headless}function setGLEnable(enable){if(enable&&!glCanBeEnabled){console.warn("Can not enable WebGL if it was disabled on start.");return}glEnable=enable;if(glCanvas)glCanvas.style.display=enable?"":"none"}function setGLCircleSides(sides){glCircleSides=sides}function setTileDefaultSize(size){tileDefaultSize=size.copy()}function setTileDefaultPadding(padding){tileDefaultPadding=padding}function setTileDefaultBleed(bleed){tileDefaultBleed=bleed}function setEnablePhysicsSolver(enable){enablePhysicsSolver=enable}function setObjectDefaultMass(mass){objectDefaultMass=mass}function setObjectDefaultDamping(damp){objectDefaultDamping=damp}function setObjectDefaultAngleDamping(damp){objectDefaultAngleDamping=damp}function setObjectDefaultRestitution(restitution){objectDefaultRestitution=restitution}function setObjectDefaultFriction(friction){objectDefaultFriction=friction}function setObjectMaxSpeed(speed){objectMaxSpeed=speed}function setGravity(newGravity){gravity=newGravity.copy()}function setParticleEmitRateScale(scale){particleEmitRateScale=scale}function setGamepadsEnable(enable){gamepadsEnable=enable}function setGamepadDirectionEmulateStick(enable){gamepadDirectionEmulateStick=enable}function setInputWASDEmulateDirection(enable){inputWASDEmulateDirection=enable}function setTouchInputEnable(enable){touchInputEnable=enable}function setTouchGamepadEnable(enable){touchGamepadEnable=enable}function setTouchGamepadPassthrough(passthrough){touchGamepadPassthrough=passthrough}function setTouchGamepadCenterButtonSize(size){touchGamepadCenterButtonSize=size}function setTouchGamepadButtonCount(count){touchGamepadButtonCount=count;if(count>0)touchGamepadRightStick=false}function setTouchGamepadLeftStick(enable){touchGamepadLeftStick=enable;if(enable)touchGamepadLeftButtonCount=0}function setTouchGamepadLeftButtonCount(count){touchGamepadLeftButtonCount=count;if(count>0)touchGamepadLeftStick=false}function setTouchGamepadRightStick(rightStick){touchGamepadRightStick=rightStick;if(rightStick)touchGamepadButtonCount=0}function setTouchGamepadAnalog(analog){touchGamepadAnalog=analog}function setTouchGamepadFloating(floating){touchGamepadFloating=floating}function setTouchGamepadSize(size){touchGamepadSize=size}function setTouchGamepadAlpha(alpha){touchGamepadAlpha=alpha}function setTouchGamepadDisplayTime(time){touchGamepadDisplayTime=time}function setTouchGamepadVibration(ms){touchGamepadVibration=ms}function setVibrateEnable(enable){vibrateEnable=enable}function setSoundEnable(enable){soundEnable=enable}function setSoundVolume(volume){soundVolume=volume;if(soundEnable&&!headlessMode&&audioMasterGain)audioMasterGain.gain.value=volume}function setSoundDefaultRange(range){soundDefaultRange=range}function setSoundDefaultTaper(taper){soundDefaultTaper=taper}function setDebugWatermark(show){debugWatermark=show}function setDebugKey(key){debugKey=key}class EngineObject{constructor(pos=vec2(),size=vec2(1),tileInfo,angle=0,color=WHITE,renderOrder=0){ASSERT(isVector2(pos),"object pos must be a vec2");ASSERT(isVector2(size),"object size must be a vec2");ASSERT(!tileInfo||tileInfo instanceof TileInfo,"object tileInfo should be a TileInfo or undefined");ASSERT(typeof angle==="number"&&isFinite(angle),"object angle should be a number");ASSERT(isColor(color),"object color should be a valid rgba color");ASSERT(typeof renderOrder==="number","object renderOrder should be a number");this.pos=pos.copy();this.size=size.copy();this.drawSize=undefined;this.tileInfo=tileInfo;this.angle=angle;this.color=color.copy();this.additiveColor=undefined;this.mirror=false;this.destroyed=false;this.mass=objectDefaultMass;this.damping=objectDefaultDamping;this.angleDamping=objectDefaultAngleDamping;this.restitution=objectDefaultRestitution;this.friction=objectDefaultFriction;this.gravityScale=1;this.renderOrder=renderOrder;this.velocity=vec2();this.angleVelocity=0;this.spawnTime=time;this.children=[];this.clampSpeed=true;this.groundObject=undefined;this.parent=undefined;this.localPos=vec2();this.localAngle=0;this.collideTiles=false;this.collideSolidObjects=false;this.isSolid=false;this.collideRaycast=false;engineObjects.push(this)}updateTransforms(){const parent=this.parent;if(parent){const mirror=parent.getMirrorSign();const lp=this.localPos,pp=parent.pos;const lx=lp.x*mirror,ly=lp.y,pa=parent.angle;if(pa){const c=cos(-pa),s=sin(-pa);this.pos.set(lx*c-ly*s+pp.x,lx*s+ly*c+pp.y)}else this.pos.set(lx+pp.x,ly+pp.y);this.angle=mirror*this.localAngle+pa}for(const child of this.children)child.updateTransforms()}updatePhysics(){ASSERT(!this.parent);if(this.destroyed)return;if(this.clampSpeed){this.velocity.x=clamp(this.velocity.x,-objectMaxSpeed,objectMaxSpeed);this.velocity.y=clamp(this.velocity.y,-objectMaxSpeed,objectMaxSpeed)}const oldPos=this.pos.copy();this.velocity.x*=this.damping;this.velocity.y*=this.damping;if(this.mass){this.velocity.x+=gravity.x*this.gravityScale;this.velocity.y+=gravity.y*this.gravityScale}this.pos.x+=this.velocity.x;this.pos.y+=this.velocity.y;this.angle+=this.angleVelocity*=this.angleDamping;ASSERT(this.angleDamping>=0&&this.angleDamping<=1);ASSERT(this.damping>=0&&this.damping<=1);if(!enablePhysicsSolver||!this.mass)return;const wasFalling=this.velocity.y<0&&gravity.y<0||this.velocity.y>0&&gravity.y>0;if(this.groundObject){const friction=max(this.friction,this.groundObject.friction);const groundSpeed=this.groundObject.velocity.x;this.velocity.x=groundSpeed+(this.velocity.x-groundSpeed)*friction;this.groundObject=undefined}if(this.collideSolidObjects){const epsilon=.001;for(const o of engineObjectsCollide){if(o.destroyed||o.parent||o===this)continue;if(!this.isSolid&&!o.isSolid)continue;if(!this.isOverlappingObject(o))continue;const collide1=this.collideWithObject(o);const collide2=o.collideWithObject(this);if(!collide1||!collide2)continue;if(isOverlapping(oldPos,this.size,o.pos,o.size)){const deltaPos=oldPos.subtract(o.pos);const length=deltaPos.length();const pushAwayAccel=.001;const velocity=length<.001?vec2(0,1):deltaPos.scale(pushAwayAccel/length);this.velocity=this.velocity.add(velocity);if(o.mass)o.velocity=o.velocity.subtract(velocity);debugPhysics&&debugOverlap(this.pos,this.size,o.pos,o.size,"#f00");continue}const sizeBoth=this.size.add(o.size);const smallStepUp=(oldPos.y-o.pos.y)*2>sizeBoth.y+gravity.y;const isBlockedX=abs(oldPos.y-o.pos.y)*2<sizeBoth.y;const isBlockedY=abs(oldPos.x-o.pos.x)*2<sizeBoth.x;const restitution=max(this.restitution,o.restitution);if(smallStepUp||isBlockedY||!isBlockedX){this.pos.y=o.pos.y+(sizeBoth.y/2+epsilon)*sign(oldPos.y-o.pos.y);if(o.groundObject&&wasFalling||!o.mass){if(wasFalling)this.groundObject=o;this.velocity.y*=-restitution}else if(o.mass){const inelastic=(this.mass*this.velocity.y+o.mass*o.velocity.y)/(this.mass+o.mass);const elastic0=this.velocity.y*(this.mass-o.mass)/(this.mass+o.mass)+o.velocity.y*2*o.mass/(this.mass+o.mass);const elastic1=o.velocity.y*(o.mass-this.mass)/(this.mass+o.mass)+this.velocity.y*2*this.mass/(this.mass+o.mass);this.velocity.y=lerp(inelastic,elastic0,restitution);o.velocity.y=lerp(inelastic,elastic1,restitution)}}if(!smallStepUp&&isBlockedX){this.pos.x=o.pos.x+(sizeBoth.x/2+epsilon)*sign(oldPos.x-o.pos.x);if(o.mass){const inelastic=(this.mass*this.velocity.x+o.mass*o.velocity.x)/(this.mass+o.mass);const elastic0=this.velocity.x*(this.mass-o.mass)/(this.mass+o.mass)+o.velocity.x*2*o.mass/(this.mass+o.mass);const elastic1=o.velocity.x*(o.mass-this.mass)/(this.mass+o.mass)+this.velocity.x*2*this.mass/(this.mass+o.mass);this.velocity.x=lerp(inelastic,elastic0,restitution);o.velocity.x=lerp(inelastic,elastic1,restitution)}else this.velocity.x*=-restitution}debugPhysics&&debugOverlap(this.pos,this.size,o.pos,o.size,"#f0f")}}if(this.collideTiles){const hitLayer=tileCollisionTest(this.pos,this.size,this);if(hitLayer){if(!tileCollisionTest(oldPos,this.size,this)){const isBlockedX=tileCollisionTest(vec2(this.pos.x,oldPos.y),this.size,this);const isBlockedY=tileCollisionTest(vec2(oldPos.x,this.pos.y),this.size,this);const restitution=max(this.restitution,hitLayer.restitution);if(isBlockedX){const epsilon=.001;const maxMove=.1;const gravitySign=gravity.y>0?-1:1;const y=gravitySign>0?floor(oldPos.y-this.size.y/2+1)+this.size.y/2+epsilon:ceil(oldPos.y+this.size.y/2-1)-this.size.y/2-epsilon;const delta=abs(y-this.pos.y);if(delta<maxMove)if(!tileCollisionTest(vec2(this.pos.x,y),this.size,this)){this.pos.y=y;debugPhysics&&debugRect(this.pos,this.size,"#ff0");return}this.pos.x=oldPos.x;this.velocity.x*=-restitution}if(isBlockedY||!isBlockedX){if(wasFalling){const epsilon=1e-4;const offset=this.size.y/2+epsilon;this.pos.y=gravity.y<0?floor(oldPos.y-this.size.y/2)+offset:ceil(oldPos.y+this.size.y/2)-offset;this.groundObject=hitLayer}else{this.pos.y=oldPos.y;this.groundObject=undefined}this.velocity.y*=-restitution}debugPhysics&&debugRect(this.pos,this.size,"#f00")}}}}update(){}render(){drawTile(this.pos,this.drawSize||this.size,this.tileInfo,this.color,this.angle,this.mirror,this.additiveColor)}renderLight(){}destroy(immediate=false){if(this.destroyed)return;this.destroyed=true;this.parent?.removeChild(this);for(const child of this.children){child.parent=undefined;child.destroy(immediate)}}localToWorld(pos){return this.pos.add(pos.rotate(this.angle))}worldToLocal(pos){return pos.subtract(this.pos).rotate(-this.angle)}localToWorldVector(vec){return vec.rotate(this.angle)}worldToLocalVector(vec){return vec.rotate(-this.angle)}collideWithTile(tileData,pos){return tileData>0}collideWithObject(object){return true}getUp(scale=1){return vec2().setAngle(this.angle,scale)}getRight(scale=1){return vec2().setAngle(this.angle+PI/2,scale)}getAliveTime(){return time-this.spawnTime}getSpeed(){return this.velocity.length()}applyAcceleration(acceleration){if(this.mass)this.velocity=this.velocity.add(acceleration)}applyAngularAcceleration(acceleration){if(this.mass)this.angleVelocity+=acceleration}applyForce(force){if(this.mass)this.applyAcceleration(force.scale(1/this.mass))}getMirrorSign(){return this.mirror?-1:1}addChild(child,localPos=vec2(),localAngle=0){ASSERT(!this.destroyed,"cannot add child to destroyed object");if(this.destroyed)return child;ASSERT(!child.parent&&!this.children.includes(child));ASSERT(child instanceof EngineObject,"child must be an EngineObject");ASSERT(child!==this,"cannot add self as child");this.children.push(child);child.parent=this;child.localPos=localPos.copy();child.localAngle=localAngle;child.updateTransforms();return child}removeChild(child){ASSERT(child.parent===this&&this.children.includes(child));this.children.splice(this.children.indexOf(child),1);child.parent=undefined}isOverlappingObject(object){return this.isOverlapping(object.pos,object.size)}isOverlapping(pos,size=vec2()){return isOverlapping(this.pos,this.size,pos,size)}setCollision(collideSolidObjects=true,isSolid=true,collideTiles=true,collideRaycast=true){ASSERT(collideSolidObjects||!isSolid,"solid objects must be set to collide");this.collideSolidObjects=collideSolidObjects;this.isSolid=isSolid;this.collideTiles=collideTiles;this.collideRaycast=collideRaycast}toString(){let text="type = "+this.constructor.name;if(this.pos.x||this.pos.y)text+="\npos = "+this.pos;if(this.velocity.x||this.velocity.y)text+="\nvelocity = "+this.velocity;if(this.size.x||this.size.y)text+="\nsize = "+this.size;if(this.angle)text+="\nangle = "+this.angle.toFixed(3);if(this.color)text+="\ncolor = "+this.color;return text}renderDebugInfo(){if(!debug)return;const hasPhysics=this.collideTiles||this.collideSolidObjects||this.isSolid;if(!hasPhysics&&!this.parent)return;const size=vec2(max(this.size.x,.2),max(this.size.y,.2));const color=rgb(this.collideTiles?1:0,this.collideSolidObjects?1:0,this.isSolid?1:0,.5);debugRect(this.pos,size,color,0,this.angle,hasPhysics);if(this.parent)debugRect(this.pos,size.scale(.8),rgb(1,1,1,.5),0,this.angle);this.parent&&debugLine(this.pos,this.parent.pos,rgb(1,1,1,.5),.5)}}let mainCanvas;let mainContext;let drawContext;let workCanvas;let workContext;let workReadCanvas;let workReadContext;let mainCanvasSize=vec2();let textureInfos=[];let drawCount;let primitiveCount;function isWhite(c){return c.r>=1&&c.g>=1&&c.b>=1}function isBlack(c){return c.r<=0&&c.g<=0&&c.b<=0&&c.a<=0}function tile(index=0,size=tileDefaultSize,texture=0,padding=tileDefaultPadding,bleed=tileDefaultBleed){ASSERT(isVector2(index)||typeof index==="number","index must be a vec2 or number");ASSERT(isVector2(size)||typeof size==="number","size must be a vec2 or number");ASSERT(isNumber(texture)||texture instanceof TextureInfo,"texture must be a number or TextureInfo");ASSERT(isNumber(padding),"padding must be a number");if(headlessMode)return new TileInfo;if(typeof size==="number"){ASSERT(size>0);size=new Vector2(size,size)}const textureInfo=typeof texture==="number"?textureInfos[texture]:texture;ASSERT(textureInfo instanceof TextureInfo,"tile texture is not loaded");ASSERT(textureInfo.size.x>0,"tile texture is not loaded");const sizePaddedX=size.x+padding*2;const sizePaddedY=size.y+padding*2;let x,y;if(typeof index==="number"){const cols=textureInfo.size.x/sizePaddedX|0;x=index%cols;y=index/cols|0}else{x=index.x;y=index.y}const pos=new Vector2(x*sizePaddedX+padding,y*sizePaddedY+padding);return new TileInfo(pos,size,textureInfo,padding,bleed)}class TileInfo{constructor(pos=vec2(),size=tileDefaultSize,textureInfo=textureInfos[0],padding=tileDefaultPadding,bleed=tileDefaultBleed){this.pos=pos.copy();this.size=size.copy();this.padding=padding;this.textureInfo=textureInfo;this.bleed=bleed}offset(offset){return new TileInfo(this.pos.add(offset),this.size,this.textureInfo,this.padding,this.bleed)}frame(frame){ASSERT(typeof frame==="number");const w=this.size.x+this.padding*2;const x=frame*w;ASSERT(x+this.size.x<=this.textureInfo.size.x,"frame extends beyond texture width!");return this.offset(new Vector2(x))}index(index){return tile(index,this.size,this.textureInfo,this.padding,this.bleed)}setFullImage(textureInfo=this.textureInfo){this.textureInfo=textureInfo;this.pos=new Vector2;this.size=textureInfo.size.copy();this.bleed=this.padding=0;return this}}class TextureInfo{constructor(image,useWebGL=true,wrap=false){this.image=image;this.size=image?vec2(image.width,image.height):vec2();this.sizeInverse=image?vec2(1/image.width,1/image.height):vec2();this.glTexture=undefined;this.wrap=wrap;useWebGL&&this.createWebGLTexture()}createWebGLTexture(){glRegisterTextureInfo(this)}destroyWebGLTexture(){glUnregisterTextureInfo(this)}hasWebGL(){return!!this.glTexture}setWrap(wrap=true){this.wrap=wrap;glSetTextureWrap(this.glTexture,wrap)}}function drawTile(pos,size=vec2(1),tileInfo,color=WHITE,angle=0,mirror,additiveColor,useWebGL=glEnable,screenSpace=false,context){ASSERT(isVector2(pos),"pos must be a vec2");ASSERT(isVector2(size),"size must be a vec2");ASSERT(isColor(color),"color is invalid");ASSERT(isNumber(angle),"angle must be a number");ASSERT(!additiveColor||isColor(additiveColor),"additiveColor must be a color");ASSERT(!context||!useWebGL,"context only supported in canvas 2D mode");const textureInfo=tileInfo?.textureInfo;const bleed=tileInfo?.bleed??0;if(useWebGL&&glEnable){ASSERT(!!glContext,"WebGL is not enabled!");if(screenSpace)[pos,size,angle]=screenToWorldTransform(pos,size,angle);if(textureInfo){const sizeInverse=textureInfo.sizeInverse;const x=tileInfo.pos.x*sizeInverse.x;const y=tileInfo.pos.y*sizeInverse.y;const w=tileInfo.size.x*sizeInverse.x;const h=tileInfo.size.y*sizeInverse.y;glSetTexture(textureInfo.glTexture);if(bleed){const bleedX=sizeInverse.x*bleed;const bleedY=sizeInverse.y*bleed;glDraw(pos.x,pos.y,mirror?-size.x:size.x,size.y,angle,x+bleedX,y+bleedY,x-bleedX+w,y-bleedY+h,color.rgbaInt(),additiveColor&&additiveColor.rgbaInt())}else{glDraw(pos.x,pos.y,mirror?-size.x:size.x,size.y,angle,x,y,x+w,y+h,color.rgbaInt(),additiveColor&&additiveColor.rgbaInt())}}else{const combined=additiveColor?color.add(additiveColor):color;glDrawUntextured(pos.x,pos.y,size.x,size.y,angle,combined.rgbaInt())}}else{++drawCount;++primitiveCount;drawCanvas2D(pos,size,angle,mirror,context=>{if(textureInfo){context.scale(1,-1);const x=tileInfo.pos.x,y=tileInfo.pos.y;const w=tileInfo.size.x,h=tileInfo.size.y;drawImageColor(context,textureInfo.image,x,y,w,h,-.5,-.5,1,1,color,additiveColor,bleed)}else{const c=additiveColor?color.add(additiveColor):color;context.fillStyle=c.toString();context.fillRect(-.5,-.5,1,1)}},screenSpace,context)}}function drawRect(pos,size,color,angle,useWebGL,screenSpace,context){drawTile(pos,size,undefined,color,angle,false,undefined,useWebGL,screenSpace,context)}function drawRectGradient(pos,size,colorTop=WHITE,colorBottom=CLEAR_WHITE,angle=0,useWebGL=glEnable,screenSpace=false,context){ASSERT(isVector2(pos),"pos must be a vec2");ASSERT(isVector2(size),"size must be a vec2");ASSERT(isColor(colorTop)&&isColor(colorBottom),"color is invalid");ASSERT(isNumber(angle),"angle must be a number");ASSERT(!context||!useWebGL,"context only supported in canvas 2D mode");if(useWebGL&&glEnable){ASSERT(!!glContext,"WebGL is not enabled!");if(screenSpace){pos=screenToWorld(pos);size=size.scale(1/cameraScale);angle+=cameraAngle}const points=[],colors=[];const halfSizeX=size.x/2,halfSizeY=size.y/2;const colorTopInt=colorTop.rgbaInt();const colorBottomInt=colorBottom.rgbaInt();const c=cos(-angle),s=sin(-angle);for(let i=4;i--;){const x=i&1?halfSizeX:-halfSizeX;const y=i&2?halfSizeY:-halfSizeY;const rx=x*c-y*s;const ry=x*s+y*c;const color=i&2?colorTopInt:colorBottomInt;points.push(vec2(pos.x+rx,pos.y+ry));colors.push(color)}glDrawColoredPoints(points,colors)}else{++drawCount;++primitiveCount;drawCanvas2D(pos,size,angle,false,context=>{const gradient=context.createLinearGradient(0,.5,0,-.5);gradient.addColorStop(0,colorTop.toString());gradient.addColorStop(1,colorBottom.toString());context.fillStyle=gradient;context.fillRect(-.5,-.5,1,1)},screenSpace,context)}}function drawTextureWrapped(pos,size,wrapCount,texture=0,color=WHITE,angle=0,additiveColor,useWebGL=glEnable,screenSpace=false,context){ASSERT(isVector2(pos),"pos must be a vec2");ASSERT(isVector2(size),"size must be a vec2");ASSERT(isVector2(wrapCount),"wrapCount must be a vec2");ASSERT(isColor(color),"color is invalid");ASSERT(isNumber(angle),"angle must be a number");ASSERT(!additiveColor||isColor(additiveColor),"additiveColor must be a color");ASSERT(!context||!useWebGL,"context only supported in canvas 2D mode");ASSERT(!(texture instanceof TileInfo),"pass a TextureInfo or texture index, not a TileInfo — use tileInfo.textureInfo");if(headlessMode)return;const textureInfo=typeof texture==="number"?textureInfos[texture]:texture;ASSERT(textureInfo instanceof TextureInfo,"texture not loaded");ASSERT(textureInfo.size.x>0,"texture not loaded");ASSERT(textureInfo.wrap,"drawTextureWrapped requires a wrap-enabled texture; call textureInfo.setWrap(true) first");if(useWebGL&&glEnable){ASSERT(!!glContext,"WebGL is not enabled!");if(screenSpace)[pos,size,angle]=screenToWorldTransform(pos,size,angle);glSetTexture(textureInfo.glTexture);glDraw(pos.x,pos.y,size.x,size.y,angle,0,0,wrapCount.x,wrapCount.y,color.rgbaInt(),additiveColor&&additiveColor.rgbaInt());return}++drawCount;++primitiveCount;if(!screenSpace){pos=worldToScreen(pos);size=size.scale(cameraScale);angle-=cameraAngle}const noTint=!canvasColorTiles||(additiveColor?isWhite(color.add(additiveColor))&&additiveColor.a<=0:isWhite(color));const alphaBaked=!noTint&&additiveColor&&!isBlack(additiveColor);const source=noTint?textureInfo.image:bakeTintedImage(textureInfo.image,color,additiveColor);context=context||drawContext;context.save();context.translate(pos.x+.5,pos.y+.5);context.rotate(angle);context.globalAlpha=alphaBaked?1:color.a;const pattern=context.createPattern(source,"repeat");const m=(new DOMMatrix).translate(-size.x/2,-size.y/2).scale(size.x/(wrapCount.x*source.width),size.y/(wrapCount.y*source.height));pattern.setTransform(m);context.fillStyle=pattern;context.fillRect(-size.x/2,-size.y/2,size.x,size.y);context.globalAlpha=1;context.restore()}function drawLineList(points,width=.1,color=WHITE,wrap=false,pos=vec2(),angle=0,useWebGL=glEnable,screenSpace=false,context){ASSERT(isArray(points),"points must be an array");ASSERT(isNumber(width),"width must be a number");ASSERT(isColor(color),"color is invalid");ASSERT(isVector2(pos),"pos must be a vec2");ASSERT(isNumber(angle),"angle must be a number");ASSERT(!context||!useWebGL,"context only supported in canvas 2D mode");if(useWebGL&&glEnable){ASSERT(!!glContext,"WebGL is not enabled!");let size=vec2(1);if(screenSpace)[pos,size,angle]=screenToWorldTransform(pos,size,angle);glDrawOutlineTransform(points,color.rgbaInt(),width,pos.x,pos.y,size.x,size.y,angle,wrap)}else{++drawCount;++primitiveCount;drawCanvas2D(pos,vec2(1),angle,false,context=>{context.strokeStyle=color.toString();context.lineWidth=width;context.beginPath();for(let i=0;i<points.length;++i){const point=points[i];context.lineTo(point.x,point.y)}wrap&&context.closePath();context.stroke()},screenSpace,context)}}function drawLine(posA,posB,width=.1,color=WHITE,pos=vec2(),angle=0,useWebGL=glEnable,screenSpace=false,context){const halfDelta=vec2((posB.x-posA.x)/2,(posB.y-posA.y)/2);const size=vec2(width,halfDelta.length()*2);pos=pos.add(posA.add(halfDelta));if(screenSpace)halfDelta.y*=-1;angle+=halfDelta.angle();drawRect(pos,size,color,angle,useWebGL,screenSpace,context)}function drawRegularPoly(pos,size=vec2(1),sides=3,color=WHITE,lineWidth=0,lineColor=BLACK,angle=0,useWebGL=glEnable,screenSpace=false,context){ASSERT(isVector2(size),"size must be a vec2");ASSERT(isNumber(sides),"sides must be a number");const points=[];const sizeX=size.x/2,sizeY=size.y/2;for(let i=sides;i--;){const a=i/sides*PI*2;points.push(vec2(sin(a)*sizeX,cos(a)*sizeY))}drawPoly(points,color,lineWidth,lineColor,pos,angle,useWebGL,screenSpace,context)}function drawPoly(points,color=WHITE,lineWidth=0,lineColor=BLACK,pos=vec2(),angle=0,useWebGL=glEnable,screenSpace=false,context=undefined){ASSERT(isVector2(pos),"pos must be a vec2");ASSERT(isArray(points),"points must be an array");ASSERT(isColor(color)&&isColor(lineColor),"color is invalid");ASSERT(isNumber(lineWidth),"lineWidth must be a number");ASSERT(isNumber(angle),"angle must be a number");ASSERT(!context||!useWebGL,"context only supported in canvas 2D mode");if(useWebGL&&glEnable){ASSERT(!!glContext,"WebGL is not enabled!");let size=vec2(1);if(screenSpace)[pos,size,angle]=screenToWorldTransform(pos,size,angle);glDrawPointsTransform(points,color.rgbaInt(),pos.x,pos.y,size.x,size.y,angle);if(lineWidth>0)glDrawOutlineTransform(points,lineColor.rgbaInt(),lineWidth,pos.x,pos.y,size.x,size.y,angle)}else{drawCanvas2D(pos,vec2(1),angle,false,context=>{context.fillStyle=color.toString();context.beginPath();for(const point of points)context.lineTo(point.x,point.y);context.closePath();context.fill();if(lineWidth){context.strokeStyle=lineColor.toString();context.lineWidth=