// deno-fmt-ignore-file // deno-lint-ignore-file // This code was bundled using `deno bundle` and it's not recommended to edit it manually class GifReader { buf; p; width; height; globalPaletteOffset; globalPaletteSize; frames; loopCountValue; constructor(buf){ this.buf = buf; this.p = 0; this.width = 0; this.height = 0; this.globalPaletteOffset = null; this.globalPaletteSize = null; this.frames = []; this.loopCountValue = null; this.parseHeader(); this.parseFrames(); } numFrames() { return this.frames.length; } loopCount() { return this.loopCountValue; } frameInfo(frameNum) { if (frameNum < 0 || frameNum >= this.frames.length) { throw new Error("Frame index out of range."); } return this.frames[frameNum]; } decodeAndBlitFrameBGRA(frameNum, pixels) { const frame = this.frameInfo(frameNum); const numPixels = frame.width * frame.height; const indexStream = new Uint8Array(numPixels); GifReaderLZWOutputIndexStream(this.buf, frame.dataOffset, indexStream, numPixels); const paletteOffset = frame.paletteOffset; let trans = frame.transparentIndex; if (trans === null) trans = 256; const frameWidth = frame.width; const frameStride = this.width - frameWidth; let xLeft = frameWidth; const opBeg = (frame.y * this.width + frame.x) * 4; const opEnd = ((frame.y + frame.height) * this.width + frame.x) * 4; let op = opBeg; let scanStride = frameStride * 4; if (frame.interlaced === true) { scanStride += this.width * 4 * 7; } let interlaceSkip = 8; for(let i = 0, il = indexStream.length; i < il; ++i){ const index = indexStream[i]; if (xLeft === 0) { op += scanStride; xLeft = frameWidth; if (op >= opEnd) { scanStride = frameStride * 4 + this.width * 4 * (interlaceSkip - 1); op = opBeg + (frameWidth + frameStride) * (interlaceSkip << 1); interlaceSkip >>= 1; } } if (index === trans) { op += 4; } else { const r = this.buf[(paletteOffset || 0) + index * 3]; const g = this.buf[(paletteOffset || 0) + index * 3 + 1]; const b = this.buf[(paletteOffset || 0) + index * 3 + 2]; pixels[op++] = b; pixels[op++] = g; pixels[op++] = r; pixels[op++] = 255; } --xLeft; } } decodeAndBlitFrameRGBA(frameNum, pixels) { const frame = this.frameInfo(frameNum); const numPixels = frame.width * frame.height; const indexStream = new Uint8Array(numPixels); GifReaderLZWOutputIndexStream(this.buf, frame.dataOffset, indexStream, numPixels); const paletteOffset = frame.paletteOffset; let trans = frame.transparentIndex; if (trans === null) trans = 256; const frameWidth = frame.width; const frameStride = this.width - frameWidth; let xLeft = frameWidth; const opBeg = (frame.y * this.width + frame.x) * 4; const opEnd = ((frame.y + frame.height) * this.width + frame.x) * 4; let op = opBeg; let scanStride = frameStride * 4; if (frame.interlaced === true) { scanStride += this.width * 4 * 7; } let interlaceSkip = 8; for(let i = 0, il = indexStream.length; i < il; ++i){ const index = indexStream[i]; if (xLeft === 0) { op += scanStride; xLeft = frameWidth; if (op >= opEnd) { scanStride = frameStride * 4 + this.width * 4 * (interlaceSkip - 1); op = opBeg + (frameWidth + frameStride) * (interlaceSkip << 1); interlaceSkip >>= 1; } } if (index === trans) { op += 4; } else { const rI = (paletteOffset || 0) + index * 3; const r = this.buf[rI]; const g = this.buf[rI + 1]; const b = this.buf[rI + 2]; pixels[op++] = r; pixels[op++] = g; pixels[op++] = b; pixels[op++] = 255; } --xLeft; } } parseHeader() { if (this.buf[this.p++] !== 0x47 || this.buf[this.p++] !== 0x49 || this.buf[this.p++] !== 0x46 || this.buf[this.p++] !== 0x38 || (this.buf[this.p++] + 1 & 0xfd) !== 0x38 || this.buf[this.p++] !== 0x61) { throw new Error("Invalid GIF 87a/89a header."); } } parseLogicalScreenDescriptor() {} parseGlobalColorTable() {} parseFrames() { const width = this.buf[this.p++] | this.buf[this.p++] << 8; const height = this.buf[this.p++] | this.buf[this.p++] << 8; const pf0 = this.buf[this.p++]; const global_palette_flag = pf0 >> 7; const num_global_colors_pow2 = pf0 & 0x7; const num_global_colors = 1 << num_global_colors_pow2 + 1; this.buf[this.p++]; this.buf[this.p++]; let global_palette_offset = null; let global_palette_size = null; if (global_palette_flag) { global_palette_offset = this.p; global_palette_size = num_global_colors; this.p += num_global_colors * 3; } let no_eof = true; let delay = 0; let transparentIndex = null; let disposal = 0; let loopCount = null; this.width = width; this.height = height; while(no_eof && this.p < this.buf.length){ switch(this.buf[this.p++]){ case 0x21: switch(this.buf[this.p++]){ case 0xff: if (this.buf[this.p] !== 0x0b || this.buf[this.p + 1] == 0x4e && this.buf[this.p + 2] == 0x45 && this.buf[this.p + 3] == 0x54 && this.buf[this.p + 4] == 0x53 && this.buf[this.p + 5] == 0x43 && this.buf[this.p + 6] == 0x41 && this.buf[this.p + 7] == 0x50 && this.buf[this.p + 8] == 0x45 && this.buf[this.p + 9] == 0x32 && this.buf[this.p + 10] == 0x2e && this.buf[this.p + 11] == 0x30 && this.buf[this.p + 12] == 0x03 && this.buf[this.p + 13] == 0x01 && this.buf[this.p + 16] == 0) { this.p += 14; loopCount = this.buf[this.p++] | this.buf[this.p++] << 8; this.p++; } else { this.p += 12; while(true){ const block_size = this.buf[this.p++]; if (!(block_size >= 0)) throw Error("Invalid block size"); if (block_size === 0) break; this.p += block_size; } } break; case 0xf9: { if (this.buf[this.p++] !== 0x4 || this.buf[this.p + 4] !== 0) { throw new Error("Invalid graphics extension block."); } const pf1 = this.buf[this.p++]; delay = this.buf[this.p++] | this.buf[this.p++] << 8; transparentIndex = this.buf[this.p++]; if ((pf1 & 1) === 0) transparentIndex = null; disposal = pf1 >> 2 & 0x7; this.p++; break; } case 0x01: case 0xfe: while(true){ const block_size = this.buf[this.p++]; if (!(block_size >= 0)) throw Error("Invalid block size"); if (block_size === 0) break; this.p += block_size; } break; default: throw new Error("Unknown graphic control label: 0x" + this.buf[this.p - 1].toString(16)); } break; case 0x2c: { const x = this.buf[this.p++] | this.buf[this.p++] << 8; const y = this.buf[this.p++] | this.buf[this.p++] << 8; const w = this.buf[this.p++] | this.buf[this.p++] << 8; const h = this.buf[this.p++] | this.buf[this.p++] << 8; const pf2 = this.buf[this.p++]; const local_palette_flag = pf2 >> 7; const interlace_flag = pf2 >> 6 & 1; const num_local_colors_pow2 = pf2 & 0x7; const num_local_colors = 1 << num_local_colors_pow2 + 1; let palette_offset = global_palette_offset; let palette_size = global_palette_size; let has_local_palette = false; if (local_palette_flag) { has_local_palette = true; palette_offset = this.p; palette_size = num_local_colors; this.p += num_local_colors * 3; } const data_offset = this.p; this.p++; while(true){ const block_size = this.buf[this.p++]; if (!(block_size >= 0)) throw Error("Invalid block size"); if (block_size === 0) break; this.p += block_size; } this.frames.push({ x, y, width: w, height: h, hasLocalPalette: has_local_palette, paletteOffset: palette_offset, paletteSize: palette_size, dataOffset: data_offset, dataLength: this.p - data_offset, transparentIndex: transparentIndex, interlaced: !!interlace_flag, delay: delay, disposal: disposal }); break; } case 0x3b: no_eof = false; break; default: throw new Error("Unknown gif block: 0x" + this.buf[this.p - 1].toString(16)); } } } } function GifReaderLZWOutputIndexStream(codeStream, p, output, outputLength) { const minCodeSize = codeStream[p++]; const clear_code = 1 << minCodeSize; const eoi_code = clear_code + 1; let nextCode = eoi_code + 1; let curCodeSize = minCodeSize + 1; let codeMask = (1 << curCodeSize) - 1; let curShift = 0; let cur = 0; let op = 0; let subBlockSize = codeStream[p++]; const codeTable = new Int32Array(4096); let prevCode = null; while(true){ while(curShift < 16){ if (subBlockSize === 0) break; cur |= codeStream[p++] << curShift; curShift += 8; if (subBlockSize === 1) { subBlockSize = codeStream[p++]; } else { --subBlockSize; } } if (curShift < curCodeSize) { break; } const code = cur & codeMask; cur >>= curCodeSize; curShift -= curCodeSize; if (code === clear_code) { nextCode = eoi_code + 1; curCodeSize = minCodeSize + 1; codeMask = (1 << curCodeSize) - 1; prevCode = null; continue; } else if (code === eoi_code) { break; } const chaseCode = code < nextCode ? code : prevCode; let chaseLength = 0; let chase = chaseCode; while(chase > clear_code){ chase = codeTable[chase] >> 8; ++chaseLength; } const k = chase; const op_end = op + chaseLength + (chaseCode !== code ? 1 : 0); if (op_end > outputLength) { console.log("Warning, gif stream longer than expected."); return; } output[op++] = k; op += chaseLength; let b = op; if (chaseCode !== code) { output[op++] = k; } chase = chaseCode; while(chaseLength--){ chase = codeTable[chase]; output[--b] = chase & 0xff; chase >>= 8; } if (prevCode !== null && nextCode < 4096) { codeTable[nextCode++] = prevCode << 8 | k; if (nextCode >= codeMask + 1 && curCodeSize < 12) { ++curCodeSize; codeMask = codeMask << 1 | 1; } } prevCode = code; } if (op !== outputLength) { console.log("Warning, gif stream shorter than expected."); } return output; } function handleGIF(data) { const framesBase64 = []; const reader = new GifReader(data); for(let i = 0; i < reader.numFrames(); i++){ const frameData = reader.frameInfo(i); const canvas = document.createElement("canvas"); canvas.width = reader.width; canvas.height = reader.height; const ctx = canvas.getContext("2d"); const imageData = ctx.createImageData(reader.width, reader.height); reader.decodeAndBlitFrameRGBA(i, imageData.data); ctx.putImageData(imageData, 0, 0, frameData.x, frameData.y, frameData.width, frameData.height); framesBase64.push({ ...frameData, canvas }); } return { w: reader.width, h: reader.height, frames: framesBase64 }; } class GIFAnimation { origin; scale; frames; canvas; ctx; ready; constructor(url, origin, scale = 1){ this.origin = origin; this.scale = scale; this.frames = []; this.ready = false; this.totalAnimationTime = 0; this._frameCounter = 0; this.currentFrameIndex = 0; this.canvas = document.createElement("canvas"); this.init(url); } async init(url) { const res = await fetch(url); const buf = new Uint8Array(await res.arrayBuffer()); const gif = handleGIF(buf); this.frames = gif.frames; this.frameTimes = this.frames.map((f)=>f.delay); this.totalAnimationTime = this.frameTimes.reduce((a, b)=>a + b, 0); this.canvas.width = gif.w; this.canvas.height = gif.h; this.ctx = this.canvas.getContext("2d"); this.ready = true; } frameTimes; totalAnimationTime; _frameCounter; currentFrameIndex; draw(timeSinceLastFrame) { if (!this.ready) return; this._frameCounter += timeSinceLastFrame; const currentFrameDelay = this.frames[this.currentFrameIndex].delay * 10; while(this._frameCounter >= currentFrameDelay){ this._frameCounter -= currentFrameDelay; this.currentFrameIndex = (this.currentFrameIndex + 1) % this.frames.length; } const currentFrame = this.frames[this.currentFrameIndex]; doodler.drawImage(currentFrame.canvas, this.origin, this.canvas.width * this.scale, this.canvas.height * this.scale); } } const Constants = { TWO_PI: Math.PI * 2 }; class Vector { x; y; z; constructor(x = 0, y = 0, z = 0){ if (typeof x === "number") { this.x = x; this.y = y; this.z = z; } else { this.x = x.x; this.y = x.y || y; this.z = x.z || z; } } set(v, y, z) { if (arguments.length === 1 && typeof v !== "number") { this.set(v.x || v[0] || 0, v.y || v[1] || 0, v.z || v[2] || 0); } else { this.x = v; this.y = y || 0; this.z = z || 0; } } get() { return new Vector(this.x, this.y, this.z); } mag() { const x = this.x, y = this.y, z = this.z; return Math.sqrt(x * x + y * y + z * z); } magSq() { const x = this.x, y = this.y, z = this.z; return x * x + y * y + z * z; } setMag(v_or_len, len) { if (len === undefined) { len = v_or_len; this.normalize(); this.mult(len); } else { const v = v_or_len; v.normalize(); v.mult(len); return v; } } add(v, y, z) { if (arguments.length === 1 && typeof v !== "number") { this.x += v.x; this.y += v.y; this.z += v.z; } else if (arguments.length === 2) { this.x += v; this.y += y ?? 0; } else { this.x += v; this.y += y ?? 0; this.z += z ?? 0; } return this; } sub(v, y, z) { if (arguments.length === 1 && typeof v !== "number") { this.x -= v.x; this.y -= v.y; this.z -= v.z || 0; } else if (arguments.length === 2) { this.x -= v; this.y -= y ?? 0; } else { this.x -= v; this.y -= y ?? 0; this.z -= z ?? 0; } return this; } mult(v) { if (typeof v === "number") { this.x *= v; this.y *= v; this.z *= v; } else { this.x *= v.x; this.y *= v.y; this.z *= v.z; } return this; } div(v) { if (typeof v === "number") { this.x /= v; this.y /= v; this.z /= v; } else { this.x /= v.x; this.y /= v.y; this.z /= v.z; } return this; } rotate(angle) { const prev_x = this.x; const c = Math.cos(angle); const s = Math.sin(angle); this.x = c * this.x - s * this.y; this.y = s * prev_x + c * this.y; return this; } dist(v) { const dx = this.x - v.x, dy = this.y - v.y, dz = this.z - (v.z || 0); return Math.sqrt(dx * dx + dy * dy + dz * dz); } dot(v, y, z) { if (arguments.length === 1 && typeof v !== "number") { return this.x * v.x + this.y * v.y + this.z * v.z; } return this.x * v + this.y * y + this.z * z; } cross(v) { const x = this.x, y = this.y, z = this.z; return new Vector(y * v.z - v.y * z, z * v.x - v.z * x, x * v.y - v.x * y); } lerp(v_or_x, amt_or_y, z, amt) { const lerp_val = (start, stop, amt)=>{ return start + (stop - start) * amt; }; let x, y; if (arguments.length === 2 && typeof v_or_x !== "number") { amt = amt_or_y; x = v_or_x.x; y = v_or_x.y; z = v_or_x.z; } else { x = v_or_x; y = amt_or_y; } this.x = lerp_val(this.x, x, amt); this.y = lerp_val(this.y, y, amt); this.z = lerp_val(this.z, z, amt); return this; } normalize() { const m = this.mag(); if (m > 0) { this.div(m); } return this; } limit(high) { if (this.mag() > high) { this.normalize(); this.mult(high); } return this; } heading() { return -Math.atan2(-this.y, this.x); } heading2D() { return this.heading(); } toString() { return "[" + this.x + ", " + this.y + ", " + this.z + "]"; } array() { return [ this.x, this.y, this.z ]; } copy() { return new Vector(this.x, this.y, this.z); } drawDot(color) { if (!doodler) return; doodler.dot(this, { weight: 2, color: color || "red" }); } draw(origin) { if (!doodler) return; const startPoint = origin ? new Vector(origin) : new Vector(); doodler.line(startPoint, startPoint.copy().add(this.copy().normalize().mult(100))); } normal(v) { if (!v) return new Vector(-this.y, this.x); const dx = v.x - this.x; const dy = v.y - this.y; return new Vector(-dy, dx); } static fromAngle(angle, v) { if (v === undefined || v === null) { v = new Vector(); } v.x = Math.cos(angle); v.y = Math.sin(angle); return v; } static random2D(v) { return Vector.fromAngle(Math.random() * (Math.PI * 2), v); } static random3D(v) { const angle = Math.random() * Constants.TWO_PI; const vz = Math.random() * 2 - 1; const mult = Math.sqrt(1 - vz * vz); const vx = mult * Math.cos(angle); const vy = mult * Math.sin(angle); if (v === undefined || v === null) { v = new Vector(vx, vy, vz); } else { v.set(vx, vy, vz); } return v; } static dist(v1, v2) { return v1.dist(v2); } static dot(v1, v2) { return v1.dot(v2); } static cross(v1, v2) { return v1.cross(v2); } static add(v1, v2) { return new Vector(v1.x + v2.x, v1.y + v2.y, v1.z + v2.z); } static sub(v1, v2) { return new Vector(v1.x - v2.x, v1.y - v2.y, v1.z - v2.z); } static angleBetween(v1, v2) { return Math.acos(v1.dot(v2) / Math.sqrt(v1.magSq() * v2.magSq())); } static lerp(v1, v2, amt) { const val = new Vector(v1.x, v1.y, v1.z); val.lerp(v2, amt); return val; } static vectorProjection(v1, v2) { v2 = v2.copy(); v2.normalize(); const sp = v1.dot(v2); v2.mult(sp); return v2; } static vectorProjectionAndDot(v1, v2) { v2 = v2.copy(); v2.normalize(); const sp = v1.dot(v2); v2.mult(sp); return [ v2, sp ]; } static hypot2(a, b) { return Vector.dot(Vector.sub(a, b), Vector.sub(a, b)); } } class OriginVector extends Vector { origin; get halfwayPoint() { return { x: this.mag() / 2 * Math.sin(this.heading()) + this.origin.x, y: this.mag() / 2 * Math.cos(this.heading()) + this.origin.y }; } constructor(origin, p){ super(p.x, p.y, p.z); this.origin = origin; } static from(origin, p) { const v = { x: p.x - origin.x, y: p.y - origin.y }; return new OriginVector(origin, v); } } const easeInOut = (x)=>x < 0.5 ? 4 * x * x * x : 1 - Math.pow(-2 * x + 2, 3) / 2; const map = (value, x1, y1, x2, y2)=>(value - x1) * (y2 - x2) / (y1 - x1) + x2; class Doodler { ctx; _canvas; layers = []; bg; framerate; get width() { return this.ctx.canvas.width; } get height() { return this.ctx.canvas.height; } draggables = []; clickables = []; dragTarget; constructor({ width, height, fillScreen, canvas, bg, framerate }, postInit){ if (!canvas) { canvas = document.createElement("canvas"); document.body.append(canvas); } this.bg = bg || "white"; this.framerate = framerate; canvas.width = fillScreen ? document.body.clientWidth : width; canvas.height = fillScreen ? document.body.clientHeight : height; if (fillScreen) { const resizeObserver = new ResizeObserver((entries)=>{ for (const entry of entries){ this._canvas.width = entry.target.clientWidth; this._canvas.height = entry.target.clientHeight; } }); resizeObserver.observe(document.body); } this._canvas = canvas; const ctx = canvas.getContext("2d"); if (!ctx) throw "Unable to initialize Doodler: Canvas context not found"; this.ctx = ctx; postInit?.(this.ctx); } init() { this._canvas.addEventListener("mousedown", (e)=>this.onClick(e)); this._canvas.addEventListener("mouseup", (e)=>this.offClick(e)); this._canvas.addEventListener("mousemove", (e)=>this.onDrag(e)); this.startDrawLoop(); } timer; lastFrameAt = 0; startDrawLoop() { this.lastFrameAt = Date.now(); if (this.framerate) { this.timer = setInterval(()=>this.draw(Date.now()), 1000 / this.framerate); } else { const cb = (t)=>{ this.draw(t); requestAnimationFrame(cb); }; requestAnimationFrame(cb); } } draw(time) { const frameTime = time - this.lastFrameAt; this.ctx.clearRect(0, 0, this.width, this.height); this.ctx.fillStyle = this.bg; this.ctx.fillRect(0, 0, this.width, this.height); for (const [i, l] of (this.layers || []).entries()){ l(this.ctx, i, frameTime); this.drawDeferred(); } this.drawUI(); this.lastFrameAt = time; } createLayer(layer) { this.layers.push(layer); } deleteLayer(layer) { this.layers = this.layers.filter((l)=>l !== layer); } moveLayer(layer, index) { let temp = this.layers.filter((l)=>l !== layer); temp = [ ...temp.slice(0, index), layer, ...temp.slice(index) ]; this.layers = temp; } line(start, end, style) { this.setStyle(style); this.ctx.beginPath(); this.ctx.moveTo(start.x, start.y); this.ctx.lineTo(end.x, end.y); this.ctx.stroke(); } dot(at, style) { this.setStyle({ ...style, weight: 1 }); this.ctx.beginPath(); this.ctx.arc(at.x, at.y, style?.weight || 1, 0, Constants.TWO_PI); this.ctx.fill(); } drawCircle(at, radius, style) { this.setStyle(style); this.ctx.beginPath(); this.ctx.arc(at.x, at.y, radius, 0, Constants.TWO_PI); this.ctx.stroke(); } fillCircle(at, radius, style) { this.setStyle(style); this.ctx.beginPath(); this.ctx.arc(at.x, at.y, radius, 0, Constants.TWO_PI); this.ctx.fill(); } drawRect(at, width, height, style) { this.setStyle(style); this.ctx.strokeRect(at.x, at.y, width, height); } fillRect(at, width, height, style) { this.setStyle(style); this.ctx.fillRect(at.x, at.y, width, height); } drawSquare(at, size, style) { this.drawRect(at, size, size, style); } fillSquare(at, size, style) { this.fillRect(at, size, size, style); } drawCenteredRect(at, width, height, style) { this.ctx.save(); this.ctx.translate(-width / 2, -height / 2); this.drawRect(at, width, height, style); this.ctx.restore(); } fillCenteredRect(at, width, height, style) { this.ctx.save(); this.ctx.translate(-width / 2, -height / 2); this.fillRect(at, width, height, style); this.ctx.restore(); } drawCenteredSquare(at, size, style) { this.drawCenteredRect(at, size, size, style); } fillCenteredSquare(at, size, style) { this.fillCenteredRect(at, size, size, style); } drawBezier(a, b, c, d, style) { this.setStyle(style); this.ctx.beginPath(); this.ctx.moveTo(a.x, a.y); this.ctx.bezierCurveTo(b.x, b.y, c.x, c.y, d.x, d.y); this.ctx.stroke(); } drawRotated(origin, angle, cb) { this.ctx.save(); this.ctx.translate(origin.x, origin.y); this.ctx.rotate(angle); this.ctx.translate(-origin.x, -origin.y); cb(); this.ctx.restore(); } drawScaled(scale, cb) { this.ctx.save(); this.ctx.transform(scale, 0, 0, scale, 0, 0); cb(); this.ctx.restore(); } drawWithAlpha(alpha, cb) { this.ctx.save(); this.ctx.globalAlpha = Math.min(Math.max(alpha, 0), 1); cb(); this.ctx.restore(); } drawImage(img, at, w, h) { w && h ? this.ctx.drawImage(img, at.x, at.y, w, h) : this.ctx.drawImage(img, at.x, at.y); } drawImageWithOutline(img, at, w, h, style) { this.ctx.save(); const s = (typeof w === "number" || !w ? style?.weight : w.weight) || 1; this.ctx.shadowColor = (typeof w === "number" || !w ? style?.color || style?.fillColor : w.color || w.strokeColor) || "red"; this.ctx.shadowBlur = 0; for(let x = -s; x <= s; x++){ for(let y = -s; y <= s; y++){ this.ctx.shadowOffsetX = x; this.ctx.shadowOffsetY = y; typeof w === "number" && h ? this.ctx.drawImage(img, at.x, at.y, w, h) : this.ctx.drawImage(img, at.x, at.y); } } this.ctx.restore(); } drawSprite(img, spritePos, sWidth, sHeight, at, width, height) { this.ctx.drawImage(img, spritePos.x, spritePos.y, sWidth, sHeight, at.x, at.y, width, height); } deferredDrawings = []; deferDrawing(cb) { this.deferredDrawings.push(cb); } drawDeferred() { while(this.deferredDrawings.length){ this.deferredDrawings.pop()?.(); } } setStyle(style) { const ctx = this.ctx; ctx.fillStyle = style?.color || style?.fillColor || "black"; ctx.strokeStyle = style?.color || style?.strokeColor || "black"; ctx.lineWidth = style?.weight || 1; ctx.textAlign = style?.textAlign || ctx.textAlign; ctx.textBaseline = style?.textBaseline || ctx.textBaseline; } fillText(text, pos, maxWidth, style) { this.setStyle(style); this.ctx.fillText(text, pos.x, pos.y, maxWidth); } strokeText(text, pos, maxWidth, style) { this.setStyle(style); this.ctx.strokeText(text, pos.x, pos.y, maxWidth); } clearRect(at, width, height) { this.ctx.clearRect(at.x, at.y, width, height); } mouseX = 0; mouseY = 0; registerDraggable(point, radius, style) { if (this.draggables.find((d)=>d.point === point)) return; const id = this.addUIElement("circle", point, radius, { fillColor: "#5533ff50", strokeColor: "#5533ff50" }); this.draggables.push({ point, radius, style, id }); } unregisterDraggable(point) { for (const d of this.draggables){ if (d.point === point) { this.removeUIElement(d.id); } } this.draggables = this.draggables.filter((d)=>d.point !== point); } registerClickable(p1, p2, cb) { const top = Math.min(p1.y, p2.y); const left = Math.min(p1.x, p2.x); const bottom = Math.max(p1.y, p2.y); const right = Math.max(p1.x, p2.x); this.clickables.push({ onClick: cb, checkBound: (p)=>p.y >= top && p.x >= left && p.y <= bottom && p.x <= right }); } unregisterClickable(cb) { this.clickables = this.clickables.filter((c)=>c.onClick !== cb); } addDragEvents({ onDragEnd, onDragStart, onDrag, point }) { const d = this.draggables.find((d)=>d.point === point); if (d) { d.onDragEnd = onDragEnd; d.onDragStart = onDragStart; d.onDrag = onDrag; } } onClick(e) { const mouse = new Vector(this.mouseX, this.mouseY); for (const d of this.draggables){ if (d.point.dist(mouse) <= d.radius) { d.beingDragged = true; d.onDragStart?.call(null); this.dragTarget = d; } else d.beingDragged = false; } for (const c of this.clickables){ if (c.checkBound(mouse)) { c.onClick(); } } } offClick(e) { for (const d of this.draggables){ d.beingDragged = false; d.onDragEnd?.call(null); } this.dragTarget = undefined; } onDrag(e) { this._canvas.getBoundingClientRect(); this.mouseX = e.offsetX; this.mouseY = e.offsetY; for (const d of this.draggables.filter((d)=>d.beingDragged)){ d.point.add(e.movementX, e.movementY); d.onDrag && d.onDrag({ x: e.movementX, y: e.movementY }); } } uiElements = new Map(); uiDrawing = { rectangle: (...args)=>{ !args[3].noFill && this.fillRect(args[0], args[1], args[2], args[3]); !args[3].noStroke && this.drawRect(args[0], args[1], args[2], args[3]); }, square: (...args)=>{ !args[2].noFill && this.fillSquare(args[0], args[1], args[2]); !args[2].noStroke && this.drawSquare(args[0], args[1], args[2]); }, circle: (...args)=>{ !args[2].noFill && this.fillCircle(args[0], args[1], args[2]); !args[2].noStroke && this.drawCircle(args[0], args[1], args[2]); } }; drawUI() { for (const [shape, ...args] of this.uiElements.values()){ this.uiDrawing[shape].apply(null, args); } } addUIElement(shape, ...args) { const id = crypto.randomUUID(); for (const arg of args){ delete arg.color; } this.uiElements.set(id, [ shape, ...args ]); return id; } removeUIElement(id) { this.uiElements.delete(id); } } class ZoomableDoodler extends Doodler { scale = 1; dragging = false; origin = { x: 0, y: 0 }; mouse = { x: 0, y: 0 }; previousTouchLength; touchTimer; hasDoubleTapped = false; zooming = false; scaleAround = { x: 0, y: 0 }; maxScale = 4; minScale = 1; constructor(options, postInit){ super(options, postInit); this._canvas.addEventListener("wheel", (e)=>{ this.scaleAtMouse(e.deltaY < 0 ? 1.1 : .9); if (this.scale === 1) { this.origin.x = 0; this.origin.y = 0; } }); this._canvas.addEventListener("dblclick", (e)=>{ e.preventDefault(); this.scale = 1; this.origin.x = 0; this.origin.y = 0; this.ctx.setTransform(1, 0, 0, 1, 0, 0); }); this._canvas.addEventListener("mousedown", (e)=>{ e.preventDefault(); this.dragging = true; }); this._canvas.addEventListener("mouseup", (e)=>{ e.preventDefault(); this.dragging = false; }); this._canvas.addEventListener("mouseleave", (_e)=>{ this.dragging = false; }); this._canvas.addEventListener("mousemove", (e)=>{ const prev = this.mouse; this.mouse = { x: e.offsetX, y: e.offsetY }; if (this.dragging && !this.dragTarget) this.drag(prev); }); this._canvas.addEventListener("touchstart", (e)=>{ e.preventDefault(); if (e.touches.length === 1) { const t1 = e.touches.item(0); if (t1) { this.mouse = this.getTouchOffset({ x: t1.clientX, y: t1.clientY }); } } else { clearTimeout(this.touchTimer); } }); this._canvas.addEventListener("touchend", (e)=>{ if (e.touches.length !== 2) { this.previousTouchLength = undefined; } switch(e.touches.length){ case 1: break; case 0: if (!this.zooming) { this.events.get("touchend")?.map((cb)=>cb(e)); } break; } this.dragging = e.touches.length === 1; clearTimeout(this.touchTimer); }); this._canvas.addEventListener("touchmove", (e)=>{ e.preventDefault(); if (e.touches.length === 2) { const t1 = e.touches.item(0); const t2 = e.touches.item(1); if (t1 && t2) { const vect = OriginVector.from(this.getTouchOffset({ x: t1.clientX, y: t1.clientY }), { x: t2.clientX, y: t2.clientY }); if (this.previousTouchLength) { const diff = this.previousTouchLength - vect.mag(); this.scaleAt(vect.halfwayPoint, diff < 0 ? 1.01 : .99); this.scaleAround = { ...vect.halfwayPoint }; } this.previousTouchLength = vect.mag(); } } if (e.touches.length === 1) { this.dragging === true; const t1 = e.touches.item(0); if (t1) { const prev = this.mouse; this.mouse = this.getTouchOffset({ x: t1.clientX, y: t1.clientY }); this.drag(prev); } } }); this._canvas.addEventListener("touchstart", (e)=>{ if (e.touches.length !== 1) return false; if (!this.hasDoubleTapped) { this.hasDoubleTapped = true; setTimeout(()=>this.hasDoubleTapped = false, 300); return false; } if (this.scale > 1) { this.frameCounter = map(this.scale, this.maxScale, 1, 0, 59); this.zoomDirection = -1; } else { this.frameCounter = 0; this.zoomDirection = 1; } if (this.zoomDirection > 0) { this.scaleAround = { ...this.mouse }; } this.events.get("doubletap")?.map((cb)=>cb(e)); }); } worldToScreen(x, y) { x = x * this.scale + this.origin.x; y = y * this.scale + this.origin.y; return { x, y }; } screenToWorld(x, y) { x = (x - this.origin.x) / this.scale; y = (y - this.origin.y) / this.scale; return { x, y }; } scaleAtMouse(scaleBy) { if (this.scale === this.maxScale && scaleBy > 1) return; this.scaleAt({ x: this.mouse.x, y: this.mouse.y }, scaleBy); } scaleAt(p, scaleBy) { this.scale = Math.min(Math.max(this.scale * scaleBy, this.minScale), this.maxScale); this.origin.x = p.x - (p.x - this.origin.x) * scaleBy; this.origin.y = p.y - (p.y - this.origin.y) * scaleBy; this.constrainOrigin(); } moveOrigin(motion) { if (this.scale > 1) { this.origin.x += motion.x; this.origin.y += motion.y; this.constrainOrigin(); } } drag(prev) { if (this.scale > 1) { const xOffset = this.mouse.x - prev.x; const yOffset = this.mouse.y - prev.y; this.origin.x += xOffset; this.origin.y += yOffset; this.constrainOrigin(); } } constrainOrigin() { this.origin.x = Math.min(Math.max(this.origin.x, -this._canvas.width * this.scale + this._canvas.width), 0); this.origin.y = Math.min(Math.max(this.origin.y, -this._canvas.height * this.scale + this._canvas.height), 0); } draw(time) { this.ctx.setTransform(this.scale, 0, 0, this.scale, this.origin.x, this.origin.y); this.animateZoom(); this.ctx.fillStyle = this.bg; this.ctx.fillRect(0, 0, this.width / this.scale, this.height / this.scale); super.draw(time); } getTouchOffset(p) { const { x, y } = this._canvas.getBoundingClientRect(); const offsetX = p.x - x; const offsetY = p.y - y; return { x: offsetX, y: offsetY }; } onDrag(e) { const d = { ...e, movementX: e.movementX / this.scale, movementY: e.movementY / this.scale }; super.onDrag(d); const { x, y } = this.screenToWorld(e.offsetX, e.offsetY); this.mouseX = x; this.mouseY = y; } zoomDirection = -1; frameCounter = 60; animateZoom() { if (this.frameCounter < 60) { const frame = easeInOut(map(this.frameCounter, 0, 59, 0, 1)); switch(this.zoomDirection){ case 1: { this.scale = map(frame, 0, 1, 1, this.maxScale); } break; case -1: { this.scale = map(frame, 0, 1, this.maxScale, 1); } break; } this.origin.x = this.scaleAround.x - this.scaleAround.x * this.scale; this.origin.y = this.scaleAround.y - this.scaleAround.y * this.scale; this.constrainOrigin(); this.frameCounter++; } } events = new Map(); registerEvent(eventName, cb) { let events = this.events.get(eventName); if (!events) events = this.events.set(eventName, []).get(eventName); events.push(cb); } } const init = (opt, zoomable, postInit)=>{ if (window.doodler) { throw "Doodler has already been initialized in this window"; } window.doodler = zoomable ? new ZoomableDoodler(opt, postInit) : new Doodler(opt, postInit); window.doodler.init(); }; class Polygon { points; center; constructor(points){ this.points = points.map((p)=>new Vector(p)); this.center = this.calcCenter(); } draw(color) { for(let i = 0; i < this.points.length; i++){ const p1 = this.points[i]; const p2 = this.points.at(i - this.points.length + 1); doodler.line(p1.copy().add(this.center), p2.copy().add(this.center), { color }); } } calcCenter() { if (!this.points.length) return new Vector(); const center = new Vector(); for (const point of this.points){ center.add(point); } center.div(this.points.length); return center; } _circularBoundingBox; get circularBoundingBox() { this._circularBoundingBox = this.calculateCircularBoundingBox(); return this._circularBoundingBox; } calculateCircularBoundingBox() { let greatestDistance = 0; for (const p of this.points){ greatestDistance = Math.max(p.copy().add(this.center).dist(this.center), greatestDistance); } return { center: this.center.copy(), radius: greatestDistance }; } _aabb; get AABB() { this._aabb = this.recalculateAABB(); return this._aabb; } recalculateAABB() { let smallestX, biggestX, smallestY, biggestY; smallestX = smallestY = Infinity; biggestX = biggestY = -Infinity; for (const p of this.points){ const temp = p.copy().add(this.center); smallestX = Math.min(temp.x, smallestX); biggestX = Math.max(temp.x, biggestX); smallestY = Math.min(temp.y, smallestY); biggestY = Math.max(temp.y, biggestY); } return { x: smallestX + this.center.x, y: smallestY + this.center.y, w: biggestX - smallestX, h: biggestY - smallestY }; } static createPolygon(sides = 3, radius = 100) { sides = Math.round(sides); if (sides < 3) { throw "You need at least 3 sides for a polygon"; } const poly = new Polygon([]); const rotangle = Math.PI * 2 / sides; let angle = 0; for(let i = 0; i < sides; i++){ angle = i * rotangle + (Math.PI - rotangle) * 0.5; const pt = new Vector(Math.cos(angle) * radius, Math.sin(angle) * radius); poly.points.push(pt); } poly.center = poly.calcCenter(); return poly; } getEdges() { const edges = []; for(let i = 0; i < this.points.length; i++){ const nextIndex = (i + 1) % this.points.length; const edge = this.points[nextIndex].copy().add(this.center).sub(this.points[i].copy().add(this.center)); edges.push(edge); } return edges; } getNearestPoint(p) { let nearest = this.points[0]; for (const point of this.points){ if (p.dist(point) < p.dist(nearest)) nearest = point; } return nearest.copy().add(this.center); } } function satCollisionCircle(p, circle) { for (const edge of p.getEdges()){ const axis = edge.copy().normal().normalize(); const proj1 = projectPolygonOntoAxis(p, axis); const proj2 = projectCircleOntoAxis(circle, axis); if (!overlap(proj1, proj2)) return false; } const center = new Vector(circle.center); const nearest = p.getNearestPoint(center); const axis = nearest.copy().sub(center).normalize(); const proj1 = projectPolygonOntoAxis(p, axis); const proj2 = projectCircleOntoAxis(circle, axis); if (!overlap(proj1, proj2)) return false; return true; } function projectPolygonOntoAxis(p, axis) { let min = Infinity; let max = -Infinity; for (const point of p.points){ const dotProduct = point.copy().add(p.center).dot(axis); min = Math.min(min, dotProduct); max = Math.max(max, dotProduct); } return { min, max }; } function projectCircleOntoAxis(c, axis) { const dot = new Vector(c.center).dot(axis); const min = dot - c.radius; const max = dot + c.radius; return { min, max }; } function overlap(proj1, proj2) { return proj1.min <= proj2.max && proj1.max >= proj2.min; } class SplineSegment { points; length; constructor(points){ this.points = points; this.length = this.calculateApproxLength(100); } draw(color) { const [a, b, c, d] = this.points; doodler.drawBezier(a, b, c, d, { strokeColor: color || "#ffffff50" }); } getPointAtT(t) { const [a, b, c, d] = this.points; const res = a.copy(); res.add(Vector.add(a.copy().mult(-3), b.copy().mult(3)).mult(t)); res.add(Vector.add(Vector.add(a.copy().mult(3), b.copy().mult(-6)), c.copy().mult(3)).mult(Math.pow(t, 2))); res.add(Vector.add(Vector.add(a.copy().mult(-1), b.copy().mult(3)), Vector.add(c.copy().mult(-3), d.copy())).mult(Math.pow(t, 3))); return res; } getClosestPoint(v) { const resolution = 1 / 25; let closest = this.points[0]; let closestDistance = this.points[0].dist(v); let closestT = 0; for(let i = 0; i < 25; i++){ const point = this.getPointAtT(i * resolution); const distance = v.dist(point); if (distance < closestDistance) { closest = point; closestDistance = distance; closestT = i * resolution; } } return [ closest, closestDistance, closestT ]; } getPointsWithinRadius(v, r) { const points = []; const resolution = 1 / 25; for(let i = 0; i < 25 + 1; i++){ const point = this.getPointAtT(i * resolution); const distance = v.dist(point); if (distance < r) { points.push([ i * resolution, this ]); } } return points; } tangent(t) { const [a, b, c, d] = this.points; const res = Vector.sub(b, a).mult(3 * Math.pow(1 - t, 2)); res.add(Vector.add(Vector.sub(c, b).mult(6 * (1 - t) * t), Vector.sub(d, c).mult(3 * Math.pow(t, 2)))); return res; } doesIntersectCircle(x, y, r) { const v = new Vector(x, y); const resolution = 1 / 25; let distance = Infinity; let t; for(let i = 0; i < 25 - 1; i++){ const a = this.getPointAtT(i * resolution); const b = this.getPointAtT((i + 1) * resolution); const ac = Vector.sub(v, a); const ab = Vector.sub(b, a); const d = Vector.add(Vector.vectorProjection(ac, ab), a); const ad = Vector.sub(d, a); const k = Math.abs(ab.x) > Math.abs(ab.y) ? ad.x / ab.x : ad.y / ab.y; let dist; if (k <= 0.0) { dist = Vector.hypot2(v, a); } else if (k >= 1.0) { dist = Vector.hypot2(v, b); } dist = Vector.hypot2(v, d); if (dist < distance) { distance = dist; t = i * resolution; } } if (distance < r) return t; return false; } intersectsCircle(circleCenter, radius) { for(let i = 0; i < 100; i++){ const t1 = i / 100; const t2 = (i + 1) / 100; const segmentStart = this.getPointAtT(t1); const segmentEnd = this.getPointAtT(t2); const segmentLength = Math.sqrt((segmentEnd.x - segmentStart.x) ** 2 + (segmentEnd.y - segmentStart.y) ** 2); const resolution = Math.max(10, Math.ceil(100 * (segmentLength / radius))); for(let j = 0; j <= resolution; j++){ const t = j / resolution; const point = this.getPointAtT(t); const distance = Math.sqrt((point.x - circleCenter.x) ** 2 + (point.y - circleCenter.y) ** 2); if (distance <= radius) { return true; } } } return false; } calculateApproxLength(resolution = 25) { const stepSize = 1 / resolution; const points = []; for(let i = 0; i <= resolution; i++){ const current = stepSize * i; points.push(this.getPointAtT(current)); } this.length = points.reduce((acc, cur)=>{ const prev = acc.prev; acc.prev = cur; if (!prev) return acc; acc.length += cur.dist(prev); return acc; }, { prev: undefined, length: 0 }).length; return this.length; } calculateEvenlySpacedPoints(spacing, resolution = 1) { const points = []; points.push(this.points[0]); let prev = points[0]; let distSinceLastEvenPoint = 0; let t = 0; const div = Math.ceil(this.length * resolution * 10); while(t < 1){ t += 1 / div; const point = this.getPointAtT(t); distSinceLastEvenPoint += prev.dist(point); if (distSinceLastEvenPoint >= spacing) { const overshoot = distSinceLastEvenPoint - spacing; const evenPoint = Vector.add(point, Vector.sub(point, prev).normalize().mult(overshoot)); distSinceLastEvenPoint = overshoot; points.push(evenPoint); prev = evenPoint; } prev = point; } return points; } _aabb; get AABB() { if (!this._aabb) { this._aabb = this.recalculateAABB(); } return this._aabb; } recalculateAABB() { let minX = Infinity; let minY = Infinity; let maxX = -Infinity; let maxY = -Infinity; for(let i = 0; i < 100; i++){ const t = i / 100; const point = this.getPointAtT(t); minX = Math.min(minX, point.x); minY = Math.min(minY, point.y); maxX = Math.max(maxX, point.x); maxY = Math.max(maxY, point.y); } return { x: minX, y: minY, w: maxX - minX, h: maxY - minY }; } } init({ fillScreen: true, bg: "#333" }, true, (ctx)=>{ ctx.imageSmoothingEnabled = false; }); doodler.minScale = .1; const img = new Image(); img.src = "./pixel fire.gif"; const p = new Vector(500, 500); new GIFAnimation("./fire-joypixels.gif", p, .5); const spline = new SplineSegment([ new Vector({ x: -25, y: -25 }).mult(10).add(p), new Vector({ x: 25, y: -25 }).mult(10).add(p), new Vector({ x: -25, y: -25 }).mult(10).add(p), new Vector({ x: -25, y: 25 }).mult(10).add(p) ]); const poly = Polygon.createPolygon(4); const poly2 = Polygon.createPolygon(4); poly2.center = p.copy().add(100, 100); doodler.createLayer((c, i, t)=>{ c.translate(500, 500); for(let i = 0; i < c.canvas.width; i += 50){ for(let j = 0; j < c.canvas.height; j += 50){ doodler.drawSquare(new Vector(i, j), 50, { color: "#00000010" }); } } const intersects = satCollisionCircle(poly, poly2.circularBoundingBox); const color = intersects ? "red" : "aqua"; spline.draw(color); poly.draw(color); poly2.draw(color); const [gamepad] = navigator.getGamepads(); if (gamepad) { const leftX = gamepad.axes[0]; const leftY = gamepad.axes[1]; const rightX = gamepad.axes[2]; const rightY = gamepad.axes[3]; let lMulti = 10; const lMod = new Vector(Math.min(Math.max(leftX - 0.05, 0), leftX + 0.05), Math.min(Math.max(leftY - 0.05, 0), leftY + 0.05)); poly.center.add(lMod.mult(lMulti)); let rMulti = 10; const rMod = new Vector(Math.min(Math.max(rightX - 0.05, 0), rightX + 0.05), Math.min(Math.max(rightY - 0.05, 0), rightY + 0.05)); poly2.center.add(rMod.mult(rMulti)); } });