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 { 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">
|
||||
|
|
|
|||
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