Clients now render from the state delivered with each move/update and drop stale or echoed updates via a ply-version guard, instead of issuing a full game-state fetch per event. Adds captured-piece trays, a material advantage badge, a move list, a status line, and the mobile menu toggle. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
275 lines
9.5 KiB
JavaScript
275 lines
9.5 KiB
JavaScript
// Material value per piece type, indexed by PieceType (0=Pawn .. 5=King).
|
|
const PIECE_VALUES = [1, 5, 3, 3, 9, 0];
|
|
|
|
const ChessBoard = {
|
|
// Render from a full game-state payload (the shape returned by the move/state
|
|
// endpoints and pushed over SignalR). Ignores state older than what's already
|
|
// shown, so a slow initial fetch can't clobber a move that arrived first.
|
|
renderState(state) {
|
|
if (!state || !GameState.shouldApply(state.version)) return;
|
|
this.renderPieces(state.pieces);
|
|
this.renderCapturedTrays(state.pieces, state.capturedPieces);
|
|
this.renderMoveList(state.sanHistory);
|
|
this.renderStatus(state);
|
|
GameState.setVersion(state.version);
|
|
},
|
|
|
|
// Two-column numbered move list (white move, black move), latest highlighted.
|
|
renderMoveList(sanHistory) {
|
|
const list = document.getElementById("moveList");
|
|
if (!list) return;
|
|
|
|
const san = sanHistory ?? [];
|
|
|
|
if (san.length === 0) {
|
|
list.innerHTML = '<p class="movePlaceholder">Moves will appear here once a game begins.</p>';
|
|
return;
|
|
}
|
|
|
|
list.innerHTML = "";
|
|
|
|
for (let i = 0; i < san.length; i += 2) {
|
|
const row = document.createElement("div");
|
|
row.className = "moveRow";
|
|
|
|
const num = document.createElement("span");
|
|
num.className = "moveNum";
|
|
num.textContent = `${i / 2 + 1}.`;
|
|
row.appendChild(num);
|
|
|
|
row.appendChild(this.moveCell(san[i], i === san.length - 1));
|
|
|
|
if (i + 1 < san.length)
|
|
row.appendChild(this.moveCell(san[i + 1], i + 1 === san.length - 1));
|
|
|
|
list.appendChild(row);
|
|
}
|
|
|
|
list.scrollTop = list.scrollHeight;
|
|
},
|
|
|
|
moveCell(san, isLatest) {
|
|
const cell = document.createElement("span");
|
|
cell.className = isLatest ? "moveSan latest" : "moveSan";
|
|
cell.textContent = san;
|
|
return cell;
|
|
},
|
|
|
|
renderStatus(state) {
|
|
let text;
|
|
let alert = false;
|
|
|
|
if (state.isCheckmate) {
|
|
const winner = state.currentPlayer === "White" ? "Black" : "White";
|
|
text = `Checkmate — ${winner} wins`;
|
|
alert = true;
|
|
} else if (state.isStalemate) {
|
|
text = "Draw — stalemate";
|
|
alert = true;
|
|
} else if (state.isThreefoldRepetition) {
|
|
text = "Draw — threefold repetition";
|
|
alert = true;
|
|
} else {
|
|
text = `${state.currentPlayer} to move`;
|
|
if (state.isCheck) {
|
|
text += " — check";
|
|
alert = true;
|
|
}
|
|
}
|
|
|
|
// Mirror to both the desktop panel status and the mobile bar status.
|
|
["statusLine", "mobileStatus"].forEach(id => {
|
|
const el = document.getElementById(id);
|
|
if (!el) return;
|
|
el.textContent = text;
|
|
el.classList.toggle("alert", alert);
|
|
});
|
|
},
|
|
|
|
renderPieces(pieces) {
|
|
this.clearAllSquares();
|
|
this.renderCoordinateLabels();
|
|
this.placePieces(pieces);
|
|
this.setupSquareEventHandlers();
|
|
this.highlightPreviousMove();
|
|
},
|
|
|
|
// Show each side's captured pieces and the leading side's material advantage,
|
|
// arranged so the current player's tray sits below the board.
|
|
renderCapturedTrays(activePieces, capturedPieces) {
|
|
const captured = capturedPieces ?? [];
|
|
|
|
// A captured piece's color is the side that lost it, so White's haul is the
|
|
// captured Black pieces, and vice versa.
|
|
const whiteCaptured = captured.filter(p => p.color === 1);
|
|
const blackCaptured = captured.filter(p => p.color === 0);
|
|
|
|
// Net material from pieces still on the board, so promotions count correctly.
|
|
const advantage = (activePieces ?? []).reduce(
|
|
(sum, p) => sum + (p.color === 0 ? PIECE_VALUES[p.type] : -PIECE_VALUES[p.type]), 0);
|
|
|
|
const white = { captured: whiteCaptured, advantage: Math.max(advantage, 0) };
|
|
const black = { captured: blackCaptured, advantage: Math.max(-advantage, 0) };
|
|
|
|
const isWhite = GameState.currentPlayerIsWhite !== false;
|
|
this.fillCapturedTray("bottom", isWhite ? white : black);
|
|
this.fillCapturedTray("top", isWhite ? black : white);
|
|
},
|
|
|
|
fillCapturedTray(position, side) {
|
|
const tray = document.getElementById(`captured-${position}`);
|
|
const badge = document.getElementById(`advantage-${position}`);
|
|
if (!tray || !badge) return;
|
|
|
|
tray.innerHTML = "";
|
|
[...side.captured]
|
|
.sort((a, b) => PIECE_VALUES[a.type] - PIECE_VALUES[b.type])
|
|
.forEach(piece => {
|
|
const img = document.createElement("img");
|
|
img.src = ChessUtils.getPieceImageUrl(piece);
|
|
img.alt = piece.type;
|
|
img.className = "capturedPiece";
|
|
img.draggable = false;
|
|
tray.appendChild(img);
|
|
});
|
|
|
|
badge.textContent = side.advantage > 0 ? `+${side.advantage}` : "";
|
|
},
|
|
|
|
clearAllSquares() {
|
|
for (let i = 0; i < 64; i++) {
|
|
const square = document.getElementById(`square-${i}`);
|
|
square.innerHTML = "";
|
|
}
|
|
},
|
|
|
|
placePieces(pieces) {
|
|
pieces.forEach(piece => {
|
|
const [r, c] = ChessUtils.flipCoordinates(piece.row, piece.col);
|
|
const index = r * 8 + c;
|
|
const square = document.getElementById(`square-${index}`);
|
|
if (!square) return;
|
|
|
|
const img = document.createElement("img");
|
|
img.src = ChessUtils.getPieceImageUrl(piece);
|
|
img.alt = piece.type;
|
|
img.classList.add("chessPiece");
|
|
|
|
this.setupPieceDragEvents(img, piece);
|
|
this.setupPieceClickEvent(img, piece);
|
|
|
|
square.appendChild(img);
|
|
});
|
|
},
|
|
|
|
setupPieceDragEvents(img, piece) {
|
|
img.draggable = true;
|
|
img.ondragstart = async (e) => {
|
|
GameState.setSelectedPiece(piece);
|
|
try {
|
|
const moves = await ChessAPI.getLegalMoves(piece.id);
|
|
GameState.setLegalMoves(moves);
|
|
ChessInteractions.highlightSelected(piece.row, piece.col);
|
|
ChessInteractions.highlightLegalMoves(moves);
|
|
} catch (err) {
|
|
console.error(err);
|
|
}
|
|
|
|
e.dataTransfer.setData("text/plain", JSON.stringify({
|
|
srcRow: piece.Row,
|
|
srcCol: piece.Col
|
|
}));
|
|
};
|
|
|
|
img.ondragend = () => ChessInteractions.clearHighlights();
|
|
},
|
|
|
|
setupPieceClickEvent(img, piece) {
|
|
img.onclick = (e) => {
|
|
e.stopPropagation();
|
|
ChessInteractions.handlePieceClick(piece);
|
|
console.log("Clicked on:", piece);
|
|
};
|
|
},
|
|
|
|
setupSquareEventHandlers() {
|
|
for (let i = 0; i < 64; i++) {
|
|
const square = document.getElementById(`square-${i}`);
|
|
square.classList.remove("previous-start", "previous-end");
|
|
|
|
if (!square.classList.contains("legal")) {
|
|
square.onclick = () => ChessInteractions.clearHighlights();
|
|
}
|
|
}
|
|
},
|
|
|
|
highlightPreviousMove() {
|
|
if (GameState.previousMoveStart && GameState.previousMoveEnd) {
|
|
const [startRow, startCol] = ChessUtils.flipCoordinates(...GameState.previousMoveStart);
|
|
const [endRow, endCol] = ChessUtils.flipCoordinates(...GameState.previousMoveEnd);
|
|
|
|
const startIndex = startRow * 8 + startCol;
|
|
const endIndex = endRow * 8 + endCol;
|
|
|
|
document.getElementById(`square-${startIndex}`)?.classList.add("previous-start");
|
|
document.getElementById(`square-${endIndex}`)?.classList.add("previous-end");
|
|
}
|
|
},
|
|
|
|
setupDragAndDrop() {
|
|
for (let i = 0; i < 64; i++) {
|
|
const square = document.getElementById(`square-${i}`);
|
|
|
|
square.ondragover = (e) => {
|
|
e.preventDefault();
|
|
};
|
|
|
|
square.ondrop = (e) => {
|
|
e.preventDefault();
|
|
|
|
if (!GameState.selectedPiece) return;
|
|
|
|
const index = parseInt(square.id.split('-')[1], 10);
|
|
const targetRow = Math.floor(index / 8);
|
|
const targetCol = index % 8;
|
|
|
|
ChessAPI.handleMove(targetRow, targetCol);
|
|
};
|
|
}
|
|
},
|
|
|
|
renderCoordinateLabels() {
|
|
const files = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h'];
|
|
const ranks = ['8', '7', '6', '5', '4', '3', '2', '1'];
|
|
|
|
// If player is black, reverse the coordinates
|
|
if (!GameState.currentPlayerIsWhite) {
|
|
files.reverse();
|
|
ranks.reverse();
|
|
}
|
|
|
|
for (let i = 0; i < 64; i++) {
|
|
const square = document.getElementById(`square-${i}`);
|
|
const row = Math.floor(i / 8);
|
|
const col = i % 8;
|
|
|
|
// Add rank label (1-8) on the leftmost column
|
|
if (col === 0) {
|
|
const rankLabel = document.createElement('span');
|
|
rankLabel.className = 'coordinate-label row-label';
|
|
rankLabel.textContent = ranks[row];
|
|
square.appendChild(rankLabel);
|
|
}
|
|
|
|
// Add file label (a-h) on the bottom row
|
|
if (row === 7) {
|
|
const fileLabel = document.createElement('span');
|
|
fileLabel.className = 'coordinate-label col-label';
|
|
fileLabel.textContent = files[col];
|
|
square.appendChild(fileLabel);
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
console.log("ChessBoard.js loaded"); |