canvas.js (7.1 KB)
1 const view = document.getElementById('view'); 2 const vctx = view.getContext('2d', { alpha: false }); 3 4 const CHUNK = 256; 5 let frameSeq = 0; 6 // frames carry a stable id so collaborators can name the same frame even 7 // as the list shifts under inserts and deletes 8 function newFrameMap(id) { 9 const m = new Map(); 10 m.id = id || ('l' + (++frameSeq)); 11 return m; 12 } 13 let frames = [newFrameMap()]; 14 let frameIdx = 0; 15 let chunks = frames[0]; 16 let dir = 1; // playback direction: 1 forward, -1 reverse 17 let onionskin = true; 18 // how anything belonging to another frame reads: onionskinned content and 19 // the brushes of painters standing on it 20 const GHOST_ALPHA = 0.35; 21 let playing = false; 22 let playTimer = null; 23 let frameInterval = 250; 24 let lastTap = 0; 25 26 function chunkKey(cx, cy) { return cx + ',' + cy; } 27 28 // remote strokes redirect every paint write into the frame they belong to 29 let paintMap = null; 30 31 function getOrCreateChunk(cx, cy, map) { 32 map = map || paintMap || chunks; 33 const k = chunkKey(cx, cy); 34 let c = map.get(k); 35 if (c) return c; 36 const cnv = document.createElement('canvas'); 37 cnv.width = CHUNK; 38 cnv.height = CHUNK; 39 // overlay chunks stay transparent so they composite over the frame 40 const cctx = cnv.getContext('2d', { alpha: !!map.overlay }); 41 if (!map.overlay) { 42 cctx.fillStyle = '#000'; 43 cctx.fillRect(0, 0, CHUNK, CHUNK); 44 } 45 c = { canvas: cnv, ctx: cctx }; 46 map.set(k, c); 47 return c; 48 } 49 50 // unacked local strokes paint here, on top of the frame, until their echo 51 // commits them into it in the room's order 52 function overlayFor(fid) { 53 let m = net.overlays.get(fid); 54 if (!m) { 55 m = new Map(); 56 m.id = fid; 57 m.overlay = true; 58 net.overlays.set(fid, m); 59 } 60 return m; 61 } 62 63 function paintRect(wx, wy, w, h, color) { 64 const x0 = wx, y0 = wy, x1 = wx + w, y1 = wy + h; 65 const cx0 = Math.floor(x0 / CHUNK); 66 const cy0 = Math.floor(y0 / CHUNK); 67 const cx1 = Math.floor((x1 - 1) / CHUNK); 68 const cy1 = Math.floor((y1 - 1) / CHUNK); 69 for (let cy = cy0; cy <= cy1; cy++) { 70 for (let cx = cx0; cx <= cx1; cx++) { 71 const c = getOrCreateChunk(cx, cy); 72 const lx = Math.max(x0, cx * CHUNK) - cx * CHUNK; 73 const ly = Math.max(y0, cy * CHUNK) - cy * CHUNK; 74 const rx = Math.min(x1, (cx + 1) * CHUNK) - cx * CHUNK; 75 const ry = Math.min(y1, (cy + 1) * CHUNK) - cy * CHUNK; 76 c.ctx.fillStyle = color; 77 c.ctx.fillRect(lx, ly, rx - lx, ry - ly); 78 } 79 } 80 } 81 82 function readPixel(wx, wy) { 83 const cx = Math.floor(wx / CHUNK); 84 const cy = Math.floor(wy / CHUNK); 85 const k = chunkKey(cx, cy); 86 const lx = wx - cx * CHUNK; 87 const ly = wy - cy * CHUNK; 88 // an unacked stroke sits on the overlay; pick what's actually on screen 89 const ov = net.overlays.get(chunks.id); 90 const oc = ov && ov.get(k); 91 if (oc) { 92 const d = oc.ctx.getImageData(lx, ly, 1, 1).data; 93 if (d[3] === 255) return [d[0], d[1], d[2]]; 94 } 95 const c = chunks.get(k); 96 if (!c) return [0, 0, 0]; 97 const d = c.ctx.getImageData(lx, ly, 1, 1).data; 98 return [d[0], d[1], d[2]]; 99 } 100 101 function setFrame(i) { 102 frameIdx = ((i % frames.length) + frames.length) % frames.length; 103 chunks = frames[frameIdx]; 104 // a held pick tracks the frame under it as frames change 105 if (picking) pickAt(curX, curY); 106 requestDraw(); 107 } 108 109 // frame structure changes round-trip through the server when connected, so 110 // every painter ends up with the same list in the same order 111 function addFrame() { 112 const at = dir === 1 ? frameIdx + 1 : frameIdx; 113 if (net.on) { net.send({ t: 'af', at }); return; } 114 applyAddFrame(at, null, true); 115 } 116 117 function applyAddFrame(at, id, mine) { 118 at = clamp(at, 0, frames.length); 119 const cur = chunks; 120 frames.splice(at, 0, newFrameMap(id)); 121 setFrame(mine ? at : frames.indexOf(cur)); 122 } 123 124 function deleteFrame() { 125 if (frames.length === 1) return; 126 if (net.on) { net.send({ t: 'df', id: chunks.id }); return; } 127 applyDeleteFrame(chunks.id); 128 } 129 130 function applyDeleteFrame(id) { 131 if (frames.length === 1) return; 132 const i = frames.findIndex((f) => f.id === id); 133 if (i < 0) return; 134 const cur = chunks; 135 frames.splice(i, 1); 136 if (playing && frames.length === 1) stopPlayback(); 137 const j = frames.indexOf(cur); 138 setFrame(j >= 0 ? j : Math.min(i, frames.length - 1)); 139 } 140 141 function stopPlayback() { 142 playing = false; 143 clearTimeout(playTimer); 144 requestDraw(); 145 } 146 147 function startPlayback() { 148 if (frames.length === 1) return; 149 playing = true; 150 playTimer = setTimeout(function step() { 151 setFrame(frameIdx + dir); 152 playTimer = setTimeout(step, frameInterval); 153 }, frameInterval); 154 } 155 156 function tapArrow(d) { 157 dir = d; 158 const now = performance.now(); 159 if (lastTap && now - lastTap <= 10000) { 160 frameInterval = now - lastTap; 161 // stored, not applied to anyone live: whoever joins next inherits it 162 net.send({ t: 'interval', v: Math.round(frameInterval) }); 163 } 164 lastTap = now; 165 if (playing) { stopPlayback(); return; } 166 setFrame(frameIdx + d); 167 } 168 169 const keys = {}; 170 let painting = false; 171 let picking = false; 172 let lastPaintX = null, lastPaintY = null; 173 let dirty = false; 174 175 function brushTopLeft(cx, cy) { 176 const off = Math.floor(brush / 2); 177 return { x: cx - off, y: cy - off }; 178 } 179 180 function paintAt(cx, cy) { 181 dirty = true; 182 const tl = brushTopLeft(cx, cy); 183 if (roundness === 0) { 184 paintRect(tl.x, tl.y, brush, brush, 'rgb(' + r + ',' + g + ',' + b + ')'); 185 return; 186 } 187 const sprite = getBrushShape().sprite; 188 const cx0 = Math.floor(tl.x / CHUNK); 189 const cy0 = Math.floor(tl.y / CHUNK); 190 const cx1 = Math.floor((tl.x + brush - 1) / CHUNK); 191 const cy1 = Math.floor((tl.y + brush - 1) / CHUNK); 192 for (let ccy = cy0; ccy <= cy1; ccy++) { 193 for (let ccx = cx0; ccx <= cx1; ccx++) { 194 const c = getOrCreateChunk(ccx, ccy); 195 c.ctx.drawImage(sprite, tl.x - ccx * CHUNK, tl.y - ccy * CHUNK); 196 } 197 } 198 } 199 200 // every mark the local user makes goes out as a segment; a dot 201 // is just a zero-length one. in a room the pixels land on the frame's 202 // overlay - the frame itself only takes strokes in the server's order 203 function localPaintAt(x, y) { 204 if (net.on) paintMap = overlayFor(chunks.id); 205 try { 206 paintAt(x, y); 207 } finally { 208 paintMap = null; 209 } 210 net.paint(x, y, x, y); 211 } 212 213 function localPaintLine(x0, y0, x1, y1) { 214 if (net.on) paintMap = overlayFor(chunks.id); 215 try { 216 paintLine(x0, y0, x1, y1); 217 } finally { 218 paintMap = null; 219 } 220 net.paint(x0, y0, x1, y1); 221 } 222 223 // replay someone else's segment into whichever frame it belongs to, with 224 // their brush, without disturbing the local one 225 function applyPaintOp(op) { 226 const f = frames.find((fr) => fr.id === op.f); 227 if (!f || !op.o || !op.c) return; 228 const pm = paintMap, pr = r, pg = g, pb = b, pbrush = brush, pround = roundness; 229 paintMap = f; 230 r = op.c[0]; g = op.c[1]; b = op.c[2]; 231 brush = op.b; roundness = op.s; 232 try { 233 for (let i = 0; i + 3 < op.o.length; i += 4) { 234 paintLine(op.o[i], op.o[i + 1], op.o[i + 2], op.o[i + 3]); 235 } 236 } finally { 237 paintMap = pm; 238 r = pr; g = pg; b = pb; 239 brush = pbrush; roundness = pround; 240 } 241 if (f === chunks) requestDraw(); 242 } 243 244 function paintLine(x0, y0, x1, y1) { 245 let dx = Math.abs(x1 - x0), sx = x0 < x1 ? 1 : -1; 246 let dy = -Math.abs(y1 - y0), sy = y0 < y1 ? 1 : -1; 247 let err = dx + dy; 248 let x = x0, y = y0; 249 while (true) { 250 paintAt(x, y); 251 if (x === x1 && y === y1) break; 252 const e2 = 2 * err; 253 if (e2 >= dy) { err += dy; x += sx; } 254 if (e2 <= dx) { err += dx; y += sy; } 255 } 256 }