From d367f511712a3fec0ccce4b9a120c269bbbe8bfa Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Thu, 16 Apr 2026 16:01:34 -0600 Subject: [PATCH] feat(chess): add interactive Chessboard with drag-drop (P3.10) --- packages/chess/src/app/App.tsx | 11 +- packages/chess/src/hooks/useChessEngine.ts | 52 +++++++ packages/chess/src/ui/Board.tsx | 155 +++++++++++++++++++++ packages/chess/src/ui/GameView.tsx | 78 +++++++++++ packages/chess/src/ui/Piece.tsx | 52 +++++++ 5 files changed, 339 insertions(+), 9 deletions(-) create mode 100644 packages/chess/src/hooks/useChessEngine.ts create mode 100644 packages/chess/src/ui/Board.tsx create mode 100644 packages/chess/src/ui/GameView.tsx create mode 100644 packages/chess/src/ui/Piece.tsx diff --git a/packages/chess/src/app/App.tsx b/packages/chess/src/app/App.tsx index e67e411..ac30162 100644 --- a/packages/chess/src/app/App.tsx +++ b/packages/chess/src/app/App.tsx @@ -1,11 +1,12 @@ import { Routes, Route } from 'react-router-dom' +import { GameView } from '../ui/GameView' export function App() { return (
} /> - } /> + } /> } /> } /> @@ -21,14 +22,6 @@ function Home() { ) } -function Game() { - return ( -
-

Game

-
- ) -} - function Rules() { return (
diff --git a/packages/chess/src/hooks/useChessEngine.ts b/packages/chess/src/hooks/useChessEngine.ts new file mode 100644 index 0000000..5f37dde --- /dev/null +++ b/packages/chess/src/hooks/useChessEngine.ts @@ -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(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, + }; +} diff --git a/packages/chess/src/ui/Board.tsx b/packages/chess/src/ui/Board.tsx new file mode 100644 index 0000000..d479b6e --- /dev/null +++ b/packages/chess/src/ui/Board.tsx @@ -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(); + + // Group facts by entity ID + const entityFacts = new Map(); + + 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(); + + const targets = new Set(); + 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( +
handleDragOver(e, sq)} + onDrop={(e) => handleDrop(e, sq)} + > + {isHighlighted && ( +
+ )} + + {piece && ( +
+ +
+ )} + + {/* Rank/File labels (optional but helpful for debug) */} + {f === 0 && ( +
+ {r + 1} +
+ )} + {r === 0 && ( +
+ {String.fromCharCode(97 + f)} +
+ )} +
+ ); + } + } + + return ( +
+ {squares} +
+ ); +} diff --git a/packages/chess/src/ui/GameView.tsx b/packages/chess/src/ui/GameView.tsx new file mode 100644 index 0000000..3771401 --- /dev/null +++ b/packages/chess/src/ui/GameView.tsx @@ -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 ( +
+
+ + {/* Header/Info section */} +
+

+ Chess +

+ +
+
+
+ {turn === 'white' ? "White's turn" : "Black's turn"} +
+ + +
+
+ + {/* Game Result Banner */} + {isGameOver && ( +
+ {result === 'checkmate' ? 'Checkmate!' : `Draw: ${result.replace('draw-', '')}`} +
+ )} +
+ +
+ []} + legalMoves={legalMoves} + turn={turn} + onMove={handleMove} + /> + + {/* Overlay for game over to prevent further interaction visually */} + {isGameOver && ( +
+ )} +
+ +
+ ); +} diff --git a/packages/chess/src/ui/Piece.tsx b/packages/chess/src/ui/Piece.tsx new file mode 100644 index 0000000..3472a3f --- /dev/null +++ b/packages/chess/src/ui/Piece.tsx @@ -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> = { + 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 ( +
{ + // 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} +
+ ); +}