Mercurial
diff react_games/src/Wordle/main.tsx @ 37:fb9bcd3145cb
[ReactGames] Few games I made using react just to practice few things.
| author | MrJuneJune <me@mrjunejune.com> |
|---|---|
| date | Mon, 01 Dec 2025 20:22:47 -0800 |
| parents | |
| children |
line wrap: on
line diff
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/react_games/src/Wordle/main.tsx Mon Dec 01 20:22:47 2025 -0800 @@ -0,0 +1,267 @@ +import { CSSProperties, useCallback, useEffect, useReducer } from 'react'; + +/** + * Question: + + * Create a playable Wordle game in the browser using React within one hour. The game should allow the player to guess a hidden 5-letter word in at most 6 tries. After each guess, display feedback for each letter: correct letter and position (green), correct letter wrong position (yellow), or not in the word (gray). The game ends when the player guesses the word or runs out of tries. + * + * Requirements: + * + * Choose a fixed 5-letter target word in your code. + * + * Render a grid showing guesses made and remaining attempts. + * + * Accept input from the user (keyboard or on-screen). + * + * Color letters according to correctness after each guess. + * + * Display a win or lose message at the end of the game. + */ + +const MAX_WORD_LEN = 5; +const MAX_ATTEMP_LEN = 6; + +interface Position { + letterPos: number; + attempPos: number; +} + +enum WordStatus { + CORRECT_IN_POSITION, + CORRECT_IN_WRONG_POSITION, + DEFAULT, +} + +interface Letter { + value: string; + status: WordStatus; +} + +type Word = Letter[] +type Board = Word[]; + +enum GameStatus { + PLAYABLE="Try to guess the word!", + WON="You won!", + LOST="You Lost!", +} + +interface GameState { + board: Board; + targetWord: string[]; + position: Position; + status: GameStatus; +} + +const constructGameState = (): GameState => { + const board: Board = Array.from( + { length: MAX_ATTEMP_LEN }, () => { + return Array.from({ length: MAX_WORD_LEN }, () => { + return { + value: "", + status: WordStatus.DEFAULT, + } + }) + } + ); + // dummy will get replace when it hits db + const targetWord: string[] = "hello".split(''); + const position: Position = { + letterPos: 0, + attempPos: 0, + } + return { + board, + status: GameStatus.PLAYABLE, + targetWord, + position, + } +} + +interface LetterComponentProp { + letter: Letter; +} + +interface StyleProp { + board: CSSProperties; + letter: (wordStatus: WordStatus) => CSSProperties; +} + +const styles: StyleProp = { + board: { + display: "grid", + gridTemplateColumns: `repeat(${MAX_WORD_LEN}, 1fr)`, + width: 400, + gap: 8, + }, + letter: (wordStatus: WordStatus): CSSProperties => { + let backgroundColor: string = "#e6e6e6"; + switch(wordStatus) { + case WordStatus.CORRECT_IN_POSITION: + backgroundColor = "green"; + break; + case WordStatus.CORRECT_IN_WRONG_POSITION: + backgroundColor = "yellow"; + break; + case WordStatus.DEFAULT: + break; + } + return { + display: "flex", + justifyContent: "center", + alignItems: "center", + backgroundColor, + width: "100%", + aspectRatio: "1 / 1", + border: "1px solid black", + } + } +} + +const LetterComponent = ({letter}: LetterComponentProp) => { + return ( + <div style={styles.letter(letter.status)}> + {letter.value} + </div> + ) +} + +enum GameActionEnum { + INITIALZE, + PLACE, +}; + +type GameAction = + | { type: GameActionEnum.PLACE, character: string } + | { type: GameActionEnum.INITIALZE, targetWord: string }; + +const gameStateReducer = (state: GameState, action: GameAction): GameState => { + if (state.status != GameStatus.PLAYABLE) return state; + + switch(action.type) { + case GameActionEnum.INITIALZE: + return { + ...state, + targetWord: action.targetWord.split(''), + }; + case GameActionEnum.PLACE: + const { character } = action; + let newBoard = state.board.map( + (word, attempIdx) => attempIdx === state.position.attempPos ? + word.map( + (letter, letterIdx) => letterIdx === state.position.letterPos ? + {...letter, value: character} : letter) : word); + const newPosition = calculatePosition(state.position); + let newStatus: GameStatus = state.status; + if (newPosition.letterPos === 0) { + const {correctLetterIdxes, correctWrongPositionLetterIndex} = gameLogic(newBoard[state.position.attempPos], state.targetWord); + newBoard = newBoard.map( + (word, attempIdx) => attempIdx === state.position.attempPos ? + word.map( + (letter, letterIdx) => correctLetterIdxes.some((value) => value === letterIdx) ? + {...letter, status: WordStatus.CORRECT_IN_POSITION } : + (correctWrongPositionLetterIndex.some((value) => value === letterIdx) ? + {...letter, status: WordStatus.CORRECT_IN_WRONG_POSITION} : letter) + ) + : word); + newStatus = correctLetterIdxes.length === MAX_WORD_LEN ? + GameStatus.WON : ( + state.position.attempPos + 1 === MAX_ATTEMP_LEN ? + GameStatus.LOST : GameStatus.PLAYABLE + ); + } + return { + ...state, + board: newBoard, + position: newPosition, + status: newStatus, + }; + } +} + +function gameLogic(word: Word, targetWord: string[]) { + const map: Record<string, number> = {}; + const correctLetterIdxes: number[] = [] + const correctWrongPositionLetterIndex: number[] = [] + for (let i = 0; i < MAX_WORD_LEN; i++) { + map[targetWord[i]] ? map[targetWord[i]]++ : map[targetWord[i]] = 1; + } + for (let i = 0; i < MAX_WORD_LEN; i++) { + if (word[i].value === targetWord[i]) { + correctLetterIdxes.push(i); + map[targetWord[i]]--; + } + } + for (let i = 0; i < MAX_WORD_LEN; i++) { + if (correctLetterIdxes.some((value) => value===i)) continue; + if ( + targetWord.some((value) => value===word[i].value && map[value] > 0) + ) { + correctWrongPositionLetterIndex.push(i); + map[word[i].value]--; + } + } + return { + correctLetterIdxes, + correctWrongPositionLetterIndex, + } +} + +function calculatePosition(position: Position): Position { + const letterPos = (position.letterPos + 1) % MAX_WORD_LEN; + const attempPos = ( + (position.letterPos + 1) === MAX_WORD_LEN ? + position.attempPos + 1 : position.attempPos) % MAX_ATTEMP_LEN; + return { + letterPos, + attempPos, + } +} + +const Wordle = () => { + const [gameState, gameDispatch] = useReducer(gameStateReducer, null, constructGameState); + + useEffect(() => { + (async() => { + const res = await fetch('/api/v1/wordle'); + if (!res.ok) return; + const response = await res.json(); + gameDispatch({ type: GameActionEnum.INITIALZE, targetWord: response.targetWord }); + })(); + }, []); + + const placeLetters = useCallback((event: KeyboardEvent) => { + const letters = event.key.trim().toLowerCase(); + const regex = new RegExp('[a-z]'); + if ( + !regex.exec(letters) || + letters === "enter" + ) { + return; + } + gameDispatch({ type: GameActionEnum.PLACE, character: event.key.trim() }) + }, [gameDispatch]); + + useEffect(() => { + document.addEventListener("keypress", placeLetters) + }, []) + + return ( + <> + <h1> Wordle </h1> + <h2> {gameState.status} </h2> + <div style={styles.board}> + {gameState.board.map( + (word, wordIdx) => word.map( + (letter, letterIdx) => ( + <LetterComponent key={`${wordIdx}-${letterIdx}`} letter={letter} /> + )) + )} + </div> + </> + ); +} + +export { + Wordle, +}