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 }