UNPKG

dawikk-draughts

Version:

A comprehensive JavaScript library for draughts/checkers game logic with multiple variants support, FEN validation, and draw detection

1,562 lines (1,359 loc) 43.8 kB
// === CONSTANTS === /** * Different checkers board sizes */ const BOARD_SIZES = { SMALL: { size: 6, label: "6×6 (Mini)" }, STANDARD: { size: 8, label: "8×8 (Standard)" }, POLISH: { size: 10, label: "10×10 (International/Polish)" }, CANADIAN: { size: 12, label: "12×12 (Canadian/Brazilian)" } }; /** * Predefined color themes for board and pieces */ const BOARD_THEMES = { CLASSIC: { id: 'classic', name: 'Classic', description: 'Traditional black and white board with blue and red pieces.', colors: { lightSquare: '#EFEFEF', // Light gray darkSquare: '#5D5D5D', // Dark gray player1Piece: '#5895B1', // Blue player2Piece: '#ff6b6b', // Red player1King: '#FFD700', // Gold border for player 1 kings player2King: '#FFD700', // Gold border for player 2 kings highlightedSquare: '#7BB274', // Highlight for possible moves activeSquare: '#FFF178' // Border for active piece } }, FOREST: { id: 'forest', name: 'Forest', description: 'Earthy green tones with wooden pieces.', colors: { lightSquare: '#D8E1CD', darkSquare: '#4C6444', player1Piece: '#8A5A3C', player2Piece: '#D2B48C', player1King: '#FFD700', player2King: '#FFD700', highlightedSquare: '#95B866', activeSquare: '#FFF178' } }, MIDNIGHT: { id: 'midnight', name: 'Midnight', description: 'Deep blue tones with silver and gold pieces.', colors: { lightSquare: '#4A6D8C', darkSquare: '#1E3B5A', player1Piece: '#E6E6FA', // Lavender player2Piece: '#FFD700', // Gold player1King: '#C0C0C0', // Silver border player2King: '#FFFFFF', // White border highlightedSquare: '#5C87AD', activeSquare: '#FFCC00' } }, SUNSET: { id: 'sunset', name: 'Sunset', description: 'Warm sunset colors with contrasting pieces.', colors: { lightSquare: '#FFCC99', darkSquare: '#CC6600', player1Piece: '#990000', player2Piece: '#FFFF66', player1King: '#FFFFFF', player2King: '#FFFFFF', highlightedSquare: '#FF9966', activeSquare: '#FFFF00' } }, MONOCHROME: { id: 'monochrome', name: 'Monochrome', description: 'Elegant black and white theme.', colors: { lightSquare: '#FFFFFF', darkSquare: '#000000', player1Piece: '#303030', player2Piece: '#CCCCCC', player1King: '#808080', player2King: '#808080', highlightedSquare: '#AAAAAA', activeSquare: '#DDDDDD' } } }; /** * Draughts game variants with their rules */ const GAME_VARIANTS = { INTERNATIONAL: { id: 'international', name: 'International/Polish', description: 'Classic 10×10 board with flying kings and mandatory captures.', boardSize: 10, rules: { flyingKings: true, // Kings can move any distance mandatoryCapture: true, // Captures are mandatory captureBackwards: true, // Pieces can capture backwards longestCapture: true, // Must make the longest capture sequence piecesSetup: 'standard', // Standard 4 rows of pieces at start promotionRank: 'opposite', // Promotion to king at opposite end } }, AMERICAN: { id: 'american', name: 'American/English', description: '8×8 board with non-flying kings (in American variant kings can fly).', boardSize: 8, rules: { flyingKings: false, // Kings move one square (English variant) mandatoryCapture: true, // Captures are mandatory captureBackwards: false, // Pieces can only capture forward longestCapture: false, // Any capture allowed (not necessarily longest) piecesSetup: 'standard', // 3 rows of pieces at start promotionRank: 'opposite', // Promotion to king at opposite end } }, RUSSIAN: { id: 'russian', name: 'Russian', description: '8×8 board with flying kings and backward captures for regular pieces.', boardSize: 8, rules: { flyingKings: true, // Kings can move any distance mandatoryCapture: true, // Captures are mandatory captureBackwards: true, // Pieces can capture backwards longestCapture: true, // Must make the longest capture sequence piecesSetup: 'standard', // 3 rows of pieces at start promotionRank: 'opposite', // Promotion to king at opposite end } }, SPANISH: { id: 'spanish', name: 'Spanish', description: '8×8 board with flying kings but forward-only captures for regular pieces.', boardSize: 8, rules: { flyingKings: true, // Kings can move any distance mandatoryCapture: true, // Captures are mandatory captureBackwards: false, // Pieces can only capture forward longestCapture: true, // Must make the longest capture sequence piecesSetup: 'standard', // 3 rows of pieces at start promotionRank: 'opposite', // Promotion to king at opposite end } }, ITALIAN: { id: 'italian', name: 'Italian', description: '8×8 board with flying kings but limited capture range.', boardSize: 8, rules: { flyingKings: true, // Kings can move any distance mandatoryCapture: true, // Captures are mandatory captureBackwards: false, // Pieces can only capture forward longestCapture: true, // Must make the longest capture sequence piecesSetup: 'standard', // 3 rows of pieces at start promotionRank: 'opposite', // Promotion to king at opposite end kingCaptureLimit: 1, // Kings can only capture within 1 square distance } }, BRAZILIAN: { id: 'brazilian', name: 'Brazilian/Canadian', description: 'Large 12×12 board with flying kings and mandatory captures.', boardSize: 12, rules: { flyingKings: true, // Kings can move any distance mandatoryCapture: true, // Captures are mandatory captureBackwards: true, // Pieces can capture backwards longestCapture: true, // Must make the longest capture sequence piecesSetup: 'standard', // 5 rows of pieces at start promotionRank: 'opposite', // Promotion to king at opposite end } }, TURKISH: { id: 'turkish', name: 'Turkish', description: 'Unique 8×8 board where pieces move orthogonally, not diagonally.', boardSize: 8, rules: { flyingKings: true, // Kings can move any distance mandatoryCapture: true, // Captures are mandatory captureBackwards: true, // Pieces can capture backwards longestCapture: false, // No requirement for longest capture piecesSetup: 'turkish', // Special starting setup promotionRank: 'opposite', // Promotion to king at opposite end orthogonalMovement: true, // Move along straight lines instead of diagonals } } }; /** * Default values */ const DEFAULT_THEME = BOARD_THEMES.CLASSIC; const DEFAULT_VARIANT = GAME_VARIANTS.INTERNATIONAL; /** * Game status constants */ const GAME_STATUS = { ONGOING: 'ongoing', CHECKMATE: 'checkmate', // In draughts: no moves available STALEMATE: 'stalemate', // In draughts: draw DRAW: 'draw' }; /** * Move type constants */ const MOVE_TYPES = { NORMAL: 'normal', CAPTURE: 'capture', MULTIPLE_CAPTURE: 'multiple_capture', PROMOTION: 'promotion' }; /** * Player constants */ const PLAYERS = { PLAYER1: 'r', // red/blue (first player) PLAYER2: 'b' // black (second player) }; // === DRAUGHTS CLASS === /** * Main Draughts class for managing game state and logic */ class Draughts { /** * Constructor - Initialize a new draughts game * @param {Object} config - Configuration object * @param {number} config.boardSize - Size of the board (default: 8) * @param {Object} config.variant - Game variant (default: INTERNATIONAL) * @param {Object} config.theme - Color theme (default: CLASSIC) */ constructor(config = {}) { // Initial configuration this._boardSize = config.boardSize || BOARD_SIZES.STANDARD.size; this._variant = config.variant || DEFAULT_VARIANT; this._theme = config.theme || DEFAULT_THEME; // Game state this._board = []; this._turn = PLAYERS.PLAYER1; // First player starts this._gameStatus = GAME_STATUS.ONGOING; this._winner = null; this._moveHistory = []; this._boardHistory = []; this._moveCount = 0; // Mandatory capture data this._availableCaptures = []; this._captureLength = 0; // Statistics this._stats = { captures: { [PLAYERS.PLAYER1]: 0, [PLAYERS.PLAYER2]: 0 }, kings: { [PLAYERS.PLAYER1]: 0, [PLAYERS.PLAYER2]: 0 } }; // Initialize board this.reset(); } // === PUBLIC API METHODS === /** * Get a copy of the current board * @returns {Array<Array<string>>} 2D array representing the board */ board() { return this._board.map(row => [...row]); } /** * Get the current player on move * @returns {string} 'r' or 'b' */ turn() { return this._turn; } /** * Check if the game is over * @returns {boolean} True if game is finished */ isGameOver() { return this._gameStatus !== GAME_STATUS.ONGOING; } /** * Get current game status * @returns {string} Game status constant */ gameStatus() { return this._gameStatus; } /** * Get the winner of the game * @returns {string|null} Winner ('r', 'b') or null if no winner */ winner() { return this._winner; } /** * Get the current move number * @returns {number} Move number (increments every 2 moves) */ moveNumber() { return Math.floor(this._moveCount / 2) + 1; } /** * Get the move history * @returns {Array} Array of move objects */ history() { return [...this._moveHistory]; } /** * Get game statistics * @returns {Object} Statistics object with captures and kings counts */ stats() { return JSON.parse(JSON.stringify(this._stats)); } /** * Reset the game to initial state * @returns {Draughts} This instance for chaining */ reset() { this._board = this._generateBoard(this._boardSize, this._variant); this._turn = PLAYERS.PLAYER1; this._gameStatus = GAME_STATUS.ONGOING; this._winner = null; this._moveHistory = []; this._boardHistory = []; this._moveCount = 0; this._stats = { captures: { [PLAYERS.PLAYER1]: 0, [PLAYERS.PLAYER2]: 0 }, kings: { [PLAYERS.PLAYER1]: 0, [PLAYERS.PLAYER2]: 0 } }; this._updateAvailableCaptures(); return this; } /** * Get all possible moves for the current position * @param {string} [square] - Specific square (e.g., 'a3') to get moves for * @returns {Array} Array of move objects */ moves(square = null) { if (this.isGameOver()) return []; if (square) { // Moves for specific square const [row, col] = this._parseSquare(square); if (row === null || col === null) return []; const moves = this._findAllPossibleMoves(row, col, this._board, this._turn, this._variant); return moves.map(move => this._formatMove(row, col, move)); } // All possible moves const allMoves = []; for (let row = 0; row < this._boardSize; row++) { for (let col = 0; col < this._boardSize; col++) { if (this._board[row][col].includes && this._board[row][col].includes(this._turn)) { const moves = this._findAllPossibleMoves(row, col, this._board, this._turn, this._variant); moves.forEach(move => { allMoves.push(this._formatMove(row, col, move)); }); } } } return allMoves; } /** * Make a move * @param {string|Object} moveNotation - Move notation or move object * @returns {boolean|Object} False if invalid, move result object if successful */ move(moveNotation) { if (this.isGameOver()) return false; let move; if (typeof moveNotation === 'string') { move = this._parseMove(moveNotation); } else if (typeof moveNotation === 'object') { move = moveNotation; } if (!move || !this._isValidMove(move)) { return false; } // Save state before move this._boardHistory.push(this._cloneBoard(this._board)); // Execute move const moveResult = this._executeMove(move); // Save move to history this._moveHistory.push(moveResult); this._moveCount++; // Switch player this._turn = this._turn === PLAYERS.PLAYER1 ? PLAYERS.PLAYER2 : PLAYERS.PLAYER1; // Check game over this._checkGameOver(); // Update available captures this._updateAvailableCaptures(); return moveResult; } /** * Undo the last move * @returns {boolean} True if successful, false if no moves to undo */ undo() { if (this._moveHistory.length === 0) return false; const lastMove = this._moveHistory.pop(); this._board = this._boardHistory.pop(); this._turn = this._turn === PLAYERS.PLAYER1 ? PLAYERS.PLAYER2 : PLAYERS.PLAYER1; this._gameStatus = GAME_STATUS.ONGOING; this._winner = null; this._moveCount--; // Update statistics (reverse) if (lastMove.captures && lastMove.captures.length > 0) { this._stats.captures[this._turn] -= lastMove.captures.length; } if (lastMove.promotion) { this._stats.kings[this._turn]--; } this._updateAvailableCaptures(); return true; } /** * Get position in FEN notation for draughts * @returns {string} FEN string */ fen() { return this._convertBoardToScanFormat(this._board); } /** * Load position from FEN notation * @param {string} fen - FEN string to load * @returns {boolean} True if successful, false otherwise */ load(fen) { try { if (!fen || typeof fen !== 'string') { return false; } const fenData = this._parseFEN(fen); if (!fenData) { return false; } // Reset and set new position this._board = this._createEmptyBoard(); this._turn = fenData.turn; this._gameStatus = GAME_STATUS.ONGOING; this._winner = null; this._moveHistory = []; this._boardHistory = []; this._moveCount = 0; this._stats = { captures: { [PLAYERS.PLAYER1]: 0, [PLAYERS.PLAYER2]: 0 }, kings: { [PLAYERS.PLAYER1]: 0, [PLAYERS.PLAYER2]: 0 } }; // Set pieces on board this._setBoardFromFEN(fenData); this._updateAvailableCaptures(); return true; } catch (error) { console.error('FEN load error:', error); return false; } } /** * Get ASCII representation of the board * @returns {string} ASCII board representation */ ascii() { let result = '\n '; // Column headers for (let i = 0; i < this._boardSize; i++) { result += String.fromCharCode(97 + i) + ' '; } result += '\n'; // Rows for (let row = 0; row < this._boardSize; row++) { result += `${this._boardSize - row} `; for (let col = 0; col < this._boardSize; col++) { const piece = this._board[row][col]; let symbol = '.'; if (piece !== '-') { if (piece.includes('r')) { symbol = piece.includes('k') ? 'R' : 'r'; } else if (piece.includes('b')) { symbol = piece.includes('k') ? 'B' : 'b'; } } result += symbol + ' '; } result += ` ${this._boardSize - row}\n`; } // Column footer result += ' '; for (let i = 0; i < this._boardSize; i++) { result += String.fromCharCode(97 + i) + ' '; } result += '\n'; return result; } // === CONFIGURATION METHODS === /** * Set game variant * @param {Object} variant - Game variant object * @returns {Draughts} This instance for chaining */ setVariant(variant) { this._variant = variant; this.reset(); return this; } /** * Set board size * @param {number} size - Board size * @returns {Draughts} This instance for chaining */ setBoardSize(size) { this._boardSize = size; this.reset(); return this; } /** * Set color theme * @param {Object} theme - Theme object * @returns {Draughts} This instance for chaining */ setTheme(theme) { this._theme = theme; return this; } /** * Get current configuration * @returns {Object} Configuration object */ getConfig() { return { boardSize: this._boardSize, variant: this._variant, theme: this._theme }; } /** * Get information about mandatory captures * @returns {Object} Captures info with mandatory flag and max length */ getCaptures() { return { captures: this._availableCaptures, maxLength: this._captureLength, mandatory: this._variant.rules.mandatoryCapture && this._captureLength > 0 }; } /** * Check if a move is legal * @param {string|Object} moveNotation - Move notation or object * @returns {boolean} True if move is legal */ isLegalMove(moveNotation) { if (this.isGameOver()) return false; let move; if (typeof moveNotation === 'string') { move = this._parseMove(moveNotation); } else { move = moveNotation; } return move && this._isValidMove(move); } /** * Check if a square is attacked by the opponent * @param {string} square - Square in algebraic notation * @param {string} [byPlayer] - Which player attacks (default: opponent) * @returns {boolean} True if square is attacked */ isAttacked(square, byPlayer = null) { const [row, col] = this._parseSquare(square); if (row === null) return false; const attacker = byPlayer || (this._turn === PLAYERS.PLAYER1 ? PLAYERS.PLAYER2 : PLAYERS.PLAYER1); // Check if any opponent piece can capture this square for (let r = 0; r < this._boardSize; r++) { for (let c = 0; c < this._boardSize; c++) { if (this._board[r][c].includes && this._board[r][c].includes(attacker)) { const moves = this._findAllPossibleMoves(r, c, this._board, attacker, this._variant); const captures = moves.filter(move => move.wouldDelete && move.wouldDelete.length > 0); for (const capture of captures) { if (capture.wouldDelete.some(del => del.targetRow === row && del.targetCell === col)) { return true; } } } } } return false; } // === PRIVATE METHODS === /** * Format move to standard format * @private */ _formatMove(fromRow, fromCol, move) { const moveObj = { from: this._squareToAlgebraic(fromRow, fromCol), to: this._squareToAlgebraic(move.targetRow, move.targetCell), piece: this._board[fromRow][fromCol], type: MOVE_TYPES.NORMAL }; if (move.wouldDelete && move.wouldDelete.length > 0) { moveObj.captures = move.wouldDelete.map(cap => this._squareToAlgebraic(cap.targetRow, cap.targetCell) ); moveObj.type = move.wouldDelete.length > 1 ? MOVE_TYPES.MULTIPLE_CAPTURE : MOVE_TYPES.CAPTURE; } // Check for promotion const piece = this._board[fromRow][fromCol]; if (!piece.includes('k')) { if ((this._turn === PLAYERS.PLAYER2 && move.targetRow === this._boardSize - 1) || (this._turn === PLAYERS.PLAYER1 && move.targetRow === 0)) { moveObj.promotion = true; moveObj.type = MOVE_TYPES.PROMOTION; } } return moveObj; } /** * Generate initial board for given size and variant * @private */ _generateBoard(size, variant) { if (variant.rules.orthogonalMovement) { return this._generateTurkishBoard(size); } let piecesRows; if (variant.id === 'international' || variant.id === 'polish') { piecesRows = Math.floor(size / 2) - 1; } else if (variant.id === 'brazilian' || variant.id === 'canadian') { piecesRows = Math.ceil(size / 3); } else if (size === 8) { piecesRows = 3; } else { piecesRows = Math.ceil(size / 4); } const board = []; for (let i = 0; i < size; i++) { const row = Array(size).fill('-'); board.push(row); } // Place pieces on top (black) for (let row = 0; row < piecesRows; row++) { for (let col = 0; col < size; col++) { if ((row + col) % 2 === 1) { board[row][col] = PLAYERS.PLAYER2; } } } // Place pieces on bottom (red/blue) for (let row = size - piecesRows; row < size; row++) { for (let col = 0; col < size; col++) { if ((row + col) % 2 === 1) { board[row][col] = PLAYERS.PLAYER1; } } } return board; } /** * Generate board for Turkish variant * @private */ _generateTurkishBoard(size) { const board = []; for (let i = 0; i < size; i++) { const row = Array(size).fill('-'); board.push(row); } // Top rows (black) - offset by one row from edge for (let row = 1; row < 3; row++) { for (let col = 0; col < size; col++) { board[row][col] = PLAYERS.PLAYER2; } } // Bottom rows (red/blue) - offset by one row from edge for (let row = size - 3; row < size - 1; row++) { for (let col = 0; col < size; col++) { board[row][col] = PLAYERS.PLAYER1; } } return board; } /** * Create empty board * @private */ _createEmptyBoard() { const board = []; for (let i = 0; i < this._boardSize; i++) { board.push(Array(this._boardSize).fill('-')); } return board; } /** * Clone board * @private */ _cloneBoard(board) { return board.map(row => [...row]); } /** * Parse FEN string * @private */ _parseFEN(fen) { try { // Format: W:W31,32,33:B1,2,3:K4,5:F1 const parts = fen.split(':'); if (parts.length < 3) return null; const result = { turn: parts[0] === 'W' ? PLAYERS.PLAYER1 : PLAYERS.PLAYER2, whitePieces: [], blackPieces: [], kings: [] }; // Parse white pieces if (parts[1].startsWith('W') && parts[1].length > 1) { const positions = parts[1].substring(1).split(','); result.whitePieces = positions.map(p => parseInt(p.trim(), 10)).filter(n => !isNaN(n)); } // Parse black pieces if (parts[2].startsWith('B') && parts[2].length > 1) { const positions = parts[2].substring(1).split(','); result.blackPieces = positions.map(p => parseInt(p.trim(), 10)).filter(n => !isNaN(n)); } // Parse kings (optional) for (let i = 3; i < parts.length; i++) { if (parts[i].startsWith('K') && parts[i].length > 1) { const positions = parts[i].substring(1).split(','); result.kings = positions.map(p => parseInt(p.trim(), 10)).filter(n => !isNaN(n)); } } return result; } catch (error) { return null; } } /** * Set board from FEN data * @private */ _setBoardFromFEN(fenData) { // Convert field number to position (row, col) const fieldToPosition = (fieldNumber) => { let currentField = 0; for (let row = 0; row < this._boardSize; row++) { for (let col = 0; col < this._boardSize; col++) { if ((row + col) % 2 === 1) { currentField++; if (currentField === fieldNumber) { return [row, col]; } } } } return null; }; // Set white/red pieces fenData.whitePieces.forEach(fieldNum => { const pos = fieldToPosition(fieldNum); if (pos) { const [row, col] = pos; const isKing = fenData.kings.includes(fieldNum); this._board[row][col] = isKing ? PLAYERS.PLAYER1 + ' k' : PLAYERS.PLAYER1; } }); // Set black pieces fenData.blackPieces.forEach(fieldNum => { const pos = fieldToPosition(fieldNum); if (pos) { const [row, col] = pos; const isKing = fenData.kings.includes(fieldNum); this._board[row][col] = isKing ? PLAYERS.PLAYER2 + ' k' : PLAYERS.PLAYER2; } }); } /** * Find all possible moves for a piece * @private */ _findAllPossibleMoves(rowIndex, cellIndex, board, activePlayer, gameVariant) { const rules = gameVariant.rules; if (rowIndex < 0 || rowIndex >= this._boardSize || cellIndex < 0 || cellIndex >= this._boardSize) { return []; } if (!board[rowIndex][cellIndex] || board[rowIndex][cellIndex] === '-') { return []; } const isKing = board[rowIndex][cellIndex].includes('k'); if (rules.orthogonalMovement) { return this._findTurkishMoves(rowIndex, cellIndex, board, activePlayer, isKing, rules); } if (isKing) { return this._findQueenMoves(rowIndex, cellIndex, board, activePlayer, rules); } // For regular piece const moveDirections = activePlayer === PLAYERS.PLAYER2 ? [1] : [-1]; let captureDirections = [1, -1]; if (!rules.captureBackwards) { captureDirections = activePlayer === PLAYERS.PLAYER2 ? [1] : [-1]; } const jumpMoves = this._findAllJumps( rowIndex, cellIndex, board, captureDirections, [], [], isKing, activePlayer, rules ); if (jumpMoves.length > 0 && rules.mandatoryCapture) { if (rules.longestCapture) { const maxCaptures = Math.max(...jumpMoves.map(move => move.wouldDelete.length)); return jumpMoves.filter(move => move.wouldDelete.length === maxCaptures); } return jumpMoves; } else if (jumpMoves.length > 0) { return jumpMoves; } // Regular moves const possibleMoves = []; const leftOrRight = [1, -1]; moveDirections.forEach(direction => { leftOrRight.forEach(lr => { const nextRow = rowIndex + direction; const nextCell = cellIndex + lr; if ( nextRow >= 0 && nextRow < this._boardSize && nextCell >= 0 && nextCell < this._boardSize && board[nextRow][nextCell] === '-' ) { possibleMoves.push({ targetRow: nextRow, targetCell: nextCell, wouldDelete: [] }); } }); }); return possibleMoves; } /** * Find moves for king/queen pieces * @private */ _findQueenMoves(rowIndex, cellIndex, board, activePlayer, rules) { const captures = this._findQueenCaptures(board, rowIndex, cellIndex, activePlayer, [], new Set(), rules); if (captures.length > 0) { const maxJumpLength = Math.max(...captures.map(move => move.jumpLength)); return captures.filter(move => move.jumpLength === maxJumpLength); } const moves = []; const directions = [ { row: 1, col: 1 }, { row: 1, col: -1 }, { row: -1, col: 1 }, { row: -1, col: -1 } ]; const isFlying = !rules || rules.flyingKings !== false; for (const direction of directions) { let currentRow = rowIndex; let currentCol = cellIndex; let steps = 0; const maxSteps = isFlying ? this._boardSize : 1; while (steps < maxSteps) { currentRow += direction.row; currentCol += direction.col; steps++; if (currentRow < 0 || currentRow >= this._boardSize || currentCol < 0 || currentCol >= this._boardSize) { break; } if (board[currentRow][currentCol] !== '-') { break; } moves.push({ targetRow: currentRow, targetCell: currentCol, wouldDelete: [] }); if (!isFlying) { break; } } } return moves; } /** * Find captures for king pieces * @private */ _findQueenCaptures(board, startRow, startCol, activePlayer, capturedPieces = [], visited = new Set(), rules = null) { const moves = []; const directions = [ { row: 1, col: 1 }, { row: 1, col: -1 }, { row: -1, col: 1 }, { row: -1, col: -1 } ]; const posKey = `${startRow},${startCol}`; if (visited.has(posKey)) { return moves; } visited.add(posKey); for (const direction of directions) { let currentRow = startRow; let currentCol = startCol; let enemyFound = false; let enemyPos = null; while (true) { currentRow += direction.row; currentCol += direction.col; if (currentRow < 0 || currentRow >= this._boardSize || currentCol < 0 || currentCol >= this._boardSize) { break; } if (board[currentRow][currentCol] !== '-') { if (board[currentRow][currentCol].includes(activePlayer === PLAYERS.PLAYER1 ? PLAYERS.PLAYER2 : PLAYERS.PLAYER1)) { if (!capturedPieces.some(piece => piece.row === currentRow && piece.col === currentCol)) { enemyFound = true; enemyPos = { row: currentRow, col: currentCol }; } } break; } } if (enemyFound) { currentRow = enemyPos.row; currentCol = enemyPos.col; let maxLandingDistance = this._boardSize; if (rules && rules.kingCaptureLimit) { maxLandingDistance = rules.kingCaptureLimit; } let landingDistance = 0; while (landingDistance < maxLandingDistance) { currentRow += direction.row; currentCol += direction.col; landingDistance++; if (currentRow < 0 || currentRow >= this._boardSize || currentCol < 0 || currentCol >= this._boardSize) { break; } if (board[currentRow][currentCol] !== '-') { break; } const newCapturedPieces = [...capturedPieces, enemyPos]; const tempBoard = board.map(row => [...row]); tempBoard[startRow][startCol] = '-'; tempBoard[enemyPos.row][enemyPos.col] = '-'; tempBoard[currentRow][currentCol] = activePlayer + ' k'; const nextCaptures = this._findQueenCaptures( tempBoard, currentRow, currentCol, activePlayer, newCapturedPieces, new Set(visited), rules ); if (nextCaptures.length > 0) { nextCaptures.forEach(nextCapture => { moves.push({ targetRow: nextCapture.targetRow, targetCell: nextCapture.targetCell, wouldDelete: [...newCapturedPieces, ...nextCapture.wouldDelete], jumpLength: newCapturedPieces.length + nextCapture.wouldDelete.length }); }); } else { moves.push({ targetRow: currentRow, targetCell: currentCol, wouldDelete: newCapturedPieces, jumpLength: newCapturedPieces.length }); } } } } return moves; } /** * Find jumps for regular pieces * @private */ _findAllJumps(sourceRowIndex, sourceCellIndex, board, directions, possibleJumps = [], wouldDelete = [], isKing, activePlayer, rules = null, currentJumpLength = 0) { if (isKing) { const queenMoves = this._findQueenMoves(sourceRowIndex, sourceCellIndex, board, activePlayer, rules); return queenMoves.filter(move => move.wouldDelete.length > 0); } if (!rules || rules.captureBackwards) { directions = [1, -1]; } else { directions = activePlayer === PLAYERS.PLAYER2 ? [1] : [-1]; } let thisIterationDidSomething = false; const leftOrRight = [1, -1]; directions.forEach(direction => { leftOrRight.forEach(lr => { const nextRow = sourceRowIndex + direction; const nextCell = sourceCellIndex + lr; const jumpRow = sourceRowIndex + (direction * 2); const jumpCell = sourceCellIndex + (lr * 2); if ( nextRow >= 0 && nextRow < this._boardSize && nextCell >= 0 && nextCell < this._boardSize && jumpRow >= 0 && jumpRow < this._boardSize && jumpCell >= 0 && jumpCell < this._boardSize && board[nextRow][nextCell] && board[nextRow][nextCell].includes(activePlayer === PLAYERS.PLAYER1 ? PLAYERS.PLAYER2 : PLAYERS.PLAYER1) && board[jumpRow][jumpCell] === '-' && !wouldDelete.some(del => del.targetRow === nextRow && del.targetCell === nextCell ) ) { const moveKey = `${jumpRow}${jumpCell}`; if (!possibleJumps.some(move => `${move.targetRow}${move.targetCell}` === moveKey)) { const newWouldDelete = [ ...wouldDelete, { targetRow: nextRow, targetCell: nextCell } ]; const tempJumpObject = { targetRow: jumpRow, targetCell: jumpCell, wouldDelete: newWouldDelete, jumpLength: currentJumpLength + 1 }; possibleJumps.push(tempJumpObject); thisIterationDidSomething = true; this._findAllJumps( jumpRow, jumpCell, board, directions, possibleJumps, newWouldDelete, isKing, activePlayer, rules, currentJumpLength + 1 ); } } }); }); return possibleJumps; } /** * Find moves for Turkish variant (orthogonal movement) * @private */ _findTurkishMoves(rowIndex, cellIndex, board, activePlayer, isKing, rules) { const possibleMoves = []; const directions = [ { row: -1, col: 0 }, // up { row: 0, col: 1 }, // right { row: 1, col: 0 }, // down { row: 0, col: -1 } // left ]; let allowedDirections = directions; if (!isKing && !rules.captureBackwards) { allowedDirections = activePlayer === PLAYERS.PLAYER2 ? [directions[2]] // black: only down : [directions[0]]; // red: only up } allowedDirections.forEach(dir => { let row = rowIndex; let col = cellIndex; const maxSteps = isKing && rules.flyingKings ? this._boardSize : 1; for (let step = 1; step <= maxSteps; step++) { row += dir.row; col += dir.col; if (row < 0 || row >= this._boardSize || col < 0 || col >= this._boardSize) { break; } if (board[row][col] === '-') { possibleMoves.push({ targetRow: row, targetCell: col, wouldDelete: [] }); } else { if (board[row][col].includes(activePlayer === PLAYERS.PLAYER1 ? PLAYERS.PLAYER2 : PLAYERS.PLAYER1)) { const jumpRow = row + dir.row; const jumpCol = col + dir.col; if ( jumpRow >= 0 && jumpRow < this._boardSize && jumpCol >= 0 && jumpCol < this._boardSize && board[jumpRow][jumpCol] === '-' ) { possibleMoves.push({ targetRow: jumpRow, targetCell: jumpCol, wouldDelete: [{ targetRow: row, targetCell: col }] }); } } break; } } }); return possibleMoves; } /** * Update available captures * @private */ _updateAvailableCaptures() { if (!this._variant.rules.mandatoryCapture) { this._availableCaptures = []; this._captureLength = 0; return; } const { captures, maxLength } = this._findAllCaptures(this._board, this._turn, this._variant); this._availableCaptures = captures; this._captureLength = maxLength; } /** * Find all available captures * @private */ _findAllCaptures(board, activePlayer, gameVariant) { let allCaptures = []; let maxCaptureLength = 0; if (!gameVariant.rules.mandatoryCapture) { return { captures: [], maxLength: 0 }; } for (let i = 0; i < this._boardSize; i++) { for (let j = 0; j < this._boardSize; j++) { if (board[i][j].includes && board[i][j].includes(activePlayer)) { const moves = this._findAllPossibleMoves(i, j, board, activePlayer, gameVariant); const captures = moves.filter(move => move.wouldDelete.length > 0); if (captures.length > 0) { const captureLength = captures[0].wouldDelete.length; if (gameVariant.rules.longestCapture) { if (captureLength > maxCaptureLength) { maxCaptureLength = captureLength; allCaptures = [{ piece: { row: i, cell: j }, moves: captures }]; } else if (captureLength === maxCaptureLength) { allCaptures.push({ piece: { row: i, cell: j }, moves: captures }); } } else { allCaptures.push({ piece: { row: i, cell: j }, moves: captures }); if (captureLength > maxCaptureLength) { maxCaptureLength = captureLength; } } } } } } return { captures: allCaptures, maxLength: maxCaptureLength }; } /** * Execute move on board * @private */ _executeMove(move) { const fromRow = this._parseSquare(move.from)[0]; const fromCol = this._parseSquare(move.from)[1]; const toRow = this._parseSquare(move.to)[0]; const toCol = this._parseSquare(move.to)[1]; if (fromRow === null || toRow === null) return false; const piece = this._board[fromRow][fromCol]; this._board[fromRow][fromCol] = '-'; // Prepare move result object const moveResult = { from: move.from, to: move.to, piece: piece, type: MOVE_TYPES.NORMAL, captured: [] }; // Remove captured pieces if (move.captures && move.captures.length > 0) { move.captures.forEach(captureSquare => { const [captureRow, captureCol] = this._parseSquare(captureSquare); if (captureRow !== null) { const capturedPiece = this._board[captureRow][captureCol]; this._board[captureRow][captureCol] = '-'; moveResult.captured.push({ piece: capturedPiece, square: captureSquare }); // Update statistics this._stats.captures[this._turn]++; } }); moveResult.type = move.captures.length > 1 ? MOVE_TYPES.MULTIPLE_CAPTURE : MOVE_TYPES.CAPTURE; } // Check for king promotion let newPiece = piece; if (!piece.includes('k')) { if ((this._turn === PLAYERS.PLAYER2 && toRow === this._boardSize - 1) || (this._turn === PLAYERS.PLAYER1 && toRow === 0)) { newPiece += ' k'; moveResult.promotion = true; moveResult.type = MOVE_TYPES.PROMOTION; this._stats.kings[this._turn]++; } } this._board[toRow][toCol] = newPiece; return moveResult; } /** * Check if move is valid * @private */ _isValidMove(move) { const allMoves = this.moves(); return allMoves.some(m => m.from === move.from && m.to === move.to && JSON.stringify(m.captures || []) === JSON.stringify(move.captures || []) ); } /** * Check for game over * @private */ _checkGameOver() { // Check if opponent has any moves const opponentMoves = this.moves(); if (opponentMoves.length === 0) { this._gameStatus = GAME_STATUS.CHECKMATE; this._winner = this._turn === PLAYERS.PLAYER1 ? PLAYERS.PLAYER2 : PLAYERS.PLAYER1; // Winner is who made the last move return; } // Check if opponent has any pieces left let hasOpponentPieces = false; for (let i = 0; i < this._boardSize; i++) { for (let j = 0; j < this._boardSize; j++) { if (this._board[i][j].includes && this._board[i][j].includes(this._turn)) { hasOpponentPieces = true; break; } } if (hasOpponentPieces) break; } if (!hasOpponentPieces) { this._gameStatus = GAME_STATUS.CHECKMATE; this._winner = this._turn === PLAYERS.PLAYER1 ? PLAYERS.PLAYER2 : PLAYERS.PLAYER1; } } /** * Convert position to algebraic notation * @private */ _squareToAlgebraic(row, col) { const file = String.fromCharCode(97 + col); // 97 = 'a' const rank = this._boardSize - row; return file + rank; } /** * Parse algebraic notation to position * @private */ _parseSquare(square) { if (!square || square.length !== 2) return [null, null]; const file = square.charCodeAt(0) - 97; // 'a' = 97 const rank = parseInt(square[1], 10); if (file < 0 || file >= this._boardSize || rank < 1 || rank > this._boardSize) { return [null, null]; } return [this._boardSize - rank, file]; } /** * Parse move notation * @private */ _parseMove(notation) { // Handle notations like "a3-b4", "a3xb4", "a3:c5" const moveRegex = /^([a-h]\d+)[-x:]([a-h]\d+)$/; const match = notation.match(moveRegex); if (!match) return null; return { from: match[1], to: match[2], captures: notation.includes('x') || notation.includes(':') ? [] : [] }; } /** * Convert board to FEN format * @private */ _convertBoardToScanFormat(board) { const whitePieces = []; const blackPieces = []; const whiteKings = []; const blackKings = []; let fieldNumber = 1; for (let row = 0; row < board.length; row++) { for (let col = 0; col < board[row].length; col++) { if ((row + col) % 2 === 1) { const piece = board[row][col]; if (piece.includes && piece.includes(PLAYERS.PLAYER1)) { if (piece.includes('k')) { whiteKings.push(fieldNumber); } else { whitePieces.push(fieldNumber); } } else if (piece.includes && piece.includes(PLAYERS.PLAYER2)) { if (piece.includes('k')) { blackKings.push(fieldNumber); } else { blackPieces.push(fieldNumber); } } fieldNumber++; } } } let fen = 'W:'; const parts = []; if (whitePieces.length > 0) { parts.push(`W${whitePieces.join(',')}`); } if (blackPieces.length > 0) { parts.push(`B${blackPieces.join(',')}`); } if (whiteKings.length > 0 || blackKings.length > 0) { const allKings = [...whiteKings, ...blackKings]; if (allKings.length > 0) { parts.push(`K${allKings.join(',')}`); } } fen += parts.join(':'); return fen; } } // === EXPORTS === export default Draughts; // Export all useful constants export { Draughts, BOARD_SIZES, BOARD_THEMES, GAME_VARIANTS, DEFAULT_THEME, DEFAULT_VARIANT, GAME_STATUS, MOVE_TYPES, PLAYERS };