(function () { const cfg = window.CANVAS; const canvas = document.getElementById("canvas"); const ctx = canvas.getContext("2d"); canvas.style.width = cfg.cols * cfg.scale + "px"; canvas.style.height = cfg.rows * cfg.scale + "px"; // palette picker let selected = 0; const paletteEl = document.getElementById("palette"); cfg.palette.forEach((hex, i) => { const sw = document.createElement("div"); sw.className = "swatch" + (i === 0 ? " selected" : ""); sw.style.background = hex; sw.title = hex; sw.addEventListener("click", () => { selected = i; [...paletteEl.children].forEach((c) => c.classList.remove("selected")); sw.classList.add("selected"); }); paletteEl.appendChild(sw); }); const hexFromInt = (c) => "#" + c.toString(16).padStart(6, "0"); const drawPixel = (x, y, hex) => { ctx.fillStyle = hex; ctx.fillRect(x, y, 1, 1); }; // The server is authoritative: we only paint pixels it broadcasts back. Open // the socket first and queue messages until the initial state is painted, so // nothing placed during the load is lost. let loaded = false; const queue = []; function apply(msg) { if (msg.type === "place") drawPixel(msg.x, msg.y, hexFromInt(msg.c)); else if (msg.type === "cooldown") startCooldown(msg.ms); } let ws; function connect() { const proto = location.protocol === "https:" ? "wss" : "ws"; ws = new WebSocket(`${proto}://${location.host}/canvas/ws`); ws.onmessage = (e) => { const msg = JSON.parse(e.data); loaded ? apply(msg) : queue.push(msg); }; ws.onclose = () => setTimeout(connect, 1000); ws.onerror = () => ws.close(); } connect(); fetch("/canvas/state") .then((r) => r.arrayBuffer()) .then((buf) => { const bytes = new Uint8Array(buf); const img = ctx.createImageData(cfg.cols, cfg.rows); for (let p = 0; p < cfg.cols * cfg.rows; p++) { img.data[p * 4] = bytes[p * 3]; img.data[p * 4 + 1] = bytes[p * 3 + 1]; img.data[p * 4 + 2] = bytes[p * 3 + 2]; img.data[p * 4 + 3] = 255; } ctx.putImageData(img, 0, 0); loaded = true; queue.forEach(apply); queue.length = 0; }); // cooldown indicator (server enforces; this is UI feedback) let cooldownUntil = 0; const cdEl = document.getElementById("cooldown"); function startCooldown(ms) { cooldownUntil = Math.max(cooldownUntil, Date.now() + ms); tick(); } function tick() { const rem = cooldownUntil - Date.now(); if (rem > 0) { cdEl.textContent = `cooldown: ${(rem / 1000).toFixed(1)}s`; requestAnimationFrame(tick); } else { cdEl.textContent = ""; } } canvas.addEventListener("click", (e) => { if (Date.now() < cooldownUntil) return; if (!ws || ws.readyState !== WebSocket.OPEN) return; const rect = canvas.getBoundingClientRect(); const x = Math.floor(((e.clientX - rect.left) / rect.width) * cfg.cols); const y = Math.floor(((e.clientY - rect.top) / rect.height) * cfg.rows); if (x < 0 || x >= cfg.cols || y < 0 || y >= cfg.rows) return; ws.send(JSON.stringify({ type: "place", x, y, i: selected })); startCooldown(cfg.cooldownMs); }); })();