Spaces:
Sleeping
Sleeping
| /// <reference path="../react-app-env.d.ts" /> | |
| import * as React from 'react'; | |
| const { useState, useEffect } = React; | |
| import { getGameState, makeMove, getHint } from '../services/api'; | |
| import { playSound, preloadSounds } from '../services/soundService'; | |
| import ChessClock from './ChessClock'; | |
| interface ChessBoardProps { | |
| theme: 'brown' | 'grey'; | |
| } | |
| interface Piece { | |
| type: string; | |
| color: 'white' | 'black'; | |
| position: string; | |
| } | |
| interface HighlightedSquare { | |
| square: string; | |
| type: 'selected' | 'hint' | 'legal-move'; | |
| } | |
| const ChessBoard: React.FC<ChessBoardProps> = ({ theme }) => { | |
| const [fen, setFen] = useState<string>('rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1'); | |
| const [pieces, setPieces] = useState<Piece[]>([]); | |
| const [selectedPiece, setSelectedPiece] = useState<string | null>(null); | |
| const [legalMoves, setLegalMoves] = useState<string[]>([]); | |
| const [highlightedSquares, setHighlightedSquares] = useState<HighlightedSquare[]>([]); | |
| const [hintMove, setHintMove] = useState<{ from: string; to: string } | null>(null); | |
| const [isPlayerTurn, setIsPlayerTurn] = useState<boolean>(true); | |
| const [playerColor, setPlayerColor] = useState<'white' | 'black'>('white'); | |
| const [lastMove, setLastMove] = useState<{ from: string; to: string } | null>(null); | |
| const [isLoading, setIsLoading] = useState<boolean>(false); | |
| const [moveInProgress, setMoveInProgress] = useState<boolean>(false); | |
| const [statusMessage, setStatusMessage] = useState<string>(''); | |
| const [gameStarted, setGameStarted] = useState<boolean>(false); | |
| const [lastPlayerMove, setLastPlayerMove] = useState<{ from: string; to: string } | null>(null); | |
| const [lastAiMove, setLastAiMove] = useState<{ from: string; to: string } | null>(null); | |
| // Initialize the board and preload sounds | |
| useEffect(() => { | |
| fetchGameState(); | |
| preloadSounds(); // Preload sound effects | |
| }, []); | |
| // Parse FEN and update pieces | |
| useEffect(() => { | |
| if (fen) { | |
| const parsedPieces = parseFen(fen); | |
| setPieces(parsedPieces); | |
| } | |
| }, [fen]); | |
| const fetchGameState = async () => { | |
| try { | |
| console.log('Fetching game state...'); | |
| const response = await getGameState(); | |
| console.log('Game state response:', response); | |
| setFen(response.board_state.fen); | |
| setLegalMoves(response.board_state.legal_moves); | |
| setIsPlayerTurn(response.board_state.turn === (response.player_color || 'white')); | |
| if (response.player_color) { | |
| setPlayerColor(response.player_color); | |
| } | |
| // Check if game has started (move count > 0 or legal moves available) | |
| setGameStarted(response.board_state.move_count > 0 || response.board_state.legal_moves.length > 0); | |
| } catch (error) { | |
| console.error('Error fetching game state:', error); | |
| console.error('Error details:', error.response?.data || error.message); | |
| setStatusMessage(`Connection error: ${error.response?.status || 'Server not responding'}`); | |
| } | |
| }; | |
| const parseFen = (fenString: string): Piece[] => { | |
| const pieces: Piece[] = []; | |
| const fenParts = fenString.split(' '); | |
| const ranks = fenParts[0].split('/'); | |
| ranks.forEach((rank, rankIndex) => { | |
| let fileIndex = 0; | |
| for (let i = 0; i < rank.length; i++) { | |
| const char = rank[i]; | |
| if (!isNaN(parseInt(char))) { | |
| fileIndex += parseInt(char); | |
| } else { | |
| const color = char === char.toUpperCase() ? 'white' : 'black'; | |
| const type = char.toLowerCase(); | |
| const position = String.fromCharCode(97 + fileIndex) + (8 - rankIndex); | |
| pieces.push({ | |
| type, | |
| color, | |
| position, | |
| }); | |
| fileIndex++; | |
| } | |
| } | |
| }); | |
| return pieces; | |
| }; | |
| const handleSquareClick = async (square: string) => { | |
| // Check if game has started | |
| if (!gameStarted) { | |
| setStatusMessage('Please start a new game first'); | |
| return; | |
| } | |
| // If a hint is active and the clicked square is the hint destination | |
| if (hintMove && square === hintMove.to) { | |
| await handleMove(hintMove.from, hintMove.to); | |
| setHintMove(null); | |
| return; | |
| } | |
| // Find if there's a piece on the clicked square | |
| const pieceOnSquare = pieces.find((p: Piece) => p.position === square); | |
| // If a piece is already selected | |
| if (selectedPiece) { | |
| // Check if the clicked square is a legal move | |
| const move = `${selectedPiece}${square}`; | |
| const isLegalMove = legalMoves.some((m: string) => m === move); | |
| if (isLegalMove) { | |
| await handleMove(selectedPiece, square); | |
| } | |
| // Clear selection | |
| setSelectedPiece(null); | |
| setHighlightedSquares([]); | |
| } | |
| // If clicking on a piece of the player's color | |
| else if (pieceOnSquare && pieceOnSquare.color === (isPlayerTurn ? playerColor : (playerColor === 'white' ? 'black' : 'white'))) { | |
| setSelectedPiece(square); | |
| // Highlight legal moves for this piece | |
| const legalMovesForPiece = legalMoves | |
| .filter((move: string) => move.startsWith(square)) | |
| .map((move: string) => move.substring(2)); | |
| const highlights: HighlightedSquare[] = [ | |
| { square, type: 'selected' }, | |
| ...legalMovesForPiece.map((move: string) => ({ square: move, type: 'legal-move' })), | |
| ]; | |
| setHighlightedSquares(highlights); | |
| } | |
| }; | |
| const handleMove = async (from: string, to: string) => { | |
| try { | |
| console.log(`Attempting to make move: ${from} to ${to}`); | |
| // IMMEDIATELY switch to AI's turn when player commits to a move | |
| // This stops the player's clock and starts the AI's clock | |
| setIsPlayerTurn(false); | |
| console.log('Player move committed - switched to AI turn'); | |
| // Set loading state and update status message | |
| setMoveInProgress(true); | |
| setStatusMessage('Processing move...'); | |
| // IMMEDIATELY update the UI for the player's move (optimistic update) | |
| // Find the piece being moved | |
| const movingPiece = pieces.find(p => p.position === from); | |
| const targetPiece = pieces.find(p => p.position === to); | |
| if (movingPiece) { | |
| // Create a new pieces array with the updated position | |
| const updatedPieces = pieces.map(p => { | |
| if (p.position === from) { | |
| return { ...p, position: to }; | |
| } | |
| // Remove the captured piece if there is one | |
| if (p.position === to && p !== movingPiece) { | |
| return null; | |
| } | |
| return p; | |
| }).filter(Boolean) as Piece[]; | |
| // Update the pieces state immediately for the player's move | |
| setPieces(updatedPieces); | |
| // Determine the type of move for sound effects | |
| const isCapture = targetPiece !== undefined; | |
| const isCastle = movingPiece.type === 'k' && | |
| ((from === 'e1' && (to === 'g1' || to === 'c1')) || | |
| (from === 'e8' && (to === 'g8' || to === 'c8'))); | |
| // Play appropriate sound | |
| if (isCastle) { | |
| playSound('castle'); | |
| } else if (isCapture) { | |
| playSound('capture'); | |
| } else { | |
| playSound('move'); | |
| } | |
| } | |
| // First highlight the squares for visual feedback | |
| setHighlightedSquares([ | |
| { square: from, type: 'selected' }, | |
| { square: to, type: 'selected' } | |
| ]); | |
| // Make the API call to process the move | |
| const moveNotation = `${from}${to}`; | |
| console.log(`Making API call with move: ${moveNotation}`); | |
| const response = await makeMove(moveNotation); | |
| console.log('API response:', response); | |
| if (response.status === 'success' || response.status === 'game_over') { | |
| // Check if the move puts the opponent in check | |
| const isCheck = response.board_state?.in_check; | |
| if (isCheck) { | |
| setTimeout(() => playSound('check'), 150); | |
| setStatusMessage('Check!'); | |
| } else { | |
| setStatusMessage(''); | |
| } | |
| // Check if game is over | |
| if (response.status === 'game_over') { | |
| setTimeout(() => playSound('game-end'), 300); | |
| setStatusMessage(response.result === 'draw' ? 'Game ended in a draw' : `${response.winner} wins!`); | |
| } | |
| // Store player's move first | |
| setLastPlayerMove({ from, to }); | |
| setLastMove({ from, to }); | |
| // Update the board state with the server response (but not the FEN yet if AI will move) | |
| setLegalMoves(response.board_state.legal_moves); | |
| // If AI made a move, handle the sequence properly | |
| if (response.ai_move) { | |
| const aiFrom = response.ai_move.substring(0, 2); | |
| const aiTo = response.ai_move.substring(2, 4); | |
| // AI's clock is already running since we switched turns at the start | |
| // Show AI move after a delay | |
| setTimeout(() => { | |
| // Store AI's last move | |
| setLastAiMove({ from: aiFrom, to: aiTo }); | |
| // Update the board with AI's move | |
| setFen(response.board_state.fen); | |
| // Play sound for AI move | |
| const aiMovingPiece = pieces.find(p => p.position === aiFrom); | |
| const aiTargetPiece = pieces.find(p => p.position === aiTo); | |
| if (aiMovingPiece) { | |
| const isAiCapture = aiTargetPiece !== undefined; | |
| const isAiCastle = aiMovingPiece.type === 'k' && | |
| ((aiFrom === 'e1' && (aiTo === 'g1' || aiTo === 'c1')) || | |
| (aiFrom === 'e8' && (aiTo === 'g8' || aiTo === 'c8'))); | |
| if (isAiCastle) { | |
| playSound('castle'); | |
| } else if (isAiCapture) { | |
| playSound('capture'); | |
| } else { | |
| playSound('move'); | |
| } | |
| } | |
| // After AI move is shown, switch back to player's turn | |
| setTimeout(() => { | |
| const finalPlayerTurn = response.board_state.turn === playerColor; | |
| setIsPlayerTurn(finalPlayerTurn); | |
| console.log(`Final turn update: ${finalPlayerTurn ? 'Player' : 'AI'} turn`); | |
| }, 200); | |
| }, 800); // Delay AI move display by 800ms | |
| } else { | |
| // No AI move, just update the board and turn | |
| setFen(response.board_state.fen); | |
| const newPlayerTurn = response.board_state.turn === playerColor; | |
| setIsPlayerTurn(newPlayerTurn); | |
| console.log(`Updated turn: board turn=${response.board_state.turn}, playerColor=${playerColor}, isPlayerTurn=${newPlayerTurn}`); | |
| } | |
| // Clear highlights after a short delay | |
| setTimeout(() => { | |
| setHighlightedSquares([]); | |
| }, 500); | |
| } else { | |
| // Clear highlights immediately if move failed | |
| setHighlightedSquares([]); | |
| setStatusMessage('Invalid move'); | |
| // Reset pieces to match the FEN since our optimistic update was wrong | |
| setPieces(parseFen(fen)); | |
| } | |
| } catch (error) { | |
| console.error('Error making move:', error); | |
| console.error('Error details:', error.response?.data || error.message); | |
| setHighlightedSquares([]); | |
| setStatusMessage(`Error making move: ${error.response?.data?.message || error.message}`); | |
| // Reset pieces to match the FEN since our optimistic update was wrong | |
| setPieces(parseFen(fen)); | |
| // Reset player turn on error | |
| setIsPlayerTurn(true); | |
| } finally { | |
| setMoveInProgress(false); | |
| } | |
| }; | |
| const handleRequestHint = async () => { | |
| try { | |
| const response = await getHint(); | |
| if (response.status === 'success') { | |
| const hint = response.hint; | |
| const from = hint.substring(0, 2); | |
| const to = hint.substring(2, 4); | |
| setHintMove({ from, to }); | |
| setHighlightedSquares([ | |
| { square: from, type: 'hint' }, | |
| { square: to, type: 'hint' }, | |
| ]); | |
| } | |
| } catch (error) { | |
| console.error('Error getting hint:', error); | |
| } | |
| }; | |
| const renderSquare = (square: string, isLight: boolean) => { | |
| const piece = pieces.find(p => p.position === square); | |
| const isHighlighted = highlightedSquares.find(h => h.square === square); | |
| const isLastMoveFrom = lastMove && lastMove.from === square; | |
| const isLastMoveTo = lastMove && lastMove.to === square; | |
| // Check for player and AI move highlights | |
| const isPlayerMoveFrom = lastPlayerMove && lastPlayerMove.from === square; | |
| const isPlayerMoveTo = lastPlayerMove && lastPlayerMove.to === square; | |
| const isAiMoveFrom = lastAiMove && lastAiMove.from === square; | |
| const isAiMoveTo = lastAiMove && lastAiMove.to === square; | |
| const squareColor = isLight ? 'light' : 'dark'; | |
| const boardColor = 'brown'; | |
| const colorInFilename = 'brown'; | |
| const imageUrl = `/assets/boards/${boardColor}/square ${colorInFilename} ${squareColor}_1x.png`; | |
| return ( | |
| <div | |
| key={square} | |
| className={`chess-square ${isLight ? 'bg-board-light' : 'bg-board-dark'} ${isLight ? 'light-square' : 'dark-square'}`} | |
| style={{ | |
| backgroundImage: `url(${imageUrl})`, | |
| backgroundSize: 'cover', | |
| backgroundColor: isLight ? '#f0d9b5' : '#b58863' | |
| }} | |
| data-high-res={`/assets/boards/${boardColor}/square ${colorInFilename} ${squareColor}_2x.png`} | |
| data-theme={theme} | |
| onClick={() => handleSquareClick(square)} | |
| > | |
| {isHighlighted && isHighlighted.type === 'selected' && ( | |
| <div className="square-selected"></div> | |
| )} | |
| {isHighlighted && isHighlighted.type === 'hint' && ( | |
| <div className="hint-square"></div> | |
| )} | |
| {isHighlighted && isHighlighted.type === 'legal-move' && ( | |
| <div | |
| className="move-dot" | |
| style={{ | |
| position: 'absolute', | |
| top: '50%', | |
| left: '50%', | |
| transform: 'translate(-50%, -50%)', | |
| width: piece ? '80%' : '25%', // Larger circle for capture moves | |
| height: piece ? '80%' : '25%', // Larger circle for capture moves | |
| borderRadius: '50%', | |
| backgroundColor: piece ? 'rgba(34, 197, 94, 0.3)' : 'rgba(34, 197, 94, 0.8)', // Different opacity for captures | |
| border: piece ? '3px solid rgba(34, 197, 94, 0.9)' : 'none', // Border for capture moves | |
| zIndex: piece ? 3 : 2, // Higher z-index for captures to show above pieces | |
| pointerEvents: 'none' | |
| }} | |
| /> | |
| )} | |
| {/* Player move highlighting - light blue border */} | |
| {(isPlayerMoveFrom || isPlayerMoveTo) && ( | |
| <div | |
| className="player-move-highlight" | |
| style={{ | |
| position: 'absolute', | |
| top: 0, | |
| left: 0, | |
| right: 0, | |
| bottom: 0, | |
| boxShadow: 'inset 0 0 0 4px rgba(0, 120, 255, 0.8)', | |
| zIndex: 1 | |
| }} | |
| /> | |
| )} | |
| {/* AI move highlighting - orange border */} | |
| {(isAiMoveFrom || isAiMoveTo) && ( | |
| <div | |
| className="ai-move-highlight" | |
| style={{ | |
| position: 'absolute', | |
| top: 0, | |
| left: 0, | |
| right: 0, | |
| bottom: 0, | |
| boxShadow: 'inset 0 0 0 4px rgba(255, 165, 0, 0.8)', | |
| zIndex: 1 | |
| }} | |
| /> | |
| )} | |
| {piece && ( | |
| <div | |
| className={`${isLastMoveFrom || isLastMoveTo ? 'moving-piece' : ''}`} | |
| style={{ | |
| backgroundImage: `url(/assets/pieces/${piece.color[0]}_${getPieceName(piece.type)}_1x.png)`, | |
| backgroundSize: 'contain', | |
| backgroundRepeat: 'no-repeat', | |
| backgroundPosition: 'center', | |
| width: '85%', | |
| height: '85%', | |
| transition: 'transform 0.2s', | |
| position: 'relative', | |
| zIndex: 2 | |
| }} | |
| data-high-res={`/assets/pieces/${piece.color[0]}_${getPieceName(piece.type)}_2x.png`} | |
| /> | |
| )} | |
| </div> | |
| ); | |
| }; | |
| const getPieceName = (type: string): string => { | |
| switch (type) { | |
| case 'p': return 'pawn'; | |
| case 'r': return 'rook'; | |
| case 'n': return 'knight'; | |
| case 'b': return 'bishop'; | |
| case 'q': return 'queen'; | |
| case 'k': return 'king'; | |
| default: return 'pawn'; | |
| } | |
| }; | |
| const renderBoard = () => { | |
| const squares = []; | |
| const files = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h']; | |
| const ranks = ['8', '7', '6', '5', '4', '3', '2', '1']; | |
| // Flip the board if player is black | |
| const displayRanks = playerColor === 'white' ? ranks : [...ranks].reverse(); | |
| const displayFiles = playerColor === 'white' ? files : [...files].reverse(); | |
| for (let rankIndex = 0; rankIndex < 8; rankIndex++) { | |
| for (let fileIndex = 0; fileIndex < 8; fileIndex++) { | |
| const file = displayFiles[fileIndex]; | |
| const rank = displayRanks[rankIndex]; | |
| const square = file + rank; | |
| const isLight = (fileIndex + rankIndex) % 2 === 1; | |
| squares.push(renderSquare(square, isLight)); | |
| } | |
| } | |
| return squares; | |
| }; | |
| return ( | |
| <div className="flex flex-col items-center"> | |
| {/* Chess Clock */} | |
| <ChessClock | |
| isPlayerTurn={isPlayerTurn} | |
| playerColor={playerColor} | |
| gameActive={gameStarted && !statusMessage.includes('wins') && !statusMessage.includes('draw')} | |
| initialTimeMinutes={10} | |
| /> | |
| {/* Status message and turn indicator */} | |
| <div className="w-full max-w-[800px] mb-4 flex justify-between items-center text-gradio-text"> | |
| <div className="text-2xl font-medium"> | |
| {!gameStarted ? "Click 'New Game' to start playing" : (isPlayerTurn ? "Your turn" : "AI is thinking...")} | |
| </div> | |
| <div className={`text-2xl font-bold ${ | |
| statusMessage.includes('Check') | |
| ? 'text-gradio-red' | |
| : statusMessage.includes('wins') | |
| ? 'text-gradio-green' | |
| : 'text-gradio-blue' | |
| }`}> | |
| {statusMessage} | |
| </div> | |
| {moveInProgress && ( | |
| <div className="flex items-center"> | |
| <div className="animate-spin rounded-full h-7 w-7 border-b-2 border-gradio-orange mr-2"></div> | |
| <span className="text-xl">Processing move...</span> | |
| </div> | |
| )} | |
| </div> | |
| {/* Chess board with external rank and file labels */} | |
| <div className="relative" style={{ width: '880px', maxWidth: '100%', margin: '0 auto', paddingBottom: '60px' }}> | |
| {/* Rank labels (1-8) */} | |
| <div className="absolute left-0 top-0 bottom-60px flex flex-col justify-around" style={{ width: '40px', height: '800px' }}> | |
| {playerColor === 'white' | |
| ? ['8', '7', '6', '5', '4', '3', '2', '1'].map(rank => ( | |
| <div key={rank} className="flex items-center justify-center text-2xl font-bold text-gradio-text"> | |
| {rank} | |
| </div> | |
| )) | |
| : ['1', '2', '3', '4', '5', '6', '7', '8'].map(rank => ( | |
| <div key={rank} className="flex items-center justify-center text-2xl font-bold text-gradio-text"> | |
| {rank} | |
| </div> | |
| )) | |
| } | |
| </div> | |
| {/* Chess board with dark theme border */} | |
| <div | |
| className="chess-board ml-10" | |
| style={{ | |
| display: 'grid', | |
| gridTemplateColumns: 'repeat(8, 1fr)', | |
| gridTemplateRows: 'repeat(8, 1fr)', | |
| width: '800px', | |
| height: '800px', | |
| maxWidth: 'calc(100% - 40px)', | |
| boxShadow: '0 4px 15px -1px rgba(0, 0, 0, 0.5)', | |
| position: 'relative', | |
| border: '4px solid #374151', | |
| margin: '0 auto' | |
| }} | |
| > | |
| {renderBoard()} | |
| {/* Loading overlay */} | |
| {isLoading && ( | |
| <div style={{ | |
| position: 'absolute', | |
| inset: 0, | |
| backgroundColor: 'rgba(0, 0, 0, 0.5)', | |
| display: 'flex', | |
| alignItems: 'center', | |
| justifyContent: 'center', | |
| zIndex: 10 | |
| }}> | |
| <div style={{ | |
| backgroundColor: '#1f2937', | |
| padding: '1.5rem', | |
| borderRadius: '0.5rem', | |
| boxShadow: '0 10px 15px -3px rgba(0, 0, 0, 0.3)', | |
| display: 'flex', | |
| flexDirection: 'column', | |
| alignItems: 'center', | |
| color: 'white' | |
| }}> | |
| <div className="animate-spin rounded-full h-12 w-12 border-b-2 border-gradio-orange mb-3"></div> | |
| <p className="text-xl">Loading...</p> | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| {/* File labels (a-h) */} | |
| <div className="absolute left-10 top-[800px] right-0 flex justify-around" style={{ height: '50px' }}> | |
| {playerColor === 'white' | |
| ? ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h'].map(file => ( | |
| <div key={file} className="flex items-center justify-center text-2xl font-bold text-gradio-text"> | |
| {file} | |
| </div> | |
| )) | |
| : ['h', 'g', 'f', 'e', 'd', 'c', 'b', 'a'].map(file => ( | |
| <div key={file} className="flex items-center justify-center text-2xl font-bold text-gradio-text"> | |
| {file} | |
| </div> | |
| )) | |
| } | |
| </div> | |
| </div> | |
| {/* Hint button with full width */} | |
| <button | |
| className="mt-20 w-[800px] max-w-full py-4 bg-gradio-green text-white rounded-lg hover:bg-green-500 transition-colors text-2xl font-medium disabled:bg-gray-400 disabled:cursor-not-allowed" | |
| onClick={handleRequestHint} | |
| disabled={moveInProgress || !isPlayerTurn || !gameStarted} | |
| style={{ margin: '0 auto' }} | |
| > | |
| Get Hint | |
| </button> | |
| </div> | |
| ); | |
| }; | |
| export default ChessBoard; |