diff --git a/resources/App.tsx b/resources/App.tsx new file mode 100644 index 0000000..4aa6291 --- /dev/null +++ b/resources/App.tsx @@ -0,0 +1,470 @@ +import { letters, status } from './constants' +import { useEffect, useState } from 'react' + +import { EndGameModal } from './components/EndGameModal' +import { InfoModal } from './components/InfoModal' +import { Keyboard } from './components/Keyboard' +import { SettingsModal } from './components/SettingsModal' +import answers from './data/answers' +import { useLocalStorage } from './hooks/useLocalStorage' +import { ReactComponent as Info } from './data/Info.svg' +import { ReactComponent as Settings } from './data/Settings.svg' +const words = require('./data/words').default as { [key: string]: boolean } + +const state = { + playing: 'playing', + won: 'won', + lost: 'lost', +} + +export const difficulty = { + easy: 'easy', + normal: 'normal', + hard: 'hard', +} + +const getRandomAnswer = () => { + const randomIndex = Math.floor(Math.random() * answers.length) + return answers[randomIndex].toUpperCase() +} + +type State = { + answer: () => string + gameState: string + board: string[][] + cellStatuses: string[][] + currentRow: number + currentCol: number + letterStatuses: () => { [key: string]: string } + submittedInvalidWord: boolean + darkMode: boolean +} + +function App() { + const initialStates: State = { + answer: () => getRandomAnswer(), + gameState: state.playing, + board: [ + ['', '', '', '', ''], + ['', '', '', '', ''], + ['', '', '', '', ''], + ['', '', '', '', ''], + ['', '', '', '', ''], + ['', '', '', '', ''], + ], + cellStatuses: Array(6).fill(Array(5).fill(status.unguessed)), + currentRow: 0, + currentCol: 0, + letterStatuses: () => { + const letterStatuses: { [key: string]: string } = {} + letters.forEach((letter) => { + letterStatuses[letter] = status.unguessed + }) + return letterStatuses + }, + submittedInvalidWord: false, + darkMode: window.matchMedia('(prefers-color-scheme: dark)').matches, + } + + const [answer, setAnswer] = useLocalStorage('stateAnswer', initialStates.answer()) + const [gameState, setGameState] = useLocalStorage('stateGameState', initialStates.gameState) + const [board, setBoard] = useLocalStorage('stateBoard', initialStates.board) + const [cellStatuses, setCellStatuses] = useLocalStorage( + 'stateCellStatuses', + initialStates.cellStatuses + ) + const [currentRow, setCurrentRow] = useLocalStorage('stateCurrentRow', initialStates.currentRow) + const [currentCol, setCurrentCol] = useLocalStorage('stateCurrentCol', initialStates.currentCol) + const [letterStatuses, setLetterStatuses] = useLocalStorage( + 'stateLetterStatuses', + initialStates.letterStatuses() + ) + const [submittedInvalidWord, setSubmittedInvalidWord] = useLocalStorage( + 'stateSubmittedInvalidWord', + initialStates.submittedInvalidWord + ) + + const [currentStreak, setCurrentStreak] = useLocalStorage('current-streak', 0) + const [longestStreak, setLongestStreak] = useLocalStorage('longest-streak', 0) + const [modalIsOpen, setIsOpen] = useState(false) + const [firstTime, setFirstTime] = useLocalStorage('first-time', true) + const [guessesInStreak, setGuessesInStreak] = useLocalStorage( + 'guesses-in-streak', + firstTime ? 0 : -1 + ) + const [infoModalIsOpen, setInfoModalIsOpen] = useState(firstTime) + const [settingsModalIsOpen, setSettingsModalIsOpen] = useState(false) + const [difficultyLevel, setDifficultyLevel] = useLocalStorage('difficulty', difficulty.normal) + const getDifficultyLevelInstructions = () => { + if (difficultyLevel === difficulty.easy) { + return 'Guess any 5 letters' + } else if (difficultyLevel === difficulty.hard) { + return "Guess any valid word using all the hints you've been given" + } else { + return 'Guess any valid word' + } + } + const eg: { [key: number]: string } = {} + const [exactGuesses, setExactGuesses] = useLocalStorage('exact-guesses', eg) + + const openModal = () => setIsOpen(true) + const closeModal = () => setIsOpen(false) + const handleInfoClose = () => { + setFirstTime(false) + setInfoModalIsOpen(false) + } + + const [darkMode, setDarkMode] = useLocalStorage('dark-mode', initialStates.darkMode) + const toggleDarkMode = () => setDarkMode((prev: boolean) => !prev) + + useEffect( + () => document.documentElement.classList[darkMode ? 'add' : 'remove']('dark'), + [darkMode] + ) + + useEffect(() => { + if (gameState !== state.playing) { + setTimeout(() => { + openModal() + }, 500) + } + }, [gameState]) + + const getCellStyles = (rowNumber: number, colNumber: number, letter: string) => { + if (rowNumber === currentRow) { + if (letter) { + return `nm-inset-background dark:nm-inset-background-dark text-primary dark:text-primary-dark ${ + submittedInvalidWord ? 'border border-red-800' : '' + }` + } + return 'nm-flat-background dark:nm-flat-background-dark text-primary dark:text-primary-dark' + } + + switch (cellStatuses[rowNumber][colNumber]) { + case status.green: + return 'nm-inset-n-green text-gray-50' + case status.yellow: + return 'nm-inset-yellow-500 text-gray-50' + case status.gray: + return 'nm-inset-n-gray text-gray-50' + default: + return 'nm-flat-background dark:nm-flat-background-dark text-primary dark:text-primary-dark' + } + } + + const addLetter = (letter: string) => { + setSubmittedInvalidWord(false) + setBoard((prev: string[][]) => { + if (currentCol > 4) { + return prev + } + const newBoard = [...prev] + newBoard[currentRow][currentCol] = letter + return newBoard + }) + if (currentCol < 5) { + setCurrentCol((prev: number) => prev + 1) + } + } + + // returns an array with a boolean of if the word is valid and an error message if it is not + const isValidWord = (word: string): [boolean] | [boolean, string] => { + if (word.length < 5) return [false, `please enter a 5 letter word`] + if (difficultyLevel === difficulty.easy) return [true] + if (!words[word.toLowerCase()]) return [false, `${word} is not a valid word. Please try again.`] + if (difficultyLevel === difficulty.normal) return [true] + const guessedLetters = Object.entries(letterStatuses).filter(([letter, letterStatus]) => + [status.yellow, status.green].includes(letterStatus) + ) + const yellowsUsed = guessedLetters.every(([letter, _]) => word.includes(letter)) + const greensUsed = Object.entries(exactGuesses).every( + ([position, letter]) => word[parseInt(position)] === letter + ) + if (!yellowsUsed || !greensUsed) + return [false, `In hard mode, you must use all the hints you've been given.`] + return [true] + } + + const onEnterPress = () => { + const word = board[currentRow].join('') + const [valid, _err] = isValidWord(word) + if (!valid) { + console.log({ valid, _err }) + setSubmittedInvalidWord(true) + // alert(_err) + return + } + + if (currentRow === 6) return + + updateCellStatuses(word, currentRow) + updateLetterStatuses(word) + setCurrentRow((prev: number) => prev + 1) + setCurrentCol(0) + + // Only calculate guesses in streak if they've + // started a new streak since this feature was added. + if (guessesInStreak >= 0) { + setGuessesInStreak((prev: number) => prev + 1) + } + } + + const onDeletePress = () => { + setSubmittedInvalidWord(false) + if (currentCol === 0) return + + setBoard((prev: any) => { + const newBoard = [...prev] + newBoard[currentRow][currentCol - 1] = '' + return newBoard + }) + + setCurrentCol((prev: number) => prev - 1) + } + + const updateCellStatuses = (word: string, rowNumber: number) => { + const fixedLetters: { [key: number]: string } = {} + setCellStatuses((prev: any) => { + const newCellStatuses = [...prev] + newCellStatuses[rowNumber] = [...prev[rowNumber]] + const wordLength = word.length + const answerLetters: string[] = answer.split('') + + // set all to gray + for (let i = 0; i < wordLength; i++) { + newCellStatuses[rowNumber][i] = status.gray + } + + // check greens + for (let i = wordLength - 1; i >= 0; i--) { + if (word[i] === answer[i]) { + newCellStatuses[rowNumber][i] = status.green + answerLetters.splice(i, 1) + fixedLetters[i] = answer[i] + } + } + + // check yellows + for (let i = 0; i < wordLength; i++) { + if (answerLetters.includes(word[i]) && newCellStatuses[rowNumber][i] !== status.green) { + newCellStatuses[rowNumber][i] = status.yellow + answerLetters.splice(answerLetters.indexOf(word[i]), 1) + } + } + + return newCellStatuses + }) + setExactGuesses((prev: { [key: number]: string }) => ({ ...prev, ...fixedLetters })) + } + + const isRowAllGreen = (row: string[]) => { + return row.every((cell: string) => cell === status.green) + } + + const avgGuessesPerGame = (): number => { + if (currentStreak > 0) { + return guessesInStreak / currentStreak + } else { + return 0 + } + } + + // every time cellStatuses updates, check if the game is won or lost + useEffect(() => { + const cellStatusesCopy = [...cellStatuses] + const reversedStatuses = cellStatusesCopy.reverse() + const lastFilledRow = reversedStatuses.find((r) => { + return r[0] !== status.unguessed + }) + + if (gameState === state.playing && lastFilledRow && isRowAllGreen(lastFilledRow)) { + setGameState(state.won) + + var streak = currentStreak + 1 + setCurrentStreak(streak) + setLongestStreak((prev: number) => (streak > prev ? streak : prev)) + } else if (gameState === state.playing && currentRow === 6) { + setGameState(state.lost) + setCurrentStreak(0) + } + }, [ + cellStatuses, + currentRow, + gameState, + setGameState, + currentStreak, + setCurrentStreak, + setLongestStreak, + ]) + + const updateLetterStatuses = (word: string) => { + setLetterStatuses((prev: { [key: string]: string }) => { + const newLetterStatuses = { ...prev } + const wordLength = word.length + for (let i = 0; i < wordLength; i++) { + if (newLetterStatuses[word[i]] === status.green) continue + + if (word[i] === answer[i]) { + newLetterStatuses[word[i]] = status.green + } else if (answer.includes(word[i])) { + newLetterStatuses[word[i]] = status.yellow + } else { + newLetterStatuses[word[i]] = status.gray + } + } + return newLetterStatuses + }) + } + + const playAgain = () => { + if (gameState === state.lost) { + setGuessesInStreak(0) + } + + setAnswer(initialStates.answer()) + setGameState(initialStates.gameState) + setBoard(initialStates.board) + setCellStatuses(initialStates.cellStatuses) + setCurrentRow(initialStates.currentRow) + setCurrentCol(initialStates.currentCol) + setLetterStatuses(initialStates.letterStatuses()) + setSubmittedInvalidWord(initialStates.submittedInvalidWord) + setExactGuesses({}) + + closeModal() + } + + const modalStyles = { + overlay: { + position: 'fixed', + top: 0, + left: 0, + right: 0, + bottom: 0, + backgroundColor: darkMode ? 'hsl(231, 16%, 25%)' : 'hsl(231, 16%, 92%)', + zIndex: 99, + }, + content: { + top: '50%', + left: '50%', + right: 'auto', + bottom: 'auto', + transform: 'translate(-50%, -50%)', + height: 'calc(100% - 2rem)', + width: 'calc(100% - 2rem)', + backgroundColor: darkMode ? 'hsl(231, 16%, 25%)' : 'hsl(231, 16%, 92%)', + boxShadow: `${ + darkMode + ? '0.2em 0.2em calc(0.2em * 2) #252834, calc(0.2em * -1) calc(0.2em * -1) calc(0.2em * 2) #43475C' + : '0.2em 0.2em calc(0.2em * 2) #A3A7BD, calc(0.2em * -1) calc(0.2em * -1) calc(0.2em * 2) #FFFFFF' + }`, + border: 'none', + borderRadius: '1rem', + maxWidth: '475px', + maxHeight: '650px', + position: 'relative', + }, + } + + return ( +