net.js (7.6 KB)
1 // painting together: mirrors strokes, frames, and brushes between 2 // everyone in the room 3 4 const net = { 5 on: false, 6 id: null, 7 ws: null, 8 peers: new Map(), 9 joined: false, // has this page ever been in a room 10 queue: null, // messages held while a snapshot loads 11 lastCursor: '', 12 batch: null, // segments painted this frame, not yet sent 13 key: 0, 14 pending: [], // own strokes still waiting for their echo 15 overlays: new Map(), // frame id -> chunk map of those unacked strokes 16 }; 17 18 net.send = function (msg) { 19 if (!net.on) return; 20 try { 21 net.ws.send(JSON.stringify(msg)); 22 } catch (err) {} 23 }; 24 25 // a fast pen reports far more moves than anyone can see, so segments pile up 26 // into one message per animation frame. a new frame, color, or brush starts 27 // a new batch, since a batch carries a single style 28 net.paint = function (x0, y0, x1, y1) { 29 if (!net.on) return; 30 const cur = net.batch; 31 if (cur && cur.f === chunks.id && cur.b === brush && cur.s === roundness 32 && cur.c[0] === r && cur.c[1] === g && cur.c[2] === b) { 33 cur.o.push(x0, y0, x1, y1); 34 return; 35 } 36 net.flush(); 37 net.batch = { t: 'p', k: ++net.key, f: chunks.id, o: [x0, y0, x1, y1], c: [r, g, b], b: brush, s: roundness }; 38 // tracked from the first segment, so the overlay it painted onto is held 39 // until the echo commits it to the frame proper 40 net.pending.push({ k: net.batch.k, f: chunks.id }); 41 }; 42 43 net.flush = function () { 44 const out = net.batch; 45 if (!out) return; 46 net.batch = null; 47 net.send(out); 48 }; 49 50 // an overlay lives exactly as long as some stroke on it awaits its echo 51 function clearOverlayIfIdle(fid) { 52 if (net.pending.some((p) => p.f === fid)) return; 53 if (net.overlays.delete(fid)) requestDraw(); 54 } 55 56 // called at the end of every draw, so the brush other people see moves 57 // exactly when ours does, and never more often than a frame 58 net.sendCursor = function () { 59 if (!net.on) return; 60 // the target, not the current alpha: everyone else runs the same fade 61 // from the moment it starts, so a slow fade-out never reads as a blink 62 const v = cursorFadeTo > 0; 63 const key = v ? curX + ',' + curY + ',' + brush + ',' + roundness + ',' + r + ',' + g + ',' + b + ',' + chunks.id : '0'; 64 if (key === net.lastCursor) return; 65 net.lastCursor = key; 66 net.send({ t: 'c', x: curX, y: curY, b: brush, s: roundness, c: [r, g, b], f: chunks.id, v }); 67 }; 68 69 function frameSnapshot(f) { 70 let any = false, minCx = Infinity, minCy = Infinity, maxCx = -Infinity, maxCy = -Infinity; 71 for (const k of f.keys()) { 72 any = true; 73 const [cx, cy] = k.split(',').map(Number); 74 if (cx < minCx) minCx = cx; 75 if (cy < minCy) minCy = cy; 76 if (cx > maxCx) maxCx = cx; 77 if (cy > maxCy) maxCy = cy; 78 } 79 if (!any) return { id: f.id, img: null }; 80 const w = (maxCx - minCx + 1) * CHUNK; 81 const h = (maxCy - minCy + 1) * CHUNK; 82 const cnv = document.createElement('canvas'); 83 cnv.width = w; 84 cnv.height = h; 85 const cctx = cnv.getContext('2d'); 86 cctx.fillStyle = '#000'; 87 cctx.fillRect(0, 0, w, h); 88 for (const [k, c] of f) { 89 const [cx, cy] = k.split(',').map(Number); 90 cctx.drawImage(c.canvas, (cx - minCx) * CHUNK, (cy - minCy) * CHUNK); 91 } 92 return { id: f.id, img: cnv.toDataURL('image/png'), ox: minCx * CHUNK, oy: minCy * CHUNK, w, h }; 93 } 94 95 // rebuild frames from flat images the server is holding 96 async function framesFromBases(list) { 97 const out = []; 98 for (const e of list) { 99 const f = newFrameMap(e.id); 100 if (e.img) { 101 try { 102 const img = await loadImage(e.img); 103 drawIntoFrame(f, img, e.w || img.width, e.h || img.height, e.ox || 0, e.oy || 0); 104 } catch (err) {} 105 } 106 out.push(f); 107 } 108 return out; 109 } 110 111 // hold live messages until the frames they refer to exist 112 function netDefer(msg) { 113 if (!net.queue) return false; 114 net.queue.push(msg); 115 return true; 116 } 117 118 function netFlush() { 119 const q = net.queue; 120 net.queue = null; 121 if (!q) return; 122 for (const msg of q) netHandle(msg); 123 } 124 125 // swap in a server-supplied frame list, replaying anything that arrived 126 // while the images were decoding only after `after` has caught us up 127 async function netAdopt(list, after) { 128 net.queue = []; 129 const built = await framesFromBases(list); 130 frames = built; 131 setFrame(0); 132 if (after) after(); 133 netFlush(); 134 requestDraw(); 135 } 136 137 function netHandle(msg) { 138 if (msg.t === 'init') { 139 net.id = msg.id; 140 net.batch = null; 141 net.pending.length = 0; 142 net.overlays.clear(); 143 if (msg.interval) frameInterval = msg.interval; 144 netAdopt(msg.frames, () => { 145 for (const e of msg.frames) { 146 for (const op of e.ops || []) applyPaintOp({ ...op, f: e.id }); 147 } 148 dirty = false; 149 }); 150 return; 151 } 152 if (netDefer(msg)) return; 153 if (msg.t === 'p') { 154 // strokes land in the frame strictly in the order the server sent 155 // them - ours included. until now our own stroke existed only on the 156 // overlay; this echo is what commits it, so every painter's frame is 157 // the same ops in the same order 158 applyPaintOp(msg); 159 if (msg.by === net.id) { 160 const i = net.pending.findIndex((p) => p.k === msg.k); 161 if (i < 0) return; 162 // earlier entries can only be strokes the server dropped (their 163 // frame was deleted); sweep them along 164 const gone = net.pending.splice(0, i + 1); 165 for (const p of gone) clearOverlayIfIdle(p.f); 166 } 167 } else if (msg.t === 'c') { 168 const p = net.peers.get(msg.id) || { alpha: 0, fadeFrom: 0, fadeTo: 0, fadeStart: 0 }; 169 const target = msg.v ? 1 : 0; 170 if (target !== p.fadeTo) { 171 p.fadeFrom = p.alpha; 172 p.fadeTo = target; 173 p.fadeStart = performance.now(); 174 } 175 p.x = msg.x; p.y = msg.y; p.b = msg.b; p.s = msg.s; p.c = msg.c; p.f = msg.f; 176 net.peers.set(msg.id, p); 177 requestDraw(); 178 } else if (msg.t === 'bye') { 179 const p = net.peers.get(msg.id); 180 if (p && p.fadeTo !== 0) { 181 p.fadeFrom = p.alpha; 182 p.fadeTo = 0; 183 p.fadeStart = performance.now(); 184 } 185 requestDraw(); 186 } else if (msg.t === 'af') { 187 applyAddFrame(msg.at, msg.id, msg.by === net.id); 188 } else if (msg.t === 'df') { 189 // the server drops strokes aimed at a deleted frame, so their echoes 190 // never come; the frame is going away, and its overlay with it 191 net.pending = net.pending.filter((p) => p.f !== msg.id); 192 net.overlays.delete(msg.id); 193 applyDeleteFrame(msg.id); 194 } else if (msg.t === 'reset') { 195 if (playing) stopPlayback(); 196 net.pending.length = 0; 197 net.overlays.clear(); 198 if (msg.interval) frameInterval = msg.interval; 199 netAdopt(msg.frames, () => { 200 const first = msg.frames[0] || {}; 201 dir = 1; 202 camX = (first.ox || 0) - (cssW / zoom - (first.w || 0)) / 2; 203 camY = (first.oy || 0) - (cssH / zoom - (first.h || 0)) / 2; 204 dirty = false; 205 }); 206 } else if (msg.t === 'snap') { 207 // the server's stroke log got long - hand it flat images instead so 208 // the next person to join doesn't have to replay the whole session 209 net.send({ t: 'snapshot', upto: msg.upto, frames: frames.map(frameSnapshot) }); 210 } 211 } 212 213 function netConnect() { 214 if (location.protocol !== 'http:' && location.protocol !== 'https:') return; 215 const ws = new WebSocket((location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/ws'); 216 ws.onopen = () => { 217 net.ws = ws; 218 net.on = true; 219 net.joined = true; 220 net.lastCursor = ''; 221 }; 222 ws.onmessage = (e) => { 223 let msg = null; 224 try { msg = JSON.parse(e.data); } catch (err) { return; } 225 netHandle(msg); 226 }; 227 ws.onclose = () => { 228 const wasOn = net.on; 229 net.on = false; 230 net.ws = null; 231 net.peers.clear(); 232 net.queue = null; 233 // whatever was in flight is moot: the server's state wins on the 234 // way back in 235 net.batch = null; 236 net.pending.length = 0; 237 net.overlays.clear(); 238 if (wasOn) requestDraw(); 239 // a page that was never in a room (opened from a file, or from a 240 // static host) stays solo; one that was keeps reaching for the 241 // server, which may just be restarting 242 if (net.joined) setTimeout(netConnect, 1500); 243 }; 244 ws.onerror = () => ws.close(); 245 }