fix(chess): wire multiplayer live sync via GameClient + PredictionManager
Previously, GameView used a local ChessEngine regardless of whether the user was in a multiplayer room. Moves were never sent to the server and the opponent only saw updates on full page reload. Introduce useMultiplayerGame, a React hook that wraps GameClient and PredictionManager and exposes the same shape as useChessEngine. App reads sessionStorage once at mount of /game and dispatches to either MultiplayerGameView (server-backed) or GameView (local) accordingly. Board now accepts myColor to gate drag by piece ownership in addition to turn, so black pieces never become draggable on white`s board and vice versa. Rewrites the multiplayer E2E to actually validate live sync: each drag on one page is asserted to appear on the opposite page before the next move. The previous test drove both colors from one page because the GameClient wiring was missing; that workaround is no longer needed.
This commit is contained in:
parent
8b8a5179ec
commit
5fb96647eb
5 changed files with 509 additions and 90 deletions
|
|
@ -1,7 +1,7 @@
|
|||
import { useEffect } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Routes, Route, useNavigate, useLocation } from 'react-router-dom'
|
||||
import { Lobby } from '../ui/Lobby'
|
||||
import { GameView } from '../ui/GameView'
|
||||
import { GameView, MultiplayerGameView } from '../ui/GameView'
|
||||
import { RulesView } from '../ui/RulesView'
|
||||
import { SavePanel } from '../ui/SavePanel'
|
||||
import { ImportExport } from '../ui/ImportExport'
|
||||
|
|
@ -33,7 +33,7 @@ export function App() {
|
|||
<AnimatePresence mode="wait">
|
||||
<Routes location={location} key={location.pathname}>
|
||||
<Route path="/" element={<PageTransition><Lobby chessState={chessState} /></PageTransition>} />
|
||||
<Route path="/game" element={<PageTransition><GameView engineState={chessState} /></PageTransition>} />
|
||||
<Route path="/game" element={<PageTransition><GameRoute chessState={chessState} /></PageTransition>} />
|
||||
<Route path="/rules" element={<PageTransition><RulesView chessState={chessState} /></PageTransition>} />
|
||||
<Route path="/save" element={<PageTransition><SaveWrapper chessState={chessState} /></PageTransition>} />
|
||||
</Routes>
|
||||
|
|
@ -42,6 +42,36 @@ export function App() {
|
|||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* /game route dispatcher. Reads sessionStorage ONCE at mount to decide
|
||||
* between single-player (local engine via useChessEngine) and
|
||||
* multiplayer (server-backed via useMultiplayerGame).
|
||||
*
|
||||
* We snapshot into state so that a subsequent sessionStorage mutation
|
||||
* (e.g. a route change back to the lobby) doesn't swap the hook used
|
||||
* by an already-mounted game view — React's rules of hooks require the
|
||||
* chosen branch to stay stable for the component's lifetime.
|
||||
*
|
||||
* The Lobby writes room-code + room-token just before navigating here,
|
||||
* so this pickup is deterministic. If either is missing we render the
|
||||
* local mode.
|
||||
*/
|
||||
function GameRoute({
|
||||
chessState,
|
||||
}: {
|
||||
chessState: ReturnType<typeof useChessEngine>
|
||||
}) {
|
||||
const [mpCreds] = useState(() => {
|
||||
const code = sessionStorage.getItem('room-code')
|
||||
const token = sessionStorage.getItem('room-token')
|
||||
return code !== null && token !== null ? { code, token } : null
|
||||
})
|
||||
if (mpCreds !== null) {
|
||||
return <MultiplayerGameView code={mpCreds.code} token={mpCreds.token} />
|
||||
}
|
||||
return <GameView engineState={chessState} />
|
||||
}
|
||||
|
||||
function PageTransition({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<motion.div
|
||||
|
|
|
|||
256
packages/chess/src/hooks/useMultiplayerGame.ts
Normal file
256
packages/chess/src/hooks/useMultiplayerGame.ts
Normal file
|
|
@ -0,0 +1,256 @@
|
|||
/**
|
||||
* React hook for a server-backed chess game.
|
||||
*
|
||||
* Wraps GameClient + PredictionManager and exposes the same surface as
|
||||
* `useChessEngine` so `<GameView>` doesn't need to care whether it's
|
||||
* running locally or over the wire. Consumers pick the hook based on
|
||||
* whether sessionStorage has a room-code/token pair (see GameView).
|
||||
*
|
||||
* Lifecycle
|
||||
* ─────────
|
||||
* - On mount: opens a WebSocket, sends `room.join` with the stored token.
|
||||
* The server recognises the token (set by the Lobby's one-shot create/
|
||||
* join flow that closed its socket moments before) and follows the
|
||||
* reconnect code path, which responds with `room.joined` + a full
|
||||
* `game.state` snapshot. PredictionManager populates `baseEngine` from
|
||||
* that snapshot.
|
||||
* - While connected: remote `game.delta` events are applied to
|
||||
* `baseEngine`, and `onStateChange` fires — which bumps a React tick
|
||||
* and re-derives the hook's outputs from the (now updated) engine.
|
||||
* Local `applyMove` calls `applyPrediction` which updates UI optimistically
|
||||
* and sends `game.move` to the server. The server echoes a `game.delta`
|
||||
* that clears the prediction.
|
||||
* - On unmount: closes the socket. A fresh mount will reconnect again.
|
||||
*
|
||||
* Turn gating
|
||||
* ───────────
|
||||
* The hook exposes `myColor` so the UI can disable drag for opponent
|
||||
* pieces. The server rejects out-of-turn `game.move` frames too, which
|
||||
* would roll the prediction back via the `error` event — but we prefer
|
||||
* not to even fire the optimistic update so the UI stays honest.
|
||||
*/
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type { PieceType } from '../schema';
|
||||
import type { GameResult } from '../engine';
|
||||
import { GameClient } from '../net/client';
|
||||
import { PredictionManager } from '../net/prediction';
|
||||
import type { Color, PromotionPiece } from '../net/types';
|
||||
import * as audio from '../audio';
|
||||
|
||||
const WS_URL =
|
||||
(import.meta as { env?: Record<string, string> }).env?.['VITE_WS_URL'] ??
|
||||
'ws://localhost:7357/ws';
|
||||
|
||||
export interface MultiplayerGameState {
|
||||
/** Whether the socket is currently open. */
|
||||
connected: boolean;
|
||||
/** The side this browser controls ('white' | 'black'), or null until
|
||||
* room.joined arrives. */
|
||||
myColor: Color | null;
|
||||
/** True while we're waiting for the initial game.state snapshot to
|
||||
* arrive (engine may be empty). */
|
||||
loading: boolean;
|
||||
/** Room error surfaced to the UI (e.g. ROOM_NOT_FOUND on reconnect
|
||||
* after grace expiry). Null when healthy. */
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param code Room code from sessionStorage.
|
||||
* @param token Player token from sessionStorage.
|
||||
*/
|
||||
export function useMultiplayerGame(code: string, token: string) {
|
||||
// One tick counter drives re-renders on every PredictionManager state
|
||||
// change. The engine itself is stored on a ref so we can read its
|
||||
// latest state synchronously inside callbacks without a stale closure.
|
||||
const clientRef = useRef<GameClient | null>(null);
|
||||
const managerRef = useRef<PredictionManager | null>(null);
|
||||
const [tick, setTick] = useState(0);
|
||||
const [meta, setMeta] = useState<MultiplayerGameState>({
|
||||
connected: false,
|
||||
myColor: null,
|
||||
loading: true,
|
||||
error: null,
|
||||
});
|
||||
|
||||
// Mount the connection exactly once per code/token pair.
|
||||
useEffect(() => {
|
||||
const client = new GameClient(WS_URL);
|
||||
const manager = new PredictionManager(client, () => {
|
||||
// Any change in authoritative or predicted state: bump tick so the
|
||||
// component re-renders and picks up the new facts.
|
||||
setTick((t) => t + 1);
|
||||
});
|
||||
clientRef.current = client;
|
||||
managerRef.current = manager;
|
||||
|
||||
const onConnected = () => {
|
||||
setMeta((m) => ({ ...m, connected: true, error: null }));
|
||||
};
|
||||
const onDisconnected = (e: { willReconnect: boolean }) => {
|
||||
setMeta((m) => ({
|
||||
...m,
|
||||
connected: false,
|
||||
error: e.willReconnect ? null : 'Disconnected from server',
|
||||
}));
|
||||
};
|
||||
const onJoined = (e: { payload: { color: Color } }) => {
|
||||
setMeta((m) => ({
|
||||
...m,
|
||||
myColor: e.payload.color,
|
||||
loading: false,
|
||||
}));
|
||||
};
|
||||
const onGameState = () => {
|
||||
// The first game.state snapshot after (re)connect clears `loading`.
|
||||
setMeta((m) => ({ ...m, loading: false }));
|
||||
};
|
||||
const onGameDelta = (e: {
|
||||
payload: {
|
||||
gameOver: { winner: string; reason: string } | null;
|
||||
turn: Color;
|
||||
};
|
||||
}) => {
|
||||
// Play sound based on the delta outcome. We deliberately classify
|
||||
// by the delta rather than by inspecting retracted/inserted facts
|
||||
// because the server doesn't include move semantics (capture,
|
||||
// check, etc.) in the wire payload today.
|
||||
if (e.payload.gameOver !== null) {
|
||||
audio.play(
|
||||
e.payload.gameOver.reason === 'checkmate' ? 'checkmate' : 'move',
|
||||
);
|
||||
} else {
|
||||
audio.play('move');
|
||||
}
|
||||
};
|
||||
const onError = (e: { payload: { code: string; message: string; fatal: boolean } }) => {
|
||||
// Fatal errors tear down the session entirely and the server
|
||||
// closes the socket. Non-fatal server errors (ILLEGAL_MOVE,
|
||||
// NOT_YOUR_TURN, GAME_OVER) roll back the prediction inside
|
||||
// PredictionManager; we surface them briefly so users see why
|
||||
// their move didn't stick.
|
||||
if (e.payload.fatal) {
|
||||
setMeta((m) => ({ ...m, error: e.payload.message }));
|
||||
} else {
|
||||
setMeta((m) => ({ ...m, error: e.payload.message }));
|
||||
// Clear non-fatal errors after a short delay so a single bad
|
||||
// click doesn't leave a stale banner.
|
||||
setTimeout(() => {
|
||||
setMeta((m) =>
|
||||
m.error === e.payload.message ? { ...m, error: null } : m,
|
||||
);
|
||||
}, 2000);
|
||||
}
|
||||
};
|
||||
|
||||
client.on('connected', onConnected);
|
||||
client.on('disconnected', onDisconnected);
|
||||
client.on('room.joined', onJoined);
|
||||
client.on('game.state', onGameState);
|
||||
client.on('game.delta', onGameDelta);
|
||||
client.on('error', onError);
|
||||
|
||||
client.connect(code, token).catch((err: unknown) => {
|
||||
setMeta((m) => ({
|
||||
...m,
|
||||
error: err instanceof Error ? err.message : 'Connection failed',
|
||||
loading: false,
|
||||
}));
|
||||
});
|
||||
|
||||
return () => {
|
||||
// Remove listeners before close so in-flight events don't trigger
|
||||
// state updates after unmount.
|
||||
client.off('connected', onConnected);
|
||||
client.off('disconnected', onDisconnected);
|
||||
client.off('room.joined', onJoined);
|
||||
client.off('game.state', onGameState);
|
||||
client.off('game.delta', onGameDelta);
|
||||
client.off('error', onError);
|
||||
client.close();
|
||||
clientRef.current = null;
|
||||
managerRef.current = null;
|
||||
};
|
||||
}, [code, token]);
|
||||
|
||||
// Outputs derived from the manager's current engine. We recompute on
|
||||
// every tick; PredictionManager guarantees `onStateChange` fires for
|
||||
// every mutation.
|
||||
const manager = managerRef.current;
|
||||
const engine = manager?.getCurrentEngine() ?? null;
|
||||
|
||||
const facts = engine?.session.allFacts() ?? [];
|
||||
const turn = engine?.getCurrentTurn() ?? 'white';
|
||||
const legalMoves = engine?.getAllLegalMoves() ?? [];
|
||||
const result: GameResult = engine?.checkGameResult() ?? 'ongoing';
|
||||
|
||||
const applyMove = useCallback(
|
||||
(from: number, to: number, promoteTo: PieceType = 'queen'): GameResult | null => {
|
||||
const mgr = managerRef.current;
|
||||
if (!mgr) return null;
|
||||
const promo = promoteTo as PromotionPiece;
|
||||
const ok = mgr.applyPrediction(from, to, promo);
|
||||
if (!ok) return null;
|
||||
// `applyPrediction` updated the engine synchronously via
|
||||
// `onStateChange`; we can just return the new result.
|
||||
const eng = mgr.getCurrentEngine();
|
||||
const moveResult = eng.checkGameResult();
|
||||
// Local sound playback for the moving player. The opponent's client
|
||||
// plays its own sound off the `game.delta` event.
|
||||
if (moveResult === 'checkmate') audio.play('checkmate');
|
||||
else audio.play('move');
|
||||
return moveResult;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// Undo is not meaningful in multiplayer: moves are authoritative on
|
||||
// the server. Return a no-op + canUndo=false so GameView's shared
|
||||
// contract still works.
|
||||
const undo = useCallback(() => {
|
||||
// intentional no-op
|
||||
}, []);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
setTick((t) => t + 1);
|
||||
}, []);
|
||||
|
||||
const loadEngine = useCallback(() => {
|
||||
// Also a no-op: authoritative state is server-driven. The UI should
|
||||
// not have any need to swap the engine under multiplayer — all state
|
||||
// changes come through `game.state` / `game.delta`.
|
||||
}, []);
|
||||
|
||||
// `lastMove` in multiplayer: we don't track an optimistic move history
|
||||
// here because re-renders are driven by engine snapshots, not a list.
|
||||
// Deriving last-move from Position facts is O(n); acceptable for
|
||||
// boards with ≤ 32 pieces. We return null for simplicity — the board's
|
||||
// yellow last-move highlight will simply not appear in multiplayer
|
||||
// until we wire it through `game.delta.moveNotation` in a later pass.
|
||||
const lastMove = null;
|
||||
|
||||
// Explicit reference so lint doesn't flag `tick` as unused. Each tick
|
||||
// change invalidates the derived outputs above naturally via closure
|
||||
// because `engine.session.allFacts()` is called fresh each render.
|
||||
void tick;
|
||||
|
||||
return {
|
||||
engine,
|
||||
turn,
|
||||
facts,
|
||||
legalMoves,
|
||||
result,
|
||||
applyMove,
|
||||
undo,
|
||||
canUndo: false,
|
||||
loadEngine,
|
||||
refresh,
|
||||
lastMove,
|
||||
// Multiplayer-only metadata. GameView uses these to gate drag and
|
||||
// render a "waiting for opponent" / error overlay.
|
||||
myColor: meta.myColor,
|
||||
connected: meta.connected,
|
||||
loading: meta.loading,
|
||||
error: meta.error,
|
||||
};
|
||||
}
|
||||
|
|
@ -11,6 +11,10 @@ interface BoardProps {
|
|||
legalMoves: LegalMove[];
|
||||
onMove: (from: number, to: number, promoteTo?: PieceType) => void;
|
||||
turn: PieceColor;
|
||||
/** The color the local player controls. `null` means local/solo play
|
||||
* (either color can be dragged on its turn). In multiplayer this
|
||||
* restricts drag to the player's own pieces only. */
|
||||
myColor?: PieceColor | null;
|
||||
/** Last move played — extra fields ignored. Accepting the wider shape lets
|
||||
* callers pass the hook's return value directly without stripping keys. */
|
||||
lastMove?: { from: number; to: number; [key: string]: unknown } | null | undefined;
|
||||
|
|
@ -23,7 +27,7 @@ interface PieceState {
|
|||
color: PieceColor;
|
||||
}
|
||||
|
||||
export function Board({ facts, legalMoves, onMove, turn, lastMove, checkedKingSquare }: BoardProps) {
|
||||
export function Board({ facts, legalMoves, onMove, turn, myColor, lastMove, checkedKingSquare }: BoardProps) {
|
||||
// Build pieces map: square -> { id, type, color }
|
||||
const pieces = useMemo(() => {
|
||||
const map = new Map<number, PieceState>();
|
||||
|
|
@ -74,12 +78,15 @@ export function Board({ facts, legalMoves, onMove, turn, lastMove, checkedKingSq
|
|||
return targets;
|
||||
}, [draggedPiece, legalMoves]);
|
||||
|
||||
// Handlers
|
||||
// Handlers. Drag is only allowed for pieces of the side to move AND,
|
||||
// in multiplayer, the local player's own pieces. `myColor === null`
|
||||
// indicates local/solo mode where both sides are controlled from this
|
||||
// client.
|
||||
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 });
|
||||
}
|
||||
if (!p || p.color !== turn) return;
|
||||
if (myColor !== null && myColor !== undefined && p.color !== myColor) return;
|
||||
setDraggedPiece({ id, square });
|
||||
};
|
||||
|
||||
const handleDragEnd = () => {
|
||||
|
|
@ -175,7 +182,10 @@ export function Board({ facts, legalMoves, onMove, turn, lastMove, checkedKingSq
|
|||
type={piece.type}
|
||||
pieceId={piece.id}
|
||||
square={sq}
|
||||
isDraggable={piece.color === turn}
|
||||
isDraggable={
|
||||
piece.color === turn &&
|
||||
(myColor === null || myColor === undefined || piece.color === myColor)
|
||||
}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,22 +1,111 @@
|
|||
import { Board } from './Board';
|
||||
import { RulesDrawer } from './RulesDrawer';
|
||||
import { useChessEngine } from '../hooks/useChessEngine';
|
||||
import { useMultiplayerGame } from '../hooks/useMultiplayerGame';
|
||||
import type { ChessFact, ChessAttrMap, PieceType } from '../schema';
|
||||
import type { Color } from '../net/types';
|
||||
import type { GameResult } from '../engine';
|
||||
import type { LegalMove } from '../rules/types';
|
||||
import { useEffect, useState } from 'react';
|
||||
import confetti from 'canvas-confetti';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import { Volume2, VolumeX } from 'lucide-react';
|
||||
import * as audio from '../audio';
|
||||
|
||||
/**
|
||||
* Shared state shape consumed by the game UI, covering both the local
|
||||
* single-player engine and the server-backed multiplayer engine. Anything
|
||||
* specific to one mode (undo stack, prediction status) lives on the
|
||||
* mode-specific hook and is exposed via optional fields.
|
||||
*/
|
||||
interface GameEngineState {
|
||||
facts: ReadonlyArray<ChessFact<keyof ChessAttrMap>> | ReturnType<typeof useChessEngine>['facts'];
|
||||
legalMoves: LegalMove[];
|
||||
turn: Color | 'white' | 'black';
|
||||
result: GameResult;
|
||||
applyMove: (from: number, to: number, promoteTo?: PieceType) => GameResult | null;
|
||||
undo: () => void;
|
||||
canUndo: boolean;
|
||||
lastMove: { from: number; to: number; [key: string]: unknown } | null | undefined;
|
||||
refresh: () => void;
|
||||
}
|
||||
|
||||
interface GameViewProps {
|
||||
/** Optional injected state (local mode) — when omitted, GameView runs
|
||||
* its own local useChessEngine. */
|
||||
engineState?: ReturnType<typeof useChessEngine>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Local single-player GameView. Renders the board against a
|
||||
* browser-local ChessEngine. Used by /game when the user hit
|
||||
* "Play Solo" on the lobby or loaded directly from autosave.
|
||||
*/
|
||||
export function GameView({ engineState }: GameViewProps) {
|
||||
// Use passed in engine state or create local state if none provided
|
||||
const localChessState = useChessEngine();
|
||||
const state = engineState || localChessState;
|
||||
|
||||
return <GameLayout state={state} myColor={null} />;
|
||||
}
|
||||
|
||||
interface MultiplayerGameViewProps {
|
||||
code: string;
|
||||
token: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Multiplayer GameView. Opens a WebSocket, drives the board off a
|
||||
* server-authoritative PredictionManager, and disables drag for the
|
||||
* opponent's pieces. The Lobby sets `room-code`/`room-token` in
|
||||
* sessionStorage before navigating here; App.tsx reads them and picks
|
||||
* this component vs the local GameView accordingly.
|
||||
*/
|
||||
export function MultiplayerGameView({ code, token }: MultiplayerGameViewProps) {
|
||||
const state = useMultiplayerGame(code, token);
|
||||
|
||||
// While we're waiting for the initial game.state snapshot, don't render
|
||||
// the board. Two players loading simultaneously both call room.join on
|
||||
// connect; the server responds with game.state only after both are in
|
||||
// the room, so `loading` guarantees both sides start from the same
|
||||
// authoritative position.
|
||||
if (state.loading) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-24">
|
||||
<div className="text-neutral-500 font-medium">Waiting for opponent…</div>
|
||||
<div className="mt-2 text-sm text-neutral-400" data-testid="mp-room-code">
|
||||
Room: {code}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{state.error !== null && (
|
||||
<div
|
||||
data-testid="mp-error"
|
||||
className="fixed top-4 left-1/2 -translate-x-1/2 z-50 px-4 py-2 bg-red-50 border border-red-200 text-red-700 text-sm font-medium rounded-md shadow-sm"
|
||||
>
|
||||
{state.error}
|
||||
</div>
|
||||
)}
|
||||
<GameLayout state={state} myColor={state.myColor} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mode-agnostic board + header layout. Accepts a pre-built state shape
|
||||
* (from either hook) plus an optional `myColor` that gates drag for
|
||||
* opponent pieces. Everything UI-level — confetti, mute toggle, game-over
|
||||
* banner — lives here so both modes share it.
|
||||
*/
|
||||
function GameLayout({
|
||||
state,
|
||||
myColor,
|
||||
}: {
|
||||
state: GameEngineState;
|
||||
myColor: Color | null;
|
||||
}) {
|
||||
const { facts, legalMoves, turn, result, applyMove, undo, canUndo, lastMove, refresh } = state;
|
||||
|
||||
const handleMove = (from: number, to: number, promoteTo?: PieceType) => {
|
||||
|
|
@ -24,7 +113,7 @@ export function GameView({ engineState }: GameViewProps) {
|
|||
};
|
||||
|
||||
const isGameOver = result !== 'ongoing';
|
||||
|
||||
|
||||
// Confetti on checkmate
|
||||
useEffect(() => {
|
||||
if (result === 'checkmate') {
|
||||
|
|
@ -67,15 +156,15 @@ export function GameView({ engineState }: GameViewProps) {
|
|||
const checkedKingSquare = facts.find(
|
||||
f => f.attr === 'InCheck' && f.value === true
|
||||
) ? facts.find(
|
||||
f => f.attr === 'PieceType' && f.value === 'king' &&
|
||||
f => f.attr === 'PieceType' && f.value === 'king' &&
|
||||
facts.some(f2 => f2.id === f.id && f2.attr === 'Color' && f2.value === turn)
|
||||
)?.id ? facts.find(f3 => f3.id === facts.find(
|
||||
f => f.attr === 'PieceType' && f.value === 'king' &&
|
||||
f => f.attr === 'PieceType' && f.value === 'king' &&
|
||||
facts.some(f2 => f2.id === f.id && f2.attr === 'Color' && f2.value === turn)
|
||||
)?.id && f3.attr === 'Position')?.value as number : null : null;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
|
|
@ -84,33 +173,44 @@ export function GameView({ engineState }: GameViewProps) {
|
|||
>
|
||||
<RulesDrawer onRulesChanged={refresh} />
|
||||
<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">
|
||||
<div className="flex items-center gap-4">
|
||||
<h1 className="text-3xl font-bold tracking-tight text-neutral-800">
|
||||
Chess
|
||||
</h1>
|
||||
<button
|
||||
<button
|
||||
onClick={toggleMute}
|
||||
className="p-2 text-neutral-500 hover:text-neutral-800 hover:bg-neutral-100 rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-neutral-200"
|
||||
title={isMuted ? "Unmute sounds" : "Mute sounds"}
|
||||
>
|
||||
{isMuted ? <VolumeX size={20} /> : <Volume2 size={20} />}
|
||||
</button>
|
||||
{myColor && (
|
||||
<span
|
||||
data-testid="my-color"
|
||||
className="text-sm font-medium text-neutral-500 px-2 py-1 bg-neutral-100 rounded"
|
||||
>
|
||||
You are {myColor}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
<div
|
||||
data-testid="turn-indicator"
|
||||
className="flex items-center gap-2 px-4 py-2 bg-white 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'}`}
|
||||
<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>
|
||||
|
||||
|
||||
{/* Undo is only meaningful in local mode (server is authoritative
|
||||
* in multiplayer); the hook exposes canUndo=false there so the
|
||||
* button naturally stays disabled. */}
|
||||
<motion.button
|
||||
whileTap={canUndo ? { scale: 0.95 } : {}}
|
||||
data-action="undo"
|
||||
|
|
@ -127,7 +227,7 @@ export function GameView({ engineState }: GameViewProps) {
|
|||
{/* Game Result Banner */}
|
||||
<AnimatePresence>
|
||||
{isGameOver && (
|
||||
<motion.div
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9, y: -20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
transition={{ type: 'spring', damping: 20 }}
|
||||
|
|
@ -141,25 +241,26 @@ export function GameView({ engineState }: GameViewProps) {
|
|||
</div>
|
||||
|
||||
<div className="w-full px-4 relative">
|
||||
<Board
|
||||
facts={facts as ChessFact<keyof ChessAttrMap>[]}
|
||||
legalMoves={legalMoves}
|
||||
turn={turn}
|
||||
onMove={handleMove}
|
||||
<Board
|
||||
facts={facts as ChessFact<keyof ChessAttrMap>[]}
|
||||
legalMoves={legalMoves}
|
||||
turn={turn as Color}
|
||||
myColor={myColor}
|
||||
onMove={handleMove}
|
||||
lastMove={lastMove}
|
||||
checkedKingSquare={checkedKingSquare}
|
||||
/>
|
||||
|
||||
|
||||
{/* Overlay for game over to prevent further interaction visually */}
|
||||
{isGameOver && (
|
||||
<motion.div
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="absolute inset-0 bg-white/10 backdrop-blur-[1px] pointer-events-none z-30"
|
||||
className="absolute inset-0 bg-white/10 backdrop-blur-[1px] pointer-events-none z-30"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue