testing
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';7
import type { PageProps } from './$types';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 keyboard32
*/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 enabled58
*/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 that75
* desktop users can use the keyboard to play the game76
*/77
function keydown(event: KeyboardEvent) {78
if (event.metaKey) return;80
if (event.key === 'Enter' && !submittable) return;82
document83
.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
<form98
method="post"99
action="?/enter"100
use:enhance={() => {101
// prevent default callback from resetting the form102
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
empty136
{/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
<button158
onclick={update}159
data-key="backspace"160
formaction="?/update"161
name="key"162
value="backspace"163
>164
back165
</button>167
{#each ['qwertyuiop', 'asdfghjkl', 'zxcvbnm'] as row (row)}168
<div class="row">169
{#each row as letter, index (index)}170
<button171
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
<div192
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>