From e0dcfe6abeaefd422542479bf89cf44cde26d657 Mon Sep 17 00:00:00 2001 From: syntaxbullet Date: Thu, 2 Apr 2026 17:28:50 +0200 Subject: [PATCH] fix: (chess) new styling --- panel/src/games/chess/ChessBoard.tsx | 559 ++++++++++++++++++--------- 1 file changed, 375 insertions(+), 184 deletions(-) diff --git a/panel/src/games/chess/ChessBoard.tsx b/panel/src/games/chess/ChessBoard.tsx index 7a39c92..533a558 100644 --- a/panel/src/games/chess/ChessBoard.tsx +++ b/panel/src/games/chess/ChessBoard.tsx @@ -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 ( +
+ {username?.[0]?.toUpperCase() ?? "?"} +
+ ); +} + +function CapturedPieces({ fen, color }: { fen: string; color: "white" | "black" }) { + const captured = useMemo(() => { + const game = new Chess(fen); + const board = game.board(); + const counts: Record = { 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 = { p: 8, n: 2, b: 2, r: 2, q: 1 }; + const pieceSymbols: Record = { + 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 ( + {captured.join("")} + ); +} + +function PlayerPanel({ + username, + color, + isActive, + fen, + isTop, +}: { + username: string; + color: "white" | "black"; + isActive: boolean; + fen: string; + isTop: boolean; +}) { + return ( +
+ +
+ + {username} + +
+ + {color} + + +
+
+ {isActive && ( +
+ + + Your turn + +
+ )} +
+ ); +} + +function MoveHistory({ moveHistory }: { moveHistory: string[] }) { + const ref = useRef(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 ( +
+
+ Moves +
+
+ {pairs.length === 0 ? ( +
+ No moves yet +
+ ) : ( + + + {pairs.map(([white, black], i) => ( + + + + + + ))} + +
+ {i + 1}. + + {white} + + {black ?? "—"} +
+ )} +
+
+ ); +} + +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 ( +
+
+
+ {title} +
+
+ {subtitle} +
+
+
+ ); +} + export function ChessBoard({ state, myPlayerId, isSpectator, onAction, players }: GameUIProps) { const chess = state as ChessState; const [promotionFrom, setPromotionFrom] = useState(null); @@ -18,85 +244,82 @@ export function ChessBoard({ state, myPlayerId, isSpectator, onAction, players } const [selectedSquare, setSelectedSquare] = useState(null); const [confirmForfeit, setConfirmForfeit] = useState(false); const containerRef = useRef(null); - const moveHistoryRef = useRef(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(() => chess?.fen ?? "start"); const localFenRef = useRef(localFen); + const localMoveCountRef = useRef(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
Waiting for game to start...
; + if (!chess?.players) { + return ( +
+ Waiting for game to start… +
+ ); } 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 = {}; - // 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 ( -
+
{/* Board column */} -
- {/* Opponent info */} -
-
- {opponent?.username?.[0]?.toUpperCase() ?? "?"} -
- {opponent?.username ?? "Opponent"} - · {myColor === "white" ? "Black" : "White"} - {isOpponentTurn && ( - - Their turn - - )} -
+
+ {/* Opponent panel */} + -
+ {/* Board wrapper */} +
-
- - {/* Player info */} -
-
- {me?.username?.[0]?.toUpperCase() ?? "?"} -
- {me?.username ?? "You"} - · {myColor ?? "Spectator"} - {isMyTurn && !isGameOver && ( - Your turn + {isGameOver && ( + )}
+ + {/* My panel */} +
- {/* Sidebar - stacks below on mobile */} -
-
-
Move History
-
- {chess.moveHistory.length === 0 ? ( -
No moves yet
- ) : ( -
- {chess.moveHistory.map((move, i) => ( - - {i % 2 === 0 && {Math.floor(i / 2) + 1}. } - {move}{" "} - - ))} -
- )} -
-
+ {/* Sidebar */} +
+ {!isSpectator && chess.status === "playing" && ( -
+
{confirmForfeit ? ( - <> +
- +
) : ( )}