{ "version": 3, "sources": ["../src/kaboom.ts", "../src/utils.ts", "../src/math.ts", "../src/fps.ts", "../src/timer.ts"], "sourcesContent": ["import {\n\tvec2,\n\tvec3,\n\tVec3,\n\tRect,\n\tLine,\n\tCircle,\n\tColor,\n\tVec2,\n\tMat4,\n\tQuad,\n\tRNG,\n\tquad,\n\trgb,\n\thsl2rgb,\n\trng,\n\trand,\n\trandi,\n\trandSeed,\n\tchance,\n\tchoose,\n\tclamp,\n\tlerp,\n\tmap,\n\tmapc,\n\twave,\n\ttestAreaRect,\n\ttestAreaLine,\n\ttestAreaCircle,\n\ttestAreaPolygon,\n\ttestAreaPoint,\n\ttestAreaArea,\n\ttestLineLineT,\n\ttestRectRect2,\n\ttestLineLine,\n\ttestRectRect,\n\ttestRectLine,\n\ttestRectPoint,\n\ttestPolygonPoint,\n\ttestLinePolygon,\n\ttestPolygonPolygon,\n\ttestCircleCircle,\n\ttestCirclePoint,\n\ttestRectPolygon,\n\tminkDiff,\n\tdeg2rad,\n\trad2deg,\n} from \"./math\";\n\nimport {\n\tIDList,\n\tdownloadURL,\n\tdownloadBlob,\n\tuid,\n\tdeprecate,\n\tdeprecateMsg,\n\tisDataURL,\n\tdeepEq,\n} from \"./utils\";\n\nimport {\n\tGfxShader,\n\tGfxFont,\n\tTexFilter,\n\tRenderProps,\n\tCharTransform,\n\tCharTransformFunc,\n\tTexWrap,\n\tFormattedText,\n\tFormattedChar,\n\tDrawRectOpt,\n\tDrawLineOpt,\n\tDrawLinesOpt,\n\tDrawTriangleOpt,\n\tDrawPolygonOpt,\n\tDrawCircleOpt,\n\tDrawEllipseOpt,\n\tDrawUVQuadOpt,\n\tVertex,\n\tSpriteData,\n\tSoundData,\n\tFontData,\n\tShaderData,\n\tSpriteLoadSrc,\n\tSpriteLoadOpt,\n\tSpriteAtlasData,\n\tFontLoadOpt,\n\tGfxTexData,\n\tKaboomCtx,\n\tKaboomOpt,\n\tAudioPlay,\n\tAudioPlayOpt,\n\tDrawSpriteOpt,\n\tDrawTextOpt,\n\tGameObj,\n\tEventCanceller,\n\tSceneID,\n\tSceneDef,\n\tCompList,\n\tComp,\n\tTag,\n\tKey,\n\tMouseButton,\n\tTouchID,\n\tCollision,\n\tPosComp,\n\tScaleComp,\n\tRotateComp,\n\tColorComp,\n\tOpacityComp,\n\tOrigin,\n\tOriginComp,\n\tLayerComp,\n\tZComp,\n\tFollowComp,\n\tMoveComp,\n\tOutviewCompOpt,\n\tOutviewComp,\n\tCleanupCompOpt,\n\tCleanupComp,\n\tAreaCompOpt,\n\tAreaComp,\n\tArea,\n\tSpriteComp,\n\tSpriteCompOpt,\n\tGfxTexture,\n\tSpriteAnimPlayOpt,\n\tTextComp,\n\tTextCompOpt,\n\tRectComp,\n\tRectCompOpt,\n\tUVQuadComp,\n\tCircleComp,\n\tOutlineComp,\n\tTimerComp,\n\tBodyComp,\n\tBodyCompOpt,\n\tUniform,\n\tShaderComp,\n\tSolidComp,\n\tFixedComp,\n\tStayComp,\n\tHealthComp,\n\tLifespanComp,\n\tLifespanCompOpt,\n\tStateComp,\n\tDebug,\n\tKaboomPlugin,\n\tMergeObj,\n\tLevel,\n\tLevelOpt,\n\tCursor,\n\tRecording,\n\tKaboom,\n} from \"./types\";\n\nimport FPSCounter from \"./fps\";\nimport Timer from \"./timer\";\n\n// @ts-ignore\nimport apl386Src from \"./assets/apl386.png\";\n// @ts-ignore\nimport apl386oSrc from \"./assets/apl386o.png\";\n// @ts-ignore\nimport sinkSrc from \"./assets/sink.png\";\n// @ts-ignore\nimport sinkoSrc from \"./assets/sinko.png\";\n// @ts-ignore\nimport beanSrc from \"./assets/bean.png\";\n// @ts-ignore\nimport burpBytes from \"./assets/burp.mp3\";\n// @ts-ignore\nimport kaSrc from \"./assets/ka.png\";\n// @ts-ignore\nimport boomSrc from \"./assets/boom.png\";\n\ntype ButtonState =\n\t\"up\"\n\t| \"pressed\"\n\t| \"rpressed\"\n\t| \"down\"\n\t| \"released\"\n\t;\n\ntype DrawTextureOpt = RenderProps & {\n\ttex: GfxTexture,\n\twidth?: number,\n\theight?: number,\n\ttiled?: boolean,\n\tflipX?: boolean,\n\tflipY?: boolean,\n\tquad?: Quad,\n\torigin?: Origin | Vec2,\n}\n\ninterface GfxTexOpt {\n\tfilter?: TexFilter,\n\twrap?: TexWrap,\n}\n\n// translate these key names to a simpler version\nconst KEY_ALIAS = {\n\t\"ArrowLeft\": \"left\",\n\t\"ArrowRight\": \"right\",\n\t\"ArrowUp\": \"up\",\n\t\"ArrowDown\": \"down\",\n\t\" \": \"space\",\n};\n\n// according to https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent/button\nconst MOUSE_BUTTONS = [\n\t\"left\",\n\t\"middle\",\n\t\"right\",\n\t\"back\",\n\t\"forward\",\n];\n\n// don't trigger browser default event when these keys are pressed\nconst PREVENT_DEFAULT_KEYS = [\n\t\"space\",\n\t\"left\",\n\t\"right\",\n\t\"up\",\n\t\"down\",\n\t\"tab\",\n\t\"f1\",\n\t\"f2\",\n\t\"f3\",\n\t\"f4\",\n\t\"f5\",\n\t\"f6\",\n\t\"f7\",\n\t\"f8\",\n\t\"f9\",\n\t\"f10\",\n\t\"f11\",\n\t\"s\",\n];\n\n// some default charsets for loading bitmap fonts\nconst ASCII_CHARS = \" !\\\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\\\]^_`abcdefghijklmnopqrstuvwxyz{|}~\";\nconst CP437_CHARS = \" \u263A\u263B\u2665\u2666\u2663\u2660\u2022\u25D8\u25CB\u25D9\u2642\u2640\u266A\u266B\u263C\u25BA\u25C4\u2195\u203C\u00B6\u00A7\u25AC\u21A8\u2191\u2193\u2192\u2190\u221F\u2194\u25B2\u25BC !\\\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\\\]^_`abcdefghijklmnopqrstuvwxyz{|}~\u2302\u00C7\u00FC\u00E9\u00E2\u00E4\u00E0\u00E5\u00E7\u00EA\u00EB\u00E8\u00EF\u00EE\u00EC\u00C4\u00C5\u00C9\u00E6\u00C6\u00F4\u00F6\u00F2\u00FB\u00F9\u00FF\u00D6\u00DC\u00A2\u00A3\u00A5\u20A7\u0192\u00E1\u00ED\u00F3\u00FA\u00F1\u00D1\u00AA\u00BA\u00BF\u2310\u00AC\u00BD\u00BC\u00A1\u00AB\u00BB\u2591\u2592\u2593\u2502\u2524\u2561\u2562\u2556\u2555\u2563\u2551\u2557\u255D\u255C\u255B\u2510\u2514\u2534\u252C\u251C\u2500\u253C\u255E\u255F\u255A\u2554\u2569\u2566\u2560\u2550\u256C\u2567\u2568\u2564\u2565\u2559\u2558\u2552\u2553\u256B\u256A\u2518\u250C\u2588\u2584\u258C\u2590\u2580\u03B1\u00DF\u0393\u03C0\u03A3\u03C3\u00B5\u03C4\u03A6\u0398\u03A9\u03B4\u221E\u03C6\u03B5\u2229\u2261\u00B1\u2265\u2264\u2320\u2321\u00F7\u2248\u00B0\u2219\u00B7\u221A\u207F\u00B2\u25A0\";\n\n// audio gain range\nconst MIN_GAIN = 0;\nconst MAX_GAIN = 3;\n\n// audio speed range\nconst MIN_SPEED = 0;\nconst MAX_SPEED = 3;\n\n// audio detune range\nconst MIN_DETUNE = -1200;\nconst MAX_DETUNE = 1200;\n\nconst DEF_ORIGIN = \"topleft\";\nconst DEF_GRAVITY = 1600;\nconst QUEUE_COUNT = 65536;\nconst BG_GRID_SIZE = 64;\n\nconst DEF_FONT = \"apl386o\";\nconst DBG_FONT = \"sink\";\n\n// vertex format stride (vec3 pos, vec2 uv, vec4 color)\nconst STRIDE = 9;\n\n// vertex shader template, replace {{user}} with user vertex shader code\nconst VERT_TEMPLATE = `\nattribute vec3 a_pos;\nattribute vec2 a_uv;\nattribute vec4 a_color;\n\nvarying vec3 v_pos;\nvarying vec2 v_uv;\nvarying vec4 v_color;\n\nvec4 def_vert() {\n\treturn vec4(a_pos, 1.0);\n}\n\n{{user}}\n\nvoid main() {\n\tvec4 pos = vert(a_pos, a_uv, a_color);\n\tv_pos = a_pos;\n\tv_uv = a_uv;\n\tv_color = a_color;\n\tgl_Position = pos;\n}\n`;\n\n// fragment shader template, replace {{user}} with user fragment shader code\nconst FRAG_TEMPLATE = `\nprecision mediump float;\n\nvarying vec3 v_pos;\nvarying vec2 v_uv;\nvarying vec4 v_color;\n\nuniform sampler2D u_tex;\n\nvec4 def_frag() {\n\treturn v_color * texture2D(u_tex, v_uv);\n}\n\n{{user}}\n\nvoid main() {\n\tgl_FragColor = frag(v_pos, v_uv, v_color, u_tex);\n\tif (gl_FragColor.a == 0.0) {\n\t\tdiscard;\n\t}\n}\n`;\n\n// default {{user}} vertex shader code\nconst DEF_VERT = `\nvec4 vert(vec3 pos, vec2 uv, vec4 color) {\n\treturn def_vert();\n}\n`;\n\n// default {{user}} fragment shader code\nconst DEF_FRAG = `\nvec4 frag(vec3 pos, vec2 uv, vec4 color, sampler2D tex) {\n\treturn def_frag();\n}\n`;\n\nconst COMP_DESC = new Set([\n\t\"id\",\n\t\"require\",\n]);\n\nconst COMP_EVENTS = new Set([\n\t\"add\",\n\t\"load\",\n\t\"update\",\n\t\"draw\",\n\t\"destroy\",\n\t\"inspect\",\n]);\n\n// transform the button state to the next state\n// e.g. a button might become \"pressed\" one frame, and it should become \"down\" next frame\nfunction processButtonState(s: ButtonState): ButtonState {\n\tif (s === \"pressed\" || s === \"rpressed\") {\n\t\treturn \"down\";\n\t}\n\tif (s === \"released\") {\n\t\treturn \"up\";\n\t}\n\treturn s;\n}\n\n// wrappers around full screen functions to work across browsers\nfunction enterFullscreen(el: HTMLElement) {\n\tif (el.requestFullscreen) el.requestFullscreen();\n\t// @ts-ignore\n\telse if (el.webkitRequestFullscreen) el.webkitRequestFullscreen();\n};\n\nfunction exitFullscreen() {\n\tif (document.exitFullscreen) document.exitFullscreen();\n\t// @ts-ignore\n\telse if (document.webkitExitFullScreen) document.webkitExitFullScreen();\n};\n\nfunction getFullscreenElement(): Element | void {\n\treturn document.fullscreenElement\n\t\t// @ts-ignore\n\t\t|| document.webkitFullscreenElement\n\t\t;\n};\n\n// convert origin string to a vec2 offset\nfunction originPt(orig: Origin | Vec2): Vec2 {\n\tswitch (orig) {\n\t\tcase \"topleft\": return vec2(-1, -1);\n\t\tcase \"top\": return vec2(0, -1);\n\t\tcase \"topright\": return vec2(1, -1);\n\t\tcase \"left\": return vec2(-1, 0);\n\t\tcase \"center\": return vec2(0, 0);\n\t\tcase \"right\": return vec2(1, 0);\n\t\tcase \"botleft\": return vec2(-1, 1);\n\t\tcase \"bot\": return vec2(0, 1);\n\t\tcase \"botright\": return vec2(1, 1);\n\t\tdefault: return orig;\n\t}\n}\n\nfunction createEmptyAudioBuffer() {\n\treturn new AudioBuffer({\n\t\tlength: 1,\n\t\tnumberOfChannels: 1,\n\t\tsampleRate: 44100\n\t});\n}\n\n// only exports one kaboom() which contains all the state\nexport default (gopt: KaboomOpt = {}): KaboomCtx => {\n\nconst app = (() => {\n\n\tconst root = gopt.root ?? document.body;\n\n\t// if root is not defined (which falls back to ) we assume user is using kaboom on a clean page, and modify to better fit a full screen canvas\n\tif (root === document.body) {\n\t\tdocument.body.style[\"width\"] = \"100%\";\n\t\tdocument.body.style[\"height\"] = \"100%\";\n\t\tdocument.body.style[\"margin\"] = \"0px\";\n\t\tdocument.documentElement.style[\"width\"] = \"100%\";\n\t\tdocument.documentElement.style[\"height\"] = \"100%\";\n\t}\n\n\t// create a if user didn't provide one\n\tconst canvas = gopt.canvas ?? (() => {\n\t\tconst canvas = document.createElement(\"canvas\");\n\t\troot.appendChild(canvas);\n\t\treturn canvas;\n\t})();\n\n\t// global pixel scale\n\tconst gscale = gopt.scale ?? 1;\n\n\t// adjust canvas size according to user size / viewport settings\n\tif (gopt.width && gopt.height && !gopt.stretch && !gopt.letterbox) {\n\t\tcanvas.width = gopt.width * gscale;\n\t\tcanvas.height = gopt.height * gscale;\n\t} else {\n\t\tcanvas.width = canvas.parentElement.offsetWidth;\n\t\tcanvas.height = canvas.parentElement.offsetHeight;\n\t}\n\n\t// canvas css styles\n\tconst styles = [\n\t\t\"outline: none\",\n\t\t\"cursor: default\",\n\t];\n\n\tif (gopt.crisp) {\n\t\tstyles.push(\"image-rendering: pixelated\");\n\t\tstyles.push(\"image-rendering: crisp-edges\");\n\t}\n\n\t// TODO: .style is supposed to be readonly? alternative?\n\t// @ts-ignore\n\tcanvas.style = styles.join(\";\");\n\n\t// make canvas focusable\n\tcanvas.setAttribute(\"tabindex\", \"0\");\n\n\t// create webgl context\n\tconst gl = canvas\n\t\t.getContext(\"webgl\", {\n\t\t\tantialias: true,\n\t\t\tdepth: true,\n\t\t\tstencil: true,\n\t\t\talpha: true,\n\t\t\tpreserveDrawingBuffer: true,\n\t\t});\n\n\treturn {\n\n\t\tcanvas: canvas,\n\t\tscale: gscale,\n\t\tgl: gl,\n\n\t\t// keep track of all button states\n\t\tkeyStates: {} as Record,\n\t\tmouseStates: {} as Record,\n\n\t\t// input states from last frame, should reset every frame\n\t\tcharInputted: [],\n\t\tisMouseMoved: false,\n\t\tisKeyPressed: false,\n\t\tisKeyPressedRepeat: false,\n\t\tisKeyReleased: false,\n\t\tmousePos: vec2(0, 0),\n\t\tmouseDeltaPos: vec2(0, 0),\n\n\t\t// total time elapsed\n\t\ttime: 0,\n\t\t// real total time elapsed (including paused time)\n\t\trealTime: 0,\n\t\t// if we should skip next dt, to prevent the massive dt surge if user switch to another tab for a while and comeback\n\t\tskipTime: false,\n\t\t// how much time last frame took\n\t\tdt: 0.0,\n\t\t// total frames elapsed\n\t\tnumFrames: 0,\n\n\t\t// if we're on a touch device\n\t\tisTouch: (\"ontouchstart\" in window) || navigator.maxTouchPoints > 0,\n\n\t\t// requestAnimationFrame id\n\t\tloopID: null,\n\t\t// if our game loop is currently stopped / paused\n\t\tstopped: false,\n\t\tpaused: false,\n\n\t\t// TODO: take fps counter out pure\n\t\tfpsCounter: new FPSCounter(),\n\n\t\t// if we finished loading all assets\n\t\tloaded: false,\n\n\t};\n\n})();\n\nconst gfx = (() => {\n\n\tconst gl = app.gl;\n\tconst defShader = makeShader(DEF_VERT, DEF_FRAG);\n\n\t// a 1x1 white texture to draw raw shapes like rectangles and polygons\n\t// we use a texture for those so we can use only 1 pipeline for drawing sprites + shapes\n\tconst emptyTex = makeTex(\n\t\tnew ImageData(new Uint8ClampedArray([ 255, 255, 255, 255, ]), 1, 1)\n\t);\n\n\tconst c = gopt.background ?? rgb(0, 0, 0);\n\n\tif (gopt.background) {\n\t\tconst c = Color.fromArray(gopt.background);\n\t\tgl.clearColor(c.r / 255, c.g / 255, c.b / 255, 1);\n\t}\n\n\tgl.enable(gl.BLEND);\n\tgl.enable(gl.SCISSOR_TEST);\n\tgl.blendFuncSeparate(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA, gl.ONE, gl.ONE_MINUS_SRC_ALPHA);\n\n\t// we only use one vertex and index buffer that batches all draw calls\n\tconst vbuf = gl.createBuffer();\n\n\tgl.bindBuffer(gl.ARRAY_BUFFER, vbuf);\n\t// vec3 pos\n\tgl.vertexAttribPointer(0, 3, gl.FLOAT, false, STRIDE * 4, 0);\n\tgl.enableVertexAttribArray(0);\n\t// vec2 uv\n\tgl.vertexAttribPointer(1, 2, gl.FLOAT, false, STRIDE * 4, 12);\n\tgl.enableVertexAttribArray(1);\n\t// vec4 color\n\tgl.vertexAttribPointer(2, 4, gl.FLOAT, false, STRIDE * 4, 20);\n\tgl.enableVertexAttribArray(2);\n\tgl.bufferData(gl.ARRAY_BUFFER, QUEUE_COUNT * 4, gl.DYNAMIC_DRAW);\n\tgl.bindBuffer(gl.ARRAY_BUFFER, null);\n\n\tconst ibuf = gl.createBuffer();\n\n\tgl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, ibuf);\n\tgl.bufferData(gl.ELEMENT_ARRAY_BUFFER, QUEUE_COUNT * 2, gl.DYNAMIC_DRAW);\n\tgl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, null);\n\n\t// a checkerboard texture used for the default background\n\tconst bgTex = makeTex(\n\t\tnew ImageData(new Uint8ClampedArray([\n\t\t\t128, 128, 128, 255,\n\t\t\t190, 190, 190, 255,\n\t\t\t190, 190, 190, 255,\n\t\t\t128, 128, 128, 255,\n\t\t]), 2, 2), {\n\t\t\twrap: \"repeat\",\n\t\t\tfilter: \"nearest\",\n\t\t},\n\t);\n\n\treturn {\n\n\t\t// keep track of how many draw calls we're doing this frame\n\t\tdrawCalls: 0,\n\t\t// how many draw calls we're doing last frame, this is the number we give to users\n\t\tlastDrawCalls: 0,\n\n\t\t// gfx states\n\t\tdefShader: defShader,\n\t\tcurShader: defShader,\n\t\tdefTex: emptyTex,\n\t\tcurTex: emptyTex,\n\t\tcurUniform: {},\n\t\tvbuf: vbuf,\n\t\tibuf: ibuf,\n\n\t\t// local vertex / index buffer queue\n\t\tvqueue: [],\n\t\tiqueue: [],\n\n\t\ttransform: new Mat4(),\n\t\ttransformStack: [],\n\n\t\tbgTex: bgTex,\n\n\t\twidth: gopt.width,\n\t\theight: gopt.height,\n\n\t\tviewport: {\n\t\t\tx: 0,\n\t\t\ty: 0,\n\t\t\twidth: gl.drawingBufferWidth,\n\t\t\theight: gl.drawingBufferHeight,\n\t\t},\n\n\t};\n\n})();\n\nupdateViewport();\n\nconst audio = (() => {\n\n\t// TODO: handle when audio context is unavailable\n\tconst ctx = new (window.AudioContext || (window as any).webkitAudioContext)() as AudioContext\n\tconst masterNode = ctx.createGain();\n\tmasterNode.connect(ctx.destination);\n\n\t// by default browsers can only load audio async, we don't deal with that and just start with an empty audio buffer\n\tconst burpSnd = {\n\t\tbuf: createEmptyAudioBuffer(),\n\t};\n\n\t// load that burp sound\n\tctx.decodeAudioData(burpBytes.buffer.slice(0), (buf) => {\n\t\tburpSnd.buf = buf;\n\t}, () => {\n\t\tthrow new Error(\"Failed to load burp.\")\n\t});\n\n\treturn {\n\t\tctx,\n\t\tmasterNode,\n\t\tburpSnd,\n\t};\n\n})();\n\nconst assets = {\n\n\t// keep track of how many assets are loading / loaded, for calculaating progress\n\tnumLoading: 0,\n\tnumLoaded: 0,\n\n\t// prefix for when loading from a url\n\turlPrefix: \"\",\n\n\t// asset holders\n\tsprites: {},\n\tsounds: {},\n\tshaders: {},\n\tfonts: {},\n\n};\n\nconst game = {\n\n\t// event callbacks\n\tevents: {},\n\tobjEvents: {},\n\n\t// root game object\n\t// these transforms are used as camera\n\troot: make([]),\n\n\ttimers: new IDList(),\n\n\t// misc\n\tlayers: {},\n\tdefLayer: null,\n\tgravity: DEF_GRAVITY,\n\ton(ev: string, cb: F): EventCanceller {\n\t\tif (!this.events[ev]) {\n\t\t\tthis.events[ev] = new IDList();\n\t\t}\n\t\treturn this.events[ev].pushd(cb);\n\t},\n\ttrigger(ev: string, ...args) {\n\t\tif (this.events[ev]) {\n\t\t\tthis.events[ev].forEach((cb) => cb(...args));\n\t\t}\n\t},\n\tscenes: {},\n\n\t// on screen log\n\tlogs: [],\n\n\t// camera\n\tcam: {\n\t\tpos: center(),\n\t\tscale: vec2(1),\n\t\tangle: 0,\n\t\tshake: 0,\n\t\ttransform: new Mat4(),\n\t},\n\n}\n\n// wrap individual loaders with global loader counter, for stuff like progress bar\nfunction load(prom: Promise): Promise {\n\n\tassets.numLoading++;\n\n\t// wrapping another layer of promise because we are catching errors here internally and we also want users be able to catch errors, however only one catch is allowed per promise chain\n\treturn new Promise((resolve, reject) => {\n\t\tprom\n\t\t\t.then(resolve)\n\t\t\t.catch((err) => {\n\t\t\t\tdebug.error(err);\n\t\t\t\treject(err);\n\t\t\t})\n\t\t\t.finally(() => {\n\t\t\t\tassets.numLoading--;\n\t\t\t\tassets.numLoaded++;\n\t\t\t});\n\t}) as Promise;\n\n}\n\n// get current load progress\nfunction loadProgress(): number {\n\treturn assets.numLoaded / (assets.numLoading + assets.numLoaded);\n}\n\n// global load path prefix\nfunction loadRoot(path?: string): string {\n\tif (path !== undefined) {\n\t\tassets.urlPrefix = path;\n\t}\n\treturn assets.urlPrefix;\n}\n\n// wrapper around fetch() that applies urlPrefix and basic error handling\nfunction fetchURL(path: string) {\n\tconst url = assets.urlPrefix + path;\n\treturn fetch(url)\n\t\t.then((res) => {\n\t\t\tif (!res.ok) {\n\t\t\t\tthrow new Error(`Failed to fetch ${url}`);\n\t\t\t}\n\t\t\treturn res;\n\t\t});\n}\n\n// wrapper around image loader to get a Promise\nfunction loadImg(src: string): Promise {\n\tconst img = new Image();\n\timg.src = isDataURL(src) ? src : assets.urlPrefix + src;\n\timg.crossOrigin = \"anonymous\";\n\treturn new Promise((resolve, reject) => {\n\t\timg.onload = () => resolve(img);\n\t\t// TODO: truncate for long dataurl src\n\t\timg.onerror = () => reject(`Failed to load image from \"${src}\"`);\n\t});\n}\n\n// TODO: support SpriteLoadSrc\nfunction loadFont(\n\tname: string | null,\n\tsrc: string,\n\tgw: number,\n\tgh: number,\n\topt: FontLoadOpt = {},\n): Promise {\n\treturn load(loadImg(src)\n\t\t.then((img) => {\n\t\t\tconst font = makeFont(\n\t\t\t\tmakeTex(img, opt),\n\t\t\t\tgw,\n\t\t\t\tgh,\n\t\t\t\topt.chars ?? ASCII_CHARS\n\t\t\t);\n\t\t\tif (name) {\n\t\t\t\tassets.fonts[name] = font;\n\t\t\t}\n\t\t\treturn font;\n\t\t})\n\t);\n}\n\nfunction getSprite(name: string): SpriteData | null {\n\treturn assets.sprites[name] ?? null;\n}\n\nfunction getSound(name: string): SoundData | null {\n\treturn assets.sounds[name] ?? null;\n}\n\nfunction getFont(name: string): FontData | null {\n\treturn assets.fonts[name] ?? null;\n}\n\nfunction getShader(name: string): ShaderData | null {\n\treturn assets.shaders[name] ?? null;\n}\n\n// get an array of frames based on configuration on how to slice the image\nfunction slice(x = 1, y = 1, dx = 0, dy = 0, w = 1, h = 1): Quad[] {\n\tconst frames = [];\n\tconst qw = w / x;\n\tconst qh = h / y;\n\tfor (let j = 0; j < y; j++) {\n\t\tfor (let i = 0; i < x; i++) {\n\t\t\tframes.push(new Quad(\n\t\t\t\tdx + i * qw,\n\t\t\t\tdy + j * qh,\n\t\t\t\tqw,\n\t\t\t\tqh,\n\t\t\t));\n\t\t}\n\t}\n\treturn frames;\n}\n\nfunction loadSpriteAtlas(\n\tsrc: SpriteLoadSrc,\n\tdata: SpriteAtlasData | string\n): Promise> {\n\tif (typeof data === \"string\") {\n\t\t// TODO: this adds a new loader asyncly\n\t\treturn load(fetchURL(data)\n\t\t\t.then((res) => res.json())\n\t\t\t.then((data2) => loadSpriteAtlas(src, data2)));\n\t}\n\treturn load(loadSprite(null, src).then((atlas) => {\n\t\tconst map = {};\n\t\tconst w = atlas.tex.width;\n\t\tconst h = atlas.tex.height;\n\t\tfor (const name in data) {\n\t\t\tconst info = data[name];\n\t\t\tconst spr = {\n\t\t\t\ttex: atlas.tex,\n\t\t\t\tframes: slice(info.sliceX, info.sliceY, info.x / w, info.y / h, info.width / w, info.height / h),\n\t\t\t\tanims: info.anims,\n\t\t\t}\n\t\t\tassets.sprites[name] = spr;\n\t\t\tmap[name] = spr;\n\t\t}\n\t\treturn map;\n\t}));\n}\n\n// synchronously load sprite from local pixel data\nfunction loadRawSprite(\n\tname: string | null,\n\tsrc: GfxTexData,\n\topt: SpriteLoadOpt = {}\n) {\n\n\tconst tex = makeTex(src, opt);\n\tconst frames = slice(opt.sliceX || 1, opt.sliceY || 1);\n\n\tconst sprite = {\n\t\ttex: tex,\n\t\tframes: frames,\n\t\tanims: opt.anims || {},\n\t};\n\n\tif (name) {\n\t\tassets.sprites[name] = sprite;\n\t}\n\n\treturn sprite;\n\n}\n\n// load a sprite to asset manager\nfunction loadSprite(\n\tname: string | null,\n\tsrc: SpriteLoadSrc,\n\topt: SpriteLoadOpt = {\n\t\tsliceX: 1,\n\t\tsliceY: 1,\n\t\tanims: {},\n\t},\n): Promise {\n\n\treturn load(new Promise((resolve, reject) => {\n\n\t\tif (!src) {\n\t\t\treturn reject(`Expected sprite src for \"${name}\"`);\n\t\t}\n\n\t\t// from url\n\t\tif (typeof(src) === \"string\") {\n\t\t\tloadImg(src)\n\t\t\t\t.then((img) => resolve(loadRawSprite(name, img, opt)))\n\t\t\t\t.catch(reject);\n\t\t} else {\n\t\t\tresolve(loadRawSprite(name, src, opt));\n\t\t}\n\n\t}));\n\n}\n\n// TODO: accept raw json\nfunction loadPedit(name: string, src: string): Promise {\n\n\treturn load(new Promise((resolve, reject) => {\n\n\t\tfetchURL(src)\n\t\t\t.then((res) => res.json())\n\t\t\t.then(async (data) => {\n\n\t\t\t\tconst images = await Promise.all(data.frames.map(loadImg));\n\t\t\t\tconst canvas = document.createElement(\"canvas\");\n\t\t\t\tcanvas.width = data.width;\n\t\t\t\tcanvas.height = data.height * data.frames.length;\n\n\t\t\t\tconst ctx = canvas.getContext(\"2d\");\n\n\t\t\t\timages.forEach((img: HTMLImageElement, i) => {\n\t\t\t\t\tctx.drawImage(img, 0, i * data.height);\n\t\t\t\t});\n\n\t\t\t\treturn loadSprite(name, canvas, {\n\t\t\t\t\tsliceY: data.frames.length,\n\t\t\t\t\tanims: data.anims,\n\t\t\t\t});\n\t\t\t})\n\t\t\t.then(resolve)\n\t\t\t.catch(reject)\n\t\t\t;\n\n\t}));\n\n}\n\n// TODO: accept raw json\nfunction loadAseprite(\n\tname: string | null,\n\timgSrc: SpriteLoadSrc,\n\tjsonSrc: string\n): Promise {\n\n\treturn load(new Promise((resolve, reject) => {\n\n\t\tloadSprite(name, imgSrc)\n\t\t\t.then((sprite: SpriteData) => {\n\t\t\t\tfetchURL(jsonSrc)\n\t\t\t\t\t.then((res) => res.json())\n\t\t\t\t\t.then((data) => {\n\t\t\t\t\t\tconst size = data.meta.size;\n\t\t\t\t\t\tsprite.frames = data.frames.map((f: any) => {\n\t\t\t\t\t\t\treturn new Quad(\n\t\t\t\t\t\t\t\tf.frame.x / size.w,\n\t\t\t\t\t\t\t\tf.frame.y / size.h,\n\t\t\t\t\t\t\t\tf.frame.w / size.w,\n\t\t\t\t\t\t\t\tf.frame.h / size.h,\n\t\t\t\t\t\t\t);\n\t\t\t\t\t\t});\n\t\t\t\t\t\tfor (const anim of data.meta.frameTags) {\n\t\t\t\t\t\t\tif (anim.from === anim.to) {\n\t\t\t\t\t\t\t\tsprite.anims[anim.name] = anim.from\n\t\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\t\tsprite.anims[anim.name] = {\n\t\t\t\t\t\t\t\t\tfrom: anim.from,\n\t\t\t\t\t\t\t\t\tto: anim.to,\n\t\t\t\t\t\t\t\t\t// TODO: let users define these\n\t\t\t\t\t\t\t\t\tspeed: 10,\n\t\t\t\t\t\t\t\t\tloop: true,\n\t\t\t\t\t\t\t\t};\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t\tresolve(sprite);\n\t\t\t\t\t})\n\t\t\t\t\t.catch(reject)\n\t\t\t\t\t;\n\t\t\t})\n\t\t\t.catch(reject);\n\n\t}));\n\n}\n\nfunction loadShader(\n\tname: string | null,\n\tvert?: string,\n\tfrag?: string,\n\tisUrl: boolean = false,\n): Promise {\n\n\tfunction loadRawShader(\n\t\tname: string | null,\n\t\tvert: string | null,\n\t\tfrag: string | null,\n\t): ShaderData {\n\t\tconst shader = makeShader(vert, frag);\n\t\tif (name) {\n\t\t\tassets.shaders[name] = shader;\n\t\t}\n\t\treturn shader;\n\t}\n\n\treturn load(new Promise((resolve, reject) => {\n\n\t\tif (!vert && !frag) {\n\t\t\treturn reject(\"no shader\");\n\t\t}\n\n\t\tfunction resolveUrl(url?: string) {\n\t\t\treturn url ?\n\t\t\t\tfetchURL(url)\n\t\t\t\t\t.then((res) => res.text())\n\t\t\t\t\t.catch(reject)\n\t\t\t\t: new Promise((r) => r(null));\n\t\t}\n\n\t\tif (isUrl) {\n\t\t\tPromise.all([resolveUrl(vert), resolveUrl(frag)])\n\t\t\t\t.then(([vcode, fcode]: [string | null, string | null]) => {\n\t\t\t\t\tresolve(loadRawShader(name, vcode, fcode));\n\t\t\t\t})\n\t\t\t\t.catch(reject);\n\t\t} else {\n\t\t\ttry {\n\t\t\t\tresolve(loadRawShader(name, vert, frag));\n\t\t\t} catch (err) {\n\t\t\t\treject(err);\n\t\t\t}\n\t\t}\n\n\t}));\n\n}\n\n// TODO: accept dataurl\n// load a sound to asset manager\nfunction loadSound(\n\tname: string | null,\n\tsrc: string,\n): Promise {\n\n\treturn load(new Promise((resolve, reject) => {\n\n\t\tif (!src) {\n\t\t\treturn reject(`expected sound src for \"${name}\"`);\n\t\t}\n\n\t\t// from url\n\t\tif (typeof(src) === \"string\") {\n\t\t\tfetchURL(src)\n\t\t\t\t.then((res) => res.arrayBuffer())\n\t\t\t\t.then((data) => {\n\t\t\t\t\treturn new Promise((resolve2, reject2) =>\n\t\t\t\t\t\taudio.ctx.decodeAudioData(data, resolve2, reject2)\n\t\t\t\t\t);\n\t\t\t\t})\n\t\t\t\t.then((buf: AudioBuffer) => {\n\t\t\t\t\tconst snd = {\n\t\t\t\t\t\tbuf: buf,\n\t\t\t\t\t}\n\t\t\t\t\tif (name) {\n\t\t\t\t\t\tassets.sounds[name] = snd;\n\t\t\t\t\t}\n\t\t\t\t\tresolve(snd);\n\t\t\t\t})\n\t\t\t\t.catch(reject);\n\t\t}\n\n\t}));\n\n}\n\nfunction loadBean(name: string = \"bean\"): Promise {\n\treturn loadSprite(name, beanSrc);\n}\n\n// get / set master volume\nfunction volume(v?: number): number {\n\tif (v !== undefined) {\n\t\taudio.masterNode.gain.value = clamp(v, MIN_GAIN, MAX_GAIN);\n\t}\n\treturn audio.masterNode.gain.value;\n}\n\n// plays a sound, returns a control handle\nfunction play(\n\tsrc: SoundData | string,\n\topt: AudioPlayOpt = {\n\t\tloop: false,\n\t\tvolume: 1,\n\t\tspeed: 1,\n\t\tdetune: 0,\n\t\tseek: 0,\n\t},\n): AudioPlay {\n\n\t// TODO: clean?\n\tif (typeof src === \"string\") {\n\n\t\tconst pb = play({\n\t\t\tbuf: createEmptyAudioBuffer(),\n\t\t});\n\n\t\tonLoad(() => {\n\t\t\tconst snd = assets.sounds[src];\n\t\t\tif (!snd) {\n\t\t\t\tthrow new Error(`Sound not found: \"${src}\"`);\n\t\t\t}\n\t\t\tconst pb2 = play(snd, opt);\n\t\t\tfor (const k in pb2) {\n\t\t\t\tpb[k] = pb2[k];\n\t\t\t}\n\t\t});\n\n\t\treturn pb;\n\n\t}\n\n\tconst ctx = audio.ctx;\n\tlet stopped = false;\n\tlet srcNode = ctx.createBufferSource();\n\n\tsrcNode.buffer = src.buf;\n\tsrcNode.loop = opt.loop ? true : false;\n\n\tconst gainNode = ctx.createGain();\n\n\tsrcNode.connect(gainNode);\n\tgainNode.connect(audio.masterNode);\n\n\tconst pos = opt.seek ?? 0;\n\n\tsrcNode.start(0, pos);\n\n\tlet startTime = ctx.currentTime - pos;\n\tlet stopTime: number | null = null;\n\n\tconst handle = {\n\n\t\tstop() {\n\t\t\tif (stopped) {\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tthis.pause();\n\t\t\tstartTime = ctx.currentTime;\n\t\t},\n\n\t\tplay(seek?: number) {\n\n\t\t\tif (!stopped) {\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tconst oldNode = srcNode;\n\n\t\t\tsrcNode = ctx.createBufferSource();\n\t\t\tsrcNode.buffer = oldNode.buffer;\n\t\t\tsrcNode.loop = oldNode.loop;\n\t\t\tsrcNode.playbackRate.value = oldNode.playbackRate.value;\n\n\t\t\tif (srcNode.detune) {\n\t\t\t\tsrcNode.detune.value = oldNode.detune.value;\n\t\t\t}\n\n\t\t\tsrcNode.connect(gainNode);\n\n\t\t\tconst pos = seek ?? this.time();\n\n\t\t\tsrcNode.start(0, pos);\n\t\t\tstartTime = ctx.currentTime - pos;\n\t\t\tstopped = false;\n\t\t\tstopTime = null;\n\n\t\t},\n\n\t\tpause() {\n\t\t\tif (stopped) {\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tsrcNode.stop();\n\t\t\tstopped = true;\n\t\t\tstopTime = ctx.currentTime;\n\t\t},\n\n\t\tisPaused(): boolean {\n\t\t\treturn stopped;\n\t\t},\n\n\t\tpaused(): boolean {\n\t\t\tdeprecateMsg(\"paused()\", \"isPaused()\");\n\t\t\treturn this.isPaused();\n\t\t},\n\n\t\tisStopped(): boolean {\n\t\t\treturn stopped;\n\t\t},\n\n\t\tstopped(): boolean {\n\t\t\tdeprecateMsg(\"stopped()\", \"isStopped()\");\n\t\t\treturn this.isStopped();\n\t\t},\n\n\t\t// TODO: affect time()\n\t\tspeed(val?: number): number {\n\t\t\tif (val !== undefined) {\n\t\t\t\tsrcNode.playbackRate.value = clamp(val, MIN_SPEED, MAX_SPEED);\n\t\t\t}\n\t\t\treturn srcNode.playbackRate.value;\n\t\t},\n\n\t\tdetune(val?: number): number {\n\t\t\tif (!srcNode.detune) {\n\t\t\t\treturn 0;\n\t\t\t}\n\t\t\tif (val !== undefined) {\n\t\t\t\tsrcNode.detune.value = clamp(val, MIN_DETUNE, MAX_DETUNE);\n\t\t\t}\n\t\t\treturn srcNode.detune.value;\n\t\t},\n\n\t\tvolume(val?: number): number {\n\t\t\tif (val !== undefined) {\n\t\t\t\tgainNode.gain.value = clamp(val, MIN_GAIN, MAX_GAIN);\n\t\t\t}\n\t\t\treturn gainNode.gain.value;\n\t\t},\n\n\t\tloop() {\n\t\t\tsrcNode.loop = true;\n\t\t},\n\n\t\tunloop() {\n\t\t\tsrcNode.loop = false;\n\t\t},\n\n\t\tduration(): number {\n\t\t\treturn src.buf.duration;\n\t\t},\n\n\t\ttime(): number {\n\t\t\tif (stopped) {\n\t\t\t\treturn stopTime - startTime;\n\t\t\t} else {\n\t\t\t\treturn ctx.currentTime - startTime;\n\t\t\t}\n\t\t},\n\n\t};\n\n\thandle.speed(opt.speed);\n\thandle.detune(opt.detune);\n\thandle.volume(opt.volume);\n\n\treturn handle;\n\n}\n\n// core kaboom logic\nfunction burp(opt?: AudioPlayOpt): AudioPlay {\n\treturn play(audio.burpSnd, opt);\n}\n\n// TODO: take these webgl structures out pure\nfunction makeTex(\n\tdata: GfxTexData,\n\topt: GfxTexOpt = {}\n): GfxTexture {\n\n\tconst gl = app.gl;\n\tconst id = gl.createTexture();\n\n\tgl.bindTexture(gl.TEXTURE_2D, id);\n\tgl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, data);\n\n\tconst filter = (() => {\n\t\tswitch (opt.filter ?? gopt.texFilter) {\n\t\t\tcase \"linear\": return gl.LINEAR;\n\t\t\tcase \"nearest\": return gl.NEAREST;\n\t\t\tdefault: return gl.NEAREST;\n\t\t}\n\t})();\n\n\tconst wrap = (() => {\n\t\tswitch (opt.wrap) {\n\t\t\tcase \"repeat\": return gl.REPEAT;\n\t\t\tcase \"clampToEdge\": return gl.CLAMP_TO_EDGE;\n\t\t\tdefault: return gl.CLAMP_TO_EDGE;\n\t\t}\n\t})();\n\n\tgl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, filter);\n\tgl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, filter);\n\tgl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, wrap);\n\tgl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, wrap);\n\tgl.bindTexture(gl.TEXTURE_2D, null);\n\n\treturn {\n\t\twidth: data.width,\n\t\theight: data.height,\n\t\tbind() {\n\t\t\tgl.bindTexture(gl.TEXTURE_2D, id);\n\t\t},\n\t\tunbind() {\n\t\t\tgl.bindTexture(gl.TEXTURE_2D, null);\n\t\t},\n\t};\n\n}\n\nfunction makeShader(\n\tvertSrc: string | null = DEF_VERT,\n\tfragSrc: string | null = DEF_FRAG,\n): GfxShader {\n\n\tconst gl = app.gl;\n\tlet msg;\n\tconst vcode = VERT_TEMPLATE.replace(\"{{user}}\", vertSrc ?? DEF_VERT);\n\tconst fcode = FRAG_TEMPLATE.replace(\"{{user}}\", fragSrc ?? DEF_FRAG);\n\tconst vertShader = gl.createShader(gl.VERTEX_SHADER);\n\tconst fragShader = gl.createShader(gl.FRAGMENT_SHADER);\n\n\tgl.shaderSource(vertShader, vcode);\n\tgl.shaderSource(fragShader, fcode);\n\tgl.compileShader(vertShader);\n\tgl.compileShader(fragShader);\n\n\tif ((msg = gl.getShaderInfoLog(vertShader))) {\n\t\tthrow new Error(msg);\n\t}\n\n\tif ((msg = gl.getShaderInfoLog(fragShader))) {\n\t\tthrow new Error(msg);\n\t}\n\n\tconst id = gl.createProgram();\n\n\tgl.attachShader(id, vertShader);\n\tgl.attachShader(id, fragShader);\n\n\tgl.bindAttribLocation(id, 0, \"a_pos\");\n\tgl.bindAttribLocation(id, 1, \"a_uv\");\n\tgl.bindAttribLocation(id, 2, \"a_color\");\n\n\tgl.linkProgram(id);\n\n\tif ((msg = gl.getProgramInfoLog(id))) {\n\t\t// for some reason on safari it always has a \"\\n\" msg\n\t\tif (msg !== \"\\n\") {\n\t\t\tthrow new Error(msg);\n\t\t}\n\t}\n\n\treturn {\n\n\t\tbind() {\n\t\t\tgl.useProgram(id);\n\t\t},\n\n\t\tunbind() {\n\t\t\tgl.useProgram(null);\n\t\t},\n\n\t\tsend(uniform: Uniform) {\n\t\t\tthis.bind();\n\t\t\tfor (const name in uniform) {\n\t\t\t\tconst val = uniform[name];\n\t\t\t\tconst loc = gl.getUniformLocation(id, name);\n\t\t\t\tif (typeof val === \"number\") {\n\t\t\t\t\tgl.uniform1f(loc, val);\n\t\t\t\t} else if (val instanceof Mat4) {\n\t\t\t\t\t// @ts-ignore\n\t\t\t\t\tgl.uniformMatrix4fv(loc, false, new Float32Array(val.m));\n\t\t\t\t} else if (val instanceof Color) {\n\t\t\t\t\t// @ts-ignore\n\t\t\t\t\tgl.uniform4f(loc, val.r, val.g, val.b, val.a);\n\t\t\t\t} else if (val instanceof Vec3) {\n\t\t\t\t\t// @ts-ignore\n\t\t\t\t\tgl.uniform3f(loc, val.x, val.y, val.z);\n\t\t\t\t} else if (val instanceof Vec2) {\n\t\t\t\t\t// @ts-ignore\n\t\t\t\t\tgl.uniform2f(loc, val.x, val.y);\n\t\t\t\t}\n\t\t\t}\n\t\t\tthis.unbind();\n\t\t},\n\n\t};\n\n}\n\nfunction makeFont(\n\ttex: GfxTexture,\n\tgw: number,\n\tgh: number,\n\tchars: string,\n): GfxFont {\n\n\tconst cols = tex.width / gw;\n\tconst rows = tex.height / gh;\n\tconst qw = 1.0 / cols;\n\tconst qh = 1.0 / rows;\n\tconst map: Record = {};\n\tconst charMap = chars.split(\"\").entries();\n\n\tfor (const [i, ch] of charMap) {\n\t\tmap[ch] = vec2(\n\t\t\t(i % cols) * qw,\n\t\t\tMath.floor(i / cols) * qh,\n\t\t);\n\t}\n\n\treturn {\n\t\ttex: tex,\n\t\tmap: map,\n\t\tqw: qw,\n\t\tqh: qh,\n\t};\n\n}\n\n// TODO: expose\nfunction drawRaw(\n\tverts: Vertex[],\n\tindices: number[],\n\tfixed: boolean,\n\ttex: GfxTexture = gfx.defTex,\n\tshader: GfxShader = gfx.defShader,\n\tuniform: Uniform = {},\n) {\n\n\ttex = tex ?? gfx.defTex;\n\tshader = shader ?? gfx.defShader;\n\n\t// flush on texture / shader change and overflow\n\tif (\n\t\ttex !== gfx.curTex\n\t\t|| shader !== gfx.curShader\n\t\t|| !deepEq(gfx.curUniform, uniform)\n\t\t|| gfx.vqueue.length + verts.length * STRIDE > QUEUE_COUNT\n\t\t|| gfx.iqueue.length + indices.length > QUEUE_COUNT\n\t) {\n\t\tflush();\n\t}\n\n\tfor (const v of verts) {\n\n\t\t// TODO: cache camTransform * gfxTransform?\n\t\tconst transform = fixed ? gfx.transform : game.cam.transform.mult(gfx.transform);\n\n\t\t// normalized world space coordinate [-1.0 ~ 1.0]\n\t\tconst pt = screen2ndc(transform.multVec2(v.pos.xy()));\n\n\t\tgfx.vqueue.push(\n\t\t\tpt.x, pt.y, v.pos.z,\n\t\t\tv.uv.x, v.uv.y,\n\t\t\tv.color.r / 255, v.color.g / 255, v.color.b / 255, v.opacity,\n\t\t);\n\n\t}\n\n\tfor (const i of indices) {\n\t\tgfx.iqueue.push(i + gfx.vqueue.length / STRIDE - verts.length);\n\t}\n\n\tgfx.curTex = tex;\n\tgfx.curShader = shader;\n\tgfx.curUniform = uniform;\n\n}\n\n// draw all batched shapes\nfunction flush() {\n\n\tif (\n\t\t!gfx.curTex\n\t\t|| !gfx.curShader\n\t\t|| gfx.vqueue.length === 0\n\t\t|| gfx.iqueue.length === 0\n\t) {\n\t\treturn;\n\t}\n\n\tconst gl = app.gl;\n\n\tgfx.curShader.send(gfx.curUniform);\n\tgl.bindBuffer(gl.ARRAY_BUFFER, gfx.vbuf);\n\tgl.bufferSubData(gl.ARRAY_BUFFER, 0, new Float32Array(gfx.vqueue));\n\tgl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, gfx.ibuf);\n\tgl.bufferSubData(gl.ELEMENT_ARRAY_BUFFER, 0, new Uint16Array(gfx.iqueue));\n\tgfx.curShader.bind();\n\tgfx.curTex.bind();\n\tgl.drawElements(gl.TRIANGLES, gfx.iqueue.length, gl.UNSIGNED_SHORT, 0);\n\tgfx.curTex.unbind();\n\tgfx.curShader.unbind();\n\tgl.bindBuffer(gl.ARRAY_BUFFER, null);\n\tgl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, null);\n\n\tgfx.iqueue = [];\n\tgfx.vqueue = [];\n\n\tgfx.drawCalls++;\n\n}\n\n// start a rendering frame, reset some states\nfunction frameStart() {\n\n\tapp.gl.clear(app.gl.COLOR_BUFFER_BIT);\n\n\tif (!gopt.background) {\n\t\tdrawUVQuad({\n\t\t\twidth: width(),\n\t\t\theight: height(),\n\t\t\tquad: new Quad(\n\t\t\t\t0,\n\t\t\t\t0,\n\t\t\t\twidth() * app.scale / BG_GRID_SIZE,\n\t\t\t\theight() * app.scale / BG_GRID_SIZE,\n\t\t\t),\n\t\t\ttex: gfx.bgTex,\n\t\t\tfixed: true,\n\t\t})\n\t}\n\n\tgfx.drawCalls = 0;\n\tgfx.transformStack = [];\n\tgfx.transform = new Mat4();\n\n}\n\nfunction frameEnd() {\n\tflush();\n\tgfx.lastDrawCalls = gfx.drawCalls;\n}\n\nfunction drawCalls() {\n\treturn gfx.lastDrawCalls;\n}\n\n// convert a screen space coordinate to webgl normalized device coordinate\nfunction screen2ndc(pt: Vec2): Vec2 {\n\treturn vec2(\n\t\tpt.x / width() * 2 - 1,\n\t\t-pt.y / height() * 2 + 1,\n\t);\n}\n\n// convert a webgl normalied device coordinate to screen space coordinate\nfunction ndc2screen(pt: Vec2): Vec2 {\n\treturn vec2(\n\t\t(pt.x + 1) / 2 * width(),\n\t\t-(pt.y - 1) / 2 * height(),\n\t);\n}\n\nfunction applyMatrix(m: Mat4) {\n\tgfx.transform = m.clone();\n}\n\nfunction pushTranslate(...args) {\n\tif (args[0] === undefined) return;\n\tconst p = vec2(...args);\n\tif (p.x === 0 && p.y === 0) return;\n\tgfx.transform = gfx.transform.translate(p);\n}\n\nfunction pushScale(...args) {\n\tif (args[0] === undefined) return;\n\tconst p = vec2(...args);\n\tif (p.x === 1 && p.y === 1) return;\n\tgfx.transform = gfx.transform.scale(p);\n}\n\nfunction pushRotateX(a: number) {\n\tif (!a) {\n\t\treturn;\n\t}\n\tgfx.transform = gfx.transform.rotateX(a);\n}\n\nfunction pushRotateY(a: number) {\n\tif (!a) {\n\t\treturn;\n\t}\n\tgfx.transform = gfx.transform.rotateY(a);\n}\n\nfunction pushRotateZ(a: number) {\n\tif (!a) {\n\t\treturn;\n\t}\n\tgfx.transform = gfx.transform.rotateZ(a);\n}\n\nfunction pushTransform() {\n\tgfx.transformStack.push(gfx.transform.clone());\n}\n\nfunction popTransform() {\n\tif (gfx.transformStack.length > 0) {\n\t\tgfx.transform = gfx.transformStack.pop();\n\t}\n}\n\n// draw a uv textured quad\nfunction drawUVQuad(opt: DrawUVQuadOpt) {\n\n\tif (opt.width === undefined || opt.height === undefined) {\n\t\tthrow new Error(\"drawUVQuad() requires property \\\"width\\\" and \\\"height\\\".\");\n\t}\n\n\tif (opt.width <= 0 || opt.height <= 0) {\n\t\treturn;\n\t}\n\n\tconst w = opt.width;\n\tconst h = opt.height;\n\tconst origin = originPt(opt.origin || DEF_ORIGIN);\n\tconst offset = origin.scale(vec2(w, h).scale(-0.5));\n\tconst q = opt.quad || new Quad(0, 0, 1, 1);\n\tconst color = opt.color || rgb(255, 255, 255);\n\tconst opacity = opt.opacity ?? 1;\n\n\tpushTransform();\n\tpushTranslate(opt.pos);\n\tpushRotateZ(opt.angle);\n\tpushScale(opt.scale);\n\tpushTranslate(offset);\n\n\tdrawRaw([\n\t\t{\n\t\t\tpos: vec3(-w / 2, h / 2, 0),\n\t\t\tuv: vec2(opt.flipX ? q.x + q.w : q.x, opt.flipY ? q.y : q.y + q.h),\n\t\t\tcolor: color,\n\t\t\topacity: opacity,\n\t\t},\n\t\t{\n\t\t\tpos: vec3(-w / 2, -h / 2, 0),\n\t\t\tuv: vec2(opt.flipX ? q.x + q.w : q.x, opt.flipY ? q.y + q.h : q.y),\n\t\t\tcolor: color,\n\t\t\topacity: opacity,\n\t\t},\n\t\t{\n\t\t\tpos: vec3(w / 2, -h / 2, 0),\n\t\t\tuv: vec2(opt.flipX ? q.x : q.x + q.w, opt.flipY ? q.y + q.h : q.y),\n\t\t\tcolor: color,\n\t\t\topacity: opacity,\n\t\t},\n\t\t{\n\t\t\tpos: vec3(w / 2, h / 2, 0),\n\t\t\tuv: vec2(opt.flipX ? q.x : q.x + q.w, opt.flipY ? q.y : q.y + q.h),\n\t\t\tcolor: color,\n\t\t\topacity: opacity,\n\t\t},\n\t], [0, 1, 3, 1, 2, 3], opt.fixed, opt.tex, opt.shader, opt.uniform);\n\n\tpopTransform();\n\n}\n\n// TODO: clean\nfunction drawTexture(opt: DrawTextureOpt) {\n\n\tif (!opt.tex) {\n\t\tthrow new Error(\"drawTexture() requires property \\\"tex\\\".\");\n\t}\n\n\tconst q = opt.quad ?? new Quad(0, 0, 1, 1);\n\tconst w = opt.tex.width * q.w;\n\tconst h = opt.tex.height * q.h;\n\tconst scale = vec2(1);\n\n\tif (opt.tiled) {\n\n\t\t// TODO: draw fract\n\t\tconst repX = Math.ceil((opt.width || w) / w);\n\t\tconst repY = Math.ceil((opt.height || h) / h);\n\t\tconst origin = originPt(opt.origin || DEF_ORIGIN).add(vec2(1, 1)).scale(0.5);\n\t\tconst offset = origin.scale(repX * w, repY * h);\n\n\t\t// TODO: rotation\n\t\tfor (let i = 0; i < repX; i++) {\n\t\t\tfor (let j = 0; j < repY; j++) {\n\t\t\t\tdrawUVQuad({\n\t\t\t\t\t...opt,\n\t\t\t\t\tpos: (opt.pos || vec2(0)).add(vec2(w * i, h * j)).sub(offset),\n\t\t\t\t\t// @ts-ignore\n\t\t\t\t\tscale: scale.scale(opt.scale || vec2(1)),\n\t\t\t\t\ttex: opt.tex,\n\t\t\t\t\tquad: q,\n\t\t\t\t\twidth: w,\n\t\t\t\t\theight: h,\n\t\t\t\t\torigin: \"topleft\",\n\t\t\t\t});\n\t\t\t}\n\t\t}\n\t} else {\n\n\t\t// TODO: should this ignore scale?\n\t\tif (opt.width && opt.height) {\n\t\t\tscale.x = opt.width / w;\n\t\t\tscale.y = opt.height / h;\n\t\t} else if (opt.width) {\n\t\t\tscale.x = opt.width / w;\n\t\t\tscale.y = scale.x;\n\t\t} else if (opt.height) {\n\t\t\tscale.y = opt.height / h;\n\t\t\tscale.x = scale.y;\n\t\t}\n\n\t\tdrawUVQuad({\n\t\t\t...opt,\n\t\t\t// @ts-ignore\n\t\t\tscale: scale.scale(opt.scale || vec2(1)),\n\t\t\ttex: opt.tex,\n\t\t\tquad: q,\n\t\t\twidth: w,\n\t\t\theight: h,\n\t\t});\n\n\t}\n\n}\n\n// TODO: use native asset loader tracking\nconst loading = new Set();\n\nfunction drawSprite(opt: DrawSpriteOpt) {\n\n\tif (!opt.sprite) {\n\t\tthrow new Error(`drawSprite() requires property \"sprite\"`);\n\t}\n\n\tconst spr = findAsset(opt.sprite, assets.sprites);\n\n\tif (!spr) {\n\n\t\t// if passes a source url, we load it implicitly\n\t\tif (typeof opt.sprite === \"string\") {\n\t\t\tif (!loading.has(opt.sprite)) {\n\t\t\t\tloading.add(opt.sprite);\n\t\t\t\tloadSprite(opt.sprite, opt.sprite)\n\t\t\t\t\t.then((a) => loading.delete(opt.sprite));\n\t\t\t}\n\t\t\treturn;\n\t\t} else {\n\t\t\tthrow new Error(`sprite not found: \"${opt.sprite}\"`);\n\t\t}\n\n\t}\n\n\tconst q = spr.frames[opt.frame ?? 0];\n\n\tif (!q) {\n\t\tthrow new Error(`frame not found: ${opt.frame ?? 0}`);\n\t}\n\n\tdrawTexture({\n\t\t...opt,\n\t\ttex: spr.tex,\n\t\tquad: q.scale(opt.quad || new Quad(0, 0, 1, 1)),\n\t\tuniform: opt.uniform,\n\t});\n\n}\n\n// generate vertices to form an arc\nfunction getArcPts(\n\tpos: Vec2,\n\tradiusX: number,\n\tradiusY: number,\n\tstart: number,\n\tend: number,\n\tres: number = 1,\n): Vec2[] {\n\n\t// normalize and turn start and end angles to radians\n\tstart = deg2rad(start % 360);\n\tend = deg2rad(end % 360);\n\tif (end <= start) end += Math.PI * 2;\n\n\t// TODO: better way to get this?\n\t// the number of vertices is sqrt(r1 + r2) * 3 * res with a minimum of 16\n\tconst nverts = Math.ceil(Math.max(Math.sqrt(radiusX + radiusY) * 3 * (res || 1), 16));\n\tconst step = (end - start) / nverts;\n\tconst pts = [];\n\n\t// calculate vertices\n\tfor (let a = start; a < end; a += step) {\n\t\tpts.push(pos.add(radiusX * Math.cos(a), radiusY * Math.sin(a)));\n\t}\n\n\t// doing this on the side due to possible floating point inaccuracy\n\tpts.push(pos.add(radiusX * Math.cos(end), radiusY * Math.sin(end)));\n\n\treturn pts;\n\n}\n\nfunction drawRect(opt: DrawRectOpt) {\n\n\tif (opt.width === undefined || opt.height === undefined) {\n\t\tthrow new Error(\"drawRect() requires property \\\"width\\\" and \\\"height\\\".\");\n\t}\n\n\tif (opt.width <= 0 || opt.height <= 0) {\n\t\treturn;\n\t}\n\n\tconst w = opt.width;\n\tconst h = opt.height;\n\tconst origin = originPt(opt.origin || DEF_ORIGIN).add(1, 1);\n\tconst offset = origin.scale(vec2(w, h).scale(-0.5));\n\n\tlet pts = [\n\t\tvec2(0, 0),\n\t\tvec2(w, 0),\n\t\tvec2(w, h),\n\t\tvec2(0, h),\n\t];\n\n\t// TODO: drawPolygon should handle generic rounded corners\n\tif (opt.radius) {\n\n\t\t// maxium radius is half the shortest side\n\t\tconst r = Math.min(Math.min(w, h) / 2, opt.radius);\n\n\t\tpts = [\n\t\t\tvec2(r, 0),\n\t\t\tvec2(w - r, 0),\n\t\t\t...getArcPts(vec2(w - r, r), r, r, 270, 360),\n\t\t\tvec2(w, r),\n\t\t\tvec2(w, h - r),\n\t\t\t...getArcPts(vec2(w - r, h - r), r, r, 0, 90),\n\t\t\tvec2(w - r, h),\n\t\t\tvec2(r, h),\n\t\t\t...getArcPts(vec2(r, h - r), r, r, 90, 180),\n\t\t\tvec2(0, h - r),\n\t\t\tvec2(0, r),\n\t\t\t...getArcPts(vec2(r, r), r, r, 180, 270),\n\t\t];\n\n\t}\n\n\tdrawPolygon({ ...opt, offset, pts });\n\n}\n\nfunction drawLine(opt: DrawLineOpt) {\n\n\tconst { p1, p2 } = opt;\n\n\tif (!p1 || !p2) {\n\t\tthrow new Error(\"drawLine() requires properties \\\"p1\\\" and \\\"p2\\\".\");\n\t}\n\n\tconst w = opt.width || 1;\n\n\t// the displacement from the line end point to the corner point\n\tconst dis = p2.sub(p1).unit().normal().scale(w * 0.5);\n\n\t// calculate the 4 corner points of the line polygon\n\tconst verts = [\n\t\tp1.sub(dis),\n\t\tp1.add(dis),\n\t\tp2.add(dis),\n\t\tp2.sub(dis),\n\t].map((p) => ({\n\t\tpos: vec3(p.x, p.y, 0),\n\t\tuv: vec2(0),\n\t\tcolor: opt.color ?? Color.WHITE,\n\t\topacity: opt.opacity ?? 1,\n\t}));\n\n\tdrawRaw(verts, [0, 1, 3, 1, 2, 3], opt.fixed, gfx.defTex, opt.shader, opt.uniform);\n\n}\n\nfunction drawLines(opt: DrawLinesOpt) {\n\n\tconst pts = opt.pts;\n\n\tif (!pts) {\n\t\tthrow new Error(\"drawLines() requires property \\\"pts\\\".\");\n\t}\n\n\tif (pts.length < 2) {\n\t\treturn;\n\t}\n\n\tif (opt.radius && pts.length >= 3) {\n\n\t\t// TODO: rounded vertices for arbitury polygonic shape\n\t\tlet minLen = pts[0].dist(pts[1]);\n\n\t\tfor (let i = 1; i < pts.length - 1; i++) {\n\t\t\tminLen = Math.min(pts[i].dist(pts[i + 1]), minLen);\n\t\t}\n\n\t\tconst radius = Math.min(opt.radius, minLen / 2);\n\n\t\tdrawLine({ ...opt, p1: pts[0], p2: pts[1], });\n\n\t\tfor (let i = 1; i < pts.length - 2; i++) {\n\t\t\tconst p1 = pts[i];\n\t\t\tconst p2 = pts[i + 1];\n\t\t\tdrawLine({\n\t\t\t\t...opt,\n\t\t\t\tp1: p1,\n\t\t\t\tp2: p2,\n\t\t\t});\n\t\t}\n\n\t\tdrawLine({ ...opt, p1: pts[pts.length - 2], p2: pts[pts.length - 1], });\n\n\t} else {\n\n\t\tfor (let i = 0; i < pts.length - 1; i++) {\n\t\t\tdrawLine({\n\t\t\t\t...opt,\n\t\t\t\tp1: pts[i],\n\t\t\t\tp2: pts[i + 1],\n\t\t\t});\n\t\t}\n\n\t}\n\n}\n\nfunction drawTriangle(opt: DrawTriangleOpt) {\n\tif (!opt.p1 || !opt.p2 || !opt.p3) {\n\t\tthrow new Error(\"drawPolygon() requires properties \\\"p1\\\", \\\"p2\\\" and \\\"p3\\\".\");\n\t}\n\treturn drawPolygon({\n\t\t...opt,\n\t\tpts: [opt.p1, opt.p2, opt.p3],\n\t});\n}\n\n// TODO: origin\nfunction drawCircle(opt: DrawCircleOpt) {\n\n\tif (!opt.radius) {\n\t\tthrow new Error(\"drawCircle() requires property \\\"radius\\\".\");\n\t}\n\n\tif (opt.radius === 0) {\n\t\treturn;\n\t}\n\n\tdrawEllipse({\n\t\t...opt,\n\t\tradiusX: opt.radius,\n\t\tradiusY: opt.radius,\n\t\tangle: 0,\n\t});\n\n}\n\n// TODO: use fan-like triangulation\nfunction drawEllipse(opt: DrawEllipseOpt) {\n\n\tif (opt.radiusX === undefined || opt.radiusY === undefined) {\n\t\tthrow new Error(\"drawEllipse() requires properties \\\"radiusX\\\" and \\\"radiusY\\\".\");\n\t}\n\n\tif (opt.radiusX === 0 || opt.radiusY === 0) {\n\t\treturn;\n\t}\n\n\tdrawPolygon({\n\t\t...opt,\n\t\tpts: getArcPts(\n\t\t\tvec2(0),\n\t\t\topt.radiusX,\n\t\t\topt.radiusY,\n\t\t\topt.start ?? 0,\n\t\t\topt.end ?? 360,\n\t\t\topt.resolution\n\t\t),\n\t\tradius: 0,\n\t});\n\n}\n\nfunction drawPolygon(opt: DrawPolygonOpt) {\n\n\tif (!opt.pts) {\n\t\tthrow new Error(\"drawPolygon() requires property \\\"pts\\\".\");\n\t}\n\n\tconst npts = opt.pts.length;\n\n\tif (npts < 3) {\n\t\treturn;\n\t}\n\n\tpushTransform();\n\tpushTranslate(opt.pos);\n\tpushScale(opt.scale);\n\tpushRotateZ(opt.angle);\n\tpushTranslate(opt.offset);\n\n\tif (opt.fill !== false) {\n\n\t\tconst color = opt.color ?? Color.WHITE;\n\n\t\tconst verts = opt.pts.map((pt) => ({\n\t\t\tpos: vec3(pt.x, pt.y, 0),\n\t\t\tuv: vec2(0, 0),\n\t\t\tcolor: color,\n\t\t\topacity: opt.opacity ?? 1,\n\t\t}));\n\n\t\t// TODO: better triangulation\n\t\tconst indices = [...Array(npts - 2).keys()]\n\t\t\t.map((n) => [0, n + 1, n + 2])\n\t\t\t.flat();\n\n\t\tdrawRaw(verts, opt.indices ?? indices, opt.fixed, gfx.defTex, opt.shader, opt.uniform);\n\n\t}\n\n\tif (opt.outline) {\n\t\tdrawLines({\n\t\t\tpts: [ ...opt.pts, opt.pts[0] ],\n\t\t\tradius: opt.radius,\n\t\t\twidth: opt.outline.width,\n\t\t\tcolor: opt.outline.color,\n\t\t\tuniform: opt.uniform,\n\t\t\tfixed: opt.fixed,\n\t\t});\n\t}\n\n\tpopTransform();\n\n}\n\nfunction applyCharTransform(fchar: FormattedChar, tr: CharTransform) {\n\tif (tr.pos) fchar.pos = fchar.pos.add(tr.pos);\n\tif (tr.scale) fchar.scale = fchar.scale.scale(vec2(tr.scale));\n\tif (tr.angle) fchar.angle += tr.angle;\n\tif (tr.color) fchar.color = fchar.color.mult(tr.color);\n\tif (tr.opacity) fchar.opacity *= tr.opacity;\n}\n\n// TODO: escape\nconst TEXT_STYLE_RE = /\\[(?[^\\]]*)\\]\\.(?