1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
|
(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);
});
})();
|