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:
tukaunuandClaude Opus 5.5 committed 2026-09-28 03:36:46 +02:00
commit c52f30dc59
18 files changed
+1743

No files matched your search

+58
View File
@@ -0,0 +1,58 @@
"use strict";
// Board rendering shared by the online game (app.js) and practice mode (practice.js).
const MARKS = ["X", "O"];
const PIECE_SVG = [
'<svg viewBox="0 0 100 100" class="x-mark"><path d="M15 15 L85 85 M85 15 L15 85"/></svg>',
'<svg viewBox="0 0 100 100" class="o-mark"><circle cx="50" cy="50" r="36"/></svg>',
];
// Fills `container` with nine cells. `onPlay(cell)` fires when a playable cell is clicked.
function createBoard(container, onPlay) {
const cells = [];
let shown = null; // board as currently drawn; null means draw the next state without animation
for (let i = 0; i < 9; i++) {
const cell = document.createElement("button");
cell.className = "cell";
cell.addEventListener("click", () => {
if (cell.classList.contains("playable")) onPlay(i);
});
container.appendChild(cell);
cells.push(cell);
}
return {
// `s` is a game state from the server; `canPlay` makes empty cells clickable.
render(s, canPlay) {
// Age of each piece within its owner's pieces: 0 = newest. Styled only when the
// board has the "freshness" class.
const ages = Array(9).fill(null);
for (const hist of s.history || []) {
hist.forEach((cell, i) => { ages[cell] = hist.length - 1 - i; });
}
s.board.forEach((v, i) => {
const cell = cells[i];
if (!shown || v !== shown[i]) {
cell.innerHTML = v === null ? "" : PIECE_SVG[v];
cell.classList.toggle("placed", v !== null && shown !== null);
}
cell.classList.toggle("playable", canPlay && v === null);
cell.classList.toggle("doomed", s.doomed === i);
if (ages[i] === null) delete cell.dataset.age;
else cell.dataset.age = ages[i];
cell.classList.toggle("win", !!(s.winLine && s.winLine.includes(i)));
cell.setAttribute("aria-label",
`Square ${i + 1}: ${v === null ? "empty" : MARKS[v]}${s.doomed === i ? " (vanishes next)" : ""}`);
});
shown = s.board.slice();
},
// Next render draws without the placement animation (e.g. after joining a game).
reset() {
shown = null;
},
};
}