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]>
59 lines
2.1 KiB
JavaScript
59 lines
2.1 KiB
JavaScript
"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;
|
|
},
|
|
};
|
|
}
|