const VERSION = "3000.1.17" import initApp from "./app" import initGfx, { Texture, FrameBuffer, Shader, BatchRenderer, } from "./gfx" import { Asset, AssetBucket, fetchArrayBuffer, fetchJSON, fetchText, loadImg, } from "./assets" import { sat, vec2, Rect, Polygon, Line, Circle, Color, Vec2, Mat4, Quad, RNG, quad, rgb, hsl2rgb, rand, randi, randSeed, chance, choose, clamp, lerp, map, mapc, wave, testLinePoint, testLineLine, testLineCircle, testRectRect, testRectLine, testRectPoint, testPolygonPoint, testCirclePolygon, deg2rad, rad2deg, } from "./math" import easings from "./easings" import TexPacker from "./texPacker" import { Registry, Event, EventHandler, download, downloadText, downloadJSON, downloadBlob, uid, isDataURL, getFileName, overload2, dataURLToArrayBuffer, EventController, getErrorMessage, // eslint-disable-next-line warn, // eslint-disable-next-line benchmark, // eslint-disable-next-line comparePerf, BinaryHeap, runes, } from "./utils" import type { GfxFont, RenderProps, CharTransform, ImageSource, FormattedText, FormattedChar, DrawRectOpt, DrawLineOpt, DrawLinesOpt, DrawTriangleOpt, DrawPolygonOpt, DrawCircleOpt, DrawEllipseOpt, DrawUVQuadOpt, Vertex, BitmapFontData, ShaderData, AsepriteData, LoadSpriteSrc, LoadSpriteOpt, SpriteAtlasData, LoadBitmapFontOpt, KaboomCtx, KaboomOpt, AudioPlay, AudioPlayOpt, DrawSpriteOpt, DrawTextOpt, TextAlign, GameObj, SceneName, SceneDef, CompList, Comp, Tag, Key, MouseButton, PosComp, ScaleComp, RotateComp, ColorComp, OpacityComp, Anchor, AnchorComp, ZComp, FollowComp, OffScreenCompOpt, OffScreenComp, AreaCompOpt, AreaComp, SpriteComp, SpriteCompOpt, SpriteAnimPlayOpt, SpriteAnims, TextComp, TextCompOpt, RectComp, RectCompOpt, UVQuadComp, CircleCompOpt, CircleComp, OutlineComp, TimerComp, BodyComp, BodyCompOpt, Uniform, ShaderComp, FixedComp, StayComp, HealthComp, LifespanCompOpt, StateComp, Debug, KaboomPlugin, EmptyComp, LevelComp, Edge, TileComp, TileCompOpt, LevelOpt, Recording, BoomOpt, PeditFile, Shape, DoubleJumpComp, TimerController, TweenController, LoadFontOpt, AgentComp, AgentCompOpt, PathFindOpt, GetOpt, Vec2Args, NineSlice, LerpValue, TexFilter, MaskComp, Mask, Outline, PolygonComp, PolygonCompOpt, } from "./types" import beanSpriteSrc from "./assets/bean.png" import burpSoundSrc from "./assets/burp.mp3" import kaSpriteSrc from "./assets/ka.png" import boomSpriteSrc from "./assets/boom.png" interface SpriteCurAnim { name: string, timer: number, loop: boolean, speed: number, pingpong: boolean, onEnd: () => void, } // some default charsets for loading bitmap fonts const ASCII_CHARS = " !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~" const DEF_ANCHOR = "topleft" const BG_GRID_SIZE = 64 const DEF_FONT = "monospace" const DBG_FONT = "monospace" const DEF_TEXT_SIZE = 36 const DEF_TEXT_CACHE_SIZE = 64 const MAX_TEXT_CACHE_SIZE = 256 const FONT_ATLAS_WIDTH = 2048 const FONT_ATLAS_HEIGHT = 2048 const SPRITE_ATLAS_WIDTH = 2048 const SPRITE_ATLAS_HEIGHT = 2048 // 0.1 pixel padding to texture coordinates to prevent artifact const UV_PAD = 0.1 const DEF_HASH_GRID_SIZE = 64 const DEF_FONT_FILTER = "linear" const LOG_MAX = 8 const LOG_TIME = 4 const VERTEX_FORMAT = [ { name: "a_pos", size: 2 }, { name: "a_uv", size: 2 }, { name: "a_color", size: 4 }, ] const STRIDE = VERTEX_FORMAT.reduce((sum, f) => sum + f.size, 0) const MAX_BATCHED_QUAD = 2048 const MAX_BATCHED_VERTS = MAX_BATCHED_QUAD * 4 * STRIDE const MAX_BATCHED_INDICES = MAX_BATCHED_QUAD * 6 // vertex shader template, replace {{user}} with user vertex shader code const VERT_TEMPLATE = ` attribute vec2 a_pos; attribute vec2 a_uv; attribute vec4 a_color; varying vec2 v_pos; varying vec2 v_uv; varying vec4 v_color; vec4 def_vert() { return vec4(a_pos, 0.0, 1.0); } {{user}} void main() { vec4 pos = vert(a_pos, a_uv, a_color); v_pos = a_pos; v_uv = a_uv; v_color = a_color; gl_Position = pos; } ` // fragment shader template, replace {{user}} with user fragment shader code const FRAG_TEMPLATE = ` precision mediump float; varying vec2 v_pos; varying vec2 v_uv; varying vec4 v_color; uniform sampler2D u_tex; vec4 def_frag() { return v_color * texture2D(u_tex, v_uv); } {{user}} void main() { gl_FragColor = frag(v_pos, v_uv, v_color, u_tex); if (gl_FragColor.a == 0.0) { discard; } } ` // default {{user}} vertex shader code const DEF_VERT = ` vec4 vert(vec2 pos, vec2 uv, vec4 color) { return def_vert(); } ` // default {{user}} fragment shader code const DEF_FRAG = ` vec4 frag(vec2 pos, vec2 uv, vec4 color, sampler2D tex) { return def_frag(); } ` const COMP_DESC = new Set([ "id", "require", ]) const COMP_EVENTS = new Set([ "add", "update", "draw", "destroy", "inspect", "drawInspect", ]) // convert anchor string to a vec2 offset function anchorPt(orig: Anchor | Vec2): Vec2 { switch (orig) { case "topleft": return new Vec2(-1, -1) case "top": return new Vec2(0, -1) case "topright": return new Vec2(1, -1) case "left": return new Vec2(-1, 0) case "center": return new Vec2(0, 0) case "right": return new Vec2(1, 0) case "botleft": return new Vec2(-1, 1) case "bot": return new Vec2(0, 1) case "botright": return new Vec2(1, 1) default: return orig } } function alignPt(align: TextAlign): number { switch (align) { case "left": return 0 case "center": return 0.5 case "right": return 1 default: return 0 } } function createEmptyAudioBuffer(ctx: AudioContext) { return ctx.createBuffer(1, 1, 44100) } // only exports one kaboom() which contains all the state export default (gopt: KaboomOpt = {}): KaboomCtx => { const root = gopt.root ?? document.body // 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 if (root === document.body) { document.body.style["width"] = "100%" document.body.style["height"] = "100%" document.body.style["margin"] = "0px" document.documentElement.style["width"] = "100%" document.documentElement.style["height"] = "100%" } // create a if user didn't provide one const canvas = gopt.canvas ?? (() => { const canvas = document.createElement("canvas") root.appendChild(canvas) return canvas })() // global pixel scale const gscale = gopt.scale ?? 1 const fixedSize = gopt.width && gopt.height && !gopt.stretch && !gopt.letterbox // adjust canvas size according to user size / viewport settings if (fixedSize) { canvas.width = gopt.width * gscale canvas.height = gopt.height * gscale } else { canvas.width = canvas.parentElement.offsetWidth canvas.height = canvas.parentElement.offsetHeight } // canvas css styles const styles = [ "outline: none", "cursor: default", ] if (fixedSize) { const cw = canvas.width const ch = canvas.height styles.push(`width: ${cw}px`) styles.push(`height: ${ch}px`) } else { styles.push("width: 100%") styles.push("height: 100%") } if (gopt.crisp) { // chrome only supports pixelated and firefox only supports crisp-edges styles.push("image-rendering: pixelated") styles.push("image-rendering: crisp-edges") } canvas.style.cssText = styles.join(";") const pixelDensity = gopt.pixelDensity || window.devicePixelRatio canvas.width *= pixelDensity canvas.height *= pixelDensity // make canvas focusable canvas.tabIndex = 0 const fontCacheCanvas = document.createElement("canvas") fontCacheCanvas.width = MAX_TEXT_CACHE_SIZE fontCacheCanvas.height = MAX_TEXT_CACHE_SIZE const fontCacheC2d = fontCacheCanvas.getContext("2d", { willReadFrequently: true, }) const app = initApp({ canvas: canvas, touchToMouse: gopt.touchToMouse, gamepads: gopt.gamepads, pixelDensity: gopt.pixelDensity, maxFPS: gopt.maxFPS, }) const gc: Array<() => void> = [] const gl = app.canvas .getContext("webgl", { antialias: true, depth: true, stencil: true, alpha: true, preserveDrawingBuffer: true, }) const ggl = initGfx(gl, { texFilter: gopt.texFilter, }) const gfx = (() => { const defShader = makeShader(DEF_VERT, DEF_FRAG) // a 1x1 white texture to draw raw shapes like rectangles and polygons // we use a texture for those so we can use only 1 pipeline for drawing sprites + shapes const emptyTex = Texture.fromImage( ggl, new ImageData(new Uint8ClampedArray([ 255, 255, 255, 255 ]), 1, 1), ) const frameBuffer = (gopt.width && gopt.height) ? new FrameBuffer(ggl, gopt.width * pixelDensity * gscale, gopt.height * pixelDensity * gscale) : new FrameBuffer(ggl, gl.drawingBufferWidth, gl.drawingBufferHeight) let bgColor: null | Color = null let bgAlpha = 1 if (gopt.background) { bgColor = rgb(gopt.background) bgAlpha = Array.isArray(gopt.background) ? gopt.background[3] : 1 gl.clearColor( bgColor.r / 255, bgColor.g / 255, bgColor.b / 255, bgAlpha ?? 1, ) } gl.enable(gl.BLEND) gl.blendFuncSeparate( gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA, gl.ONE, gl.ONE_MINUS_SRC_ALPHA, ) const renderer = new BatchRenderer(ggl, VERTEX_FORMAT, MAX_BATCHED_VERTS, MAX_BATCHED_INDICES) // a checkerboard texture used for the default background const bgTex = Texture.fromImage( ggl, new ImageData(new Uint8ClampedArray([ 128, 128, 128, 255, 190, 190, 190, 255, 190, 190, 190, 255, 128, 128, 128, 255, ]), 2, 2), { wrap: "repeat", filter: "nearest", }, ) return { // how many draw calls we're doing last frame, this is the number we give to users lastDrawCalls: 0, // gfx states defShader: defShader, defTex: emptyTex, frameBuffer: frameBuffer, postShader: null, postShaderUniform: null, renderer: renderer, transform: new Mat4(), transformStack: [], bgTex: bgTex, bgColor: bgColor, bgAlpha: bgAlpha, width: gopt.width ?? gl.drawingBufferWidth / pixelDensity / gscale, height: gopt.height ?? gl.drawingBufferHeight / pixelDensity / gscale, viewport: { x: 0, y: 0, width: gl.drawingBufferWidth, height: gl.drawingBufferHeight, }, fixed: false, } })() class SpriteData { tex: Texture frames: Quad[] = [ new Quad(0, 0, 1, 1) ] anims: SpriteAnims = {} slice9: NineSlice | null = null constructor( tex: Texture, frames?: Quad[], anims: SpriteAnims = {}, slice9: NineSlice = null, ) { this.tex = tex if (frames) this.frames = frames this.anims = anims this.slice9 = slice9 } get width() { return this.tex.width * this.frames[0].w } get height() { return this.tex.height * this.frames[0].h } static from(src: LoadSpriteSrc, opt: LoadSpriteOpt = {}): Promise { return typeof src === "string" ? SpriteData.fromURL(src, opt) : Promise.resolve(SpriteData.fromImage(src, opt)) } static fromImage(data: ImageSource, opt: LoadSpriteOpt = {}): SpriteData { const [tex, quad] = assets.packer.add(data) const frames = opt.frames ? opt.frames.map((f) => new Quad( quad.x + f.x * quad.w, quad.y + f.y * quad.h, f.w * quad.w, f.h * quad.h, )) : slice(opt.sliceX || 1, opt.sliceY || 1, quad.x, quad.y, quad.w, quad.h) return new SpriteData(tex, frames, opt.anims, opt.slice9) } static fromURL(url: string, opt: LoadSpriteOpt = {}): Promise { return loadImg(url).then((img) => SpriteData.fromImage(img, opt)) } } class SoundData { buf: AudioBuffer constructor(buf: AudioBuffer) { this.buf = buf } static fromArrayBuffer(buf: ArrayBuffer): Promise { return new Promise((resolve, reject) => audio.ctx.decodeAudioData(buf, resolve, reject), ).then((buf: AudioBuffer) => new SoundData(buf)) } static fromURL(url: string): Promise { if (isDataURL(url)) { return SoundData.fromArrayBuffer(dataURLToArrayBuffer(url)) } else { return fetchArrayBuffer(url).then((buf) => SoundData.fromArrayBuffer(buf)) } } } const audio = (() => { const ctx = new ( window.AudioContext || (window as any).webkitAudioContext )() as AudioContext const masterNode = ctx.createGain() masterNode.connect(ctx.destination) // by default browsers can only load audio async, we don't deal with that and just start with an empty audio buffer const burpSnd = new SoundData(createEmptyAudioBuffer(ctx)) // load that burp sound ctx.decodeAudioData(burpSoundSrc.buffer.slice(0)).then((buf) => { burpSnd.buf = buf }).catch((err) => { console.error("Failed to load burp: ", err) }) return { ctx, masterNode, burpSnd, } })() const assets = { urlPrefix: "", // asset holders sprites: new AssetBucket(), fonts: new AssetBucket(), bitmapFonts: new AssetBucket(), sounds: new AssetBucket(), shaders: new AssetBucket(), custom: new AssetBucket(), packer: new TexPacker(ggl, SPRITE_ATLAS_WIDTH, SPRITE_ATLAS_HEIGHT), // if we finished initially loading all assets loaded: false, } function fixURL(url: D): D { if (typeof url !== "string" || isDataURL(url)) return url return assets.urlPrefix + url as D } const game = { // general events events: new EventHandler<{ mouseMove: [], mouseDown: [MouseButton], mousePress: [MouseButton], mouseRelease: [MouseButton], charInput: [string], keyPress: [Key], keyDown: [Key], keyPressRepeat: [Key], keyRelease: [Key], touchStart: [Vec2, Touch], touchMove: [Vec2, Touch], touchEnd: [Vec2, Touch], gamepadButtonDown: [string], gamepadButtonPress: [string], gamepadButtonRelease: [string], gamepadStick: [string, Vec2], gamepadConnect: [Gamepad], gamepadDisconnect: [Gamepad], scroll: [Vec2], add: [GameObj], destroy: [GameObj], load: [], loading: [number], error: [Error], input: [], frameEnd: [], resize: [], sceneLeave: [string], }>(), // object events objEvents: new EventHandler(), // root game object root: make([]), // misc gravity: 0, scenes: {}, // on screen log logs: [], // camera cam: { pos: null, scale: new Vec2(1), angle: 0, shake: 0, transform: new Mat4(), }, } game.root.use(timer()) // wrap individual loaders with global loader counter, for stuff like progress bar function load(prom: Promise): Asset { return assets.custom.add(null, prom) } // get current load progress function loadProgress(): number { const buckets = [ assets.sprites, assets.sounds, assets.shaders, assets.fonts, assets.bitmapFonts, assets.custom, ] return buckets.reduce((n, bucket) => n + bucket.progress(), 0) / buckets.length } // global load path prefix function loadRoot(path?: string): string { if (path !== undefined) { assets.urlPrefix = path } return assets.urlPrefix } function loadJSON(name, url) { return assets.custom.add(name, fetchJSON(url)) } class FontData { fontface: FontFace filter: TexFilter = DEF_FONT_FILTER outline: Outline | null = null size: number = DEF_TEXT_CACHE_SIZE constructor(face: FontFace, opt: LoadFontOpt = {}) { this.fontface = face this.filter = opt.filter ?? DEF_FONT_FILTER this.size = opt.size ?? DEF_TEXT_CACHE_SIZE if (this.size > MAX_TEXT_CACHE_SIZE) { throw new Error(`Max font size: ${MAX_TEXT_CACHE_SIZE}`) } if (opt.outline) { this.outline = { width: 1, color: rgb(0, 0, 0), } if (typeof opt.outline === "number") { this.outline.width = opt.outline } else if (typeof opt.outline === "object") { if (opt.outline.width) this.outline.width = opt.outline.width if (opt.outline.color) this.outline.color = opt.outline.color } } } } // TODO: pass in null src to store opt for default fonts like "monospace" function loadFont( name: string, src: string | BinaryData, opt: LoadFontOpt = {}, ): Asset { const font = new FontFace(name, typeof src === "string" ? `url(${src})` : src) document.fonts.add(font) return assets.fonts.add(name, font.load().catch((err) => { throw new Error(`Failed to load font from "${src}": ${err}`) }).then((face) => new FontData(face, opt))) } // TODO: support outline // TODO: support LoadSpriteSrc function loadBitmapFont( name: string | null, src: string, gw: number, gh: number, opt: LoadBitmapFontOpt = {}, ): Asset { return assets.bitmapFonts.add(name, loadImg(src) .then((img) => { return makeFont( Texture.fromImage(ggl, img, opt), gw, gh, opt.chars ?? ASCII_CHARS, ) }), ) } // get an array of frames based on configuration on how to slice the image function slice(x = 1, y = 1, dx = 0, dy = 0, w = 1, h = 1): Quad[] { const frames = [] const qw = w / x const qh = h / y for (let j = 0; j < y; j++) { for (let i = 0; i < x; i++) { frames.push(new Quad( dx + i * qw, dy + j * qh, qw, qh, )) } } return frames } // TODO: load synchronously if passed ImageSource function loadSpriteAtlas( src: LoadSpriteSrc, data: SpriteAtlasData | string, ): Asset> { src = fixURL(src) if (typeof data === "string") { return load(new Promise((res, rej) => { fetchJSON(data).then((json) => { loadSpriteAtlas(src, json).then(res).catch(rej) }) })) } return load(SpriteData.from(src).then((atlas) => { const map = {} for (const name in data) { const info = data[name] const quad = atlas.frames[0] const w = SPRITE_ATLAS_WIDTH * quad.w const h = SPRITE_ATLAS_HEIGHT * quad.h const frames = info.frames ? info.frames.map((f) => new Quad( quad.x + (info.x + f.x) / w * quad.w, quad.y + (info.y + f.y) / h * quad.h, f.w / w * quad.w, f.h / h * quad.h, )) : slice( info.sliceX || 1, info.sliceY || 1, quad.x + info.x / w * quad.w, quad.y + info.y / h * quad.h, info.width / w * quad.w, info.height / h * quad.h, ) const spr = new SpriteData(atlas.tex, frames, info.anims) assets.sprites.addLoaded(name, spr) map[name] = spr } return map })) } function createSpriteSheet( images: ImageSource[], opt: LoadSpriteOpt = {}, ): SpriteData { const canvas = document.createElement("canvas") const width = images[0].width const height = images[0].height canvas.width = width * images.length canvas.height = height const c2d = canvas.getContext("2d") images.forEach((img, i) => { if (img instanceof ImageData) { c2d.putImageData(img, i * width, 0) } else { c2d.drawImage(img, i * width, 0) } }) const merged = c2d.getImageData(0, 0, images.length * width, height) return SpriteData.fromImage(merged, { ...opt, sliceX: images.length, sliceY: 1, }) } // load a sprite to asset manager function loadSprite( name: string | null, src: LoadSpriteSrc | LoadSpriteSrc[], opt: LoadSpriteOpt = { sliceX: 1, sliceY: 1, anims: {}, }, ): Asset { src = fixURL(src) if (Array.isArray(src)) { if (src.some((s) => typeof s === "string")) { return assets.sprites.add( name, Promise.all(src.map((s) => { return typeof s === "string" ? loadImg(s) : Promise.resolve(s) })).then((images) => createSpriteSheet(images, opt)), ) } else { return assets.sprites.addLoaded(name, createSpriteSheet(src as ImageSource[], opt)) } } else { if (typeof src === "string") { return assets.sprites.add(name, SpriteData.from(src, opt)) } else { return assets.sprites.addLoaded(name, SpriteData.fromImage(src, opt)) } } } function loadPedit(name: string | null, src: string | PeditFile): Asset { src = fixURL(src) // eslint-disable-next-line return assets.sprites.add(name, new Promise(async (resolve) => { const data = typeof src === "string" ? await fetchJSON(src) : src const images = await Promise.all(data.frames.map(loadImg)) const canvas = document.createElement("canvas") canvas.width = data.width canvas.height = data.height * data.frames.length const c2d = canvas.getContext("2d") images.forEach((img: HTMLImageElement, i) => { c2d.drawImage(img, 0, i * data.height) }) const spr = await loadSprite(null, canvas, { sliceY: data.frames.length, anims: data.anims, }) resolve(spr) })) } function loadAseprite( name: string | null, imgSrc: LoadSpriteSrc, jsonSrc: string | AsepriteData, ): Asset { imgSrc = fixURL(imgSrc) jsonSrc = fixURL(jsonSrc) if (typeof imgSrc === "string" && !jsonSrc) { jsonSrc = getFileName(imgSrc) + ".json" } const resolveJSON = typeof jsonSrc === "string" ? fetchJSON(jsonSrc) : Promise.resolve(jsonSrc) return assets.sprites.add(name, resolveJSON.then((data: AsepriteData) => { const size = data.meta.size const frames = data.frames.map((f: any) => { return new Quad( f.frame.x / size.w, f.frame.y / size.h, f.frame.w / size.w, f.frame.h / size.h, ) }) const anims = {} for (const anim of data.meta.frameTags) { if (anim.from === anim.to) { anims[anim.name] = anim.from } else { anims[anim.name] = { from: anim.from, to: anim.to, speed: 10, loop: true, pingpong: anim.direction === "pingpong", } } } return SpriteData.from(imgSrc, { frames: frames, anims: anims, }) })) } function loadShader( name: string | null, vert?: string, frag?: string, ) { return assets.shaders.addLoaded(name, makeShader(vert, frag)) } function loadShaderURL( name: string | null, vert?: string, frag?: string, ): Asset { vert = fixURL(vert) frag = fixURL(frag) const resolveUrl = (url?: string) => url ? fetchText(url) : Promise.resolve(null) const load = Promise.all([resolveUrl(vert), resolveUrl(frag)]) .then(([vcode, fcode]: [string | null, string | null]) => { return makeShader(vcode, fcode) }) return assets.shaders.add(name, load) } // TODO: allow stream big audio // load a sound to asset manager function loadSound( name: string | null, src: string | ArrayBuffer, ): Asset { src = fixURL(src) return assets.sounds.add( name, typeof src === "string" ? SoundData.fromURL(src) : SoundData.fromArrayBuffer(src), ) } function loadBean(name: string = "bean"): Asset { return loadSprite(name, beanSpriteSrc) } function getSprite(name: string): Asset | void { return assets.sprites.get(name) } function getSound(name: string): Asset | void { return assets.sounds.get(name) } function getFont(name: string): Asset | void { return assets.fonts.get(name) } function getBitmapFont(name: string): Asset | void { return assets.bitmapFonts.get(name) } function getShader(name: string): Asset | void { return assets.shaders.get(name) } function getAsset(name: string): Asset | void { return assets.custom.get(name) } function resolveSprite( src: DrawSpriteOpt["sprite"], ): Asset | null { if (typeof src === "string") { const spr = getSprite(src) if (spr) { // if it's already loaded or being loading, return it return spr } else if (loadProgress() < 1) { // if there's any other ongoing loading task we return empty and don't error yet return null } else { // if all other assets are loaded and we still haven't found this sprite, throw throw new Error(`Sprite not found: ${src}`) } } else if (src instanceof SpriteData) { return Asset.loaded(src) } else if (src instanceof Asset) { return src } else { throw new Error(`Invalid sprite: ${src}`) } } function resolveSound( src: Parameters[0], ): Asset | null { if (typeof src === "string") { const snd = getSound(src) if (snd) { return snd } else if (loadProgress() < 1) { return null } else { throw new Error(`Sound not found: ${src}`) } } else if (src instanceof SoundData) { return Asset.loaded(src) } else if (src instanceof Asset) { return src } else { throw new Error(`Invalid sound: ${src}`) } } function resolveShader( src: RenderProps["shader"], ): ShaderData | Asset | null { if (!src) { return gfx.defShader } if (typeof src === "string") { const shader = getShader(src) if (shader) { return shader.data ?? shader } else if (loadProgress() < 1) { return null } else { throw new Error(`Shader not found: ${src}`) } } else if (src instanceof Asset) { return src.data ? src.data : src } // TODO: check type // @ts-ignore return src } function resolveFont( src: DrawTextOpt["font"], ): | FontData | Asset | BitmapFontData | Asset | string | void { if (!src) { return resolveFont(gopt.font ?? DEF_FONT) } if (typeof src === "string") { const bfont = getBitmapFont(src) const font = getFont(src) if (bfont) { return bfont.data ?? bfont } else if (font) { return font.data ?? font } else if (document.fonts.check(`${DEF_TEXT_CACHE_SIZE}px ${src}`)) { return src } else if (loadProgress() < 1) { return null } else { throw new Error(`Font not found: ${src}`) } } else if (src instanceof Asset) { return src.data ? src.data : src } // TODO: check type // @ts-ignore return src } // get / set master volume function volume(v?: number): number { if (v !== undefined) { audio.masterNode.gain.value = v } return audio.masterNode.gain.value } // TODO: method to completely destory audio? // TODO: time() not correct when looped over or ended // TODO: onEnd() not working // plays a sound, returns a control handle function play( src: string | SoundData | Asset, opt: AudioPlayOpt = {}, ): AudioPlay { const ctx = audio.ctx let paused = opt.paused ?? false let srcNode = ctx.createBufferSource() const onEndEvents = new Event() const gainNode = ctx.createGain() const pos = opt.seek ?? 0 let startTime = 0 let stopTime = 0 let started = false srcNode.loop = Boolean(opt.loop) srcNode.detune.value = opt.detune ?? 0 srcNode.playbackRate.value = opt.speed ?? 1 srcNode.connect(gainNode) srcNode.onended = () => { if (getTime() >= srcNode.buffer?.duration ?? Number.POSITIVE_INFINITY) { onEndEvents.trigger() } } gainNode.connect(audio.masterNode) gainNode.gain.value = opt.volume ?? 1 const start = (data: SoundData) => { srcNode.buffer = data.buf if (!paused) { startTime = ctx.currentTime srcNode.start(0, pos) started = true } } const snd = resolveSound(src) if (snd instanceof Asset) { snd.onLoad(start) } const getTime = () => { if (!srcNode.buffer) return 0 const t = paused ? stopTime - startTime : ctx.currentTime - startTime const d = srcNode.buffer.duration return srcNode.loop ? t % d : Math.min(t, d) } const cloneNode = (oldNode: AudioBufferSourceNode) => { const newNode = ctx.createBufferSource() newNode.buffer = oldNode.buffer newNode.loop = oldNode.loop newNode.playbackRate.value = oldNode.playbackRate.value newNode.detune.value = oldNode.detune.value newNode.onended = oldNode.onended newNode.connect(gainNode) return newNode } return { stop() { this.paused = true this.seek(0) }, set paused(p: boolean) { if (paused === p) return paused = p if (p) { if (started) { srcNode.stop() started = false } stopTime = ctx.currentTime } else { srcNode = cloneNode(srcNode) const pos = stopTime - startTime srcNode.start(0, pos) started = true startTime = ctx.currentTime - pos stopTime = 0 } }, get paused() { return paused }, play(time: number = 0) { this.seek(time) this.paused = false }, seek(time: number) { if (!srcNode.buffer?.duration) return if (time > srcNode.buffer.duration) return if (paused) { srcNode = cloneNode(srcNode) startTime = stopTime - time } else { srcNode.stop() srcNode = cloneNode(srcNode) startTime = ctx.currentTime - time srcNode.start(0, time) started = true stopTime = 0 } }, // TODO: affect time() set speed(val: number) { srcNode.playbackRate.value = val }, get speed() { return srcNode.playbackRate.value }, set detune(val: number) { srcNode.detune.value = val }, get detune() { return srcNode.detune.value }, set volume(val: number) { gainNode.gain.value = Math.max(val, 0) }, get volume() { return gainNode.gain.value }, set loop(l: boolean) { srcNode.loop = l }, get loop() { return srcNode.loop }, duration(): number { return srcNode.buffer?.duration ?? 0 }, time(): number { return getTime() % this.duration() }, onEnd(action: () => void) { return onEndEvents.add(action) }, then(action: () => void) { return this.onEnd(action) }, } } // core kaboom logic function burp(opt?: AudioPlayOpt): AudioPlay { return play(audio.burpSnd, opt) } type DrawTextureOpt = RenderProps & { tex: Texture, width?: number, height?: number, tiled?: boolean, flipX?: boolean, flipY?: boolean, quad?: Quad, anchor?: Anchor | Vec2, } function makeCanvas(w: number, h: number) { return new FrameBuffer(ggl, w, h) } function makeShader( vertSrc: string | null = DEF_VERT, fragSrc: string | null = DEF_FRAG, ): Shader { const vcode = VERT_TEMPLATE.replace("{{user}}", vertSrc ?? DEF_VERT) const fcode = FRAG_TEMPLATE.replace("{{user}}", fragSrc ?? DEF_FRAG) try { return new Shader(ggl, vcode, fcode, VERTEX_FORMAT.map((vert) => vert.name)) } catch (e) { const lineOffset = 14 const fmt = /(?^\w+) SHADER ERROR: 0:(?\d+): (?.+)/ const match = getErrorMessage(e).match(fmt) const line = Number(match.groups.line) - lineOffset const msg = match.groups.msg.trim() const ty = match.groups.type.toLowerCase() throw new Error(`${ty} shader line ${line}: ${msg}`) } } function makeFont( tex: Texture, gw: number, gh: number, chars: string, ): GfxFont { const cols = tex.width / gw const map: Record = {} const charMap = chars.split("").entries() for (const [i, ch] of charMap) { map[ch] = new Quad( (i % cols) * gw, Math.floor(i / cols) * gh, gw, gh, ) } return { tex: tex, map: map, size: gh, } } // TODO: expose function drawRaw( verts: Vertex[], indices: number[], fixed: boolean, tex: Texture = gfx.defTex, shaderSrc: RenderProps["shader"] = gfx.defShader, uniform: Uniform = {}, ) { const shader = resolveShader(shaderSrc) if (!shader || shader instanceof Asset) { return } const transform = (gfx.fixed || fixed) ? gfx.transform : game.cam.transform.mult(gfx.transform) const vv = [] for (const v of verts) { // normalized world space coordinate [-1.0 ~ 1.0] const pt = screen2ndc(transform.multVec2(v.pos)) vv.push( pt.x, pt.y, v.uv.x, v.uv.y, v.color.r / 255, v.color.g / 255, v.color.b / 255, v.opacity, ) } gfx.renderer.push(gl.TRIANGLES, vv, indices, shader, tex, uniform) } // draw all batched shapes function flush() { gfx.renderer.flush() } // start a rendering frame, reset some states function frameStart() { // clear backbuffer gl.clear(gl.COLOR_BUFFER_BIT) gfx.frameBuffer.bind() // clear framebuffer gl.clear(gl.COLOR_BUFFER_BIT) if (!gfx.bgColor) { drawUnscaled(() => { drawUVQuad({ width: width(), height: height(), quad: new Quad( 0, 0, width() / BG_GRID_SIZE, height() / BG_GRID_SIZE, ), tex: gfx.bgTex, fixed: true, }) }) } gfx.renderer.numDraws = 0 gfx.fixed = false gfx.transformStack.length = 0 gfx.transform = new Mat4() } function usePostEffect(name: string, uniform?: Uniform | (() => Uniform)) { gfx.postShader = name gfx.postShaderUniform = uniform ?? null } function frameEnd() { // TODO: don't render debug UI with framebuffer // TODO: polish framebuffer rendering / sizing issues flush() gfx.lastDrawCalls = gfx.renderer.numDraws gfx.frameBuffer.unbind() gl.viewport(0, 0, gl.drawingBufferWidth, gl.drawingBufferHeight) const ow = gfx.width const oh = gfx.height gfx.width = gl.drawingBufferWidth / pixelDensity gfx.height = gl.drawingBufferHeight / pixelDensity drawTexture({ flipY: true, tex: gfx.frameBuffer.tex, pos: new Vec2(gfx.viewport.x, gfx.viewport.y), width: gfx.viewport.width, height: gfx.viewport.height, shader: gfx.postShader, uniform: typeof gfx.postShaderUniform === "function" ? gfx.postShaderUniform() : gfx.postShaderUniform, fixed: true, }) flush() gfx.width = ow gfx.height = oh } // convert a screen space coordinate to webgl normalized device coordinate function screen2ndc(pt: Vec2): Vec2 { return new Vec2( pt.x / width() * 2 - 1, -pt.y / height() * 2 + 1, ) } function pushMatrix(m: Mat4) { gfx.transform = m.clone() } function pushTranslate(...args: Vec2Args) { if (args[0] === undefined) return const p = vec2(...args) if (p.x === 0 && p.y === 0) return gfx.transform.translate(p) } function pushScale(...args: Vec2Args) { if (args[0] === undefined) return const p = vec2(...args) if (p.x === 1 && p.y === 1) return gfx.transform.scale(p) } function pushRotate(a: number) { if (!a) return gfx.transform.rotate(a) } function pushTransform() { gfx.transformStack.push(gfx.transform.clone()) } function popTransform() { if (gfx.transformStack.length > 0) { gfx.transform = gfx.transformStack.pop() } } // draw a uv textured quad function drawUVQuad(opt: DrawUVQuadOpt) { if (opt.width === undefined || opt.height === undefined) { throw new Error("drawUVQuad() requires property \"width\" and \"height\".") } if (opt.width <= 0 || opt.height <= 0) { return } const w = opt.width const h = opt.height const anchor = anchorPt(opt.anchor || DEF_ANCHOR) const offset = anchor.scale(new Vec2(w, h).scale(-0.5)) const q = opt.quad || new Quad(0, 0, 1, 1) const color = opt.color || rgb(255, 255, 255) const opacity = opt.opacity ?? 1 // apply uv padding to avoid artifacts const uvPadX = opt.tex ? UV_PAD / opt.tex.width : 0 const uvPadY = opt.tex ? UV_PAD / opt.tex.height : 0 const qx = q.x + uvPadX const qy = q.y + uvPadY const qw = q.w - uvPadX * 2 const qh = q.h - uvPadY * 2 pushTransform() pushTranslate(opt.pos) pushRotate(opt.angle) pushScale(opt.scale) pushTranslate(offset) drawRaw([ { pos: new Vec2(-w / 2, h / 2), uv: new Vec2(opt.flipX ? qx + qw : qx, opt.flipY ? qy : qy + qh), color: color, opacity: opacity, }, { pos: new Vec2(-w / 2, -h / 2), uv: new Vec2(opt.flipX ? qx + qw : qx, opt.flipY ? qy + qh : qy), color: color, opacity: opacity, }, { pos: new Vec2(w / 2, -h / 2), uv: new Vec2(opt.flipX ? qx : qx + qw, opt.flipY ? qy + qh : qy), color: color, opacity: opacity, }, { pos: new Vec2(w / 2, h / 2), uv: new Vec2(opt.flipX ? qx : qx + qw, opt.flipY ? qy : qy + qh), color: color, opacity: opacity, }, ], [0, 1, 3, 1, 2, 3], opt.fixed, opt.tex, opt.shader, opt.uniform) popTransform() } // TODO: clean function drawTexture(opt: DrawTextureOpt) { if (!opt.tex) { throw new Error("drawTexture() requires property \"tex\".") } const q = opt.quad ?? new Quad(0, 0, 1, 1) const w = opt.tex.width * q.w const h = opt.tex.height * q.h const scale = new Vec2(1) if (opt.tiled) { // TODO: draw fract const repX = Math.ceil((opt.width || w) / w) const repY = Math.ceil((opt.height || h) / h) const anchor = anchorPt(opt.anchor || DEF_ANCHOR).add(new Vec2(1, 1)).scale(0.5) const offset = anchor.scale(repX * w, repY * h) // TODO: rotation for (let i = 0; i < repX; i++) { for (let j = 0; j < repY; j++) { drawUVQuad(Object.assign({}, opt, { pos: (opt.pos || new Vec2(0)).add(new Vec2(w * i, h * j)).sub(offset), scale: scale.scale(opt.scale || new Vec2(1)), tex: opt.tex, quad: q, width: w, height: h, anchor: "topleft", })) } } } else { // TODO: should this ignore scale? if (opt.width && opt.height) { scale.x = opt.width / w scale.y = opt.height / h } else if (opt.width) { scale.x = opt.width / w scale.y = scale.x } else if (opt.height) { scale.y = opt.height / h scale.x = scale.y } drawUVQuad(Object.assign({}, opt, { scale: scale.scale(opt.scale || new Vec2(1)), tex: opt.tex, quad: q, width: w, height: h, })) } } function drawSprite(opt: DrawSpriteOpt) { if (!opt.sprite) { throw new Error("drawSprite() requires property \"sprite\"") } // TODO: slow const spr = resolveSprite(opt.sprite) if (!spr || !spr.data) { return } const q = spr.data.frames[opt.frame ?? 0] if (!q) { throw new Error(`Frame not found: ${opt.frame ?? 0}`) } drawTexture(Object.assign({}, opt, { tex: spr.data.tex, quad: q.scale(opt.quad ?? new Quad(0, 0, 1, 1)), })) } // generate vertices to form an arc function getArcPts( pos: Vec2, radiusX: number, radiusY: number, start: number, end: number, res: number = 1, ): Vec2[] { // normalize and turn start and end angles to radians start = deg2rad(start % 360) end = deg2rad(end % 360) if (end <= start) end += Math.PI * 2 const pts = [] const nverts = Math.ceil((end - start) / deg2rad(8) * res) const step = (end - start) / nverts // calculate vertices for (let a = start; a < end; a += step) { pts.push(pos.add(radiusX * Math.cos(a), radiusY * Math.sin(a))) } pts.push(pos.add(radiusX * Math.cos(end), radiusY * Math.sin(end))) return pts } function drawRect(opt: DrawRectOpt) { if (opt.width === undefined || opt.height === undefined) { throw new Error("drawRect() requires property \"width\" and \"height\".") } if (opt.width <= 0 || opt.height <= 0) { return } const w = opt.width const h = opt.height const anchor = anchorPt(opt.anchor || DEF_ANCHOR).add(1, 1) const offset = anchor.scale(new Vec2(w, h).scale(-0.5)) let pts = [ new Vec2(0, 0), new Vec2(w, 0), new Vec2(w, h), new Vec2(0, h), ] // TODO: gradient for rounded rect // TODO: drawPolygon should handle generic rounded corners if (opt.radius) { // maxium radius is half the shortest side const r = Math.min(Math.min(w, h) / 2, opt.radius) pts = [ new Vec2(r, 0), new Vec2(w - r, 0), ...getArcPts(new Vec2(w - r, r), r, r, 270, 360), new Vec2(w, r), new Vec2(w, h - r), ...getArcPts(new Vec2(w - r, h - r), r, r, 0, 90), new Vec2(w - r, h), new Vec2(r, h), ...getArcPts(new Vec2(r, h - r), r, r, 90, 180), new Vec2(0, h - r), new Vec2(0, r), ...getArcPts(new Vec2(r, r), r, r, 180, 270), ] } drawPolygon(Object.assign({}, opt, { offset, pts, ...(opt.gradient ? { colors: opt.horizontal ? [ opt.gradient[0], opt.gradient[1], opt.gradient[1], opt.gradient[0], ] : [ opt.gradient[0], opt.gradient[0], opt.gradient[1], opt.gradient[1], ], } : {}), })) } function drawLine(opt: DrawLineOpt) { const { p1, p2 } = opt if (!p1 || !p2) { throw new Error("drawLine() requires properties \"p1\" and \"p2\".") } const w = opt.width || 1 // the displacement from the line end point to the corner point const dis = p2.sub(p1).unit().normal().scale(w * 0.5) // calculate the 4 corner points of the line polygon const verts = [ p1.sub(dis), p1.add(dis), p2.add(dis), p2.sub(dis), ].map((p) => ({ pos: new Vec2(p.x, p.y), uv: new Vec2(0), color: opt.color ?? Color.WHITE, opacity: opt.opacity ?? 1, })) drawRaw(verts, [0, 1, 3, 1, 2, 3], opt.fixed, gfx.defTex, opt.shader, opt.uniform) } function drawLines(opt: DrawLinesOpt) { const pts = opt.pts if (!pts) { throw new Error("drawLines() requires property \"pts\".") } if (pts.length < 2) { return } if (opt.radius && pts.length >= 3) { // TODO: line joines // TODO: rounded vertices for arbitury polygonic shape let minSLen = pts[0].sdist(pts[1]) for (let i = 1; i < pts.length - 1; i++) { minSLen = Math.min(pts[i].sdist(pts[i + 1]), minSLen) } // eslint-disable-next-line const radius = Math.min(opt.radius, Math.sqrt(minSLen) / 2) drawLine(Object.assign({}, opt, { p1: pts[0], p2: pts[1] })) for (let i = 1; i < pts.length - 2; i++) { const p1 = pts[i] const p2 = pts[i + 1] drawLine(Object.assign({}, opt, { p1: p1, p2: p2, })) } drawLine(Object.assign({}, opt, { p1: pts[pts.length - 2], p2: pts[pts.length - 1], })) } else { for (let i = 0; i < pts.length - 1; i++) { drawLine(Object.assign({}, opt, { p1: pts[i], p2: pts[i + 1], })) // TODO: other line join types if (opt.join !== "none") { drawCircle(Object.assign({}, opt, { pos: pts[i], radius: opt.width / 2, })) } } } } function drawTriangle(opt: DrawTriangleOpt) { if (!opt.p1 || !opt.p2 || !opt.p3) { throw new Error("drawTriangle() requires properties \"p1\", \"p2\" and \"p3\".") } return drawPolygon(Object.assign({}, opt, { pts: [opt.p1, opt.p2, opt.p3], })) } function drawCircle(opt: DrawCircleOpt) { if (typeof opt.radius !== "number") { throw new Error("drawCircle() requires property \"radius\".") } if (opt.radius === 0) { return } drawEllipse(Object.assign({}, opt, { radiusX: opt.radius, radiusY: opt.radius, angle: 0, })) } function drawEllipse(opt: DrawEllipseOpt) { if (opt.radiusX === undefined || opt.radiusY === undefined) { throw new Error("drawEllipse() requires properties \"radiusX\" and \"radiusY\".") } if (opt.radiusX === 0 || opt.radiusY === 0) { return } const start = opt.start ?? 0 const end = opt.end ?? 360 const offset = anchorPt(opt.anchor ?? "center").scale(new Vec2(-opt.radiusX, -opt.radiusY)) const pts = getArcPts( offset, opt.radiusX, opt.radiusY, start, end, opt.resolution, ) // center pts.unshift(offset) const polyOpt = Object.assign({}, opt, { pts, radius: 0, ...(opt.gradient ? { colors: [ opt.gradient[0], ...Array(pts.length - 1).fill(opt.gradient[1]), ], } : {}), }) // full circle with outline shouldn't have the center point if (end - start >= 360 && opt.outline) { if (opt.fill !== false) { drawPolygon(Object.assign(polyOpt, { outline: null, })) } drawPolygon(Object.assign(polyOpt, { pts: pts.slice(1), fill: false, })) return } drawPolygon(polyOpt) } function drawPolygon(opt: DrawPolygonOpt) { if (!opt.pts) { throw new Error("drawPolygon() requires property \"pts\".") } const npts = opt.pts.length if (npts < 3) { return } pushTransform() pushTranslate(opt.pos) pushScale(opt.scale) pushRotate(opt.angle) pushTranslate(opt.offset) if (opt.fill !== false) { const color = opt.color ?? Color.WHITE const verts = opt.pts.map((pt, i) => ({ pos: new Vec2(pt.x, pt.y), uv: new Vec2(0, 0), color: opt.colors ? (opt.colors[i] ? opt.colors[i].mult(color) : color) : color, opacity: opt.opacity ?? 1, })) // TODO: better triangulation const indices = [...Array(npts - 2).keys()] .map((n) => [0, n + 1, n + 2]) .flat() drawRaw(verts, opt.indices ?? indices, opt.fixed, gfx.defTex, opt.shader, opt.uniform) } if (opt.outline) { drawLines({ pts: [ ...opt.pts, opt.pts[0] ], radius: opt.radius, width: opt.outline.width, color: opt.outline.color, join: opt.outline.join, uniform: opt.uniform, fixed: opt.fixed, opacity: opt.opacity, }) } popTransform() } function drawStenciled(content: () => void, mask: () => void, test: number) { flush() gl.clear(gl.STENCIL_BUFFER_BIT) gl.enable(gl.STENCIL_TEST) // don't perform test, pure write gl.stencilFunc( gl.NEVER, 1, 0xFF, ) // always replace since we're writing to the buffer gl.stencilOp( gl.REPLACE, gl.REPLACE, gl.REPLACE, ) mask() flush() // perform test gl.stencilFunc( test, 1, 0xFF, ) // don't write since we're only testing gl.stencilOp( gl.KEEP, gl.KEEP, gl.KEEP, ) content() flush() gl.disable(gl.STENCIL_TEST) } function drawMasked(content: () => void, mask: () => void) { drawStenciled(content, mask, gl.EQUAL) } function drawSubtracted(content: () => void, mask: () => void) { drawStenciled(content, mask, gl.NOTEQUAL) } function getViewportScale() { return (gfx.viewport.width + gfx.viewport.height) / (gfx.width + gfx.height) } function drawUnscaled(content: () => void) { flush() const ow = gfx.width const oh = gfx.height gfx.width = gfx.viewport.width gfx.height = gfx.viewport.height content() flush() gfx.width = ow gfx.height = oh } function applyCharTransform(fchar: FormattedChar, tr: CharTransform) { if (tr.pos) fchar.pos = fchar.pos.add(tr.pos) if (tr.scale) fchar.scale = fchar.scale.scale(vec2(tr.scale)) if (tr.angle) fchar.angle += tr.angle if (tr.color && fchar.ch.length === 1) fchar.color = fchar.color.mult(tr.color) if (tr.opacity) fchar.opacity *= tr.opacity } // TODO: escape // eslint-disable-next-line const TEXT_STYLE_RE = /\[(?