diff --git a/src/components/LinearProgressBar.tsx b/src/components/LinearProgressBar.tsx index 211ffa82..2f599c53 100644 --- a/src/components/LinearProgressBar.tsx +++ b/src/components/LinearProgressBar.tsx @@ -7,11 +7,17 @@ import { linearProgressClasses, } from "@mui/material"; -const LinearProgressBar = ( - props: LinearProgressProps & { value: number; label: string } -) => { - if (props.value === 0) return null; +/** + * A styled linear progress bar with optional label and percentage display. + */ +interface LinearProgressBarProps extends LinearProgressProps { + value: number; + label: string; +} +function LinearProgressBar({ value, label, ...rest }: LinearProgressBarProps) { + // Allow rendering if label === "" (OpeningProgress case), otherwise hide if value === 0 + if (value === 0 && label !== "") return null; return ( - - {props.label} + + {label} ({ borderRadius: "5px", height: "5px", @@ -45,11 +55,11 @@ const LinearProgressBar = ( {`${Math.round( - props.value + value )}%`} ); -}; +} export default LinearProgressBar; diff --git a/src/components/OpeningControls.tsx b/src/components/OpeningControls.tsx new file mode 100644 index 00000000..bc465a98 --- /dev/null +++ b/src/components/OpeningControls.tsx @@ -0,0 +1,50 @@ +import { Button, Stack } from "@mui/material"; +import { memo } from "react"; + +/** + * Control buttons for skipping or resetting the opening variation. + */ +export interface OpeningControlsProps { + moveIdx: number; + selectedVariationMovesLength: number; + allDone: boolean; + onSkip: () => void; + onReset: () => void; + disabled?: boolean; +} + +function OpeningControls({ + moveIdx, + selectedVariationMovesLength, + allDone, + onSkip, + onReset, + disabled = false, +}: OpeningControlsProps) { + return ( + + + + + ); +} + +export default memo(OpeningControls); diff --git a/src/components/OpeningProgress.tsx b/src/components/OpeningProgress.tsx new file mode 100644 index 00000000..b732e672 --- /dev/null +++ b/src/components/OpeningProgress.tsx @@ -0,0 +1,60 @@ +import { useEffect, useState, memo } from "react"; +import LinearProgressBar from "./LinearProgressBar"; +import { Box } from "@mui/material"; +import { useTheme } from "@mui/material/styles"; + +// Props: +// - total: total number of variations +// - currentVariationIndex: index of the current variation (optional, for display) + +/** + * Progress bar for opening training, showing completed variations out of total. + */ +export interface OpeningProgressProps { + total: number; + // List of completed variation indexes + completed: number[]; +} + +function OpeningProgress({ total, completed }: OpeningProgressProps) { + const [progress, setProgress] = useState(completed); + const theme = useTheme(); + + useEffect(() => { + setProgress(completed); + }, [completed]); + + // Calculate percentage + const percent = total > 0 ? (progress.length / total) * 100 : 0; + const label = `${progress.length} / ${total}`; + + return ( + + + + {label} + + + + + + + ); +} + +export default memo(OpeningProgress); diff --git a/src/components/VariationHeader.tsx b/src/components/VariationHeader.tsx new file mode 100644 index 00000000..73446d11 --- /dev/null +++ b/src/components/VariationHeader.tsx @@ -0,0 +1,70 @@ +import { Typography, Stack, Button } from "@mui/material"; +import { memo } from "react"; + +/** + * Header for the opening variation panel. + */ +export interface VariationHeaderProps { + variationName?: string; + trainingMode: boolean; + onSetTrainingMode: (training: boolean) => void; + variationComplete: boolean; +} + +const VariationHeader: React.FC = ({ + variationName, + trainingMode, + onSetTrainingMode, + variationComplete, +}) => ( + <> + + {variationName} + + + + + + + {variationComplete ? ( + + Variation complete! Next variation loading… + + ) : trainingMode ? ( + + Play the correct move to continue. + + ) : ( + + Play the move indicated by the arrow. + + )} + +); + +export default memo(VariationHeader); diff --git a/src/components/board/index.tsx b/src/components/board/index.tsx index d02404b9..48ea7f8f 100644 --- a/src/components/board/index.tsx +++ b/src/components/board/index.tsx @@ -22,6 +22,12 @@ import PlayerHeader from "./playerHeader"; import { boardHueAtom, pieceSetAtom } from "./states"; import tinycolor from "tinycolor2"; +export interface TrainingFeedback { + square: string; // ex: 'e4' + icon: string; // chemin de l'icône + alt: string; // texte alternatif +} + export interface Props { id: string; canPlay?: Color | boolean; @@ -34,6 +40,9 @@ export interface Props { showBestMoveArrow?: boolean; showPlayerMoveIconAtom?: PrimitiveAtom; showEvaluationBar?: boolean; + trainingFeedback?: TrainingFeedback; + bestMoveUci?: string; + hidePlayerHeaders?: boolean; } export default function Board({ @@ -48,6 +57,9 @@ export default function Board({ showBestMoveArrow = false, showPlayerMoveIconAtom, showEvaluationBar = false, + trainingFeedback, + bestMoveUci, + hidePlayerHeaders = false, }: Props) { const boardRef = useRef(null); const game = useAtomValue(gameAtom); @@ -208,9 +220,21 @@ export default function Board({ ); const customArrows: Arrow[] = useMemo(() => { + if (bestMoveUci && showBestMoveArrow) { + // Priorité à la flèche d'ouverture + return [ + [ + bestMoveUci.slice(0, 2), + bestMoveUci.slice(2, 4), + tinycolor(CLASSIFICATION_COLORS[MoveClassification.Best]) + .spin(-boardHue) + .toHexString(), + ] as Arrow, + ]; + } + // Fallback moteur const bestMove = position?.lastEval?.bestMove; const moveClassification = position?.eval?.moveClassification; - if ( bestMove && showBestMoveArrow && @@ -226,12 +250,10 @@ export default function Board({ .spin(-boardHue) .toHexString(), ] as Arrow; - return [bestMoveArrow]; } - return []; - }, [position, showBestMoveArrow, boardHue]); + }, [bestMoveUci, position, showBestMoveArrow, boardHue]); const SquareRenderer: CustomSquareRenderer = useMemo(() => { return getSquareRenderer({ @@ -239,12 +261,14 @@ export default function Board({ clickedSquaresAtom, playableSquaresAtom, showPlayerMoveIconAtom, + trainingFeedback, // nouvelle prop transmise }); }, [ currentPositionAtom, clickedSquaresAtom, playableSquaresAtom, showPlayerMoveIconAtom, + trainingFeedback, ]); const customPieces = useMemo( @@ -306,11 +330,16 @@ export default function Board({ paddingLeft={showEvaluationBar ? 2 : 0} size="grow" > - + {/* Enlève l'affichage des PlayerHeader si hidePlayerHeaders est true */} + {!hidePlayerHeaders && ( + + )} - + {!hidePlayerHeaders && ( + + )} ); diff --git a/src/components/board/squareRenderer.tsx b/src/components/board/squareRenderer.tsx index 67cb7673..02cae926 100644 --- a/src/components/board/squareRenderer.tsx +++ b/src/components/board/squareRenderer.tsx @@ -15,6 +15,11 @@ export interface Props { clickedSquaresAtom: PrimitiveAtom; playableSquaresAtom: PrimitiveAtom; showPlayerMoveIconAtom?: PrimitiveAtom; + trainingFeedback?: { + square: string; + icon: string; + alt: string; + }; } export function getSquareRenderer({ @@ -22,6 +27,7 @@ export function getSquareRenderer({ clickedSquaresAtom, playableSquaresAtom, showPlayerMoveIconAtom = atom(false), + trainingFeedback, }: Props) { const squareRenderer = forwardRef( (props, ref) => { @@ -64,6 +70,25 @@ export function getSquareRenderer({ {children} {highlightSquareStyle &&
} {playableSquareStyle &&
} + {/* Affichage de l’icône de feedback training si demandé et sur la bonne case */} + {trainingFeedback && trainingFeedback.square === square && ( + {trainingFeedback.alt} + )} + {/* Aucun affichage de message texte d'erreur ici, seulement l'icône */} {moveClassification && showPlayerMoveIcon && square === toSquare && ( void; + setLastMistakeVisible: (mistake: Mistake | null) => void; + undoMove: () => void; +} + +/** + * Custom hook that checks if the user's move matches the expected move in the opening sequence, + * and handles mistakes (visual feedback + undo). + */ +export function useMistakeHandler({ + selectedVariation, + game, + moveIdx, + isUserTurn, + setMoveIdx, + setLastMistakeVisible, + undoMove, +}: UseMistakeHandlerParams) { + return useCallback(() => { + if (!selectedVariation || !game) return; + if (moveIdx >= selectedVariation.moves.length) return; + if (!isUserTurn) return; + const history = game.history({ verbose: true }); + if (history.length !== moveIdx + 1) return; + const last = history[history.length - 1]; + const expectedMove = new Chess(); + for (let i = 0; i < moveIdx; i++) { + expectedMove.move(selectedVariation.moves[i]); + } + const expected = expectedMove.move(selectedVariation.moves[moveIdx]); + if (!expected || last.from !== expected.from || last.to !== expected.to) { + const mistakeType = + last.captured || last.san.includes("#") ? "Blunder" : "Mistake"; + setTimeout(() => { + setLastMistakeVisible({ + from: last.from, + to: last.to, + type: mistakeType, + }); + setTimeout(() => { + undoMove(); + setLastMistakeVisible(null); + }, 1300); + }, 200); + } else { + setMoveIdx(moveIdx + 1); + } + }, [ + selectedVariation, + game, + moveIdx, + isUserTurn, + setMoveIdx, + setLastMistakeVisible, + undoMove, + ]); +} diff --git a/src/pages/choose-opening.tsx b/src/pages/choose-opening.tsx new file mode 100644 index 00000000..e5564330 --- /dev/null +++ b/src/pages/choose-opening.tsx @@ -0,0 +1,119 @@ +import React from "react"; +import { useRouter } from "next/router"; +import { PageTitle } from "@/components/pageTitle"; +import { Box, Typography, Paper, Stack } from "@mui/material"; + +// List of available openings (evolutive) +interface Opening { + key: string; + name: string; + description?: string; + available: boolean; +} + +const openings: Opening[] = [ + { + key: "italian", + name: "Italian Game", + description: + "A classic and popular opening for beginners and intermediate players.", + available: true, + }, + { + key: "caro-kann", + name: "Caro-Kann", + description: "Coming soon ! A solid defense for strategic players.", + available: false, + }, + { + key: "england-gambit", + name: "England Gambit", + description: + "Coming soon ! An aggressive gambit to surprise your opponent.", + available: false, + }, +]; + +export default function ChooseOpeningPage() { + const router = useRouter(); + + const handleChoose = (openingKey: string) => { + router.push(`/opening-trainer?opening=${openingKey}`); + }; + + return ( + <> + + + + Choose an opening + + + {openings.map((opening) => ( + + theme.palette.mode === "dark" ? "#232323" : "#fafbfc", + transition: + "box-shadow 0.2s, border-color 0.2s, background 0.2s", + cursor: opening.available ? "pointer" : "not-allowed", + opacity: opening.available ? 1 : 0.6, + "&:hover": opening.available + ? { + boxShadow: 6, + borderColor: "primary.main", + background: (theme) => + theme.palette.mode === "dark" ? "#232323" : "#f0f7fa", + } + : {}, + }} + onClick={ + opening.available ? () => handleChoose(opening.key) : undefined + } + > + + {opening.name} + + {opening.description && ( + + {opening.description} + + )} + + ))} + + + + ); +} diff --git a/src/pages/opening-trainer.tsx b/src/pages/opening-trainer.tsx new file mode 100644 index 00000000..4a2a6ba9 --- /dev/null +++ b/src/pages/opening-trainer.tsx @@ -0,0 +1,447 @@ +import { italianGameVariations } from "../data/openings_to_learn/italian"; +import { Box, Grid2 as Grid } from "@mui/material"; +import { useState, useMemo, useEffect, useCallback } from "react"; +import Board from "../components/board"; +import { Chess } from "chess.js"; +import { atom, useAtom } from "jotai"; +import { useChessActions } from "../hooks/useChessActions"; +import { Color, EngineName } from "../types/enums"; +import { CurrentPosition } from "../types/eval"; +import OpeningProgress from "../components/OpeningProgress"; +import { useScreenSize } from "../hooks/useScreenSize"; +import { useEngine } from "../hooks/useEngine"; +import OpeningControls from "../components/OpeningControls"; +import VariationHeader from "../components/VariationHeader"; +import { useMistakeHandler } from "../hooks/useMistakeHandler"; + +// Returns the learning color for the variation (default is white, but can be extended) +function getLearningColor(): Color { + // Always returns white for now + return Color.White; +} + +interface Mistake { + from: string; + to: string; + type: string; +} + +export default function OpeningPage() { + const [currentVariantIdx, setCurrentVariantIdx] = useState(0); + const [moveIdx, setMoveIdx] = useState(0); + const [trainingMode, setTrainingMode] = useState(false); + const [lastMistakeVisible, setLastMistakeVisible] = useState( + null + ); + // Atom Jotai for game state + const [gameAtomInstance] = useState(() => atom(new Chess())); + const [game, setGame] = useAtom(gameAtomInstance); + const { undoMove } = useChessActions(gameAtomInstance); + + // List of variations to learn (all) + const variations = italianGameVariations; + const selectedVariation = variations[currentVariantIdx] || null; + + // Learning color (fixed for the variation) + const learningColor = useMemo(() => { + if (!selectedVariation) return Color.White; + return getLearningColor(); // No argument needed + }, [selectedVariation]); + + // Indicates if it's the user's turn to play + const isUserTurn = useMemo(() => { + if (!selectedVariation) return false; + // moveIdx % 2 === 0 => white, 1 => black (if the sequence starts with white) + const colorToPlay = moveIdx % 2 === 0 ? Color.White : Color.Black; + return colorToPlay === learningColor; + }, [moveIdx, learningColor, selectedVariation]); + + // Generate the expected move in UCI format for the arrow (only if it's the user's turn to play) + const bestMoveUci = useMemo(() => { + if (selectedVariation && game && moveIdx < selectedVariation.moves.length) { + const chess = new Chess(game.fen()); + const san = selectedVariation.moves[moveIdx]; + const moves = chess.moves({ verbose: true }); + const moveObj = moves.find((m) => m.san === san); + if (moveObj) { + return ( + moveObj.from + + moveObj.to + + (moveObj.promotion ? moveObj.promotion : "") + ); + } + } + return undefined; + }, [selectedVariation, game, moveIdx]); + + // Writable atom for currentPosition (read/write) + // Instead of a local atom, use the engine evaluation mechanism already present in the project + // (see useEngine, useGameData, or similar hooks if available) + // Here, we use a simple effect to update the evaluation after each move using the engine + const [currentPositionAtom, setCurrentPositionAtom] = useState(() => + atom({ + lastEval: { lines: [] }, + eval: { moveClassification: undefined, lines: [] }, + }) + ); + + // Engine integration for real-time evaluation + const engine = useEngine(EngineName.Stockfish17Lite); + + useEffect(() => { + if (!game || !engine || !engine.getIsReady()) return; + let cancelled = false; + const fen = game.fen(); + // Delay the analysis to prioritize move animation + const timeout = setTimeout(() => { + if (cancelled) return; + engine.evaluatePositionWithUpdate({ + fen, + depth: 14, + multiPv: 2, + setPartialEval: (evalResult) => { + if (!cancelled) { + setCurrentPositionAtom( + atom({ + lastEval: evalResult, + eval: evalResult, + }) + ); + } + }, + }); + }, 200); // 200ms allows time for move animation + return () => { + cancelled = true; + clearTimeout(timeout); + engine.stopAllCurrentJobs(); + }; + }, [game, moveIdx, engine]); + + // Reset on each variation or progression + useEffect(() => { + if (!selectedVariation) return; + try { + const chess = new Chess(); + for (let i = 0; i < moveIdx; i++) { + const move = selectedVariation.moves[i]; + const result = chess.move(move); + if (!result) break; // Stop if invalid move + } + setGame(chess); + } catch { + // Error handling: avoid crash + setGame(new Chess()); + } + }, [selectedVariation, moveIdx, setGame]); + + // Validate user move using the custom mistake handler + const checkMistake = useMistakeHandler({ + selectedVariation, + game, + moveIdx, + isUserTurn, + setMoveIdx, + setLastMistakeVisible, + undoMove, + }); + + useEffect(() => { + checkMistake(); + }, [checkMistake]); + + // Automatically advance opponent moves after a correct user move + useEffect(() => { + if (!selectedVariation) return; + if (moveIdx >= selectedVariation.moves.length) return; + // If it's not the user's turn, automatically advance opponent moves + if (!isUserTurn) { + // Play all opponent moves until the next user move or end of sequence + let nextIdx = moveIdx; + let colorToPlay = nextIdx % 2 === 0 ? Color.White : Color.Black; + while ( + nextIdx < selectedVariation.moves.length && + colorToPlay !== learningColor + ) { + nextIdx++; + colorToPlay = nextIdx % 2 === 0 ? Color.White : Color.Black; + } + if (nextIdx !== moveIdx) { + // Delay increased to 500ms to allow time for user move animation + setTimeout(() => setMoveIdx(nextIdx), 500); + } + } + }, [moveIdx, isUserTurn, selectedVariation, learningColor]); + + // Automatically chain variations + useEffect(() => { + if (!selectedVariation) return; + if (moveIdx >= selectedVariation.moves.length) { + // Success: move to the next variation after a short delay + if (currentVariantIdx < variations.length - 1) { + setTimeout(() => { + setCurrentVariantIdx((idx) => idx + 1); + setMoveIdx(0); + }, 800); + } + } + }, [moveIdx, selectedVariation, currentVariantIdx, variations.length]); + + // If all variations are completed + const allDone = currentVariantIdx >= variations.length; + + // Progress management (persisted by mode) + const openingKey = "italian"; + const progressMode = trainingMode ? "training" : "learning"; + const progressStorageKey = `${openingKey}-progress-${progressMode}`; + const [completedVariations, setCompletedVariations] = useState( + () => { + if (typeof window === "undefined") return []; + try { + const raw = localStorage.getItem(progressStorageKey); + return raw ? JSON.parse(raw) : []; + } catch { + return []; + } + } + ); + + // Mark a variation as completed + useEffect(() => { + if (!selectedVariation) return; + if (moveIdx >= selectedVariation.moves.length) { + if (!completedVariations.includes(currentVariantIdx)) { + const updated = [...completedVariations, currentVariantIdx]; + setCompletedVariations(updated); + localStorage.setItem(progressStorageKey, JSON.stringify(updated)); + } + } + }, [ + moveIdx, + selectedVariation, + currentVariantIdx, + progressStorageKey, + completedVariations, + ]); + + // When loading, if there is a completed variation, jump to the first incomplete one + useEffect(() => { + if ( + completedVariations.length > 0 && + completedVariations.length < variations.length + ) { + // Find the first incomplete variation + const firstIncomplete = variations.findIndex( + (_, idx) => !completedVariations.includes(idx) + ); + if (firstIncomplete !== -1 && currentVariantIdx !== firstIncomplete) { + setCurrentVariantIdx(firstIncomplete); + setMoveIdx(0); + setLastMistakeVisible(null); + setGame(new Chess()); + } + } else if ( + completedVariations.length === variations.length && + currentVariantIdx !== variations.length + ) { + // All done, move to the end + setCurrentVariantIdx(variations.length - 1); // Correction: do not exceed max index + setMoveIdx(0); + setLastMistakeVisible(null); + setGame(new Chess()); + } + }, [completedVariations, variations, currentVariantIdx, setGame]); + + // Reset progress + const handleResetProgress = useCallback(() => { + localStorage.removeItem(progressStorageKey); + setCompletedVariations([]); + setCurrentVariantIdx(0); + setMoveIdx(0); + setLastMistakeVisible(null); + setGame(new Chess()); + }, [ + setCompletedVariations, + setCurrentVariantIdx, + setMoveIdx, + setLastMistakeVisible, + setGame, + progressStorageKey, + ]); + + // Determine the target square of the last move played (for overlay) + const lastMoveSquare = useMemo(() => { + if (!game) return null; + const history = game.history({ verbose: true }); + if (history.length === 0) return null; + const last = history[history.length - 1]; + return last.to; + }, [game]); + + // Determine the type of icon to display (success/error) + const trainingFeedback = useMemo(() => { + if (!lastMoveSquare) return undefined; + // Show red cross icon if the last move was incorrectly played by the human + if (lastMistakeVisible && lastMistakeVisible.to === lastMoveSquare) { + return { + square: lastMoveSquare, + icon: "/icons/mistake.png", + alt: "Incorrect move", + }; + } + // Show nothing if the move is correct + return undefined; + }, [lastMistakeVisible, lastMoveSquare]); + + const screenSize = useScreenSize(); + + // Dynamic board size calculation + const boardSize = useMemo(() => { + const { width, height } = screenSize; + if (typeof window !== "undefined" && window.innerWidth < 900) { + return Math.max(180, Math.min(width, height - 150)); + } + return Math.max(240, Math.min(width - 300, height * 0.83)); + }, [screenSize]); + + // Handler for skip variation + const handleSkipVariation = useCallback(() => { + let newCompleted = completedVariations; + if (!completedVariations.includes(currentVariantIdx)) { + newCompleted = [...completedVariations, currentVariantIdx]; + setCompletedVariations(newCompleted); + localStorage.setItem(progressStorageKey, JSON.stringify(newCompleted)); + } + if (currentVariantIdx < variations.length - 1) { + setCurrentVariantIdx((idx) => idx + 1); + setMoveIdx(0); + setLastMistakeVisible(null); + setGame(new Chess()); + } else { + setMoveIdx(0); + setLastMistakeVisible(null); + setGame(new Chess()); + } + }, [ + completedVariations, + currentVariantIdx, + setCompletedVariations, + setCurrentVariantIdx, + setMoveIdx, + setLastMistakeVisible, + setGame, + variations.length, + progressStorageKey, + ]); + + return ( + + {/* Left area: evaluation bar + board */} + + {selectedVariation && !allDone && game && ( + + )} + + + {/* Right area: progress panel, buttons, text */} + + {/* Centered container for title and buttons */} + + = (selectedVariation?.moves.length || 0) + } + /> + + + {/* Progress bar at the bottom right, always visible */} + + + + + + + ); +} diff --git a/src/public/openings/italian-game.png b/src/public/openings/italian-game.png new file mode 100644 index 00000000..c3313f2e --- /dev/null +++ b/src/public/openings/italian-game.png @@ -0,0 +1 @@ +// Illustration d'ouverture Italienne pour la page Opening diff --git a/src/sections/layout/NavMenu.tsx b/src/sections/layout/NavMenu.tsx index 337b2299..8d08eca4 100644 --- a/src/sections/layout/NavMenu.tsx +++ b/src/sections/layout/NavMenu.tsx @@ -1,4 +1,4 @@ -import NavLink from "@/components/NavLink"; +import NavLink from "../../components/NavLink"; import { Icon } from "@iconify/react"; import { Box, @@ -19,6 +19,11 @@ const MenuOptions = [ icon: "streamline:database", href: "/database", }, + { + text: "Opening Trainer", + icon: "mdi:book-open-variant", + href: "/choose-opening", // Redirige désormais vers la page de choix d'ouverture + }, ]; interface Props { diff --git a/src/sections/play/board.tsx b/src/sections/play/board.tsx index 209a5438..7c98db92 100644 --- a/src/sections/play/board.tsx +++ b/src/sections/play/board.tsx @@ -79,6 +79,7 @@ export default function BoardContainer() { blackPlayer={black} boardOrientation={playerColor} currentPositionAtom={gameDataAtom} + hidePlayerHeaders={true} /> ); }