diff --git a/.gitignore b/.gitignore index 87904c12..c8bdcb5f 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,5 @@ tsconfig.tsbuildinfo .cdk.staging cdk.out cdk.context.json + +/docs \ No newline at end of file diff --git a/bench.js b/bench.js new file mode 100644 index 00000000..837efd66 --- /dev/null +++ b/bench.js @@ -0,0 +1,24 @@ +const { Chess } = require('chess.js'); +const c = new Chess(); +for(let i=0; i<40; i++) { + const moves = c.moves(); + c.move(moves[Math.floor(Math.random() * moves.length)]); +} +const pgn = c.pgn(); +const history = c.history({verbose:true}); + +console.time('loadPgn'); +for(let i=0; i<100; i++) { + const c2 = new Chess(); + c2.loadPgn(pgn); +} +console.timeEnd('loadPgn'); + +console.time('replayHistory'); +for(let i=0; i<100; i++) { + const c3 = new Chess(); + for(const m of history) { + c3.move(m); + } +} +console.timeEnd('replayHistory'); diff --git a/package.json b/package.json index 5c51c6b8..33ee3acc 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "private": true, "license": "GPL-3.0-only", "scripts": { - "dev": "next dev --turbo", + "dev": "SENTRY_SUPPRESS_TURBOPACK_WARNING=1 next dev --turbo", "build": "next build", "start": "next start", "lint": "next lint && tsc --noEmit", diff --git a/src/components/prettyMoveSan/chess_merida_unicode.ttf b/public/fonts/chess_merida_unicode.ttf similarity index 100% rename from src/components/prettyMoveSan/chess_merida_unicode.ttf rename to public/fonts/chess_merida_unicode.ttf diff --git a/public/sounds/check.mp3 b/public/sounds/check.mp3 new file mode 100644 index 00000000..82cce215 Binary files /dev/null and b/public/sounds/check.mp3 differ diff --git a/src/components/board/board.css b/src/components/board/board.css new file mode 100644 index 00000000..95fd6d08 --- /dev/null +++ b/src/components/board/board.css @@ -0,0 +1,55 @@ +/* Override react-chessboard's piece slide animation easing. + ease-out-cubic: snappy initial velocity, smooth deceleration. */ +[data-boardid] div[style*="transition: transform"] { + transition-timing-function: cubic-bezier(0.33, 1, 0.68, 1) !important; + z-index: 300 !important; +} + +/* Temporarily disable animations for zero-latency drops */ +.disable-piece-animations div[style*="transition: transform"] { + transition: none !important; +} + +/* Piece cursor: hand on hover */ +[data-piece] { + cursor: grab !important; + touch-action: none !important; /* Prevent mobile page swipe on pieces */ + -webkit-user-drag: none !important; /* Prevent browser-default image drag */ +} + +/* Disable text/element selection on the board */ +[data-boardid], +[data-boardid] * { + user-select: none !important; + -webkit-user-select: none !important; +} + +/* Piece return flight */ +.piece-return-ghost { + position: absolute; + pointer-events: none; + z-index: 15; + will-change: transform; + transition: transform 150ms cubic-bezier(0.33, 1, 0.68, 1); +} + +/* Ensure react-chessboard promotion dialog rises above hardware-accelerated custom pieces */ +[data-boardid] > div:not([data-piece]):not([data-square]) { + z-index: 1000 !important; + transform: translateZ(1px); +} + +/* Prevent the board container from cutting off the overflowing promotion dialog */ +[data-boardid], +[data-boardid] div { + overflow: visible !important; + clip-path: none !important; + transform-style: preserve-3d !important; +} + +@font-face { + font-family: 'ChessMerida'; + src: url('/fonts/chess_merida_unicode.ttf') format('truetype'); + font-weight: normal; + font-style: normal; +} diff --git a/src/components/board/capturedPieces.tsx b/src/components/board/capturedPieces.tsx index ddd4d949..eba56d20 100644 --- a/src/components/board/capturedPieces.tsx +++ b/src/components/board/capturedPieces.tsx @@ -1,7 +1,7 @@ import { getCapturedPieces, getMaterialDifference } from "@/lib/chess"; import { Color } from "@/types/enums"; import { Box, Grid2 as Grid, Stack, Typography } from "@mui/material"; -import { ReactElement, useMemo } from "react"; +import { ReactElement, useMemo, memo } from "react"; export interface Props { fen: string; @@ -10,7 +10,7 @@ export interface Props { const PIECE_SCALE = 0.55; -export default function CapturedPieces({ fen, color }: Props) { +const CapturedPieces = memo(function CapturedPieces({ fen, color }: Props) { const piecesComponents = useMemo(() => { const capturedPieces = getCapturedPieces(fen, color); return capturedPieces.map(({ piece, count }) => @@ -46,7 +46,7 @@ export default function CapturedPieces({ fen, color }: Props) { )} ); -} +}); const getCapturedPiecesComponents = ( pieceSymbol: string, @@ -74,3 +74,5 @@ const getCapturedPiecesComponents = ( ); }; + +export default CapturedPieces; diff --git a/src/components/board/evaluationBar.tsx b/src/components/board/evaluationBar.tsx index 78d33dd8..fcbd0ccf 100644 --- a/src/components/board/evaluationBar.tsx +++ b/src/components/board/evaluationBar.tsx @@ -1,6 +1,6 @@ import { Box, Grid2 as Grid, Typography } from "@mui/material"; import { PrimitiveAtom, atom, useAtomValue } from "jotai"; -import { useEffect, useState } from "react"; +import { useEffect, useState, memo } from "react"; import { getEvaluationBarValue } from "@/lib/chess"; import { Color } from "@/types/enums"; import { CurrentPosition } from "@/types/eval"; @@ -11,7 +11,7 @@ interface Props { currentPositionAtom?: PrimitiveAtom; } -export default function EvaluationBar({ +const EvaluationBar = memo(function EvaluationBar({ height, boardOrientation, currentPositionAtom = atom({}), @@ -107,4 +107,6 @@ export default function EvaluationBar({ ); -} +}); + +export default EvaluationBar; diff --git a/src/components/board/index.tsx b/src/components/board/index.tsx index 40cf5ca5..71707c1d 100644 --- a/src/components/board/index.tsx +++ b/src/components/board/index.tsx @@ -1,27 +1,73 @@ -import { Box, Grid2 as Grid } from "@mui/material"; +import { Grid2 as Grid } from "@mui/material"; import { Chessboard } from "react-chessboard"; -import { PrimitiveAtom, atom, useAtomValue, useSetAtom } from "jotai"; +import { PrimitiveAtom, atom, useAtomValue, useSetAtom, Atom } from "jotai"; import { Arrow, CustomPieces, - CustomSquareRenderer, Piece, PromotionPieceOption, Square, } from "react-chessboard/dist/chessboard/types"; import { useChessActions } from "@/hooks/useChessActions"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, + memo, + createContext, + useContext, +} from "react"; import { Color, MoveClassification } from "@/types/enums"; import { Chess } from "chess.js"; -import { getSquareRenderer } from "./squareRenderer"; import { CurrentPosition } from "@/types/eval"; import EvaluationBar from "./evaluationBar"; import { CLASSIFICATION_COLORS } from "@/constants"; import { Player } from "@/types/game"; import PlayerHeader from "./playerHeader"; import { boardHueAtom, pieceSetAtom } from "./states"; +import type { ClickedSquare } from "./types"; import tinycolor from "tinycolor2"; +const clickedSquaresAtom = atom([]); +const playableSquaresAtom = atom([]); +const captureSquaresAtom = atom([]); +const moveClickFromAtom = atom(null); +const moveClickToAtom = atom(null); + +const defaultCurrentPositionAtom = atom({} as CurrentPosition); +const defaultShowPlayerMoveIconAtom = atom(false); + +export const BoardStateContext = createContext<{ + pieceSet: string; + checkSquare: Square | null; + turn: "w" | "b"; + boardHue: number; + boardSize: number; + currentPositionAtom: Atom; + clickedSquaresAtom: Atom; + playableSquaresAtom: Atom; + captureSquaresAtom: Atom; + showPlayerMoveIconAtom: Atom; + moveClickFromAtom: Atom; +}>({ + pieceSet: "maestro", + checkSquare: null, + turn: "w", + boardHue: 0, + boardSize: 400, + currentPositionAtom: defaultCurrentPositionAtom, + clickedSquaresAtom, + playableSquaresAtom, + captureSquaresAtom, + showPlayerMoveIconAtom: atom(false), + moveClickFromAtom, +}); + +import { getSquareRenderer } from "./squareRenderer"; + export interface Props { id: string; canPlay?: Color | boolean; @@ -34,9 +80,79 @@ export interface Props { showBestMoveArrow?: boolean; showPlayerMoveIconAtom?: PrimitiveAtom; showEvaluationBar?: boolean; + animationDurationAtom?: PrimitiveAtom; } -export default function Board({ +const CustomPiece = memo( + ({ + squareWidth, + isDragging, + piece, + }: { + squareWidth: number; + isDragging: boolean; + piece: string; + }) => { + const { pieceSet, checkSquare, turn, boardHue } = + useContext(BoardStateContext); + + const isCheck = + (piece === "wK" && turn === "w" && checkSquare) || + (piece === "bK" && turn === "b" && checkSquare); + + const hueFilter = boardHue ? `hue-rotate(-${boardHue}deg)` : ""; + + const checkShadow = isCheck + ? "drop-shadow(0 0 8px rgba(235, 97, 80, 1)) drop-shadow(0 0 16px rgba(235, 97, 80, 0.8))" + : ""; + const dragShadow = isDragging + ? "drop-shadow(0 6px 8px rgba(0, 0, 0, 0.25))" + : "drop-shadow(0 0px 0px rgba(0, 0, 0, 0))"; + + return ( +
+ ); + } +); + +CustomPiece.displayName = "CustomPiece"; + +export const PIECE_CODES = [ + "wP", + "wB", + "wN", + "wR", + "wQ", + "wK", + "bP", + "bB", + "bN", + "bR", + "bQ", + "bK", +] as const satisfies Piece[]; + +function Board({ id: boardId, canPlay, gameAtom, @@ -44,33 +160,171 @@ export default function Board({ whitePlayer, blackPlayer, boardOrientation = Color.White, - currentPositionAtom = atom({}), + currentPositionAtom = defaultCurrentPositionAtom, showBestMoveArrow = false, showPlayerMoveIconAtom, showEvaluationBar = false, + animationDurationAtom, }: Props) { const boardRef = useRef(null); const game = useAtomValue(gameAtom); - const { playMove } = useChessActions(gameAtom); - const clickedSquaresAtom = useMemo(() => atom([]), []); + const { playMove, undoMove } = useChessActions(gameAtom); + const pieceSet = useAtomValue(pieceSetAtom); + const boardHue = useAtomValue(boardHueAtom); + + const checkSquareAtom = useMemo( + () => + atom((get) => { + const game = get(gameAtom); + if (!game.inCheck() && !game.isCheckmate()) return null; + const turn = game.turn(); + return ( + game + .board() + .flat() + .find((p) => p?.type === "k" && p?.color === turn)?.square ?? null + ); + }), + [gameAtom] + ); + + // Jotai Setters const setClickedSquares = useSetAtom(clickedSquaresAtom); - const playableSquaresAtom = useMemo(() => atom([]), []); const setPlayableSquares = useSetAtom(playableSquaresAtom); - const position = useAtomValue(currentPositionAtom); + const setCaptureSquares = useSetAtom(captureSquaresAtom); + + // Jotai Getters (derived state) + const [moveClickFrom, setMoveClickFrom] = [ + useAtomValue(moveClickFromAtom), + useSetAtom(moveClickFromAtom), + ]; + const [moveClickTo, setMoveClickTo] = [ + useAtomValue(moveClickToAtom), + useSetAtom(moveClickToAtom), + ]; + + const checkSquare = useAtomValue(checkSquareAtom); + + const customPieces = useMemo(() => { + return PIECE_CODES.reduce((acc, pieceCode) => { + acc[pieceCode] = (props) => ; + return acc; + }, {}); + }, []); + + // Derive only the specific primitive values Board needs from currentPositionAtom. + const arrowBestMove = useAtomValue( + useMemo( + () => atom((get) => get(currentPositionAtom)?.lastEval?.bestMove), + [currentPositionAtom] + ) + ); + const arrowMoveClassification = useAtomValue( + useMemo( + () => atom((get) => get(currentPositionAtom)?.eval?.moveClassification), + [currentPositionAtom] + ) + ); + + // Local State + const [userArrows, setUserArrows] = useState([]); + const [newArrow, setNewArrow] = useState(null); const [showPromotionDialog, setShowPromotionDialog] = useState(false); - const [moveClickFrom, setMoveClickFrom] = useState(null); - const [moveClickTo, setMoveClickTo] = useState(null); - const pieceSet = useAtomValue(pieceSetAtom); - const boardHue = useAtomValue(boardHueAtom); + const [localAnimationDuration, setLocalAnimationDuration] = useState(150); - const gameFen = game.fen(); + // Animation Duration Logic + const externalAnimationDuration = useAtomValue( + useMemo(() => animationDurationAtom || atom(150), [animationDurationAtom]) + ); + const setExternalAnimationDuration = useSetAtom( + useMemo(() => animationDurationAtom || atom(150), [animationDurationAtom]) + ); + + const animationDurationToUse = animationDurationAtom + ? externalAnimationDuration + : localAnimationDuration; + const setAnimationDurationToUse = useCallback( + (duration: number) => { + if (animationDurationAtom) { + setExternalAnimationDuration(duration); + } else { + setLocalAnimationDuration(duration); + } + }, + [animationDurationAtom, setExternalAnimationDuration] + ); + + // Refs for event handling and drag state + const isAltPressedRef = useRef(false); + const isCtrlPressedRef = useRef(false); + const isDraggingRef = useRef(false); + const shouldCancelDragRef = useRef(false); + const lastRightClickRef = useRef(0); + const dragCancelledRef = useRef(0); + const rightClickDownRef = useRef(false); + const lastRightClickUpTimeRef = useRef(0); + const lastDropMoveTimeRef = useRef(0); + const dragOriginSquareRef = useRef(null); + const dragPieceRef = useRef(null); + const rightClickDragStartRef = useRef(null); + + // Custom pointer drag refs + const customDragGhostRef = useRef(null); + const dragStartPosRef = useRef<{ + x: number; + y: number; + constraints?: { + minDx: number; + maxDx: number; + minDy: number; + maxDy: number; + }; + } | null>(null); + const draggedPieceElementRef = useRef(null); + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Alt") isAltPressedRef.current = true; + if (e.key === "Control" || e.key === "Meta") + isCtrlPressedRef.current = true; + }; + const handleKeyUp = (e: KeyboardEvent) => { + if (e.key === "Alt") isAltPressedRef.current = false; + if (e.key === "Control" || e.key === "Meta") + isCtrlPressedRef.current = false; + }; + const handleBlur = () => { + isAltPressedRef.current = false; + isCtrlPressedRef.current = false; + }; + window.addEventListener("keydown", handleKeyDown); + window.addEventListener("keyup", handleKeyUp); + window.addEventListener("blur", handleBlur); + return () => { + window.removeEventListener("keydown", handleKeyDown); + window.removeEventListener("keyup", handleKeyUp); + window.removeEventListener("blur", handleBlur); + }; + }, []); + + const gameFen = useMemo(() => game.fen(), [game]); useEffect(() => { setClickedSquares([]); + setUserArrows([]); }, [gameFen, setClickedSquares]); const isPiecePlayable = useCallback( ({ piece }: { piece: string }): boolean => { + if ( + rightClickDownRef.current || + Date.now() - lastRightClickUpTimeRef.current < 250 || + Date.now() - lastRightClickRef.current < 250 || + Date.now() - dragCancelledRef.current < 250 + ) { + return false; + } + if (game.isGameOver() || !canPlay) return false; if (canPlay === true || canPlay === piece[0]) return true; return false; @@ -78,71 +332,641 @@ export default function Board({ [canPlay, game] ); + const resetMoveClick = useCallback( + (square?: Square | null) => { + setMoveClickFrom(square ?? null); + setMoveClickTo(null); + setShowPromotionDialog(false); + if (square) { + const moves = game.moves({ square, verbose: true }); + setPlayableSquares(moves.map((m) => m.to)); + setCaptureSquares(moves.filter((m) => m.captured).map((m) => m.to)); + } else { + setPlayableSquares([]); + setCaptureSquares([]); + } + }, + [ + setMoveClickFrom, + setMoveClickTo, + setPlayableSquares, + setCaptureSquares, + game, + ] + ); + + const getSquareFromCoords = useCallback( + (clientX: number, clientY: number): Square | null => { + if (!boardRef.current) return null; + const rect = boardRef.current.getBoundingClientRect(); + if ( + clientX < rect.left || + clientX > rect.right || + clientY < rect.top || + clientY > rect.bottom + ) { + return null; + } + + const squareSize = rect.width / 8; + const col = Math.floor((clientX - rect.left) / squareSize); + const row = Math.floor((clientY - rect.top) / squareSize); + + if (col < 0 || col > 7 || row < 0 || row > 7) return null; + + const file = + boardOrientation === Color.White + ? String.fromCharCode(97 + col) + : String.fromCharCode(97 + (7 - col)); + const rank = + boardOrientation === Color.White ? String(8 - row) : String(row + 1); + + return `${file}${rank}` as Square; + }, + [boardOrientation] + ); + + const animateReturnFlight = useCallback( + ( + sourceSquare: Square, + piece: string, + existingGhost?: HTMLDivElement | null, + draggedPiece?: HTMLElement | null + ) => { + const boardElement = boardRef.current; + const targetSquareElement = boardElement?.querySelector( + `[data-square="${sourceSquare}"]` + ) as HTMLElement; + + if (!targetSquareElement || !boardElement) { + if (existingGhost) existingGhost.remove(); + if (draggedPiece) draggedPiece.style.opacity = "1"; + return; + } + + const targetRect = targetSquareElement.getBoundingClientRect(); + + if (existingGhost) { + // Animate the actual custom drag ghost back to its origin square + // We override its transform to fly to the origin instead of translating + const flightTimeMs = 150; + existingGhost.style.transition = `top ${flightTimeMs}ms ease-out, left ${flightTimeMs}ms ease-out, transform ${flightTimeMs}ms ease-out`; + existingGhost.style.transform = "scale(1.0) translate(0px, 0px)"; + existingGhost.style.top = `${targetRect.top}px`; + existingGhost.style.left = `${targetRect.left}px`; + + setTimeout(() => { + if (existingGhost.parentNode) { + existingGhost.remove(); + } + if (draggedPiece) draggedPiece.style.opacity = "1"; + }, flightTimeMs); + } else { + // This is the fallback fading ghost for invalid drops + const ghost = document.createElement("div"); + ghost.style.position = "fixed"; + ghost.style.top = `${targetRect.top}px`; + ghost.style.left = `${targetRect.left}px`; + ghost.style.width = `${targetRect.width}px`; + ghost.style.height = `${targetRect.height}px`; + ghost.style.backgroundImage = `url(/piece/${pieceSet}/${piece}.svg)`; + ghost.style.backgroundSize = "contain"; + ghost.style.backgroundRepeat = "no-repeat"; + ghost.style.backgroundPosition = "center"; + ghost.style.pointerEvents = "none"; + ghost.style.zIndex = "100"; + ghost.classList.add("piece-return-ghost"); + + document.body.appendChild(ghost); + + setTimeout(() => { + if (ghost.parentNode) ghost.remove(); + if (draggedPiece) draggedPiece.style.opacity = "1"; + }, 150); + } + }, + [pieceSet] + ); + + const abortCustomDrag = useCallback(() => { + if (!isDraggingRef.current) return; + dragCancelledRef.current = Date.now(); + shouldCancelDragRef.current = true; + isDraggingRef.current = false; + setClickedSquares((prev) => [...prev]); + resetMoveClick(); + + // Let the ghost fly back + if ( + dragOriginSquareRef.current && + dragPieceRef.current && + customDragGhostRef.current + ) { + animateReturnFlight( + dragOriginSquareRef.current, + dragPieceRef.current, + customDragGhostRef.current, + draggedPieceElementRef.current + ); + } else { + // Cleanup immediately if no ghost was created yet + if (customDragGhostRef.current) { + customDragGhostRef.current.remove(); + } + if (draggedPieceElementRef.current) { + draggedPieceElementRef.current.style.opacity = "1"; + } + } + + customDragGhostRef.current = null; + draggedPieceElementRef.current = null; + dragOriginSquareRef.current = null; + dragPieceRef.current = null; + }, [resetMoveClick, setClickedSquares, animateReturnFlight]); + + const handleGlobalPointerMove = useCallback((e: PointerEvent) => { + e.preventDefault(); + if (!customDragGhostRef.current || !dragStartPosRef.current) return; + + const rawDx = e.clientX - dragStartPosRef.current.x; + const rawDy = e.clientY - dragStartPosRef.current.y; + + // Threshold to prevent ghost initialization on pure clicks + if (Math.abs(rawDx) > 3 || Math.abs(rawDy) > 3) { + if (!customDragGhostRef.current.parentNode) { + document.body.appendChild(customDragGhostRef.current); + if (draggedPieceElementRef.current) { + draggedPieceElementRef.current.style.opacity = "0"; + } + } + + let dx = rawDx; + let dy = rawDy; + + const constraints = dragStartPosRef.current.constraints; + if (constraints) { + dx = Math.max(constraints.minDx, Math.min(dx, constraints.maxDx)); + dy = Math.max(constraints.minDy, Math.min(dy, constraints.maxDy)); + } + + const translateX = Math.round(dx / 1.05); + const translateY = Math.round(dy / 1.05); + customDragGhostRef.current.style.transform = `scale(1.05) translate(${translateX}px, ${translateY}px)`; + } + }, []); + + const handleGlobalPointerMoveRightClick = useCallback( + (e: PointerEvent) => { + e.preventDefault(); + if (!rightClickDragStartRef.current) return; + const hoverSquare = getSquareFromCoords(e.clientX, e.clientY); + if (hoverSquare) { + const color = isAltPressedRef.current + ? "#70bbd9" + : isCtrlPressedRef.current + ? "#eb6150" + : "#ffaa00"; + setNewArrow([rightClickDragStartRef.current, hoverSquare, color]); + } + }, + [getSquareFromCoords] + ); + + const handleGlobalPointerUpRightClick = useCallback( + (e: PointerEvent) => { + document.removeEventListener( + "pointermove", + handleGlobalPointerMoveRightClick + ); + document.removeEventListener( + "pointerup", + handleGlobalPointerUpRightClick + ); + + const startSquare = rightClickDragStartRef.current; + rightClickDragStartRef.current = null; + setNewArrow(null); + + if (!startSquare) return; + const hoverSquare = getSquareFromCoords(e.clientX, e.clientY); + + if (hoverSquare && hoverSquare !== startSquare) { + const finalColor = isAltPressedRef.current + ? "#70bbd9" + : isCtrlPressedRef.current + ? "#eb6150" + : "#ffaa00"; + const finalArrow = [startSquare, hoverSquare, finalColor] as Arrow; + setUserArrows((prev) => { + const existing = prev.find( + (a) => a[0] === finalArrow[0] && a[1] === finalArrow[1] + ); + if (existing) { + if (existing[2] === finalArrow[2]) { + return prev.filter((a) => a !== existing); + } else { + return [...prev.filter((a) => a !== existing), finalArrow]; + } + } + return [...prev, finalArrow]; + }); + } + }, + [ + getSquareFromCoords, + handleGlobalPointerMoveRightClick, + isAltPressedRef, + isCtrlPressedRef, + ] + ); + const onPieceDrop = useCallback( (source: Square, target: Square, piece: string): boolean => { + if ( + shouldCancelDragRef.current || + rightClickDownRef.current || + Date.now() - lastRightClickUpTimeRef.current < 250 || + Date.now() - lastRightClickRef.current < 250 || + Date.now() - dragCancelledRef.current < 250 + ) { + return false; + } + if (!isPiecePlayable({ piece })) return false; + setAnimationDurationToUse(0); + const result = playMove({ from: source, to: target, promotion: piece[1]?.toLowerCase() ?? "q", }); + if (result) { + lastDropMoveTimeRef.current = Date.now(); + } + return !!result; }, - [isPiecePlayable, playMove] + [isPiecePlayable, playMove, setAnimationDurationToUse] ); - const resetMoveClick = useCallback( - (square?: Square | null) => { - setMoveClickFrom(square ?? null); - setMoveClickTo(null); - setShowPromotionDialog(false); - if (square) { - const moves = game.moves({ square, verbose: true }); - setPlayableSquares(moves.map((m) => m.to)); + const handleGlobalPointerUp = useCallback( + (e: PointerEvent) => { + document.removeEventListener("pointermove", handleGlobalPointerMove); + document.removeEventListener("pointerup", handleGlobalPointerUp); + + if (!isDraggingRef.current || shouldCancelDragRef.current) { + return; + } + + const targetSquare = getSquareFromCoords(e.clientX, e.clientY); + const sourceSquare = dragOriginSquareRef.current; + const piece = dragPieceRef.current; + + const wasDraggingVisibly = !!customDragGhostRef.current?.parentNode; + let moveSucceeded = false; + let isPendingPromotion = false; + + if ( + wasDraggingVisibly && + targetSquare && + sourceSquare && + piece && + targetSquare !== sourceSquare + ) { + const validMoves = game.moves({ square: sourceSquare, verbose: true }); + let move = validMoves.find((m) => m.to === targetSquare); + let actualTargetSquare = targetSquare; + + if (!move) { + const epMove = validMoves.find( + (m) => m.isEnPassant() && m.to[0] + m.from[1] === targetSquare + ); + if (epMove) { + move = epMove; + actualTargetSquare = epMove.to as Square; + } + } + + if ( + move && + move.piece === "p" && + ((move.color === "w" && actualTargetSquare[1] === "8") || + (move.color === "b" && actualTargetSquare[1] === "1")) + ) { + isPendingPromotion = true; + setAnimationDurationToUse(0); + setMoveClickFrom(sourceSquare); + setMoveClickTo(actualTargetSquare); + setShowPromotionDialog(true); + } else { + moveSucceeded = onPieceDrop(sourceSquare, actualTargetSquare, piece); + } + } + + if ( + !moveSucceeded && + !isPendingPromotion && + wasDraggingVisibly && + sourceSquare && + piece + ) { + animateReturnFlight( + sourceSquare, + piece, + customDragGhostRef.current, + draggedPieceElementRef.current + ); + } else if (moveSucceeded && wasDraggingVisibly && targetSquare) { + if (customDragGhostRef.current) { + customDragGhostRef.current.remove(); + } } else { + if (customDragGhostRef.current) { + customDragGhostRef.current.remove(); + } + if (draggedPieceElementRef.current) { + draggedPieceElementRef.current.style.opacity = "1"; + } + } + + customDragGhostRef.current = null; + draggedPieceElementRef.current = null; + isDraggingRef.current = false; + if (wasDraggingVisibly) { setPlayableSquares([]); + setCaptureSquares([]); } + dragOriginSquareRef.current = null; + dragPieceRef.current = null; + shouldCancelDragRef.current = false; }, - [setMoveClickFrom, setMoveClickTo, setPlayableSquares, game] + [ + getSquareFromCoords, + onPieceDrop, + animateReturnFlight, + setPlayableSquares, + setCaptureSquares, + handleGlobalPointerMove, + game, + setMoveClickFrom, + setMoveClickTo, + setAnimationDurationToUse, + ] + ); + + const handleBoardPointerDownCapture = useCallback( + (e: React.PointerEvent) => { + if (e.button === 2) { + rightClickDownRef.current = true; + lastRightClickRef.current = Date.now(); + if (isDraggingRef.current) { + e.stopPropagation(); + abortCustomDrag(); + } else { + const target = e.target as HTMLElement; + const squareElement = target.closest("[data-square]") as HTMLElement; + const square = squareElement?.dataset.square as Square; + if (square) { + rightClickDragStartRef.current = square; + const color = isAltPressedRef.current + ? "#70bbd9" + : isCtrlPressedRef.current + ? "#eb6150" + : "#ffaa00"; + setNewArrow([square, square, color]); + document.addEventListener( + "pointermove", + handleGlobalPointerMoveRightClick + ); + document.addEventListener( + "pointerup", + handleGlobalPointerUpRightClick + ); + } + } + return; + } + + if (e.button === 0) { + // Prevent browser default selection/drag on the entire board + e.preventDefault(); + + setClickedSquares([]); + setUserArrows([]); + + const target = e.target as HTMLElement; + const pieceElement = target.closest("[data-piece]") as HTMLElement; + if (!pieceElement) return; + + const piece = pieceElement.dataset.piece; + const squareElement = pieceElement.closest( + "[data-square]" + ) as HTMLElement; + const square = squareElement?.dataset.square as Square; + + if (moveClickFrom) { + const validMoves = game.moves({ + square: moveClickFrom, + verbose: true, + }); + + let move = validMoves.find((m) => m.to === square); + let actualTargetSquare = square; + + if (!move) { + const epMove = validMoves.find( + (m) => m.isEnPassant() && m.to[0] + m.from[1] === square + ); + if (epMove) { + move = epMove; + actualTargetSquare = epMove.to as Square; + } + } + + if (move) { + e.preventDefault(); + e.stopPropagation(); + + if ( + move.piece === "p" && + ((move.color === "w" && actualTargetSquare[1] === "8") || + (move.color === "b" && actualTargetSquare[1] === "1")) + ) { + setAnimationDurationToUse(150); + setMoveClickTo(actualTargetSquare); + setShowPromotionDialog(true); + return; + } + + setAnimationDurationToUse(150); + playMove({ + from: moveClickFrom, + to: actualTargetSquare, + }); + + resetMoveClick(undefined); + return; + } + } + + if (!piece || !square || !isPiecePlayable({ piece })) return; + + shouldCancelDragRef.current = false; + isDraggingRef.current = true; + dragOriginSquareRef.current = square; + dragPieceRef.current = piece; + + setMoveClickFrom(null); + setMoveClickTo(null); + setShowPromotionDialog(false); + const moves = game.moves({ square, verbose: true }); + setPlayableSquares(moves.map((m) => m.to)); + setCaptureSquares(moves.filter((m) => m.captured).map((m) => m.to)); + + const rect = pieceElement.getBoundingClientRect(); + const ghost = document.createElement("div"); + ghost.style.position = "fixed"; + ghost.style.top = `${rect.top}px`; + ghost.style.left = `${rect.left}px`; + ghost.style.width = `${rect.width}px`; + ghost.style.height = `${rect.height}px`; + ghost.style.backgroundImage = `url(/piece/${pieceSet}/${piece}.svg)`; + ghost.style.backgroundSize = "contain"; + ghost.style.backgroundRepeat = "no-repeat"; + ghost.style.backgroundPosition = "center"; + ghost.style.pointerEvents = "none"; + ghost.style.zIndex = "9999"; + ghost.style.transform = "scale(1.05)"; + ghost.style.filter = "drop-shadow(0 4px 10px rgba(0,0,0,0.5))"; + ghost.style.transition = "transform 0.05s linear"; + + customDragGhostRef.current = ghost; + draggedPieceElementRef.current = pieceElement; + + let constraints; + if (boardRef.current) { + const squares = Array.from( + boardRef.current.querySelectorAll("[data-square]") + ) as HTMLElement[]; + + if (squares.length > 0) { + let left = Infinity, + right = -Infinity, + top = Infinity, + bottom = -Infinity; + for (const s of squares) { + const r = s.getBoundingClientRect(); + if (r.left < left) left = r.left; + if (r.right > right) right = r.right; + if (r.top < top) top = r.top; + if (r.bottom > bottom) bottom = r.bottom; + } + left = Math.ceil(left); + right = Math.floor(right); + top = Math.ceil(top); + bottom = Math.floor(bottom); + constraints = { + minDx: left - rect.left, + maxDx: right - rect.right, + minDy: top - rect.top, + maxDy: bottom - rect.bottom, + }; + } + } + + dragStartPosRef.current = { x: e.clientX, y: e.clientY, constraints }; + document.addEventListener("pointermove", handleGlobalPointerMove); + document.addEventListener("pointerup", handleGlobalPointerUp); + } + }, + [ + resetMoveClick, + setClickedSquares, + isPiecePlayable, + game, + pieceSet, + setPlayableSquares, + setCaptureSquares, + abortCustomDrag, + moveClickFrom, + setMoveClickFrom, + handleGlobalPointerMove, + handleGlobalPointerUp, + playMove, + setMoveClickTo, + handleGlobalPointerMoveRightClick, + handleGlobalPointerUpRightClick, + setAnimationDurationToUse, + ] ); const handleSquareLeftClick = useCallback( (square: Square, piece?: string) => { + if (isDraggingRef.current || shouldCancelDragRef.current) return; + + if ( + rightClickDownRef.current || + Date.now() - lastRightClickUpTimeRef.current < 250 || + Date.now() - lastRightClickRef.current < 250 || + Date.now() - dragCancelledRef.current < 250 + ) { + return; + } setClickedSquares([]); + setUserArrows([]); + + if (moveClickFrom === square) { + resetMoveClick(); + return; + } if (!moveClickFrom) { - if (piece && !isPiecePlayable({ piece })) return; + if (!piece) return; + if (!isPiecePlayable({ piece })) return; resetMoveClick(square); return; } const validMoves = game.moves({ square: moveClickFrom, verbose: true }); - const move = validMoves.find((m) => m.to === square); + let move = validMoves.find((m) => m.to === square); + let actualTargetSquare = square; if (!move) { - resetMoveClick(square); + const epMove = validMoves.find( + (m) => m.isEnPassant() && m.to[0] + m.from[1] === square + ); + if (epMove) { + move = epMove; + actualTargetSquare = epMove.to as Square; + } + } + + if (!move) { + resetMoveClick(piece ? square : undefined); return; } - setMoveClickTo(square); + setMoveClickTo(actualTargetSquare); if ( move.piece === "p" && - ((move.color === "w" && square[1] === "8") || - (move.color === "b" && square[1] === "1")) + ((move.color === "w" && actualTargetSquare[1] === "8") || + (move.color === "b" && actualTargetSquare[1] === "1")) ) { + setAnimationDurationToUse(150); setShowPromotionDialog(true); return; } + setAnimationDurationToUse(150); const result = playMove({ from: moveClickFrom, - to: square, + to: actualTargetSquare, }); - resetMoveClick(result ? undefined : square); + resetMoveClick(result ? undefined : piece ? square : undefined); }, [ game, @@ -151,139 +975,241 @@ export default function Board({ playMove, resetMoveClick, setClickedSquares, + setMoveClickTo, + setAnimationDurationToUse, ] ); const handleSquareRightClick = useCallback( (square: Square) => { - setClickedSquares((prev) => - prev.includes(square) - ? prev.filter((s) => s !== square) - : [...prev, square] - ); + if ( + isDraggingRef.current || + shouldCancelDragRef.current || + Date.now() - dragCancelledRef.current < 250 + ) { + shouldCancelDragRef.current = true; + isDraggingRef.current = false; + resetMoveClick(); + return; + } + + const color = isAltPressedRef.current + ? "#70bbd9" + : isCtrlPressedRef.current + ? "#ffaa00" + : "#eb6150"; + setClickedSquares((prev) => { + const actual = prev.filter((s) => s !== undefined) as ClickedSquare[]; + const exists = actual.find((s) => s.square === square); + if (exists) { + if (exists.color === color) { + return actual.filter((s) => s.square !== square); + } else { + return [ + ...actual.filter((s) => s.square !== square), + { square, color }, + ]; + } + } else { + return [...actual, { square, color }]; + } + }); + }, + [resetMoveClick, setClickedSquares] + ); + + const handleBoardPointerUpCapture = useCallback( + (e: React.PointerEvent) => { + if (e.button === 2) { + rightClickDownRef.current = false; + lastRightClickUpTimeRef.current = Date.now(); + setClickedSquares((prev) => [...prev]); + } }, [setClickedSquares] ); const handlePieceDragBegin = useCallback( - (_: string, square: Square) => { - resetMoveClick(square); + (piece: string, square: Square) => { + shouldCancelDragRef.current = false; + isDraggingRef.current = true; + dragOriginSquareRef.current = square; + dragPieceRef.current = piece; + setMoveClickFrom(null); + setMoveClickTo(null); + setShowPromotionDialog(false); + const moves = game.moves({ square, verbose: true }); + setPlayableSquares(moves.map((m) => m.to)); + setCaptureSquares(moves.filter((m) => m.captured).map((m) => m.to)); }, - [resetMoveClick] + [ + game, + setMoveClickFrom, + setMoveClickTo, + setPlayableSquares, + setCaptureSquares, + ] ); const handlePieceDragEnd = useCallback(() => { - resetMoveClick(); - }, [resetMoveClick]); + const wasCancelled = shouldCancelDragRef.current; + isDraggingRef.current = false; + setPlayableSquares([]); + setCaptureSquares([]); + + if (wasCancelled && dragOriginSquareRef.current && dragPieceRef.current) { + animateReturnFlight(dragOriginSquareRef.current, dragPieceRef.current); + } + + dragOriginSquareRef.current = null; + dragPieceRef.current = null; + + if (wasCancelled) { + dragCancelledRef.current = Date.now(); + setTimeout(() => { + shouldCancelDragRef.current = false; + }, 50); + } else { + shouldCancelDragRef.current = false; + } + }, [setPlayableSquares, setCaptureSquares, animateReturnFlight]); + + useLayoutEffect(() => { + if (!isDraggingRef.current && draggedPieceElementRef.current) { + draggedPieceElementRef.current.style.opacity = "1"; + draggedPieceElementRef.current = null; + } + }, [gameFen]); + + useEffect(() => { + const handleContextMenuUndo = (e: MouseEvent) => { + if (Date.now() - lastDropMoveTimeRef.current < 150) { + lastDropMoveTimeRef.current = 0; + setAnimationDurationToUse(0); + undoMove(); + } else if (isDraggingRef.current) { + e.preventDefault(); + abortCustomDrag(); + } + }; + document.addEventListener("contextmenu", handleContextMenuUndo, true); + return () => + document.removeEventListener("contextmenu", handleContextMenuUndo, true); + }, [undoMove, abortCustomDrag, setAnimationDurationToUse]); const onPromotionPieceSelect = useCallback( (piece?: PromotionPieceOption, from?: Square, to?: Square) => { - if (!piece) return false; - const promotionPiece = piece[1]?.toLowerCase() ?? "q"; - - if (moveClickFrom && moveClickTo) { - const result = playMove({ - from: moveClickFrom, - to: moveClickTo, - promotion: promotionPiece, - }); + if (!piece) { resetMoveClick(); - return !!result; + return false; } + const promotionPiece = piece[1]?.toLowerCase() ?? "q"; - if (from && to) { - const result = playMove({ - from, - to, - promotion: promotionPiece, - }); + const currentFrom = moveClickFrom || from; + const currentTo = moveClickTo || to; + + if (!currentFrom || !currentTo) { resetMoveClick(); - return !!result; + return false; } - resetMoveClick(moveClickFrom); + // We don't need the check anymore since we're returning false to stop react-chessboard + playMove({ + from: currentFrom, + to: currentTo, + promotion: promotionPiece, + }); + + resetMoveClick(); + // ALWAYS return false to prevent react-chessboard from subsequently triggering onPieceDrop return false; }, [moveClickFrom, moveClickTo, playMove, resetMoveClick] ); const customArrows: Arrow[] = useMemo(() => { - const bestMove = position?.lastEval?.bestMove; - const moveClassification = position?.eval?.moveClassification; + let arrows = [...userArrows]; + + if (newArrow && newArrow[0] && newArrow[1] && newArrow[0] !== newArrow[1]) { + arrows = arrows.filter( + (a) => !(a[0] === newArrow[0] && a[1] === newArrow[1]) + ); + arrows.push(newArrow); + } if ( - bestMove && + arrowBestMove && showBestMoveArrow && - moveClassification !== MoveClassification.Best && - moveClassification !== MoveClassification.Opening && - moveClassification !== MoveClassification.Forced && - moveClassification !== MoveClassification.Perfect + arrowMoveClassification !== MoveClassification.Best && + arrowMoveClassification !== MoveClassification.Opening && + arrowMoveClassification !== MoveClassification.Forced && + arrowMoveClassification !== MoveClassification.Perfect ) { const bestMoveArrow = [ - bestMove.slice(0, 2), - bestMove.slice(2, 4), + arrowBestMove.slice(0, 2), + arrowBestMove.slice(2, 4), tinycolor(CLASSIFICATION_COLORS[MoveClassification.Best]) .spin(-boardHue) .toHexString(), ] as Arrow; - return [bestMoveArrow]; + if (bestMoveArrow[0] && bestMoveArrow[1]) { + arrows.push(bestMoveArrow); + } } - return []; - }, [position, showBestMoveArrow, boardHue]); - - const SquareRenderer: CustomSquareRenderer = useMemo(() => { - return getSquareRenderer({ - currentPositionAtom: currentPositionAtom, - clickedSquaresAtom, - playableSquaresAtom, - showPlayerMoveIconAtom, - boardSize: boardSize || 400, + const uniqueArrows = new Map(); + arrows.forEach((a) => { + if (a && a[0] && a[1]) { + uniqueArrows.set(`${a[0]}-${a[1]}`, a); + } }); + + return Array.from(uniqueArrows.values()); }, [ - currentPositionAtom, - clickedSquaresAtom, - playableSquaresAtom, - showPlayerMoveIconAtom, - boardSize, + arrowBestMove, + arrowMoveClassification, + showBestMoveArrow, + boardHue, + userArrows, + newArrow, ]); - const customPieces = useMemo( - () => - PIECE_CODES.reduce((acc, piece) => { - acc[piece] = ({ squareWidth }) => ( - - ); - - return acc; - }, {}), - [pieceSet] - ); + const SquareRendererComponent = useMemo(() => getSquareRenderer(), []); const customBoardStyle = useMemo(() => { const commonBoardStyle = { borderRadius: "5px", boxShadow: "0 2px 10px rgba(0, 0, 0, 0.5)", + transform: "translateZ(0)", + backfaceVisibility: "hidden" as const, + WebkitBackfaceVisibility: "hidden" as const, + overflow: "visible", + backgroundColor: "#b58863", }; if (boardHue) { return { ...commonBoardStyle, filter: `hue-rotate(${boardHue}deg)`, + willChange: "filter", }; } return commonBoardStyle; }, [boardHue]); + const handleContextMenu = useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + lastDropMoveTimeRef.current = 0; + if (isDraggingRef.current) { + abortCustomDrag(); + } + }, + [abortCustomDrag] + ); + return ( - + + + handleSquareLeftClick(square, piece) + } + onSquareRightClick={handleSquareRightClick} + onPieceDragBegin={handlePieceDragBegin} + onPieceDragEnd={handlePieceDragEnd} + onPromotionPieceSelect={onPromotionPieceSelect} + showPromotionDialog={showPromotionDialog} + promotionToSquare={moveClickTo} + animationDuration={animationDurationToUse} + customPieces={customPieces} + /> + ; } -export default function PlayerHeader({ color, player, gameAtom }: Props) { +const PlayerHeader = memo(function PlayerHeader({ + color, + player, + gameAtom, +}: Props) { const game = useAtomValue(gameAtom); const gameFen = game.fen(); @@ -96,7 +100,7 @@ export default function PlayerHeader({ color, player, gameAtom }: Props) { )} ); -} +}); const getClock = (comment: string | undefined) => { if (!comment) return undefined; @@ -111,3 +115,5 @@ const getClock = (comment: string | undefined) => { tenths: match[4] ? parseInt(match[4]) : 0, }; }; + +export default PlayerHeader; diff --git a/src/components/board/squareRenderer.tsx b/src/components/board/squareRenderer.tsx index add5599e..ae819c0f 100644 --- a/src/components/board/squareRenderer.tsx +++ b/src/components/board/squareRenderer.tsx @@ -1,74 +1,135 @@ -import { CurrentPosition } from "@/types/eval"; import { MoveClassification } from "@/types/enums"; -import { PrimitiveAtom, atom, useAtomValue } from "jotai"; +import { atom, useAtomValue } from "jotai"; import Image from "next/image"; -import { CSSProperties, forwardRef, useMemo } from "react"; -import { - CustomSquareProps, - Square, -} from "react-chessboard/dist/chessboard/types"; +import { CSSProperties, forwardRef, memo, useMemo, useContext } from "react"; +import { CustomSquareProps } from "react-chessboard/dist/chessboard/types"; import { CLASSIFICATION_COLORS } from "@/constants"; -import { boardHueAtom } from "./states"; - -export interface Props { - currentPositionAtom: PrimitiveAtom; - clickedSquaresAtom: PrimitiveAtom; - playableSquaresAtom: PrimitiveAtom; - showPlayerMoveIconAtom?: PrimitiveAtom; - boardSize: number; -} +import { BoardStateContext } from "./index"; -export function getSquareRenderer({ - currentPositionAtom, - clickedSquaresAtom, - playableSquaresAtom, - showPlayerMoveIconAtom = atom(false), - boardSize, -}: Props) { - const squareRenderer = forwardRef( - (props, ref) => { +export function getSquareRenderer() { + const SquareRendererComponent = memo( + forwardRef((props, ref) => { const { children, square, style } = props; - const showPlayerMoveIcon = useAtomValue(showPlayerMoveIconAtom); - const position = useAtomValue(currentPositionAtom); - const clickedSquares = useAtomValue(clickedSquaresAtom); - const playableSquares = useAtomValue(playableSquaresAtom); - const boardHue = useAtomValue(boardHueAtom); + const { backgroundColor, ...containerStyle } = (style || + {}) as React.CSSProperties; + + const { + boardHue, + currentPositionAtom, + clickedSquaresAtom, + playableSquaresAtom, + captureSquaresAtom, + showPlayerMoveIconAtom, + moveClickFromAtom, + boardSize, + } = useContext(BoardStateContext); + + // Derived stable subscriptions to prevent O(N) re-render cycles + const isPlayable = useAtomValue( + useMemo( + () => atom((get) => get(playableSquaresAtom).includes(square)), + [playableSquaresAtom, square] + ) + ); - const fromSquare = position.lastMove?.from; - const toSquare = position.lastMove?.to; - const moveClassification = position?.eval?.moveClassification; + const isCapture = useAtomValue( + useMemo( + () => atom((get) => get(captureSquaresAtom).includes(square)), + [captureSquaresAtom, square] + ) + ); + + const clickedSquare = useAtomValue( + useMemo( + () => + atom((get) => + get(clickedSquaresAtom).find((s) => s.square === square) + ), + [clickedSquaresAtom, square] + ) + ); + + const isMoveClickFrom = useAtomValue( + useMemo( + () => atom((get) => get(moveClickFromAtom) === square), + [moveClickFromAtom, square] + ) + ); + + const classification = useAtomValue( + useMemo( + () => + atom((get) => { + const pos = get(currentPositionAtom); + const isLastMove = + pos.lastMove?.from === square || pos.lastMove?.to === square; + return isLastMove ? pos.eval?.moveClassification || null : null; + }), + [currentPositionAtom, square] + ) + ); + + const isLastMoveTo = useAtomValue( + useMemo( + () => atom((get) => get(currentPositionAtom).lastMove?.to === square), + [currentPositionAtom, square] + ) + ); + + const showPlayerMoveIcon = useAtomValue(showPlayerMoveIconAtom); const highlightSquareStyle: CSSProperties | undefined = useMemo( () => - clickedSquares.includes(square) - ? rightClickSquareStyle - : fromSquare === square || toSquare === square - ? previousMoveSquareStyle(moveClassification) - : undefined, - [clickedSquares, square, fromSquare, toSquare, moveClassification] + isMoveClickFrom + ? activeSquareStyle + : clickedSquare + ? rightClickSquareStyle(clickedSquare.color) + : classification !== null + ? previousMoveSquareStyle(classification) + : undefined, + [clickedSquare, isMoveClickFrom, classification] ); const playableSquareStyle: CSSProperties | undefined = useMemo( () => - playableSquares.includes(square) ? playableSquareStyles : undefined, - [playableSquares, square] + isPlayable + ? isCapture + ? captureRingStyle + : playableSquareStyles + : undefined, + [isPlayable, isCapture] ); + const showIcon = classification && showPlayerMoveIcon && isLastMoveTo; + return (
- {children} + {backgroundColor && ( +
+ )} {highlightSquareStyle &&
} {playableSquareStyle &&
} - {moveClassification && showPlayerMoveIcon && square === toSquare && ( + {children} + {showIcon && ( move-icon )}
); + }), + (prev, next) => { + // aggressive layout-shift prevention cache + if (prev.square !== next.square) return false; + if (prev.children !== next.children) return false; + return true; } ); - squareRenderer.displayName = "SquareRenderer"; + SquareRendererComponent.displayName = "SquareRenderer"; - return squareRenderer; + return SquareRendererComponent; } -const rightClickSquareStyle: CSSProperties = { +const rightClickSquareStyle = (color?: string): CSSProperties => ({ position: "absolute", width: "100%", height: "100%", - backgroundColor: "#eb6150", + backgroundColor: color || "#eb6150", opacity: "0.8", + transform: "translateZ(1px)", + pointerEvents: "none", +}); + +const activeSquareStyle: CSSProperties = { + position: "absolute", + width: "100%", + height: "100%", + backgroundColor: "#fad541", + opacity: 0.5, + transform: "translateZ(1px)", + pointerEvents: "none", }; const playableSquareStyles: CSSProperties = { @@ -107,16 +190,33 @@ const playableSquareStyles: CSSProperties = { backgroundClip: "content-box", borderRadius: "50%", boxSizing: "border-box", + transform: "translateZ(1px)", + pointerEvents: "none", +}; + +const captureRingStyle: CSSProperties = { + position: "absolute", + width: "100%", + height: "100%", + borderRadius: "50%", + boxSizing: "border-box", + background: + "radial-gradient(transparent 60%, rgba(0,0,0,.14) 60%, rgba(0,0,0,.14) 100%)", + transform: "translateZ(1px)", + pointerEvents: "none", }; const previousMoveSquareStyle = ( - moveClassification?: MoveClassification + moveClassification?: MoveClassification | null ): CSSProperties => ({ position: "absolute", width: "100%", height: "100%", - backgroundColor: moveClassification - ? CLASSIFICATION_COLORS[moveClassification] - : "#fad541", + backgroundColor: + moveClassification && moveClassification !== MoveClassification.Opening + ? CLASSIFICATION_COLORS[moveClassification] + : "#fad541", opacity: 0.5, + transform: "translateZ(1px)", + pointerEvents: "none", }); diff --git a/src/components/board/types.ts b/src/components/board/types.ts new file mode 100644 index 00000000..b88b87bd --- /dev/null +++ b/src/components/board/types.ts @@ -0,0 +1,12 @@ +import { Square } from "react-chessboard/dist/chessboard/types"; + +export interface CapturedSquare { + square: Square; + piece: string; + timestamp: number; +} + +export interface ClickedSquare { + square: Square; + color: string; +} diff --git a/src/components/prettyMoveSan/index.tsx b/src/components/prettyMoveSan/index.tsx index 954f6966..7e85a7c2 100644 --- a/src/components/prettyMoveSan/index.tsx +++ b/src/components/prettyMoveSan/index.tsx @@ -5,13 +5,8 @@ import { TypographyProps, useTheme, } from "@mui/material"; -import localFont from "next/font/local"; import { useMemo } from "react"; -const chessFont = localFont({ - src: "./chess_merida_unicode.ttf", -}); - interface Props { san: string; color: "w" | "b"; @@ -47,7 +42,7 @@ export default function PrettyMoveSan({ {icon && ( {icon} diff --git a/src/hooks/useChessActions.ts b/src/hooks/useChessActions.ts index cb931c3d..1af8845a 100644 --- a/src/hooks/useChessActions.ts +++ b/src/hooks/useChessActions.ts @@ -35,20 +35,17 @@ export const useChessActions = (chessAtom: PrimitiveAtom) => { const copyGame = useCallback(() => { const newGame = new Chess(); - - if (game.history().length === 0) { - const pgnSplitted = game.pgn().split("]"); - if ( - ["1-0", "0-1", "1/2-1/2", "*"].includes( - pgnSplitted.at(-1)?.trim() ?? "" - ) - ) { - newGame.loadPgn(pgnSplitted.slice(0, -1).join("]") + "]"); - return newGame; + try { + newGame.loadPgn(game.pgn()); + } catch { + // Fallback for custom-FEN or edge-case PGN formats + newGame.load(game.getHeaders().FEN || DEFAULT_POSITION, { + preserveHeaders: true, + }); + for (const move of game.history()) { + newGame.move(move); } } - - newGame.loadPgn(game.pgn()); return newGame; }, [game]); @@ -92,12 +89,17 @@ export const useChessActions = (chessAtom: PrimitiveAtom) => { (moves: string[]) => { const newGame = copyGame(); - let lastMove: Move | null = null; - for (const move of moves) { - lastMove = newGame.move(move); + try { + let lastMove: Move | null = null; + for (const move of moves) { + lastMove = newGame.move(move); + } + setGame(newGame); + if (lastMove) playSoundFromMove(lastMove); + } catch (e) { + console.warn("Invalid move sequence", e); + playIllegalMoveSound(); } - setGame(newGame); - if (lastMove) playSoundFromMove(lastMove); }, [copyGame, setGame] ); diff --git a/src/hooks/useEngine.ts b/src/hooks/useEngine.ts index e51a6f7a..96109a89 100644 --- a/src/hooks/useEngine.ts +++ b/src/hooks/useEngine.ts @@ -11,6 +11,8 @@ export const useEngine = (engineName: EngineName | undefined) => { const [engine, setEngine] = useState(null); useEffect(() => { + let isMounted = true; + if (!engineName) return; if (engineName !== EngineName.Stockfish11 && !isWasmSupported()) { @@ -18,11 +20,22 @@ export const useEngine = (engineName: EngineName | undefined) => { } pickEngine(engineName).then((newEngine) => { + if (!isMounted) { + // If React unmounted this hook while the worker was booting, + // instantly kill the orphaned worker to prevent invisible memory/CPU leaks + newEngine.shutdown(); + return; + } + setEngine((prev) => { prev?.shutdown(); return newEngine; }); }); + + return () => { + isMounted = false; + }; }, [engineName]); return engine; diff --git a/src/lib/chess.ts b/src/lib/chess.ts index af3f0f5f..fadc52b1 100644 --- a/src/lib/chess.ts +++ b/src/lib/chess.ts @@ -251,36 +251,42 @@ export const getIsPieceSacrifice = ( }; export const getMaterialDifference = (fen: string): number => { - const game = new Chess(fen); - const board = game.board().flat(); - - return board.reduce((acc, square) => { - if (!square) return acc; - const piece = square.type; - - if (square.color === "w") { - return acc + getPieceValue(piece); + const placement = fen.split(" ")[0]; + let diff = 0; + + for (let i = 0; i < placement.length; i++) { + const c = placement[i]; + switch (c) { + case "P": + diff += 1; + break; + case "N": + case "B": + diff += 3; + break; + case "R": + diff += 5; + break; + case "Q": + diff += 9; + break; + case "p": + diff -= 1; + break; + case "n": + case "b": + diff -= 3; + break; + case "r": + diff -= 5; + break; + case "q": + diff -= 9; + break; } - - return acc - getPieceValue(piece); - }, 0); -}; - -const getPieceValue = (piece: PieceSymbol): number => { - switch (piece) { - case "p": - return 1; - case "n": - return 3; - case "b": - return 3; - case "r": - return 5; - case "q": - return 9; - default: - return 0; } + + return diff; }; export const isCheck = (fen: string): boolean => { diff --git a/src/lib/engine/uciEngine.ts b/src/lib/engine/uciEngine.ts index ef0a2599..e2fc8e09 100644 --- a/src/lib/engine/uciEngine.ts +++ b/src/lib/engine/uciEngine.ts @@ -144,7 +144,20 @@ export class UciEngine { } public async stopAllCurrentJobs(): Promise { + // Prevent unhandled promise rejections by cleanly resolving abandoned queue jobs + const abandonedJobs = [...this.workerQueue]; this.workerQueue = []; + + for (const job of abandonedJobs) { + job.resolve([]); // Yield empty string array to fulfill the pending Promise safely + } + + // Unbind any active React callback listeners immediately so in-flight engine updates + // do not trigger "Maximum update depth exceeded" during the stop pipeline. + for (const worker of this.workers) { + worker.listen = () => null; + } + await this.sendCommandsToEachWorker(["stop", "isready"], "readyok"); for (const worker of this.workers) { @@ -369,14 +382,20 @@ export class UciEngine { await this.stopAllCurrentJobs(); await this.setMultiPv(multiPv); + let lastUpdate = 0; + const THROTTLE_MS = 60; // Limit UI updates to ~16 updates per second for maximum fluidity and zero lag + const onNewMessage = (messages: string[]) => { if (!setPartialEval) return; + + const now = performance.now(); + if (now - lastUpdate < THROTTLE_MS) return; + lastUpdate = now; + const parsedResults = parseEvaluationResults(messages, fen); setPartialEval(parsedResults); }; - console.log(`Evaluating position: ${fen}`); - const lichessEval = await lichessEvalPromise; if ( lichessEval.lines.length >= multiPv && @@ -405,8 +424,6 @@ export class UciEngine { await this.stopAllCurrentJobs(); await this.setElo(elo); - console.log(`Evaluating position: ${fen}`); - const results = await this.sendCommands( [`position fen ${fen}`, `go depth ${depth}`], "bestmove" diff --git a/src/lib/engine/worker.ts b/src/lib/engine/worker.ts index 224e0d9c..24ee4247 100644 --- a/src/lib/engine/worker.ts +++ b/src/lib/engine/worker.ts @@ -2,8 +2,6 @@ import { EngineWorker } from "@/types/engine"; import { isIosDevice, isMobileDevice } from "./shared"; export const getEngineWorker = (enginePath: string): EngineWorker => { - console.log(`Creating worker from ${enginePath}`); - const worker = new window.Worker(enginePath); const engineWorker: EngineWorker = { @@ -34,6 +32,7 @@ export const sendCommandsToWorker = ( onNewMessage?.(messages); if (data.startsWith(finalMessage)) { + worker.listen = () => null; // Cleanup memory and prevent dangling state updates resolve(messages); } }; diff --git a/src/lib/lichess.ts b/src/lib/lichess.ts index 5215e3ef..d13f4af2 100644 --- a/src/lib/lichess.ts +++ b/src/lib/lichess.ts @@ -85,12 +85,17 @@ const fetchLichessEval = async ( try { const res = await fetch( `https://lichess.org/api/cloud-eval?fen=${fen}&multiPv=${multiPv}`, - { method: "GET", signal: AbortSignal.timeout(200) } + { method: "GET", signal: AbortSignal.timeout(1200) } // Increased buffer for international latency ); return res.json(); } catch (error) { - console.error(error); + // We intentionally silence TimeoutError and DOMException logs here. + // Failing over to the local WebAssembly engine is a routine, intended behavior + // and does not need to flood the user's console visually. + if (!(error instanceof Error && error.name === "TimeoutError")) { + console.error("Lichess fetch error:", error); + } return { error: LichessError.NotFound }; } diff --git a/src/lib/sounds.ts b/src/lib/sounds.ts index 87158aff..0339ff57 100644 --- a/src/lib/sounds.ts +++ b/src/lib/sounds.ts @@ -1,48 +1,44 @@ import { Move } from "chess.js"; let audioContext: AudioContext | null = null; -let timeout: NodeJS.Timeout | null = null; const soundsCache = new Map(); -type Sound = "move" | "capture" | "illegalMove"; +type Sound = "move" | "capture" | "illegalMove" | "check"; const soundUrls: Record = { move: "/sounds/move.mp3", capture: "/sounds/capture.mp3", illegalMove: "/sounds/error.mp3", + check: "/sounds/check.mp3", }; export const play = async (sound: Sound) => { - if (timeout) clearTimeout(timeout); + if (!audioContext) audioContext = new AudioContext(); + if (audioContext.state === "suspended") await audioContext.resume(); - timeout = setTimeout(async () => { - if (!audioContext) audioContext = new AudioContext(); - if (audioContext.state === "suspended") await audioContext.resume(); + let audioBuffer = soundsCache.get(soundUrls[sound]); + if (!audioBuffer) { + const res = await fetch(soundUrls[sound]); + const buffer = await audioContext.decodeAudioData(await res.arrayBuffer()); + audioBuffer = buffer; + soundsCache.set(soundUrls[sound], buffer); + } - let audioBuffer = soundsCache.get(soundUrls[sound]); - if (!audioBuffer) { - const res = await fetch(soundUrls[sound]); - const buffer = await audioContext.decodeAudioData( - await res.arrayBuffer() - ); - audioBuffer = buffer; - soundsCache.set(soundUrls[sound], buffer); - } - - const audioSrc = audioContext.createBufferSource(); - audioSrc.buffer = audioBuffer; - const volume = audioContext.createGain(); - volume.gain.value = 0.3; - audioSrc.connect(volume); - volume.connect(audioContext.destination); - audioSrc.start(); - }, 1); + const audioSrc = audioContext.createBufferSource(); + audioSrc.buffer = audioBuffer; + const volume = audioContext.createGain(); + volume.gain.value = 0.3; + audioSrc.connect(volume); + volume.connect(audioContext.destination); + audioSrc.start(); }; export const playCaptureSound = () => play("capture"); export const playIllegalMoveSound = () => play("illegalMove"); export const playMoveSound = () => play("move"); +export const playCheckSound = () => play("check"); export const playSoundFromMove = (move: Move | null) => { if (!move) return playIllegalMoveSound(); + if (move.san.includes("+") || move.san.includes("#")) return playCheckSound(); if (move.captured) return playCaptureSound(); return playMoveSound(); }; diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index f863fb7d..7efc41dc 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -2,6 +2,7 @@ import "@fontsource/roboto/300.css"; import "@fontsource/roboto/400.css"; import "@fontsource/roboto/500.css"; import "@fontsource/roboto/700.css"; +import "@/components/board/board.css"; import { AppProps } from "next/app"; import Layout from "@/sections/layout"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; diff --git a/src/pages/_document.tsx b/src/pages/_document.tsx index 38a7e1d2..7b8e10c2 100644 --- a/src/pages/_document.tsx +++ b/src/pages/_document.tsx @@ -1,5 +1,7 @@ import { Head, Html, Main, NextScript } from "next/document"; +const DEFAULT_PIECE_SET = "maestro"; + export default function Document() { return ( @@ -22,6 +24,28 @@ export default function Document() { sizes="16x16" href="/favicon-16x16.png" /> + + + + + + ); } diff --git a/src/sections/analysis/hooks/useCurrentPosition.ts b/src/sections/analysis/hooks/useCurrentPosition.ts index 257be3f8..f22af367 100644 --- a/src/sections/analysis/hooks/useCurrentPosition.ts +++ b/src/sections/analysis/hooks/useCurrentPosition.ts @@ -27,6 +27,7 @@ export const useCurrentPosition = (engine: UciEngine | null) => { useEffect(() => { const boardHistory = board.history({ verbose: true }); const position: CurrentPosition = { + fen: board.fen(), lastMove: boardHistory.at(-1), }; diff --git a/src/sections/analysis/panelBody/analysisTab/engineLines/index.tsx b/src/sections/analysis/panelBody/analysisTab/engineLines/index.tsx index f554afbf..5307ec9a 100644 --- a/src/sections/analysis/panelBody/analysisTab/engineLines/index.tsx +++ b/src/sections/analysis/panelBody/analysisTab/engineLines/index.tsx @@ -4,22 +4,64 @@ import { boardAtom, currentPositionAtom, engineMultiPvAtom, + boardAnimationDurationAtom, } from "../../../states"; -import { useAtomValue } from "jotai"; +import { useAtomValue, useSetAtom } from "jotai"; import { LineEval } from "@/types/eval"; +import { useEffect, useRef } from "react"; +import { useChessActions } from "@/hooks/useChessActions"; export default function EngineLines(props: GridProps) { const board = useAtomValue(boardAtom); const linesNumber = useAtomValue(engineMultiPvAtom); const position = useAtomValue(currentPositionAtom); + const { addMoves } = useChessActions(boardAtom); + const setAnimationDuration = useSetAtom(boardAnimationDurationAtom); const linesSkeleton: LineEval[] = Array.from({ length: linesNumber }).map( (_, i) => ({ pv: [`${i}`], depth: 0, multiPv: i + 1 }) ); - const engineLines = position?.eval?.lines?.length - ? position.eval.lines - : linesSkeleton; + const isStale = position?.fen !== board.fen(); + + const engineLines = + position?.eval?.lines?.length && !isStale + ? position.eval.lines + : linesSkeleton; + + const positionRef = useRef(position); + const boardRef = useRef(board); + const addMovesRef = useRef(addMoves); + + useEffect(() => { + positionRef.current = position; + boardRef.current = board; + addMovesRef.current = addMoves; + }, [position, board, addMoves]); + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + // Allow spacebar only if target is not an input or textarea + const target = e.target as HTMLElement; + const isInput = + target.tagName === "INPUT" || + target.tagName === "TEXTAREA" || + target.isContentEditable; + + if (e.code === "Space" && !isInput) { + e.preventDefault(); + if (boardRef.current?.isCheckmate()) return; + const bestLine = positionRef.current?.eval?.lines?.[0]; + if (bestLine && bestLine.pv && bestLine.pv.length > 0) { + setAnimationDuration(150); + addMovesRef.current([bestLine.pv[0]]); + } + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [setAnimationDuration]); if (board.isCheckmate()) return null; diff --git a/src/sections/analysis/panelBody/analysisTab/engineLines/lineEvaluation.tsx b/src/sections/analysis/panelBody/analysisTab/engineLines/lineEvaluation.tsx index e503f23a..737e3ae3 100644 --- a/src/sections/analysis/panelBody/analysisTab/engineLines/lineEvaluation.tsx +++ b/src/sections/analysis/panelBody/analysisTab/engineLines/lineEvaluation.tsx @@ -1,7 +1,7 @@ import { LineEval } from "@/types/eval"; import { ListItem, Skeleton, Typography } from "@mui/material"; -import { useAtomValue } from "jotai"; -import { boardAtom } from "../../../states"; +import { useAtomValue, useSetAtom } from "jotai"; +import { boardAtom, boardAnimationDurationAtom } from "../../../states"; import { getLineEvalLabel, moveLineUciToSan } from "@/lib/chess"; import { useChessActions } from "@/hooks/useChessActions"; import PrettyMoveSan from "@/components/prettyMoveSan"; @@ -13,6 +13,7 @@ interface Props { export default function LineEvaluation({ line }: Props) { const board = useAtomValue(boardAtom); const { addMoves } = useChessActions(boardAtom); + const setAnimationDuration = useSetAtom(boardAnimationDurationAtom); const lineLabel = getLineEvalLabel(line); const isBlackCp = @@ -78,6 +79,7 @@ export default function LineEvaluation({ line }: Props) { additionalText={i < line.pv.length - 1 ? "," : ""} boxProps={{ onClick: () => { + setAnimationDuration(150); addMoves(line.pv.slice(0, i + 1)); }, sx: { diff --git a/src/sections/analysis/panelBody/analysisTab/opening.tsx b/src/sections/analysis/panelBody/analysisTab/opening.tsx index 1cad63d6..c81e107b 100644 --- a/src/sections/analysis/panelBody/analysisTab/opening.tsx +++ b/src/sections/analysis/panelBody/analysisTab/opening.tsx @@ -1,14 +1,16 @@ import { useAtomValue } from "jotai"; import { Grid2 as Grid, Skeleton, Typography } from "@mui/material"; -import { currentPositionAtom } from "../../states"; +import { boardAtom, currentPositionAtom } from "../../states"; export default function Opening() { const position = useAtomValue(currentPositionAtom); + const board = useAtomValue(boardAtom); const lastMove = position?.lastMove; if (!lastMove) return null; + const isStale = position?.fen !== board.fen(); - const opening = position?.eval?.opening || position.opening; + const opening = (!isStale && position?.eval?.opening) || position.opening; if (!opening) { return ( diff --git a/src/sections/analysis/states.ts b/src/sections/analysis/states.ts index a294bdf6..034e8509 100644 --- a/src/sections/analysis/states.ts +++ b/src/sections/analysis/states.ts @@ -25,3 +25,5 @@ export const engineWorkersNbAtom = atomWithStorage( export const evaluationProgressAtom = atom(0); export const savedEvalsAtom = atom({}); + +export const boardAnimationDurationAtom = atom(150); diff --git a/src/types/eval.ts b/src/types/eval.ts index 467aeb9a..f60d2067 100644 --- a/src/types/eval.ts +++ b/src/types/eval.ts @@ -48,6 +48,7 @@ export interface EvaluatePositionWithUpdateParams { } export interface CurrentPosition { + fen?: string; lastMove?: Move; eval?: PositionEval; lastEval?: PositionEval;