From 4029123c57dd1fbb8e72f18e1111ee062f9cc715 Mon Sep 17 00:00:00 2001 From: Ali Raza Khalid Date: Tue, 3 Jun 2025 23:46:53 +0500 Subject: [PATCH 1/3] Chore: Add a co-ordinates training page --- src/pages/coordinate-trainer.tsx | 751 +++++++++++++++++++++++++++++++ src/sections/layout/NavMenu.tsx | 1 + 2 files changed, 752 insertions(+) create mode 100644 src/pages/coordinate-trainer.tsx diff --git a/src/pages/coordinate-trainer.tsx b/src/pages/coordinate-trainer.tsx new file mode 100644 index 00000000..42b42f03 --- /dev/null +++ b/src/pages/coordinate-trainer.tsx @@ -0,0 +1,751 @@ +import { useState, useEffect } from "react"; +import { + Box, + Typography, + Button, + Card, + CardContent, + FormControl, + InputLabel, + Select, + MenuItem, + Chip, + LinearProgress, + IconButton, + Dialog, + DialogTitle, + DialogContent, + DialogActions, + FormControlLabel, + Switch, + useTheme, +} from "@mui/material"; +import { Icon } from "@iconify/react"; + +const files = ["a", "b", "c", "d", "e", "f", "g", "h"]; +const ranks = [8, 7, 6, 5, 4, 3, 2, 1]; + +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 }, +]; + +const getRandomSquare = () => { + const file = files[Math.floor(Math.random() * 8)]; + const rank = ranks[Math.floor(Math.random() * 8)]; + return `${file}${rank}`; +}; + +interface GameSettings { + timeControl: number; + boardColor: "random" | "white" | "black"; + showCoordinates: boolean; +} + +/* +1. This page itself does not need a complete chess board with piece movements. So for now we have just implemented a simple board. +2. In the future when the board optimization is complete it may or maynot be updated in the future. +3. I didn't feel that rendering pieces was necessary however we can add an option for that so that the user can just choose if they want pieces or not. +4. We can use Atomstorage to keep track of the best score for the user. +*/ +export default function CoordinateTrainer() { + const theme = useTheme(); + const [gameState, setGameState] = useState< + "setup" | "playing" | "paused" | "finished" + >("setup"); + const [settings, setSettings] = useState({ + timeControl: 60, + boardColor: "white", + showCoordinates: false, + }); + const [targetSquare, setTargetSquare] = useState(getRandomSquare()); + const [score, setScore] = useState(0); + const [timeLeft, setTimeLeft] = useState(0); + const [feedback, setFeedback] = useState<{ + message: string; + type: "success" | "error"; + } | null>(null); + const [showSettings, setShowSettings] = useState(false); + const [wrongAnswer, setWrongAnswer] = useState(false); + const [currentBoardOrientation, setCurrentBoardOrientation] = useState< + "white" | "black" + >("white"); + + //* Timer Handling + useEffect(() => { + let interval: NodeJS.Timeout; + if (gameState === "playing" && timeLeft > 0) { + interval = setInterval(() => { + setTimeLeft((prev) => { + if (prev <= 1) { + setGameState("finished"); + return 0; + } + return prev - 1; + }); + }, 1000); + } + return () => clearInterval(interval); + }, [gameState, timeLeft]); + + // Clear feedback after 2 seconds + useEffect(() => { + let timer: NodeJS.Timeout; + if (feedback) { + timer = setTimeout(() => setFeedback(null), 2000); + } + return () => clearTimeout(timer); + }, [feedback]); + + const startGame = () => { + setScore(0); + setTimeLeft(settings.timeControl); + setTargetSquare(getRandomSquare()); + setGameState("playing"); + setFeedback(null); + setWrongAnswer(false); + + // Set initial board orientation + if (settings.boardColor === "random") { + setCurrentBoardOrientation(Math.random() > 0.5 ? "white" : "black"); + } else { + setCurrentBoardOrientation(settings.boardColor as "white" | "black"); + } + }; + + const pauseGame = () => { + setGameState(gameState === "paused" ? "playing" : "paused"); + }; + + const stopGame = () => { + setGameState("setup"); + setScore(0); + setTimeLeft(0); + setFeedback(null); + }; + + const handleSquareClick = (square: string) => { + if (gameState !== "playing") return; + + if (square === targetSquare) { + setScore(score + 1); + + // We can use icons but inline emojis also seem to do the work just fine + setFeedback({ message: "✅ Correct!", type: "success" }); + + setWrongAnswer(false); + + // Only generate new target on correct answer + setTargetSquare(getRandomSquare()); + + // We can randomly choose board orientation if set to random after each successfull click + // But for now the initial choosen value (b or w) be used through out each set. If not see the follow up code + // if (settings.boardColor === "random") { + // setCurrentBoardOrientation(Math.random() > 0.5 ? "white" : "black") + // } + } else { + setFeedback({ + message: "❌ Wrong! Try again", + type: "error", + }); + setWrongAnswer(true); + } + }; + + //* Formats the time into minutes:seconds format + const formatTime = (seconds: number) => { + const mins = Math.floor(seconds / 60); + const secs = seconds % 60; + return `${mins}:${secs.toString().padStart(2, "0")}`; + }; + + //* This func will be used to print the board + const getSquareColor = (file: string, rank: number) => { + const isLight = (files.indexOf(file) + ranks.indexOf(rank)) % 2 === 0; + + //* Since we have not implemented a board theme system for the whole application we can just set dark as light colors as follows These may be updated based on the board style: + return isLight ? "#f0d9b5" : "#b58863"; + }; + + //* Display the files on the side of the board + const getDisplayFiles = () => { + const orientation = + settings.boardColor === "random" + ? currentBoardOrientation + : settings.boardColor; + return orientation === "black" ? [...files].reverse() : files; + }; + + //* Display the ranks on the side of the board + const getDisplayRanks = () => { + const orientation = + settings.boardColor === "random" + ? currentBoardOrientation + : settings.boardColor; + return orientation === "black" ? [...ranks].reverse() : ranks; + }; + + //* This is the game setup dialog. used to choose time control color or coordinates settings + if (gameState === "setup") { + return ( + + + + + Coordinate Trainer + + + Train your board vision by finding squares quickly + + + + + Time Control + + + + + Board Orientation + + + + + setSettings({ + ...settings, + showCoordinates: e.target.checked, + }) + } + /> + } + label="Show coordinates in squares" + sx={{ mb: 2 }} + /> + + + + + + + ); + } + + return ( + + {/* Left Side - Chess Board */} + + {/* Board Container with Coordinates */} + + {/* Rank Numbers (Left Side) */} + + {getDisplayRanks().map((rank) => ( + + {rank} + + ))} + + + {/* File Letters (Bottom) */} + + {getDisplayFiles().map((file) => ( + + {file} + + ))} + + + {/* Chess Board */} + e.preventDefault()} + > + {getDisplayRanks().map((rank) => + getDisplayFiles().map((file) => { + const square = `${file}${rank}`; + + return ( + handleSquareClick(square)} + sx={{ + bgcolor: getSquareColor(file, rank), + display: "flex", + alignItems: settings.showCoordinates + ? "flex-end" + : "center", + justifyContent: settings.showCoordinates + ? "flex-start" + : "center", + cursor: gameState === "playing" ? "pointer" : "default", + position: "relative", + transition: "all 0.2s ease", + "&:hover": + gameState === "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 === "playing" && ( + + + {targetSquare} + + + )} + + + + {/* Right Side - Game Info */} + + {/* Header */} + + + + Coordinate Trainer + + + setShowSettings(true)} size="small"> + + + + + + + + + {/* Timer and Score */} + + + {settings.timeControl > 0 && ( + + )} + + + {/* Progress Bar */} + {settings.timeControl > 0 && ( + + )} + + {/* Game Controls */} + + + + + + + {/* Game Status Display */} + + {gameState === "playing" && !feedback && ( + <> + + Find Square + + + Click on the highlighted square on the board + + + )} + + {gameState === "paused" && ( + + Game Paused + + )} + + {gameState === "finished" && ( + + + Time's Up! + + + {score} + + + squares found + + + + )} + + {/* Feedback Display */} + {feedback && feedback.type === "error" && ( + + + {feedback.message} + + + Keep trying to find {targetSquare}! + + + )} + {feedback && feedback.type === "success" && ( + + + {feedback.message} + + + Next target coming up... + + + )} + + + + {/* Settings Dialog */} + setShowSettings(false)} + slotProps={{ + paper: { + sx: { + bgcolor: "background.paper", + color: "text.primary", + }, + }, + }} + > + Game Settings + + + Time Control + + + + + Board Orientation + + + + + setSettings({ + ...settings, + showCoordinates: e.target.checked, + }) + } + /> + } + label="Show coordinates in squares" + /> + + + + + + + ); +} 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", From c2be3a0ebdd0b05d95c7fd7e1c218e02b4a7eee3 Mon Sep 17 00:00:00 2001 From: Ali Raza Khalid Date: Tue, 17 Jun 2025 13:13:20 +0500 Subject: [PATCH 2/3] Fix: Simple Ui fixes --- src/pages/coordinate-trainer.tsx | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/src/pages/coordinate-trainer.tsx b/src/pages/coordinate-trainer.tsx index 42b42f03..44ae453b 100644 --- a/src/pages/coordinate-trainer.tsx +++ b/src/pages/coordinate-trainer.tsx @@ -193,7 +193,7 @@ export default function CoordinateTrainer() { return ( {/* Board Container with Coordinates */} @@ -433,7 +429,9 @@ export default function CoordinateTrainer() { opacity: 0.8, transform: "scale(0.95)", } - : {}, + : { + xs: { } + }, fontSize: { xs: "0.6rem", sm: "0.8rem", md: "1rem" }, fontWeight: "bold", color: settings.showCoordinates @@ -490,7 +488,7 @@ export default function CoordinateTrainer() { Date: Tue, 17 Jun 2025 14:19:59 +0500 Subject: [PATCH 3/3] Chore: Use Hooks for handling state and separation into components --- src/components/coordinates-trainer/Board.tsx | 199 +++++ .../coordinates-trainer/GameInfo.tsx | 208 +++++ .../coordinates-trainer/GameSetup.tsx | 106 +++ .../coordinates-trainer/SettingsDialog.tsx | 88 ++ src/hooks/useCoordinateTrainer.ts | 177 ++++ src/pages/coordinate-trainer.tsx | 769 ++---------------- src/types/coordinatesTrainer.ts | 18 + 7 files changed, 843 insertions(+), 722 deletions(-) create mode 100644 src/components/coordinates-trainer/Board.tsx create mode 100644 src/components/coordinates-trainer/GameInfo.tsx create mode 100644 src/components/coordinates-trainer/GameSetup.tsx create mode 100644 src/components/coordinates-trainer/SettingsDialog.tsx create mode 100644 src/hooks/useCoordinateTrainer.ts create mode 100644 src/types/coordinatesTrainer.ts 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 index 44ae453b..e11acc8c 100644 --- a/src/pages/coordinate-trainer.tsx +++ b/src/pages/coordinate-trainer.tsx @@ -1,749 +1,74 @@ -import { useState, useEffect } from "react"; +import { Box } from "@mui/material"; import { - Box, - Typography, - Button, - Card, - CardContent, - FormControl, - InputLabel, - Select, - MenuItem, - Chip, - LinearProgress, - IconButton, - Dialog, - DialogTitle, - DialogContent, - DialogActions, - FormControlLabel, - Switch, - useTheme, -} from "@mui/material"; -import { Icon } from "@iconify/react"; + 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"; -const files = ["a", "b", "c", "d", "e", "f", "g", "h"]; -const ranks = [8, 7, 6, 5, 4, 3, 2, 1]; - -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 }, -]; - -const getRandomSquare = () => { - const file = files[Math.floor(Math.random() * 8)]; - const rank = ranks[Math.floor(Math.random() * 8)]; - return `${file}${rank}`; -}; - -interface GameSettings { - timeControl: number; - boardColor: "random" | "white" | "black"; - showCoordinates: boolean; -} - -/* -1. This page itself does not need a complete chess board with piece movements. So for now we have just implemented a simple board. -2. In the future when the board optimization is complete it may or maynot be updated in the future. -3. I didn't feel that rendering pieces was necessary however we can add an option for that so that the user can just choose if they want pieces or not. -4. We can use Atomstorage to keep track of the best score for the user. -*/ export default function CoordinateTrainer() { - const theme = useTheme(); - const [gameState, setGameState] = useState< - "setup" | "playing" | "paused" | "finished" - >("setup"); - const [settings, setSettings] = useState({ - timeControl: 60, - boardColor: "white", - showCoordinates: false, - }); - const [targetSquare, setTargetSquare] = useState(getRandomSquare()); - const [score, setScore] = useState(0); - const [timeLeft, setTimeLeft] = useState(0); - const [feedback, setFeedback] = useState<{ - message: string; - type: "success" | "error"; - } | null>(null); - const [showSettings, setShowSettings] = useState(false); - const [wrongAnswer, setWrongAnswer] = useState(false); - const [currentBoardOrientation, setCurrentBoardOrientation] = useState< - "white" | "black" - >("white"); - - //* Timer Handling - useEffect(() => { - let interval: NodeJS.Timeout; - if (gameState === "playing" && timeLeft > 0) { - interval = setInterval(() => { - setTimeLeft((prev) => { - if (prev <= 1) { - setGameState("finished"); - return 0; - } - return prev - 1; - }); - }, 1000); - } - return () => clearInterval(interval); - }, [gameState, timeLeft]); - - // Clear feedback after 2 seconds - useEffect(() => { - let timer: NodeJS.Timeout; - if (feedback) { - timer = setTimeout(() => setFeedback(null), 2000); - } - return () => clearTimeout(timer); - }, [feedback]); - - const startGame = () => { - setScore(0); - setTimeLeft(settings.timeControl); - setTargetSquare(getRandomSquare()); - setGameState("playing"); - setFeedback(null); - setWrongAnswer(false); - - // Set initial board orientation - if (settings.boardColor === "random") { - setCurrentBoardOrientation(Math.random() > 0.5 ? "white" : "black"); - } else { - setCurrentBoardOrientation(settings.boardColor as "white" | "black"); - } - }; - - const pauseGame = () => { - setGameState(gameState === "paused" ? "playing" : "paused"); - }; - - const stopGame = () => { - setGameState("setup"); - setScore(0); - setTimeLeft(0); - setFeedback(null); - }; - - const handleSquareClick = (square: string) => { - if (gameState !== "playing") return; - - if (square === targetSquare) { - setScore(score + 1); - - // We can use icons but inline emojis also seem to do the work just fine - setFeedback({ message: "✅ Correct!", type: "success" }); - - setWrongAnswer(false); - - // Only generate new target on correct answer - setTargetSquare(getRandomSquare()); - - // We can randomly choose board orientation if set to random after each successfull click - // But for now the initial choosen value (b or w) be used through out each set. If not see the follow up code - // if (settings.boardColor === "random") { - // setCurrentBoardOrientation(Math.random() > 0.5 ? "white" : "black") - // } - } else { - setFeedback({ - message: "❌ Wrong! Try again", - type: "error", - }); - setWrongAnswer(true); - } - }; - - //* Formats the time into minutes:seconds format - const formatTime = (seconds: number) => { - const mins = Math.floor(seconds / 60); - const secs = seconds % 60; - return `${mins}:${secs.toString().padStart(2, "0")}`; - }; - - //* This func will be used to print the board - const getSquareColor = (file: string, rank: number) => { - const isLight = (files.indexOf(file) + ranks.indexOf(rank)) % 2 === 0; - - //* Since we have not implemented a board theme system for the whole application we can just set dark as light colors as follows These may be updated based on the board style: - return isLight ? "#f0d9b5" : "#b58863"; - }; - - //* Display the files on the side of the board - const getDisplayFiles = () => { - const orientation = - settings.boardColor === "random" - ? currentBoardOrientation - : settings.boardColor; - return orientation === "black" ? [...files].reverse() : files; - }; - - //* Display the ranks on the side of the board - const getDisplayRanks = () => { - const orientation = - settings.boardColor === "random" - ? currentBoardOrientation - : settings.boardColor; - return orientation === "black" ? [...ranks].reverse() : ranks; - }; - - //* This is the game setup dialog. used to choose time control color or coordinates settings - if (gameState === "setup") { + const { settings, showSettings, setShowSettings, updateSettings } = + useGameSettings(); + const { + gameState, + startGame, + pauseGame, + stopGame, + handleSquareClick, + formatTime, + } = useCoordinateGame(settings); + + if (gameState.status === "setup") { return ( - - - - Coordinate Trainer - - - Train your board vision by finding squares quickly - - - - - Time Control - - - - - Board Orientation - - - - - setSettings({ - ...settings, - showCoordinates: e.target.checked, - }) - } - /> - } - label="Show coordinates in squares" - sx={{ mb: 2 }} - /> - - - - - + ); } return ( - - {/* Left Side - Chess Board */} + <> - {/* Board Container with Coordinates */} - - {/* Rank Numbers (Left Side) */} - - {getDisplayRanks().map((rank) => ( - - {rank} - - ))} - - - {/* File Letters (Bottom) */} - - {getDisplayFiles().map((file) => ( - - {file} - - ))} - - - {/* Chess Board */} - e.preventDefault()} - > - {getDisplayRanks().map((rank) => - getDisplayFiles().map((file) => { - const square = `${file}${rank}`; - - return ( - handleSquareClick(square)} - sx={{ - bgcolor: getSquareColor(file, rank), - display: "flex", - alignItems: settings.showCoordinates - ? "flex-end" - : "center", - justifyContent: settings.showCoordinates - ? "flex-start" - : "center", - cursor: gameState === "playing" ? "pointer" : "default", - position: "relative", - transition: "all 0.2s ease", - "&:hover": - gameState === "playing" - ? { - opacity: 0.8, - transform: "scale(0.95)", - } - : { - xs: { } - }, - 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 === "playing" && ( - - - {targetSquare} - - - )} - + + setShowSettings(true)} + formatTime={formatTime} + /> - {/* Right Side - Game Info */} - - {/* Header */} - - - - Coordinate Trainer - - - setShowSettings(true)} size="small"> - - - - - - - - - {/* Timer and Score */} - - - {settings.timeControl > 0 && ( - - )} - - - {/* Progress Bar */} - {settings.timeControl > 0 && ( - - )} - - {/* Game Controls */} - - - - - - - {/* Game Status Display */} - - {gameState === "playing" && !feedback && ( - <> - - Find Square - - - Click on the highlighted square on the board - - - )} - - {gameState === "paused" && ( - - Game Paused - - )} - - {gameState === "finished" && ( - - - Time's Up! - - - {score} - - - squares found - - - - )} - - {/* Feedback Display */} - {feedback && feedback.type === "error" && ( - - - {feedback.message} - - - Keep trying to find {targetSquare}! - - - )} - {feedback && feedback.type === "success" && ( - - - {feedback.message} - - - Next target coming up... - - - )} - - - - {/* Settings Dialog */} - setShowSettings(false)} - slotProps={{ - paper: { - sx: { - bgcolor: "background.paper", - color: "text.primary", - }, - }, - }} - > - Game Settings - - - Time Control - - - - - Board Orientation - - - - - setSettings({ - ...settings, - showCoordinates: e.target.checked, - }) - } - /> - } - label="Show coordinates in squares" - /> - - - - - - + onSettingsChange={updateSettings} + /> + ); } 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; +}