355 lines
13 KiB
HTML
355 lines
13 KiB
HTML
<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<title>Play - ChessStars</title>
|
|
{{template "head" .}}
|
|
<script src="/static/board.js"></script>
|
|
</head>
|
|
<body>
|
|
{{template "nav" .}}
|
|
<main>
|
|
<div id="lobby-view">
|
|
<div class="panel">
|
|
<h2>New game</h2>
|
|
<div class="tabs-row">
|
|
<button class="opp-tab active" data-mode="bot">vs Bot</button>
|
|
<button class="opp-tab" data-mode="challenge">Challenge a player</button>
|
|
<button class="opp-tab" data-mode="open">Open seat</button>
|
|
</div>
|
|
|
|
<div class="field" id="challenge-field" style="display:none">
|
|
<label>Opponent's username</label>
|
|
<input type="text" id="opponent-username" placeholder="username">
|
|
</div>
|
|
<div class="field" id="bot-depth-field">
|
|
<label>Bot strength (search depth 1-4)</label>
|
|
<input type="range" id="bot-depth" min="1" max="4" value="3">
|
|
</div>
|
|
|
|
<div class="field">
|
|
<label>Variant</label>
|
|
<select id="variant">
|
|
<option value="0">Standard</option>
|
|
<option value="1">Relaxed (no check restriction)</option>
|
|
</select>
|
|
</div>
|
|
<div class="field">
|
|
<label><input type="checkbox" id="rule-no-castling" style="width:auto"> disable castling</label>
|
|
<label><input type="checkbox" id="rule-no-ep" style="width:auto"> disable en passant</label>
|
|
</div>
|
|
<div class="field">
|
|
<label>Custom start FEN (optional)</label>
|
|
<input type="text" id="start-fen" placeholder="leave blank for standard start">
|
|
</div>
|
|
<button id="new-game-btn">Create game</button>
|
|
<p class="auth-error" id="new-game-msg"></p>
|
|
</div>
|
|
|
|
<div class="panel">
|
|
<h2>Open games waiting for an opponent</h2>
|
|
<div id="open-list" class="muted">none right now</div>
|
|
</div>
|
|
|
|
<div class="panel">
|
|
<h2>My games</h2>
|
|
<div id="my-list" class="muted">log in to see your games</div>
|
|
</div>
|
|
|
|
<div class="panel">
|
|
<h2>Browse games</h2>
|
|
<p class="muted">Every game on the server, most recent first -- click to spectate.</p>
|
|
<div id="all-list" class="muted">loading...</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="game-view" style="display:none">
|
|
<p><a href="/play">← back to lobby</a></p>
|
|
<div id="status-banner" class="status-banner ongoing"></div>
|
|
<div class="board-wrap">
|
|
<div id="board"></div>
|
|
<div class="side-panel panel">
|
|
<p><b id="white-name"></b> (white) vs <b id="black-name"></b> (black)</p>
|
|
<h3>Moves</h3>
|
|
<div id="move-list" class="move-list"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</main>
|
|
|
|
<script>
|
|
const params = new URLSearchParams(location.search);
|
|
let gameID = params.get("id");
|
|
let selected = null;
|
|
let legalTargets = [];
|
|
let lastBoard = new Array(64).fill(0);
|
|
let lastMove = null;
|
|
let pollTimer = null;
|
|
let myUserID = null;
|
|
let currentState = null;
|
|
|
|
// --- lobby view ---
|
|
document.querySelectorAll(".opp-tab").forEach((btn) => {
|
|
btn.addEventListener("click", () => {
|
|
document.querySelectorAll(".opp-tab").forEach((b) => b.classList.remove("active"));
|
|
btn.classList.add("active");
|
|
const mode = btn.dataset.mode;
|
|
document.getElementById("challenge-field").style.display = mode === "challenge" ? "block" : "none";
|
|
document.getElementById("bot-depth-field").style.display = mode === "bot" ? "block" : "none";
|
|
});
|
|
});
|
|
|
|
function currentOpponentMode() {
|
|
const active = document.querySelector(".opp-tab.active");
|
|
return active ? active.dataset.mode : "bot";
|
|
}
|
|
|
|
document.getElementById("new-game-btn").addEventListener("click", async () => {
|
|
let ruleFlags = 0;
|
|
if (document.getElementById("rule-no-castling").checked) ruleFlags |= 0x01;
|
|
if (document.getElementById("rule-no-ep").checked) ruleFlags |= 0x02;
|
|
|
|
const mode = currentOpponentMode();
|
|
let opponent = "";
|
|
if (mode === "bot") opponent = "bot";
|
|
else if (mode === "challenge") opponent = document.getElementById("opponent-username").value.trim();
|
|
|
|
const res = await api.post("/api/games", {
|
|
opponent,
|
|
variant: parseInt(document.getElementById("variant").value, 10),
|
|
start_fen: document.getElementById("start-fen").value,
|
|
rule_flags: ruleFlags,
|
|
bot_depth: parseInt(document.getElementById("bot-depth").value, 10),
|
|
});
|
|
if (res.ok) {
|
|
location.search = "?id=" + res.id;
|
|
} else {
|
|
document.getElementById("new-game-msg").textContent = res.error || "could not create game";
|
|
}
|
|
});
|
|
|
|
async function loadOpen() {
|
|
const res = await api.get("/api/games/open");
|
|
const el = document.getElementById("open-list");
|
|
const games = (res.games || []);
|
|
if (games.length === 0) {
|
|
el.className = "muted";
|
|
el.textContent = "none right now";
|
|
return;
|
|
}
|
|
el.className = "";
|
|
el.innerHTML = "";
|
|
games.forEach((g) => {
|
|
const div = document.createElement("div");
|
|
div.className = "game-card";
|
|
div.innerHTML = "<div><b>" + g.white + "</b><div class='meta'>waiting for an opponent</div></div>";
|
|
const btn = document.createElement("button");
|
|
btn.textContent = "Join";
|
|
btn.addEventListener("click", async () => {
|
|
const r = await api.post("/api/games/" + g.id + "/join");
|
|
if (r.id) location.href = "/play?id=" + r.id;
|
|
else alert(r.error || "could not join");
|
|
});
|
|
div.appendChild(btn);
|
|
el.appendChild(div);
|
|
});
|
|
}
|
|
|
|
async function loadMine() {
|
|
const el = document.getElementById("my-list");
|
|
const res = await api.get("/api/me/games");
|
|
if (!res.ok) {
|
|
el.className = "muted";
|
|
el.textContent = "log in to see your games";
|
|
return;
|
|
}
|
|
const games = res.games || [];
|
|
if (games.length === 0) {
|
|
el.className = "muted";
|
|
el.textContent = "no games yet -- create one above";
|
|
return;
|
|
}
|
|
el.className = "";
|
|
el.innerHTML = "";
|
|
games.forEach((g) => {
|
|
const div = document.createElement("div");
|
|
div.className = "game-card";
|
|
const opponent = g.vs_bot ? "bot" : (g.black || "(open seat)");
|
|
const statusText = g.status === 0 ? (g.black ? "in progress" : "waiting for opponent") : STATUS_LABELS[g.status];
|
|
div.innerHTML = "<div><b>" + g.white + "</b> vs <b>" + opponent + "</b>" +
|
|
"<div class='meta'>" + statusText + "</div></div>";
|
|
const a = document.createElement("a");
|
|
a.className = "btn secondary";
|
|
a.href = "/play?id=" + g.id;
|
|
a.textContent = "Open";
|
|
div.appendChild(a);
|
|
el.appendChild(div);
|
|
});
|
|
}
|
|
|
|
async function loadAll() {
|
|
const el = document.getElementById("all-list");
|
|
const res = await api.get("/api/games/all");
|
|
const games = res.games || [];
|
|
if (games.length === 0) {
|
|
el.className = "muted";
|
|
el.textContent = "no games yet";
|
|
return;
|
|
}
|
|
el.className = "";
|
|
el.innerHTML = "";
|
|
games.forEach((g) => {
|
|
const div = document.createElement("div");
|
|
div.className = "game-card";
|
|
const opponent = g.vs_bot ? "bot" : (g.black || "(open seat)");
|
|
const statusText = g.status === 0 ? (g.black || g.vs_bot ? "in progress" : "waiting for opponent") : STATUS_LABELS[g.status];
|
|
div.innerHTML = "<div><b>" + g.white + "</b> vs <b>" + opponent + "</b>" +
|
|
"<div class='meta'>" + statusText + "</div></div>";
|
|
const a = document.createElement("a");
|
|
a.className = "btn secondary";
|
|
a.href = "/play?id=" + g.id;
|
|
a.textContent = "Watch";
|
|
div.appendChild(a);
|
|
el.appendChild(div);
|
|
});
|
|
}
|
|
|
|
// --- game view ---
|
|
function renderMoveList(sanMoves) {
|
|
const el = document.getElementById("move-list");
|
|
el.innerHTML = "";
|
|
for (let i = 0; i < sanMoves.length; i += 2) {
|
|
const num = document.createElement("div");
|
|
num.className = "mv-num";
|
|
num.textContent = (i / 2 + 1) + ".";
|
|
el.appendChild(num);
|
|
const whiteEl = document.createElement("div");
|
|
whiteEl.textContent = sanMoves[i] || "";
|
|
el.appendChild(whiteEl);
|
|
const blackEl = document.createElement("div");
|
|
blackEl.textContent = sanMoves[i + 1] || "";
|
|
el.appendChild(blackEl);
|
|
}
|
|
el.scrollTop = el.scrollHeight;
|
|
}
|
|
|
|
async function loadMoveList() {
|
|
const res = await api.get("/api/games/" + gameID + "/moves");
|
|
renderMoveList(res.moves || []);
|
|
}
|
|
|
|
function updateStatusBanner(state) {
|
|
const banner = document.getElementById("status-banner");
|
|
if (state.status !== 0) {
|
|
banner.className = "status-banner over";
|
|
banner.textContent = "Game over: " + STATUS_LABELS[state.status];
|
|
} else if (state.in_check) {
|
|
banner.className = "status-banner check";
|
|
banner.textContent = (state.turn === 0 ? "White" : "Black") + " is in check";
|
|
} else {
|
|
banner.className = "status-banner ongoing";
|
|
banner.textContent = (state.turn === 0 ? "White" : "Black") + " to move";
|
|
}
|
|
}
|
|
|
|
async function refresh() {
|
|
const state = await api.get("/api/games/" + gameID);
|
|
if (!state || !state.id) return;
|
|
currentState = state;
|
|
lastBoard = state.board;
|
|
document.getElementById("white-name").textContent = state.white || "?";
|
|
document.getElementById("black-name").textContent = state.vs_bot ? "bot" : (state.black || "(waiting)");
|
|
updateStatusBanner(state);
|
|
|
|
if (state.history_count > 0) {
|
|
const mv = await api.get("/api/games/" + gameID + "/history/" + (state.history_count - 1));
|
|
if (mv && mv.from !== undefined) lastMove = { from: mv.from, to: mv.to };
|
|
}
|
|
|
|
let checkSquare = null;
|
|
if (state.in_check) {
|
|
const kingCode = state.turn === 0 ? 6 : 14;
|
|
const idx = state.board.findIndex((p) => p === kingCode);
|
|
if (idx >= 0) checkSquare = idx;
|
|
}
|
|
|
|
renderBoard(document.getElementById("board"), state.board, {
|
|
selected, targets: legalTargets, lastMove, checkSquare, onClick: onSquareClick, flipped: isFlipped(),
|
|
});
|
|
loadMoveList();
|
|
|
|
if (state.status !== 0 && pollTimer) {
|
|
clearInterval(pollTimer);
|
|
pollTimer = null;
|
|
}
|
|
}
|
|
|
|
function isMyTurn() {
|
|
if (!currentState || myUserID === null) return true;
|
|
const turnName = currentState.turn === 0 ? currentState.white : currentState.black;
|
|
return turnName === myUserID;
|
|
}
|
|
|
|
// Each player sees their own pieces at the bottom: flip the board for
|
|
// whoever is playing black. Spectators (and white) see it unflipped.
|
|
function isFlipped() {
|
|
return !!(currentState && myUserID !== null && currentState.black === myUserID);
|
|
}
|
|
|
|
async function onSquareClick(sq) {
|
|
if (currentState && currentState.status !== 0) return;
|
|
if (!isMyTurn()) return;
|
|
if (selected === null) {
|
|
if (lastBoard[sq] === 0) return;
|
|
selected = sq;
|
|
const res = await api.get("/api/games/" + gameID + "/legal-moves?from=" + sq);
|
|
legalTargets = res.targets || [];
|
|
renderBoard(document.getElementById("board"), lastBoard, { selected, targets: legalTargets, lastMove, onClick: onSquareClick, flipped: isFlipped() });
|
|
return;
|
|
}
|
|
|
|
const from = selected;
|
|
const to = sq;
|
|
selected = null;
|
|
const wasTarget = legalTargets.includes(to);
|
|
legalTargets = [];
|
|
|
|
if (!wasTarget) {
|
|
// clicking a different own piece re-selects instead of attempting an illegal move
|
|
if (lastBoard[sq] !== 0) {
|
|
return onSquareClick(sq);
|
|
}
|
|
renderBoard(document.getElementById("board"), lastBoard, { onClick: onSquareClick, flipped: isFlipped() });
|
|
return;
|
|
}
|
|
|
|
let promo = 5;
|
|
if (isPromotionMove(lastBoard, from, to)) {
|
|
const color = lastBoard[from] === 1 ? 0 : 1;
|
|
promo = await askPromotion(color);
|
|
}
|
|
|
|
await api.post("/api/games/" + gameID + "/move", { from, to, promo });
|
|
refresh();
|
|
}
|
|
|
|
if (gameID) {
|
|
document.getElementById("lobby-view").style.display = "none";
|
|
document.getElementById("game-view").style.display = "block";
|
|
api.get("/api/me").then((me) => {
|
|
if (me && me.username) {
|
|
// white_id/black_id come back with the game state itself; just
|
|
// compare usernames instead of round-tripping for an id.
|
|
myUserID = me.username;
|
|
}
|
|
refresh();
|
|
});
|
|
pollTimer = setInterval(refresh, 2000);
|
|
} else {
|
|
loadOpen();
|
|
loadMine();
|
|
loadAll();
|
|
}
|
|
</script>
|
|
</body>
|
|
</html>
|