diff --git a/src/App.js b/src/App.js index 1ef1d24..ffe1ff2 100644 --- a/src/App.js +++ b/src/App.js @@ -23,6 +23,7 @@ import { DEFAULT_SOUND_TYPE_KEY, } from "./components/features/sound/sound"; import DynamicBackground from "./components/common/DynamicBackground"; +import GameComponent from "./components/features/game"; function App() { // localStorage persist theme setting @@ -82,17 +83,27 @@ function App() { false, "IsInWordsCardMode" ); + const [isGameMode, setIsGameMode] = useLocalPersistState( + false, + "IsInGameMode" + ); const isWordGameMode = gameMode === GAME_MODE_DEFAULT && !isCoffeeMode && !isTrainerMode && + !isWordsCardMode && !isGameMode; + const isInGameMode = + gameMode === GAME_MODE && + !isCoffeeMode && + !isTrainerMode && !isWordsCardMode; const isSentenceGameMode = gameMode === GAME_MODE_SENTENCE && !isCoffeeMode && !isTrainerMode && - !isWordsCardMode; + !isWordsCardMode && + !isGameMode; const handleThemeChange = (e) => { window.localStorage.setItem("theme", JSON.stringify(e.value)); @@ -119,22 +130,11 @@ function App() { setIsUltraZenMode(!isUltraZenMode); }; - const toggleCoffeeMode = () => { - setIsCoffeeMode(!isCoffeeMode); - setIsTrainerMode(false); - setIsWordsCardMode(false); - }; - - const toggleTrainerMode = () => { - setIsTrainerMode(!isTrainerMode); - setIsCoffeeMode(false); - setIsWordsCardMode(false); - }; - - const toggleWordsCardMode = () => { - setIsTrainerMode(false); - setIsCoffeeMode(false); - setIsWordsCardMode(!isWordsCardMode); + const toggleMode = (mode) => { + setIsTrainerMode( mode === "trainer" && !isTrainerMode); + setIsCoffeeMode( mode === "coffee" && !isCoffeeMode); + setIsWordsCardMode( mode === "wordsCard" && !isWordsCardMode); + setIsGameMode( mode === "game" && !isGameMode); }; useEffect(() => { @@ -159,6 +159,9 @@ function App() { const focusSentenceInput = () => { sentenceInputRef.current && sentenceInputRef.current.focus(); }; + const focusGameInput = () => { + textInputRef.current && textInputRef.current.focus(); + }; useEffect(() => { if (isWordGameMode) { @@ -173,6 +176,10 @@ function App() { focusTextArea(); return; } + if (isInGameMode) { + focusGameInput(); + return; + } return; }, [ theme, @@ -183,6 +190,7 @@ function App() { isSentenceGameMode, soundMode, soundType, + isInGameMode ]); return ( @@ -204,6 +212,20 @@ function App() { handleInputFocus={() => focusTextInput()} > )} + { + isGameMode && ( + focusTextInput()} + > + ) + } {isSentenceGameMode && ( { const isSiteInfoDisabled = isMusicMode || isFocusedMode; const isBottomLogoEnabled = isFocusedMode && !isMusicMode; - const isTypeTestEnabled = !isCoffeeMode && !isTrainerMode && !isWordsCardMode; + const isTypeTestEnabled = !isCoffeeMode && !isTrainerMode && !isWordsCardMode && !isGameMode; const getModeButtonClassName = (mode) => { if (mode) { @@ -96,7 +97,6 @@ const FooterMenu = ({ onChange={handleThemeChange} menuPlacement="top" > - @@ -122,7 +122,7 @@ const FooterMenu = ({ menuPlacement="top" > )} - + {toggleMode("wordsCard")}}> @@ -135,7 +135,7 @@ const FooterMenu = ({ - + toggleMode("coffee")}> {FREE_MODE} @@ -146,7 +146,7 @@ const FooterMenu = ({ - + toggleMode("trainer")}> @@ -160,6 +160,15 @@ const FooterMenu = ({ {" "} + toggleMode("game")}> + {GAME_MODE} + }> + + + + + {isTypeTestEnabled && ( <> { const [mode, setMode] = useLocalPersistState("vocab", "mode"); // selective, vocab - const [selectiveWord, setSelectiveWord] = useLocalPersistState("word",""); + const [selectiveWord, setSelectiveWord] = useLocalPersistState("","word"); const [play] = useSound(SOUND_MAP[soundType], { volume: 0.5 }); @@ -489,18 +489,17 @@ const WordsCard = ({ soundType, soundMode }) => { controlsList="nodownload nofullscreen noremoteplayback" /> - { - mode === "vocab" && -
- {"Chapter " + currChapter.toUpperCase() + ": "} {index + 1} /{" "} - {currChapterCount} -
- } - { - mode === "selective" && -

Selected Keys:

- } - + { + mode === "vocab" && +
+ {"Chapter " + currChapter.toUpperCase() + ": "} {index + 1} /{" "} + {currChapterCount} +
+ } + { + mode === "selective" && +

Selected Keys:

+ }
{ @@ -567,7 +566,7 @@ const WordsCard = ({ soundType, soundMode }) => { } - + setMode("vocab")}> Vocab diff --git a/src/components/features/game/index.js b/src/components/features/game/index.js new file mode 100644 index 0000000..09bc440 --- /dev/null +++ b/src/components/features/game/index.js @@ -0,0 +1,372 @@ +import useSound from "use-sound"; +import { SOUND_MAP } from "../sound/sound"; +import { useState, useEffect, useRef } from "react"; +import { getRandomWord, initData, isWordPresent } from "./util"; +import useLocalPersistState from "../../../hooks/useLocalPersistState"; +import { Box, Dialog, DialogActions, DialogTitle, Grid, Tooltip, Button } from "@mui/material"; +import IconButton from "../../utils/IconButton"; +import RestartAltIcon from "@mui/icons-material/RestartAlt"; +import LightbulbIcon from '@mui/icons-material/Lightbulb'; +import { HINT_BUTTON_TOOLTIP_TITLE, HINT_LIMIT, RESET_BUTTON_TOOLTIP_TITLE } from "../../../constants/Constants"; +import LinearProgress from "@mui/material/LinearProgress"; +import RestoreIcon from '@mui/icons-material/Restore'; + + +const GameComponent = ({ soundType, soundMode }) => { + const [play] = useSound(SOUND_MAP[soundType], { volume: 0.5 }); + //easy, medium, hard + const [difficulty, setDifficulty] = useLocalPersistState("easy", "game-difficulty"); + const [guessWord, setGuessWord] = useLocalPersistState("","guessWord"); + const [progress, setProgress] = useState(100); // Progress bar value + const [timer, setTimer] = useState(null); // Timer reference + const [gameOverDialogOpen, setGameOverDialogOpen] = useState(false); // State for game over dialog + const [guessedWordsCount, setGuessedWordsCount] = useState(0); + const [highScore, setHighScore] = useLocalPersistState(0, "highscore"); // High score tracker + + // set up game loop status state + const [status, setStatus] = useState("waiting"); + const [visibleIndex, setVisibleIndex] = useState([]); + + const [currInput, setCurrInput] = useState(""); + + const hiddenInputRef = useRef(); + + const start = () => { + if (status === "finished") { + return; + } + if (status !== "started") { + setStatus("started"); + startTimer(); + } + }; + + const restartGame = () => { + setStatus("waiting"); + setCurrInput(""); + setProgress(100); // Reset progress bar + setVisibleIndex([]); + setGuessedWordsCount(0); // Reset guessed words count + requestWord(); + if (hiddenInputRef.current) { + hiddenInputRef.current.value = ""; + hiddenInputRef.current.focus(); // Refocus the input field + } + clearInterval(timer); // Clear the timer + setTimer(null); + setGameOverDialogOpen(false); // Close the game over dialog + }; + + const startTimer = () => { + clearInterval(timer); // Clear any existing timer + const newTimer = setInterval(() => { + setProgress((prev) => { + if (prev <= 0) { + clearInterval(newTimer); + setStatus("finished"); // End the game when the timer reaches 0 + return 0; + } + return prev - 1; // Decrease progress by 1% every second + }); + }, 1000); + setTimer(newTimer); + }; + + const currWord = guessWord; + const handleInputBlur = (event) => { + hiddenInputRef.current && hiddenInputRef.current.focus(); + }; + + const handleInputChange = (e) => { + setCurrInput(e.target.value); + hiddenInputRef.current.value = e.target.value; + e.preventDefault(); + }; + useEffect(() => { + hiddenInputRef.current && hiddenInputRef.current.focus(); + initData(); + requestWord(); + }, []); + useEffect(() => { + // Call requestWord whenever difficulty changes + requestWord(); + }, [difficulty]); + // Show game over dialog when the game ends + useEffect(() => { + if (status === "finished") { + setGameOverDialogOpen(true); // Open the game over dialog + } + }, [status]); + + useEffect(() => { + hiddenInputRef.current.value = ""; + setCurrInput(""); + let random = 0 + while (random===0 && guessWord){ + random = Math.floor(guessWord.length * Math.random()) + } + setVisibleIndex([random]); + }, [guessWord]); + + useEffect(() => { + // Reset guessed words count and request a new word when difficulty changes + setGuessedWordsCount(0); + requestWord(); + }, [difficulty]); + + const allVisibleRevealed = () => { + for (let i = 0; i < currWord.length; i++) { + if ((i === 0 || visibleIndex.includes(i)) && currInput[i] !== currWord[i]) { + return false; + } + } + return isWordPresent(currInput); + } + + const getCharClassName = (idx, char) => { + const wordClass = ["wordcard-error-char", "correct-wordcard-char", "wordcard-char", "error-wordcard-space-char"]; + + // case 1. If the input is longer than or equal to the word length, all chars are wrong. + if(currInput.length > currWord.length){ + return wordClass[0]; // error char + } + + // Case 2: If the input length equal to the word length. + if (currWord.length === currInput.length) { + // if all visible chars are correct and the word is valid, show correct char, otherwise show error char. + return allVisibleRevealed() ? wordClass[1] : wordClass[0]; + } + + // Case 3: If the input length is less than the word length, check the visible chars. If the char is visible and not correct, show error char. If the char is typed but not visible, show correct char if it's correct, otherwise show error char. If the char is not typed, show default char. + if(idx === 0 || visibleIndex.includes(idx)){ + if(currInput[idx] && char !== currInput[idx]){ + return wordClass[0]; // error char + } + } + if (idx < currInput.length) { + // if the char is space, show error space char, otherwise show correct char + return char === " " ? wordClass[3] : wordClass[1]; + } + return wordClass[2]; // default char + }; + + const getExtraCharClassName = (char) => { + if (char === " ") { + return "wordcard-error-char-space-char"; + } + return "wordcard-error-char"; + }; + + const extra = currInput.slice(guessWord.length, currInput.length).split(""); + const getCharDisplay = (idx, char) => { + if ( visibleIndex.includes(idx) || idx === 0) { + return char; + } + if(idx < currInput.length){ + return currInput[idx]; + } + return "_"; + }; + + const handleReset = () => { + if (status !== "started") { + start(); // Start the game if it's not already started + } + requestWord(); + hiddenInputRef.current.value = ""; + setProgress((prev) => Math.min(prev - 2, 100)); // Increase progress by 2% for reset + } + + const requestWord = () => { + const difficultyRanges = { + easy: { min: 3, max: 4 }, + medium: { min: 5, max: 7 }, + hard: { min: 8, max: 20 }, + }; + const { min, max } = difficultyRanges[difficulty] || difficultyRanges["easy"]; + const newWord = getRandomWord(min, max); + + if (newWord !== guessWord) { + setGuessWord(newWord); + } + } + + const handleDisable = () =>{ + return visibleIndex.length > HINT_LIMIT || visibleIndex.length + 2 === currWord.length; + } + const handleHint = () => { + if (status !== "started") { + start(); // Start the game if it's not already started + } + if (visibleIndex.length > HINT_LIMIT || visibleIndex.length === currWord.length - 2) { + return; + } + let newVisibleIndex = [...visibleIndex]; + let random = 0 + while (currWord && (newVisibleIndex.includes(random) || random === 0)) { + random = Math.floor(currWord.length * Math.random()) + } + newVisibleIndex.push(random); + setVisibleIndex(newVisibleIndex); + setProgress((prev) => Math.min(prev - 1, 100)); // Increase progress by 1% for hint + } + const getModeActivation = (type) => { + // return "active-button" ; + return difficulty === type ? "active-button" : "inactive-button" + } + + const handleKeyDown = (e) => { + if (soundMode) { + play(); + } + const keyCode = e.keyCode; + + // disable tab key + if (keyCode === 9) { + e.preventDefault(); + return; + } + + if (status === "finished") { + e.preventDefault(); + return; + } + + // start the game by typing any thing + if (status !== "started" && status !== "finished") { + start(); + return; + } + + // Handle word completion + if (currInput.length >= guessWord.length) { + if (keyCode === 13 || keyCode === 32) { + if (guessWord === currInput || (currWord.length === currInput.length && allVisibleRevealed())) { + e.preventDefault(); + requestWord(); + setProgress((prev) => Math.min(prev + 2, 100)); + setCurrInput(""); + hiddenInputRef.current.value = ""; + setGuessedWordsCount((prev) => { + const newCount = prev + 1; + setHighScore((highScore) => Math.max(highScore, newCount)); // Update high score if needed + return newCount; + }); + } + return; + } + return; + } + }; + + return ( +
+
+ handleKeyDown(e)} + > +
+ {currWord.split("").map((char, idx) => ( + + {getCharDisplay(idx, char)} + + ))} + {extra.map((char, idx) => ( + + {char} + + ))} +
+
+
+ + + + + + + + + + + + + + + + + + + + setDifficulty("easy")}> + + Easy + + + setDifficulty("medium")}> + + Medium + + + setDifficulty("hard")}> + + Hard + + + + +

You guessed {guessedWordsCount} words correctly!

+

High Score: {highScore}

+
+ +
+
+
+ + + +
+ setGameOverDialogOpen(false)}> + Game Over + +

+ The word was: {currWord} +

+
+ + + +
+
+ ); +}; + +export default GameComponent; \ No newline at end of file diff --git a/src/components/features/game/util.js b/src/components/features/game/util.js new file mode 100644 index 0000000..939075c --- /dev/null +++ b/src/components/features/game/util.js @@ -0,0 +1,89 @@ +const result = {}; +// Parse CET4Words +const guessedWords = new Set(); // New set to track guessed words + +function initData(){ + ParseCET4Words(); + ParseCET6Words(); + ParseGREWords(); +} + +// Parse CET4Words +const ParseCET4Words = () => { + const CET4Words = require('../../../assets/Vocab/CET4Words.json'); + Object.keys(CET4Words).forEach((key) => { + separator(CET4Words[key]?.key); + }); +}; + +// Parse CET6Words +const ParseCET6Words = () => { + const CET6Words = require('../../../assets/Vocab/CET6Words.json'); + Object.keys(CET6Words).forEach((key) => { + separator(CET6Words[key]?.key); + }); +}; + +// Parse GREWords +const ParseGREWords = () => { + const GREWords = require('../../../assets/Vocab/GREWords.json'); + Object.keys(GREWords).forEach((key) => { + separator(GREWords[key]?.key); + }); +}; + +// Function to create a data structure of separate words based on first character and length. +function separator(word) { + if(word.includes('.') || !word) return; + const firstChar = word.charAt(0).toLowerCase(); + const len = word.length; + if (!result[firstChar]) { + result[firstChar] = {}; + } + if(!result[firstChar][len]){ + result[firstChar][len] = new Set(); + } + result[firstChar][len].add(word); +} + +// Function to check if a word exists in the result +function isWordPresent(word) { + if (!word) return false; + const firstChar = word[0].toLowerCase(); + const len = word.length; + return result[firstChar]?.[len]?.has(word) || guessedWords.has(word) || false; +} + +// Function to get a random word from the result +function getRandomWord(min, max) { + + // Flatten all words into a single array + const allWords = []; + Object.values(result).forEach(lengthsObj => { + Object.values(lengthsObj).forEach(wordsSet => { + allWords.push(...Array.from(wordsSet)); + }); + }); + // Filter words based on the length constraints + const filteredWords = allWords.filter(word => word.length >= min && word.length <= max); + + // Pick a random word from the filtered list + const randomIndex = Math.floor(Math.random() * filteredWords.length); + const word = filteredWords[randomIndex]; + + // Remove the selected word from the result structure + const firstChar = word.charAt(0).toLowerCase(); + const len = word.length; + result[firstChar][len].delete(word); + + // Add the word to guessedWords + guessedWords.add(word); + + return word; +} + +export { isWordPresent, result, getRandomWord, initData }; + +// console.log(result); +// console.log(getRandomWord()); +// console.log(isWordPresent("adult")); \ No newline at end of file diff --git a/src/constants/Constants.js b/src/constants/Constants.js index ebcf289..c45edea 100644 --- a/src/constants/Constants.js +++ b/src/constants/Constants.js @@ -55,7 +55,9 @@ const FREE_MODE = const ENGLISH_MODE = "ENGLISH_MODE"; const CHINESE_MODE = "CHINESE_MODE"; -const GAME_MODE = "GAME_MODE"; +const GAME_MODE = "Time based word guess mode"; +const HINT_BUTTON_TOOLTIP_TITLE = "Show Hint"; +const RESET_BUTTON_TOOLTIP_TITLE = "New word for guessing"; const GAME_MODE_DEFAULT = "WORD_MODE"; const GAME_MODE_SENTENCE = "SENTENCE_MODE"; const WORD_MODE_LABEL = "word"; @@ -65,6 +67,7 @@ const TRAINER_MODE = "QWERTY keyboard practice mode"; const DEFAULT_SENTENCES_COUNT = 5; const TEN_SENTENCES_COUNT = 10; const FIFTEEN_SENTENCES_COUNT = 15; +const HINT_LIMIT = 2; const ENGLISH_SENTENCE_MODE_TOOLTIP_TITLE = "English Sentence Mode"; const CHINESE_SENTENCE_MODE_TOOLTIP_TITLE = "Chinese Sentence Mode"; @@ -137,5 +140,8 @@ export { SYMBOL_ADDON_KEY, ULTRA_ZEN_MODE, VOCAB_MODE, - SELECTIVE_MODE + SELECTIVE_MODE, + HINT_BUTTON_TOOLTIP_TITLE, + RESET_BUTTON_TOOLTIP_TITLE, + HINT_LIMIT }; diff --git a/src/style/global.js b/src/style/global.js index cf4e83e..10b1742 100644 --- a/src/style/global.js +++ b/src/style/global.js @@ -368,6 +368,15 @@ width: 8em transform:scale(1.18); transition:0.3s; } +.restart-button-game{ +margin-left: auto; +margin-right: auto; +width: 11em +} +.restart-button-game button:hover{ +transform:scale(1.18); +transition:0.3s; +} .alert{ opacity: 0.3; background-image: ${({ theme }) => theme.gradient}; @@ -687,9 +696,19 @@ transform: translate(0); .CorrectKeyDowns{ color: inherit; } +.center-row{ +display: flex; +justify-content: center; +} .IncorrectKeyDowns{ color: red; } +.game-card-container{ +display: flex; +justify-content: center; +width: 100%; +height: 100%; +} .words-card-container{ display: block; width: 100%;