feat(chess): add Save/Load panel + time-travel undo (P3.12)
This commit is contained in:
parent
116cbb42b5
commit
bc753aadfd
4 changed files with 206 additions and 19 deletions
|
|
@ -1,20 +1,47 @@
|
||||||
import { Routes, Route } from 'react-router-dom'
|
import { Routes, Route, useNavigate } from 'react-router-dom'
|
||||||
import { GameView } from '../ui/GameView'
|
import { GameView } from '../ui/GameView'
|
||||||
import { RulesView } from '../ui/RulesView'
|
import { RulesView } from '../ui/RulesView'
|
||||||
|
import { SavePanel } from '../ui/SavePanel'
|
||||||
|
import { useChessEngine } from '../hooks/useChessEngine'
|
||||||
|
import { ChessEngine } from '../engine'
|
||||||
|
import type { AttrKey, FactValue } from '@paratype/rete'
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
|
const chessState = useChessEngine()
|
||||||
|
|
||||||
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={<GameView />} />
|
<Route path="/game" element={<GameView engineState={chessState} />} />
|
||||||
<Route path="/rules" element={<RulesView />} />
|
<Route path="/rules" element={<RulesView />} />
|
||||||
<Route path="/save" element={<Save />} />
|
<Route path="/save" element={<SaveWrapper chessState={chessState} />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function SaveWrapper({ chessState }: { chessState: ReturnType<typeof useChessEngine> }) {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
return (
|
||||||
|
<SavePanel
|
||||||
|
currentFacts={chessState.facts}
|
||||||
|
onLoad={(loadedFacts) => {
|
||||||
|
const newEngine = new ChessEngine()
|
||||||
|
const oldFacts = newEngine.session.allFacts();
|
||||||
|
for (const f of oldFacts) {
|
||||||
|
newEngine.session.retract(f.id, f.attr as AttrKey);
|
||||||
|
}
|
||||||
|
for (const fact of loadedFacts) {
|
||||||
|
newEngine.session.insert(fact.id, fact.attr as AttrKey, fact.value as FactValue)
|
||||||
|
}
|
||||||
|
chessState.loadEngine(newEngine)
|
||||||
|
navigate('/game')
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function Home() {
|
function Home() {
|
||||||
return (
|
return (
|
||||||
<main data-testid="page-home">
|
<main data-testid="page-home">
|
||||||
|
|
@ -22,11 +49,3 @@ function Home() {
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function Save() {
|
|
||||||
return (
|
|
||||||
<main data-testid="page-save">
|
|
||||||
<h1>Save</h1>
|
|
||||||
</main>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import type { PieceType } from '../schema';
|
||||||
|
|
||||||
export function useChessEngine() {
|
export function useChessEngine() {
|
||||||
const engineRef = useRef<ChessEngine | null>(null);
|
const engineRef = useRef<ChessEngine | null>(null);
|
||||||
|
const moveHistoryRef = useRef<Array<{ from: number, to: number, promoteTo: PieceType }>>([]);
|
||||||
|
|
||||||
// Force re-render state
|
// Force re-render state
|
||||||
const [tick, setTick] = useState(0);
|
const [tick, setTick] = useState(0);
|
||||||
|
|
@ -15,6 +16,12 @@ export function useChessEngine() {
|
||||||
|
|
||||||
const engine = engineRef.current;
|
const engine = engineRef.current;
|
||||||
|
|
||||||
|
const loadEngine = useCallback((newEngine: ChessEngine) => {
|
||||||
|
engineRef.current = newEngine;
|
||||||
|
moveHistoryRef.current = [];
|
||||||
|
setTick(t => t + 1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const getTurn = useCallback(() => {
|
const getTurn = useCallback(() => {
|
||||||
return engine.getCurrentTurn();
|
return engine.getCurrentTurn();
|
||||||
}, [engine, tick]);
|
}, [engine, tick]);
|
||||||
|
|
@ -34,6 +41,7 @@ export function useChessEngine() {
|
||||||
const applyMove = useCallback((from: number, to: number, promoteTo: PieceType = 'queen'): GameResult | null => {
|
const applyMove = useCallback((from: number, to: number, promoteTo: PieceType = 'queen'): GameResult | null => {
|
||||||
const move = engine.findMove(from, to, promoteTo);
|
const move = engine.findMove(from, to, promoteTo);
|
||||||
if (move) {
|
if (move) {
|
||||||
|
moveHistoryRef.current.push({ from, to, promoteTo });
|
||||||
const result = engine.applyMove(move, promoteTo);
|
const result = engine.applyMove(move, promoteTo);
|
||||||
setTick(t => t + 1); // trigger re-render
|
setTick(t => t + 1); // trigger re-render
|
||||||
return result;
|
return result;
|
||||||
|
|
@ -41,6 +49,21 @@ export function useChessEngine() {
|
||||||
return null;
|
return null;
|
||||||
}, [engine]);
|
}, [engine]);
|
||||||
|
|
||||||
|
const undo = useCallback(() => {
|
||||||
|
if (moveHistoryRef.current.length > 0) {
|
||||||
|
moveHistoryRef.current.pop();
|
||||||
|
const newEngine = new ChessEngine();
|
||||||
|
for (const historyMove of moveHistoryRef.current) {
|
||||||
|
const move = newEngine.findMove(historyMove.from, historyMove.to, historyMove.promoteTo);
|
||||||
|
if (move) {
|
||||||
|
newEngine.applyMove(move, historyMove.promoteTo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
engineRef.current = newEngine;
|
||||||
|
setTick(t => t + 1);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
engine,
|
engine,
|
||||||
turn: getTurn(),
|
turn: getTurn(),
|
||||||
|
|
@ -48,5 +71,8 @@ export function useChessEngine() {
|
||||||
legalMoves: getLegalMoves(),
|
legalMoves: getLegalMoves(),
|
||||||
result: getResult(),
|
result: getResult(),
|
||||||
applyMove,
|
applyMove,
|
||||||
|
undo,
|
||||||
|
canUndo: moveHistoryRef.current.length > 0,
|
||||||
|
loadEngine,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,17 @@
|
||||||
import { useChessEngine } from '../hooks/useChessEngine';
|
|
||||||
import { Board } from './Board';
|
import { Board } from './Board';
|
||||||
|
import { useChessEngine } from '../hooks/useChessEngine';
|
||||||
import type { ChessFact, ChessAttrMap } from '../schema';
|
import type { ChessFact, ChessAttrMap } from '../schema';
|
||||||
|
|
||||||
interface GameViewProps {
|
interface GameViewProps {
|
||||||
onUndo?: () => void;
|
engineState?: ReturnType<typeof useChessEngine>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function GameView({ onUndo }: GameViewProps) {
|
export function GameView({ engineState }: GameViewProps) {
|
||||||
const { facts, legalMoves, turn, result, applyMove } = useChessEngine();
|
// Use passed in engine state or create local state if none provided
|
||||||
|
const localChessState = useChessEngine();
|
||||||
|
const state = engineState || localChessState;
|
||||||
|
|
||||||
|
const { facts, legalMoves, turn, result, applyMove, undo, canUndo } = state;
|
||||||
|
|
||||||
const handleMove = (from: number, to: number) => {
|
const handleMove = (from: number, to: number) => {
|
||||||
applyMove(from, to, 'queen');
|
applyMove(from, to, 'queen');
|
||||||
|
|
@ -39,8 +42,9 @@ export function GameView({ onUndo }: GameViewProps) {
|
||||||
|
|
||||||
<button
|
<button
|
||||||
data-action="undo"
|
data-action="undo"
|
||||||
onClick={onUndo}
|
onClick={undo}
|
||||||
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"
|
disabled={!canUndo}
|
||||||
|
className={`px-4 py-2 bg-white font-medium rounded-md border border-neutral-300 shadow-sm transition-colors focus:outline-none focus:ring-2 focus:ring-neutral-200 ${canUndo ? 'hover:bg-neutral-50 active:bg-neutral-100 text-neutral-600' : 'opacity-50 cursor-not-allowed text-neutral-400'}`}
|
||||||
title="Undo last move"
|
title="Undo last move"
|
||||||
>
|
>
|
||||||
Undo
|
Undo
|
||||||
|
|
|
||||||
138
packages/chess/src/ui/SavePanel.tsx
Normal file
138
packages/chess/src/ui/SavePanel.tsx
Normal file
|
|
@ -0,0 +1,138 @@
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import type { EntityId } from '@paratype/rete';
|
||||||
|
|
||||||
|
export interface SaveSlot {
|
||||||
|
name: string;
|
||||||
|
facts: Array<{ id: EntityId; attr: string; value: unknown }>;
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SavePanelProps {
|
||||||
|
onLoad: (facts: Array<{ id: EntityId; attr: string; value: unknown }>) => void;
|
||||||
|
currentFacts?: Array<{ id: EntityId; attr: string; value: unknown }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LOCAL_STORAGE_PREFIX = 'paratype-chess:v1:slot:';
|
||||||
|
|
||||||
|
export function SavePanel({ onLoad, currentFacts }: SavePanelProps) {
|
||||||
|
const [slots, setSlots] = useState<SaveSlot[]>([]);
|
||||||
|
const [newSlotName, setNewSlotName] = useState('');
|
||||||
|
|
||||||
|
const loadSlots = () => {
|
||||||
|
const loadedSlots: SaveSlot[] = [];
|
||||||
|
for (let i = 0; i < localStorage.length; i++) {
|
||||||
|
const key = localStorage.key(i);
|
||||||
|
if (key?.startsWith(LOCAL_STORAGE_PREFIX)) {
|
||||||
|
try {
|
||||||
|
const item = localStorage.getItem(key);
|
||||||
|
if (item) {
|
||||||
|
const slot = JSON.parse(item) as SaveSlot;
|
||||||
|
loadedSlots.push(slot);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`Failed to parse save slot for key: ${key}`, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loadedSlots.sort((a, b) => b.timestamp - a.timestamp);
|
||||||
|
setSlots(loadedSlots);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadSlots();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
if (!newSlotName.trim() || !currentFacts) return;
|
||||||
|
|
||||||
|
const slot: SaveSlot = {
|
||||||
|
name: newSlotName.trim(),
|
||||||
|
facts: currentFacts,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
localStorage.setItem(`${LOCAL_STORAGE_PREFIX}${slot.name}`, JSON.stringify(slot));
|
||||||
|
setNewSlotName('');
|
||||||
|
loadSlots();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = (name: string) => {
|
||||||
|
localStorage.removeItem(`${LOCAL_STORAGE_PREFIX}${name}`);
|
||||||
|
loadSlots();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center gap-8 py-8 w-full max-w-2xl mx-auto px-4">
|
||||||
|
<div className="w-full">
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight text-neutral-800 mb-8">Save / Load Game</h1>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-lg border border-neutral-200 shadow-sm overflow-hidden mb-8">
|
||||||
|
<div className="p-6 border-b border-neutral-100 bg-neutral-50/50">
|
||||||
|
<h2 className="text-lg font-semibold text-neutral-800 mb-4">Create New Save</h2>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newSlotName}
|
||||||
|
onChange={(e) => setNewSlotName(e.target.value)}
|
||||||
|
placeholder="Name your save slot..."
|
||||||
|
className="flex-1 px-4 py-2 border border-neutral-300 rounded-md focus:outline-none focus:ring-2 focus:ring-neutral-200"
|
||||||
|
disabled={!currentFacts}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
data-action="save"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={!newSlotName.trim() || !currentFacts}
|
||||||
|
className="px-6 py-2 bg-neutral-900 text-white font-medium rounded-md hover:bg-neutral-800 active:bg-neutral-950 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{!currentFacts && (
|
||||||
|
<p className="text-sm text-neutral-500 mt-2">Start a game to enable saving.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-0">
|
||||||
|
{slots.length === 0 ? (
|
||||||
|
<div className="p-8 text-center text-neutral-500">
|
||||||
|
No saved games found.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y divide-neutral-100">
|
||||||
|
{slots.map((slot) => (
|
||||||
|
<li
|
||||||
|
key={slot.name}
|
||||||
|
data-testid={`save-slot-${slot.name}`}
|
||||||
|
className="p-4 flex items-center justify-between hover:bg-neutral-50/50 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-medium text-neutral-800">{slot.name}</span>
|
||||||
|
<span className="text-sm text-neutral-500">
|
||||||
|
{new Date(slot.timestamp).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
data-action="load"
|
||||||
|
onClick={() => onLoad(slot.facts)}
|
||||||
|
className="px-4 py-1.5 text-sm font-medium text-neutral-700 bg-white border border-neutral-300 rounded-md hover:bg-neutral-50 active:bg-neutral-100 transition-colors"
|
||||||
|
>
|
||||||
|
Load
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(slot.name)}
|
||||||
|
className="px-4 py-1.5 text-sm font-medium text-red-600 bg-white border border-neutral-300 rounded-md hover:bg-red-50 hover:border-red-200 active:bg-red-100 transition-colors"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue