9,308 bytes View directory
1 <script lang="ts">
2 import { enhance } from '$app/forms';
3 import { resolve } from '$app/paths';
4 import { confetti } from '@neoconfetti/svelte';
5 import { MediaQuery } from 'svelte/reactivity';
6
7 import type { PageProps } from './$types';
8
9 let { data }: PageProps = $props();
11 /** Whether the user prefers reduced motion */
12 const reducedMotion = new MediaQuery('(prefers-reduced-motion: reduce)');
14 let shake = $state(false);
16 /** Whether or not the user has won */
17 let won = $derived(data.answers.at(-1) === 'xxxxx');
19 /** The index of the current guess */
20 let i = $derived(won ? -1 : data.answers.length);
22 /** The current guess */
23 let currentGuess = $derived(data.guesses[i] || '');
25 /** Whether the current guess can be submitted */
26 let submittable = $derived(currentGuess.length === 5);
28 const { classnames, description } = $derived.by(() => {
29 /**
30 * A map of classnames for all letters that have been guessed,
31 * used for styling the keyboard
32 */
33 let classnames: Record<string, 'exact' | 'close' | 'missing'> = {};
34 /**
35 * A map of descriptions for all letters that have been guessed,
36 * used for adding text for assistive technology (e.g. screen readers)
37 */
38 let description: Record<string, string> = {};
39 data.answers.forEach((answer, i) => {
40 const guess = data.guesses[i];
41 for (let i = 0; i < 5; i += 1) {
42 const letter = guess[i];
43 if (answer[i] === 'x') {
44 classnames[letter] = 'exact';
45 description[letter] = 'correct';
46 } else if (!classnames[letter]) {
47 classnames[letter] = answer[i] === 'c' ? 'close' : 'missing';
48 description[letter] = answer[i] === 'c' ? 'present' : 'absent';
49 }
50 }
51 });
52 return { classnames, description };
53 });
55 /**
56 * Modify the game state without making a trip to the server,
57 * if client-side JavaScript is enabled
58 */
59 function update(event: MouseEvent) {
60 event.preventDefault();
61 const key = (event.target as HTMLButtonElement).getAttribute(
62 'data-key'
63 );
65 if (key === 'backspace') {
66 currentGuess = currentGuess.slice(0, -1);
67 shake = false;
68 } else if (currentGuess.length < 5) {
69 currentGuess += key;
70 }
71 }
73 /**
74 * Trigger form logic in response to a keydown event, so that
75 * desktop users can use the keyboard to play the game
76 */
77 function keydown(event: KeyboardEvent) {
78 if (event.metaKey) return;
80 if (event.key === 'Enter' && !submittable) return;
82 document
83 .querySelector(`[data-key="${event.key}" i]`)
84 ?.dispatchEvent(new MouseEvent('click', { cancelable: true, bubbles: true }));
85 }
86 </script>
88 <svelte:window onkeydown={keydown} />
90 <svelte:head>
91 <title>Sverdle</title>
92 <meta name="description" content="A Wordle clone written in SvelteKit" />
93 </svelte:head>
95 <h1 class="visually-hidden">Sverdle</h1>
97 <form
98 method="post"
99 action="?/enter"
100 use:enhance={() => {
101 // prevent default callback from resetting the form
102 return ({ result, update }) => {
103 shake = result.type === 'failure';
104 update({ reset: false });
105 };
106 }}
107 >
108 <a class="how-to-play" href={resolve('/sverdle/how-to-play')}>How to play</a>
110 <div class="grid" class:playing={!won} class:shake onanimationend={() => (shake = false)}>
111 {#each Array.from(Array(6).keys()) as row (row)}
112 {const current = $derived(row === i)}
114 <h2 class="visually-hidden">Row {row + 1}</h2>
115 <div class="row" class:current>
116 {#each Array.from(Array(5).keys()) as column (column)}
117 {const guess = $derived(current ? currentGuess : data.guesses[row])}
118 {const answer = $derived(data.answers[row]?.[column])}
119 {const value = $derived(guess?.[column] ?? '')}
120 {const selected = $derived(current && column === guess.length)}
121 {const exact = $derived(answer === 'x')}
122 {const close = $derived(answer === 'c')}
123 {const missing = $derived(answer === '_')}
125 <div class="letter" class:exact class:close class:missing class:selected>
126 {value}
127 <span class="visually-hidden">
128 {#if exact}
129 (correct)
130 {:else if close}
131 (present)
132 {:else if missing}
133 (absent)
134 {:else}
135 empty
136 {/if}
137 </span>
138 <input name="guess" disabled={!current} type="hidden" {value} />
139 </div>
140 {/each}
141 </div>
142 {/each}
143 </div>
145 <div class="controls">
146 {#if won || data.answers.length >= 6}
147 {#if !won && data.answer}
148 <p>the answer was "{data.answer}"</p>
149 {/if}
150 <button data-key="enter" class="restart selected" formaction="?/restart">
151 {won ? 'you won :)' : `game over :(`} play again?
152 </button>
153 {:else}
154 <div class="keyboard">
155 <button data-key="enter" class:selected={submittable} disabled={!submittable}>enter</button>
157 <button
158 onclick={update}
159 data-key="backspace"
160 formaction="?/update"
161 name="key"
162 value="backspace"
163 >
164 back
165 </button>
167 {#each ['qwertyuiop', 'asdfghjkl', 'zxcvbnm'] as row (row)}
168 <div class="row">
169 {#each row as letter, index (index)}
170 <button
171 onclick={update}
172 data-key={letter}
173 class={classnames[letter]}
174 disabled={submittable}
175 formaction="?/update"
176 name="key"
177 value={letter}
178 aria-label="{letter} {description[letter] || ''}"
179 >
180 {letter}
181 </button>
182 {/each}
183 </div>
184 {/each}
185 </div>
186 {/if}
187 </div>
188 </form>
190 {#if won}
191 <div
192 style="position: absolute; left: 50%; top: 30%"
193 use:confetti={{
194 particleCount: reducedMotion.current ? 0 : undefined,
195 force: 0.7,
196 stageWidth: window.innerWidth,
197 stageHeight: window.innerHeight,
198 colors: ['#ff3e00', '#40b3ff', '#676778']
199 }}
200 ></div>
201 {/if}
203 <style>
204 form {
205 width: 100%;
206 height: 100%;
207 display: flex;
208 flex-direction: column;
209 align-items: center;
210 justify-content: center;
211 gap: 1rem;
212 flex: 1;
213 }
215 .how-to-play {
216 color: var(--color-text);
217 }
219 .how-to-play::before {
220 content: 'i';
221 display: inline-block;
222 font-size: 0.8em;
223 font-weight: 900;
224 width: 1em;
225 height: 1em;
226 padding: 0.2em;
227 line-height: 1;
228 border: 1.5px solid var(--color-text);
229 border-radius: 50%;
230 text-align: center;
231 margin: 0 0.5em 0 0;
232 position: relative;
233 top: -0.05em;
234 }
236 .grid {
237 --width: min(100vw, 40vh, 380px);
238 max-width: var(--width);
239 align-self: center;
240 justify-self: center;
241 width: 100%;
242 height: 100%;
243 display: flex;
244 flex-direction: column;
245 justify-content: flex-start;
246 }
248 .grid .row {
249 display: grid;
250 grid-template-columns: repeat(5, 1fr);
251 grid-gap: 0.2rem;
252 margin: 0 0 0.2rem 0;
253 }
255 @media (prefers-reduced-motion: no-preference) {
256 .grid.shake .row.current {
257 animation: wiggle 0.5s;
258 }
259 }
261 .grid.playing .row.current {
262 filter: drop-shadow(3px 3px 10px var(--color-bg-0));
263 }
265 .letter {
266 aspect-ratio: 1;
267 width: 100%;
268 display: flex;
269 align-items: center;
270 justify-content: center;
271 text-align: center;
272 box-sizing: border-box;
273 text-transform: lowercase;
274 border: none;
275 font-size: calc(0.08 * var(--width));
276 border-radius: 2px;
277 background: white;
278 margin: 0;
279 color: rgba(0, 0, 0, 0.7);
280 }
282 .letter.missing {
283 background: rgba(255, 255, 255, 0.5);
284 color: rgba(0, 0, 0, 0.5);
285 }
287 .letter.exact {
288 background: var(--color-theme-2);
289 color: white;
290 }
292 .letter.close {
293 border: 2px solid var(--color-theme-2);
294 }
296 .selected {
297 outline: 2px solid var(--color-theme-1);
298 }
300 .controls {
301 text-align: center;
302 justify-content: center;
303 height: min(18vh, 10rem);
304 }
306 .keyboard {
307 --gap: 0.2rem;
308 position: relative;
309 display: flex;
310 flex-direction: column;
311 gap: var(--gap);
312 height: 100%;
313 }
315 .keyboard .row {
316 display: flex;
317 justify-content: center;
318 gap: 0.2rem;
319 flex: 1;
320 }
322 .keyboard button,
323 .keyboard button:disabled {
324 --size: min(8vw, 4vh, 40px);
325 background-color: white;
326 color: black;
327 width: var(--size);
328 border: none;
329 border-radius: 2px;
330 font-size: calc(var(--size) * 0.5);
331 margin: 0;
332 }
334 .keyboard button.exact {
335 background: var(--color-theme-2);
336 color: white;
337 }
339 .keyboard button.missing {
340 opacity: 0.5;
341 }
343 .keyboard button.close {
344 border: 2px solid var(--color-theme-2);
345 }
347 .keyboard button:focus {
348 background: var(--color-theme-1);
349 color: white;
350 outline: none;
351 }
353 .keyboard button[data-key='enter'],
354 .keyboard button[data-key='backspace'] {
355 position: absolute;
356 bottom: 0;
357 width: calc(1.5 * var(--size));
358 height: calc(1 / 3 * (100% - 2 * var(--gap)));
359 text-transform: uppercase;
360 font-size: calc(0.3 * var(--size));
361 padding-top: calc(0.15 * var(--size));
362 }
364 .keyboard button[data-key='enter'] {
365 right: calc(50% + 3.5 * var(--size) + 0.8rem);
366 }
368 .keyboard button[data-key='backspace'] {
369 left: calc(50% + 3.5 * var(--size) + 0.8rem);
370 }
372 .keyboard button[data-key='enter']:disabled {
373 opacity: 0.5;
374 }
376 .restart {
377 width: 100%;
378 padding: 1rem;
379 background: rgba(255, 255, 255, 0.5);
380 border-radius: 2px;
381 border: none;
382 }
384 .restart:focus,
385 .restart:hover {
386 background: var(--color-theme-1);
387 color: white;
388 outline: none;
389 }
391 @keyframes wiggle {
392 0% {
393 transform: translateX(0);
394 }
395 10% {
396 transform: translateX(-2px);
397 }
398 30% {
399 transform: translateX(4px);
400 }
401 50% {
402 transform: translateX(-6px);
403 }
404 70% {
405 transform: translateX(+4px);
406 }
407 90% {
408 transform: translateX(-2px);
409 }
410 100% {
411 transform: translateX(0);
412 }
413 }
414 </style>