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,32 +1,51 @@
|
|||
import { Routes, Route } from 'react-router-dom'
|
||||
import { Routes, Route, useNavigate } from 'react-router-dom'
|
||||
import { GameView } from '../ui/GameView'
|
||||
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() {
|
||||
const chessState = useChessEngine()
|
||||
|
||||
return (
|
||||
<div data-testid="app-root">
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/game" element={<GameView />} />
|
||||
<Route path="/game" element={<GameView engineState={chessState} />} />
|
||||
<Route path="/rules" element={<RulesView />} />
|
||||
<Route path="/save" element={<Save />} />
|
||||
<Route path="/save" element={<SaveWrapper chessState={chessState} />} />
|
||||
</Routes>
|
||||
</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() {
|
||||
return (
|
||||
<main data-testid="page-home">
|
||||
<h1>Home</h1>
|
||||
</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() {
|
||||
const engineRef = useRef<ChessEngine | null>(null);
|
||||
const moveHistoryRef = useRef<Array<{ from: number, to: number, promoteTo: PieceType }>>([]);
|
||||
|
||||
// Force re-render state
|
||||
const [tick, setTick] = useState(0);
|
||||
|
|
@ -15,6 +16,12 @@ export function useChessEngine() {
|
|||
|
||||
const engine = engineRef.current;
|
||||
|
||||
const loadEngine = useCallback((newEngine: ChessEngine) => {
|
||||
engineRef.current = newEngine;
|
||||
moveHistoryRef.current = [];
|
||||
setTick(t => t + 1);
|
||||
}, []);
|
||||
|
||||
const getTurn = useCallback(() => {
|
||||
return engine.getCurrentTurn();
|
||||
}, [engine, tick]);
|
||||
|
|
@ -34,6 +41,7 @@ export function useChessEngine() {
|
|||
const applyMove = useCallback((from: number, to: number, promoteTo: PieceType = 'queen'): GameResult | null => {
|
||||
const move = engine.findMove(from, to, promoteTo);
|
||||
if (move) {
|
||||
moveHistoryRef.current.push({ from, to, promoteTo });
|
||||
const result = engine.applyMove(move, promoteTo);
|
||||
setTick(t => t + 1); // trigger re-render
|
||||
return result;
|
||||
|
|
@ -41,6 +49,21 @@ export function useChessEngine() {
|
|||
return null;
|
||||
}, [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 {
|
||||
engine,
|
||||
turn: getTurn(),
|
||||
|
|
@ -48,5 +71,8 @@ export function useChessEngine() {
|
|||
legalMoves: getLegalMoves(),
|
||||
result: getResult(),
|
||||
applyMove,
|
||||
undo,
|
||||
canUndo: moveHistoryRef.current.length > 0,
|
||||
loadEngine,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,14 +1,17 @@
|
|||
import { useChessEngine } from '../hooks/useChessEngine';
|
||||
import { Board } from './Board';
|
||||
|
||||
import { useChessEngine } from '../hooks/useChessEngine';
|
||||
import type { ChessFact, ChessAttrMap } from '../schema';
|
||||
|
||||
interface GameViewProps {
|
||||
onUndo?: () => void;
|
||||
engineState?: ReturnType<typeof useChessEngine>;
|
||||
}
|
||||
|
||||
export function GameView({ onUndo }: GameViewProps) {
|
||||
const { facts, legalMoves, turn, result, applyMove } = useChessEngine();
|
||||
export function GameView({ engineState }: GameViewProps) {
|
||||
// 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) => {
|
||||
applyMove(from, to, 'queen');
|
||||
|
|
@ -39,8 +42,9 @@ export function GameView({ onUndo }: GameViewProps) {
|
|||
|
||||
<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"
|
||||
onClick={undo}
|
||||
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"
|
||||
>
|
||||
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