Mercurial
comparison 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 |
comparison
equal
deleted
inserted
replaced
| 36:84672efec192 | 37:fb9bcd3145cb |
|---|---|
| 1 import { CSSProperties, useCallback, useEffect, useReducer } from 'react'; | |
| 2 | |
| 3 /** | |
| 4 * Question: | |
| 5 | |
| 6 * 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. | |
| 7 * | |
| 8 * Requirements: | |
| 9 * | |
| 10 * Choose a fixed 5-letter target word in your code. | |
| 11 * | |
| 12 * Render a grid showing guesses made and remaining attempts. | |
| 13 * | |
| 14 * Accept input from the user (keyboard or on-screen). | |
| 15 * | |
| 16 * Color letters according to correctness after each guess. | |
| 17 * | |
| 18 * Display a win or lose message at the end of the game. | |
| 19 */ | |
| 20 | |
| 21 const MAX_WORD_LEN = 5; | |
| 22 const MAX_ATTEMP_LEN = 6; | |
| 23 | |
| 24 interface Position { | |
| 25 letterPos: number; | |
| 26 attempPos: number; | |
| 27 } | |
| 28 | |
| 29 enum WordStatus { | |
| 30 CORRECT_IN_POSITION, | |
| 31 CORRECT_IN_WRONG_POSITION, | |
| 32 DEFAULT, | |
| 33 } | |
| 34 | |
| 35 interface Letter { | |
| 36 value: string; | |
| 37 status: WordStatus; | |
| 38 } | |
| 39 | |
| 40 type Word = Letter[] | |
| 41 type Board = Word[]; | |
| 42 | |
| 43 enum GameStatus { | |
| 44 PLAYABLE="Try to guess the word!", | |
| 45 WON="You won!", | |
| 46 LOST="You Lost!", | |
| 47 } | |
| 48 | |
| 49 interface GameState { | |
| 50 board: Board; | |
| 51 targetWord: string[]; | |
| 52 position: Position; | |
| 53 status: GameStatus; | |
| 54 } | |
| 55 | |
| 56 const constructGameState = (): GameState => { | |
| 57 const board: Board = Array.from( | |
| 58 { length: MAX_ATTEMP_LEN }, () => { | |
| 59 return Array.from({ length: MAX_WORD_LEN }, () => { | |
| 60 return { | |
| 61 value: "", | |
| 62 status: WordStatus.DEFAULT, | |
| 63 } | |
| 64 }) | |
| 65 } | |
| 66 ); | |
| 67 // dummy will get replace when it hits db | |
| 68 const targetWord: string[] = "hello".split(''); | |
| 69 const position: Position = { | |
| 70 letterPos: 0, | |
| 71 attempPos: 0, | |
| 72 } | |
| 73 return { | |
| 74 board, | |
| 75 status: GameStatus.PLAYABLE, | |
| 76 targetWord, | |
| 77 position, | |
| 78 } | |
| 79 } | |
| 80 | |
| 81 interface LetterComponentProp { | |
| 82 letter: Letter; | |
| 83 } | |
| 84 | |
| 85 interface StyleProp { | |
| 86 board: CSSProperties; | |
| 87 letter: (wordStatus: WordStatus) => CSSProperties; | |
| 88 } | |
| 89 | |
| 90 const styles: StyleProp = { | |
| 91 board: { | |
| 92 display: "grid", | |
| 93 gridTemplateColumns: `repeat(${MAX_WORD_LEN}, 1fr)`, | |
| 94 width: 400, | |
| 95 gap: 8, | |
| 96 }, | |
| 97 letter: (wordStatus: WordStatus): CSSProperties => { | |
| 98 let backgroundColor: string = "#e6e6e6"; | |
| 99 switch(wordStatus) { | |
| 100 case WordStatus.CORRECT_IN_POSITION: | |
| 101 backgroundColor = "green"; | |
| 102 break; | |
| 103 case WordStatus.CORRECT_IN_WRONG_POSITION: | |
| 104 backgroundColor = "yellow"; | |
| 105 break; | |
| 106 case WordStatus.DEFAULT: | |
| 107 break; | |
| 108 } | |
| 109 return { | |
| 110 display: "flex", | |
| 111 justifyContent: "center", | |
| 112 alignItems: "center", | |
| 113 backgroundColor, | |
| 114 width: "100%", | |
| 115 aspectRatio: "1 / 1", | |
| 116 border: "1px solid black", | |
| 117 } | |
| 118 } | |
| 119 } | |
| 120 | |
| 121 const LetterComponent = ({letter}: LetterComponentProp) => { | |
| 122 return ( | |
| 123 <div style={styles.letter(letter.status)}> | |
| 124 {letter.value} | |
| 125 </div> | |
| 126 ) | |
| 127 } | |
| 128 | |
| 129 enum GameActionEnum { | |
| 130 INITIALZE, | |
| 131 PLACE, | |
| 132 }; | |
| 133 | |
| 134 type GameAction = | |
| 135 | { type: GameActionEnum.PLACE, character: string } | |
| 136 | { type: GameActionEnum.INITIALZE, targetWord: string }; | |
| 137 | |
| 138 const gameStateReducer = (state: GameState, action: GameAction): GameState => { | |
| 139 if (state.status != GameStatus.PLAYABLE) return state; | |
| 140 | |
| 141 switch(action.type) { | |
| 142 case GameActionEnum.INITIALZE: | |
| 143 return { | |
| 144 ...state, | |
| 145 targetWord: action.targetWord.split(''), | |
| 146 }; | |
| 147 case GameActionEnum.PLACE: | |
| 148 const { character } = action; | |
| 149 let newBoard = state.board.map( | |
| 150 (word, attempIdx) => attempIdx === state.position.attempPos ? | |
| 151 word.map( | |
| 152 (letter, letterIdx) => letterIdx === state.position.letterPos ? | |
| 153 {...letter, value: character} : letter) : word); | |
| 154 const newPosition = calculatePosition(state.position); | |
| 155 let newStatus: GameStatus = state.status; | |
| 156 if (newPosition.letterPos === 0) { | |
| 157 const {correctLetterIdxes, correctWrongPositionLetterIndex} = gameLogic(newBoard[state.position.attempPos], state.targetWord); | |
| 158 newBoard = newBoard.map( | |
| 159 (word, attempIdx) => attempIdx === state.position.attempPos ? | |
| 160 word.map( | |
| 161 (letter, letterIdx) => correctLetterIdxes.some((value) => value === letterIdx) ? | |
| 162 {...letter, status: WordStatus.CORRECT_IN_POSITION } : | |
| 163 (correctWrongPositionLetterIndex.some((value) => value === letterIdx) ? | |
| 164 {...letter, status: WordStatus.CORRECT_IN_WRONG_POSITION} : letter) | |
| 165 ) | |
| 166 : word); | |
| 167 newStatus = correctLetterIdxes.length === MAX_WORD_LEN ? | |
| 168 GameStatus.WON : ( | |
| 169 state.position.attempPos + 1 === MAX_ATTEMP_LEN ? | |
| 170 GameStatus.LOST : GameStatus.PLAYABLE | |
| 171 ); | |
| 172 } | |
| 173 return { | |
| 174 ...state, | |
| 175 board: newBoard, | |
| 176 position: newPosition, | |
| 177 status: newStatus, | |
| 178 }; | |
| 179 } | |
| 180 } | |
| 181 | |
| 182 function gameLogic(word: Word, targetWord: string[]) { | |
| 183 const map: Record<string, number> = {}; | |
| 184 const correctLetterIdxes: number[] = [] | |
| 185 const correctWrongPositionLetterIndex: number[] = [] | |
| 186 for (let i = 0; i < MAX_WORD_LEN; i++) { | |
| 187 map[targetWord[i]] ? map[targetWord[i]]++ : map[targetWord[i]] = 1; | |
| 188 } | |
| 189 for (let i = 0; i < MAX_WORD_LEN; i++) { | |
| 190 if (word[i].value === targetWord[i]) { | |
| 191 correctLetterIdxes.push(i); | |
| 192 map[targetWord[i]]--; | |
| 193 } | |
| 194 } | |
| 195 for (let i = 0; i < MAX_WORD_LEN; i++) { | |
| 196 if (correctLetterIdxes.some((value) => value===i)) continue; | |
| 197 if ( | |
| 198 targetWord.some((value) => value===word[i].value && map[value] > 0) | |
| 199 ) { | |
| 200 correctWrongPositionLetterIndex.push(i); | |
| 201 map[word[i].value]--; | |
| 202 } | |
| 203 } | |
| 204 return { | |
| 205 correctLetterIdxes, | |
| 206 correctWrongPositionLetterIndex, | |
| 207 } | |
| 208 } | |
| 209 | |
| 210 function calculatePosition(position: Position): Position { | |
| 211 const letterPos = (position.letterPos + 1) % MAX_WORD_LEN; | |
| 212 const attempPos = ( | |
| 213 (position.letterPos + 1) === MAX_WORD_LEN ? | |
| 214 position.attempPos + 1 : position.attempPos) % MAX_ATTEMP_LEN; | |
| 215 return { | |
| 216 letterPos, | |
| 217 attempPos, | |
| 218 } | |
| 219 } | |
| 220 | |
| 221 const Wordle = () => { | |
| 222 const [gameState, gameDispatch] = useReducer(gameStateReducer, null, constructGameState); | |
| 223 | |
| 224 useEffect(() => { | |
| 225 (async() => { | |
| 226 const res = await fetch('/api/v1/wordle'); | |
| 227 if (!res.ok) return; | |
| 228 const response = await res.json(); | |
| 229 gameDispatch({ type: GameActionEnum.INITIALZE, targetWord: response.targetWord }); | |
| 230 })(); | |
| 231 }, []); | |
| 232 | |
| 233 const placeLetters = useCallback((event: KeyboardEvent) => { | |
| 234 const letters = event.key.trim().toLowerCase(); | |
| 235 const regex = new RegExp('[a-z]'); | |
| 236 if ( | |
| 237 !regex.exec(letters) || | |
| 238 letters === "enter" | |
| 239 ) { | |
| 240 return; | |
| 241 } | |
| 242 gameDispatch({ type: GameActionEnum.PLACE, character: event.key.trim() }) | |
| 243 }, [gameDispatch]); | |
| 244 | |
| 245 useEffect(() => { | |
| 246 document.addEventListener("keypress", placeLetters) | |
| 247 }, []) | |
| 248 | |
| 249 return ( | |
| 250 <> | |
| 251 <h1> Wordle </h1> | |
| 252 <h2> {gameState.status} </h2> | |
| 253 <div style={styles.board}> | |
| 254 {gameState.board.map( | |
| 255 (word, wordIdx) => word.map( | |
| 256 (letter, letterIdx) => ( | |
| 257 <LetterComponent key={`${wordIdx}-${letterIdx}`} letter={letter} /> | |
| 258 )) | |
| 259 )} | |
| 260 </div> | |
| 261 </> | |
| 262 ); | |
| 263 } | |
| 264 | |
| 265 export { | |
| 266 Wordle, | |
| 267 } |