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);
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Moonchess</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<section id="lobby">
|
||||
<h1>Moonchess</h1>
|
||||
<p class="lede">Tic-tac-toe, but you only get three pieces. Place a fourth and your oldest one vanishes first.</p>
|
||||
<button id="create" class="primary">Generate code</button>
|
||||
<div class="divider"><span>or enter any code</span></div>
|
||||
<form id="join-form">
|
||||
<input id="join-code" placeholder="Any code or word" maxlength="32" autocomplete="off"
|
||||
autocapitalize="characters" spellcheck="false" aria-label="Game code">
|
||||
<button type="submit">Join</button>
|
||||
</form>
|
||||
<p id="lobby-error" class="error" role="alert"></p>
|
||||
</section>
|
||||
|
||||
<section id="game" hidden>
|
||||
<header class="bar">
|
||||
<div class="code-wrap">
|
||||
<span class="label">Code</span>
|
||||
<span id="code" class="code"></span>
|
||||
<button id="copy" class="small">Copy link</button>
|
||||
</div>
|
||||
<button id="leave" class="small ghost">Leave</button>
|
||||
</header>
|
||||
|
||||
<div class="players">
|
||||
<div class="player" id="player-0"><span class="mark x">X</span><span class="who"></span></div>
|
||||
<div class="player" id="player-1"><span class="mark o">O</span><span class="who"></span></div>
|
||||
</div>
|
||||
|
||||
<p id="status" class="status" aria-live="polite"></p>
|
||||
|
||||
<div id="board" class="board" role="grid" aria-label="Board"></div>
|
||||
|
||||
<div class="footer">
|
||||
<button id="rematch" class="primary" hidden>Rematch</button>
|
||||
<p id="meta" class="meta"></p>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<script src="/static/board.js"></script>
|
||||
<script src="/static/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,48 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Moonchess Practice</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<header class="bar">
|
||||
<h1 class="page-title">Moonchess practice</h1>
|
||||
</header>
|
||||
|
||||
<div class="players" role="group" aria-label="Choose your side">
|
||||
<button class="player pickable" id="player-0"><span class="mark x">X</span><span class="who"></span></button>
|
||||
<button class="player pickable" id="player-1"><span class="mark o">O</span><span class="who"></span></button>
|
||||
</div>
|
||||
<p class="hint">Click a side to play as it, any time. X moves first.</p>
|
||||
|
||||
<p id="status" class="status" aria-live="polite"></p>
|
||||
|
||||
<div id="board" class="board" role="grid" aria-label="Board"></div>
|
||||
|
||||
<div class="difficulty">
|
||||
<label for="accuracy">Columbina's accuracy <output id="accuracy-value" for="accuracy"></output></label>
|
||||
<input id="accuracy" type="range" min="0" max="100" step="5" value="50">
|
||||
<p class="hint">Chance that each of Columbina's moves is perfect. Otherwise she plays a random empty square.</p>
|
||||
</div>
|
||||
|
||||
<details class="spoiler">
|
||||
<summary>Spoilers</summary>
|
||||
<p id="spoiler-text"></p>
|
||||
<label class="toggle"><input type="checkbox" id="freshness"> Show piece freshness</label>
|
||||
</details>
|
||||
|
||||
<div class="footer">
|
||||
<div class="actions">
|
||||
<button id="undo" title="Undo (Ctrl+Z / Cmd+Z)">Undo</button>
|
||||
<button id="new-game" class="primary">New game</button>
|
||||
</div>
|
||||
<p id="meta" class="meta"></p>
|
||||
</div>
|
||||
</main>
|
||||
<script src="/static/board.js"></script>
|
||||
<script src="/static/practice.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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();
|
||||
@@ -0,0 +1,181 @@
|
||||
:root {
|
||||
--bg: #f6f5f1;
|
||||
--surface: #ffffff;
|
||||
--border: #dedbd2;
|
||||
--text: #1d1c1a;
|
||||
--muted: #6f6c64;
|
||||
--x: #d0543a;
|
||||
--o: #2f6fb5;
|
||||
--win: #e9c46a;
|
||||
--error: #b3261e;
|
||||
--radius: 14px;
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: #15161a;
|
||||
--surface: #1f2026;
|
||||
--border: #33353d;
|
||||
--text: #ecebe6;
|
||||
--muted: #9a988f;
|
||||
--x: #f07a5f;
|
||||
--o: #6aa6ea;
|
||||
--win: #c9a64a;
|
||||
--error: #ff8a80;
|
||||
color-scheme: dark;
|
||||
}
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font: 16px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
main {
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
padding: 48px 16px;
|
||||
}
|
||||
|
||||
h1 { font-size: 2.2rem; margin: 0 0 8px; letter-spacing: -0.02em; }
|
||||
.lede { color: var(--muted); margin: 0 0 32px; }
|
||||
|
||||
button {
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
padding: 12px 18px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
}
|
||||
button:hover { border-color: var(--muted); }
|
||||
button.primary { background: var(--text); color: var(--bg); border-color: var(--text); width: 100%; }
|
||||
button.primary:hover { opacity: 0.9; }
|
||||
button.small { padding: 6px 12px; font-size: 0.875rem; }
|
||||
button.ghost { background: transparent; }
|
||||
|
||||
.divider {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
color: var(--muted); font-size: 0.875rem; margin: 28px 0 16px;
|
||||
}
|
||||
.divider::before, .divider::after { content: ""; flex: 1; height: 1px; background: var(--border); }
|
||||
|
||||
#join-form { display: flex; gap: 8px; }
|
||||
#join-code {
|
||||
flex: 1; min-width: 0;
|
||||
font: 600 1.25rem/1 ui-monospace, "SF Mono", Menlo, monospace;
|
||||
letter-spacing: 0.05em; text-transform: uppercase;
|
||||
padding: 12px 14px;
|
||||
border-radius: 10px; border: 1px solid var(--border);
|
||||
background: var(--surface); color: var(--text);
|
||||
}
|
||||
#join-code::placeholder { text-transform: none; letter-spacing: 0; font-family: system-ui, sans-serif; font-weight: 400; font-size: 1rem; }
|
||||
#join-code:focus { outline: 2px solid var(--o); outline-offset: -1px; }
|
||||
|
||||
.error { color: var(--error); min-height: 1.5em; margin: 12px 0 0; }
|
||||
|
||||
.bar { display: flex; justify-content: space-between; align-items: center; gap: 8px; }
|
||||
.code-wrap { display: flex; align-items: center; gap: 10px; min-width: 0; }
|
||||
.label { color: var(--muted); font-size: 0.875rem; }
|
||||
.code {
|
||||
font: 700 1.3rem ui-monospace, "SF Mono", Menlo, monospace; letter-spacing: 0.1em;
|
||||
min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.code-wrap button, .bar > button { flex-shrink: 0; }
|
||||
|
||||
.players { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin: 24px 0 8px; }
|
||||
.player {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 10px 12px; border-radius: 10px;
|
||||
border: 1px solid var(--border); background: var(--surface);
|
||||
font-size: 0.9rem; color: var(--muted);
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.player.active { border-color: var(--text); color: var(--text); }
|
||||
.player.empty { border-style: dashed; }
|
||||
.player.gone { border-style: dashed; }
|
||||
.player.gone .who { color: var(--error); }
|
||||
.player .mark { font-weight: 800; font-size: 1.1rem; }
|
||||
.mark.x { color: var(--x); }
|
||||
.mark.o { color: var(--o); }
|
||||
|
||||
button.player {
|
||||
font: inherit; font-size: 0.9rem; font-weight: 400;
|
||||
text-align: left; width: 100%; cursor: pointer;
|
||||
}
|
||||
button.player[aria-pressed="true"] .who { font-weight: 600; }
|
||||
.page-title { font-size: 1.4rem; margin: 0; }
|
||||
.hint { color: var(--muted); font-size: 0.85rem; margin: 6px 0 0; }
|
||||
|
||||
.difficulty { margin-top: 24px; }
|
||||
.difficulty label { display: flex; justify-content: space-between; font-weight: 600; }
|
||||
.difficulty output { font-variant-numeric: tabular-nums; }
|
||||
.difficulty input { width: 100%; margin: 10px 0 0; accent-color: var(--text); }
|
||||
|
||||
.spoiler {
|
||||
margin-top: 20px; padding: 10px 12px;
|
||||
border: 1px solid var(--border); border-radius: 10px; background: var(--surface);
|
||||
}
|
||||
.spoiler summary { cursor: pointer; font-weight: 600; }
|
||||
.spoiler p { margin: 8px 0 0; color: var(--muted); }
|
||||
.spoiler strong { color: var(--text); }
|
||||
|
||||
.toggle { display: flex; align-items: center; gap: 8px; margin-top: 12px; cursor: pointer; user-select: none; }
|
||||
.toggle input { width: 18px; height: 18px; margin: 0; accent-color: var(--text); cursor: pointer; }
|
||||
|
||||
.status { font-size: 1.15rem; font-weight: 600; text-align: center; margin: 20px 0 16px; min-height: 1.5em; }
|
||||
|
||||
.board {
|
||||
/* minmax(0, 1fr) and absolutely positioned marks keep cell sizes independent of content */
|
||||
display: grid; gap: 8px;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
grid-template-rows: repeat(3, minmax(0, 1fr));
|
||||
aspect-ratio: 1; width: min(100%, 60vh); margin: 0 auto;
|
||||
}
|
||||
.cell {
|
||||
position: relative;
|
||||
padding: 0; border-radius: var(--radius);
|
||||
border: 1px solid var(--border); background: var(--surface);
|
||||
display: grid; place-items: center;
|
||||
cursor: default;
|
||||
}
|
||||
.cell.playable { cursor: pointer; }
|
||||
.cell.playable:hover { border-color: var(--muted); }
|
||||
.cell.win { background: color-mix(in srgb, var(--win) 30%, var(--surface)); border-color: var(--win); }
|
||||
.cell svg { position: absolute; inset: 21%; width: 58%; height: 58%; overflow: visible; }
|
||||
.cell svg * { fill: none; stroke-width: 12; stroke-linecap: round; }
|
||||
.cell .x-mark * { stroke: var(--x); }
|
||||
.cell .o-mark * { stroke: var(--o); }
|
||||
/* Freshness view: newest piece solid, older ones fading toward the doomed look */
|
||||
.board.freshness .cell[data-age="1"] svg { opacity: 0.65; }
|
||||
.board.freshness .cell[data-age="2"] svg { opacity: 0.3; }
|
||||
.cell.doomed svg { opacity: 0.3; animation: fade 1.6s ease-in-out infinite; }
|
||||
.cell.placed svg { animation: pop 0.18s ease-out; }
|
||||
|
||||
@keyframes fade { 50% { opacity: 0.12; } }
|
||||
@keyframes pop { from { transform: scale(0.6); opacity: 0; } }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.cell.doomed svg, .cell.placed svg { animation: none; }
|
||||
}
|
||||
|
||||
#game.offline .board,
|
||||
#game.offline .players { opacity: 0.4; pointer-events: none; }
|
||||
#game.offline .status { color: var(--error); }
|
||||
|
||||
.footer { margin-top: 20px; }
|
||||
.actions { display: flex; gap: 8px; }
|
||||
.actions > button { flex: 1; }
|
||||
.actions > button.primary { flex: 2; }
|
||||
.actions > button:disabled { opacity: 0.45; cursor: default; border-color: var(--border); }
|
||||
.meta { color: var(--muted); font-size: 0.875rem; text-align: center; margin: 12px 0 0; min-height: 1.5em; }
|
||||
Reference in new issue
Block a user