diff --git a/src/components/coordinates-trainer/Board.tsx b/src/components/coordinates-trainer/Board.tsx new file mode 100644 index 00000000..58c5dd8a --- /dev/null +++ b/src/components/coordinates-trainer/Board.tsx @@ -0,0 +1,199 @@ +import { Box, Typography, useTheme } from "@mui/material"; +import type { GameSettings, GameState } from "@/types/coordinatesTrainer"; +import { + getSquareColor, + getDisplayFiles, + getDisplayRanks, +} from "@/hooks/useCoordinateTrainer"; + +interface ChessBoardProps { + gameState: GameState; + settings: GameSettings; + onSquareClick: (square: string) => void; +} + +export default function ChessBoard({ + gameState, + settings, + onSquareClick, +}: ChessBoardProps) { + const theme = useTheme(); + + const displayFiles = getDisplayFiles(gameState.currentBoardOrientation); + const displayRanks = getDisplayRanks(gameState.currentBoardOrientation); + + return ( + + + {/* Rank Numbers (Left Side) */} + + {displayRanks.map((rank) => ( + + {rank} + + ))} + + + {/* File Letters (Bottom) */} + + {displayFiles.map((file) => ( + + {file} + + ))} + + + {/* Chess Board */} + e.preventDefault()} + > + {displayRanks.map((rank) => + displayFiles.map((file) => { + const square = `${file}${rank}`; + + return ( + onSquareClick(square)} + sx={{ + bgcolor: getSquareColor(file, rank), + display: "flex", + alignItems: settings.showCoordinates + ? "flex-end" + : "center", + justifyContent: settings.showCoordinates + ? "flex-start" + : "center", + cursor: + gameState.status === "playing" ? "pointer" : "default", + position: "relative", + transition: "all 0.2s ease", + "&:hover": + gameState.status === "playing" + ? { + opacity: 0.8, + transform: "scale(0.95)", + } + : {}, + fontSize: { xs: "0.6rem", sm: "0.8rem", md: "1rem" }, + fontWeight: "bold", + color: settings.showCoordinates + ? "rgba(0,0,0,0.4)" + : "transparent", + p: settings.showCoordinates ? { xs: 0.3, sm: 0.5 } : 0, + }} + > + {settings.showCoordinates && square} + + ); + }) + )} + + + {/* Floating Target Square */} + {gameState.status === "playing" && ( + + + {gameState.targetSquare} + + + )} + + + ); +} diff --git a/src/components/coordinates-trainer/GameInfo.tsx b/src/components/coordinates-trainer/GameInfo.tsx new file mode 100644 index 00000000..9abb3789 --- /dev/null +++ b/src/components/coordinates-trainer/GameInfo.tsx @@ -0,0 +1,208 @@ +import { Icon } from "@iconify/react"; +import { + Box, + Button, + Chip, + IconButton, + LinearProgress, + Typography, + useTheme, +} from "@mui/material"; +import type { GameSettings, GameState } from "@/types/coordinatesTrainer"; + +interface GameInfoProps { + gameState: GameState; + settings: GameSettings; + onPauseGame: () => void; + onStopGame: () => void; + onStartGame: () => void; + onShowSettings: () => void; + formatTime: (seconds: number) => string; +} + +export default function GameInfo({ + gameState, + settings, + onPauseGame, + onStopGame, + onStartGame, + onShowSettings, + formatTime, +}: GameInfoProps) { + const theme = useTheme(); + + return ( + + {/* Header */} + + + + Coordinate Trainer + + + + + + + + + + + + {/* Timer and Score */} + + + {settings.timeControl > 0 && ( + + )} + + + {/* Progress Bar */} + {settings.timeControl > 0 && ( + + )} + + {/* Game Controls */} + + + + + + + {/* Game Status Display */} + + {gameState.status === "playing" && !gameState.feedback && ( + <> + + Find Square + + + Click on the highlighted square on the board + + + )} + + {gameState.status === "paused" && ( + + Game Paused + + )} + + {gameState.status === "finished" && ( + + + Time's Up! + + + {gameState.score} + + + squares found + + + + )} + + {/* Feedback Display */} + {gameState.feedback && ( + + + {gameState.feedback.message} + + + {gameState.feedback.type === "error" + ? `Keep trying to find ${gameState.targetSquare}!` + : "Next target coming up..."} + + + )} + + + ); +} diff --git a/src/components/coordinates-trainer/GameSetup.tsx b/src/components/coordinates-trainer/GameSetup.tsx new file mode 100644 index 00000000..2aaef160 --- /dev/null +++ b/src/components/coordinates-trainer/GameSetup.tsx @@ -0,0 +1,106 @@ +import { Icon } from "@iconify/react"; +import { + Box, + Button, + Card, + CardContent, + FormControl, + FormControlLabel, + InputLabel, + MenuItem, + Select, + Switch, + Typography, +} from "@mui/material"; +import type { GameSettings } from "@/types/coordinatesTrainer"; +import { timeControls } from "@/hooks/useCoordinateTrainer"; + +interface GameSetupProps { + settings: GameSettings; + onSettingsChange: (settings: Partial) => void; + onStartGame: () => void; +} + +export default function GameSetup({ + settings, + onSettingsChange, + onStartGame, +}: GameSetupProps) { + return ( + + + + Coordinate Trainer + + + Train your board vision by finding squares quickly + + + + + Time Control + + + + + Board Orientation + + + + + onSettingsChange({ showCoordinates: e.target.checked }) + } + /> + } + label="Show coordinates in squares" + sx={{ mb: 2 }} + /> + + + + + + ); +} diff --git a/src/components/coordinates-trainer/SettingsDialog.tsx b/src/components/coordinates-trainer/SettingsDialog.tsx new file mode 100644 index 00000000..2d1d2fd9 --- /dev/null +++ b/src/components/coordinates-trainer/SettingsDialog.tsx @@ -0,0 +1,88 @@ +import { + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Button, + FormControl, + FormControlLabel, + InputLabel, + MenuItem, + Select, + Switch, + Box, +} from "@mui/material"; +import { timeControls } from "@/hooks/useCoordinateTrainer"; +import { GameSettings } from "@/types/coordinatesTrainer"; + +interface SettingsDialogProps { + open: boolean; + settings: GameSettings; + onClose: () => void; + onSettingsChange: (settings: Partial) => void; +} + +export default function SettingsDialog({ + open, + settings, + onClose, + onSettingsChange, +}: SettingsDialogProps) { + return ( + + Game Settings + + + + Time Control + + + + + Board Orientation + + + + + onSettingsChange({ showCoordinates: e.target.checked }) + } + /> + } + label="Show coordinates in squares" + /> + + + + + + + ); +} diff --git a/src/hooks/useCoordinateTrainer.ts b/src/hooks/useCoordinateTrainer.ts new file mode 100644 index 00000000..afb2a9ba --- /dev/null +++ b/src/hooks/useCoordinateTrainer.ts @@ -0,0 +1,177 @@ +import { GameSettings, GameState } from "@/types/coordinatesTrainer"; +import { useState, useEffect, useCallback } from "react"; + +export const files = ["a", "b", "c", "d", "e", "f", "g", "h"]; +export const ranks = [8, 7, 6, 5, 4, 3, 2, 1]; + +// This should be made compattible witht the board theme that the user has selected +export const getSquareColor = (file: string, rank: number): string => { + const isLight = (files.indexOf(file) + ranks.indexOf(rank)) % 2 === 0; + return isLight ? "#f0d9b5" : "#b58863"; +}; + +export const getDisplayFiles = (orientation: "white" | "black"): string[] => { + return orientation === "black" ? [...files].reverse() : files; +}; + +export const getDisplayRanks = (orientation: "white" | "black"): number[] => { + return orientation === "black" ? [...ranks].reverse() : ranks; +}; + +const getRandomSquare = (): string => { + const file = files[Math.floor(Math.random() * 8)]; + const rank = ranks[Math.floor(Math.random() * 8)]; + return `${file}${rank}`; +}; + +/* 1.This hook serves as the core to the co-ordinates trainer. Following is an overview + 2.Setting and removing feedback text using timeouts. + 3.Generating and verfying target squares. + 4.Handling training start and end. +*/ +export const useCoordinateGame = (settings: GameSettings) => { + const [gameState, setGameState] = useState({ + status: "setup", + score: 0, + timeLeft: 0, + targetSquare: getRandomSquare(), + currentBoardOrientation: "white", + feedback: null, + wrongAnswer: false, + }); + + // Timer effect + useEffect(() => { + let interval: NodeJS.Timeout; + if (gameState.status === "playing" && gameState.timeLeft > 0) { + interval = setInterval(() => { + setGameState((prev) => { + if (prev.timeLeft <= 1) { + return { ...prev, status: "finished", timeLeft: 0 }; + } + return { ...prev, timeLeft: prev.timeLeft - 1 }; + }); + }, 1000); + } + return () => clearInterval(interval); + }, [gameState.status, gameState.timeLeft]); + + // Clear feedback after 2 seconds + useEffect(() => { + let timer: NodeJS.Timeout; + if (gameState.feedback) { + timer = setTimeout(() => { + setGameState((prev) => ({ ...prev, feedback: null })); + }, 2000); + } + return () => clearTimeout(timer); + }, [gameState.feedback]); + + const startGame = useCallback(() => { + const orientation = + settings.boardColor === "random" + ? Math.random() > 0.5 + ? "white" + : "black" + : (settings.boardColor as "white" | "black"); + + setGameState({ + status: "playing", + score: 0, + timeLeft: settings.timeControl, + targetSquare: getRandomSquare(), + currentBoardOrientation: orientation, + feedback: null, + wrongAnswer: false, + }); + }, [settings]); + + const pauseGame = useCallback(() => { + setGameState((prev) => ({ + ...prev, + status: prev.status === "paused" ? "playing" : "paused", + })); + }, []); + + const stopGame = useCallback(() => { + setGameState((prev) => ({ + ...prev, + status: "setup", + score: 0, + timeLeft: 0, + feedback: null, + })); + }, []); + + const handleSquareClick = useCallback( + (square: string) => { + if (gameState.status !== "playing") return; + + if (square === gameState.targetSquare) { + setGameState((prev) => ({ + ...prev, + score: prev.score + 1, + feedback: { message: "✅ Correct!", type: "success" }, + wrongAnswer: false, + targetSquare: getRandomSquare(), + })); + } else { + setGameState((prev) => ({ + ...prev, + feedback: { message: "❌ Wrong! Try again", type: "error" }, + wrongAnswer: true, + })); + } + }, + [gameState.status, gameState.targetSquare] + ); + + const formatTime = useCallback((seconds: number): string => { + const mins = Math.floor(seconds / 60); + const secs = seconds % 60; + return `${mins}:${secs.toString().padStart(2, "0")}`; + }, []); + + return { + gameState, + startGame, + pauseGame, + stopGame, + handleSquareClick, + formatTime, + }; +}; + +export const timeControls = [ + { label: "30 seconds", value: 30 }, + { label: "1 minute", value: 60 }, + { label: "2 minutes", value: 120 }, + { label: "5 minutes", value: 300 }, + { label: "Unlimited", value: 0 }, +]; + +/* +This hook get the user prefferd settings and passes it to the game state handling hook. +This one hook servers to both the game settings Dialog and the game Setup Form that appears in the start. +Any Change in the settings triggers a respective change in the game state +*/ +export const useGameSettings = () => { + const [settings, setSettings] = useState({ + timeControl: 60, + boardColor: "white", + showCoordinates: false, + }); + + const [showSettings, setShowSettings] = useState(false); + + const updateSettings = (newSettings: Partial) => { + setSettings((prev) => ({ ...prev, ...newSettings })); + }; + + return { + settings, + showSettings, + setShowSettings, + updateSettings, + }; +}; diff --git a/src/pages/coordinate-trainer.tsx b/src/pages/coordinate-trainer.tsx new file mode 100644 index 00000000..e11acc8c --- /dev/null +++ b/src/pages/coordinate-trainer.tsx @@ -0,0 +1,74 @@ +import { Box } from "@mui/material"; +import { + useCoordinateGame, + useGameSettings, +} from "@/hooks/useCoordinateTrainer"; +import ChessBoard from "@/components/coordinates-trainer/Board"; +import GameInfo from "@/components/coordinates-trainer/GameInfo"; +import GameSetup from "@/components/coordinates-trainer/GameSetup"; +import SettingsDialog from "@/components/coordinates-trainer/SettingsDialog"; + +export default function CoordinateTrainer() { + const { settings, showSettings, setShowSettings, updateSettings } = + useGameSettings(); + const { + gameState, + startGame, + pauseGame, + stopGame, + handleSquareClick, + formatTime, + } = useCoordinateGame(settings); + + if (gameState.status === "setup") { + return ( + + + + ); + } + + return ( + <> + + + setShowSettings(true)} + formatTime={formatTime} + /> + + + setShowSettings(false)} + onSettingsChange={updateSettings} + /> + + ); +} diff --git a/src/sections/layout/NavMenu.tsx b/src/sections/layout/NavMenu.tsx index 337b2299..cb512e01 100644 --- a/src/sections/layout/NavMenu.tsx +++ b/src/sections/layout/NavMenu.tsx @@ -14,6 +14,7 @@ import { const MenuOptions = [ { text: "Play", icon: "streamline:chess-pawn", href: "/play" }, { text: "Analysis", icon: "streamline:magnifying-glass-solid", href: "/" }, + { text: "Train", icon: "streamline:target", href: "/coordinate-trainer" }, { text: "Database", icon: "streamline:database", diff --git a/src/types/coordinatesTrainer.ts b/src/types/coordinatesTrainer.ts new file mode 100644 index 00000000..b74dac18 --- /dev/null +++ b/src/types/coordinatesTrainer.ts @@ -0,0 +1,18 @@ +export interface GameSettings { + timeControl: number; + boardColor: "random" | "white" | "black"; + showCoordinates: boolean; +} + +export interface GameState { + status: "setup" | "playing" | "paused" | "finished"; + score: number; + timeLeft: number; + targetSquare: string; + currentBoardOrientation: "white" | "black"; + feedback: { + message: string; + type: "success" | "error"; + } | null; + wrongAnswer: boolean; +}