Chess_Engine / web /src /components /ChessBoard.tsx
electro-sb's picture
first commit
100a6dd
/// <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;