Moonchess: vanishing tic-tac-toe, online and practice modes
Online play over WebSockets with any-string game codes, seats and spectators, and a 1-second heartbeat. Practice mode against Columbina, backed by a full solver, with an accuracy slider, spoilers, undo and piece freshness. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
commit
c52f30dc59
18 files changed
+1743
No files matched your search
+207
@@ -0,0 +1,207 @@
|
||||
"use strict";
|
||||
|
||||
const HEARTBEAT_MS = 1000;
|
||||
const TIMEOUT_MS = 4000; // no message from the server for this long -> reconnect
|
||||
const RECONNECT_MS = 1000;
|
||||
const CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; // no 0/O, 1/I lookalikes
|
||||
const CODE_LENGTH = 5;
|
||||
|
||||
// Identifies this page load, so a reconnect can reclaim its own seat before the
|
||||
// server notices the old connection died. A refresh gets a new id on purpose.
|
||||
const clientId = Array.from(crypto.getRandomValues(new Uint8Array(8)),
|
||||
b => b.toString(16).padStart(2, "0")).join("");
|
||||
|
||||
const $ = id => document.getElementById(id);
|
||||
const board = createBoard($("board"), cell => send({ type: "move", cell }));
|
||||
|
||||
let token = null;
|
||||
let ws = null;
|
||||
let lastMessage = 0;
|
||||
let reconnectTimer = null;
|
||||
let state = null;
|
||||
let flashTimer = null;
|
||||
|
||||
// ---------- lobby ----------
|
||||
|
||||
// Any string is a valid code, and the server creates the game on first join.
|
||||
// This just picks a random one that's easy to read out loud.
|
||||
$("create").addEventListener("click", () => {
|
||||
const code = Array.from(crypto.getRandomValues(new Uint8Array(CODE_LENGTH)),
|
||||
b => CODE_ALPHABET[b % CODE_ALPHABET.length]).join("");
|
||||
enterGame(code);
|
||||
});
|
||||
|
||||
$("join-form").addEventListener("submit", e => {
|
||||
e.preventDefault();
|
||||
const code = $("join-code").value.trim();
|
||||
if (code) enterGame(code);
|
||||
});
|
||||
|
||||
function showLobby(error = "") {
|
||||
leaveGame();
|
||||
$("game").hidden = true;
|
||||
$("lobby").hidden = false;
|
||||
$("lobby-error").textContent = error;
|
||||
history.replaceState(null, "", "/");
|
||||
}
|
||||
|
||||
// ---------- game ----------
|
||||
|
||||
function enterGame(code) {
|
||||
token = code;
|
||||
state = null;
|
||||
board.reset();
|
||||
$("lobby").hidden = true;
|
||||
$("game").hidden = false;
|
||||
$("code").textContent = code;
|
||||
$("status").textContent = "Connecting…";
|
||||
history.replaceState(null, "", "/?game=" + encodeURIComponent(code));
|
||||
connect();
|
||||
}
|
||||
|
||||
function leaveGame() {
|
||||
token = null;
|
||||
clearTimeout(reconnectTimer);
|
||||
if (ws) {
|
||||
const old = ws;
|
||||
ws = null;
|
||||
old.close();
|
||||
}
|
||||
}
|
||||
|
||||
$("leave").addEventListener("click", () => showLobby());
|
||||
|
||||
$("copy").addEventListener("click", async () => {
|
||||
const link = location.origin + "/?game=" + token;
|
||||
try {
|
||||
await navigator.clipboard.writeText(link);
|
||||
flash("Link copied");
|
||||
} catch {
|
||||
prompt("Copy this link:", link); // clipboard API needs https or localhost
|
||||
}
|
||||
});
|
||||
|
||||
$("rematch").addEventListener("click", () => send({ type: "rematch" }));
|
||||
|
||||
// ---------- connection ----------
|
||||
|
||||
function connect() {
|
||||
const proto = location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const url = `${proto}//${location.host}/ws?game=${encodeURIComponent(token)}&client=${clientId}`;
|
||||
const sock = new WebSocket(url);
|
||||
ws = sock;
|
||||
lastMessage = Date.now();
|
||||
|
||||
sock.onmessage = e => {
|
||||
if (sock !== ws) return;
|
||||
lastMessage = Date.now();
|
||||
const msg = JSON.parse(e.data);
|
||||
if (msg.type === "state") {
|
||||
render(msg);
|
||||
} else if (msg.type === "error") {
|
||||
flash(msg.message);
|
||||
}
|
||||
};
|
||||
|
||||
sock.onclose = e => {
|
||||
if (sock !== ws) return;
|
||||
ws = null;
|
||||
if (e.code >= 4000) { // the server refused this code; retrying won't help
|
||||
showLobby(e.reason || "Could not join that game.");
|
||||
return;
|
||||
}
|
||||
scheduleReconnect();
|
||||
};
|
||||
}
|
||||
|
||||
function scheduleReconnect() {
|
||||
if (!token) return;
|
||||
$("game").classList.add("offline");
|
||||
$("status").textContent = "Disconnected. Reconnecting…";
|
||||
clearTimeout(reconnectTimer);
|
||||
reconnectTimer = setTimeout(connect, RECONNECT_MS);
|
||||
}
|
||||
|
||||
function send(msg) {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(msg));
|
||||
}
|
||||
|
||||
setInterval(() => {
|
||||
if (!ws) return;
|
||||
if (Date.now() - lastMessage > TIMEOUT_MS) {
|
||||
// The socket is probably dead; don't wait for the browser to agree.
|
||||
const old = ws;
|
||||
ws = null;
|
||||
old.close();
|
||||
scheduleReconnect();
|
||||
return;
|
||||
}
|
||||
send({ type: "ping" });
|
||||
}, HEARTBEAT_MS);
|
||||
|
||||
// ---------- rendering ----------
|
||||
|
||||
function render(s) {
|
||||
state = s;
|
||||
const { seat, turn, winner, players } = s;
|
||||
$("code").textContent = s.token;
|
||||
const bothHere = players[0] !== "empty" && players[1] !== "empty";
|
||||
$("game").classList.remove("offline");
|
||||
const myTurn = seat === turn && winner === null && bothHere;
|
||||
|
||||
board.render(s, myTurn);
|
||||
|
||||
for (const i of [0, 1]) {
|
||||
const el = $("player-" + i);
|
||||
el.querySelector(".who").textContent =
|
||||
players[i] === "empty" ? "Empty seat"
|
||||
: players[i] === "disconnected" ? "Disconnected"
|
||||
: seat === i ? "You" : seat === null ? "Player" : "Opponent";
|
||||
el.classList.toggle("empty", players[i] === "empty");
|
||||
el.classList.toggle("gone", players[i] === "disconnected");
|
||||
el.classList.toggle("active", winner === null ? turn === i : winner === i);
|
||||
}
|
||||
|
||||
$("status").textContent = statusText(s, bothHere);
|
||||
|
||||
const rematch = $("rematch");
|
||||
rematch.hidden = winner === null || seat === null;
|
||||
if (!rematch.hidden) {
|
||||
const mine = s.rematch[seat];
|
||||
const theirs = s.rematch[1 - seat];
|
||||
rematch.disabled = mine;
|
||||
rematch.textContent = mine ? "Waiting for opponent…" : theirs ? "Opponent wants a rematch. Play again?" : "Rematch";
|
||||
}
|
||||
|
||||
if (!flashTimer) $("meta").textContent = metaText(s);
|
||||
}
|
||||
|
||||
function statusText({ seat, turn, winner, players }, bothHere) {
|
||||
if (winner !== null) {
|
||||
if (seat === null) return `${MARKS[winner]} wins`;
|
||||
return seat === winner ? "You win!" : "You lose";
|
||||
}
|
||||
if (!bothHere) return seat === null ? "Waiting for players…" : "Waiting for an opponent…";
|
||||
if (seat !== turn && players[turn] === "disconnected") return `${MARKS[turn]} disconnected…`;
|
||||
if (seat === null) return `${MARKS[turn]} to move`;
|
||||
return seat === turn ? "Your turn" : `Waiting for ${MARKS[turn]}…`;
|
||||
}
|
||||
|
||||
function metaText({ seat, spectators }) {
|
||||
const role = seat === null ? "Spectating" : `You are ${MARKS[seat]}`;
|
||||
return spectators ? `${role} · ${spectators} watching` : role;
|
||||
}
|
||||
|
||||
function flash(text) {
|
||||
$("meta").textContent = text;
|
||||
clearTimeout(flashTimer);
|
||||
flashTimer = setTimeout(() => {
|
||||
flashTimer = null;
|
||||
if (state) $("meta").textContent = metaText(state);
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
// ---------- start ----------
|
||||
|
||||
const initial = new URLSearchParams(location.search).get("game");
|
||||
if (initial) enterGame(initial);
|
||||
Reference in new issue
Block a user