feat(chess): add interactive Chessboard with drag-drop (P3.10)

This commit is contained in:
Joey Yakimowich-Payne 2026-04-16 16:01:34 -06:00
commit d367f51171
No known key found for this signature in database
5 changed files with 339 additions and 9 deletions

View file

@ -1,11 +1,12 @@
import { Routes, Route } from 'react-router-dom'
import { GameView } from '../ui/GameView'
export function App() {
return (
<div data-testid="app-root">
<Routes>
<Route path="/" element={<Home />} />
<Route path="/game" element={<Game />} />
<Route path="/game" element={<GameView />} />
<Route path="/rules" element={<Rules />} />
<Route path="/save" element={<Save />} />
</Routes>
@ -21,14 +22,6 @@ function Home() {
)
}
function Game() {
return (
<main data-testid="page-game">
<h1>Game</h1>
</main>
)
}
function Rules() {
return (
<main data-testid="page-rules">

View file

@ -0,0 +1,52 @@
import { useState, useRef, useCallback } from 'react';
import { ChessEngine, type GameResult } from '../engine';
import type { PieceType } from '../schema';
export function useChessEngine() {
const engineRef = useRef<ChessEngine | null>(null);
// Force re-render state
const [tick, setTick] = useState(0);
// Initialize engine lazily
if (!engineRef.current) {
engineRef.current = new ChessEngine();
}
const engine = engineRef.current;
const getTurn = useCallback(() => {
return engine.getCurrentTurn();
}, [engine, tick]);
const getFacts = useCallback(() => {
return engine.session.allFacts();
}, [engine, tick]);
const getLegalMoves = useCallback(() => {
return engine.getAllLegalMoves();
}, [engine, tick]);
const getResult = useCallback(() => {
return engine.checkGameResult();
}, [engine, tick]);
const applyMove = useCallback((from: number, to: number, promoteTo: PieceType = 'queen'): GameResult | null => {
const move = engine.findMove(from, to, promoteTo);
if (move) {
const result = engine.applyMove(move, promoteTo);
setTick(t => t + 1); // trigger re-render
return result;
}
return null;
}, [engine]);
return {
engine,
turn: getTurn(),
facts: getFacts(),
legalMoves: getLegalMoves(),
result: getResult(),
applyMove,
};
}

View file

@ -0,0 +1,155 @@
import { useState, useMemo } from 'react';
import type { ChessFact, PieceColor, PieceType } from '../schema';
import { squareToAlgebraic, squareOf, squareColor } from '../coord';
import type { LegalMove } from '../rules/types';
import { Piece } from './Piece';
interface BoardProps {
facts: ChessFact[];
legalMoves: LegalMove[];
onMove: (from: number, to: number) => void;
turn: PieceColor;
}
interface PieceState {
id: number;
type: PieceType;
color: PieceColor;
}
export function Board({ facts, legalMoves, onMove, turn }: BoardProps) {
// Build pieces map: square -> { id, type, color }
const pieces = useMemo(() => {
const map = new Map<number, PieceState>();
// Group facts by entity ID
const entityFacts = new Map<number, { pos?: number; type?: PieceType; color?: PieceColor }>();
for (const fact of facts) {
if (typeof fact.id !== 'number' || fact.id === 0) continue; // Skip GAME_ENTITY
let ent = entityFacts.get(fact.id);
if (!ent) {
ent = {};
entityFacts.set(fact.id, ent);
}
if (fact.attr === 'Position') ent.pos = fact.value as number;
if (fact.attr === 'PieceType') ent.type = fact.value as PieceType;
if (fact.attr === 'Color') ent.color = fact.value as PieceColor;
}
// Populate the board map
for (const [id, ent] of entityFacts.entries()) {
if (ent.pos !== undefined && ent.type && ent.color) {
map.set(ent.pos, { id, type: ent.type, color: ent.color });
}
}
return map;
}, [facts]);
// Drag state
const [draggedPiece, setDraggedPiece] = useState<{ id: number, square: number } | null>(null);
// Compute highlighted squares based on current dragged piece
const highlightedSquares = useMemo(() => {
if (!draggedPiece) return new Set<number>();
const targets = new Set<number>();
for (const move of legalMoves) {
if (move.pieceId === draggedPiece.id) {
targets.add(move.to);
}
}
return targets;
}, [draggedPiece, legalMoves]);
// Handlers
const handleDragStart = (id: number, square: number) => {
const p = pieces.get(square);
if (p && p.color === turn) { // only drag pieces of current turn
setDraggedPiece({ id, square });
}
};
const handleDragEnd = () => {
setDraggedPiece(null);
};
const handleDragOver = (e: React.DragEvent, square: number) => {
if (!draggedPiece) return;
if (highlightedSquares.has(square)) {
e.preventDefault(); // allow drop
e.dataTransfer.dropEffect = 'move';
}
};
const handleDrop = (e: React.DragEvent, targetSquare: number) => {
e.preventDefault();
if (draggedPiece && highlightedSquares.has(targetSquare)) {
onMove(draggedPiece.square, targetSquare);
}
setDraggedPiece(null);
};
// Generate board squares (rank 7 down to 0, file 0 to 7)
const squares = [];
for (let r = 7; r >= 0; r--) {
for (let f = 0; f <= 7; f++) {
const sq = squareOf(f, r);
const isDark = squareColor(sq) === 'dark';
const algebraic = squareToAlgebraic(sq);
const isHighlighted = highlightedSquares.has(sq);
const piece = pieces.get(sq);
squares.push(
<div
key={sq}
data-square={algebraic}
className={`relative aspect-square flex items-center justify-center ${
isDark ? 'bg-amber-700' : 'bg-amber-100'
}`}
onDragOver={(e) => handleDragOver(e, sq)}
onDrop={(e) => handleDrop(e, sq)}
>
{isHighlighted && (
<div className="absolute inset-0 bg-green-400/60 pointer-events-none ring-4 ring-inset ring-green-500/70 z-10" />
)}
{piece && (
<div className="absolute inset-0 z-20">
<Piece
color={piece.color}
type={piece.type}
pieceId={piece.id}
square={sq}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
/>
</div>
)}
{/* Rank/File labels (optional but helpful for debug) */}
{f === 0 && (
<div className={`absolute top-1 left-1 text-xs font-semibold select-none ${isDark ? 'text-amber-100' : 'text-amber-700'}`}>
{r + 1}
</div>
)}
{r === 0 && (
<div className={`absolute bottom-1 right-1 text-xs font-semibold select-none ${isDark ? 'text-amber-100' : 'text-amber-700'}`}>
{String.fromCharCode(97 + f)}
</div>
)}
</div>
);
}
}
return (
<div className="grid grid-cols-8 grid-rows-8 w-full max-w-2xl mx-auto border-4 border-amber-900 rounded shadow-2xl overflow-hidden">
{squares}
</div>
);
}

View file

@ -0,0 +1,78 @@
import { useChessEngine } from '../hooks/useChessEngine';
import { Board } from './Board';
import type { ChessFact, ChessAttrMap } from '../schema';
interface GameViewProps {
onUndo?: () => void;
}
export function GameView({ onUndo }: GameViewProps) {
const { facts, legalMoves, turn, result, applyMove } = useChessEngine();
const handleMove = (from: number, to: number) => {
applyMove(from, to, 'queen');
};
const isGameOver = result !== 'ongoing';
return (
<div className="flex flex-col items-center gap-8 py-8 w-full max-w-4xl mx-auto">
<div className="flex flex-col md:flex-row w-full items-start justify-between gap-4 px-4">
{/* Header/Info section */}
<div className="flex flex-col gap-4">
<h1 className="text-3xl font-bold tracking-tight text-neutral-800">
Chess
</h1>
<div className="flex items-center gap-3">
<div
data-testid="turn-indicator"
className="flex items-center gap-2 px-4 py-2 bg-neutral-100 border border-neutral-200 rounded-md font-medium text-neutral-700 shadow-sm"
>
<div
className={`w-3 h-3 rounded-full shadow-inner ${turn === 'white' ? 'bg-white border border-neutral-300' : 'bg-neutral-900 border border-neutral-950'}`}
/>
<span>{turn === 'white' ? "White's turn" : "Black's turn"}</span>
</div>
<button
data-action="undo"
onClick={onUndo}
className="px-4 py-2 bg-white hover:bg-neutral-50 active:bg-neutral-100 text-neutral-600 font-medium rounded-md border border-neutral-300 shadow-sm transition-colors focus:outline-none focus:ring-2 focus:ring-neutral-200"
title="Undo last move"
>
Undo
</button>
</div>
</div>
{/* Game Result Banner */}
{isGameOver && (
<div
data-testid="game-over"
className="px-6 py-3 bg-amber-100 border border-amber-300 text-amber-900 font-semibold rounded-md shadow-sm"
>
{result === 'checkmate' ? 'Checkmate!' : `Draw: ${result.replace('draw-', '')}`}
</div>
)}
</div>
<div className="w-full px-4 relative">
<Board
facts={facts as ChessFact<keyof ChessAttrMap>[]}
legalMoves={legalMoves}
turn={turn}
onMove={handleMove}
/>
{/* Overlay for game over to prevent further interaction visually */}
{isGameOver && (
<div className="absolute inset-0 bg-white/20 backdrop-blur-[1px] pointer-events-none z-30" />
)}
</div>
</div>
);
}

View file

@ -0,0 +1,52 @@
import type { PieceColor, PieceType } from '../schema';
export interface PieceProps {
color: PieceColor;
type: PieceType;
pieceId: number;
square: number;
onDragStart: (pieceId: number, square: number) => void;
onDragEnd: () => void;
}
const PIECE_SYMBOLS: Record<PieceColor, Record<PieceType, string>> = {
white: {
king: '♔',
queen: '♕',
rook: '♖',
bishop: '♗',
knight: '♘',
pawn: '♙',
},
black: {
king: '♚',
queen: '♛',
rook: '♜',
bishop: '♝',
knight: '♞',
pawn: '♟',
},
};
export function Piece({ color, type, pieceId, square, onDragStart, onDragEnd }: PieceProps) {
const symbol = PIECE_SYMBOLS[color][type];
return (
<div
draggable
data-piece={`${color}-${type}`}
data-piece-id={pieceId}
onDragStart={(e) => {
// Set drag image (optional, but good for native dnd)
e.dataTransfer.effectAllowed = 'move';
// Need to set some data for Firefox to allow dragging
e.dataTransfer.setData('text/plain', `${pieceId}@${square}`);
onDragStart(pieceId, square);
}}
onDragEnd={onDragEnd}
className="flex items-center justify-center w-full h-full text-5xl cursor-grab active:cursor-grabbing select-none hover:bg-white/20 rounded"
>
{symbol}
</div>
);
}