feat(chess): add interactive Chessboard with drag-drop (P3.10)
This commit is contained in:
parent
0f891fa013
commit
d367f51171
5 changed files with 339 additions and 9 deletions
|
|
@ -1,11 +1,12 @@
|
||||||
import { Routes, Route } from 'react-router-dom'
|
import { Routes, Route } from 'react-router-dom'
|
||||||
|
import { GameView } from '../ui/GameView'
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
return (
|
return (
|
||||||
<div data-testid="app-root">
|
<div data-testid="app-root">
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Home />} />
|
<Route path="/" element={<Home />} />
|
||||||
<Route path="/game" element={<Game />} />
|
<Route path="/game" element={<GameView />} />
|
||||||
<Route path="/rules" element={<Rules />} />
|
<Route path="/rules" element={<Rules />} />
|
||||||
<Route path="/save" element={<Save />} />
|
<Route path="/save" element={<Save />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|
@ -21,14 +22,6 @@ function Home() {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function Game() {
|
|
||||||
return (
|
|
||||||
<main data-testid="page-game">
|
|
||||||
<h1>Game</h1>
|
|
||||||
</main>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function Rules() {
|
function Rules() {
|
||||||
return (
|
return (
|
||||||
<main data-testid="page-rules">
|
<main data-testid="page-rules">
|
||||||
|
|
|
||||||
52
packages/chess/src/hooks/useChessEngine.ts
Normal file
52
packages/chess/src/hooks/useChessEngine.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
155
packages/chess/src/ui/Board.tsx
Normal file
155
packages/chess/src/ui/Board.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
78
packages/chess/src/ui/GameView.tsx
Normal file
78
packages/chess/src/ui/GameView.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
52
packages/chess/src/ui/Piece.tsx
Normal file
52
packages/chess/src/ui/Piece.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue