commit 627b3aeb050604206c41cc26cf8e6a2b6235a5b1
parent 20cc9a3c2939125fc9eec128caee45c88cbb542a
Author: Hunter
Date:   Thu, 23 Jul 2026 23:31:42 -0400

add multiplayer via serve.py

Diffstat:
A.gitignore | 2++
Mindex.html | 1402+------------------------------------------------------------------------------
Mreadme.md | 9++++++---
Aresources/brush.js | 141+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aresources/canvas.js | 256+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aresources/input.js | 576+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aresources/io.js | 269+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aresources/net.js | 245+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aresources/render.js | 237+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aresources/styles.css | 22++++++++++++++++++++++
Aresources/view.js | 93+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aserve.py | 477+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
12 files changed, 2333 insertions(+), 1396 deletions(-)

diff --git a/.gitignore b/.gitignore @@ -0,0 +1,2 @@ +__pycache__/ +.DS_Store diff --git a/index.html b/index.html @@ -5,1401 +5,17 @@ <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover"> <title>animus</title> <link id="favicon" rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1 1'%3E%3Crect width='1' height='1' fill='white'/%3E%3C/svg%3E"> -<style> - html, body { - margin: 0; - padding: 0; - width: 100%; - height: 100%; - overflow: hidden; - background: #000; - cursor: none; - overscroll-behavior: none; - touch-action: none; - -webkit-user-select: none; - user-select: none; - -webkit-touch-callout: none; - } - canvas { - display: block; - position: absolute; - top: 0; - left: 0; - image-rendering: pixelated; - image-rendering: crisp-edges; - } -</style> +<link rel="stylesheet" href="resources/styles.css"> </head> <body> <canvas id="view"></canvas> -<script> -(() => { - const view = document.getElementById('view'); - const vctx = view.getContext('2d', { alpha: false }); - - const CHUNK = 256; - let frames = [new Map()]; - let frameIdx = 0; - let chunks = frames[0]; - let dir = 1; // playback direction: 1 forward, -1 reverse - let onionskin = false; - let playing = false; - let playTimer = null; - let frameInterval = 250; - let lastTap = 0; - - function chunkKey(cx, cy) { return cx + ',' + cy; } - - function getOrCreateChunk(cx, cy, map = chunks) { - const k = chunkKey(cx, cy); - let c = map.get(k); - if (c) return c; - const cnv = document.createElement('canvas'); - cnv.width = CHUNK; - cnv.height = CHUNK; - const cctx = cnv.getContext('2d', { alpha: false }); - cctx.fillStyle = '#000'; - cctx.fillRect(0, 0, CHUNK, CHUNK); - c = { canvas: cnv, ctx: cctx }; - map.set(k, c); - return c; - } - - function paintRect(wx, wy, w, h, color) { - const x0 = wx, y0 = wy, x1 = wx + w, y1 = wy + h; - const cx0 = Math.floor(x0 / CHUNK); - const cy0 = Math.floor(y0 / CHUNK); - const cx1 = Math.floor((x1 - 1) / CHUNK); - const cy1 = Math.floor((y1 - 1) / CHUNK); - for (let cy = cy0; cy <= cy1; cy++) { - for (let cx = cx0; cx <= cx1; cx++) { - const c = getOrCreateChunk(cx, cy); - const lx = Math.max(x0, cx * CHUNK) - cx * CHUNK; - const ly = Math.max(y0, cy * CHUNK) - cy * CHUNK; - const rx = Math.min(x1, (cx + 1) * CHUNK) - cx * CHUNK; - const ry = Math.min(y1, (cy + 1) * CHUNK) - cy * CHUNK; - c.ctx.fillStyle = color; - c.ctx.fillRect(lx, ly, rx - lx, ry - ly); - } - } - } - - function readPixel(wx, wy) { - const cx = Math.floor(wx / CHUNK); - const cy = Math.floor(wy / CHUNK); - const k = chunkKey(cx, cy); - const c = chunks.get(k); - if (!c) return [0, 0, 0]; - const lx = wx - cx * CHUNK; - const ly = wy - cy * CHUNK; - const d = c.ctx.getImageData(lx, ly, 1, 1).data; - return [d[0], d[1], d[2]]; - } - - function setFrame(i) { - frameIdx = ((i % frames.length) + frames.length) % frames.length; - chunks = frames[frameIdx]; - // a held pick tracks the frame under it as frames change - if (picking) pickAt(curX, curY); - requestDraw(); - } - - function addFrame() { - const at = dir === 1 ? frameIdx + 1 : frameIdx; - frames.splice(at, 0, new Map()); - setFrame(at); - } - - function deleteFrame() { - if (frames.length === 1) return; - frames.splice(frameIdx, 1); - if (playing && frames.length === 1) stopPlayback(); - setFrame(Math.min(frameIdx, frames.length - 1)); - } - - function stopPlayback() { - playing = false; - clearTimeout(playTimer); - requestDraw(); - } - - function startPlayback() { - if (frames.length === 1) return; - playing = true; - playTimer = setTimeout(function step() { - setFrame(frameIdx + dir); - playTimer = setTimeout(step, frameInterval); - }, frameInterval); - } - - function tapArrow(d) { - dir = d; - const now = performance.now(); - if (lastTap && now - lastTap <= 10000) frameInterval = now - lastTap; - lastTap = now; - if (playing) { stopPlayback(); return; } - setFrame(frameIdx + d); - } - - let dpr = window.devicePixelRatio || 1; - let cssW = 0, cssH = 0; - - const ZOOM_STOPS = [2, 3, 4, 5, 6, 8, 11, 15, 21, 30]; - const MIN_ZOOM = ZOOM_STOPS[0]; - const ZOOM_SENSITIVITY = 3.5; // fractional levels per unit log(pinch factor) - let zoomIdx = 0; - let zoom = MIN_ZOOM; - - let camX = 0, camY = 0; - - let curX = 0, curY = 0; - // used to keep the brush pinned under the real cursor while panning, since - // the OS does not emit pointermove events when only the camera moves. - let curClientX = null, curClientY = null; - let mouseInside = false; - let cursorAlpha = 0; - let cursorFadeFrom = 0; - let cursorFadeTo = 0; - let cursorFadeStart = 0; - const CURSOR_FADE_IN_MS = 100; - const CURSOR_FADE_OUT_MS = 400; - function setCursorFadeTarget(target) { - if (target === cursorFadeTo) return; - cursorFadeFrom = cursorAlpha; - cursorFadeTo = target; - cursorFadeStart = performance.now(); - requestDraw(); - } - function showCursor() { - mouseInside = true; - setCursorFadeTarget(1); - } - function hideCursorNow() { - mouseInside = false; - cursorAlpha = 0; - cursorFadeFrom = 0; - cursorFadeTo = 0; - } - - let r = 255, g = 255, b = 255; - let brush = 1; - let roundness = 0; - - let brushShape = null; - let brushShapeKey = ''; - function getBrushShape() { - const key = brush + ',' + roundness + ',' + r + ',' + g + ',' + b; - if (brushShapeKey === key && brushShape) return brushShape; - const n = brush; - const inside = new Uint8Array(n * n); - const rad = roundness * (n / 2); - const rad2 = rad * rad; - const lo = rad - 0.5; - const hi = n - 0.5 - rad; - for (let dy = 0; dy < n; dy++) { - for (let dx = 0; dx < n; dx++) { - let qx = 0, qy = 0; - if (dx < lo) qx = lo - dx; - else if (dx > hi) qx = dx - hi; - if (dy < lo) qy = lo - dy; - else if (dy > hi) qy = dy - hi; - if (qx * qx + qy * qy <= rad2 + 1e-9) inside[dy * n + dx] = 1; - } - } - const sprite = document.createElement('canvas'); - sprite.width = n; - sprite.height = n; - const sctx = sprite.getContext('2d'); - const img = sctx.createImageData(n, n); - const data = img.data; - for (let i = 0; i < n * n; i++) { - if (inside[i]) { - data[i * 4] = r; - data[i * 4 + 1] = g; - data[i * 4 + 2] = b; - data[i * 4 + 3] = 255; - } - } - sctx.putImageData(img, 0, 0); - - const fillRuns = []; - for (let dy = 0; dy < n; dy++) { - let dx = 0; - while (dx < n) { - if (!inside[dy * n + dx]) { dx++; continue; } - let dx1 = dx + 1; - while (dx1 < n && inside[dy * n + dx1]) dx1++; - fillRuns.push(dx, dy, dx1 - dx); - dx = dx1; - } - } - - // 1-pixel outline ring: cells outside the shape that are 8-way adjacent - // to any inside cell. stored as row-runs in an (n+2)x(n+2) grid with - // coordinates offset by -1 so they index directly in shape-local space. - const m = n + 2; - const ring = new Uint8Array(m * m); - const isIn = (x, y) => x >= 0 && y >= 0 && x < n && y < n && inside[y * n + x] === 1; - for (let y = -1; y <= n; y++) { - for (let x = -1; x <= n; x++) { - if (isIn(x, y)) continue; - let adj = false; - for (let oy = -1; oy <= 1 && !adj; oy++) { - for (let ox = -1; ox <= 1 && !adj; ox++) { - if (ox === 0 && oy === 0) continue; - if (isIn(x + ox, y + oy)) adj = true; - } - } - if (adj) ring[(y + 1) * m + (x + 1)] = 1; - } - } - const outlineRuns = []; - for (let y = 0; y < m; y++) { - let x = 0; - while (x < m) { - if (!ring[y * m + x]) { x++; continue; } - let x1 = x + 1; - while (x1 < m && ring[y * m + x1]) x1++; - outlineRuns.push(x - 1, y - 1, x1 - x); - x = x1; - } - } - - brushShape = { inside, sprite, fillRuns, outlineRuns, n }; - brushShapeKey = key; - return brushShape; - } - - // outline blend: 0 = dark color (lighten with screen), 1 = light color (darken with multiply). - let outlineLightness = 1; - let outlineAnimStart = 0; - let outlineAnimFrom = 1; - let outlineAnimTo = 1; - const OUTLINE_ANIM_MS = 250; - function setOutlineTarget(target) { - if (target === outlineAnimTo) return; - outlineAnimFrom = outlineLightness; - outlineAnimTo = target; - outlineAnimStart = performance.now(); - requestDraw(); - } - - // displayed cursor fill eases toward r/g/b; painting still uses r/g/b immediately. - let dispR = 255, dispG = 255, dispB = 255; - let colorAnimStart = 0; - let colorAnimFromR = 255, colorAnimFromG = 255, colorAnimFromB = 255; - let colorAnimToR = 255, colorAnimToG = 255, colorAnimToB = 255; - function setDisplayColorTarget(nr, ng, nb) { - if (nr === colorAnimToR && ng === colorAnimToG && nb === colorAnimToB) return; - colorAnimFromR = dispR; colorAnimFromG = dispG; colorAnimFromB = dispB; - colorAnimToR = nr; colorAnimToG = ng; colorAnimToB = nb; - colorAnimStart = performance.now(); - requestDraw(); - } - - const keys = {}; - let painting = false; - let picking = false; - let lastPaintX = null, lastPaintY = null; - let dirty = false; - - function resize() { - const firstResize = cssW === 0; - dpr = window.devicePixelRatio || 1; - cssW = window.innerWidth; - cssH = window.innerHeight; - view.style.width = cssW + 'px'; - view.style.height = cssH + 'px'; - view.width = Math.floor(cssW * dpr); - view.height = Math.floor(cssH * dpr); - if (firstResize) { - curX = Math.floor(cssW / (2 * zoom)); - curY = Math.floor(cssH / (2 * zoom)); - } - requestDraw(); - } - - function brushTopLeft(cx, cy) { - const off = Math.floor(brush / 2); - return { x: cx - off, y: cy - off }; - } - - function paintAt(cx, cy) { - dirty = true; - const tl = brushTopLeft(cx, cy); - if (roundness === 0) { - paintRect(tl.x, tl.y, brush, brush, 'rgb(' + r + ',' + g + ',' + b + ')'); - return; - } - const sprite = getBrushShape().sprite; - const cx0 = Math.floor(tl.x / CHUNK); - const cy0 = Math.floor(tl.y / CHUNK); - const cx1 = Math.floor((tl.x + brush - 1) / CHUNK); - const cy1 = Math.floor((tl.y + brush - 1) / CHUNK); - for (let ccy = cy0; ccy <= cy1; ccy++) { - for (let ccx = cx0; ccx <= cx1; ccx++) { - const c = getOrCreateChunk(ccx, ccy); - c.ctx.drawImage(sprite, tl.x - ccx * CHUNK, tl.y - ccy * CHUNK); - } - } - } - - function paintLine(x0, y0, x1, y1) { - let dx = Math.abs(x1 - x0), sx = x0 < x1 ? 1 : -1; - let dy = -Math.abs(y1 - y0), sy = y0 < y1 ? 1 : -1; - let err = dx + dy; - let x = x0, y = y0; - while (true) { - paintAt(x, y); - if (x === x1 && y === y1) break; - const e2 = 2 * err; - if (e2 >= dy) { err += dy; x += sx; } - if (e2 <= dx) { err += dx; y += sy; } - } - } - - const faviconEl = document.getElementById('favicon'); - function updateFavicon() { - const svg = "<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1 1'><rect width='1' height='1' fill='rgb(" + r + "," + g + "," + b + ")'/></svg>"; - faviconEl.href = 'data:image/svg+xml,' + encodeURIComponent(svg); - } - - function pickAt(cx, cy) { - const c = readPixel(cx, cy); - r = c[0]; g = c[1]; b = c[2]; - setOutlineTarget((r + g + b > 384) ? 1 : 0); - setDisplayColorTarget(r, g, b); - updateFavicon(); - } - - function setColor(nr, ng, nb) { - r = nr; g = ng; b = nb; - setOutlineTarget((r + g + b > 384) ? 1 : 0); - updateFavicon(); - dispR = r; dispG = g; dispB = b; - colorAnimFromR = r; colorAnimFromG = g; colorAnimFromB = b; - colorAnimToR = r; colorAnimToG = g; colorAnimToB = b; - } - - function clientToWorld(clientX, clientY) { - const lx = Math.floor(clientX / zoom + camX); - const ly = Math.floor(clientY / zoom + camY); - return { x: lx, y: ly }; - } - - let drawQueued = false; - function requestDraw() { - if (drawQueued) return; - drawQueued = true; - requestAnimationFrame(() => { - drawQueued = false; - draw(); - }); - } - - function draw() { - const now = performance.now(); - if (outlineLightness !== outlineAnimTo) { - const t = (now - outlineAnimStart) / OUTLINE_ANIM_MS; - if (t >= 1) { - outlineLightness = outlineAnimTo; - } else { - const e = t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2; - outlineLightness = outlineAnimFrom + (outlineAnimTo - outlineAnimFrom) * e; - requestDraw(); - } - } - if (dispR !== colorAnimToR || dispG !== colorAnimToG || dispB !== colorAnimToB) { - const t = (now - colorAnimStart) / OUTLINE_ANIM_MS; - if (t >= 1) { - dispR = colorAnimToR; dispG = colorAnimToG; dispB = colorAnimToB; - } else { - const e = t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2; - dispR = Math.round(colorAnimFromR + (colorAnimToR - colorAnimFromR) * e); - dispG = Math.round(colorAnimFromG + (colorAnimToG - colorAnimFromG) * e); - dispB = Math.round(colorAnimFromB + (colorAnimToB - colorAnimFromB) * e); - requestDraw(); - } - } - - const W = view.width, H = view.height; - vctx.setTransform(1, 0, 0, 1, 0, 0); - vctx.imageSmoothingEnabled = false; - vctx.fillStyle = '#000'; - vctx.fillRect(0, 0, W, H); - - // one logical pixel on screen = integer device px. we round here so - // every logical pixel occupies the exact same number of device pixels: - // otherwise fractional dpr (1.25/1.5/1.75) makes nearest-neighbor - // resampling drop or duplicate rows, producing transparent stripes - // through painted content at high zoom. - const pxD = Math.max(1, Math.round(zoom * dpr)); - - const viewWLog = cssW / zoom; - const viewHLog = cssH / zoom; - const wx0 = camX; - const wy0 = camY; - const wx1 = camX + viewWLog; - const wy1 = camY + viewHLog; - - const cx0 = Math.floor(wx0 / CHUNK); - const cy0 = Math.floor(wy0 / CHUNK); - const cx1 = Math.floor((wx1 - 1e-9) / CHUNK); - const cy1 = Math.floor((wy1 - 1e-9) / CHUNK); - - // round destinations to integer device pixels to avoid seams between - // adjacent chunks. compute right/bottom edges from the neighbor's - // rounded left/top so shared edges line up exactly. - const destX = (wx) => Math.round((wx - camX) * pxD); - const destY = (wy) => Math.round((wy - camY) * pxD); - - for (let cy = cy0; cy <= cy1; cy++) { - for (let cx = cx0; cx <= cx1; cx++) { - const c = chunks.get(chunkKey(cx, cy)); - if (!c) continue; - const x0 = destX(cx * CHUNK); - const y0 = destY(cy * CHUNK); - const x1 = destX((cx + 1) * CHUNK); - const y1 = destY((cy + 1) * CHUNK); - vctx.drawImage(c.canvas, x0, y0, x1 - x0, y1 - y0); - } - } - - // onionskin: ghost the frame behind us in playback order, screen-blended - // so black contributes nothing over the opaque chunks - if (onionskin && !playing && frames.length > 1) { - const ghost = frames[(frameIdx - dir + frames.length) % frames.length]; - vctx.save(); - vctx.globalAlpha = 0.35; - vctx.globalCompositeOperation = 'screen'; - for (let cy = cy0; cy <= cy1; cy++) { - for (let cx = cx0; cx <= cx1; cx++) { - const c = ghost.get(chunkKey(cx, cy)); - if (!c) continue; - const x0 = destX(cx * CHUNK); - const y0 = destY(cy * CHUNK); - vctx.drawImage(c.canvas, x0, y0, destX((cx + 1) * CHUNK) - x0, destY((cy + 1) * CHUNK) - y0); - } - } - vctx.restore(); - } - - if (cursorAlpha !== cursorFadeTo) { - const dur = cursorFadeTo > cursorFadeFrom ? CURSOR_FADE_IN_MS : CURSOR_FADE_OUT_MS; - const t = (now - cursorFadeStart) / dur; - if (t >= 1) { - cursorAlpha = cursorFadeTo; - if (cursorFadeTo === 0) mouseInside = false; - } else { - const e = t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2; - cursorAlpha = cursorFadeFrom + (cursorFadeTo - cursorFadeFrom) * e; - requestDraw(); - } - } - if (mouseInside) { - const tl = brushTopLeft(curX, curY); - // round to integer device pixels - camX/camY are fractional (smooth - // pan), and fractional fillRect coordinates antialias their edges, - // which would leave transparent lines between adjacent row strips. - // during an active pan, anchor the cursor to the real client - // position (rounded only to device pixels) so it tracks smoothly - // instead of jittering as the logical-pixel floor flips back and - // forth under a fractional camera. when the pan ends the cursor - // snaps back to the logical-pixel grid via the idle timer below. - let sx, sy; - if (panning && curClientX !== null) { - const off = Math.floor(brush / 2); - sx = Math.round((curClientX - off * zoom) * dpr); - sy = Math.round((curClientY - off * zoom) * dpr); - } else { - sx = Math.round((tl.x - camX) * pxD); - sy = Math.round((tl.y - camY) * pxD); - } - const n = brush; - - vctx.save(); - vctx.globalAlpha = cursorAlpha; - vctx.fillStyle = 'rgb(' + dispR + ',' + dispG + ',' + dispB + ')'; - if (roundness === 0) { - vctx.fillRect(sx, sy, n * pxD, n * pxD); - } else { - const runs = getBrushShape().fillRuns; - for (let i = 0; i < runs.length; i += 3) { - vctx.fillRect(sx + runs[i] * pxD, sy + runs[i + 1] * pxD, runs[i + 2] * pxD, pxD); - } - } - - // 1-logical-pixel outline, cross-faded darken/lighten blend. - const shape = roundness === 0 ? null : getBrushShape(); - const drawOutline = () => { - if (shape) { - const runs = shape.outlineRuns; - for (let i = 0; i < runs.length; i += 3) { - vctx.fillRect(sx + runs[i] * pxD, sy + runs[i + 1] * pxD, runs[i + 2] * pxD, pxD); - } - } else { - vctx.fillRect(sx - pxD, sy - pxD, (n + 2) * pxD, pxD); // top - vctx.fillRect(sx - pxD, sy + n * pxD, (n + 2) * pxD, pxD); // bottom - vctx.fillRect(sx - pxD, sy, pxD, n * pxD); // left - vctx.fillRect(sx + n * pxD, sy, pxD, n * pxD); // right - } - }; - const L = outlineLightness; - vctx.save(); - if (L > 0) { - vctx.globalCompositeOperation = 'multiply'; - vctx.globalAlpha = L * cursorAlpha; - vctx.fillStyle = 'rgb(128,128,128)'; - drawOutline(); - } - if (L < 1) { - vctx.globalCompositeOperation = 'screen'; - vctx.globalAlpha = (1 - L) * cursorAlpha; - vctx.fillStyle = 'rgb(128,128,128)'; - drawOutline(); - } - vctx.restore(); - vctx.restore(); - } - } - - const fileInput = document.createElement('input'); - fileInput.type = 'file'; - fileInput.accept = 'image/*'; - fileInput.style.display = 'none'; - document.body.appendChild(fileInput); - fileInput.addEventListener('change', (e) => { - const f = e.target.files && e.target.files[0]; - if (f) importFile(f); - fileInput.value = ''; - }); - - function formatTimestamp(d) { - const p = (n) => String(n).padStart(2, '0'); - return p(d.getFullYear() % 100) + '\u00b7' + p(d.getMonth() + 1) + '\u00b7' + p(d.getDate()) + '\u00b7' + p(d.getHours()) + '\u00b7' + p(d.getMinutes()) + '\u00b7' + p(d.getSeconds()); - } - - // union of painted chunk bounds across all frames, so every exported - // frame shares the canvas of the biggest one - function frameBounds() { - let any = false, minCx = Infinity, minCy = Infinity, maxCx = -Infinity, maxCy = -Infinity; - for (const f of frames) { - for (const k of f.keys()) { - any = true; - const [cx, cy] = k.split(',').map(Number); - if (cx < minCx) minCx = cx; - if (cy < minCy) minCy = cy; - if (cx > maxCx) maxCx = cx; - if (cy > maxCy) maxCy = cy; - } - } - return any ? { minCx, minCy, maxCx, maxCy } : null; - } - - function renderFrame(f, b, w, h) { - const out = document.createElement('canvas'); - out.width = w; - out.height = h; - const octx = out.getContext('2d'); - octx.fillStyle = '#000'; - octx.fillRect(0, 0, w, h); - for (const [k, c] of f) { - const [cx, cy] = k.split(',').map(Number); - octx.drawImage(c.canvas, (cx - b.minCx) * CHUNK, (cy - b.minCy) * CHUNK); - } - return octx.getImageData(0, 0, w, h); - } - - function lzwEncode(minCode, data, lookup, out) { - const clear = 1 << minCode, eoi = clear + 1; - let codeSize = minCode + 1, next = eoi + 1; - let dict = new Map(); - let acc = 0, accBits = 0; - let block = []; - const flushBlock = () => { - if (block.length) { out.push(block.length, ...block); block = []; } - }; - const emit = (code) => { - acc |= code << accBits; - accBits += codeSize; - while (accBits >= 8) { - block.push(acc & 255); - acc >>= 8; - accBits -= 8; - if (block.length === 255) flushBlock(); - } - }; - const idx = (i) => lookup((data[i] << 16) | (data[i + 1] << 8) | data[i + 2]); - emit(clear); - let prev = idx(0); - for (let i = 4; i < data.length; i += 4) { - const k = idx(i); - const key = prev * 256 + k; - if (dict.has(key)) { prev = dict.get(key); continue; } - emit(prev); - if (next === 4096) { - emit(clear); - dict = new Map(); - next = eoi + 1; - codeSize = minCode + 1; - } else { - if (next >= (1 << codeSize)) codeSize++; - dict.set(key, next++); - } - prev = k; - } - emit(prev); - emit(eoi); - if (accBits) block.push(acc & 255); - flushBlock(); - } - - // minimal GIF89a encoder: exact global palette when <=256 colors, - // else uniform 6x6x6 quantization - function encodeGIF(images, w, h, delayMs) { - const colorIdx = new Map(); - let over = false; - for (const img of images) { - const d = img.data; - for (let i = 0; i < d.length && !over; i += 4) { - const c = (d[i] << 16) | (d[i + 1] << 8) | d[i + 2]; - if (!colorIdx.has(c)) { - if (colorIdx.size === 256) over = true; - else colorIdx.set(c, colorIdx.size); - } - } - if (over) break; - } - if (over) { - colorIdx.clear(); - for (let i = 0; i < 216; i++) { - colorIdx.set(((Math.floor(i / 36) * 51) << 16) | ((Math.floor(i / 6) % 6 * 51) << 8) | (i % 6 * 51), i); - } - } - const lookup = (c) => over - ? Math.round(((c >> 16) & 255) / 51) * 36 + Math.round(((c >> 8) & 255) / 51) * 6 + Math.round((c & 255) / 51) - : colorIdx.get(c); - let bits = 2; - while ((1 << bits) < colorIdx.size) bits++; - const out = []; - const u16 = (v) => { out.push(v & 255, (v >> 8) & 255); }; - out.push(71, 73, 70, 56, 57, 97); // "GIF89a" - u16(w); u16(h); - out.push(0x80 | ((bits - 1) << 4) | (bits - 1), 0, 0); - const pal = [...colorIdx.keys()]; - for (let i = 0; i < (1 << bits); i++) { - const c = pal[i] || 0; - out.push((c >> 16) & 255, (c >> 8) & 255, c & 255); - } - const animated = images.length > 1; - if (animated) { - // NETSCAPE2.0 loop forever - out.push(0x21, 0xff, 11, 78, 69, 84, 83, 67, 65, 80, 69, 50, 46, 48, 3, 1, 0, 0, 0); - } - const delay = clamp(Math.round(delayMs / 10), 2, 65535); - for (const img of images) { - if (animated) out.push(0x21, 0xf9, 4, 0, delay & 255, (delay >> 8) & 255, 0, 0); - out.push(0x2c); - u16(0); u16(0); u16(w); u16(h); - out.push(0); - const minCode = Math.max(2, bits); - out.push(minCode); - lzwEncode(minCode, img.data, lookup, out); - out.push(0); - } - out.push(0x3b); - return new Uint8Array(out); - } - - function exportGIF() { - const b = frameBounds(); - if (!b) return; - const w = (b.maxCx - b.minCx + 1) * CHUNK; - const h = (b.maxCy - b.minCy + 1) * CHUNK; - const imgs = frames.map((f) => renderFrame(f, b, w, h)); - if (dir === -1) imgs.reverse(); - const blob = new Blob([encodeGIF(imgs, w, h, frameInterval)], { type: 'image/gif' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = 'animus \u00b7 ' + formatTimestamp(new Date()) + '.gif'; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); - dirty = false; - } - - function imageToFrame(src, w, h) { - const f = new Map(); - const ox = -Math.floor(w / 2); - const oy = -Math.floor(h / 2); - const cx0 = Math.floor(ox / CHUNK); - const cy0 = Math.floor(oy / CHUNK); - const cx1 = Math.floor((ox + w - 1) / CHUNK); - const cy1 = Math.floor((oy + h - 1) / CHUNK); - for (let cy = cy0; cy <= cy1; cy++) { - for (let cx = cx0; cx <= cx1; cx++) { - getOrCreateChunk(cx, cy, f).ctx.drawImage(src, ox - cx * CHUNK, oy - cy * CHUNK); - } - } - return { f, w, h, ox, oy }; - } - - function finishImport(newFrames, w, h, ox, oy) { - frames = newFrames; - dir = 1; - setFrame(0); - camX = ox - (cssW / zoom - w) / 2; - camY = oy - (cssH / zoom - h) / 2; - dirty = false; - requestDraw(); - } - - async function importFile(file) { - if (dirty && !confirm('Importing will discard the current ' + (frames.length > 1 ? 'animation' : 'painting') + '. Continue?')) return; - if (playing) stopPlayback(); - // ImageDecoder gives us every frame of an animated gif; fall back to - // single-image import where unsupported - if (typeof ImageDecoder !== 'undefined') { - try { - const dec = new ImageDecoder({ data: await file.arrayBuffer(), type: file.type }); - await dec.tracks.ready; - const count = dec.tracks.selectedTrack.frameCount; - const newFrames = []; - let r = null; - for (let i = 0; i < count; i++) { - const { image } = await dec.decode({ frameIndex: i }); - if (i === 0 && image.duration) frameInterval = Math.max(20, image.duration / 1000); - r = imageToFrame(image, image.displayWidth, image.displayHeight); - newFrames.push(r.f); - image.close(); - } - finishImport(newFrames, r.w, r.h, r.ox, r.oy); - return; - } catch (err) {} - } - const img = new Image(); - img.onload = () => { - const r = imageToFrame(img, img.width, img.height); - finishImport([r.f], r.w, r.h, r.ox, r.oy); - URL.revokeObjectURL(img.src); - }; - img.onerror = () => URL.revokeObjectURL(img.src); - img.src = URL.createObjectURL(file); - } - - window.addEventListener('resize', resize); - - // mouse and pen share the pointer-event path; finger touches are handled - // by the touch-event path below - function hoverPointer(e) { return e.pointerType === 'mouse' || e.pointerType === 'pen'; } - - window.addEventListener('pointerover', (e) => { - if (!hoverPointer(e)) return; - showCursor(); - curClientX = e.clientX; - curClientY = e.clientY; - const p = clientToWorld(e.clientX, e.clientY); - curX = p.x; - curY = p.y; - requestDraw(); - }); - - // brush resize curve: linear (fine, precise) up to BRUSH_LINEAR_MAX, then a - // power-law ramp beyond, tuned by BRUSH_ACCEL. - function brushFromDrag(start, dyPx) { - const A = BRUSH_DRAG_PX_PER_STEP, T = BRUSH_LINEAR_MAX, c = BRUSH_ACCEL; - const seam = T * A; // drag distance from size 0 to the seam - const brushToPos = (b) => b <= T ? b * A : seam + (seam / c) * (Math.pow(b / T, c) - 1); - const posToBrush = (p) => p <= seam ? p / A : T * Math.pow(1 + c * (p - seam) / seam, 1 / c); - return posToBrush(brushToPos(start) + dyPx); - } - - // modifier-drag: shift/cmd/RGB held -> vertical motion from the anchor - // drives brush size / roundness / color channels. drag up (dy negative) - // -> increase. when a value hits its bound and the drag continues past, - // re-anchor so reversing direction responds immediately. each drag is - // independent so any combination can run at once. - function applyDrags(clientY) { - if (dragBrush) { - const dyPx = (dragBrush.anchorY - clientY) * dpr; - const target = brushFromDrag(dragBrush.start, dyPx); - const clamped = clamp(Math.round(target), 1, MAX_BRUSH); - brush = clamped; - if (target < 1 || target > MAX_BRUSH) { - dragBrush.start = clamped; - dragBrush.anchorY = clientY; - } - } - if (dragRoundness) { - const dyPx = (dragRoundness.anchorY - clientY) * dpr; - const target = dragRoundness.start - dyPx / ROUNDNESS_DRAG_FULL_PX; - const clamped = clamp(target, 0, 1); - roundness = clamped; - if (target !== clamped) { - dragRoundness.start = clamped; - dragRoundness.anchorY = clientY; - } - } - if (dragColor) { - const dyPx = (dragColor.anchorY - clientY) * dpr; - const delta = (dyPx / COLOR_DRAG_FULL_PX) * 255; - let nr = r, ng = g, nb = b; - let overshoot = 0; - if (keys['r']) { - const t = dragColor.r0 + delta; - nr = clamp(t, 0, 255); - if (t !== nr) overshoot = Math.max(overshoot, Math.abs(t - nr)); - } - if (keys['g']) { - const t = dragColor.g0 + delta; - ng = clamp(t, 0, 255); - if (t !== ng) overshoot = Math.max(overshoot, Math.abs(t - ng)); - } - if (keys['b']) { - const t = dragColor.b0 + delta; - nb = clamp(t, 0, 255); - if (t !== nb) overshoot = Math.max(overshoot, Math.abs(t - nb)); - } - setColor(Math.round(nr), Math.round(ng), Math.round(nb)); - if (overshoot > 0) { - dragColor.r0 = nr; dragColor.g0 = ng; dragColor.b0 = nb; - dragColor.anchorY = clientY; - } - } - } - - window.addEventListener('pointermove', (e) => { - if (!hoverPointer(e)) return; - // a pen only proves it can hover by moving while lifted. without hover - // it gets the finger treatment: modifiers adjust instead of marking - if (e.pointerType === 'pen' && e.buttons === 0) penHover = true; - showCursor(); - curClientX = e.clientX; - curClientY = e.clientY; - const p = clientToWorld(e.clientX, e.clientY); - const nx = p.x, ny = p.y; - - applyDrags(e.clientY); - - if (painting) { - if (lastPaintX !== null) { - paintLine(lastPaintX, lastPaintY, nx, ny); - } else { - paintAt(nx, ny); - } - lastPaintX = nx; - lastPaintY = ny; - } else if (picking) { - pickAt(nx, ny); - } - curX = nx; - curY = ny; - requestDraw(); - }); - - window.addEventListener('pointerleave', (e) => { - if (!hoverPointer(e)) return; - hideCursorNow(); - requestDraw(); - }); - - // a pen without hover support sends no move events before contact, so the - // down event must place the cursor itself - let penDown = false; - let penHover = false; - window.addEventListener('pointerdown', (e) => { - if (!hoverPointer(e)) return; - if (e.button !== 0) return; - e.preventDefault(); - stopInertia(); - if (e.pointerType === 'pen') penDown = true; - showCursor(); - curClientX = e.clientX; - curClientY = e.clientY; - const p = clientToWorld(e.clientX, e.clientY); - curX = p.x; - curY = p.y; - // hoverless pen + modifier held -> the contact adjusts, like a finger - if (e.pointerType === 'pen' && !penHover && !keys['c'] && (dragBrush || dragRoundness || dragColor)) { - anchorDrags(e.clientY); - requestDraw(); - return; - } - if (keys['c']) { - picking = true; - pickAt(curX, curY); - requestDraw(); - return; - } - painting = true; - lastPaintX = curX; - lastPaintY = curY; - paintAt(curX, curY); - requestDraw(); - }); - - function pointerUp(e) { - if (!hoverPointer(e)) return; - if (e.button === 0 || e.type === 'pointercancel') { - painting = false; - picking = false; - lastPaintX = null; - lastPaintY = null; - if (e.pointerType === 'pen') { - penDown = false; - // hoverless pens get no more events after lift, so fade like touch - if (mouseInside) setCursorFadeTarget(0); - requestDraw(); - } - } - } - window.addEventListener('pointerup', pointerUp); - window.addEventListener('pointercancel', pointerUp); - - // touch: one finger paints, two fingers pan/pinch-zoom. once a second - // finger lands the whole touch becomes a gesture (no painting) until all - // fingers lift, so a stray finger during a pan never leaves a mark. - // stylus touches are excluded here - let touchMode = null; // 'paint' | 'adjust' | 'gesture' - let touchPainted = false; - let pinchDist = 0, pinchMidX = 0, pinchMidY = 0; - - // inertial pan: centroid velocity (css px/ms) tracked during the gesture, - // thrown on release and decayed with exponential friction - let panVX = 0, panVY = 0; - let panLastT = 0; - let inertiaRAF = null; - function stopInertia() { - if (inertiaRAF !== null) { - cancelAnimationFrame(inertiaRAF); - inertiaRAF = null; - } - } - function startInertia() { - // no throw if the finger paused before lifting or was barely moving - const speed = Math.hypot(panVX, panVY); - if (performance.now() - panLastT > 100 || speed < 0.05) return; - // exponential decay per ms - const DECEL = 0.995; - const MAX_SPEED = 3; // css px/ms - if (speed > MAX_SPEED) { - panVX *= MAX_SPEED / speed; - panVY *= MAX_SPEED / speed; - } - let prev = performance.now(); - const step = (now) => { - const dt = now - prev; - prev = now; - camX -= panVX * dt / zoom; - camY -= panVY * dt / zoom; - const f = Math.pow(DECEL, dt); - panVX *= f; - panVY *= f; - requestDraw(); - inertiaRAF = Math.hypot(panVX, panVY) > 0.02 ? requestAnimationFrame(step) : null; - }; - inertiaRAF = requestAnimationFrame(step); - } - - function touchCentroid(touches) { - let x = 0, y = 0; - for (const t of touches) { x += t.clientX; y += t.clientY; } - return { x: x / touches.length, y: y / touches.length }; - } - - function fingerTouches(list) { - const out = []; - for (const t of list) if (t.touchType !== 'stylus') out.push(t); - return out; - } - - // re-anchor active drags to a new y and re-baseline to the current values - // so successive drag strokes accumulate instead of snapping back - function anchorDrags(clientY) { - if (dragBrush) { dragBrush.anchorY = clientY; dragBrush.start = brush; } - if (dragRoundness) { dragRoundness.anchorY = clientY; dragRoundness.start = roundness; } - if (dragColor) { dragColor.anchorY = clientY; dragColor.r0 = r; dragColor.g0 = g; dragColor.b0 = b; } - } - - function anchorGesture(touches) { - const m = touchCentroid(touches); - pinchMidX = m.x; - pinchMidY = m.y; - pinchDist = touches.length >= 2 - ? Math.hypot(touches[0].clientX - touches[1].clientX, touches[0].clientY - touches[1].clientY) - : 0; - } - - window.addEventListener('touchstart', (e) => { - e.preventDefault(); - const fingers = fingerTouches(e.touches); - // fingers are ignored while the pen is down so a resting hand can't - // hijack or extend the pen's stroke - if (fingers.length === 0 || penDown) return; - stopInertia(); - if (fingers.length === 1 && touchMode === null) { - const t = fingers[0]; - curClientX = t.clientX; - curClientY = t.clientY; - if (!keys['c'] && (dragBrush || dragRoundness || dragColor)) { - touchMode = 'adjust'; - const p = clientToWorld(t.clientX, t.clientY); - curX = p.x; - curY = p.y; - showCursor(); - anchorDrags(t.clientY); - } else { - const p = clientToWorld(t.clientX, t.clientY); - curX = p.x; - curY = p.y; - showCursor(); - touchMode = 'paint'; - touchPainted = false; - if (keys['c']) { - picking = true; - } else { - painting = true; - lastPaintX = curX; - lastPaintY = curY; - } - } - } else { - touchMode = 'gesture'; - painting = false; - picking = false; - lastPaintX = null; - lastPaintY = null; - hideCursorNow(); - panVX = 0; - panVY = 0; - panLastT = 0; - anchorGesture(fingers); - } - requestDraw(); - }, { passive: false }); - - window.addEventListener('touchmove', (e) => { - e.preventDefault(); - const fingers = fingerTouches(e.touches); - if (fingers.length === 0) return; - if (touchMode === 'paint') { - const t = fingers[0]; - curClientX = t.clientX; - curClientY = t.clientY; - // on hover devices held keys adjust during the stroke, like mouse - applyDrags(t.clientY); - const p = clientToWorld(t.clientX, t.clientY); - if (picking) { - pickAt(p.x, p.y); - } else if (painting) { - paintLine(lastPaintX, lastPaintY, p.x, p.y); - lastPaintX = p.x; - lastPaintY = p.y; - } - touchPainted = true; - curX = p.x; - curY = p.y; - } else if (touchMode === 'adjust') { - const t = fingers[0]; - curClientX = t.clientX; - curClientY = t.clientY; - applyDrags(t.clientY); - const p = clientToWorld(t.clientX, t.clientY); - curX = p.x; - curY = p.y; - } else if (touchMode === 'gesture') { - const m = touchCentroid(fingers); - const dxs = m.x - pinchMidX; - const dys = m.y - pinchMidY; - camX -= dxs / zoom; - camY -= dys / zoom; - const nowT = performance.now(); - const dt = nowT - panLastT; - if (dt > 0 && dt < 100) { - panVX = panVX * 0.5 + (dxs / dt) * 0.5; - panVY = panVY * 0.5 + (dys / dt) * 0.5; - } - panLastT = nowT; - if (fingers.length >= 2) { - const dist = Math.hypot(fingers[0].clientX - fingers[1].clientX, fingers[0].clientY - fingers[1].clientY); - if (pinchDist > 0) applyZoom(dist / pinchDist, m.x, m.y); - pinchDist = dist; - } - pinchMidX = m.x; - pinchMidY = m.y; - } else { - return; - } - requestDraw(); - }, { passive: false }); - - function touchEnd(e) { - e.preventDefault(); - if (touchMode === null) return; - const fingers = fingerTouches(e.touches); - if (fingers.length === 0) { - // a tap that never moved still paints its dot (or picks its point) - if (touchMode === 'paint' && !touchPainted) { - if (picking) pickAt(curX, curY); - else paintAt(curX, curY); - } - if (touchMode === 'gesture') startInertia(); - touchMode = null; - painting = false; - picking = false; - lastPaintX = null; - lastPaintY = null; - // fade the preview out instead of hiding it instantly - if (mouseInside) setCursorFadeTarget(0); - requestDraw(); - } else if (touchMode === 'gesture') { - // re-anchor to the remaining fingers so the camera doesn't jump - anchorGesture(fingers); - } - } - window.addEventListener('touchend', touchEnd, { passive: false }); - window.addEventListener('touchcancel', touchEnd, { passive: false }); - - function startDragMode(mode) { - const anchorY = curClientY !== null ? curClientY : 0; - if (mode === 'brush' && !dragBrush) dragBrush = { anchorY, start: brush }; - else if (mode === 'roundness' && !dragRoundness) dragRoundness = { anchorY, start: roundness }; - else if (mode === 'color' && !dragColor) dragColor = { anchorY, r0: r, g0: g, b0: b }; - // hoverless input: a modifier pressed mid-stroke ends the stroke and - // the rest of the contact becomes an adjust drag. hover-capable pens - // keep drawing (they can adjust while lifted instead) - if (touchMode === 'paint' || (penDown && !penHover)) { - if (touchMode === 'paint') touchMode = 'adjust'; - painting = false; - picking = false; - lastPaintX = null; - lastPaintY = null; - } - } - function endDragMode(mode) { - if (mode === 'brush') dragBrush = null; - else if (mode === 'roundness') dragRoundness = null; - else if (mode === 'color') dragColor = null; - } - - window.addEventListener('keydown', (e) => { - if ((e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey && e.key.toLowerCase() === 's') { - e.preventDefault(); - exportGIF(); - return; - } - if ((e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey && e.key.toLowerCase() === 'o') { - e.preventDefault(); - fileInput.click(); - return; - } - if ((e.metaKey || e.ctrlKey) && !e.altKey) { - if (e.key === '=' || e.key === '+') { e.preventDefault(); stepZoom(1); return; } - if (e.key === '-' || e.key === '_') { e.preventDefault(); stepZoom(-1); return; } - if (e.key === '0') { e.preventDefault(); resetZoom(); return; } - } - if (!e.metaKey && !e.ctrlKey && !e.altKey && !e.repeat) { - const lk = e.key.toLowerCase(); - if (e.key === 'ArrowLeft') { e.preventDefault(); tapArrow(-1); return; } - if (e.key === 'ArrowRight') { e.preventDefault(); tapArrow(1); return; } - if (e.key === ' ') { e.preventDefault(); playing ? stopPlayback() : startPlayback(); return; } - if (lk === 'a') { addFrame(); return; } - if (lk === 'd') { deleteFrame(); return; } - if (lk === 'o') { - if (playing) { stopPlayback(); onionskin = true; } - else onionskin = !onionskin; - requestDraw(); - return; - } - } - const k = e.key.toLowerCase(); - const wasDown = keys[k]; - keys[k] = true; - if (!e.metaKey && !e.ctrlKey) { - if (k === 'z' || k === 'x' || k === 'c' || k === 'r' || k === 'g' || k === 'b') e.preventDefault(); - if (k === 'x') startDragMode('roundness'); - else if (k === 'z') startDragMode('brush'); - else if (!wasDown && (k === 'r' || k === 'g' || k === 'b')) startDragMode('color'); - else if (k === 'c' && !wasDown && (touchMode === 'paint' || touchMode === 'adjust')) { - touchMode = 'paint'; - painting = false; - picking = true; - lastPaintX = null; - lastPaintY = null; - touchPainted = true; - pickAt(curX, curY); - requestDraw(); - } - } - }); - window.addEventListener('keyup', (e) => { - const k = e.key.toLowerCase(); - keys[k] = false; - if (k === 'x') endDragMode('roundness'); - else if (k === 'z') endDragMode('brush'); - else if (k === 'r' || k === 'g' || k === 'b') { - if (!keys['r'] && !keys['g'] && !keys['b']) endDragMode('color'); - else if (dragColor) { - // still holding at least one rgb key - re-anchor from current - // values so the remaining keys don't jump based on released key's history - dragColor.anchorY = curClientY !== null ? curClientY : dragColor.anchorY; - dragColor.r0 = r; dragColor.g0 = g; dragColor.b0 = b; - } - } - // releasing c mid-pick-drag -> back to adjusting if a modifier is still - // held on a hoverless contact, else seamlessly switch to painting - if (k === 'c' && picking) { - picking = false; - const hoverless = touchMode !== null || (penDown && !penHover); - if (hoverless && (dragBrush || dragRoundness || dragColor)) { - if (touchMode !== null) touchMode = 'adjust'; - painting = false; - if (curClientY !== null) anchorDrags(curClientY); - } else { - painting = true; - lastPaintX = curX; - lastPaintY = curY; - paintAt(curX, curY); - } - requestDraw(); - } - }); - window.addEventListener('blur', () => { - for (const k in keys) keys[k] = false; - dragBrush = null; dragRoundness = null; dragColor = null; - painting = false; - picking = false; - penDown = false; - lastPaintX = null; - lastPaintY = null; - }); - - function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); } - - function applyZoom(factor, ax, ay) { - const worldAtAnchorX = camX + ax / zoom; - const worldAtAnchorY = camY + ay / zoom; - zoomIdx = clamp(zoomIdx + Math.log(factor) * ZOOM_SENSITIVITY, 0, ZOOM_STOPS.length - 1); - const newZoom = ZOOM_STOPS[Math.round(zoomIdx)]; - if (newZoom !== zoom) { - zoom = newZoom; - camX = worldAtAnchorX - ax / zoom; - camY = worldAtAnchorY - ay / zoom; - } - } - - function zoomTo(newZoom) { - if (newZoom === zoom) return; - const ax = cssW / 2, ay = cssH / 2; - const worldAtAnchorX = camX + ax / zoom; - const worldAtAnchorY = camY + ay / zoom; - zoom = newZoom; - zoomIdx = ZOOM_STOPS.indexOf(zoom); - camX = worldAtAnchorX - ax / zoom; - camY = worldAtAnchorY - ay / zoom; - requestDraw(); - } - - function stepZoom(dir) { - const i = ZOOM_STOPS.indexOf(zoom); - zoomTo(ZOOM_STOPS[clamp(i + dir, 0, ZOOM_STOPS.length - 1)]); - } - function resetZoom() { zoomTo(MIN_ZOOM); } - - // wheel gesture lock: once a wheel gesture starts in a given mode, - // subsequent events in the same burst stay in that mode until the wheel - // goes idle or the controlling modifier set changes. this keeps trackpad - // momentum from leaking into pan after a modifier release, while still - // letting the user swap between modes mid-flick without stale state. - let wheelMode = null; - let wheelIdleTimer = null; - let panning = false; - - const BRUSH_DRAG_PX_PER_STEP = 25; // device px per +/- 1 brush px (below the seam) - const BRUSH_LINEAR_MAX = 6; // brush px below which resize stays linear/fine - const BRUSH_ACCEL = 0.5; - const MAX_BRUSH = 150; - const ROUNDNESS_DRAG_FULL_PX = 200; - const COLOR_DRAG_FULL_PX = 256; - // each drag carries its own anchor so any combination can run at once - let dragBrush = null; // {anchorY, start} - let dragRoundness = null; // {anchorY, start} - let dragColor = null; // {anchorY, r0, g0, b0} - function touchWheelGesture() { - if (wheelIdleTimer) clearTimeout(wheelIdleTimer); - wheelIdleTimer = setTimeout(() => { - wheelMode = null; - if (panning) { - // pan ended - resync the logical-pixel cursor from the real - // client position so the brush snaps onto its final cell. - panning = false; - if (curClientX !== null) { - const p = clientToWorld(curClientX, curClientY); - curX = p.x; - curY = p.y; - } - requestDraw(); - } - }, 150); - } - function pickModifierMode(e) { - if (e.ctrlKey) return 'zoom'; - return null; - } - - window.addEventListener('wheel', (e) => { - e.preventDefault(); - stopInertia(); - - // re-pick mode each event from live modifiers so releasing cmd and - // immediately starting a shift scroll switches over cleanly. if a - // burst started with a modifier and the modifier is then released - // mid-flick, suppress rather than leaking into pan. - const modMode = pickModifierMode(e); - if (modMode !== null) { - wheelMode = modMode; - } else if (wheelMode === null) { - wheelMode = 'pan'; - } else if (wheelMode !== 'pan') { - // modifier released mid-burst: drop the remaining momentum - // instead of letting it leak into pan. - touchWheelGesture(); - return; - } - touchWheelGesture(); - - if (wheelMode === 'zoom') { - // exponential zoom on the float accumulator so small pinches add up - applyZoom(Math.exp(-e.deltaY * 0.02), e.clientX, e.clientY); - requestDraw(); - return; - } - - // pan - smooth fractional camera. during the pan gesture the draw - // loop anchors the brush to the real client cursor position so it - // tracks the pointer smoothly; curX/curY get resynced onto the - // logical-pixel grid when the wheel idle timer fires. - panning = true; - camX += e.deltaX / zoom; - camY += e.deltaY / zoom; - requestDraw(); - }, { passive: false }); - - window.addEventListener('beforeunload', (e) => { - if (dirty) { e.preventDefault(); } - }); - - window.addEventListener('contextmenu', (e) => e.preventDefault()); - - // block the OS pinch gesture events too (Safari) - window.addEventListener('gesturestart', (e) => e.preventDefault()); - window.addEventListener('gesturechange', (e) => e.preventDefault()); - window.addEventListener('gestureend', (e) => e.preventDefault()); - - resize(); -})(); -</script> +<script src="resources/canvas.js"></script> +<script src="resources/brush.js"></script> +<script src="resources/view.js"></script> +<script src="resources/render.js"></script> +<script src="resources/io.js"></script> +<script src="resources/input.js"></script> +<script src="resources/net.js"></script> +<script>resize(); netConnect();</script> </body> </html> diff --git a/readme.md b/readme.md @@ -2,7 +2,7 @@ an infinite canvas for radical digital painting. -<!-- > inspired by KidPix, MS Paint, Mario Paint, and Flipnote Studio. --> +<!-- > inspired by Kid Pix, Mario Paint, Flipnote Studio, and iScribble. --> <p align="center"> <img src="readme_images/doodle.gif" width=300> @@ -42,4 +42,7 @@ an infinite canvas for radical digital painting. ### importing / exporting - hold `⌘` (or `ctrl`) and press `S` to save your painting as a .GIF file -- hold `⌘` (or `ctrl`) and press `O` to open an existing painting from your filesystem (will clear the current canvas) -\ No newline at end of file +- hold `⌘` (or `ctrl`) and press `O` to open an existing painting from your filesystem (will clear the current canvas) + +### multiplayer +run `./serve.py` and share the printed URL with anyone on your local network. +\ No newline at end of file diff --git a/resources/brush.js b/resources/brush.js @@ -0,0 +1,141 @@ +let r = 255, g = 255, b = 255; +let brush = 1; +let roundness = 0; + +// keyed cache: the local brush and every peer's brush pull from the same one +const brushShapes = new Map(); +const BRUSH_CACHE_MAX = 64; +function getBrushShape() { return brushShapeFor(brush, roundness, r, g, b); } +function brushShapeFor(n, round, sr, sg, sb) { + const key = n + ',' + round + ',' + sr + ',' + sg + ',' + sb; + const hit = brushShapes.get(key); + if (hit) return hit; + if (brushShapes.size >= BRUSH_CACHE_MAX) brushShapes.clear(); + const inside = new Uint8Array(n * n); + const rad = round * (n / 2); + const rad2 = rad * rad; + const lo = rad - 0.5; + const hi = n - 0.5 - rad; + for (let dy = 0; dy < n; dy++) { + for (let dx = 0; dx < n; dx++) { + let qx = 0, qy = 0; + if (dx < lo) qx = lo - dx; + else if (dx > hi) qx = dx - hi; + if (dy < lo) qy = lo - dy; + else if (dy > hi) qy = dy - hi; + if (qx * qx + qy * qy <= rad2 + 1e-9) inside[dy * n + dx] = 1; + } + } + const sprite = document.createElement('canvas'); + sprite.width = n; + sprite.height = n; + const sctx = sprite.getContext('2d'); + const img = sctx.createImageData(n, n); + const data = img.data; + for (let i = 0; i < n * n; i++) { + if (inside[i]) { + data[i * 4] = sr; + data[i * 4 + 1] = sg; + data[i * 4 + 2] = sb; + data[i * 4 + 3] = 255; + } + } + sctx.putImageData(img, 0, 0); + + const fillRuns = []; + for (let dy = 0; dy < n; dy++) { + let dx = 0; + while (dx < n) { + if (!inside[dy * n + dx]) { dx++; continue; } + let dx1 = dx + 1; + while (dx1 < n && inside[dy * n + dx1]) dx1++; + fillRuns.push(dx, dy, dx1 - dx); + dx = dx1; + } + } + + // 1-pixel outline ring: cells outside the shape that are 8-way adjacent + // to any inside cell. stored as row-runs in an (n+2)x(n+2) grid with + // coordinates offset by -1 so they index directly in shape-local space. + const m = n + 2; + const ring = new Uint8Array(m * m); + const isIn = (x, y) => x >= 0 && y >= 0 && x < n && y < n && inside[y * n + x] === 1; + for (let y = -1; y <= n; y++) { + for (let x = -1; x <= n; x++) { + if (isIn(x, y)) continue; + let adj = false; + for (let oy = -1; oy <= 1 && !adj; oy++) { + for (let ox = -1; ox <= 1 && !adj; ox++) { + if (ox === 0 && oy === 0) continue; + if (isIn(x + ox, y + oy)) adj = true; + } + } + if (adj) ring[(y + 1) * m + (x + 1)] = 1; + } + } + const outlineRuns = []; + for (let y = 0; y < m; y++) { + let x = 0; + while (x < m) { + if (!ring[y * m + x]) { x++; continue; } + let x1 = x + 1; + while (x1 < m && ring[y * m + x1]) x1++; + outlineRuns.push(x - 1, y - 1, x1 - x); + x = x1; + } + } + + const shape = { inside, sprite, fillRuns, outlineRuns, n }; + brushShapes.set(key, shape); + return shape; +} + +// outline blend: 0 = dark color (lighten with screen), 1 = light color (darken with multiply). +let outlineLightness = 1; +let outlineAnimStart = 0; +let outlineAnimFrom = 1; +let outlineAnimTo = 1; +const OUTLINE_ANIM_MS = 250; +function setOutlineTarget(target) { + if (target === outlineAnimTo) return; + outlineAnimFrom = outlineLightness; + outlineAnimTo = target; + outlineAnimStart = performance.now(); + requestDraw(); +} + +// displayed cursor fill eases toward r/g/b; painting still uses r/g/b immediately. +let dispR = 255, dispG = 255, dispB = 255; +let colorAnimStart = 0; +let colorAnimFromR = 255, colorAnimFromG = 255, colorAnimFromB = 255; +let colorAnimToR = 255, colorAnimToG = 255, colorAnimToB = 255; +function setDisplayColorTarget(nr, ng, nb) { + if (nr === colorAnimToR && ng === colorAnimToG && nb === colorAnimToB) return; + colorAnimFromR = dispR; colorAnimFromG = dispG; colorAnimFromB = dispB; + colorAnimToR = nr; colorAnimToG = ng; colorAnimToB = nb; + colorAnimStart = performance.now(); + requestDraw(); +} + +const faviconEl = document.getElementById('favicon'); +function updateFavicon() { + const svg = "<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1 1'><rect width='1' height='1' fill='rgb(" + r + "," + g + "," + b + ")'/></svg>"; + faviconEl.href = 'data:image/svg+xml,' + encodeURIComponent(svg); +} + +function pickAt(cx, cy) { + const c = readPixel(cx, cy); + r = c[0]; g = c[1]; b = c[2]; + setOutlineTarget((r + g + b > 384) ? 1 : 0); + setDisplayColorTarget(r, g, b); + updateFavicon(); +} + +function setColor(nr, ng, nb) { + r = nr; g = ng; b = nb; + setOutlineTarget((r + g + b > 384) ? 1 : 0); + updateFavicon(); + dispR = r; dispG = g; dispB = b; + colorAnimFromR = r; colorAnimFromG = g; colorAnimFromB = b; + colorAnimToR = r; colorAnimToG = g; colorAnimToB = b; +} diff --git a/resources/canvas.js b/resources/canvas.js @@ -0,0 +1,256 @@ +const view = document.getElementById('view'); +const vctx = view.getContext('2d', { alpha: false }); + +const CHUNK = 256; +let frameSeq = 0; +// frames carry a stable id so collaborators can name the same frame even +// as the list shifts under inserts and deletes +function newFrameMap(id) { + const m = new Map(); + m.id = id || ('l' + (++frameSeq)); + return m; +} +let frames = [newFrameMap()]; +let frameIdx = 0; +let chunks = frames[0]; +let dir = 1; // playback direction: 1 forward, -1 reverse +let onionskin = false; +// how anything belonging to another frame reads: onionskinned content and +// the brushes of painters standing on it +const GHOST_ALPHA = 0.35; +let playing = false; +let playTimer = null; +let frameInterval = 250; +let lastTap = 0; + +function chunkKey(cx, cy) { return cx + ',' + cy; } + +// remote strokes redirect every paint write into the frame they belong to +let paintMap = null; + +function getOrCreateChunk(cx, cy, map) { + map = map || paintMap || chunks; + const k = chunkKey(cx, cy); + let c = map.get(k); + if (c) return c; + const cnv = document.createElement('canvas'); + cnv.width = CHUNK; + cnv.height = CHUNK; + // overlay chunks stay transparent so they composite over the frame + const cctx = cnv.getContext('2d', { alpha: !!map.overlay }); + if (!map.overlay) { + cctx.fillStyle = '#000'; + cctx.fillRect(0, 0, CHUNK, CHUNK); + } + c = { canvas: cnv, ctx: cctx }; + map.set(k, c); + return c; +} + +// unacked local strokes paint here, on top of the frame, until their echo +// commits them into it in the room's order +function overlayFor(fid) { + let m = net.overlays.get(fid); + if (!m) { + m = new Map(); + m.id = fid; + m.overlay = true; + net.overlays.set(fid, m); + } + return m; +} + +function paintRect(wx, wy, w, h, color) { + const x0 = wx, y0 = wy, x1 = wx + w, y1 = wy + h; + const cx0 = Math.floor(x0 / CHUNK); + const cy0 = Math.floor(y0 / CHUNK); + const cx1 = Math.floor((x1 - 1) / CHUNK); + const cy1 = Math.floor((y1 - 1) / CHUNK); + for (let cy = cy0; cy <= cy1; cy++) { + for (let cx = cx0; cx <= cx1; cx++) { + const c = getOrCreateChunk(cx, cy); + const lx = Math.max(x0, cx * CHUNK) - cx * CHUNK; + const ly = Math.max(y0, cy * CHUNK) - cy * CHUNK; + const rx = Math.min(x1, (cx + 1) * CHUNK) - cx * CHUNK; + const ry = Math.min(y1, (cy + 1) * CHUNK) - cy * CHUNK; + c.ctx.fillStyle = color; + c.ctx.fillRect(lx, ly, rx - lx, ry - ly); + } + } +} + +function readPixel(wx, wy) { + const cx = Math.floor(wx / CHUNK); + const cy = Math.floor(wy / CHUNK); + const k = chunkKey(cx, cy); + const lx = wx - cx * CHUNK; + const ly = wy - cy * CHUNK; + // an unacked stroke sits on the overlay; pick what's actually on screen + const ov = net.overlays.get(chunks.id); + const oc = ov && ov.get(k); + if (oc) { + const d = oc.ctx.getImageData(lx, ly, 1, 1).data; + if (d[3] === 255) return [d[0], d[1], d[2]]; + } + const c = chunks.get(k); + if (!c) return [0, 0, 0]; + const d = c.ctx.getImageData(lx, ly, 1, 1).data; + return [d[0], d[1], d[2]]; +} + +function setFrame(i) { + frameIdx = ((i % frames.length) + frames.length) % frames.length; + chunks = frames[frameIdx]; + // a held pick tracks the frame under it as frames change + if (picking) pickAt(curX, curY); + requestDraw(); +} + +// frame structure changes round-trip through the server when connected, so +// every painter ends up with the same list in the same order +function addFrame() { + const at = dir === 1 ? frameIdx + 1 : frameIdx; + if (net.on) { net.send({ t: 'af', at }); return; } + applyAddFrame(at, null, true); +} + +function applyAddFrame(at, id, mine) { + at = clamp(at, 0, frames.length); + const cur = chunks; + frames.splice(at, 0, newFrameMap(id)); + setFrame(mine ? at : frames.indexOf(cur)); +} + +function deleteFrame() { + if (frames.length === 1) return; + if (net.on) { net.send({ t: 'df', id: chunks.id }); return; } + applyDeleteFrame(chunks.id); +} + +function applyDeleteFrame(id) { + if (frames.length === 1) return; + const i = frames.findIndex((f) => f.id === id); + if (i < 0) return; + const cur = chunks; + frames.splice(i, 1); + if (playing && frames.length === 1) stopPlayback(); + const j = frames.indexOf(cur); + setFrame(j >= 0 ? j : Math.min(i, frames.length - 1)); +} + +function stopPlayback() { + playing = false; + clearTimeout(playTimer); + requestDraw(); +} + +function startPlayback() { + if (frames.length === 1) return; + playing = true; + playTimer = setTimeout(function step() { + setFrame(frameIdx + dir); + playTimer = setTimeout(step, frameInterval); + }, frameInterval); +} + +function tapArrow(d) { + dir = d; + const now = performance.now(); + if (lastTap && now - lastTap <= 10000) { + frameInterval = now - lastTap; + // stored, not applied to anyone live: whoever joins next inherits it + net.send({ t: 'interval', v: Math.round(frameInterval) }); + } + lastTap = now; + if (playing) { stopPlayback(); return; } + setFrame(frameIdx + d); +} + +const keys = {}; +let painting = false; +let picking = false; +let lastPaintX = null, lastPaintY = null; +let dirty = false; + +function brushTopLeft(cx, cy) { + const off = Math.floor(brush / 2); + return { x: cx - off, y: cy - off }; +} + +function paintAt(cx, cy) { + dirty = true; + const tl = brushTopLeft(cx, cy); + if (roundness === 0) { + paintRect(tl.x, tl.y, brush, brush, 'rgb(' + r + ',' + g + ',' + b + ')'); + return; + } + const sprite = getBrushShape().sprite; + const cx0 = Math.floor(tl.x / CHUNK); + const cy0 = Math.floor(tl.y / CHUNK); + const cx1 = Math.floor((tl.x + brush - 1) / CHUNK); + const cy1 = Math.floor((tl.y + brush - 1) / CHUNK); + for (let ccy = cy0; ccy <= cy1; ccy++) { + for (let ccx = cx0; ccx <= cx1; ccx++) { + const c = getOrCreateChunk(ccx, ccy); + c.ctx.drawImage(sprite, tl.x - ccx * CHUNK, tl.y - ccy * CHUNK); + } + } +} + +// every mark the local user makes goes out as a segment; a dot +// is just a zero-length one. in a room the pixels land on the frame's +// overlay - the frame itself only takes strokes in the server's order +function localPaintAt(x, y) { + if (net.on) paintMap = overlayFor(chunks.id); + try { + paintAt(x, y); + } finally { + paintMap = null; + } + net.paint(x, y, x, y); +} + +function localPaintLine(x0, y0, x1, y1) { + if (net.on) paintMap = overlayFor(chunks.id); + try { + paintLine(x0, y0, x1, y1); + } finally { + paintMap = null; + } + net.paint(x0, y0, x1, y1); +} + +// replay someone else's segment into whichever frame it belongs to, with +// their brush, without disturbing the local one +function applyPaintOp(op) { + const f = frames.find((fr) => fr.id === op.f); + if (!f || !op.o || !op.c) return; + const pm = paintMap, pr = r, pg = g, pb = b, pbrush = brush, pround = roundness; + paintMap = f; + r = op.c[0]; g = op.c[1]; b = op.c[2]; + brush = op.b; roundness = op.s; + try { + for (let i = 0; i + 3 < op.o.length; i += 4) { + paintLine(op.o[i], op.o[i + 1], op.o[i + 2], op.o[i + 3]); + } + } finally { + paintMap = pm; + r = pr; g = pg; b = pb; + brush = pbrush; roundness = pround; + } + if (f === chunks) requestDraw(); +} + +function paintLine(x0, y0, x1, y1) { + let dx = Math.abs(x1 - x0), sx = x0 < x1 ? 1 : -1; + let dy = -Math.abs(y1 - y0), sy = y0 < y1 ? 1 : -1; + let err = dx + dy; + let x = x0, y = y0; + while (true) { + paintAt(x, y); + if (x === x1 && y === y1) break; + const e2 = 2 * err; + if (e2 >= dy) { err += dy; x += sx; } + if (e2 <= dx) { err += dx; y += sy; } + } +} diff --git a/resources/input.js b/resources/input.js @@ -0,0 +1,576 @@ +window.addEventListener('resize', resize); + +// mouse and pen share the pointer-event path; finger touches are handled +// by the touch-event path below +function hoverPointer(e) { return e.pointerType === 'mouse' || e.pointerType === 'pen'; } + +window.addEventListener('pointerover', (e) => { + if (!hoverPointer(e)) return; + showCursor(); + curClientX = e.clientX; + curClientY = e.clientY; + const p = clientToWorld(e.clientX, e.clientY); + curX = p.x; + curY = p.y; + requestDraw(); +}); + +// brush resize curve: linear (fine, precise) up to BRUSH_LINEAR_MAX, then a +// power-law ramp beyond, tuned by BRUSH_ACCEL. +function brushFromDrag(start, dyPx) { + const A = BRUSH_DRAG_PX_PER_STEP, T = BRUSH_LINEAR_MAX, c = BRUSH_ACCEL; + const seam = T * A; // drag distance from size 0 to the seam + const brushToPos = (b) => b <= T ? b * A : seam + (seam / c) * (Math.pow(b / T, c) - 1); + const posToBrush = (p) => p <= seam ? p / A : T * Math.pow(1 + c * (p - seam) / seam, 1 / c); + return posToBrush(brushToPos(start) + dyPx); +} + +// modifier-drag: shift/cmd/RGB held -> vertical motion from the anchor +// drives brush size / roundness / color channels. drag up (dy negative) +// -> increase. when a value hits its bound and the drag continues past, +// re-anchor so reversing direction responds immediately. each drag is +// independent so any combination can run at once. +function applyDrags(clientY) { + if (dragBrush) { + const dyPx = (dragBrush.anchorY - clientY) * dpr; + const target = brushFromDrag(dragBrush.start, dyPx); + const clamped = clamp(Math.round(target), 1, MAX_BRUSH); + brush = clamped; + if (target < 1 || target > MAX_BRUSH) { + dragBrush.start = clamped; + dragBrush.anchorY = clientY; + } + } + if (dragRoundness) { + const dyPx = (dragRoundness.anchorY - clientY) * dpr; + const target = dragRoundness.start - dyPx / ROUNDNESS_DRAG_FULL_PX; + const clamped = clamp(target, 0, 1); + roundness = clamped; + if (target !== clamped) { + dragRoundness.start = clamped; + dragRoundness.anchorY = clientY; + } + } + if (dragColor) { + const dyPx = (dragColor.anchorY - clientY) * dpr; + const delta = (dyPx / COLOR_DRAG_FULL_PX) * 255; + let nr = r, ng = g, nb = b; + let overshoot = 0; + if (keys['r']) { + const t = dragColor.r0 + delta; + nr = clamp(t, 0, 255); + if (t !== nr) overshoot = Math.max(overshoot, Math.abs(t - nr)); + } + if (keys['g']) { + const t = dragColor.g0 + delta; + ng = clamp(t, 0, 255); + if (t !== ng) overshoot = Math.max(overshoot, Math.abs(t - ng)); + } + if (keys['b']) { + const t = dragColor.b0 + delta; + nb = clamp(t, 0, 255); + if (t !== nb) overshoot = Math.max(overshoot, Math.abs(t - nb)); + } + setColor(Math.round(nr), Math.round(ng), Math.round(nb)); + if (overshoot > 0) { + dragColor.r0 = nr; dragColor.g0 = ng; dragColor.b0 = nb; + dragColor.anchorY = clientY; + } + } +} + +window.addEventListener('pointermove', (e) => { + if (!hoverPointer(e)) return; + // a pen only proves it can hover by moving while lifted. without hover + // it gets the finger treatment: modifiers adjust instead of marking + if (e.pointerType === 'pen' && e.buttons === 0) penHover = true; + showCursor(); + curClientX = e.clientX; + curClientY = e.clientY; + const p = clientToWorld(e.clientX, e.clientY); + const nx = p.x, ny = p.y; + + applyDrags(e.clientY); + + if (painting) { + if (lastPaintX !== null) { + localPaintLine(lastPaintX, lastPaintY, nx, ny); + } else { + localPaintAt(nx, ny); + } + lastPaintX = nx; + lastPaintY = ny; + } else if (picking) { + pickAt(nx, ny); + } + curX = nx; + curY = ny; + requestDraw(); +}); + +window.addEventListener('pointerleave', (e) => { + if (!hoverPointer(e)) return; + hideCursorNow(); + requestDraw(); +}); + +// a pen without hover support sends no move events before contact, so the +// down event must place the cursor itself +let penDown = false; +let penHover = false; +window.addEventListener('pointerdown', (e) => { + if (!hoverPointer(e)) return; + if (e.button !== 0) return; + e.preventDefault(); + stopInertia(); + if (e.pointerType === 'pen') penDown = true; + showCursor(); + curClientX = e.clientX; + curClientY = e.clientY; + const p = clientToWorld(e.clientX, e.clientY); + curX = p.x; + curY = p.y; + // hoverless pen + modifier held -> the contact adjusts, like a finger + if (e.pointerType === 'pen' && !penHover && !keys['c'] && (dragBrush || dragRoundness || dragColor)) { + anchorDrags(e.clientY); + requestDraw(); + return; + } + if (keys['c']) { + picking = true; + pickAt(curX, curY); + requestDraw(); + return; + } + painting = true; + lastPaintX = curX; + lastPaintY = curY; + localPaintAt(curX, curY); + requestDraw(); +}); + +function pointerUp(e) { + if (!hoverPointer(e)) return; + if (e.button === 0 || e.type === 'pointercancel') { + painting = false; + picking = false; + lastPaintX = null; + lastPaintY = null; + if (e.pointerType === 'pen') { + penDown = false; + // hoverless pens get no more events after lift, so fade like touch + if (mouseInside) setCursorFadeTarget(0); + requestDraw(); + } + } +} +window.addEventListener('pointerup', pointerUp); +window.addEventListener('pointercancel', pointerUp); + +// touch: one finger paints, two fingers pan/pinch-zoom. once a second +// finger lands the whole touch becomes a gesture (no painting) until all +// fingers lift, so a stray finger during a pan never leaves a mark. +// stylus touches are excluded here +let touchMode = null; // 'paint' | 'adjust' | 'gesture' +let touchPainted = false; +let pinchDist = 0, pinchMidX = 0, pinchMidY = 0; + +// inertial pan: centroid velocity (css px/ms) tracked during the gesture, +// thrown on release and decayed with exponential friction +let panVX = 0, panVY = 0; +let panLastT = 0; +let inertiaRAF = null; +function stopInertia() { + if (inertiaRAF !== null) { + cancelAnimationFrame(inertiaRAF); + inertiaRAF = null; + } +} +function startInertia() { + // no throw if the finger paused before lifting or was barely moving + const speed = Math.hypot(panVX, panVY); + if (performance.now() - panLastT > 100 || speed < 0.05) return; + // exponential decay per ms + const DECEL = 0.995; + const MAX_SPEED = 3; // css px/ms + if (speed > MAX_SPEED) { + panVX *= MAX_SPEED / speed; + panVY *= MAX_SPEED / speed; + } + let prev = performance.now(); + const step = (now) => { + const dt = now - prev; + prev = now; + camX -= panVX * dt / zoom; + camY -= panVY * dt / zoom; + const f = Math.pow(DECEL, dt); + panVX *= f; + panVY *= f; + requestDraw(); + inertiaRAF = Math.hypot(panVX, panVY) > 0.02 ? requestAnimationFrame(step) : null; + }; + inertiaRAF = requestAnimationFrame(step); +} + +function touchCentroid(touches) { + let x = 0, y = 0; + for (const t of touches) { x += t.clientX; y += t.clientY; } + return { x: x / touches.length, y: y / touches.length }; +} + +function fingerTouches(list) { + const out = []; + for (const t of list) if (t.touchType !== 'stylus') out.push(t); + return out; +} + +// re-anchor active drags to a new y and re-baseline to the current values +// so successive drag strokes accumulate instead of snapping back +function anchorDrags(clientY) { + if (dragBrush) { dragBrush.anchorY = clientY; dragBrush.start = brush; } + if (dragRoundness) { dragRoundness.anchorY = clientY; dragRoundness.start = roundness; } + if (dragColor) { dragColor.anchorY = clientY; dragColor.r0 = r; dragColor.g0 = g; dragColor.b0 = b; } +} + +function anchorGesture(touches) { + const m = touchCentroid(touches); + pinchMidX = m.x; + pinchMidY = m.y; + pinchDist = touches.length >= 2 + ? Math.hypot(touches[0].clientX - touches[1].clientX, touches[0].clientY - touches[1].clientY) + : 0; +} + +window.addEventListener('touchstart', (e) => { + e.preventDefault(); + const fingers = fingerTouches(e.touches); + // fingers are ignored while the pen is down so a resting hand can't + // hijack or extend the pen's stroke + if (fingers.length === 0 || penDown) return; + stopInertia(); + if (fingers.length === 1 && touchMode === null) { + const t = fingers[0]; + curClientX = t.clientX; + curClientY = t.clientY; + if (!keys['c'] && (dragBrush || dragRoundness || dragColor)) { + touchMode = 'adjust'; + const p = clientToWorld(t.clientX, t.clientY); + curX = p.x; + curY = p.y; + showCursor(); + anchorDrags(t.clientY); + } else { + const p = clientToWorld(t.clientX, t.clientY); + curX = p.x; + curY = p.y; + showCursor(); + touchMode = 'paint'; + touchPainted = false; + if (keys['c']) { + picking = true; + } else { + painting = true; + lastPaintX = curX; + lastPaintY = curY; + } + } + } else { + touchMode = 'gesture'; + painting = false; + picking = false; + lastPaintX = null; + lastPaintY = null; + hideCursorNow(); + panVX = 0; + panVY = 0; + panLastT = 0; + anchorGesture(fingers); + } + requestDraw(); +}, { passive: false }); + +window.addEventListener('touchmove', (e) => { + e.preventDefault(); + const fingers = fingerTouches(e.touches); + if (fingers.length === 0) return; + if (touchMode === 'paint') { + const t = fingers[0]; + curClientX = t.clientX; + curClientY = t.clientY; + // on hover devices held keys adjust during the stroke, like mouse + applyDrags(t.clientY); + const p = clientToWorld(t.clientX, t.clientY); + if (picking) { + pickAt(p.x, p.y); + } else if (painting) { + localPaintLine(lastPaintX, lastPaintY, p.x, p.y); + lastPaintX = p.x; + lastPaintY = p.y; + } + touchPainted = true; + curX = p.x; + curY = p.y; + } else if (touchMode === 'adjust') { + const t = fingers[0]; + curClientX = t.clientX; + curClientY = t.clientY; + applyDrags(t.clientY); + const p = clientToWorld(t.clientX, t.clientY); + curX = p.x; + curY = p.y; + } else if (touchMode === 'gesture') { + const m = touchCentroid(fingers); + const dxs = m.x - pinchMidX; + const dys = m.y - pinchMidY; + camX -= dxs / zoom; + camY -= dys / zoom; + const nowT = performance.now(); + const dt = nowT - panLastT; + if (dt > 0 && dt < 100) { + panVX = panVX * 0.5 + (dxs / dt) * 0.5; + panVY = panVY * 0.5 + (dys / dt) * 0.5; + } + panLastT = nowT; + if (fingers.length >= 2) { + const dist = Math.hypot(fingers[0].clientX - fingers[1].clientX, fingers[0].clientY - fingers[1].clientY); + if (pinchDist > 0) applyZoom(dist / pinchDist, m.x, m.y); + pinchDist = dist; + } + pinchMidX = m.x; + pinchMidY = m.y; + } else { + return; + } + requestDraw(); +}, { passive: false }); + +function touchEnd(e) { + e.preventDefault(); + if (touchMode === null) return; + const fingers = fingerTouches(e.touches); + if (fingers.length === 0) { + // a tap that never moved still paints its dot (or picks its point) + if (touchMode === 'paint' && !touchPainted) { + if (picking) pickAt(curX, curY); + else localPaintAt(curX, curY); + } + if (touchMode === 'gesture') startInertia(); + touchMode = null; + painting = false; + picking = false; + lastPaintX = null; + lastPaintY = null; + // fade the preview out instead of hiding it instantly + if (mouseInside) setCursorFadeTarget(0); + requestDraw(); + } else if (touchMode === 'gesture') { + // re-anchor to the remaining fingers so the camera doesn't jump + anchorGesture(fingers); + } +} +window.addEventListener('touchend', touchEnd, { passive: false }); +window.addEventListener('touchcancel', touchEnd, { passive: false }); + +function startDragMode(mode) { + const anchorY = curClientY !== null ? curClientY : 0; + if (mode === 'brush' && !dragBrush) dragBrush = { anchorY, start: brush }; + else if (mode === 'roundness' && !dragRoundness) dragRoundness = { anchorY, start: roundness }; + else if (mode === 'color' && !dragColor) dragColor = { anchorY, r0: r, g0: g, b0: b }; + // hoverless input: a modifier pressed mid-stroke ends the stroke and + // the rest of the contact becomes an adjust drag. hover-capable pens + // keep drawing (they can adjust while lifted instead) + if (touchMode === 'paint' || (penDown && !penHover)) { + if (touchMode === 'paint') touchMode = 'adjust'; + painting = false; + picking = false; + lastPaintX = null; + lastPaintY = null; + } +} +function endDragMode(mode) { + if (mode === 'brush') dragBrush = null; + else if (mode === 'roundness') dragRoundness = null; + else if (mode === 'color') dragColor = null; +} + +window.addEventListener('keydown', (e) => { + if ((e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey && e.key.toLowerCase() === 's') { + e.preventDefault(); + exportGIF(); + return; + } + if ((e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey && e.key.toLowerCase() === 'o') { + e.preventDefault(); + fileInput.click(); + return; + } + if ((e.metaKey || e.ctrlKey) && !e.altKey) { + if (e.key === '=' || e.key === '+') { e.preventDefault(); stepZoom(1); return; } + if (e.key === '-' || e.key === '_') { e.preventDefault(); stepZoom(-1); return; } + if (e.key === '0') { e.preventDefault(); resetZoom(); return; } + } + if (!e.metaKey && !e.ctrlKey && !e.altKey && !e.repeat) { + const lk = e.key.toLowerCase(); + if (e.key === 'ArrowLeft') { e.preventDefault(); tapArrow(-1); return; } + if (e.key === 'ArrowRight') { e.preventDefault(); tapArrow(1); return; } + if (e.key === ' ') { e.preventDefault(); playing ? stopPlayback() : startPlayback(); return; } + if (lk === 'a') { addFrame(); return; } + if (lk === 'd') { deleteFrame(); return; } + if (lk === 'o') { + if (playing) { stopPlayback(); onionskin = true; } + else onionskin = !onionskin; + requestDraw(); + return; + } + } + const k = e.key.toLowerCase(); + const wasDown = keys[k]; + keys[k] = true; + if (!e.metaKey && !e.ctrlKey) { + if (k === 'z' || k === 'x' || k === 'c' || k === 'r' || k === 'g' || k === 'b') e.preventDefault(); + if (k === 'x') startDragMode('roundness'); + else if (k === 'z') startDragMode('brush'); + else if (!wasDown && (k === 'r' || k === 'g' || k === 'b')) startDragMode('color'); + else if (k === 'c' && !wasDown && (touchMode === 'paint' || touchMode === 'adjust')) { + touchMode = 'paint'; + painting = false; + picking = true; + lastPaintX = null; + lastPaintY = null; + touchPainted = true; + pickAt(curX, curY); + requestDraw(); + } + } +}); +window.addEventListener('keyup', (e) => { + const k = e.key.toLowerCase(); + keys[k] = false; + if (k === 'x') endDragMode('roundness'); + else if (k === 'z') endDragMode('brush'); + else if (k === 'r' || k === 'g' || k === 'b') { + if (!keys['r'] && !keys['g'] && !keys['b']) endDragMode('color'); + else if (dragColor) { + // still holding at least one rgb key - re-anchor from current + // values so the remaining keys don't jump based on released key's history + dragColor.anchorY = curClientY !== null ? curClientY : dragColor.anchorY; + dragColor.r0 = r; dragColor.g0 = g; dragColor.b0 = b; + } + } + // releasing c mid-pick-drag -> back to adjusting if a modifier is still + // held on a hoverless contact, else seamlessly switch to painting + if (k === 'c' && picking) { + picking = false; + const hoverless = touchMode !== null || (penDown && !penHover); + if (hoverless && (dragBrush || dragRoundness || dragColor)) { + if (touchMode !== null) touchMode = 'adjust'; + painting = false; + if (curClientY !== null) anchorDrags(curClientY); + } else { + painting = true; + lastPaintX = curX; + lastPaintY = curY; + localPaintAt(curX, curY); + } + requestDraw(); + } +}); +window.addEventListener('blur', () => { + for (const k in keys) keys[k] = false; + dragBrush = null; dragRoundness = null; dragColor = null; + painting = false; + picking = false; + penDown = false; + lastPaintX = null; + lastPaintY = null; +}); + +// wheel gesture lock: once a wheel gesture starts in a given mode, +// subsequent events in the same burst stay in that mode until the wheel +// goes idle or the controlling modifier set changes. this keeps trackpad +// momentum from leaking into pan after a modifier release, while still +// letting the user swap between modes mid-flick without stale state. +let wheelMode = null; +let wheelIdleTimer = null; +let panning = false; + +const BRUSH_DRAG_PX_PER_STEP = 25; // device px per +/- 1 brush px (below the seam) +const BRUSH_LINEAR_MAX = 6; // brush px below which resize stays linear/fine +const BRUSH_ACCEL = 0.5; +const MAX_BRUSH = 150; +const ROUNDNESS_DRAG_FULL_PX = 200; +const COLOR_DRAG_FULL_PX = 256; +// each drag carries its own anchor so any combination can run at once +let dragBrush = null; // {anchorY, start} +let dragRoundness = null; // {anchorY, start} +let dragColor = null; // {anchorY, r0, g0, b0} +function touchWheelGesture() { + if (wheelIdleTimer) clearTimeout(wheelIdleTimer); + wheelIdleTimer = setTimeout(() => { + wheelMode = null; + if (panning) { + // pan ended - resync the logical-pixel cursor from the real + // client position so the brush snaps onto its final cell. + panning = false; + if (curClientX !== null) { + const p = clientToWorld(curClientX, curClientY); + curX = p.x; + curY = p.y; + } + requestDraw(); + } + }, 150); +} +function pickModifierMode(e) { + if (e.ctrlKey) return 'zoom'; + return null; +} + +window.addEventListener('wheel', (e) => { + e.preventDefault(); + stopInertia(); + + // re-pick mode each event from live modifiers so releasing cmd and + // immediately starting a shift scroll switches over cleanly. if a + // burst started with a modifier and the modifier is then released + // mid-flick, suppress rather than leaking into pan. + const modMode = pickModifierMode(e); + if (modMode !== null) { + wheelMode = modMode; + } else if (wheelMode === null) { + wheelMode = 'pan'; + } else if (wheelMode !== 'pan') { + // modifier released mid-burst: drop the remaining momentum + // instead of letting it leak into pan. + touchWheelGesture(); + return; + } + touchWheelGesture(); + + if (wheelMode === 'zoom') { + // exponential zoom on the float accumulator so small pinches add up + applyZoom(Math.exp(-e.deltaY * 0.02), e.clientX, e.clientY); + requestDraw(); + return; + } + + // pan - smooth fractional camera. during the pan gesture the draw + // loop anchors the brush to the real client cursor position so it + // tracks the pointer smoothly; curX/curY get resynced onto the + // logical-pixel grid when the wheel idle timer fires. + panning = true; + camX += e.deltaX / zoom; + camY += e.deltaY / zoom; + requestDraw(); +}, { passive: false }); + +window.addEventListener('beforeunload', (e) => { + if (dirty) { e.preventDefault(); } +}); + +window.addEventListener('contextmenu', (e) => e.preventDefault()); + +// block the OS pinch gesture events too (Safari) +window.addEventListener('gesturestart', (e) => e.preventDefault()); +window.addEventListener('gesturechange', (e) => e.preventDefault()); +window.addEventListener('gestureend', (e) => e.preventDefault()); diff --git a/resources/io.js b/resources/io.js @@ -0,0 +1,269 @@ +const fileInput = document.createElement('input'); +fileInput.type = 'file'; +fileInput.accept = 'image/*'; +fileInput.style.display = 'none'; +document.body.appendChild(fileInput); +fileInput.addEventListener('change', (e) => { + const f = e.target.files && e.target.files[0]; + if (f) importFile(f); + fileInput.value = ''; +}); + +function formatTimestamp(d) { + const p = (n) => String(n).padStart(2, '0'); + return p(d.getFullYear() % 100) + '\u00b7' + p(d.getMonth() + 1) + '\u00b7' + p(d.getDate()) + '\u00b7' + p(d.getHours()) + '\u00b7' + p(d.getMinutes()) + '\u00b7' + p(d.getSeconds()); +} + +// union of painted chunk bounds across all frames, so every exported +// frame shares the canvas of the biggest one +function frameBounds() { + let any = false, minCx = Infinity, minCy = Infinity, maxCx = -Infinity, maxCy = -Infinity; + for (const f of frames) { + for (const k of f.keys()) { + any = true; + const [cx, cy] = k.split(',').map(Number); + if (cx < minCx) minCx = cx; + if (cy < minCy) minCy = cy; + if (cx > maxCx) maxCx = cx; + if (cy > maxCy) maxCy = cy; + } + } + return any ? { minCx, minCy, maxCx, maxCy } : null; +} + +function renderFrame(f, b, w, h) { + const out = document.createElement('canvas'); + out.width = w; + out.height = h; + const octx = out.getContext('2d'); + octx.fillStyle = '#000'; + octx.fillRect(0, 0, w, h); + for (const [k, c] of f) { + const [cx, cy] = k.split(',').map(Number); + octx.drawImage(c.canvas, (cx - b.minCx) * CHUNK, (cy - b.minCy) * CHUNK); + } + return octx.getImageData(0, 0, w, h); +} + +function lzwEncode(minCode, data, lookup, out) { + const clear = 1 << minCode, eoi = clear + 1; + let codeSize = minCode + 1, next = eoi + 1; + let dict = new Map(); + let acc = 0, accBits = 0; + let block = []; + const flushBlock = () => { + if (block.length) { out.push(block.length, ...block); block = []; } + }; + const emit = (code) => { + acc |= code << accBits; + accBits += codeSize; + while (accBits >= 8) { + block.push(acc & 255); + acc >>= 8; + accBits -= 8; + if (block.length === 255) flushBlock(); + } + }; + const idx = (i) => lookup((data[i] << 16) | (data[i + 1] << 8) | data[i + 2]); + emit(clear); + let prev = idx(0); + for (let i = 4; i < data.length; i += 4) { + const k = idx(i); + const key = prev * 256 + k; + if (dict.has(key)) { prev = dict.get(key); continue; } + emit(prev); + if (next === 4096) { + emit(clear); + dict = new Map(); + next = eoi + 1; + codeSize = minCode + 1; + } else { + if (next >= (1 << codeSize)) codeSize++; + dict.set(key, next++); + } + prev = k; + } + emit(prev); + emit(eoi); + if (accBits) block.push(acc & 255); + flushBlock(); +} + +// minimal GIF89a encoder: exact global palette when <=256 colors, +// else uniform 6x6x6 quantization +function encodeGIF(images, w, h, delayMs) { + const colorIdx = new Map(); + let over = false; + for (const img of images) { + const d = img.data; + for (let i = 0; i < d.length && !over; i += 4) { + const c = (d[i] << 16) | (d[i + 1] << 8) | d[i + 2]; + if (!colorIdx.has(c)) { + if (colorIdx.size === 256) over = true; + else colorIdx.set(c, colorIdx.size); + } + } + if (over) break; + } + if (over) { + colorIdx.clear(); + for (let i = 0; i < 216; i++) { + colorIdx.set(((Math.floor(i / 36) * 51) << 16) | ((Math.floor(i / 6) % 6 * 51) << 8) | (i % 6 * 51), i); + } + } + const lookup = (c) => over + ? Math.round(((c >> 16) & 255) / 51) * 36 + Math.round(((c >> 8) & 255) / 51) * 6 + Math.round((c & 255) / 51) + : colorIdx.get(c); + let bits = 2; + while ((1 << bits) < colorIdx.size) bits++; + const out = []; + const u16 = (v) => { out.push(v & 255, (v >> 8) & 255); }; + out.push(71, 73, 70, 56, 57, 97); // "GIF89a" + u16(w); u16(h); + out.push(0x80 | ((bits - 1) << 4) | (bits - 1), 0, 0); + const pal = [...colorIdx.keys()]; + for (let i = 0; i < (1 << bits); i++) { + const c = pal[i] || 0; + out.push((c >> 16) & 255, (c >> 8) & 255, c & 255); + } + const animated = images.length > 1; + if (animated) { + // NETSCAPE2.0 loop forever + out.push(0x21, 0xff, 11, 78, 69, 84, 83, 67, 65, 80, 69, 50, 46, 48, 3, 1, 0, 0, 0); + } + const delay = clamp(Math.round(delayMs / 10), 2, 65535); + for (const img of images) { + if (animated) out.push(0x21, 0xf9, 4, 0, delay & 255, (delay >> 8) & 255, 0, 0); + out.push(0x2c); + u16(0); u16(0); u16(w); u16(h); + out.push(0); + const minCode = Math.max(2, bits); + out.push(minCode); + lzwEncode(minCode, img.data, lookup, out); + out.push(0); + } + out.push(0x3b); + return new Uint8Array(out); +} + +function exportGIF() { + const b = frameBounds(); + if (!b) return; + const w = (b.maxCx - b.minCx + 1) * CHUNK; + const h = (b.maxCy - b.minCy + 1) * CHUNK; + const imgs = frames.map((f) => renderFrame(f, b, w, h)); + if (dir === -1) imgs.reverse(); + const blob = new Blob([encodeGIF(imgs, w, h, frameInterval)], { type: 'image/gif' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'animus \u00b7 ' + formatTimestamp(new Date()) + '.gif'; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + dirty = false; +} + +function imageToFrame(src, w, h) { + const ox = -Math.floor(w / 2); + const oy = -Math.floor(h / 2); + const f = newFrameMap(); + drawIntoFrame(f, src, w, h, ox, oy); + return { f, w, h, ox, oy }; +} + +function drawIntoFrame(f, src, w, h, ox, oy) { + const cx0 = Math.floor(ox / CHUNK); + const cy0 = Math.floor(oy / CHUNK); + const cx1 = Math.floor((ox + w - 1) / CHUNK); + const cy1 = Math.floor((oy + h - 1) / CHUNK); + for (let cy = cy0; cy <= cy1; cy++) { + for (let cx = cx0; cx <= cx1; cx++) { + getOrCreateChunk(cx, cy, f).ctx.drawImage(src, ox - cx * CHUNK, oy - cy * CHUNK); + } + } +} + +function finishImport(newFrames, w, h, ox, oy) { + frames = newFrames; + dir = 1; + setFrame(0); + camX = ox - (cssW / zoom - w) / 2; + camY = oy - (cssH / zoom - h) / 2; + dirty = false; + requestDraw(); +} + +function loadImage(src) { + return new Promise((resolve, reject) => { + const img = new Image(); + img.onload = () => resolve(img); + img.onerror = reject; + img.src = src; + }); +} + +// decoded frames of the file as {src, w, h, close} +async function decodeSources(file) { + // ImageDecoder gives us every frame of an animated gif; fall back to + // single-image import where unsupported + if (typeof ImageDecoder !== 'undefined') { + try { + const dec = new ImageDecoder({ data: await file.arrayBuffer(), type: file.type }); + await dec.tracks.ready; + const count = dec.tracks.selectedTrack.frameCount; + const srcs = []; + let interval = 0; + for (let i = 0; i < count; i++) { + const { image } = await dec.decode({ frameIndex: i }); + if (i === 0 && image.duration) interval = Math.max(20, image.duration / 1000); + srcs.push({ src: image, w: image.displayWidth, h: image.displayHeight, close: () => image.close() }); + } + return { srcs, interval }; + } catch (err) {} + } + const url = URL.createObjectURL(file); + try { + const img = await loadImage(url); + return { srcs: [{ src: img, w: img.width, h: img.height }], interval: 0 }; + } catch (err) { + return null; + } finally { + URL.revokeObjectURL(url); + } +} + +function applyImport(srcs) { + const newFrames = []; + let last = null; + for (const s of srcs) { + last = imageToFrame(s.src, s.w, s.h); + newFrames.push(last.f); + } + if (last) finishImport(newFrames, last.w, last.h, last.ox, last.oy); +} + +// flat png per frame, positioned in world space, so an import can be handed +// to everyone else in the room (and to whoever joins later) +function sourcesToBases(srcs) { + const cnv = document.createElement('canvas'); + const cctx = cnv.getContext('2d'); + return srcs.map((s) => { + cnv.width = s.w; + cnv.height = s.h; + cctx.drawImage(s.src, 0, 0); + return { img: cnv.toDataURL('image/png'), ox: -Math.floor(s.w / 2), oy: -Math.floor(s.h / 2), w: s.w, h: s.h }; + }); +} + +async function importFile(file) { + if (dirty && !confirm('Importing will discard the current ' + (frames.length > 1 ? 'animation' : 'painting') + '. Continue?' + (net.on ? ' (for everyone)' : ''))) return; + if (playing) stopPlayback(); + const dec = await decodeSources(file); + if (!dec || !dec.srcs.length) return; + if (dec.interval) frameInterval = dec.interval; + if (net.on) net.send({ t: 'reset', frames: sourcesToBases(dec.srcs), interval: frameInterval }); + else applyImport(dec.srcs); + for (const s of dec.srcs) if (s.close) s.close(); +} diff --git a/resources/net.js b/resources/net.js @@ -0,0 +1,245 @@ +// painting together: mirrors strokes, frames, and brushes between +// everyone in the room + +const net = { + on: false, + id: null, + ws: null, + peers: new Map(), + joined: false, // has this page ever been in a room + queue: null, // messages held while a snapshot loads + lastCursor: '', + batch: null, // segments painted this frame, not yet sent + key: 0, + pending: [], // own strokes still waiting for their echo + overlays: new Map(), // frame id -> chunk map of those unacked strokes +}; + +net.send = function (msg) { + if (!net.on) return; + try { + net.ws.send(JSON.stringify(msg)); + } catch (err) {} +}; + +// a fast pen reports far more moves than anyone can see, so segments pile up +// into one message per animation frame. a new frame, color, or brush starts +// a new batch, since a batch carries a single style +net.paint = function (x0, y0, x1, y1) { + if (!net.on) return; + const cur = net.batch; + if (cur && cur.f === chunks.id && cur.b === brush && cur.s === roundness + && cur.c[0] === r && cur.c[1] === g && cur.c[2] === b) { + cur.o.push(x0, y0, x1, y1); + return; + } + net.flush(); + net.batch = { t: 'p', k: ++net.key, f: chunks.id, o: [x0, y0, x1, y1], c: [r, g, b], b: brush, s: roundness }; + // tracked from the first segment, so the overlay it painted onto is held + // until the echo commits it to the frame proper + net.pending.push({ k: net.batch.k, f: chunks.id }); +}; + +net.flush = function () { + const out = net.batch; + if (!out) return; + net.batch = null; + net.send(out); +}; + +// an overlay lives exactly as long as some stroke on it awaits its echo +function clearOverlayIfIdle(fid) { + if (net.pending.some((p) => p.f === fid)) return; + if (net.overlays.delete(fid)) requestDraw(); +} + +// called at the end of every draw, so the brush other people see moves +// exactly when ours does, and never more often than a frame +net.sendCursor = function () { + if (!net.on) return; + // the target, not the current alpha: everyone else runs the same fade + // from the moment it starts, so a slow fade-out never reads as a blink + const v = cursorFadeTo > 0; + const key = v ? curX + ',' + curY + ',' + brush + ',' + roundness + ',' + r + ',' + g + ',' + b + ',' + chunks.id : '0'; + if (key === net.lastCursor) return; + net.lastCursor = key; + net.send({ t: 'c', x: curX, y: curY, b: brush, s: roundness, c: [r, g, b], f: chunks.id, v }); +}; + +function frameSnapshot(f) { + let any = false, minCx = Infinity, minCy = Infinity, maxCx = -Infinity, maxCy = -Infinity; + for (const k of f.keys()) { + any = true; + const [cx, cy] = k.split(',').map(Number); + if (cx < minCx) minCx = cx; + if (cy < minCy) minCy = cy; + if (cx > maxCx) maxCx = cx; + if (cy > maxCy) maxCy = cy; + } + if (!any) return { id: f.id, img: null }; + const w = (maxCx - minCx + 1) * CHUNK; + const h = (maxCy - minCy + 1) * CHUNK; + const cnv = document.createElement('canvas'); + cnv.width = w; + cnv.height = h; + const cctx = cnv.getContext('2d'); + cctx.fillStyle = '#000'; + cctx.fillRect(0, 0, w, h); + for (const [k, c] of f) { + const [cx, cy] = k.split(',').map(Number); + cctx.drawImage(c.canvas, (cx - minCx) * CHUNK, (cy - minCy) * CHUNK); + } + return { id: f.id, img: cnv.toDataURL('image/png'), ox: minCx * CHUNK, oy: minCy * CHUNK, w, h }; +} + +// rebuild frames from flat images the server is holding +async function framesFromBases(list) { + const out = []; + for (const e of list) { + const f = newFrameMap(e.id); + if (e.img) { + try { + const img = await loadImage(e.img); + drawIntoFrame(f, img, e.w || img.width, e.h || img.height, e.ox || 0, e.oy || 0); + } catch (err) {} + } + out.push(f); + } + return out; +} + +// hold live messages until the frames they refer to exist +function netDefer(msg) { + if (!net.queue) return false; + net.queue.push(msg); + return true; +} + +function netFlush() { + const q = net.queue; + net.queue = null; + if (!q) return; + for (const msg of q) netHandle(msg); +} + +// swap in a server-supplied frame list, replaying anything that arrived +// while the images were decoding only after `after` has caught us up +async function netAdopt(list, after) { + net.queue = []; + const built = await framesFromBases(list); + frames = built; + setFrame(0); + if (after) after(); + netFlush(); + requestDraw(); +} + +function netHandle(msg) { + if (msg.t === 'init') { + net.id = msg.id; + net.batch = null; + net.pending.length = 0; + net.overlays.clear(); + if (msg.interval) frameInterval = msg.interval; + netAdopt(msg.frames, () => { + for (const e of msg.frames) { + for (const op of e.ops || []) applyPaintOp({ ...op, f: e.id }); + } + dirty = false; + }); + return; + } + if (netDefer(msg)) return; + if (msg.t === 'p') { + // strokes land in the frame strictly in the order the server sent + // them - ours included. until now our own stroke existed only on the + // overlay; this echo is what commits it, so every painter's frame is + // the same ops in the same order + applyPaintOp(msg); + if (msg.by === net.id) { + const i = net.pending.findIndex((p) => p.k === msg.k); + if (i < 0) return; + // earlier entries can only be strokes the server dropped (their + // frame was deleted); sweep them along + const gone = net.pending.splice(0, i + 1); + for (const p of gone) clearOverlayIfIdle(p.f); + } + } else if (msg.t === 'c') { + const p = net.peers.get(msg.id) || { alpha: 0, fadeFrom: 0, fadeTo: 0, fadeStart: 0 }; + const target = msg.v ? 1 : 0; + if (target !== p.fadeTo) { + p.fadeFrom = p.alpha; + p.fadeTo = target; + p.fadeStart = performance.now(); + } + p.x = msg.x; p.y = msg.y; p.b = msg.b; p.s = msg.s; p.c = msg.c; p.f = msg.f; + net.peers.set(msg.id, p); + requestDraw(); + } else if (msg.t === 'bye') { + const p = net.peers.get(msg.id); + if (p && p.fadeTo !== 0) { + p.fadeFrom = p.alpha; + p.fadeTo = 0; + p.fadeStart = performance.now(); + } + requestDraw(); + } else if (msg.t === 'af') { + applyAddFrame(msg.at, msg.id, msg.by === net.id); + } else if (msg.t === 'df') { + // the server drops strokes aimed at a deleted frame, so their echoes + // never come; the frame is going away, and its overlay with it + net.pending = net.pending.filter((p) => p.f !== msg.id); + net.overlays.delete(msg.id); + applyDeleteFrame(msg.id); + } else if (msg.t === 'reset') { + if (playing) stopPlayback(); + net.pending.length = 0; + net.overlays.clear(); + if (msg.interval) frameInterval = msg.interval; + netAdopt(msg.frames, () => { + const first = msg.frames[0] || {}; + dir = 1; + camX = (first.ox || 0) - (cssW / zoom - (first.w || 0)) / 2; + camY = (first.oy || 0) - (cssH / zoom - (first.h || 0)) / 2; + dirty = false; + }); + } else if (msg.t === 'snap') { + // the server's stroke log got long - hand it flat images instead so + // the next person to join doesn't have to replay the whole session + net.send({ t: 'snapshot', upto: msg.upto, frames: frames.map(frameSnapshot) }); + } +} + +function netConnect() { + if (location.protocol !== 'http:' && location.protocol !== 'https:') return; + const ws = new WebSocket((location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/ws'); + ws.onopen = () => { + net.ws = ws; + net.on = true; + net.joined = true; + net.lastCursor = ''; + }; + ws.onmessage = (e) => { + let msg = null; + try { msg = JSON.parse(e.data); } catch (err) { return; } + netHandle(msg); + }; + ws.onclose = () => { + const wasOn = net.on; + net.on = false; + net.ws = null; + net.peers.clear(); + net.queue = null; + // whatever was in flight is moot: the server's state wins on the + // way back in + net.batch = null; + net.pending.length = 0; + net.overlays.clear(); + if (wasOn) requestDraw(); + // a page that was never in a room (opened from a file, or from a + // static host) stays solo; one that was keeps reaching for the + // server, which may just be restarting + if (net.joined) setTimeout(netConnect, 1500); + }; + ws.onerror = () => ws.close(); +} diff --git a/resources/render.js b/resources/render.js @@ -0,0 +1,237 @@ +let drawQueued = false; +function requestDraw() { + if (drawQueued) return; + drawQueued = true; + requestAnimationFrame(() => { + drawQueued = false; + draw(); + }); +} + +function draw() { + const now = performance.now(); + if (outlineLightness !== outlineAnimTo) { + const t = (now - outlineAnimStart) / OUTLINE_ANIM_MS; + if (t >= 1) { + outlineLightness = outlineAnimTo; + } else { + const e = t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2; + outlineLightness = outlineAnimFrom + (outlineAnimTo - outlineAnimFrom) * e; + requestDraw(); + } + } + if (dispR !== colorAnimToR || dispG !== colorAnimToG || dispB !== colorAnimToB) { + const t = (now - colorAnimStart) / OUTLINE_ANIM_MS; + if (t >= 1) { + dispR = colorAnimToR; dispG = colorAnimToG; dispB = colorAnimToB; + } else { + const e = t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2; + dispR = Math.round(colorAnimFromR + (colorAnimToR - colorAnimFromR) * e); + dispG = Math.round(colorAnimFromG + (colorAnimToG - colorAnimFromG) * e); + dispB = Math.round(colorAnimFromB + (colorAnimToB - colorAnimFromB) * e); + requestDraw(); + } + } + + const W = view.width, H = view.height; + vctx.setTransform(1, 0, 0, 1, 0, 0); + vctx.imageSmoothingEnabled = false; + vctx.fillStyle = '#000'; + vctx.fillRect(0, 0, W, H); + + // one logical pixel on screen = integer device px. we round here so + // every logical pixel occupies the exact same number of device pixels: + // otherwise fractional dpr (1.25/1.5/1.75) makes nearest-neighbor + // resampling drop or duplicate rows, producing transparent stripes + // through painted content at high zoom. + const pxD = Math.max(1, Math.round(zoom * dpr)); + + const viewWLog = cssW / zoom; + const viewHLog = cssH / zoom; + const wx0 = camX; + const wy0 = camY; + const wx1 = camX + viewWLog; + const wy1 = camY + viewHLog; + + const cx0 = Math.floor(wx0 / CHUNK); + const cy0 = Math.floor(wy0 / CHUNK); + const cx1 = Math.floor((wx1 - 1e-9) / CHUNK); + const cy1 = Math.floor((wy1 - 1e-9) / CHUNK); + + // round destinations to integer device pixels to avoid seams between + // adjacent chunks. compute right/bottom edges from the neighbor's + // rounded left/top so shared edges line up exactly. + const destX = (wx) => Math.round((wx - camX) * pxD); + const destY = (wy) => Math.round((wy - camY) * pxD); + + for (let cy = cy0; cy <= cy1; cy++) { + for (let cx = cx0; cx <= cx1; cx++) { + const c = chunks.get(chunkKey(cx, cy)); + if (!c) continue; + const x0 = destX(cx * CHUNK); + const y0 = destY(cy * CHUNK); + const x1 = destX((cx + 1) * CHUNK); + const y1 = destY((cy + 1) * CHUNK); + vctx.drawImage(c.canvas, x0, y0, x1 - x0, y1 - y0); + } + } + + // our own strokes still waiting for their echo, composited over the frame + const overlay = net.overlays.get(chunks.id); + if (overlay) { + for (let cy = cy0; cy <= cy1; cy++) { + for (let cx = cx0; cx <= cx1; cx++) { + const c = overlay.get(chunkKey(cx, cy)); + if (!c) continue; + const x0 = destX(cx * CHUNK); + const y0 = destY(cy * CHUNK); + vctx.drawImage(c.canvas, x0, y0, destX((cx + 1) * CHUNK) - x0, destY((cy + 1) * CHUNK) - y0); + } + } + } + + // onionskin: ghost the frame behind us in playback order, screen-blended + // so black contributes nothing over the opaque chunks + if (onionskin && !playing && frames.length > 1) { + const ghost = frames[(frameIdx - dir + frames.length) % frames.length]; + const ghostOverlay = net.overlays.get(ghost.id); + vctx.save(); + vctx.globalAlpha = GHOST_ALPHA; + vctx.globalCompositeOperation = 'screen'; + for (const layer of ghostOverlay ? [ghost, ghostOverlay] : [ghost]) { + for (let cy = cy0; cy <= cy1; cy++) { + for (let cx = cx0; cx <= cx1; cx++) { + const c = layer.get(chunkKey(cx, cy)); + if (!c) continue; + const x0 = destX(cx * CHUNK); + const y0 = destY(cy * CHUNK); + vctx.drawImage(c.canvas, x0, y0, destX((cx + 1) * CHUNK) - x0, destY((cy + 1) * CHUNK) - y0); + } + } + } + vctx.restore(); + } + + if (cursorAlpha !== cursorFadeTo) { + const dur = cursorFadeTo > cursorFadeFrom ? CURSOR_FADE_IN_MS : CURSOR_FADE_OUT_MS; + const t = (now - cursorFadeStart) / dur; + if (t >= 1) { + cursorAlpha = cursorFadeTo; + if (cursorFadeTo === 0) mouseInside = false; + } else { + const e = t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2; + cursorAlpha = cursorFadeFrom + (cursorFadeTo - cursorFadeFrom) * e; + requestDraw(); + } + } + // peers first, so the local brush always sits on top of theirs. their + // fades run here rather than arriving over the wire, so a brush leaving + // a phone still eases out on everyone else's screen + for (const [id, p] of net.peers) { + if (p.alpha !== p.fadeTo) { + const dur = p.fadeTo > p.fadeFrom ? CURSOR_FADE_IN_MS : CURSOR_FADE_OUT_MS; + const t = (now - p.fadeStart) / dur; + if (t >= 1) { + p.alpha = p.fadeTo; + } else { + const e = t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2; + p.alpha = p.fadeFrom + (p.fadeTo - p.fadeFrom) * e; + requestDraw(); + } + } + if (p.alpha <= 0) { + if (p.fadeTo === 0) net.peers.delete(id); + continue; + } + if (!p.b) continue; + const off = Math.floor(p.b / 2); + const sx = Math.round((p.x - off - camX) * pxD); + const sy = Math.round((p.y - off - camY) * pxD); + if (sx > W || sy > H || sx + p.b * pxD < 0 || sy + p.b * pxD < 0) continue; + // someone standing on another frame is drawn the way that frame's + // own marks would be: ghosted, so their brush can't read as + // something on the frame you're actually painting + const ghost = p.f !== chunks.id; + const a = p.alpha * (ghost ? GHOST_ALPHA : 1); + drawCursorShape(sx, sy, p.b, p.s, p.c, p.c[0] + p.c[1] + p.c[2] > 384 ? 1 : 0, a, pxD, ghost); + } + + if (mouseInside) { + const tl = brushTopLeft(curX, curY); + // round to integer device pixels - camX/camY are fractional (smooth + // pan), and fractional fillRect coordinates antialias their edges, + // which would leave transparent lines between adjacent row strips. + // during an active pan, anchor the cursor to the real client + // position (rounded only to device pixels) so it tracks smoothly + // instead of jittering as the logical-pixel floor flips back and + // forth under a fractional camera. when the pan ends the cursor + // snaps back to the logical-pixel grid via the idle timer below. + let sx, sy; + if (panning && curClientX !== null) { + const off = Math.floor(brush / 2); + sx = Math.round((curClientX - off * zoom) * dpr); + sy = Math.round((curClientY - off * zoom) * dpr); + } else { + sx = Math.round((tl.x - camX) * pxD); + sy = Math.round((tl.y - camY) * pxD); + } + drawCursorShape(sx, sy, brush, roundness, [dispR, dispG, dispB], outlineLightness, cursorAlpha, pxD); + } + + net.flush(); + net.sendCursor(); +} + +// a brush preview: solid fill in its own color, wrapped in a +// 1-logical-pixel outline cross-faded between darken and lighten. as a +// ghost it drops the outline and screens like onionskinned content, since +// that is exactly what it is - a brush on some other frame +function drawCursorShape(sx, sy, n, round, col, lightness, alpha, pxD, ghost) { + if (alpha <= 0) return; + const shape = round === 0 ? null : brushShapeFor(n, round, col[0], col[1], col[2]); + vctx.save(); + vctx.globalAlpha = alpha; + if (ghost) vctx.globalCompositeOperation = 'screen'; + vctx.fillStyle = 'rgb(' + col[0] + ',' + col[1] + ',' + col[2] + ')'; + if (!shape) { + vctx.fillRect(sx, sy, n * pxD, n * pxD); + } else { + const runs = shape.fillRuns; + for (let i = 0; i < runs.length; i += 3) { + vctx.fillRect(sx + runs[i] * pxD, sy + runs[i + 1] * pxD, runs[i + 2] * pxD, pxD); + } + } + if (ghost) { + vctx.restore(); + return; + } + + const drawOutline = () => { + if (shape) { + const runs = shape.outlineRuns; + for (let i = 0; i < runs.length; i += 3) { + vctx.fillRect(sx + runs[i] * pxD, sy + runs[i + 1] * pxD, runs[i + 2] * pxD, pxD); + } + } else { + vctx.fillRect(sx - pxD, sy - pxD, (n + 2) * pxD, pxD); // top + vctx.fillRect(sx - pxD, sy + n * pxD, (n + 2) * pxD, pxD); // bottom + vctx.fillRect(sx - pxD, sy, pxD, n * pxD); // left + vctx.fillRect(sx + n * pxD, sy, pxD, n * pxD); // right + } + }; + vctx.save(); + if (lightness > 0) { + vctx.globalCompositeOperation = 'multiply'; + vctx.globalAlpha = lightness * alpha; + vctx.fillStyle = 'rgb(128,128,128)'; + drawOutline(); + } + if (lightness < 1) { + vctx.globalCompositeOperation = 'screen'; + vctx.globalAlpha = (1 - lightness) * alpha; + vctx.fillStyle = 'rgb(128,128,128)'; + drawOutline(); + } + vctx.restore(); + vctx.restore(); +} diff --git a/resources/styles.css b/resources/styles.css @@ -0,0 +1,22 @@ +html, body { + margin: 0; + padding: 0; + width: 100%; + height: 100%; + overflow: hidden; + background: #000; + cursor: none; + overscroll-behavior: none; + touch-action: none; + -webkit-user-select: none; + user-select: none; + -webkit-touch-callout: none; +} +canvas { + display: block; + position: absolute; + top: 0; + left: 0; + image-rendering: pixelated; + image-rendering: crisp-edges; +} diff --git a/resources/view.js b/resources/view.js @@ -0,0 +1,93 @@ +let dpr = window.devicePixelRatio || 1; +let cssW = 0, cssH = 0; + +const ZOOM_STOPS = [2, 3, 4, 5, 6, 8, 11, 15, 21, 30]; +const MIN_ZOOM = ZOOM_STOPS[0]; +const ZOOM_SENSITIVITY = 3.5; // fractional levels per unit log(pinch factor) +let zoomIdx = 0; +let zoom = MIN_ZOOM; + +let camX = 0, camY = 0; + +let curX = 0, curY = 0; +// used to keep the brush pinned under the real cursor while panning, since +// the OS does not emit pointermove events when only the camera moves. +let curClientX = null, curClientY = null; +let mouseInside = false; +let cursorAlpha = 0; +let cursorFadeFrom = 0; +let cursorFadeTo = 0; +let cursorFadeStart = 0; +const CURSOR_FADE_IN_MS = 100; +const CURSOR_FADE_OUT_MS = 400; +function setCursorFadeTarget(target) { + if (target === cursorFadeTo) return; + cursorFadeFrom = cursorAlpha; + cursorFadeTo = target; + cursorFadeStart = performance.now(); + requestDraw(); +} +function showCursor() { + mouseInside = true; + setCursorFadeTarget(1); +} +function hideCursorNow() { + mouseInside = false; + cursorAlpha = 0; + cursorFadeFrom = 0; + cursorFadeTo = 0; +} + +function resize() { + const firstResize = cssW === 0; + dpr = window.devicePixelRatio || 1; + cssW = window.innerWidth; + cssH = window.innerHeight; + view.style.width = cssW + 'px'; + view.style.height = cssH + 'px'; + view.width = Math.floor(cssW * dpr); + view.height = Math.floor(cssH * dpr); + if (firstResize) { + curX = Math.floor(cssW / (2 * zoom)); + curY = Math.floor(cssH / (2 * zoom)); + } + requestDraw(); +} + +function clientToWorld(clientX, clientY) { + const lx = Math.floor(clientX / zoom + camX); + const ly = Math.floor(clientY / zoom + camY); + return { x: lx, y: ly }; +} + +function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); } + +function applyZoom(factor, ax, ay) { + const worldAtAnchorX = camX + ax / zoom; + const worldAtAnchorY = camY + ay / zoom; + zoomIdx = clamp(zoomIdx + Math.log(factor) * ZOOM_SENSITIVITY, 0, ZOOM_STOPS.length - 1); + const newZoom = ZOOM_STOPS[Math.round(zoomIdx)]; + if (newZoom !== zoom) { + zoom = newZoom; + camX = worldAtAnchorX - ax / zoom; + camY = worldAtAnchorY - ay / zoom; + } +} + +function zoomTo(newZoom) { + if (newZoom === zoom) return; + const ax = cssW / 2, ay = cssH / 2; + const worldAtAnchorX = camX + ax / zoom; + const worldAtAnchorY = camY + ay / zoom; + zoom = newZoom; + zoomIdx = ZOOM_STOPS.indexOf(zoom); + camX = worldAtAnchorX - ax / zoom; + camY = worldAtAnchorY - ay / zoom; + requestDraw(); +} + +function stepZoom(dir) { + const i = ZOOM_STOPS.indexOf(zoom); + zoomTo(ZOOM_STOPS[clamp(i + dir, 0, ZOOM_STOPS.length - 1)]); +} +function resetZoom() { zoomTo(MIN_ZOOM); } diff --git a/serve.py b/serve.py @@ -0,0 +1,477 @@ +#!/usr/bin/env python3 +"""serve animus over the local network with a shared canvas. + +usage: ./serve.py [port] +""" + +import base64 +import hashlib +import json +import os +import queue +import socket +import struct +import sys +import threading +import time +from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer + +ROOT = os.path.dirname(os.path.abspath(__file__)) +DEFAULT_PORT = 8000 +# per-painter outbound backlog. a stroke burst is a few hundred small messages, +# so this is generous; past it the connection is too far behind to be correct +SEND_QUEUE_MAX = 4096 +# rfc 6455 fixes this string +WS_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11' +MAX_PAYLOAD = 64 * 1024 * 1024 + +# once the stored stroke log for the room passes this, ask a painter for flat +# images of the frames so joiners don't have to replay an entire session +SNAPSHOT_AFTER_OPS = 4000 +SNAPSHOT_COOLDOWN = 15.0 + +# the ways a connection ends when the other side simply walks off +DEAD_SOCKET = (ConnectionError, BrokenPipeError, TimeoutError, socket.timeout) + + +def read_exact(rfile, n): + buf = b'' + while len(buf) < n: + chunk = rfile.read(n - len(buf)) + if not chunk: + return None + buf += chunk + return buf + + +def unmask(payload, mask): + if not payload: + return payload + n = len(payload) + # xor the whole payload as one big int: the repeated key is trimmed back + # down to n bytes so byte i lines up with mask[i % 4] + rep = mask * (n // 4 + 1) + key = int.from_bytes(rep, 'big') >> (8 * (len(rep) - n)) + return (int.from_bytes(payload, 'big') ^ key).to_bytes(n, 'big') + + +def ws_recv(rfile, on_control=None): + """next complete data message as (opcode, payload), or None when the peer + closes. control frames are answered inline so they can't interrupt a + message that arrives in fragments.""" + data = b'' + msg_op = None + while True: + hdr = read_exact(rfile, 2) + if hdr is None: + return None + fin = hdr[0] & 0x80 + op = hdr[0] & 0x0f + masked = hdr[1] & 0x80 + length = hdr[1] & 0x7f + if length == 126: + ext = read_exact(rfile, 2) + if ext is None: + return None + length = struct.unpack('>H', ext)[0] + elif length == 127: + ext = read_exact(rfile, 8) + if ext is None: + return None + length = struct.unpack('>Q', ext)[0] + if length > MAX_PAYLOAD: + return None + mask = read_exact(rfile, 4) if masked else b'' + if masked and mask is None: + return None + payload = read_exact(rfile, length) if length else b'' + if payload is None: + return None + if masked: + payload = unmask(payload, mask) + if op & 0x8: + if op == 0x8: + return None + if op == 0x9 and on_control: + on_control(0xa, payload) + continue + if op != 0: + msg_op = op + data = payload + else: + data += payload + if fin: + return msg_op or 1, data + + +def ws_frame(payload, opcode=1): + head = bytearray([0x80 | opcode]) + n = len(payload) + if n < 126: + head.append(n) + elif n < 65536: + head.append(126) + head += struct.pack('>H', n) + else: + head.append(127) + head += struct.pack('>Q', n) + return bytes(head) + payload + + +class Conn: + """one painter. writes go through a queue and a dedicated thread so a + painter on bad wifi can never stall the room: their backlog is theirs.""" + + _seq = 0 + _seq_lock = threading.Lock() + + def __init__(self, sock): + self.sock = sock + self.alive = True + self.joined = time.time() + self.q = queue.Queue(maxsize=SEND_QUEUE_MAX) + with Conn._seq_lock: + Conn._seq += 1 + self.id = 'c%d' % Conn._seq + self.writer = threading.Thread(target=self._pump, daemon=True) + self.writer.start() + + def _pump(self): + while True: + item = self.q.get() + if item is None: + return + payload, opcode = item + try: + self.sock.sendall(ws_frame(payload, opcode)) + except OSError: + self.kill() + return + + def kill(self): + """drop the connection; the page reconnects and re-syncs from scratch""" + if not self.alive: + return + self.alive = False + try: + self.sock.shutdown(socket.SHUT_RDWR) + except OSError: + pass + try: + self.q.put_nowait(None) + except queue.Full: + pass + + def raw(self, payload, opcode=1, droppable=False): + if not self.alive: + return + try: + self.q.put_nowait((payload, opcode)) + except queue.Full: + # a stale brush position is worth nothing, so let it go. a lost + # stroke would desync this painter for good, so cut them loose + # instead and let the reconnect hand them the room again + if not droppable: + self.kill() + + def send(self, obj, droppable=False): + self.raw(json.dumps(obj, separators=(',', ':')).encode('utf-8'), droppable=droppable) + + +class Room: + def __init__(self): + self.lock = threading.RLock() + self.clients = [] + self.frame_seq = 0 + self.op_seq = 0 + self.op_count = 0 # stored strokes, tracked rather than recounted + self.frames = [self.blank_frame()] + self.index = {f['id']: f for f in self.frames} + self.interval = 250 + self.snap_pending = False + self.snap_at = 0.0 + + def blank_frame(self): + self.frame_seq += 1 + return {'id': 'f%d' % self.frame_seq, 'img': None, 'ox': 0, 'oy': 0, 'w': 0, 'h': 0, 'ops': []} + + def base_frame(self, src): + f = self.blank_frame() + if isinstance(src, dict) and src.get('img'): + f['img'] = src['img'] + f['ox'] = int(src.get('ox', 0)) + f['oy'] = int(src.get('oy', 0)) + f['w'] = int(src.get('w', 0)) + f['h'] = int(src.get('h', 0)) + return f + + def frame(self, fid): + return self.index.get(fid) + + def wire_frames(self, with_ops=True): + out = [] + for f in self.frames: + e = {'id': f['id'], 'img': f['img'], 'ox': f['ox'], 'oy': f['oy'], 'w': f['w'], 'h': f['h']} + if with_ops: + e['ops'] = f['ops'] + out.append(e) + return out + + def broadcast(self, msg, skip=None, droppable=False): + payload = json.dumps(msg, separators=(',', ':')).encode('utf-8') + with self.lock: + targets = [c for c in self.clients if c is not skip] + for c in targets: + c.raw(payload, droppable=droppable) + + def join(self, conn): + with self.lock: + self.clients.append(conn) + conn.send({ + 't': 'init', + 'id': conn.id, + 'frames': self.wire_frames(), + 'interval': self.interval, + }) + print(' + %s joined (%d painting)' % (conn.id, len(self.clients))) + + def leave(self, conn): + with self.lock: + if conn in self.clients: + self.clients.remove(conn) + # the painter we asked for a flat copy may be the one leaving; let + # the next stroke ask someone else + self.snap_pending = False + print(' - %s left (%d painting)' % (conn.id, len(self.clients))) + self.broadcast({'t': 'bye', 'id': conn.id}) + + def maybe_snapshot(self): + """ask one painter to flatten the room so the stroke log stays bounded""" + now = time.time() + if self.snap_pending or now - self.snap_at < SNAPSHOT_COOLDOWN: + return + if self.op_count < SNAPSHOT_AFTER_OPS or not self.clients: + return + self.snap_pending = True + self.snap_at = now + self.clients[0].send({'t': 'snap', 'upto': self.op_seq}) + + def set_frames(self, frames): + self.frames = frames + self.index = {f['id']: f for f in frames} + self.op_count = sum(len(f['ops']) for f in frames) + + def handle(self, conn, msg): + t = msg.get('t') + if t == 'p': + with self.lock: + f = self.frame(msg.get('f')) + if f is None: + return + self.op_seq += 1 + op = { + 'n': self.op_seq, + 'o': msg.get('o'), + 'c': msg.get('c'), + 'b': msg.get('b'), + 's': msg.get('s'), + } + f['ops'].append(op) + self.op_count += 1 + self.maybe_snapshot() + out = dict(op) + out['t'] = 'p' + out['f'] = f['id'] + out['by'] = conn.id + # the painter's own tag for this batch, so they can recognise it + # coming back; not stored, it means nothing to anyone else + if msg.get('k') is not None: + out['k'] = msg['k'] + # broadcast under the lock so wire order matches sequence order; + # sends only enqueue, so nothing slow happens in here. echoed to + # the painter too: the ack is what commits their stroke + self.broadcast(out) + elif t == 'c': + msg['id'] = conn.id + self.broadcast(msg, skip=conn, droppable=True) + elif t == 'af': + with self.lock: + at = max(0, min(int(msg.get('at', len(self.frames))), len(self.frames))) + f = self.blank_frame() + self.frames.insert(at, f) + self.index[f['id']] = f + self.broadcast({'t': 'af', 'at': at, 'id': f['id'], 'by': conn.id}) + elif t == 'df': + with self.lock: + if len(self.frames) <= 1: + return + f = self.frame(msg.get('id')) + if f is None: + return + self.frames.remove(f) + del self.index[f['id']] + self.op_count -= len(f['ops']) + self.broadcast({'t': 'df', 'id': f['id'], 'by': conn.id}) + elif t == 'reset': + srcs = msg.get('frames') or [] + if not srcs: + return + with self.lock: + self.set_frames([self.base_frame(s) for s in srcs]) + self.interval = int(msg.get('interval') or self.interval) + self.snap_pending = False + self.broadcast({ + 't': 'reset', + 'frames': self.wire_frames(with_ops=False), + 'interval': self.interval, + 'by': conn.id, + }) + elif t == 'snapshot': + upto = int(msg.get('upto') or 0) + with self.lock: + self.snap_pending = False + for src in msg.get('frames') or []: + f = self.frame(src.get('id')) + if f is None: + continue + f['img'] = src.get('img') + f['ox'] = int(src.get('ox', 0)) + f['oy'] = int(src.get('oy', 0)) + f['w'] = int(src.get('w', 0)) + f['h'] = int(src.get('h', 0)) + # strokes newer than the snapshot are kept; a stroke that made it + # into the image and also survives here just paints itself twice + kept = 0 + for f in self.frames: + f['ops'] = [o for o in f['ops'] if o['n'] > upto] + kept += len(f['ops']) + self.op_count = kept + elif t == 'interval': + with self.lock: + self.interval = int(msg.get('v') or self.interval) + + +room = Room() + + +class Server(ThreadingHTTPServer): + daemon_threads = True + # a whole request thread can still unwind on a dropped socket, outside any + # handler we control. same story, same silence + def handle_error(self, request, client_address): + if isinstance(sys.exc_info()[1], DEAD_SOCKET): + return + super().handle_error(request, client_address) + + +class Handler(SimpleHTTPRequestHandler): + protocol_version = 'HTTP/1.1' + server_version = 'animus' + # tiny realtime frames must go out now, not sit in nagle's buffer waiting + # on a watcher's delayed acks + disable_nagle_algorithm = True + + def __init__(self, *a, **kw): + super().__init__(*a, directory=ROOT, **kw) + + def log_message(self, fmt, *args): + pass + + def handle(self): + try: + super().handle() + except DEAD_SOCKET: + self.close_connection = True + + def finish(self): + try: + super().finish() + except DEAD_SOCKET: + pass + + def end_headers(self): + self.send_header('Cache-Control', 'no-store') + super().end_headers() + + def do_GET(self): + if self.path.split('?')[0] == '/ws': + self.do_ws() + return + super().do_GET() + + def do_ws(self): + key = self.headers.get('Sec-WebSocket-Key') + if not key or 'websocket' not in (self.headers.get('Upgrade') or '').lower(): + self.send_error(400, 'expected a websocket upgrade') + return + accept = base64.b64encode(hashlib.sha1((key + WS_GUID).encode()).digest()).decode() + self.close_connection = True + self.send_response(101, 'Switching Protocols') + self.send_header('Upgrade', 'websocket') + self.send_header('Connection', 'Upgrade') + self.send_header('Sec-WebSocket-Accept', accept) + self.end_headers() + self.wfile.flush() + + conn = Conn(self.connection) + room.join(conn) + try: + pong = lambda code, data: conn.raw(data, code) + while conn.alive: + msg = ws_recv(self.rfile, pong) + if msg is None: + break + op, payload = msg + if op != 0x1: + continue + try: + data = json.loads(payload.decode('utf-8')) + except (ValueError, UnicodeDecodeError): + continue + if isinstance(data, dict): + room.handle(conn, data) + except OSError: + pass + finally: + conn.kill() + room.leave(conn) + + +def lan_ip(): + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + try: + s.connect(('10.255.255.255', 1)) + return s.getsockname()[0] + except OSError: + return '127.0.0.1' + finally: + s.close() + + +def main(): + port = int(sys.argv[1]) if len(sys.argv) > 1 else DEFAULT_PORT + for attempt in range(20): + try: + httpd = Server(('0.0.0.0', port + attempt), Handler) + break + except OSError: + continue + else: + print('no free port near %d' % port) + return 1 + port = httpd.server_address[1] + + print('\nPress Ctrl+C to stop the server.\n') + print('http://%s:%d\n' % (lan_ip(), port)) + + + try: + httpd.serve_forever() + except KeyboardInterrupt: + print('\n\nShutting down server...') + return 0 + + +if __name__ == '__main__': + sys.stdout.reconfigure(line_buffering=True) + sys.exit(main())