fuzzyworld

word-waltz / files / src / components / WordleBoard.ts

WordleBoard.ts

7.2 KB · history · blame

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
8import { mountKeyboard, updateKeyboard } from './Keyboard.js';
9import type { LetterState } from './Keyboard.js';
10import { isValidWord } from '../utils/wordList.js';
11import { calculateScore } from '../utils/scoring.js';
12
13interface RoundResult {
14 solved: boolean;
15 guesses: string[];
16 score: number;
17}
18
19interface WordleBoardProps {
20 secretWord: string;
21 onRoundEnd: (result: RoundResult) => void;
22}
23
24const MAX_GUESSES = 6;
25const WORD_LEN = 5;
26const PRAISE = ['Genius!', 'Magnificent!', 'Impressive!', 'Splendid!', 'Great!', 'Lucky!'];
27
28type TileState = LetterState | 'filled' | '';
29
30const STATE_PRIORITY: Record<LetterState, number> = { correct: 3, present: 2, absent: 1 };
31
32function 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
56function mergeLetterState(cur: LetterState | undefined, next: LetterState): LetterState {
57 if (!cur) return next;
58 return STATE_PRIORITY[next] > STATE_PRIORITY[cur] ? next : cur;
59}
60
61export 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}