From fb653292612eddca5b088ed88466c546d1597107 Mon Sep 17 00:00:00 2001 From: Elizabeth Alexander Hunt Date: Sat, 20 Jun 2026 09:40:07 -0700 Subject: Initial commit --- static/css/styles.css | 128 +++++++++++++++++++++++++++++++++++++++++++++++++ static/img/favicon.svg | 4 ++ static/img/lab.png | Bin 0 -> 1724730 bytes static/js/canvas.js | 97 +++++++++++++++++++++++++++++++++++++ static/js/chat.js | 101 ++++++++++++++++++++++++++++++++++++++ static/js/minecraft.js | 30 ++++++++++++ static/js/waddlers.js | 30 ++++++++++++ 7 files changed, 390 insertions(+) create mode 100644 static/css/styles.css create mode 100644 static/img/favicon.svg create mode 100644 static/img/lab.png create mode 100644 static/js/canvas.js create mode 100644 static/js/chat.js create mode 100644 static/js/minecraft.js create mode 100644 static/js/waddlers.js (limited to 'static') diff --git a/static/css/styles.css b/static/css/styles.css new file mode 100644 index 0000000..53f7abd --- /dev/null +++ b/static/css/styles.css @@ -0,0 +1,128 @@ +:root { + --bg: #0d1b2a; + --panel: #1b263b; + --ink: #e0e1dd; + --muted: #8d99ae; + --accent: #48cae4; + --accent-2: #ffd166; + --error: #ef476f; + --ok: #06d6a0; +} + +* { box-sizing: border-box; } + +body { + margin: 0; + background: var(--bg); + color: var(--ink); + font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; + line-height: 1.5; +} + +.container { + max-width: 760px; + margin: 0 auto; + padding: 1.5rem 1rem 3rem; +} + +a { color: var(--accent); } +a:hover { color: var(--accent-2); } + +h1 { font-size: 1.4rem; margin: 0 0 .5rem; } +h2 { color: var(--accent); } +hr { border: none; border-top: 1px dashed var(--muted); margin: 1rem 0; } + +header nav { + display: flex; + align-items: center; + gap: .35rem; + flex-wrap: wrap; +} +.spacer { flex: 1 1 auto; } + +.nick-form { + display: flex; + align-items: center; + gap: .35rem; +} +.nick-form label { color: var(--muted); } + +input, textarea, button { + font: inherit; + border: 1px solid var(--muted); + background: var(--panel); + color: var(--ink); + padding: .35rem .5rem; +} +input:focus, textarea:focus { outline: 2px solid var(--accent); } +button { + cursor: pointer; + background: var(--accent); + color: var(--bg); + border-color: var(--accent); + font-weight: bold; +} +button:hover { background: var(--accent-2); border-color: var(--accent-2); } + +.marquee { color: var(--accent-2); background: var(--panel); padding: .25rem; } + +pre { + background: var(--panel); + padding: .75rem; + overflow-x: auto; +} + +.info { padding: .5rem .75rem; margin: .5rem 0; } +.success { background: rgba(6, 214, 160, .15); border: 1px solid var(--ok); } +.error { background: rgba(239, 71, 111, .15); border: 1px solid var(--error); } + +.status { color: var(--muted); } +.status.ok { color: var(--ok); } + +/* chat */ +.chat-log { + height: 20vh; + overflow-y: auto; + background: var(--panel); + padding: .75rem; + margin-bottom: .75rem; +} +.chat-line { margin: .15rem 0; word-wrap: break-word; } +.chat-line.mine .chat-nick { color: var(--accent-2); } +.chat-nick { color: var(--accent); font-weight: bold; } +.chat-time { color: var(--muted); font-size: .75rem; } +.chat-system { color: var(--muted); font-style: italic; margin: .25rem 0; } +.chat-form { display: flex; gap: .5rem; } +.chat-form input { flex: 1 1 auto; } + +/* guestbook */ +.guestbook-form { display: flex; flex-direction: column; gap: .5rem; align-items: flex-start; } +.guestbook-form textarea { width: 100%; min-height: 4rem; } +.guestbook-entries { list-style: none; padding: 0; } +.guestbook-entries li { background: var(--panel); padding: .5rem .75rem; margin: .5rem 0; } +.entry-message { white-space: pre-wrap; word-wrap: break-word; } +.entry-meta { color: var(--muted); font-size: .8rem; margin-top: .25rem; } + +/* portal */ +.portal-form { display: flex; flex-direction: column; gap: .5rem; align-items: flex-start; margin: .75rem 0; } +.rename-form { display: flex; gap: .5rem; align-items: center; flex-wrap: wrap; margin: .5rem 0; } +.roster { list-style: none; padding: 0; } +.roster li { padding: .15rem 0; } +.legend { color: var(--muted); font-size: .85rem; display: flex; gap: .75rem; align-items: center; flex-wrap: wrap; } +.dot { display: inline-block; width: .6rem; height: .6rem; border-radius: 50%; background: var(--muted); vertical-align: middle; } +.dot.chat { background: var(--ok); } +.dot.network { background: var(--accent-2); } +.dot.offline { background: var(--muted); } +.whoami { color: var(--accent-2); font-weight: bold; } +.whoami:hover { color: var(--accent); } +.muted { color: var(--muted); } +code { background: var(--panel); padding: .1rem .3rem; } + +/* canvas / r-place */ +.palette { display: flex; flex-wrap: wrap; gap: 4px; margin: .5rem 0; } +.swatch { width: 26px; height: 26px; border: 2px solid var(--panel); border-radius: 4px; cursor: pointer; } +.swatch.selected { border-color: var(--ink); } +.canvas-wrap { overflow: auto; max-width: 100%; } +#canvas { image-rendering: pixelated; cursor: crosshair; border: 2px solid var(--muted); touch-action: none; } + +footer { color: var(--muted); } diff --git a/static/img/favicon.svg b/static/img/favicon.svg new file mode 100644 index 0000000..9e00805 --- /dev/null +++ b/static/img/favicon.svg @@ -0,0 +1,4 @@ + + + 🐧 + diff --git a/static/img/lab.png b/static/img/lab.png new file mode 100644 index 0000000..dfd0e3d Binary files /dev/null and b/static/img/lab.png differ 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) => + `
  • ${escape(w.username)} — last seen ${escape(w.lastSeen)}
  • ` + ) + .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); +})(); -- cgit v1.3