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
@@ -0,0 +1,137 @@
|
||||
"use strict";
|
||||
|
||||
const OPPONENT = "Columbina"; // the computer's display name
|
||||
const THINK_MS = 450;
|
||||
const SQUARE_NAMES = ["top left", "top middle", "top right", "middle left", "center",
|
||||
"middle right", "bottom left", "bottom middle", "bottom right"]; // pause before the computer's move shows, so it's easy to follow
|
||||
|
||||
const $ = id => document.getElementById(id);
|
||||
const board = createBoard($("board"), cell => request({ cell }));
|
||||
const EMPTY = { board: Array(9).fill(null), history: [[], []], turn: 0, winner: null, winLine: null, doomed: null };
|
||||
|
||||
let state = EMPTY;
|
||||
let human = 0; // the computer plays the other side
|
||||
let requestId = 0; // bumped whenever the situation changes, so stale replies are dropped
|
||||
let thinkTimer = null;
|
||||
let lastComputerMove = null; // {cell, how} from the server, shown under "Spoilers"
|
||||
let past = []; // earlier {state, lastComputerMove}, oldest first, for undo
|
||||
|
||||
// Sends the position to the server: with `cell` to play it, without to get a computer move.
|
||||
async function request(extra) {
|
||||
const id = ++requestId;
|
||||
clearTimeout(thinkTimer);
|
||||
board.render(state, false); // no second click while this move is on its way
|
||||
try {
|
||||
const res = await fetch("/api/play", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ history: state.history, turn: state.turn, accuracy: accuracy(), ...extra }),
|
||||
});
|
||||
const body = await res.json();
|
||||
if (id !== requestId) return;
|
||||
if (!res.ok) throw new Error(body.error || "Something went wrong");
|
||||
past.push({ state, lastComputerMove });
|
||||
state = body;
|
||||
if (body.computerMove) lastComputerMove = body.computerMove;
|
||||
$("meta").textContent = "";
|
||||
} catch (e) {
|
||||
if (id === requestId) $("meta").textContent = e.message;
|
||||
}
|
||||
if (id === requestId) update();
|
||||
}
|
||||
|
||||
// Renders, then lets the computer move if it's its turn.
|
||||
function update() {
|
||||
const computerTurn = state.winner === null && state.turn !== human;
|
||||
render(computerTurn);
|
||||
clearTimeout(thinkTimer);
|
||||
if (computerTurn) thinkTimer = setTimeout(() => request({}), THINK_MS);
|
||||
}
|
||||
|
||||
function render(computerTurn) {
|
||||
board.render(state, !computerTurn && state.winner === null);
|
||||
for (const i of [0, 1]) {
|
||||
const el = $("player-" + i);
|
||||
el.querySelector(".who").textContent = i === human ? "You" : OPPONENT;
|
||||
el.classList.toggle("active", state.winner === null ? state.turn === i : state.winner === i);
|
||||
el.setAttribute("aria-pressed", String(i === human));
|
||||
}
|
||||
$("status").textContent =
|
||||
state.winner !== null ? (state.winner === human ? "You win!" : `${OPPONENT} wins`)
|
||||
: computerTurn ? `${OPPONENT} is thinking…` : "Your turn";
|
||||
$("new-game").textContent = state.winner === null ? "New game" : "Play again";
|
||||
$("spoiler-text").innerHTML = spoilerText();
|
||||
$("undo").disabled = undoTarget() < 0;
|
||||
}
|
||||
|
||||
// Index in `past` of the latest earlier position where it was your turn, or -1.
|
||||
function undoTarget() {
|
||||
let i = past.length - 1;
|
||||
while (i >= 0 && past[i].state.turn !== human) i--;
|
||||
return i;
|
||||
}
|
||||
|
||||
// Back to your previous turn, taking back Columbina's reply (or her pending move) too.
|
||||
function undo() {
|
||||
const i = undoTarget();
|
||||
if (i < 0) return;
|
||||
({ state, lastComputerMove } = past[i]);
|
||||
past.length = i;
|
||||
requestId++;
|
||||
$("meta").textContent = "";
|
||||
update();
|
||||
}
|
||||
|
||||
function spoilerText() {
|
||||
if (!lastComputerMove) return `${OPPONENT} hasn't moved yet.`;
|
||||
const where = `${OPPONENT}'s last move (${SQUARE_NAMES[lastComputerMove.cell]})`;
|
||||
return {
|
||||
perfect: `${where} was <strong>perfect</strong>.`,
|
||||
lucky: `${where} was <strong>random</strong>, but happened to be a perfect move anyway.`,
|
||||
random: `${where} was <strong>random</strong>.`,
|
||||
}[lastComputerMove.how];
|
||||
}
|
||||
|
||||
function accuracy() {
|
||||
return Number($("accuracy").value) / 100;
|
||||
}
|
||||
|
||||
for (const i of [0, 1]) {
|
||||
$("player-" + i).addEventListener("click", () => {
|
||||
if (human === i) return;
|
||||
human = i;
|
||||
requestId++; // drop any computer move in flight for the side you just took over
|
||||
update();
|
||||
});
|
||||
}
|
||||
|
||||
$("accuracy").addEventListener("input", () => {
|
||||
$("accuracy-value").textContent = $("accuracy").value + "%";
|
||||
});
|
||||
|
||||
$("new-game").addEventListener("click", () => {
|
||||
requestId++;
|
||||
state = EMPTY;
|
||||
lastComputerMove = null;
|
||||
past = [];
|
||||
board.reset();
|
||||
$("meta").textContent = "";
|
||||
update();
|
||||
});
|
||||
|
||||
$("undo").addEventListener("click", undo);
|
||||
|
||||
$("freshness").addEventListener("change", e => {
|
||||
$("board").classList.toggle("freshness", e.target.checked);
|
||||
});
|
||||
$("board").classList.toggle("freshness", $("freshness").checked);
|
||||
|
||||
document.addEventListener("keydown", e => {
|
||||
if ((e.ctrlKey || e.metaKey) && !e.shiftKey && e.key.toLowerCase() === "z") {
|
||||
e.preventDefault();
|
||||
undo();
|
||||
}
|
||||
});
|
||||
|
||||
$("accuracy-value").textContent = $("accuracy").value + "%";
|
||||
update();
|
||||
Reference in new issue
Block a user