This commit is contained in:
@@ -11,6 +11,232 @@ interface ChessState {
|
||||
winner: string | null;
|
||||
}
|
||||
|
||||
// Chess.com-inspired board colors
|
||||
const DARK_SQUARE = "#769656";
|
||||
const LIGHT_SQUARE = "#eeeed2";
|
||||
const LAST_MOVE_DARK = "#638a49";
|
||||
const LAST_MOVE_LIGHT = "#cdd96e";
|
||||
const SELECTED_SHADOW = "inset 0 0 0 4px rgba(20,85,30,0.8)";
|
||||
const CHECK_BG = "rgba(220, 38, 38, 0.7)";
|
||||
|
||||
function Avatar({ username, color }: { username: string; color: "white" | "black" }) {
|
||||
return (
|
||||
<div
|
||||
className="w-8 h-8 rounded-sm flex items-center justify-center text-sm font-bold shrink-0"
|
||||
style={{
|
||||
backgroundColor: color === "white" ? "#f0d9b5" : "#2b1d0e",
|
||||
color: color === "white" ? "#2b1d0e" : "#f0d9b5",
|
||||
border: "2px solid rgba(255,255,255,0.15)",
|
||||
}}
|
||||
>
|
||||
{username?.[0]?.toUpperCase() ?? "?"}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CapturedPieces({ fen, color }: { fen: string; color: "white" | "black" }) {
|
||||
const captured = useMemo(() => {
|
||||
const game = new Chess(fen);
|
||||
const board = game.board();
|
||||
const counts: Record<string, number> = { p: 0, n: 0, b: 0, r: 0, q: 0 };
|
||||
for (const row of board) {
|
||||
for (const sq of row) {
|
||||
if (sq) counts[sq.type] = (counts[sq.type] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
const start: Record<string, number> = { p: 8, n: 2, b: 2, r: 2, q: 1 };
|
||||
const pieceSymbols: Record<string, string> = {
|
||||
p: color === "white" ? "♟" : "♙",
|
||||
n: color === "white" ? "♞" : "♘",
|
||||
b: color === "white" ? "♝" : "♗",
|
||||
r: color === "white" ? "♜" : "♖",
|
||||
q: color === "white" ? "♛" : "♕",
|
||||
};
|
||||
const pieceColor = color === "white" ? "b" : "w";
|
||||
const result: string[] = [];
|
||||
for (const [type, symbol] of Object.entries(pieceSymbols)) {
|
||||
const remaining = board.flat().filter(s => s?.type === type && s.color === pieceColor).length;
|
||||
const missing = start[type]! - remaining;
|
||||
for (let i = 0; i < missing; i++) result.push(symbol);
|
||||
}
|
||||
return result;
|
||||
}, [fen, color]);
|
||||
|
||||
if (captured.length === 0) return null;
|
||||
return (
|
||||
<span className="text-sm opacity-70 leading-none tracking-tight">{captured.join("")}</span>
|
||||
);
|
||||
}
|
||||
|
||||
function PlayerPanel({
|
||||
username,
|
||||
color,
|
||||
isActive,
|
||||
fen,
|
||||
isTop,
|
||||
}: {
|
||||
username: string;
|
||||
color: "white" | "black";
|
||||
isActive: boolean;
|
||||
fen: string;
|
||||
isTop: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-2.5 px-3 py-2 rounded-sm select-none"
|
||||
style={{
|
||||
backgroundColor: "#262421",
|
||||
borderTop: isTop ? undefined : "2px solid #1a1917",
|
||||
borderBottom: isTop ? "2px solid #1a1917" : undefined,
|
||||
}}
|
||||
>
|
||||
<Avatar username={username} color={color} />
|
||||
<div className="flex flex-col gap-0.5 min-w-0">
|
||||
<span className="text-sm font-semibold leading-none truncate" style={{ color: "#f0d9b5" }}>
|
||||
{username}
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-[11px] opacity-50 leading-none capitalize" style={{ color: "#8e8984" }}>
|
||||
{color}
|
||||
</span>
|
||||
<CapturedPieces fen={fen} color={color === "white" ? "black" : "white"} />
|
||||
</div>
|
||||
</div>
|
||||
{isActive && (
|
||||
<div className="ml-auto flex items-center gap-1.5">
|
||||
<span
|
||||
className="w-2 h-2 rounded-full animate-pulse"
|
||||
style={{ backgroundColor: "#81b64c" }}
|
||||
/>
|
||||
<span className="text-[11px] font-medium" style={{ color: "#81b64c" }}>
|
||||
Your turn
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MoveHistory({ moveHistory }: { moveHistory: string[] }) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
if (ref.current) ref.current.scrollTop = ref.current.scrollHeight;
|
||||
}, [moveHistory.length]);
|
||||
|
||||
const pairs: [string, string | undefined][] = [];
|
||||
for (let i = 0; i < moveHistory.length; i += 2) {
|
||||
pairs.push([moveHistory[i]!, moveHistory[i + 1]]);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full" style={{ backgroundColor: "#262421" }}>
|
||||
<div
|
||||
className="px-3 py-2 text-xs font-semibold uppercase tracking-wider border-b"
|
||||
style={{ color: "#8e8984", borderColor: "#1a1917" }}
|
||||
>
|
||||
Moves
|
||||
</div>
|
||||
<div ref={ref} className="flex-1 overflow-y-auto" style={{ maxHeight: "360px" }}>
|
||||
{pairs.length === 0 ? (
|
||||
<div className="px-3 py-3 text-xs" style={{ color: "#6e6966" }}>
|
||||
No moves yet
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full text-xs font-mono">
|
||||
<tbody>
|
||||
{pairs.map(([white, black], i) => (
|
||||
<tr
|
||||
key={i}
|
||||
style={{
|
||||
backgroundColor: i % 2 === 0 ? "transparent" : "rgba(255,255,255,0.03)",
|
||||
}}
|
||||
>
|
||||
<td
|
||||
className="pl-3 pr-2 py-1 select-none w-8 text-right"
|
||||
style={{ color: "#6e6966" }}
|
||||
>
|
||||
{i + 1}.
|
||||
</td>
|
||||
<td
|
||||
className="px-2 py-1 w-16 font-medium"
|
||||
style={{ color: "#f0d9b5" }}
|
||||
>
|
||||
{white}
|
||||
</td>
|
||||
<td
|
||||
className="px-2 py-1 w-16"
|
||||
style={{ color: black ? "#f0d9b5" : "transparent" }}
|
||||
>
|
||||
{black ?? "—"}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GameOverOverlay({
|
||||
status,
|
||||
winner,
|
||||
myPlayerId,
|
||||
players,
|
||||
}: {
|
||||
status: string;
|
||||
winner: string | null;
|
||||
myPlayerId: string;
|
||||
players: { discordId: string; username: string }[];
|
||||
}) {
|
||||
const winnerName = winner ? (players.find(p => p.discordId === winner)?.username ?? winner) : null;
|
||||
const isWinner = winner === myPlayerId;
|
||||
const isDraw = !winner;
|
||||
|
||||
let title = "";
|
||||
let subtitle = "";
|
||||
let titleColor = "#f0d9b5";
|
||||
|
||||
if (isDraw) {
|
||||
title = "Draw";
|
||||
subtitle = status === "stalemate" ? "Stalemate" : "Game drawn";
|
||||
titleColor = "#8e8984";
|
||||
} else if (isWinner) {
|
||||
title = "You Win!";
|
||||
subtitle = status === "forfeit" ? "Opponent forfeited" : "By checkmate";
|
||||
titleColor = "#81b64c";
|
||||
} else {
|
||||
title = "You Lose";
|
||||
subtitle =
|
||||
status === "forfeit"
|
||||
? "You forfeited"
|
||||
: status === "checkmate"
|
||||
? `${winnerName} wins by checkmate`
|
||||
: `${winnerName} wins`;
|
||||
titleColor = "#c84b4b";
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute inset-0 flex flex-col items-center justify-center z-10 rounded-sm"
|
||||
style={{ backgroundColor: "rgba(20, 18, 15, 0.82)", backdropFilter: "blur(2px)" }}
|
||||
>
|
||||
<div
|
||||
className="flex flex-col items-center gap-2 px-8 py-6 rounded-lg"
|
||||
style={{ backgroundColor: "#1a1917", border: "1px solid #3a3733" }}
|
||||
>
|
||||
<div className="text-3xl font-bold" style={{ color: titleColor }}>
|
||||
{title}
|
||||
</div>
|
||||
<div className="text-sm" style={{ color: "#8e8984" }}>
|
||||
{subtitle}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChessBoard({ state, myPlayerId, isSpectator, onAction, players }: GameUIProps) {
|
||||
const chess = state as ChessState;
|
||||
const [promotionFrom, setPromotionFrom] = useState<string | null>(null);
|
||||
@@ -18,85 +244,82 @@ export function ChessBoard({ state, myPlayerId, isSpectator, onAction, players }
|
||||
const [selectedSquare, setSelectedSquare] = useState<string | null>(null);
|
||||
const [confirmForfeit, setConfirmForfeit] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const moveHistoryRef = useRef<HTMLDivElement>(null);
|
||||
const lastMoveRef = useRef<{ from: string; to: string } | null>(null);
|
||||
const [boardWidth, setBoardWidth] = useState(400);
|
||||
const [boardWidth, setBoardWidth] = useState(480);
|
||||
|
||||
// Optimistic local FEN: updated immediately on valid moves, synced from server state
|
||||
// Optimistic local FEN — updated immediately on drag/click, then confirmed by server.
|
||||
// We track a local move count so we never let a stale server message roll back the board.
|
||||
const [localFen, setLocalFen] = useState<string>(() => chess?.fen ?? "start");
|
||||
const localFenRef = useRef<string>(localFen);
|
||||
const localMoveCountRef = useRef<number>(chess?.moveHistory?.length ?? 0);
|
||||
const lastMoveRef = useRef<{ from: string; to: string } | null>(null);
|
||||
|
||||
// Sync local FEN when server confirms a new state
|
||||
// Sync local FEN from server — only if server has caught up to our optimistic position.
|
||||
// This prevents any late/duplicate GAME_STARTED messages from rolling back the board.
|
||||
useEffect(() => {
|
||||
if (chess?.fen) {
|
||||
if (!chess?.fen) return;
|
||||
const serverMoves = chess.moveHistory?.length ?? 0;
|
||||
if (serverMoves >= localMoveCountRef.current) {
|
||||
localMoveCountRef.current = serverMoves;
|
||||
localFenRef.current = chess.fen;
|
||||
setLocalFen(chess.fen);
|
||||
}
|
||||
}, [chess?.fen]);
|
||||
|
||||
// Track latest state in ref to avoid stale closures
|
||||
const chessRef = useRef(chess);
|
||||
useEffect(() => {
|
||||
chessRef.current = chess;
|
||||
}, [chess]);
|
||||
}, [chess?.fen, chess?.moveHistory?.length]);
|
||||
|
||||
// Responsive board sizing
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
const width = entries[0]?.contentRect.width ?? 400;
|
||||
// Cap board at 400px, floor at 280px
|
||||
setBoardWidth(Math.max(280, Math.min(400, width)));
|
||||
const w = entries[0]?.contentRect.width ?? 480;
|
||||
setBoardWidth(Math.max(280, Math.min(520, w)));
|
||||
});
|
||||
observer.observe(container);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
// Auto-scroll move history to bottom when new moves arrive
|
||||
useEffect(() => {
|
||||
const el = moveHistoryRef.current;
|
||||
if (el) {
|
||||
el.scrollTop = el.scrollHeight;
|
||||
}
|
||||
}, [chess?.moveHistory?.length]);
|
||||
|
||||
const game = useMemo(() => {
|
||||
if (!localFen) return null;
|
||||
return new Chess(localFen);
|
||||
if (!localFen || localFen === "start") return new Chess();
|
||||
try { return new Chess(localFen); } catch { return new Chess(); }
|
||||
}, [localFen]);
|
||||
|
||||
if (!game) {
|
||||
return <div className="text-text-tertiary text-sm">Waiting for game to start...</div>;
|
||||
if (!chess?.players) {
|
||||
return (
|
||||
<div className="flex items-center justify-center p-8 text-sm" style={{ color: "#8e8984" }}>
|
||||
Waiting for game to start…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isWhite = chess.players.white === myPlayerId;
|
||||
const isBlack = chess.players.black === myPlayerId;
|
||||
const isBothSides = isWhite && isBlack; // admin self-play
|
||||
const isBothSides = isWhite && isBlack;
|
||||
const myColor = isWhite ? "white" : isBlack ? "black" : null;
|
||||
const turn = game.turn() === "w" ? "white" : "black";
|
||||
const isMyTurn = (isBothSides || myColor === turn) && !isSpectator;
|
||||
const boardOrientation = myColor ?? "white";
|
||||
const isGameOver = chess.status !== "playing";
|
||||
|
||||
const opponentId = myColor === "white" ? chess.players.black : chess.players.white;
|
||||
const opponent = players.find(p => p.discordId === opponentId);
|
||||
const me = players.find(p => p.discordId === myPlayerId);
|
||||
const topColor = boardOrientation === "white" ? "black" : "white";
|
||||
const bottomColor = boardOrientation;
|
||||
|
||||
// Determine if it's the opponent's turn
|
||||
const isOpponentTurn = !isMyTurn && !isSpectator && !isGameOver;
|
||||
const topPlayerId = chess.players[topColor];
|
||||
const bottomPlayerId = chess.players[bottomColor];
|
||||
const topPlayer = players.find(p => p.discordId === topPlayerId);
|
||||
const bottomPlayer = players.find(p => p.discordId === bottomPlayerId);
|
||||
|
||||
const isTopActive = !isGameOver && turn === topColor && !isSpectator;
|
||||
const isBottomActive = !isGameOver && turn === bottomColor && !isSpectator;
|
||||
|
||||
function dispatchMove(from: string, to: string, promotion?: string) {
|
||||
// Optimistically apply the move locally so the board doesn't snap back
|
||||
try {
|
||||
const optimistic = new Chess(localFenRef.current);
|
||||
const moved = optimistic.move({ from, to, promotion: promotion ?? "q" });
|
||||
if (moved) {
|
||||
localMoveCountRef.current += 1;
|
||||
localFenRef.current = optimistic.fen();
|
||||
setLocalFen(optimistic.fen());
|
||||
}
|
||||
} catch { /* invalid move — server will reject it */ }
|
||||
} catch { /* invalid — server will reject */ }
|
||||
lastMoveRef.current = { from, to };
|
||||
setSelectedSquare(null);
|
||||
onAction({ type: "move", from, to, ...(promotion ? { promotion } : {}) });
|
||||
@@ -104,36 +327,26 @@ export function ChessBoard({ state, myPlayerId, isSpectator, onAction, players }
|
||||
|
||||
function onDrop(sourceSquare: string, targetSquare: string): boolean {
|
||||
if (isSpectator || !isMyTurn || isGameOver) return false;
|
||||
|
||||
const testGame = new Chess(localFenRef.current);
|
||||
|
||||
// Check if this is a promotion move
|
||||
const piece = testGame.get(sourceSquare as any);
|
||||
if (piece?.type === "p") {
|
||||
const targetRank = targetSquare[1];
|
||||
if ((piece.color === "w" && targetRank === "8") || (piece.color === "b" && targetRank === "1")) {
|
||||
const rank = targetSquare[1];
|
||||
if ((piece.color === "w" && rank === "8") || (piece.color === "b" && rank === "1")) {
|
||||
setPromotionFrom(sourceSquare);
|
||||
setPromotionTo(targetSquare);
|
||||
lastMoveRef.current = { from: sourceSquare, to: targetSquare };
|
||||
return true; // allow the visual drop, handle promotion via dialog
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
let move;
|
||||
try {
|
||||
move = testGame.move({ from: sourceSquare, to: targetSquare });
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
try { move = testGame.move({ from: sourceSquare, to: targetSquare }); } catch { return false; }
|
||||
if (!move) return false;
|
||||
|
||||
dispatchMove(sourceSquare, targetSquare);
|
||||
return true;
|
||||
}
|
||||
|
||||
function handlePromotion(piece: string) {
|
||||
if (promotionFrom && promotionTo) {
|
||||
// react-chessboard gives us e.g. "wQ" — extract just the piece letter lowercase
|
||||
const promotionPiece = piece[1]?.toLowerCase() ?? "q";
|
||||
dispatchMove(promotionFrom, promotionTo, promotionPiece);
|
||||
}
|
||||
@@ -143,32 +356,19 @@ export function ChessBoard({ state, myPlayerId, isSpectator, onAction, players }
|
||||
}
|
||||
|
||||
function onSquareClick(square: string) {
|
||||
if (isSpectator || isGameOver) return;
|
||||
if (!isMyTurn) return;
|
||||
|
||||
// If promotion dialog is open, ignore clicks
|
||||
if (isSpectator || isGameOver || !isMyTurn) return;
|
||||
if (promotionFrom !== null) return;
|
||||
|
||||
const testGame = new Chess(localFenRef.current);
|
||||
|
||||
// If a square is already selected
|
||||
if (selectedSquare !== null) {
|
||||
// Clicking the same square again → deselect
|
||||
if (square === selectedSquare) {
|
||||
setSelectedSquare(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if this is a valid destination from selected square
|
||||
if (square === selectedSquare) { setSelectedSquare(null); return; }
|
||||
const legalMoves = testGame.moves({ square: selectedSquare as any, verbose: true });
|
||||
const validDest = legalMoves.find(m => m.to === square);
|
||||
|
||||
if (validDest) {
|
||||
// Check for promotion
|
||||
const piece = testGame.get(selectedSquare as any);
|
||||
if (piece?.type === "p") {
|
||||
const targetRank = square[1];
|
||||
if ((piece.color === "w" && targetRank === "8") || (piece.color === "b" && targetRank === "1")) {
|
||||
const rank = square[1];
|
||||
if ((piece.color === "w" && rank === "8") || (piece.color === "b" && rank === "1")) {
|
||||
setPromotionFrom(selectedSquare);
|
||||
setPromotionTo(square);
|
||||
lastMoveRef.current = { from: selectedSquare, to: square };
|
||||
@@ -179,80 +379,65 @@ export function ChessBoard({ state, myPlayerId, isSpectator, onAction, players }
|
||||
dispatchMove(selectedSquare, square);
|
||||
return;
|
||||
}
|
||||
|
||||
// Not a valid dest — check if clicked square has a moveable piece to switch selection
|
||||
const clickedPiece = testGame.get(square as any);
|
||||
if (clickedPiece) {
|
||||
const clickedColor = clickedPiece.color === "w" ? "white" : "black";
|
||||
const canMovePiece = isBothSides ? clickedColor === turn : clickedColor === myColor;
|
||||
if (canMovePiece) {
|
||||
const clicked = testGame.get(square as any);
|
||||
if (clicked) {
|
||||
const color = clicked.color === "w" ? "white" : "black";
|
||||
if (isBothSides ? color === turn : color === myColor) {
|
||||
setSelectedSquare(square);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise deselect
|
||||
setSelectedSquare(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// No square selected yet — select if we can move this piece
|
||||
const clickedPiece = testGame.get(square as any);
|
||||
if (!clickedPiece) return;
|
||||
const clickedColor = clickedPiece.color === "w" ? "white" : "black";
|
||||
const canMovePiece = isBothSides ? clickedColor === turn : clickedColor === myColor;
|
||||
if (canMovePiece) {
|
||||
setSelectedSquare(square);
|
||||
}
|
||||
const clicked = testGame.get(square as any);
|
||||
if (!clicked) return;
|
||||
const color = clicked.color === "w" ? "white" : "black";
|
||||
if (isBothSides ? color === turn : color === myColor) setSelectedSquare(square);
|
||||
}
|
||||
|
||||
function isDraggablePiece({ piece }: { piece: string }): boolean {
|
||||
if (isSpectator || !isMyTurn || isGameOver) return false;
|
||||
if (isBothSides) {
|
||||
const pieceColor = piece[0] === "w" ? "white" : "black";
|
||||
return pieceColor === turn;
|
||||
}
|
||||
const pieceColor = piece[0] === "w" ? "white" : "black";
|
||||
return pieceColor === myColor;
|
||||
return isBothSides ? pieceColor === turn : pieceColor === myColor;
|
||||
}
|
||||
|
||||
// Build custom square styles: last move, selected, legal move dots, check
|
||||
// Square styles
|
||||
const customSquareStyles: Record<string, React.CSSProperties> = {};
|
||||
|
||||
// Last-move highlight
|
||||
const lastMove = lastMoveRef.current;
|
||||
if (lastMove) {
|
||||
const highlight: React.CSSProperties = { backgroundColor: "rgba(139, 92, 246, 0.2)" };
|
||||
customSquareStyles[lastMove.from] = { ...customSquareStyles[lastMove.from], ...highlight };
|
||||
customSquareStyles[lastMove.to] = { ...customSquareStyles[lastMove.to], ...highlight };
|
||||
const isDarkSquare = (sq: string) => {
|
||||
const f = sq.charCodeAt(0) - 97;
|
||||
const r = parseInt(sq[1]!) - 1;
|
||||
return (f + r) % 2 === 0;
|
||||
};
|
||||
customSquareStyles[lastMove.from] = {
|
||||
backgroundColor: isDarkSquare(lastMove.from) ? LAST_MOVE_DARK : LAST_MOVE_LIGHT,
|
||||
};
|
||||
customSquareStyles[lastMove.to] = {
|
||||
backgroundColor: isDarkSquare(lastMove.to) ? LAST_MOVE_DARK : LAST_MOVE_LIGHT,
|
||||
};
|
||||
}
|
||||
|
||||
// Selected square highlight
|
||||
if (selectedSquare !== null) {
|
||||
customSquareStyles[selectedSquare] = {
|
||||
...customSquareStyles[selectedSquare],
|
||||
boxShadow: "inset 0 0 0 3px rgba(139,92,246,0.7)",
|
||||
boxShadow: SELECTED_SHADOW,
|
||||
};
|
||||
|
||||
// Legal move dots
|
||||
const legalMoves = game.moves({ square: selectedSquare as any, verbose: true });
|
||||
for (const m of legalMoves) {
|
||||
const dest = m.to;
|
||||
const hasPiece = game.get(dest as any) !== null;
|
||||
const dotStyle: React.CSSProperties = hasPiece
|
||||
? {
|
||||
background: "radial-gradient(circle, rgba(139,92,246,0.35) 85%, transparent 87%)",
|
||||
borderRadius: "50%",
|
||||
}
|
||||
: {
|
||||
background: "radial-gradient(circle, rgba(139,92,246,0.5) 25%, transparent 27%)",
|
||||
borderRadius: "50%",
|
||||
};
|
||||
customSquareStyles[dest] = { ...customSquareStyles[dest], ...dotStyle };
|
||||
const hasPiece = game.get(m.to as any) !== null;
|
||||
customSquareStyles[m.to] = {
|
||||
...customSquareStyles[m.to],
|
||||
background: hasPiece
|
||||
? `radial-gradient(circle, rgba(20,85,30,0.4) 75%, transparent 77%)`
|
||||
: `radial-gradient(circle, rgba(20,85,30,0.45) 28%, transparent 30%)`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Check highlight (applied last so it's not overwritten by other styles)
|
||||
if (game.inCheck()) {
|
||||
const board = game.board();
|
||||
const kingColor = game.turn();
|
||||
@@ -260,42 +445,34 @@ export function ChessBoard({ state, myPlayerId, isSpectator, onAction, players }
|
||||
for (let c = 0; c < 8; c++) {
|
||||
const sq = board[r][c];
|
||||
if (sq?.type === "k" && sq.color === kingColor) {
|
||||
const file = String.fromCharCode(97 + c);
|
||||
const rank = 8 - r;
|
||||
const key = `${file}${rank}`;
|
||||
const key = `${String.fromCharCode(97 + c)}${8 - r}`;
|
||||
customSquareStyles[key] = {
|
||||
...customSquareStyles[key],
|
||||
backgroundColor: "rgba(220, 38, 38, 0.45)",
|
||||
borderRadius: "50%",
|
||||
background: `radial-gradient(ellipse at center, ${CHECK_BG} 0%, rgba(220,38,38,0.4) 50%, transparent 75%)`,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const boardBorderClass = isMyTurn && !isGameOver
|
||||
? "border-primary"
|
||||
: "border-border";
|
||||
|
||||
return (
|
||||
<div className="flex flex-col md:flex-row gap-4">
|
||||
<div className="flex flex-col lg:flex-row gap-0 items-start justify-center">
|
||||
{/* Board column */}
|
||||
<div ref={containerRef} className="w-full max-w-[400px]">
|
||||
{/* Opponent info */}
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<div className="w-6 h-6 rounded-full bg-surface flex items-center justify-center text-xs font-medium">
|
||||
{opponent?.username?.[0]?.toUpperCase() ?? "?"}
|
||||
</div>
|
||||
<span className="text-sm font-medium">{opponent?.username ?? "Opponent"}</span>
|
||||
<span className="text-xs text-text-tertiary">· {myColor === "white" ? "Black" : "White"}</span>
|
||||
{isOpponentTurn && (
|
||||
<span className="text-xs font-medium bg-primary/15 text-primary px-2 py-0.5 rounded animate-pulse">
|
||||
Their turn
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className="flex flex-col rounded-sm overflow-hidden shadow-2xl"
|
||||
style={{ backgroundColor: "#262421" }}
|
||||
>
|
||||
{/* Opponent panel */}
|
||||
<PlayerPanel
|
||||
username={topPlayer?.username ?? (isSpectator ? "Player" : "Opponent")}
|
||||
color={topColor}
|
||||
isActive={isTopActive}
|
||||
fen={localFen}
|
||||
isTop
|
||||
/>
|
||||
|
||||
<div className={`rounded overflow-hidden border-2 transition-colors duration-300 ${boardBorderClass}`}>
|
||||
{/* Board wrapper */}
|
||||
<div ref={containerRef} className="relative" style={{ lineHeight: 0 }}>
|
||||
<Chessboard
|
||||
position={localFen}
|
||||
onPieceDrop={onDrop}
|
||||
@@ -306,74 +483,88 @@ export function ChessBoard({ state, myPlayerId, isSpectator, onAction, players }
|
||||
boardWidth={boardWidth}
|
||||
showPromotionDialog={promotionFrom !== null}
|
||||
promotionToSquare={promotionTo as any}
|
||||
animationDuration={200}
|
||||
animationDuration={150}
|
||||
customSquareStyles={customSquareStyles}
|
||||
customDarkSquareStyle={{ backgroundColor: "#2D2A5F" }}
|
||||
customLightSquareStyle={{ backgroundColor: "#1E1B4B" }}
|
||||
customDarkSquareStyle={{ backgroundColor: DARK_SQUARE }}
|
||||
customLightSquareStyle={{ backgroundColor: LIGHT_SQUARE }}
|
||||
customBoardStyle={{ borderRadius: "0" }}
|
||||
customDropSquareStyle={{ boxShadow: "inset 0 0 1px 4px rgba(139, 92, 246, 0.5)" }}
|
||||
customPremoveDarkSquareStyle={{ backgroundColor: "#4c1d95" }}
|
||||
customPremoveLightSquareStyle={{ backgroundColor: "#5b21b6" }}
|
||||
customDropSquareStyle={{ boxShadow: "inset 0 0 1px 6px rgba(20,85,30,0.7)" }}
|
||||
customPremoveDarkSquareStyle={{ backgroundColor: "#a04a4a" }}
|
||||
customPremoveLightSquareStyle={{ backgroundColor: "#d08080" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Player info */}
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<div className="w-6 h-6 rounded-full bg-surface flex items-center justify-center text-xs font-medium border-2 border-primary">
|
||||
{me?.username?.[0]?.toUpperCase() ?? "?"}
|
||||
</div>
|
||||
<span className="text-sm font-medium">{me?.username ?? "You"}</span>
|
||||
<span className="text-xs text-text-tertiary">· {myColor ?? "Spectator"}</span>
|
||||
{isMyTurn && !isGameOver && (
|
||||
<span className="text-xs font-medium bg-primary/15 text-primary px-2 py-0.5 rounded">Your turn</span>
|
||||
{isGameOver && (
|
||||
<GameOverOverlay
|
||||
status={chess.status}
|
||||
winner={chess.winner}
|
||||
myPlayerId={myPlayerId}
|
||||
players={players}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* My panel */}
|
||||
<PlayerPanel
|
||||
username={bottomPlayer?.username ?? (isSpectator ? "Spectator" : "You")}
|
||||
color={bottomColor}
|
||||
isActive={isBottomActive}
|
||||
fen={localFen}
|
||||
isTop={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Sidebar - stacks below on mobile */}
|
||||
<div className="flex flex-col gap-3 w-full md:w-auto md:min-w-[180px]">
|
||||
<div className="bg-card rounded-lg border border-border">
|
||||
<div className="px-4 py-2.5 border-b border-border text-xs font-semibold">Move History</div>
|
||||
<div ref={moveHistoryRef} className="px-4 py-2 max-h-48 md:max-h-64 overflow-y-auto">
|
||||
{chess.moveHistory.length === 0 ? (
|
||||
<div className="text-xs text-text-disabled">No moves yet</div>
|
||||
) : (
|
||||
<div className="text-xs text-text-tertiary font-mono leading-6">
|
||||
{chess.moveHistory.map((move, i) => (
|
||||
<span key={i}>
|
||||
{i % 2 === 0 && <span className="text-text-disabled">{Math.floor(i / 2) + 1}. </span>}
|
||||
{move}{" "}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* Sidebar */}
|
||||
<div
|
||||
className="flex flex-col lg:w-52 w-full rounded-sm overflow-hidden shadow-2xl lg:ml-2 mt-2 lg:mt-0"
|
||||
style={{
|
||||
backgroundColor: "#262421",
|
||||
border: "1px solid #1a1917",
|
||||
minHeight: "200px",
|
||||
alignSelf: "stretch",
|
||||
}}
|
||||
>
|
||||
<MoveHistory moveHistory={chess.moveHistory ?? []} />
|
||||
|
||||
{!isSpectator && chess.status === "playing" && (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-3 border-t" style={{ borderColor: "#1a1917" }}>
|
||||
{confirmForfeit ? (
|
||||
<>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => { onAction({ type: "forfeit" }); setConfirmForfeit(false); }}
|
||||
className="flex-1 rounded-md px-3 py-2 text-sm font-medium bg-destructive/15 text-destructive border border-destructive hover:bg-destructive/25 transition-colors"
|
||||
onClick={() => {
|
||||
onAction({ type: "forfeit" });
|
||||
setConfirmForfeit(false);
|
||||
}}
|
||||
className="flex-1 rounded px-3 py-2 text-xs font-semibold transition-colors"
|
||||
style={{
|
||||
backgroundColor: "rgba(180,40,40,0.2)",
|
||||
color: "#e06060",
|
||||
border: "1px solid rgba(180,40,40,0.4)",
|
||||
}}
|
||||
>
|
||||
Confirm forfeit?
|
||||
Confirm
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setConfirmForfeit(false)}
|
||||
className="rounded-md px-2 py-2 text-sm font-medium bg-card border border-border hover:bg-surface transition-colors text-text-tertiary"
|
||||
aria-label="Cancel forfeit"
|
||||
className="rounded px-3 py-2 text-xs font-semibold transition-colors"
|
||||
style={{
|
||||
backgroundColor: "rgba(255,255,255,0.05)",
|
||||
color: "#8e8984",
|
||||
border: "1px solid #3a3733",
|
||||
}}
|
||||
>
|
||||
✕
|
||||
Cancel
|
||||
</button>
|
||||
</>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setConfirmForfeit(true)}
|
||||
className="w-full rounded-md px-3 py-2 text-sm font-medium bg-destructive/15 text-destructive hover:bg-destructive/25 transition-colors"
|
||||
className="w-full rounded px-3 py-2 text-xs font-semibold transition-opacity hover:opacity-80"
|
||||
style={{
|
||||
backgroundColor: "rgba(255,255,255,0.05)",
|
||||
color: "#8e8984",
|
||||
border: "1px solid #3a3733",
|
||||
}}
|
||||
>
|
||||
Forfeit
|
||||
Resign
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user