word-waltz / files / src / components / WordleBoard.ts
WordleBoard.ts
| 1 | /** |
| 2 | * WordleBoard — 6×5 guess grid |
| 3 | * |
| 4 | * Renders the guess grid and handles letter input for the solver. |
| 5 | * Each cell is colored based on tile state: 'correct', 'present', or 'absent'. |
| 6 | */ |
| 7 | |
| 8 | import { mountKeyboard, updateKeyboard } from './Keyboard.js'; |
| 9 | import type { LetterState } from './Keyboard.js'; |
| 10 | import { isValidWord } from '../utils/wordList.js'; |
| 11 | import { calculateScore } from '../utils/scoring.js'; |
| 12 | |
| 13 | interface RoundResult { |
| 14 | solved: boolean; |
| 15 | guesses: string[]; |
| 16 | score: number; |
| 17 | } |
| 18 | |
| 19 | interface WordleBoardProps { |
| 20 | secretWord: string; |
| 21 | onRoundEnd: (result: RoundResult) => void; |
| 22 | } |
| 23 | |
| 24 | const MAX_GUESSES = 6; |
| 25 | const WORD_LEN = 5; |
| 26 | const PRAISE = ['Genius!', 'Magnificent!', 'Impressive!', 'Splendid!', 'Great!', 'Lucky!']; |
| 27 | |
| 28 | type TileState = LetterState | 'filled' | ''; |
| 29 | |
| 30 | const STATE_PRIORITY: Record<LetterState, number> = { correct: 3, present: 2, absent: 1 }; |
| 31 | |
| 32 | function evaluate(guess: string, secret: string): TileState[] { |
| 33 | const result: TileState[] = Array(WORD_LEN).fill('absent'); |
| 34 | const remaining = secret.split(''); |
| 35 | |
| 36 | // Pass 1 — exact matches |
| 37 | for (let i = 0; i < WORD_LEN; i++) { |
| 38 | if (guess[i] === secret[i]) { |
| 39 | result[i] = 'correct'; |
| 40 | remaining[i] = ''; |
| 41 | } |
| 42 | } |
| 43 | |
| 44 | // Pass 2 — right letter, wrong position |
| 45 | for (let i = 0; i < WORD_LEN; i++) { |
| 46 | if (result[i] === 'correct') continue; |
| 47 | const idx = remaining.indexOf(guess[i]); |
| 48 | if (idx !== -1) { |
| 49 | result[i] = 'present'; |
| 50 | remaining[idx] = ''; |
| 51 | } |
| 52 | } |
| 53 | return result; |
| 54 | } |
| 55 | |
| 56 | function mergeLetterState(cur: LetterState | undefined, next: LetterState): LetterState { |
| 57 | if (!cur) return next; |
| 58 | return STATE_PRIORITY[next] > STATE_PRIORITY[cur] ? next : cur; |
| 59 | } |
| 60 | |
| 61 | export function mountWordleBoard(container: HTMLElement, props: WordleBoardProps): () => void { |
| 62 | const { secretWord, onRoundEnd } = props; |
| 63 | const secret = secretWord.toUpperCase(); |
| 64 | |
| 65 | // --- DOM --- |
| 66 | const root = document.createElement('div'); |
| 67 | root.className = 'wordle-board'; |
| 68 | |
| 69 | const grid = document.createElement('div'); |
| 70 | grid.className = 'wordle-board__grid'; |
| 71 | |
| 72 | // Build 6×5 tile cells |
| 73 | const tiles: HTMLElement[][] = []; |
| 74 | for (let r = 0; r < MAX_GUESSES; r++) { |
| 75 | const row = document.createElement('div'); |
| 76 | row.className = 'wordle-board__row'; |
| 77 | const rowTiles: HTMLElement[] = []; |
| 78 | for (let c = 0; c < WORD_LEN; c++) { |
| 79 | const tile = document.createElement('div'); |
| 80 | tile.className = 'wordle-board__tile'; |
| 81 | row.appendChild(tile); |
| 82 | rowTiles.push(tile); |
| 83 | } |
| 84 | grid.appendChild(row); |
| 85 | tiles.push(rowTiles); |
| 86 | } |
| 87 | |
| 88 | const message = document.createElement('p'); |
| 89 | message.className = 'wordle-board__message'; |
| 90 | |
| 91 | const giveUpBtn = document.createElement('button'); |
| 92 | giveUpBtn.type = 'button'; |
| 93 | giveUpBtn.className = 'wordle-board__give-up'; |
| 94 | giveUpBtn.textContent = 'Give up'; |
| 95 | |
| 96 | const keyboardEl = document.createElement('div'); |
| 97 | |
| 98 | root.appendChild(grid); |
| 99 | root.appendChild(message); |
| 100 | root.appendChild(giveUpBtn); |
| 101 | root.appendChild(keyboardEl); |
| 102 | container.appendChild(root); |
| 103 | |
| 104 | // --- State --- |
| 105 | let currentRow = 0; |
| 106 | let currentInput = ''; |
| 107 | let done = false; |
| 108 | const letterStates: Record<string, LetterState> = {}; |
| 109 | const guesses: string[] = []; |
| 110 | |
| 111 | giveUpBtn.addEventListener('click', () => { |
| 112 | if (done) return; |
| 113 | if (!confirm('Give up this round? The word will be revealed and you\'ll score 0.')) return; |
| 114 | done = true; |
| 115 | message.textContent = `The word was ${secret}`; |
| 116 | giveUpBtn.hidden = true; |
| 117 | onRoundEnd({ solved: false, guesses, score: 0 }); |
| 118 | }); |
| 119 | |
| 120 | // Mount keyboard — it sends keys back via onKey |
| 121 | const cleanupKeyboard = mountKeyboard(keyboardEl, { |
| 122 | onKey: handleKey, |
| 123 | letterStates, |
| 124 | }); |
| 125 | |
| 126 | // --- Input handling --- |
| 127 | function handleKey(key: string): void { |
| 128 | if (done) return; |
| 129 | if (key === 'Backspace') { |
| 130 | currentInput = currentInput.slice(0, -1); |
| 131 | redrawCurrentRow(); |
| 132 | } else if (key === 'Enter') { |
| 133 | submitGuess(); |
| 134 | } else if (/^[A-Z]$/.test(key) && currentInput.length < WORD_LEN) { |
| 135 | currentInput += key; |
| 136 | redrawCurrentRow(); |
| 137 | } |
| 138 | } |
| 139 | |
| 140 | function redrawCurrentRow(): void { |
| 141 | tiles[currentRow].forEach((tile, i) => { |
| 142 | const letter = currentInput[i] ?? ''; |
| 143 | tile.textContent = letter; |
| 144 | tile.className = 'wordle-board__tile' + (letter ? ' wordle-board__tile--filled' : ''); |
| 145 | }); |
| 146 | } |
| 147 | |
| 148 | // How long one tile flip takes, and the stagger between tiles. |
| 149 | const FLIP_MS = 500; |
| 150 | const STAGGER_MS = 100; |
| 151 | |
| 152 | // Reveal a row with staggered flips. Returns a promise that resolves when |
| 153 | // the last tile finishes so callers can sequence work afterward. |
| 154 | function revealRow( |
| 155 | rowTiles: HTMLElement[], |
| 156 | guess: string, |
| 157 | states: TileState[], |
| 158 | ): Promise<void> { |
| 159 | return new Promise((resolve) => { |
| 160 | rowTiles.forEach((tile, i) => { |
| 161 | const delay = i * STAGGER_MS; |
| 162 | // Start flip |
| 163 | setTimeout(() => { |
| 164 | tile.classList.add('wordle-board__tile--flipping'); |
| 165 | // At the halfway point, swap in the color |
| 166 | setTimeout(() => { |
| 167 | tile.className = `wordle-board__tile wordle-board__tile--${states[i]} wordle-board__tile--flipping`; |
| 168 | }, FLIP_MS / 2); |
| 169 | // When animation ends, remove the flipping class |
| 170 | tile.addEventListener('animationend', () => { |
| 171 | tile.classList.remove('wordle-board__tile--flipping'); |
| 172 | if (i === rowTiles.length - 1) resolve(); |
| 173 | }, { once: true }); |
| 174 | }, delay); |
| 175 | }); |
| 176 | }); |
| 177 | } |
| 178 | |
| 179 | function submitGuess(): void { |
| 180 | if (currentInput.length !== WORD_LEN) { |
| 181 | flash('Not enough letters'); |
| 182 | return; |
| 183 | } |
| 184 | const guess = currentInput; |
| 185 | if (!isValidWord(guess)) { |
| 186 | flash('Not in word list'); |
| 187 | return; |
| 188 | } |
| 189 | |
| 190 | const states = evaluate(guess, secret); |
| 191 | const row = tiles[currentRow]; |
| 192 | |
| 193 | // Lock input during animation |
| 194 | done = true; |
| 195 | currentInput = ''; |
| 196 | guesses.push(guess); |
| 197 | currentRow++; |
| 198 | |
| 199 | const solved = states.every((s) => s === 'correct'); |
| 200 | |
| 201 | revealRow(row, guess, states).then(() => { |
| 202 | // Merge letter states for keyboard only after all tiles have flipped |
| 203 | states.forEach((s, i) => { |
| 204 | if (s === 'correct' || s === 'present' || s === 'absent') { |
| 205 | letterStates[guess[i]] = mergeLetterState(letterStates[guess[i]], s); |
| 206 | } |
| 207 | }); |
| 208 | updateKeyboard(keyboardEl, letterStates); |
| 209 | |
| 210 | if (solved) { |
| 211 | giveUpBtn.hidden = true; |
| 212 | message.textContent = PRAISE[Math.min(guesses.length - 1, PRAISE.length - 1)]; |
| 213 | scheduleRoundEnd(true); |
| 214 | } else if (currentRow >= MAX_GUESSES) { |
| 215 | giveUpBtn.hidden = true; |
| 216 | message.textContent = `The word was ${secret}`; |
| 217 | scheduleRoundEnd(false); |
| 218 | } else { |
| 219 | // Allow input again for the next row |
| 220 | done = false; |
| 221 | } |
| 222 | }); |
| 223 | } |
| 224 | |
| 225 | function flash(text: string): void { |
| 226 | message.textContent = text; |
| 227 | setTimeout(() => { message.textContent = ''; }, 1200); |
| 228 | } |
| 229 | |
| 230 | let endTimer: ReturnType<typeof setTimeout> | null = null; |
| 231 | let destroyed = false; |
| 232 | |
| 233 | function scheduleRoundEnd(solved: boolean): void { |
| 234 | endTimer = setTimeout(() => { |
| 235 | if (!destroyed) { |
| 236 | onRoundEnd({ |
| 237 | solved, |
| 238 | guesses, |
| 239 | score: calculateScore(guesses.length, solved), |
| 240 | }); |
| 241 | } |
| 242 | }, 1600); |
| 243 | } |
| 244 | |
| 245 | return function cleanup() { |
| 246 | destroyed = true; |
| 247 | if (endTimer !== null) clearTimeout(endTimer); |
| 248 | cleanupKeyboard(); |
| 249 | container.removeChild(root); |
| 250 | }; |
| 251 | } |