311 lines
10 KiB
JavaScript
311 lines
10 KiB
JavaScript
const Spectate = {
|
|
connection: null,
|
|
games: new Map(), // gameId -> { isVsComputer, isComputerVsComputer, result }
|
|
pgns: new Map(), // gameId -> PGN text (prefetched when a game finishes)
|
|
|
|
pieceTypeNames: ["Pawn", "Rook", "Knight", "Bishop", "Queen", "King"],
|
|
|
|
async init() {
|
|
this.connection = new signalR.HubConnectionBuilder()
|
|
.withUrl("/chessHub")
|
|
.configureLogging(signalR.LogLevel.Warning)
|
|
.build();
|
|
|
|
this.connection.on("ReceiveMoveUpdate", (gameId, moveDto, _moveResult, state) =>
|
|
this.handleMoveUpdate(gameId, moveDto, state));
|
|
|
|
this.connection.on("ReceiveGameOver", (gameId) => this.removeGame(gameId));
|
|
|
|
try {
|
|
await this.connection.start();
|
|
} catch (err) {
|
|
console.error("❌ SignalR failed to start:", err);
|
|
}
|
|
|
|
await this.refreshGames();
|
|
setInterval(() => this.refreshGames(), 5000);
|
|
},
|
|
|
|
async startCpuGame() {
|
|
const params = new URLSearchParams({
|
|
whiteEngine: document.getElementById("whiteEngine").value,
|
|
whiteSkill: document.getElementById("whiteSkill").value,
|
|
blackEngine: document.getElementById("blackEngine").value,
|
|
blackSkill: document.getElementById("blackSkill").value
|
|
});
|
|
|
|
try {
|
|
await fetch(`/api/chess/watch/cpu?${params}`);
|
|
await this.refreshGames();
|
|
} catch (err) {
|
|
console.error("❌ Could not start CPU vs CPU game.", err);
|
|
}
|
|
},
|
|
|
|
async refreshGames() {
|
|
let games;
|
|
|
|
try {
|
|
const response = await fetch("/api/chess/active");
|
|
games = await response.json();
|
|
} catch {
|
|
return;
|
|
}
|
|
|
|
const activeIds = new Set(games.map(g => g.gameId));
|
|
|
|
for (const gameId of [...this.games.keys()])
|
|
if (!activeIds.has(gameId))
|
|
await this.removeGame(gameId);
|
|
|
|
for (const game of games)
|
|
if (this.games.has(game.gameId))
|
|
this.updateHeader(game.gameId, game.currentPlayer, game.moveCount, game.isCheck);
|
|
else
|
|
await this.addGame(game);
|
|
|
|
const status = document.getElementById("watchStatus");
|
|
status.textContent = games.length === 0
|
|
? "No games are being played right now."
|
|
: `${games.length} game${games.length === 1 ? "" : "s"} in progress`;
|
|
},
|
|
|
|
async addGame(game) {
|
|
this.games.set(game.gameId, {
|
|
isVsComputer: game.isVsComputer,
|
|
isComputerVsComputer: game.isComputerVsComputer,
|
|
whiteEngine: game.whiteEngine,
|
|
blackEngine: game.blackEngine
|
|
});
|
|
|
|
const card = document.createElement("div");
|
|
card.className = "gameCard";
|
|
card.id = `card-${game.gameId}`;
|
|
card.onclick = () => this.enterFullscreen(game.gameId);
|
|
|
|
const back = document.createElement("button");
|
|
back.className = "backButton";
|
|
back.textContent = "← Back";
|
|
back.onclick = (event) => this.exitFullscreen(game.gameId, event);
|
|
card.appendChild(back);
|
|
|
|
const header = document.createElement("div");
|
|
header.className = "gameCardHeader";
|
|
header.id = `header-${game.gameId}`;
|
|
header.textContent = this.headerText(this.games.get(game.gameId), game.currentPlayer, game.moveCount, game.isCheck);
|
|
card.appendChild(header);
|
|
|
|
const board = document.createElement("div");
|
|
board.className = "miniBoard";
|
|
|
|
for (let i = 0; i < 64; i++) {
|
|
const square = document.createElement("div");
|
|
square.id = `sq-${game.gameId}-${i}`;
|
|
square.className = `chessSquare ${(i + Math.floor(i / 8)) % 2 === 0 ? "light" : "dark"}`;
|
|
board.appendChild(square);
|
|
}
|
|
|
|
card.appendChild(board);
|
|
document.getElementById("gamesFeed").appendChild(card);
|
|
|
|
await this.connection.invoke("JoinWebsocketGroup", game.gameId).catch(() => { });
|
|
await this.renderGame(game.gameId);
|
|
},
|
|
|
|
async removeGame(gameId) {
|
|
this.games.delete(gameId);
|
|
this.pgns.delete(gameId);
|
|
document.getElementById(`card-${gameId}`)?.remove();
|
|
await this.connection.invoke("LeaveWebsocketGroup", gameId).catch(() => { });
|
|
},
|
|
|
|
async renderGame(gameId) {
|
|
const response = await fetch(`/api/chess/${gameId}`);
|
|
|
|
if (!response.ok) return;
|
|
|
|
this.renderFromState(gameId, await response.json());
|
|
},
|
|
|
|
renderFromState(gameId, state) {
|
|
const result = this.resultTextFromState(state);
|
|
const stored = this.games.get(gameId);
|
|
|
|
if (stored) stored.result = result;
|
|
|
|
this.renderPieces(gameId, state.pieces);
|
|
this.updateHeader(gameId, state.currentPlayer, state.moveHistory.length, state.isCheck);
|
|
this.setResult(gameId, result);
|
|
},
|
|
|
|
async handleMoveUpdate(gameId, moveDto, state) {
|
|
if (!this.games.has(gameId)) return;
|
|
|
|
// Render from the pushed state; fall back to a fetch only if it's missing.
|
|
if (state) this.renderFromState(gameId, state);
|
|
else await this.renderGame(gameId);
|
|
|
|
this.highlightMove(gameId, moveDto);
|
|
},
|
|
|
|
renderPieces(gameId, pieces) {
|
|
this.clearBoard(gameId);
|
|
|
|
pieces.forEach(piece => {
|
|
const square = document.getElementById(`sq-${gameId}-${piece.row * 8 + piece.col}`);
|
|
|
|
if (!square) return;
|
|
|
|
const img = document.createElement("img");
|
|
img.src = this.pieceImageUrl(piece);
|
|
img.alt = piece.type;
|
|
img.className = "chessPiece";
|
|
img.draggable = false;
|
|
square.appendChild(img);
|
|
});
|
|
},
|
|
|
|
clearBoard(gameId) {
|
|
for (let i = 0; i < 64; i++) {
|
|
const square = document.getElementById(`sq-${gameId}-${i}`);
|
|
|
|
if (!square) continue;
|
|
|
|
square.innerHTML = "";
|
|
square.classList.remove("previous-start", "previous-end");
|
|
}
|
|
},
|
|
|
|
highlightMove(gameId, moveDto) {
|
|
document.getElementById(`sq-${gameId}-${moveDto.sourceRow * 8 + moveDto.sourceCol}`)?.classList.add("previous-start");
|
|
document.getElementById(`sq-${gameId}-${moveDto.targetRow * 8 + moveDto.targetCol}`)?.classList.add("previous-end");
|
|
},
|
|
|
|
updateHeader(gameId, currentPlayer, moveCount, isCheck) {
|
|
const stored = this.games.get(gameId);
|
|
const header = document.getElementById(`header-${gameId}`);
|
|
|
|
if (stored && header)
|
|
header.textContent = this.headerText(stored, currentPlayer, moveCount, isCheck);
|
|
},
|
|
|
|
gameLabel(stored) {
|
|
if (stored.isComputerVsComputer)
|
|
return `${this.engineName(stored.whiteEngine)} (W) vs ${this.engineName(stored.blackEngine)} (B)`;
|
|
if (stored.isVsComputer) return "Vs CPU";
|
|
return "Player vs Player";
|
|
},
|
|
|
|
engineName(kind) {
|
|
switch (kind) {
|
|
case "CustomLearned": return "Learned";
|
|
case "Custom": return "Custom";
|
|
case "Stockfish": return "Stockfish";
|
|
default: return kind || "CPU";
|
|
}
|
|
},
|
|
|
|
headerText(stored, currentPlayer, moveCount, isCheck) {
|
|
if (stored.result)
|
|
return `${this.gameLabel(stored)} · move ${moveCount} · final`;
|
|
|
|
const check = isCheck ? " • check" : "";
|
|
return `${this.gameLabel(stored)} · move ${moveCount} · ${currentPlayer} to move${check}`;
|
|
},
|
|
|
|
resultTextFromState(state) {
|
|
if (state.isCheckmate)
|
|
return `${state.currentPlayer === "White" ? "Black" : "White"} wins by checkmate`;
|
|
if (state.isStalemate)
|
|
return "Draw — stalemate";
|
|
if (state.isThreefoldRepetition)
|
|
return "Draw — threefold repetition";
|
|
return null;
|
|
},
|
|
|
|
setResult(gameId, text) {
|
|
const card = document.getElementById(`card-${gameId}`);
|
|
|
|
if (!card) return;
|
|
|
|
let banner = card.querySelector(".gameResult");
|
|
|
|
if (!text) {
|
|
banner?.remove();
|
|
card.querySelector(".copyPgnBtn")?.remove();
|
|
card.classList.remove("over");
|
|
return;
|
|
}
|
|
|
|
if (!banner) {
|
|
banner = document.createElement("div");
|
|
banner.className = "gameResult";
|
|
card.appendChild(banner);
|
|
}
|
|
|
|
banner.textContent = text;
|
|
card.classList.add("over");
|
|
this.addCopyPgn(gameId, card);
|
|
},
|
|
|
|
addCopyPgn(gameId, card) {
|
|
if (card.querySelector(".copyPgnBtn")) return;
|
|
|
|
const btn = document.createElement("button");
|
|
btn.className = "copyPgnBtn";
|
|
btn.textContent = "Copy PGN";
|
|
btn.onclick = (event) => { event.stopPropagation(); this.copyPgn(gameId); };
|
|
card.appendChild(btn);
|
|
|
|
// Prefetch now (while the game is still in memory) so copy works during the
|
|
// brief window before the finished game is cleaned up.
|
|
fetch(`/api/chess/${gameId}/pgn`)
|
|
.then(r => r.ok ? r.text() : null)
|
|
.then(t => { if (t) this.pgns.set(gameId, t); })
|
|
.catch(() => { });
|
|
},
|
|
|
|
async copyPgn(gameId) {
|
|
let pgn = this.pgns.get(gameId);
|
|
|
|
if (!pgn) {
|
|
try {
|
|
const r = await fetch(`/api/chess/${gameId}/pgn`);
|
|
if (r.ok) pgn = await r.text();
|
|
} catch { /* ignore */ }
|
|
}
|
|
|
|
if (!pgn) {
|
|
alert("PGN is no longer available for this game.");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await navigator.clipboard.writeText(pgn);
|
|
alert("📋 PGN copied to clipboard!");
|
|
} catch {
|
|
alert("Couldn't access the clipboard. Here's the PGN:\n\n" + pgn);
|
|
}
|
|
},
|
|
|
|
enterFullscreen(gameId) {
|
|
document.getElementById(`card-${gameId}`)?.classList.add("fullscreen");
|
|
document.body.classList.add("fullscreen-open");
|
|
},
|
|
|
|
exitFullscreen(gameId, event) {
|
|
event?.stopPropagation();
|
|
document.getElementById(`card-${gameId}`)?.classList.remove("fullscreen");
|
|
document.body.classList.remove("fullscreen-open");
|
|
},
|
|
|
|
pieceImageUrl(piece) {
|
|
const color = piece.color === 0 ? "White" : "Black";
|
|
return `/images/Chess Images/${color}${this.pieceTypeNames[piece.type]}.svg`;
|
|
}
|
|
};
|
|
|
|
window.addEventListener("load", () => Spectate.init());
|
|
|
|
console.log("Spectate.js loaded");
|