summaryrefslogtreecommitdiff
path: root/static/js
diff options
context:
space:
mode:
authorElizabeth Alexander Hunt <me@liz.coffee>2026-06-20 09:40:07 -0700
committerElizabeth Alexander Hunt <me@liz.coffee>2026-06-20 09:40:07 -0700
commitfb653292612eddca5b088ed88466c546d1597107 (patch)
tree900ed32e066812688858ed3bb15128c41bb78054 /static/js
downloadpenguins.lan-fb653292612eddca5b088ed88466c546d1597107.tar.gz
penguins.lan-fb653292612eddca5b088ed88466c546d1597107.zip
Initial commit
Diffstat (limited to 'static/js')
-rw-r--r--static/js/canvas.js97
-rw-r--r--static/js/chat.js101
-rw-r--r--static/js/minecraft.js30
-rw-r--r--static/js/waddlers.js30
4 files changed, 258 insertions, 0 deletions
diff --git a/static/js/canvas.js b/static/js/canvas.js
new file mode 100644
index 0000000..f164803
--- /dev/null
+++ b/static/js/canvas.js
@@ -0,0 +1,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);
+ });
+})();
diff --git a/static/js/chat.js b/static/js/chat.js
new file mode 100644
index 0000000..69c169c
--- /dev/null
+++ b/static/js/chat.js
@@ -0,0 +1,101 @@
+(function () {
+ const log = document.getElementById("log");
+ const form = document.getElementById("chat-form");
+ const input = document.getElementById("msg");
+ const status = document.getElementById("status");
+ const me = document.getElementById("me").textContent.trim();
+
+ let socket;
+ let reconnectDelay = 500;
+
+ function el(tag, className, text) {
+ const node = document.createElement(tag);
+ if (className) node.className = className;
+ if (text !== undefined) node.textContent = text;
+ return node;
+ }
+
+ function atBottom() {
+ return log.scrollHeight - log.scrollTop - log.clientHeight < 40;
+ }
+
+ function append(node) {
+ const stick = atBottom();
+ log.appendChild(node);
+ if (stick) log.scrollTop = log.scrollHeight;
+ }
+
+ function fmtTime(ms) {
+ if (!ms) return "";
+ const d = new Date(ms);
+ return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
+ }
+
+ function renderChat(msg) {
+ const line = el("div", "chat-line");
+ if (msg.nick === me) line.classList.add("mine");
+ line.appendChild(el("span", "chat-nick", msg.nick));
+ line.appendChild(el("span", "chat-body", " " + msg.body));
+ line.appendChild(el("span", "chat-time", " " + fmtTime(msg.at)));
+ append(line);
+ }
+
+ function renderSystem(msg) {
+ append(el("div", "chat-system", msg.body));
+ }
+
+ function handle(msg) {
+ switch (msg.type) {
+ case "history":
+ log.innerHTML = "";
+ (msg.history || []).forEach(renderChat);
+ break;
+ case "chat":
+ renderChat(msg);
+ break;
+ case "system":
+ renderSystem(msg);
+ break;
+ }
+ }
+
+ function connect() {
+ const proto = location.protocol === "https:" ? "wss" : "ws";
+ socket = new WebSocket(`${proto}://${location.host}/ws`);
+
+ socket.onopen = function () {
+ status.textContent = "connected ✓";
+ status.className = "status ok";
+ reconnectDelay = 500;
+ };
+
+ socket.onmessage = function (event) {
+ try {
+ handle(JSON.parse(event.data));
+ } catch (e) {
+ console.error("bad message", e);
+ }
+ };
+
+ socket.onclose = function () {
+ status.textContent = "reconnecting…";
+ status.className = "status";
+ setTimeout(connect, reconnectDelay);
+ reconnectDelay = Math.min(reconnectDelay * 2, 8000);
+ };
+
+ socket.onerror = function () {
+ socket.close();
+ };
+ }
+
+ form.addEventListener("submit", function (e) {
+ e.preventDefault();
+ const body = input.value.trim();
+ if (!body || !socket || socket.readyState !== WebSocket.OPEN) return;
+ socket.send(JSON.stringify({ type: "chat", body: body }));
+ input.value = "";
+ });
+
+ connect();
+})();
diff --git a/static/js/minecraft.js b/static/js/minecraft.js
new file mode 100644
index 0000000..757053c
--- /dev/null
+++ b/static/js/minecraft.js
@@ -0,0 +1,30 @@
+(function () {
+ const el = document.getElementById("mc-status");
+ if (!el) return;
+
+ function escape(s) {
+ const d = document.createElement("div");
+ d.textContent = s;
+ return d.innerHTML;
+ }
+
+ async function poll() {
+ try {
+ const r = await fetch("/minecraft", { headers: { Accept: "application/json" } });
+ if (!r.ok) return;
+ const s = await r.json();
+ if (!s.online) {
+ el.textContent = "server offline";
+ } else if (s.count === 0) {
+ el.textContent = `online — 0/${s.max} players`;
+ } else {
+ el.innerHTML = `online — ${s.count}/${s.max}: ` + s.players.map(escape).join(", ");
+ }
+ } catch (e) {
+ /* keep the last render */
+ }
+ }
+
+ poll();
+ setInterval(poll, 10000);
+})();
diff --git a/static/js/waddlers.js b/static/js/waddlers.js
new file mode 100644
index 0000000..d1dbd63
--- /dev/null
+++ b/static/js/waddlers.js
@@ -0,0 +1,30 @@
+(function () {
+ const roster = document.getElementById("roster");
+ if (!roster) return;
+
+ function escape(s) {
+ const d = document.createElement("div");
+ d.textContent = s;
+ return d.innerHTML;
+ }
+
+ function render(list) {
+ roster.innerHTML = list
+ .map(
+ (w) =>
+ `<li><span class="dot ${w.status}"></span> <strong>${escape(w.username)}</strong> <span class="muted">— last seen ${escape(w.lastSeen)}</span></li>`
+ )
+ .join("");
+ }
+
+ async function poll() {
+ try {
+ const r = await fetch("/waddlers", { headers: { Accept: "application/json" } });
+ if (r.ok) render(await r.json());
+ } catch (e) {
+ console.error(e);
+ }
+ }
+
+ setInterval(poll, 2000);
+})();