-
-
Save shricodev/44bfa16082ade763e84162fc88bede99 to your computer and use it in GitHub Desktop.
Blog - Chess (Gemini 2.5 Pro)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<!doctype html> | |
<html lang="en"> | |
<head> | |
<meta charset="UTF-8" /> | |
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> | |
<title>Chess Game</title> | |
<style> | |
:root { | |
--board-bg: #f0d9b5; | |
--light-square: #f0d9b5; | |
--dark-square: #b58863; | |
--highlight-selected: rgba(80, 145, 255, 0.5); | |
--highlight-possible: rgba(130, 180, 90, 0.7); | |
--highlight-last-move: rgba(204, 204, 0, 0.4); | |
--piece-font-size: 36px; | |
--ui-bg: #f0f0f0; | |
--button-bg: #4caf50; | |
--button-text: white; | |
} | |
body { | |
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif; | |
display: flex; | |
justify-content: center; | |
align-items: flex-start; | |
min-height: 100vh; | |
background-color: #333; | |
margin: 0; | |
padding: 20px; | |
box-sizing: border-box; | |
} | |
#game-container { | |
display: flex; | |
flex-wrap: wrap; /* Allow UI panel to wrap on smaller screens */ | |
gap: 20px; | |
align-items: flex-start; | |
} | |
#board-container { | |
display: grid; | |
grid-template-columns: repeat(8, 1fr); | |
grid-template-rows: repeat(8, 1fr); | |
width: 480px; /* 8 * 60px */ | |
height: 480px; /* 8 * 60px */ | |
border: 2px solid #333; | |
box-shadow: 0 0 15px rgba(0, 0, 0, 0.5); | |
} | |
.square { | |
width: 60px; | |
height: 60px; | |
display: flex; | |
justify-content: center; | |
align-items: center; | |
font-size: var(--piece-font-size); | |
cursor: pointer; | |
position: relative; /* For highlight overlays */ | |
} | |
.square.light { | |
background-color: var(--light-square); | |
} | |
.square.dark { | |
background-color: var(--dark-square); | |
} | |
.square .piece { | |
user-select: none; /* Prevent text selection */ | |
} | |
.square.selected::before { | |
content: ""; | |
position: absolute; | |
top: 0; | |
left: 0; | |
right: 0; | |
bottom: 0; | |
background-color: var(--highlight-selected); | |
z-index: 1; | |
} | |
.square.possible-move::after { | |
content: ""; | |
position: absolute; | |
top: 50%; | |
left: 50%; | |
width: 20px; | |
height: 20px; | |
border-radius: 50%; | |
background-color: var(--highlight-possible); | |
transform: translate(-50%, -50%); | |
z-index: 2; | |
} | |
.square.possible-capture::after { | |
/* Differentiate capture visually */ | |
background-color: rgba(255, 80, 80, 0.6); /* Reddish for captures */ | |
/* Or a ring instead of a dot */ | |
/* border: 3px solid var(--highlight-possible); | |
background-color: transparent; | |
width: 50px; height: 50px; */ | |
} | |
.square.last-move-from::before, | |
.square.last-move-to::before { | |
content: ""; | |
position: absolute; | |
top: 0; | |
left: 0; | |
right: 0; | |
bottom: 0; | |
background-color: var(--highlight-last-move); | |
z-index: 0; | |
} | |
#ui-panel { | |
width: 250px; | |
background-color: var(--ui-bg); | |
padding: 15px; | |
border-radius: 8px; | |
box-shadow: 0 0 10px rgba(0, 0, 0, 0.3); | |
display: flex; | |
flex-direction: column; | |
gap: 15px; | |
} | |
#timers { | |
display: flex; | |
justify-content: space-between; | |
font-size: 1.1em; | |
font-weight: bold; | |
} | |
#timers .active-timer { | |
color: var(--button-bg); | |
text-decoration: underline; | |
} | |
#move-log-container { | |
background-color: white; | |
border: 1px solid #ccc; | |
border-radius: 4px; | |
padding: 10px; | |
height: 200px; /* Fixed height for scroll */ | |
overflow-y: auto; | |
} | |
#move-log-container h3 { | |
margin-top: 0; | |
} | |
#move-log { | |
list-style-type: none; | |
padding-left: 0; | |
margin: 0; | |
font-size: 0.9em; | |
} | |
#move-log li { | |
padding: 2px 0; | |
display: flex; | |
} | |
#move-log li span.move-number { | |
min-width: 25px; | |
color: #555; | |
} | |
#move-log li span.move-white { | |
min-width: 60px; /* Adjust as needed */ | |
} | |
#move-log li span.move-black { | |
min-width: 60px; /* Adjust as needed */ | |
} | |
#game-status { | |
font-size: 1.2em; | |
font-weight: bold; | |
color: #d32f2f; /* Red for emphasis */ | |
min-height: 1.5em; /* Prevent layout shift */ | |
text-align: center; | |
} | |
button { | |
background-color: var(--button-bg); | |
color: var(--button-text); | |
border: none; | |
padding: 10px 15px; | |
border-radius: 4px; | |
cursor: pointer; | |
font-size: 1em; | |
transition: background-color 0.2s; | |
} | |
button:hover { | |
background-color: #3e8e41; | |
} | |
#promotion-modal { | |
position: fixed; | |
top: 50%; | |
left: 50%; | |
transform: translate(-50%, -50%); | |
background-color: white; | |
padding: 20px; | |
border: 2px solid #555; | |
border-radius: 8px; | |
box-shadow: 0 0 20px rgba(0, 0, 0, 0.5); | |
z-index: 1000; | |
display: flex; | |
gap: 10px; | |
} | |
#promotion-modal button { | |
font-size: var(--piece-font-size); | |
padding: 5px; | |
min-width: 50px; | |
background: #eee; | |
color: #333; | |
} | |
#promotion-modal button:hover { | |
background: #ddd; | |
} | |
/* Responsive adjustments */ | |
@media (max-width: 800px) { | |
/* Approximate breakpoint for board + ui panel */ | |
body { | |
align-items: center; /* Center content vertically */ | |
flex-direction: column; | |
} | |
#game-container { | |
flex-direction: column; | |
align-items: center; | |
} | |
#ui-panel { | |
width: 90%; /* Take more width on smaller screens */ | |
max-width: 480px; /* Max width same as board */ | |
} | |
#board-container { | |
width: 90vw; /* Responsive board width */ | |
height: 90vw; /* Responsive board height */ | |
max-width: 480px; | |
max-height: 480px; | |
} | |
.square { | |
/* Size will be determined by grid fractional units */ | |
font-size: calc( | |
var(--piece-font-size) * (90vw / 480px) | |
); /* Scale font with board */ | |
} | |
} | |
@media (max-width: 400px) { | |
.square { | |
font-size: calc( | |
var(--piece-font-size) * 0.7 | |
); /* Further scale down font on very small screens */ | |
} | |
#timers { | |
font-size: 1em; | |
} | |
} | |
</style> | |
</head> | |
<body> | |
<div id="game-container"> | |
<div id="board-container"></div> | |
<div id="ui-panel"> | |
<div id="timers"> | |
<div id="white-timer">White: 5:00</div> | |
<div id="black-timer">Black: 5:00</div> | |
</div> | |
<div id="move-log-container"> | |
<h3>Move Log</h3> | |
<ol id="move-log"></ol> | |
</div> | |
<div id="game-status"></div> | |
<button id="restart-button">Restart Game</button> | |
<button id="flip-board-button">Flip Board</button> | |
</div> | |
</div> | |
<div id="promotion-modal" style="display: none"> | |
<!-- Promotion options will be added by JS | |
<!-- --> --> | |
<p>Promote pawn to:</p> | |
<button data-piece="Q">♕</button> | |
<button data-piece="R">♖</button> | |
<button data-piece="B">♗</button> | |
<button data-piece="N">♘</button> | |
</div> | |
<script> | |
const boardContainer = document.getElementById("board-container"); | |
const whiteTimerDisplay = document.getElementById("white-timer"); | |
const blackTimerDisplay = document.getElementById("black-timer"); | |
const moveLogDisplay = document.getElementById("move-log"); | |
const gameStatusDisplay = document.getElementById("game-status"); | |
const restartButton = document.getElementById("restart-button"); | |
const flipBoardButton = document.getElementById("flip-board-button"); | |
const promotionModal = document.getElementById("promotion-modal"); | |
const PIECE_UNICODE = { | |
P: "♙", | |
R: "♖", | |
N: "♘", | |
B: "♗", | |
Q: "♕", | |
K: "♔", | |
p: "♟", | |
r: "♜", | |
n: "♞", | |
b: "♝", | |
q: "♛", | |
k: "♚", | |
}; | |
const INITIAL_BOARD_FEN = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR"; // Standard starting position | |
// FEN represents board state, castling rights, en passant target, halfmove clock, fullmove number | |
// We will use a simplified model internally focusing on piece positions. | |
let gameState = {}; | |
function getPieceColor(pieceChar) { | |
if (!pieceChar) return null; | |
return pieceChar === pieceChar.toUpperCase() ? "white" : "black"; | |
} | |
function getPieceType(pieceChar) { | |
if (!pieceChar) return null; | |
const lower = pieceChar.toLowerCase(); | |
if (lower === "p") return "pawn"; | |
if (lower === "r") return "rook"; | |
if (lower === "n") return "knight"; | |
if (lower === "b") return "bishop"; | |
if (lower === "q") return "queen"; | |
if (lower === "k") return "king"; | |
return null; | |
} | |
function initGame() { | |
gameState = { | |
board: Array(8) | |
.fill(null) | |
.map(() => Array(8).fill(null)), // { piece: 'P', hasMoved: false } | |
currentPlayer: "white", | |
isGameOver: false, | |
gameStatusMessage: "", | |
selectedSquare: null, // { r, c } | |
possibleMoves: [], // array of { toR, toC, ... } | |
lastMove: null, // { fromR, fromC, toR, toC } | |
enPassantTarget: null, // { r, c } for the square a pawn can move TO for en passant | |
castlingRights: { | |
white: { K: true, Q: true }, // Kingside (short), Queenside (long) | |
black: { K: true, Q: true }, | |
}, | |
moveHistory: [], // { white: 'e4', black: 'e5' } | |
timers: { | |
white: 300, // 5 minutes in seconds | |
black: 300, | |
intervalId: null, | |
initialTime: 300, | |
}, | |
boardFlipped: false, | |
promotionChoiceCallback: null, | |
halfMoveClock: 0, // For 50-move rule (not fully implemented for draw detection) | |
fullMoveNumber: 1, | |
}; | |
setupBoardFromFen(INITIAL_BOARD_FEN + " w KQkq - 0 1"); | |
renderBoard(); | |
updateTimerDisplay(); | |
startTimers(); | |
gameStatusDisplay.textContent = "White's turn"; | |
moveLogDisplay.innerHTML = ""; // Clear move log | |
} | |
function setupBoardFromFen(fen) { | |
const parts = fen.split(" "); | |
const piecePlacement = parts[0]; | |
let r = 0, | |
c = 0; | |
for (const char of piecePlacement) { | |
if (char === "/") { | |
r++; | |
c = 0; | |
} else if (/\d/.test(char)) { | |
c += parseInt(char); | |
} else { | |
gameState.board[r][c] = { piece: char, hasMoved: false }; | |
// Logic to set hasMoved based on FEN could be added if FEN implies it | |
// For simplicity, Rooks and Kings start with hasMoved = false for castling. | |
// If FEN indicates castling not possible, hasMoved should be true or castlingRights adjusted. | |
c++; | |
} | |
} | |
gameState.currentPlayer = parts[1] === "w" ? "white" : "black"; | |
const castlingFen = parts[2]; | |
gameState.castlingRights.white.K = castlingFen.includes("K"); | |
gameState.castlingRights.white.Q = castlingFen.includes("Q"); | |
gameState.castlingRights.black.K = castlingFen.includes("k"); | |
gameState.castlingRights.black.Q = castlingFen.includes("q"); | |
if (parts[3] !== "-") { | |
const epFile = parts[3][0].charCodeAt(0) - "a".charCodeAt(0); | |
const epRank = 8 - parseInt(parts[3][1]); | |
gameState.enPassantTarget = { r: epRank, c: epFile }; | |
} else { | |
gameState.enPassantTarget = null; | |
} | |
gameState.halfMoveClock = parseInt(parts[4]); | |
gameState.fullMoveNumber = parseInt(parts[5]); | |
// A more robust FEN parser would pre-set hasMoved for rooks/kings if castling rights are lost | |
// For now, rely on castlingRights primarily. hasMoved updates during gameplay. | |
if (!gameState.castlingRights.white.K) { | |
// If no K-side castling for white | |
if (gameState.board[7][4] && gameState.board[7][4].piece === "K") | |
gameState.board[7][4].hasMoved = true; | |
if (gameState.board[7][7] && gameState.board[7][7].piece === "R") | |
gameState.board[7][7].hasMoved = true; | |
} | |
if (!gameState.castlingRights.white.Q) { | |
if (gameState.board[7][4] && gameState.board[7][4].piece === "K") | |
gameState.board[7][4].hasMoved = true; | |
if (gameState.board[7][0] && gameState.board[7][0].piece === "R") | |
gameState.board[7][0].hasMoved = true; | |
} | |
// Similarly for black | |
if (!gameState.castlingRights.black.K) { | |
if (gameState.board[0][4] && gameState.board[0][4].piece === "k") | |
gameState.board[0][4].hasMoved = true; | |
if (gameState.board[0][7] && gameState.board[0][7].piece === "r") | |
gameState.board[0][7].hasMoved = true; | |
} | |
if (!gameState.castlingRights.black.Q) { | |
if (gameState.board[0][4] && gameState.board[0][4].piece === "k") | |
gameState.board[0][4].hasMoved = true; | |
if (gameState.board[0][0] && gameState.board[0][0].piece === "r") | |
gameState.board[0][0].hasMoved = true; | |
} | |
} | |
function createBoardHTML() { | |
boardContainer.innerHTML = ""; // Clear existing board | |
for (let r_disp = 0; r_disp < 8; r_disp++) { | |
for (let c_disp = 0; c_disp < 8; c_disp++) { | |
const square = document.createElement("div"); | |
square.classList.add("square"); | |
square.classList.add( | |
(r_disp + c_disp) % 2 === 0 ? "light" : "dark", | |
); | |
// Store visual coordinates. Mapping to internal will happen in click handler. | |
square.dataset.r_disp = r_disp; | |
square.dataset.c_disp = c_disp; | |
square.addEventListener("click", () => { | |
let r_internal, c_internal; | |
if (gameState.boardFlipped) { | |
r_internal = 7 - r_disp; | |
c_internal = 7 - c_disp; | |
} else { | |
r_internal = r_disp; | |
c_internal = c_disp; | |
} | |
handleSquareClick(r_internal, c_internal); | |
}); | |
boardContainer.appendChild(square); | |
} | |
} | |
} | |
function renderBoard() { | |
if (boardContainer.children.length === 0) { | |
createBoardHTML(); // Create squares if they don't exist | |
} | |
const squares = boardContainer.children; | |
for (let r_disp = 0; r_disp < 8; r_disp++) { | |
for (let c_disp = 0; c_disp < 8; c_disp++) { | |
const squareElement = squares[r_disp * 8 + c_disp]; | |
let r_internal, c_internal; | |
if (gameState.boardFlipped) { | |
r_internal = 7 - r_disp; | |
c_internal = 7 - c_disp; | |
} else { | |
r_internal = r_disp; | |
c_internal = c_disp; | |
} | |
const pieceData = gameState.board[r_internal][c_internal]; | |
squareElement.innerHTML = pieceData | |
? `<span class="piece">${PIECE_UNICODE[pieceData.piece]}</span>` | |
: ""; | |
// Clear previous highlights | |
squareElement.classList.remove( | |
"selected", | |
"possible-move", | |
"possible-capture", | |
"last-move-from", | |
"last-move-to", | |
); | |
// Highlight selected square | |
if ( | |
gameState.selectedSquare && | |
gameState.selectedSquare.r === r_internal && | |
gameState.selectedSquare.c === c_internal | |
) { | |
squareElement.classList.add("selected"); | |
} | |
// Highlight last move | |
if (gameState.lastMove) { | |
if ( | |
gameState.lastMove.fromR === r_internal && | |
gameState.lastMove.fromC === c_internal | |
) { | |
squareElement.classList.add("last-move-from"); | |
} | |
if ( | |
gameState.lastMove.toR === r_internal && | |
gameState.lastMove.toC === c_internal | |
) { | |
squareElement.classList.add("last-move-to"); | |
} | |
} | |
} | |
} | |
// Highlight possible moves (after clearing, as selected square is also a possible source) | |
if (gameState.selectedSquare) { | |
gameState.possibleMoves.forEach((move) => { | |
let r_disp_target, c_disp_target; | |
if (gameState.boardFlipped) { | |
r_disp_target = 7 - move.toR; | |
c_disp_target = 7 - move.toC; | |
} else { | |
r_disp_target = move.toR; | |
c_disp_target = move.toC; | |
} | |
const targetSquareElement = | |
squares[r_disp_target * 8 + c_disp_target]; | |
if (targetSquareElement) { | |
targetSquareElement.classList.add("possible-move"); | |
if (gameState.board[move.toR][move.toC] || move.isEnPassant) { | |
// If target has a piece or is en passant | |
targetSquareElement.classList.add("possible-capture"); | |
} | |
} | |
}); | |
} | |
} | |
function handleSquareClick(r, c) { | |
if (gameState.isGameOver) return; | |
const pieceData = gameState.board[r][c]; | |
if (gameState.selectedSquare) { | |
// A piece is already selected | |
const { r: fromR, c: fromC } = gameState.selectedSquare; | |
const legalMove = gameState.possibleMoves.find( | |
(m) => m.toR === r && m.toC === c, | |
); | |
if (legalMove) { | |
// Check for pawn promotion | |
const movingPiece = gameState.board[fromR][fromC].piece; | |
if (getPieceType(movingPiece) === "pawn" && (r === 0 || r === 7)) { | |
promptForPromotion((promotionPieceType) => { | |
makeMove(fromR, fromC, r, c, promotionPieceType); | |
}); | |
} else { | |
makeMove(fromR, fromC, r, c); | |
} | |
} else { | |
// Clicked on an invalid square or another piece | |
gameState.selectedSquare = null; | |
gameState.possibleMoves = []; | |
if ( | |
pieceData && | |
getPieceColor(pieceData.piece) === gameState.currentPlayer | |
) { | |
// Selected another piece of the current player | |
gameState.selectedSquare = { r, c }; | |
gameState.possibleMoves = getLegalMovesForPiece(r, c); | |
} | |
} | |
} else { | |
// No piece selected | |
if ( | |
pieceData && | |
getPieceColor(pieceData.piece) === gameState.currentPlayer | |
) { | |
gameState.selectedSquare = { r, c }; | |
gameState.possibleMoves = getLegalMovesForPiece(r, c); | |
} | |
} | |
renderBoard(); | |
} | |
function makeMove(fromR, fromC, toR, toC, promotionPiece = null) { | |
if (gameState.isGameOver) return; | |
const movingPieceData = JSON.parse( | |
JSON.stringify(gameState.board[fromR][fromC]), | |
); // Deep copy | |
const capturedPieceData = gameState.board[toR][toC] | |
? JSON.parse(JSON.stringify(gameState.board[toR][toC])) | |
: null; | |
let specialMoveInfo = ""; // For notation, e.g. en passant, castling | |
// Update halfMoveClock: reset on pawn move or capture, otherwise increment | |
if ( | |
getPieceType(movingPieceData.piece) === "pawn" || | |
capturedPieceData | |
) { | |
gameState.halfMoveClock = 0; | |
} else { | |
gameState.halfMoveClock++; | |
} | |
// Handle en passant capture | |
let isEnPassantCapture = false; | |
if ( | |
getPieceType(movingPieceData.piece) === "pawn" && | |
gameState.enPassantTarget && | |
toR === gameState.enPassantTarget.r && | |
toC === gameState.enPassantTarget.c | |
) { | |
const capturedPawnR = | |
gameState.currentPlayer === "white" ? toR + 1 : toR - 1; | |
gameState.board[capturedPawnR][toC] = null; // Remove captured pawn | |
isEnPassantCapture = true; | |
specialMoveInfo = " e.p."; | |
} | |
// Update board | |
gameState.board[toR][toC] = movingPieceData; | |
gameState.board[fromR][fromC] = null; | |
movingPieceData.hasMoved = true; | |
// Handle promotion | |
if (promotionPiece) { | |
gameState.board[toR][toC].piece = | |
gameState.currentPlayer === "white" | |
? promotionPiece.toUpperCase() | |
: promotionPiece.toLowerCase(); | |
specialMoveInfo = "=" + promotionPiece.toUpperCase(); | |
} | |
// Handle castling | |
let castlingSide = null; | |
if (getPieceType(movingPieceData.piece) === "king") { | |
if (Math.abs(fromC - toC) === 2) { | |
// King moved 2 squares: castling | |
let rookFromC, rookToC; | |
if (toC === 6) { | |
// Kingside (O-O) | |
rookFromC = 7; | |
rookToC = 5; | |
castlingSide = "K"; | |
} else { | |
// Queenside (O-O-O) (toC === 2) | |
rookFromC = 0; | |
rookToC = 3; | |
castlingSide = "Q"; | |
} | |
const rook = gameState.board[fromR][rookFromC]; | |
gameState.board[fromR][rookToC] = rook; | |
gameState.board[fromR][rookFromC] = null; | |
if (rook) rook.hasMoved = true; | |
} | |
} | |
// Update castling rights | |
if (movingPieceData.piece === "K") | |
gameState.castlingRights.white = { K: false, Q: false }; | |
if (movingPieceData.piece === "k") | |
gameState.castlingRights.black = { K: false, Q: false }; | |
if (movingPieceData.piece === "R") { | |
if (fromR === 7 && fromC === 0) | |
gameState.castlingRights.white.Q = false; | |
if (fromR === 7 && fromC === 7) | |
gameState.castlingRights.white.K = false; | |
} | |
if (movingPieceData.piece === "r") { | |
if (fromR === 0 && fromC === 0) | |
gameState.castlingRights.black.Q = false; | |
if (fromR === 0 && fromC === 7) | |
gameState.castlingRights.black.K = false; | |
} | |
// If a rook is captured, castling rights might be lost | |
if (capturedPieceData) { | |
if (toR === 7 && toC === 0 && capturedPieceData.piece === "R") | |
gameState.castlingRights.white.Q = false; | |
if (toR === 7 && toC === 7 && capturedPieceData.piece === "R") | |
gameState.castlingRights.white.K = false; | |
if (toR === 0 && toC === 0 && capturedPieceData.piece === "r") | |
gameState.castlingRights.black.Q = false; | |
if (toR === 0 && toC === 7 && capturedPieceData.piece === "r") | |
gameState.castlingRights.black.K = false; | |
} | |
// Set new en passant target if pawn moved two squares | |
if ( | |
getPieceType(movingPieceData.piece) === "pawn" && | |
Math.abs(fromR - toR) === 2 | |
) { | |
gameState.enPassantTarget = { r: (fromR + toR) / 2, c: fromC }; | |
} else { | |
gameState.enPassantTarget = null; | |
} | |
// Store last move for highlighting | |
gameState.lastMove = { fromR, fromC, toR, toC }; | |
// Switch player | |
const prevPlayer = gameState.currentPlayer; | |
gameState.currentPlayer = | |
gameState.currentPlayer === "white" ? "black" : "white"; | |
if (prevPlayer === "black") { | |
// If black just moved, increment fullmove number | |
gameState.fullMoveNumber++; | |
} | |
// Clear selection | |
gameState.selectedSquare = null; | |
gameState.possibleMoves = []; | |
// Generate notation (must be done BEFORE check/checkmate status for current player) | |
const notation = generateSAN( | |
movingPieceData, | |
fromR, | |
fromC, | |
toR, | |
toC, | |
capturedPieceData, | |
promotionPiece, | |
castlingSide, | |
isEnPassantCapture, | |
); | |
// Check for check, checkmate, stalemate | |
const opponentColor = gameState.currentPlayer; | |
const legalMovesForOpponent = getAllLegalMoves(opponentColor); | |
const kingInCheck = isKingInCheck(opponentColor, gameState.board); | |
let finalNotation = notation; | |
if (kingInCheck) { | |
if (legalMovesForOpponent.length === 0) { | |
gameState.isGameOver = true; | |
gameState.gameStatusMessage = `Checkmate! ${prevPlayer.charAt(0).toUpperCase() + prevPlayer.slice(1)} wins.`; | |
finalNotation += "#"; | |
} else { | |
gameState.gameStatusMessage = `${opponentColor.charAt(0).toUpperCase() + opponentColor.slice(1)} is in Check!`; | |
finalNotation += "+"; | |
} | |
} else { | |
if (legalMovesForOpponent.length === 0) { | |
gameState.isGameOver = true; | |
gameState.gameStatusMessage = "Stalemate! It's a draw."; | |
} else { | |
gameState.gameStatusMessage = `${opponentColor.charAt(0).toUpperCase() + opponentColor.slice(1)}'s turn`; | |
} | |
} | |
// 50-move rule (basic check) | |
if (gameState.halfMoveClock >= 100) { | |
// 50 moves by each player | |
gameState.isGameOver = true; | |
gameState.gameStatusMessage = "Draw by 50-move rule."; | |
} | |
// Threefold repetition (not implemented - requires storing board states) | |
// Insufficient material (not implemented) | |
addMoveToLog(finalNotation, prevPlayer); | |
if (gameState.isGameOver) { | |
stopTimers(); | |
} else { | |
switchTimers(); | |
} | |
gameStatusDisplay.textContent = gameState.gameStatusMessage; | |
renderBoard(); // Re-render to show new state and remove highlights | |
} | |
function toAlgebraic(r, c) { | |
return String.fromCharCode("a".charCodeAt(0) + c) + (8 - r); | |
} | |
function generateSAN( | |
pieceData, | |
fromR, | |
fromC, | |
toR, | |
toC, | |
capturedPiece, | |
promotionPieceType, | |
castlingSide, | |
isEnPassant, | |
) { | |
const pieceType = getPieceType(pieceData.piece); | |
let san = ""; | |
if (castlingSide === "K") return "O-O"; | |
if (castlingSide === "Q") return "O-O-O"; | |
if (pieceType !== "pawn") { | |
san += pieceData.piece.toUpperCase(); | |
} | |
// Disambiguation (simplified: only if needed for same piece type) | |
// Full disambiguation is complex, requires checking all other pieces of same type | |
// For now, we will skip full disambiguation, but basic file/rank can be added if needed. | |
// This minimal version will use file of departure for pawn captures. | |
if (pieceType === "pawn" && (capturedPiece || isEnPassant)) { | |
san += String.fromCharCode("a".charCodeAt(0) + fromC); | |
} | |
if (capturedPiece || isEnPassant) { | |
san += "x"; | |
} | |
san += toAlgebraic(toR, toC); | |
if (promotionPieceType) { | |
san += "=" + promotionPieceType.toUpperCase(); | |
} | |
// Check and checkmate symbols (+, #) are added *after* this function, | |
// once the state of the opponent's king is known. | |
return san; | |
} | |
function addMoveToLog(notation, player) { | |
if (player === "white") { | |
const li = document.createElement("li"); | |
const moveNumSpan = document.createElement("span"); | |
moveNumSpan.className = "move-number"; | |
moveNumSpan.textContent = gameState.fullMoveNumber + "."; | |
const whiteMoveSpan = document.createElement("span"); | |
whiteMoveSpan.className = "move-white"; | |
whiteMoveSpan.textContent = notation; | |
li.appendChild(moveNumSpan); | |
li.appendChild(whiteMoveSpan); | |
gameState.moveHistory.push(li); // Store the li element itself | |
moveLogDisplay.appendChild(li); | |
} else { | |
// Black's move | |
const lastLi = | |
gameState.moveHistory[gameState.moveHistory.length - 1]; | |
if (lastLi) { | |
const blackMoveSpan = document.createElement("span"); | |
blackMoveSpan.className = "move-black"; | |
blackMoveSpan.textContent = notation; | |
lastLi.appendChild(blackMoveSpan); | |
} | |
} | |
moveLogDisplay.scrollTop = moveLogDisplay.scrollHeight; // Auto-scroll | |
} | |
function promptForPromotion(callback) { | |
gameState.promotionChoiceCallback = callback; | |
promotionModal.style.display = "flex"; | |
// Set correct piece characters for current player | |
const buttons = promotionModal.querySelectorAll("button[data-piece]"); | |
buttons.forEach((button) => { | |
const pieceType = button.dataset.piece; | |
button.textContent = | |
PIECE_UNICODE[ | |
gameState.currentPlayer === "white" | |
? pieceType.toUpperCase() | |
: pieceType.toLowerCase() | |
]; | |
}); | |
} | |
promotionModal | |
.querySelectorAll("button[data-piece]") | |
.forEach((button) => { | |
button.addEventListener("click", () => { | |
if (gameState.promotionChoiceCallback) { | |
gameState.promotionChoiceCallback(button.dataset.piece); | |
} | |
promotionModal.style.display = "none"; | |
gameState.promotionChoiceCallback = null; | |
}); | |
}); | |
// --- Legal Move Generation --- | |
function getLegalMovesForPiece(r, c) { | |
const piece = gameState.board[r][c]; | |
if (!piece) return []; | |
const pseudoLegalMoves = getPseudoLegalMoves( | |
r, | |
c, | |
piece.piece, | |
gameState.board, | |
gameState.castlingRights, | |
gameState.enPassantTarget, | |
piece.hasMoved, | |
); | |
const legalMoves = []; | |
for (const move of pseudoLegalMoves) { | |
// Simulate the move | |
const tempBoard = JSON.parse(JSON.stringify(gameState.board)); // Deep clone | |
const movingPieceTemp = JSON.parse( | |
JSON.stringify(tempBoard[move.fromR][move.fromC]), | |
); | |
tempBoard[move.toR][move.toC] = movingPieceTemp; | |
tempBoard[move.fromR][move.fromC] = null; | |
// Handle en passant capture in simulation | |
if (move.isEnPassant) { | |
const capturedPawnR = | |
getPieceColor(movingPieceTemp.piece) === "white" | |
? move.toR + 1 | |
: move.toR - 1; | |
tempBoard[capturedPawnR][move.toC] = null; | |
} | |
// Handle castling in simulation | |
if (move.isCastling) { | |
let rookFromC, rookToC; | |
if (move.castlingSide === "K") { | |
rookFromC = 7; | |
rookToC = 5; | |
} else { | |
rookFromC = 0; | |
rookToC = 3; | |
} | |
tempBoard[move.fromR][rookToC] = tempBoard[move.fromR][rookFromC]; | |
tempBoard[move.fromR][rookFromC] = null; | |
} | |
if (!isKingInCheck(getPieceColor(piece.piece), tempBoard)) { | |
legalMoves.push(move); | |
} | |
} | |
return legalMoves; | |
} | |
function getAllLegalMoves(playerColor) { | |
let allMoves = []; | |
for (let r = 0; r < 8; r++) { | |
for (let c = 0; c < 8; c++) { | |
const pieceData = gameState.board[r][c]; | |
if (pieceData && getPieceColor(pieceData.piece) === playerColor) { | |
const moves = getLegalMovesForPiece(r, c); | |
allMoves.push(...moves); | |
} | |
} | |
} | |
return allMoves; | |
} | |
function getPseudoLegalMoves( | |
r, | |
c, | |
pieceChar, | |
board, | |
castlingRights, | |
enPassantTarget, | |
hasMoved, | |
) { | |
const moves = []; | |
const color = getPieceColor(pieceChar); | |
const type = getPieceType(pieceChar); | |
const addMove = (toR, toC, options = {}) => { | |
if (toR >= 0 && toR < 8 && toC >= 0 && toC < 8) { | |
const targetPiece = board[toR][toC]; | |
if (!targetPiece || getPieceColor(targetPiece.piece) !== color) { | |
// Can move to empty or capture opponent | |
moves.push({ | |
fromR: r, | |
fromC: c, | |
toR, | |
toC, | |
piece: pieceChar, | |
...options, | |
}); | |
} | |
return !targetPiece; // Return true if square was empty (can continue sliding) | |
} | |
return false; // Off board | |
}; | |
const addCaptureOnly = (toR, toC, options = {}) => { | |
// For pawn captures | |
if (toR >= 0 && toR < 8 && toC >= 0 && toC < 8) { | |
const targetPiece = board[toR][toC]; | |
if (targetPiece && getPieceColor(targetPiece.piece) !== color) { | |
moves.push({ | |
fromR: r, | |
fromC: c, | |
toR, | |
toC, | |
piece: pieceChar, | |
...options, | |
}); | |
} else if (options.isEnPassant) { | |
// En passant target square is empty but represents a capture | |
moves.push({ | |
fromR: r, | |
fromC: c, | |
toR, | |
toC, | |
piece: pieceChar, | |
...options, | |
}); | |
} | |
} | |
}; | |
if (type === "pawn") { | |
const dir = color === "white" ? -1 : 1; | |
const startRow = color === "white" ? 6 : 1; | |
// Forward 1 square | |
if (r + dir >= 0 && r + dir < 8 && !board[r + dir][c]) { | |
addMove(r + dir, c); | |
// Forward 2 squares (from start) | |
if (r === startRow && !board[r + 2 * dir][c]) { | |
addMove(r + 2 * dir, c); | |
} | |
} | |
// Captures | |
addCaptureOnly(r + dir, c - 1); | |
addCaptureOnly(r + dir, c + 1); | |
// En Passant | |
if (enPassantTarget && enPassantTarget.r === r + dir) { | |
if (enPassantTarget.c === c - 1) | |
addCaptureOnly(r + dir, c - 1, { isEnPassant: true }); | |
if (enPassantTarget.c === c + 1) | |
addCaptureOnly(r + dir, c + 1, { isEnPassant: true }); | |
} | |
} | |
if (type === "knight") { | |
const knightMoves = [ | |
[-2, -1], | |
[-2, 1], | |
[-1, -2], | |
[-1, 2], | |
[1, -2], | |
[1, 2], | |
[2, -1], | |
[2, 1], | |
]; | |
knightMoves.forEach(([dr, dc]) => addMove(r + dr, c + dc)); | |
} | |
const slide = (dr, dc) => { | |
for (let i = 1; i < 8; i++) { | |
if (!addMove(r + i * dr, c + i * dc)) break; | |
} | |
}; | |
if (type === "rook" || type === "queen") { | |
slide(1, 0); | |
slide(-1, 0); | |
slide(0, 1); | |
slide(0, -1); | |
} | |
if (type === "bishop" || type === "queen") { | |
slide(1, 1); | |
slide(1, -1); | |
slide(-1, 1); | |
slide(-1, -1); | |
} | |
if (type === "king") { | |
const kingMoves = [ | |
[-1, -1], | |
[-1, 0], | |
[-1, 1], | |
[0, -1], | |
[0, 1], | |
[1, -1], | |
[1, 0], | |
[1, 1], | |
]; | |
kingMoves.forEach(([dr, dc]) => addMove(r + dr, c + dc)); | |
// Castling | |
if (!hasMoved) { | |
// Kingside | |
if ( | |
castlingRights[color].K && | |
!board[r][c + 1] && | |
!board[r][c + 2] && | |
!isSquareAttacked( | |
r, | |
c, | |
color === "white" ? "black" : "white", | |
board, | |
) && | |
!isSquareAttacked( | |
r, | |
c + 1, | |
color === "white" ? "black" : "white", | |
board, | |
) && | |
!isSquareAttacked( | |
r, | |
c + 2, | |
color === "white" ? "black" : "white", | |
board, | |
) && | |
board[r][c + 3] && | |
getPieceType(board[r][c + 3].piece) === "rook" && | |
!board[r][c + 3].hasMoved | |
) { | |
addMove(r, c + 2, { isCastling: true, castlingSide: "K" }); | |
} | |
// Queenside | |
if ( | |
castlingRights[color].Q && | |
!board[r][c - 1] && | |
!board[r][c - 2] && | |
!board[r][c - 3] && | |
!isSquareAttacked( | |
r, | |
c, | |
color === "white" ? "black" : "white", | |
board, | |
) && | |
!isSquareAttacked( | |
r, | |
c - 1, | |
color === "white" ? "black" : "white", | |
board, | |
) && | |
!isSquareAttacked( | |
r, | |
c - 2, | |
color === "white" ? "black" : "white", | |
board, | |
) && | |
board[r][c - 4] && | |
getPieceType(board[r][c - 4].piece) === "rook" && | |
!board[r][c - 4].hasMoved | |
) { | |
addMove(r, c - 2, { isCastling: true, castlingSide: "Q" }); | |
} | |
} | |
} | |
return moves; | |
} | |
function isSquareAttacked(r, c, attackerColor, currentBoard) { | |
for (let i = 0; i < 8; i++) { | |
for (let j = 0; j < 8; j++) { | |
const pieceData = currentBoard[i][j]; | |
if (pieceData && getPieceColor(pieceData.piece) === attackerColor) { | |
// Pass dummy castling rights and en passant target, as they don't affect attack maps for non-king pieces | |
// Pass hasMoved as pieceData.hasMoved - relevant for king potentially. | |
const pseudoMoves = getPseudoLegalMoves( | |
i, | |
j, | |
pieceData.piece, | |
currentBoard, | |
{}, | |
null, | |
pieceData.hasMoved, | |
); | |
for (const move of pseudoMoves) { | |
// For pawns, attack squares are different from move squares (except captures) | |
// The getPseudoLegalMoves already generates captures to the correct squares. | |
// A pawn at i,j attacks diagonally forward squares. | |
if (getPieceType(pieceData.piece) === "pawn") { | |
const dir = attackerColor === "white" ? -1 : 1; | |
if ( | |
(i + dir === r && j + 1 === c) || | |
(i + dir === r && j - 1 === c) | |
) { | |
return true; | |
} | |
} else if (move.toR === r && move.toC === c) { | |
return true; | |
} | |
} | |
} | |
} | |
} | |
return false; | |
} | |
function findKing(kingColor, currentBoard) { | |
const kingChar = kingColor === "white" ? "K" : "k"; | |
for (let r = 0; r < 8; r++) { | |
for (let c = 0; c < 8; c++) { | |
if (currentBoard[r][c] && currentBoard[r][c].piece === kingChar) { | |
return { r, c }; | |
} | |
} | |
} | |
return null; // Should not happen in a valid game | |
} | |
function isKingInCheck(kingColor, currentBoard) { | |
const kingPos = findKing(kingColor, currentBoard); | |
if (!kingPos) return false; // Should not happen | |
const attackerColor = kingColor === "white" ? "black" : "white"; | |
return isSquareAttacked( | |
kingPos.r, | |
kingPos.c, | |
attackerColor, | |
currentBoard, | |
); | |
} | |
// --- Timers --- | |
function formatTime(seconds) { | |
const mins = Math.floor(seconds / 60); | |
const secs = seconds % 60; | |
return `${mins}:${secs < 10 ? "0" : ""}${secs}`; | |
} | |
function updateTimerDisplay() { | |
whiteTimerDisplay.textContent = `White: ${formatTime(gameState.timers.white)}`; | |
blackTimerDisplay.textContent = `Black: ${formatTime(gameState.timers.black)}`; | |
whiteTimerDisplay.classList.remove("active-timer"); | |
blackTimerDisplay.classList.remove("active-timer"); | |
if (gameState.currentPlayer === "white" && !gameState.isGameOver) { | |
whiteTimerDisplay.classList.add("active-timer"); | |
} else if ( | |
gameState.currentPlayer === "black" && | |
!gameState.isGameOver | |
) { | |
blackTimerDisplay.classList.add("active-timer"); | |
} | |
} | |
function startTimers() { | |
stopTimers(); // Clear any existing timer | |
gameState.timers.intervalId = setInterval(() => { | |
if (gameState.isGameOver) { | |
stopTimers(); | |
return; | |
} | |
if (gameState.currentPlayer === "white") { | |
gameState.timers.white--; | |
if (gameState.timers.white <= 0) handleTimeout("white"); | |
} else { | |
gameState.timers.black--; | |
if (gameState.timers.black <= 0) handleTimeout("black"); | |
} | |
updateTimerDisplay(); | |
}, 1000); | |
} | |
function stopTimers() { | |
if (gameState.timers.intervalId) { | |
clearInterval(gameState.timers.intervalId); | |
gameState.timers.intervalId = null; | |
} | |
} | |
function switchTimers() { | |
// The timer logic simply decrements the current player's time. | |
// updateTimerDisplay handles showing which is active. | |
// No specific switch action needed beyond changing gameState.currentPlayer. | |
updateTimerDisplay(); | |
} | |
function handleTimeout(playerColor) { | |
stopTimers(); | |
gameState.isGameOver = true; | |
const winner = playerColor === "white" ? "Black" : "White"; | |
gameState.gameStatusMessage = `Time out! ${winner} wins.`; | |
gameStatusDisplay.textContent = gameState.gameStatusMessage; | |
updateTimerDisplay(); // Final update | |
} | |
// --- Event Listeners --- | |
restartButton.addEventListener("click", () => { | |
stopTimers(); | |
initGame(); | |
}); | |
flipBoardButton.addEventListener("click", () => { | |
gameState.boardFlipped = !gameState.boardFlipped; | |
// If a piece is selected, its possible moves are calculated based on internal coords. | |
// Re-rendering will map them to the new visual coords. | |
renderBoard(); | |
}); | |
// --- Initialize Game --- | |
initGame(); | |
</script> | |
</body> | |
</html> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<!doctype html> | |
<html lang="en"> | |
<head> | |
<meta charset="UTF-8" /> | |
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> | |
<title>Chess Game</title> | |
<style> | |
:root { | |
--board-bg: #f0d9b5; | |
--light-square: #f0d9b5; | |
--dark-square: #b58863; | |
--highlight-selected: rgba(80, 145, 255, 0.5); | |
--highlight-possible: rgba(130, 180, 90, 0.7); | |
--highlight-last-move: rgba(204, 204, 0, 0.4); | |
--piece-font-size: 36px; /* Base size for 60px squares */ | |
--ui-bg: #f0f0f0; | |
--button-bg: #4caf50; | |
--button-text: white; | |
} | |
body { | |
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif; | |
display: flex; | |
justify-content: center; | |
align-items: flex-start; | |
min-height: 100vh; | |
background-color: #333; | |
margin: 0; | |
padding: 20px; | |
box-sizing: border-box; | |
} | |
#game-container { | |
display: flex; | |
flex-wrap: wrap; | |
gap: 20px; | |
align-items: flex-start; | |
} | |
#board-container { | |
display: grid; | |
grid-template-columns: repeat(8, 1fr); | |
grid-template-rows: repeat(8, 1fr); | |
width: 480px; | |
height: 480px; | |
border: 2px solid #333; | |
box-shadow: 0 0 15px rgba(0, 0, 0, 0.5); | |
} | |
.square { | |
/* Width/Height will be set by grid system based on board-container size */ | |
display: flex; | |
justify-content: center; | |
align-items: center; | |
font-size: var(--piece-font-size); | |
cursor: pointer; | |
position: relative; | |
} | |
.square.light { | |
background-color: var(--light-square); | |
} | |
.square.dark { | |
background-color: var(--dark-square); | |
} | |
.square .piece { | |
user-select: none; | |
transform: Sscale( | |
1 | |
); /* Helps with rendering consistency in some browsers */ | |
} | |
.square.selected::before { | |
content: ""; | |
position: absolute; | |
top: 0; | |
left: 0; | |
right: 0; | |
bottom: 0; | |
background-color: var(--highlight-selected); | |
z-index: 1; | |
} | |
.square.possible-move::after { | |
content: ""; | |
position: absolute; | |
top: 50%; | |
left: 50%; | |
width: 30%; | |
height: 30%; /* Relative to square size */ | |
border-radius: 50%; | |
background-color: var(--highlight-possible); | |
transform: translate(-50%, -50%); | |
z-index: 2; | |
} | |
.square.possible-capture::after { | |
background-color: rgba(255, 80, 80, 0.6); | |
} | |
.square.last-move-from::before, | |
.square.last-move-to::before { | |
content: ""; | |
position: absolute; | |
top: 0; | |
left: 0; | |
right: 0; | |
bottom: 0; | |
background-color: var(--highlight-last-move); | |
z-index: 0; | |
} | |
#ui-panel { | |
width: 250px; | |
background-color: var(--ui-bg); | |
padding: 15px; | |
border-radius: 8px; | |
box-shadow: 0 0 10px rgba(0, 0, 0, 0.3); | |
display: flex; | |
flex-direction: column; | |
gap: 15px; | |
} | |
#timers { | |
display: flex; | |
justify-content: space-between; | |
font-size: 1.1em; | |
font-weight: bold; | |
} | |
#timers .active-timer { | |
color: var(--button-bg); | |
text-decoration: underline; | |
} | |
#move-log-container { | |
background-color: white; | |
border: 1px solid #ccc; | |
border-radius: 4px; | |
padding: 10px; | |
height: 200px; | |
overflow-y: auto; | |
} | |
#move-log-container h3 { | |
margin-top: 0; | |
} | |
#move-log { | |
list-style-type: none; | |
padding-left: 0; | |
margin: 0; | |
font-size: 0.9em; | |
} | |
#move-log li { | |
padding: 2px 0; | |
display: flex; | |
} | |
#move-log li span.move-number { | |
min-width: 25px; | |
color: #555; | |
} | |
#move-log li span.move-white { | |
min-width: 60px; | |
} | |
#move-log li span.move-black { | |
min-width: 60px; | |
} | |
#game-status { | |
font-size: 1.2em; | |
font-weight: bold; | |
color: #d32f2f; | |
min-height: 1.5em; | |
text-align: center; | |
} | |
button { | |
background-color: var(--button-bg); | |
color: var(--button-text); | |
border: none; | |
padding: 10px 15px; | |
border-radius: 4px; | |
cursor: pointer; | |
font-size: 1em; | |
transition: background-color 0.2s; | |
} | |
button:hover { | |
background-color: #3e8e41; | |
} | |
#promotion-modal { | |
position: fixed; | |
top: 50%; | |
left: 50%; | |
transform: translate(-50%, -50%); | |
background-color: white; | |
padding: 20px; | |
border: 2px solid #555; | |
border-radius: 8px; | |
box-shadow: 0 0 20px rgba(0, 0, 0, 0.5); | |
z-index: 1000; | |
display: flex; | |
flex-direction: column; /* Stack label and buttons vertically */ | |
align-items: center; /* Center items */ | |
gap: 10px; | |
} | |
#promotion-modal p { | |
margin: 0 0 10px 0; /* Add some margin below the text */ | |
font-weight: bold; | |
} | |
#promotion-modal div.promo-buttons { | |
/* Container for buttons */ | |
display: flex; | |
gap: 10px; | |
} | |
#promotion-modal button { | |
font-size: var(--piece-font-size); | |
padding: 5px; | |
min-width: 50px; | |
background: #eee; | |
color: #333; | |
} | |
#promotion-modal button:hover { | |
background: #ddd; | |
} | |
/* Responsive adjustments */ | |
@media (max-width: 800px) { | |
body { | |
align-items: center; | |
flex-direction: column; | |
} | |
#game-container { | |
flex-direction: column; | |
align-items: center; | |
} | |
#ui-panel { | |
width: 90%; | |
max-width: 480px; | |
} | |
#board-container { | |
width: 90vw; | |
height: 90vw; | |
max-width: 480px; | |
max-height: 480px; | |
} | |
/* Adjust piece font size based on board container width */ | |
:root { | |
/* Redefine for this media query context */ | |
--piece-font-size-dynamic: calc( | |
min(90vw, 480px) / 8 * 0.6 | |
); /* (Square Size) * (Scaling Factor) */ | |
} | |
.square { | |
font-size: var(--piece-font-size-dynamic); | |
} | |
#promotion-modal button { | |
font-size: var(--piece-font-size-dynamic); | |
} | |
} | |
@media (max-width: 400px) { | |
:root { | |
--piece-font-size-dynamic: calc(min(90vw, 480px) / 8 * 0.5); | |
} | |
.square { | |
font-size: var(--piece-font-size-dynamic); | |
} | |
#promotion-modal button { | |
font-size: var(--piece-font-size-dynamic); | |
} | |
#timers { | |
font-size: 1em; | |
} | |
} | |
</style> | |
</head> | |
<body> | |
<div id="game-container"> | |
<div id="board-container"></div> | |
<div id="ui-panel"> | |
<div id="timers"> | |
<div id="white-timer">White: 5:00</div> | |
<div id="black-timer">Black: 5:00</div> | |
</div> | |
<div id="move-log-container"> | |
<h3>Move Log</h3> | |
<ol id="move-log"></ol> | |
</div> | |
<div id="game-status"></div> | |
<button id="restart-button">Restart Game</button> | |
<button id="flip-board-button">Flip Board</button> | |
</div> | |
</div> | |
<div id="promotion-modal" style="display: none"> | |
<p>Promote pawn to:</p> | |
<div class="promo-buttons"> | |
<button data-piece="Q">♕</button> | |
<button data-piece="R">♖</button> | |
<button data-piece="B">♗</button> | |
<button data-piece="N">♘</button> | |
</div> | |
</div> | |
<script> | |
const boardContainer = document.getElementById("board-container"); | |
const whiteTimerDisplay = document.getElementById("white-timer"); | |
const blackTimerDisplay = document.getElementById("black-timer"); | |
const moveLogDisplay = document.getElementById("move-log"); | |
const gameStatusDisplay = document.getElementById("game-status"); | |
const restartButton = document.getElementById("restart-button"); | |
const flipBoardButton = document.getElementById("flip-board-button"); | |
const promotionModal = document.getElementById("promotion-modal"); | |
const PIECE_UNICODE = { | |
P: "♙", | |
R: "♖", | |
N: "♘", | |
B: "♗", | |
Q: "♕", | |
K: "♔", | |
p: "♟", | |
r: "♜", | |
n: "♞", | |
b: "♝", | |
q: "♛", | |
k: "♚", | |
}; | |
const INITIAL_BOARD_FEN = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR"; | |
let gameState = {}; | |
function getPieceColor(pieceChar) { | |
if (!pieceChar) return null; | |
return pieceChar === pieceChar.toUpperCase() ? "white" : "black"; | |
} | |
function getPieceType(pieceChar) { | |
if (!pieceChar) return null; | |
const lower = pieceChar.toLowerCase(); | |
if (lower === "p") return "pawn"; | |
if (lower === "r") return "rook"; | |
if (lower === "n") return "knight"; | |
if (lower === "b") return "bishop"; | |
if (lower === "q") return "queen"; | |
if (lower === "k") return "king"; | |
return null; | |
} | |
function initGame() { | |
gameState = { | |
board: Array(8) | |
.fill(null) | |
.map(() => Array(8).fill(null)), | |
currentPlayer: "white", | |
isGameOver: false, | |
gameStatusMessage: "", | |
selectedSquare: null, | |
possibleMoves: [], | |
lastMove: null, | |
enPassantTarget: null, | |
castlingRights: { | |
white: { K: true, Q: true }, | |
black: { K: true, Q: true }, | |
}, | |
moveHistory: [], | |
timers: { | |
white: 300, | |
black: 300, | |
intervalId: null, | |
initialTime: 300, | |
}, | |
boardFlipped: false, | |
promotionChoiceCallback: null, | |
halfMoveClock: 0, | |
fullMoveNumber: 1, | |
}; | |
setupBoardFromFen(INITIAL_BOARD_FEN + " w KQkq - 0 1"); | |
renderBoard(); | |
updateTimerDisplay(); | |
if (gameState.timers.intervalId) | |
clearInterval(gameState.timers.intervalId); // Clear before starting | |
startTimers(); | |
gameStatusDisplay.textContent = "White's turn"; | |
moveLogDisplay.innerHTML = ""; | |
} | |
function setupBoardFromFen(fen) { | |
const parts = fen.split(" "); | |
const piecePlacement = parts[0]; | |
let r = 0, | |
c = 0; | |
for (const char of piecePlacement) { | |
if (char === "/") { | |
r++; | |
c = 0; | |
} else if (/\d/.test(char)) { | |
c += parseInt(char); | |
} else { | |
gameState.board[r][c] = { piece: char, hasMoved: false }; | |
c++; | |
} | |
} | |
gameState.currentPlayer = parts[1] === "w" ? "white" : "black"; | |
const castlingFen = parts[2]; | |
gameState.castlingRights.white.K = castlingFen.includes("K"); | |
gameState.castlingRights.white.Q = castlingFen.includes("Q"); | |
gameState.castlingRights.black.K = castlingFen.includes("k"); | |
gameState.castlingRights.black.Q = castlingFen.includes("q"); | |
if (parts[3] !== "-") { | |
const epFile = parts[3][0].charCodeAt(0) - "a".charCodeAt(0); | |
const epRank = 8 - parseInt(parts[3][1]); | |
gameState.enPassantTarget = { r: epRank, c: epFile }; | |
} else { | |
gameState.enPassantTarget = null; | |
} | |
gameState.halfMoveClock = parseInt(parts[4]); | |
gameState.fullMoveNumber = parseInt(parts[5]); | |
if (!gameState.castlingRights.white.K) { | |
if (gameState.board[7][4] && gameState.board[7][4].piece === "K") | |
gameState.board[7][4].hasMoved = true; | |
if (gameState.board[7][7] && gameState.board[7][7].piece === "R") | |
gameState.board[7][7].hasMoved = true; | |
} | |
if (!gameState.castlingRights.white.Q) { | |
if (gameState.board[7][4] && gameState.board[7][4].piece === "K") | |
gameState.board[7][4].hasMoved = true; | |
if (gameState.board[7][0] && gameState.board[7][0].piece === "R") | |
gameState.board[7][0].hasMoved = true; | |
} | |
if (!gameState.castlingRights.black.K) { | |
if (gameState.board[0][4] && gameState.board[0][4].piece === "k") | |
gameState.board[0][4].hasMoved = true; | |
if (gameState.board[0][7] && gameState.board[0][7].piece === "r") | |
gameState.board[0][7].hasMoved = true; | |
} | |
if (!gameState.castlingRights.black.Q) { | |
if (gameState.board[0][4] && gameState.board[0][4].piece === "k") | |
gameState.board[0][4].hasMoved = true; | |
if (gameState.board[0][0] && gameState.board[0][0].piece === "r") | |
gameState.board[0][0].hasMoved = true; | |
} | |
} | |
function createBoardHTML() { | |
boardContainer.innerHTML = ""; | |
for (let r_disp = 0; r_disp < 8; r_disp++) { | |
for (let c_disp = 0; c_disp < 8; c_disp++) { | |
const square = document.createElement("div"); | |
square.classList.add("square"); | |
square.classList.add( | |
(r_disp + c_disp) % 2 === 0 ? "light" : "dark", | |
); | |
square.dataset.r_disp = r_disp; // Store visual coords | |
square.dataset.c_disp = c_disp; | |
square.addEventListener("click", () => { | |
let r_internal, c_internal; | |
// Get current visual coords from dataset as r_disp/c_disp might be stale from closure | |
const clicked_r_disp = parseInt(square.dataset.r_disp); | |
const clicked_c_disp = parseInt(square.dataset.c_disp); | |
if (gameState.boardFlipped) { | |
r_internal = 7 - clicked_r_disp; | |
c_internal = 7 - clicked_c_disp; | |
} else { | |
r_internal = clicked_r_disp; | |
c_internal = clicked_c_disp; | |
} | |
handleSquareClick(r_internal, c_internal); | |
}); | |
boardContainer.appendChild(square); | |
} | |
} | |
} | |
function renderBoard() { | |
if (boardContainer.children.length === 0) { | |
createBoardHTML(); | |
} | |
const squares = boardContainer.children; | |
for (let r_disp_iter = 0; r_disp_iter < 8; r_disp_iter++) { | |
for (let c_disp_iter = 0; c_disp_iter < 8; c_disp_iter++) { | |
// The square element's position in the `squares` HTMLCollection | |
// is fixed once created. So, its visual (display) row/col is fixed. | |
const squareElement = squares[r_disp_iter * 8 + c_disp_iter]; | |
let r_internal, c_internal; | |
// Determine internal board coordinates based on whether board is flipped | |
if (gameState.boardFlipped) { | |
r_internal = 7 - r_disp_iter; | |
c_internal = 7 - c_disp_iter; | |
} else { | |
r_internal = r_disp_iter; | |
c_internal = c_disp_iter; | |
} | |
const pieceData = gameState.board[r_internal][c_internal]; | |
squareElement.innerHTML = pieceData | |
? `<span class="piece">${PIECE_UNICODE[pieceData.piece]}</span>` | |
: ""; | |
squareElement.classList.remove( | |
"selected", | |
"possible-move", | |
"possible-capture", | |
"last-move-from", | |
"last-move-to", | |
); | |
if ( | |
gameState.selectedSquare && | |
gameState.selectedSquare.r === r_internal && | |
gameState.selectedSquare.c === c_internal | |
) { | |
squareElement.classList.add("selected"); | |
} | |
if (gameState.lastMove) { | |
if ( | |
gameState.lastMove.fromR === r_internal && | |
gameState.lastMove.fromC === c_internal | |
) { | |
squareElement.classList.add("last-move-from"); | |
} | |
if ( | |
gameState.lastMove.toR === r_internal && | |
gameState.lastMove.toC === c_internal | |
) { | |
squareElement.classList.add("last-move-to"); | |
} | |
} | |
} | |
} | |
if (gameState.selectedSquare) { | |
gameState.possibleMoves.forEach((move) => { | |
let r_disp_target, c_disp_target; | |
// Map the internal target coordinates of the move to display coordinates | |
if (gameState.boardFlipped) { | |
r_disp_target = 7 - move.toR; | |
c_disp_target = 7 - move.toC; | |
} else { | |
r_disp_target = move.toR; | |
c_disp_target = move.toC; | |
} | |
const targetSquareElement = | |
squares[r_disp_target * 8 + c_disp_target]; | |
if (targetSquareElement) { | |
targetSquareElement.classList.add("possible-move"); | |
if (gameState.board[move.toR][move.toC] || move.isEnPassant) { | |
targetSquareElement.classList.add("possible-capture"); | |
} | |
} | |
}); | |
} | |
} | |
function handleSquareClick(r, c) { | |
if (gameState.isGameOver) return; | |
const pieceData = gameState.board[r][c]; | |
if (gameState.selectedSquare) { | |
const { r: fromR, c: fromC } = gameState.selectedSquare; | |
const legalMove = gameState.possibleMoves.find( | |
(m) => m.toR === r && m.toC === c, | |
); | |
if (legalMove) { | |
const movingPiece = gameState.board[fromR][fromC].piece; | |
if (getPieceType(movingPiece) === "pawn" && (r === 0 || r === 7)) { | |
promptForPromotion((promotionPieceType) => { | |
makeMove(fromR, fromC, r, c, promotionPieceType); | |
}); | |
} else { | |
makeMove(fromR, fromC, r, c); | |
} | |
} else { | |
gameState.selectedSquare = null; | |
gameState.possibleMoves = []; | |
if ( | |
pieceData && | |
getPieceColor(pieceData.piece) === gameState.currentPlayer | |
) { | |
gameState.selectedSquare = { r, c }; | |
gameState.possibleMoves = getLegalMovesForPiece(r, c); | |
} | |
} | |
} else { | |
if ( | |
pieceData && | |
getPieceColor(pieceData.piece) === gameState.currentPlayer | |
) { | |
gameState.selectedSquare = { r, c }; | |
gameState.possibleMoves = getLegalMovesForPiece(r, c); | |
} | |
} | |
renderBoard(); | |
} | |
function makeMove(fromR, fromC, toR, toC, promotionPiece = null) { | |
if (gameState.isGameOver) return; | |
const movingPieceData = JSON.parse( | |
JSON.stringify(gameState.board[fromR][fromC]), | |
); | |
const capturedPieceData = gameState.board[toR][toC] | |
? JSON.parse(JSON.stringify(gameState.board[toR][toC])) | |
: null; | |
if ( | |
getPieceType(movingPieceData.piece) === "pawn" || | |
capturedPieceData | |
) { | |
gameState.halfMoveClock = 0; | |
} else { | |
gameState.halfMoveClock++; | |
} | |
let isEnPassantCapture = false; | |
if ( | |
getPieceType(movingPieceData.piece) === "pawn" && | |
gameState.enPassantTarget && | |
toR === gameState.enPassantTarget.r && | |
toC === gameState.enPassantTarget.c | |
) { | |
const capturedPawnR = | |
gameState.currentPlayer === "white" ? toR + 1 : toR - 1; | |
gameState.board[capturedPawnR][toC] = null; | |
isEnPassantCapture = true; | |
} | |
gameState.board[toR][toC] = movingPieceData; | |
gameState.board[fromR][fromC] = null; | |
movingPieceData.hasMoved = true; | |
if (promotionPiece) { | |
gameState.board[toR][toC].piece = | |
gameState.currentPlayer === "white" | |
? promotionPiece.toUpperCase() | |
: promotionPiece.toLowerCase(); | |
} | |
let castlingSide = null; | |
if (getPieceType(movingPieceData.piece) === "king") { | |
if (Math.abs(fromC - toC) === 2) { | |
let rookFromC, rookToC; | |
if (toC === 6) { | |
rookFromC = 7; | |
rookToC = 5; | |
castlingSide = "K"; | |
} else { | |
rookFromC = 0; | |
rookToC = 3; | |
castlingSide = "Q"; | |
} | |
const rook = gameState.board[fromR][rookFromC]; | |
gameState.board[fromR][rookToC] = rook; | |
gameState.board[fromR][rookFromC] = null; | |
if (rook) rook.hasMoved = true; | |
} | |
} | |
if (movingPieceData.piece === "K") | |
gameState.castlingRights.white = { K: false, Q: false }; | |
if (movingPieceData.piece === "k") | |
gameState.castlingRights.black = { K: false, Q: false }; | |
if (movingPieceData.piece === "R") { | |
if (fromR === 7 && fromC === 0) | |
gameState.castlingRights.white.Q = false; | |
if (fromR === 7 && fromC === 7) | |
gameState.castlingRights.white.K = false; | |
} | |
if (movingPieceData.piece === "r") { | |
if (fromR === 0 && fromC === 0) | |
gameState.castlingRights.black.Q = false; | |
if (fromR === 0 && fromC === 7) | |
gameState.castlingRights.black.K = false; | |
} | |
if (capturedPieceData) { | |
if (toR === 7 && toC === 0 && capturedPieceData.piece === "R") | |
gameState.castlingRights.white.Q = false; | |
if (toR === 7 && toC === 7 && capturedPieceData.piece === "R") | |
gameState.castlingRights.white.K = false; | |
if (toR === 0 && toC === 0 && capturedPieceData.piece === "r") | |
gameState.castlingRights.black.Q = false; | |
if (toR === 0 && toC === 7 && capturedPieceData.piece === "r") | |
gameState.castlingRights.black.K = false; | |
} | |
if ( | |
getPieceType(movingPieceData.piece) === "pawn" && | |
Math.abs(fromR - toR) === 2 | |
) { | |
gameState.enPassantTarget = { r: (fromR + toR) / 2, c: fromC }; | |
} else { | |
gameState.enPassantTarget = null; | |
} | |
gameState.lastMove = { fromR, fromC, toR, toC }; | |
const prevPlayer = gameState.currentPlayer; | |
gameState.currentPlayer = | |
gameState.currentPlayer === "white" ? "black" : "white"; | |
if (prevPlayer === "black") { | |
gameState.fullMoveNumber++; | |
} | |
gameState.selectedSquare = null; | |
gameState.possibleMoves = []; | |
const notation = generateSAN( | |
movingPieceData, | |
fromR, | |
fromC, | |
toR, | |
toC, | |
capturedPieceData, | |
promotionPiece, | |
castlingSide, | |
isEnPassantCapture, | |
); | |
const opponentColor = gameState.currentPlayer; | |
const legalMovesForOpponent = getAllLegalMoves(opponentColor); // Uses updated board | |
const kingInCheck = isKingInCheck(opponentColor, gameState.board); // Uses updated board | |
let finalNotation = notation; | |
if (kingInCheck) { | |
if (legalMovesForOpponent.length === 0) { | |
gameState.isGameOver = true; | |
gameState.gameStatusMessage = `Checkmate! ${prevPlayer.charAt(0).toUpperCase() + prevPlayer.slice(1)} wins.`; | |
finalNotation += "#"; | |
} else { | |
gameState.gameStatusMessage = `${opponentColor.charAt(0).toUpperCase() + opponentColor.slice(1)} is in Check!`; | |
finalNotation += "+"; | |
} | |
} else { | |
if (legalMovesForOpponent.length === 0) { | |
gameState.isGameOver = true; | |
gameState.gameStatusMessage = "Stalemate! It's a draw."; | |
// Could add notation for stalemate (e.g., 1/2-1/2) but not standard in move itself | |
} else { | |
gameState.gameStatusMessage = `${opponentColor.charAt(0).toUpperCase() + opponentColor.slice(1)}'s turn`; | |
} | |
} | |
if (gameState.halfMoveClock >= 100) { | |
gameState.isGameOver = true; | |
gameState.gameStatusMessage = "Draw by 50-move rule."; | |
} | |
addMoveToLog(finalNotation, prevPlayer); | |
if (gameState.isGameOver) { | |
stopTimers(); | |
} else { | |
switchTimers(); // This effectively means the next timer tick will use the new current player | |
} | |
gameStatusDisplay.textContent = gameState.gameStatusMessage; | |
renderBoard(); | |
} | |
function toAlgebraic(r, c) { | |
return String.fromCharCode("a".charCodeAt(0) + c) + (8 - r); | |
} | |
function generateSAN( | |
pieceData, | |
fromR, | |
fromC, | |
toR, | |
toC, | |
capturedPiece, | |
promotionPieceType, | |
castlingSide, | |
isEnPassant, | |
) { | |
const pieceType = getPieceType(pieceData.piece); | |
let san = ""; | |
if (castlingSide === "K") return "O-O"; | |
if (castlingSide === "Q") return "O-O-O"; | |
if (pieceType !== "pawn") { | |
san += pieceData.piece.toUpperCase(); | |
} | |
// Simplified disambiguation: for now, only pawn captures need departure file | |
if (pieceType === "pawn" && (capturedPiece || isEnPassant)) { | |
san += String.fromCharCode("a".charCodeAt(0) + fromC); | |
} | |
// TODO: Add full disambiguation if needed by checking other pieces of the same type | |
// that can move to the same square. | |
if (capturedPiece || isEnPassant) { | |
san += "x"; | |
} | |
san += toAlgebraic(toR, toC); | |
if (promotionPieceType) { | |
san += "=" + promotionPieceType.toUpperCase(); | |
} | |
return san; | |
} | |
function addMoveToLog(notation, player) { | |
if (player === "white") { | |
const li = document.createElement("li"); | |
const moveNumSpan = document.createElement("span"); | |
moveNumSpan.className = "move-number"; | |
moveNumSpan.textContent = gameState.fullMoveNumber + "."; | |
const whiteMoveSpan = document.createElement("span"); | |
whiteMoveSpan.className = "move-white"; | |
whiteMoveSpan.textContent = notation; | |
li.appendChild(moveNumSpan); | |
li.appendChild(whiteMoveSpan); | |
gameState.moveHistory.push(li); | |
moveLogDisplay.appendChild(li); | |
} else { | |
const lastLi = | |
gameState.moveHistory[gameState.moveHistory.length - 1]; | |
if (lastLi) { | |
const blackMoveSpan = document.createElement("span"); | |
blackMoveSpan.className = "move-black"; | |
blackMoveSpan.textContent = notation; | |
lastLi.appendChild(blackMoveSpan); | |
} | |
} | |
moveLogDisplay.scrollTop = moveLogDisplay.scrollHeight; | |
} | |
function promptForPromotion(callback) { | |
gameState.promotionChoiceCallback = callback; | |
promotionModal.style.display = "flex"; | |
const buttons = promotionModal.querySelectorAll("button[data-piece]"); | |
buttons.forEach((button) => { | |
const pieceType = button.dataset.piece; | |
button.textContent = | |
PIECE_UNICODE[ | |
gameState.currentPlayer === "white" | |
? pieceType.toUpperCase() | |
: pieceType.toLowerCase() | |
]; | |
}); | |
} | |
promotionModal | |
.querySelectorAll("div.promo-buttons button[data-piece]") | |
.forEach((button) => { | |
button.addEventListener("click", () => { | |
if (gameState.promotionChoiceCallback) { | |
gameState.promotionChoiceCallback(button.dataset.piece); | |
} | |
promotionModal.style.display = "none"; | |
gameState.promotionChoiceCallback = null; | |
}); | |
}); | |
function getLegalMovesForPiece(r, c) { | |
const piece = gameState.board[r][c]; | |
if (!piece) return []; | |
const pseudoLegalMoves = getPseudoLegalMoves( | |
r, | |
c, | |
piece.piece, | |
gameState.board, | |
gameState.castlingRights, | |
gameState.enPassantTarget, | |
piece.hasMoved, | |
); | |
const legalMoves = []; | |
const pieceColor = getPieceColor(piece.piece); | |
for (const move of pseudoLegalMoves) { | |
const tempBoard = JSON.parse(JSON.stringify(gameState.board)); | |
const movingPieceTemp = JSON.parse( | |
JSON.stringify(tempBoard[move.fromR][move.fromC]), | |
); | |
tempBoard[move.toR][move.toC] = movingPieceTemp; | |
tempBoard[move.fromR][move.fromC] = null; | |
if (move.isEnPassant) { | |
const capturedPawnR = | |
pieceColor === "white" ? move.toR + 1 : move.toR - 1; | |
tempBoard[capturedPawnR][move.toC] = null; | |
} | |
if (move.isCastling) { | |
let rookFromC, rookToC; | |
if (move.castlingSide === "K") { | |
rookFromC = 7; | |
rookToC = 5; | |
} else { | |
rookFromC = 0; | |
rookToC = 3; | |
} | |
tempBoard[move.fromR][rookToC] = tempBoard[move.fromR][rookFromC]; | |
tempBoard[move.fromR][rookFromC] = null; | |
} | |
if (!isKingInCheck(pieceColor, tempBoard)) { | |
legalMoves.push(move); | |
} | |
} | |
return legalMoves; | |
} | |
function getAllLegalMoves(playerColor) { | |
let allMoves = []; | |
for (let r = 0; r < 8; r++) { | |
for (let c = 0; c < 8; c++) { | |
const pieceData = gameState.board[r][c]; | |
if (pieceData && getPieceColor(pieceData.piece) === playerColor) { | |
const moves = getLegalMovesForPiece(r, c); | |
allMoves.push(...moves); | |
} | |
} | |
} | |
return allMoves; | |
} | |
function getPseudoLegalMoves( | |
r, | |
c, | |
pieceChar, | |
board, | |
castlingRights, | |
enPassantTarget, | |
pieceHasMoved, | |
) { | |
const moves = []; | |
const color = getPieceColor(pieceChar); | |
const type = getPieceType(pieceChar); | |
const addMove = (toR, toC, options = {}) => { | |
if (toR >= 0 && toR < 8 && toC >= 0 && toC < 8) { | |
const targetPieceOnBoard = board[toR][toC]; | |
if ( | |
!targetPieceOnBoard || | |
getPieceColor(targetPieceOnBoard.piece) !== color | |
) { | |
moves.push({ | |
fromR: r, | |
fromC: c, | |
toR, | |
toC, | |
piece: pieceChar, | |
...options, | |
}); | |
} | |
return !targetPieceOnBoard; | |
} | |
return false; | |
}; | |
const addCaptureOnly = (toR, toC, options = {}) => { | |
if (toR >= 0 && toR < 8 && toC >= 0 && toC < 8) { | |
const targetPieceOnBoard = board[toR][toC]; | |
if ( | |
targetPieceOnBoard && | |
getPieceColor(targetPieceOnBoard.piece) !== color | |
) { | |
moves.push({ | |
fromR: r, | |
fromC: c, | |
toR, | |
toC, | |
piece: pieceChar, | |
...options, | |
}); | |
} else if (options.isEnPassant) { | |
moves.push({ | |
fromR: r, | |
fromC: c, | |
toR, | |
toC, | |
piece: pieceChar, | |
...options, | |
}); | |
} | |
} | |
}; | |
if (type === "pawn") { | |
const dir = color === "white" ? -1 : 1; | |
const startRow = color === "white" ? 6 : 1; | |
if (r + dir >= 0 && r + dir < 8 && !board[r + dir][c]) { | |
addMove(r + dir, c); | |
if (r === startRow && !board[r + 2 * dir][c]) { | |
addMove(r + 2 * dir, c); | |
} | |
} | |
addCaptureOnly(r + dir, c - 1); | |
addCaptureOnly(r + dir, c + 1); | |
if (enPassantTarget && enPassantTarget.r === r + dir) { | |
if (enPassantTarget.c === c - 1) | |
addCaptureOnly(r + dir, c - 1, { isEnPassant: true }); | |
if (enPassantTarget.c === c + 1) | |
addCaptureOnly(r + dir, c + 1, { isEnPassant: true }); | |
} | |
} | |
if (type === "knight") { | |
const knightMoves = [ | |
[-2, -1], | |
[-2, 1], | |
[-1, -2], | |
[-1, 2], | |
[1, -2], | |
[1, 2], | |
[2, -1], | |
[2, 1], | |
]; | |
knightMoves.forEach(([dr, dc]) => addMove(r + dr, c + dc)); | |
} | |
const slide = (dr, dc) => { | |
for (let i = 1; i < 8; i++) { | |
if (!addMove(r + i * dr, c + i * dc)) break; | |
} | |
}; | |
if (type === "rook" || type === "queen") { | |
slide(1, 0); | |
slide(-1, 0); | |
slide(0, 1); | |
slide(0, -1); | |
} | |
if (type === "bishop" || type === "queen") { | |
slide(1, 1); | |
slide(1, -1); | |
slide(-1, 1); | |
slide(-1, -1); | |
} | |
if (type === "king") { | |
const kingMoves = [ | |
[-1, -1], | |
[-1, 0], | |
[-1, 1], | |
[0, -1], | |
[0, 1], | |
[1, -1], | |
[1, 0], | |
[1, 1], | |
]; | |
kingMoves.forEach(([dr, dc]) => addMove(r + dr, c + dc)); | |
if (!pieceHasMoved) { | |
// Use the passed hasMoved status | |
const opponentColor = color === "white" ? "black" : "white"; | |
// Kingside | |
if ( | |
castlingRights[color].K && | |
!board[r][c + 1] && | |
!board[r][c + 2] && | |
board[r][c + 3] && | |
getPieceType(board[r][c + 3].piece) === "rook" && | |
!board[r][c + 3].hasMoved && | |
!isSquareAttacked(r, c, opponentColor, board) && // King's current square | |
!isSquareAttacked(r, c + 1, opponentColor, board) && // Transit square | |
!isSquareAttacked(r, c + 2, opponentColor, board) // Final square | |
) { | |
addMove(r, c + 2, { isCastling: true, castlingSide: "K" }); | |
} | |
// Queenside | |
if ( | |
castlingRights[color].Q && | |
!board[r][c - 1] && | |
!board[r][c - 2] && | |
!board[r][c - 3] && | |
board[r][c - 4] && | |
getPieceType(board[r][c - 4].piece) === "rook" && | |
!board[r][c - 4].hasMoved && | |
!isSquareAttacked(r, c, opponentColor, board) && // King's current square | |
!isSquareAttacked(r, c - 1, opponentColor, board) && // Transit square | |
!isSquareAttacked(r, c - 2, opponentColor, board) // Final square | |
) { | |
addMove(r, c - 2, { isCastling: true, castlingSide: "Q" }); | |
} | |
} | |
} | |
return moves; | |
} | |
// Revised isSquareAttacked function | |
function isSquareAttacked( | |
r_target, | |
c_target, | |
attackerColor, | |
currentBoard, | |
) { | |
for (let r_attacker = 0; r_attacker < 8; r_attacker++) { | |
for (let c_attacker = 0; c_attacker < 8; c_attacker++) { | |
const pieceData = currentBoard[r_attacker][c_attacker]; | |
if (pieceData && getPieceColor(pieceData.piece) === attackerColor) { | |
const pieceType = getPieceType(pieceData.piece); | |
if (pieceType === "pawn") { | |
const pawnAttackDir = attackerColor === "white" ? -1 : 1; | |
if ( | |
(r_attacker + pawnAttackDir === r_target && | |
c_attacker + 1 === c_target) || | |
(r_attacker + pawnAttackDir === r_target && | |
c_attacker - 1 === c_target) | |
) { | |
return true; | |
} | |
} else { | |
// For non-pawn pieces (King, Queen, Rook, Bishop, Knight) | |
// Generate pseudo-legal moves for this attacking piece | |
// For kings, hasMoved status is pieceData.hasMoved. Castling rights/EP target are irrelevant for attack calc. | |
const pseudoMoves = getPseudoLegalMoves( | |
r_attacker, | |
c_attacker, | |
pieceData.piece, | |
currentBoard, | |
{}, | |
null, | |
pieceData.hasMoved, | |
); | |
for (const move of pseudoMoves) { | |
if (move.toR === r_target && move.toC === c_target) { | |
return true; | |
} | |
} | |
} | |
} | |
} | |
} | |
return false; | |
} | |
function findKing(kingColor, currentBoard) { | |
const kingChar = kingColor === "white" ? "K" : "k"; | |
for (let r = 0; r < 8; r++) { | |
for (let c = 0; c < 8; c++) { | |
if (currentBoard[r][c] && currentBoard[r][c].piece === kingChar) { | |
return { r, c }; | |
} | |
} | |
} | |
return null; | |
} | |
function isKingInCheck(kingColor, currentBoard) { | |
const kingPos = findKing(kingColor, currentBoard); | |
if (!kingPos) return false; | |
const attackerColor = kingColor === "white" ? "black" : "white"; | |
return isSquareAttacked( | |
kingPos.r, | |
kingPos.c, | |
attackerColor, | |
currentBoard, | |
); | |
} | |
function formatTime(seconds) { | |
const mins = Math.floor(seconds / 60); | |
const secs = seconds % 60; | |
return `${mins}:${secs < 10 ? "0" : ""}${secs}`; | |
} | |
function updateTimerDisplay() { | |
whiteTimerDisplay.textContent = `White: ${formatTime(gameState.timers.white)}`; | |
blackTimerDisplay.textContent = `Black: ${formatTime(gameState.timers.black)}`; | |
whiteTimerDisplay.classList.remove("active-timer"); | |
blackTimerDisplay.classList.remove("active-timer"); | |
if (!gameState.isGameOver) { | |
// Only show active if game is ongoing | |
if (gameState.currentPlayer === "white") { | |
whiteTimerDisplay.classList.add("active-timer"); | |
} else { | |
blackTimerDisplay.classList.add("active-timer"); | |
} | |
} | |
} | |
function startTimers() { | |
stopTimers(); | |
gameState.timers.intervalId = setInterval(() => { | |
if (gameState.isGameOver) { | |
stopTimers(); | |
return; | |
} | |
if (gameState.currentPlayer === "white") { | |
gameState.timers.white--; | |
if (gameState.timers.white <= 0) { | |
gameState.timers.white = 0; // Prevent negative display | |
handleTimeout("white"); | |
} | |
} else { | |
gameState.timers.black--; | |
if (gameState.timers.black <= 0) { | |
gameState.timers.black = 0; // Prevent negative display | |
handleTimeout("black"); | |
} | |
} | |
updateTimerDisplay(); | |
}, 1000); | |
} | |
function stopTimers() { | |
if (gameState.timers.intervalId) { | |
clearInterval(gameState.timers.intervalId); | |
gameState.timers.intervalId = null; | |
} | |
} | |
function switchTimers() { | |
updateTimerDisplay(); // Active timer highlighting is handled by updateTimerDisplay | |
} | |
function handleTimeout(playerColor) { | |
stopTimers(); | |
gameState.isGameOver = true; | |
const winner = playerColor === "white" ? "Black" : "White"; | |
gameState.gameStatusMessage = `Time out! ${winner} wins.`; | |
gameStatusDisplay.textContent = gameState.gameStatusMessage; | |
updateTimerDisplay(); | |
} | |
restartButton.addEventListener("click", () => { | |
stopTimers(); | |
initGame(); | |
}); | |
flipBoardButton.addEventListener("click", () => { | |
gameState.boardFlipped = !gameState.boardFlipped; | |
renderBoard(); | |
}); | |
initGame(); | |
</script> | |
</body> | |
</html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment