mirror of
https://github.com/tiennm99/loldle.git
synced 2026-09-05 12:17:07 +00:00
feat: rewrite components in Svelte 5 runes
Port all six React components. State becomes $state, memos become $derived, and the gameRef mirror that existed only to dodge a stale closure is gone -- runes read current values at call time, so one $state holds the game. Initialization moves to onMount rather than $effect: the page is prerendered, so loadChampions and localStorage must be browser-only, and onMount encodes that structurally instead of relying on the effect body happening to read no reactive state. champion-search becomes a real combobox/listbox: input carries the combobox role with aria-expanded and aria-activedescendant, options are list items wrapping buttons, and Escape closes the dropdown. Visual output is unchanged. next/image becomes plain img -- every usage already passed unoptimized.
This commit is contained in:
@@ -0,0 +1,122 @@
|
||||
<script>
|
||||
import { getChampionImageUrl, searchChampions } from "$lib/champion-data";
|
||||
|
||||
/** Autocomplete search input for champion selection */
|
||||
let { excludeNames, onSelect, disabled } = $props();
|
||||
|
||||
let query = $state("");
|
||||
let activeIndex = $state(-1);
|
||||
let isOpen = $state(false);
|
||||
// $state because the dropdown is conditionally mounted, so bind:this
|
||||
// reassigns these after the initial render.
|
||||
let inputEl = $state();
|
||||
let dropdownEl = $state();
|
||||
|
||||
let matches = $derived.by(() =>
|
||||
query.trim() ? searchChampions(query, excludeNames) : [],
|
||||
);
|
||||
|
||||
// Derived, never assigned from an effect — writing state that the same effect
|
||||
// reads is the classic runes loop.
|
||||
let isVisible = $derived(isOpen && matches.length > 0);
|
||||
|
||||
// Close dropdown on outside click. The element refs are read inside the
|
||||
// handler, not during setup, so this registers once like the React effect did.
|
||||
$effect(() => {
|
||||
function handleClick(e) {
|
||||
if (
|
||||
inputEl &&
|
||||
!inputEl.contains(e.target) &&
|
||||
dropdownEl &&
|
||||
!dropdownEl.contains(e.target)
|
||||
) {
|
||||
isOpen = false;
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleClick);
|
||||
return () => document.removeEventListener("mousedown", handleClick);
|
||||
});
|
||||
|
||||
function selectChampion(champion) {
|
||||
query = "";
|
||||
isOpen = false;
|
||||
activeIndex = -1;
|
||||
onSelect(champion);
|
||||
}
|
||||
|
||||
function handleKeyDown(e) {
|
||||
if (!isVisible || !matches.length) return;
|
||||
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
activeIndex = Math.min(activeIndex + 1, matches.length - 1);
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
activeIndex = Math.max(activeIndex - 1, 0);
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
if (activeIndex >= 0 && activeIndex < matches.length) {
|
||||
selectChampion(matches[activeIndex]);
|
||||
}
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
isOpen = false;
|
||||
activeIndex = -1;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="relative w-full max-w-[400px] mx-auto px-4">
|
||||
<input
|
||||
bind:this={inputEl}
|
||||
type="text"
|
||||
role="combobox"
|
||||
aria-expanded={isVisible}
|
||||
aria-controls="champion-listbox"
|
||||
aria-autocomplete="list"
|
||||
aria-activedescendant={activeIndex >= 0
|
||||
? `champion-option-${activeIndex}`
|
||||
: undefined}
|
||||
bind:value={query}
|
||||
oninput={() => {
|
||||
activeIndex = -1;
|
||||
isOpen = true;
|
||||
}}
|
||||
onkeydown={handleKeyDown}
|
||||
{disabled}
|
||||
placeholder={disabled ? "Game over" : "Type a champion name..."}
|
||||
autocomplete="off"
|
||||
class="w-full px-4 py-3 rounded-lg bg-[var(--color-input-bg)] border-2 border-[var(--color-input-border)] text-[var(--color-text)] text-base outline-none transition-colors focus:border-[var(--color-accent)] disabled:opacity-50 disabled:cursor-not-allowed placeholder:text-[var(--color-text-muted)]"
|
||||
/>
|
||||
|
||||
{#if isVisible}
|
||||
<ul
|
||||
bind:this={dropdownEl}
|
||||
id="champion-listbox"
|
||||
role="listbox"
|
||||
class="absolute top-full left-4 right-4 list-none m-0 p-0 bg-[var(--color-surface)] border border-[var(--color-input-border)] rounded-b-lg max-h-80 overflow-y-auto z-50"
|
||||
>
|
||||
{#each matches as champion, i (champion.id)}
|
||||
<li id="champion-option-{i}" role="option" aria-selected={i === activeIndex}>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => selectChampion(champion)}
|
||||
class="w-full text-left flex items-center gap-2.5 px-3 py-2 cursor-pointer transition-colors {i ===
|
||||
activeIndex
|
||||
? 'bg-[var(--color-surface-hover)]'
|
||||
: 'hover:bg-[var(--color-surface-hover)]'}"
|
||||
>
|
||||
<img
|
||||
src={getChampionImageUrl(champion.id)}
|
||||
alt={champion.name}
|
||||
width="36"
|
||||
height="36"
|
||||
class="rounded object-cover"
|
||||
/>
|
||||
<span class="text-sm">{champion.name}</span>
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,147 @@
|
||||
<script>
|
||||
import { onMount } from "svelte";
|
||||
import {
|
||||
getRandomChampion,
|
||||
getTodaySeed,
|
||||
loadChampions,
|
||||
} from "$lib/champion-data";
|
||||
import { compareChampions } from "$lib/classic-mode";
|
||||
import {
|
||||
clearExpiredCache,
|
||||
clearUnlimitedState,
|
||||
createGame,
|
||||
getOrCreateUnlimitedSeed,
|
||||
loadUnlimitedStats,
|
||||
saveUnlimitedStats,
|
||||
submitGuess,
|
||||
} from "$lib/game-engine";
|
||||
import ChampionSearch from "./champion-search.svelte";
|
||||
import GameOver from "./game-over.svelte";
|
||||
import GuessGrid from "./guess-grid.svelte";
|
||||
import StatsDisplay from "./stats-display.svelte";
|
||||
|
||||
let mode = $state("daily");
|
||||
let game = $state(null);
|
||||
let loading = $state(true);
|
||||
let error = $state(null);
|
||||
let stats = $state(null);
|
||||
|
||||
let excludeNames = $derived(game ? game.guesses.map((g) => g.name) : []);
|
||||
|
||||
function initGame(gameMode) {
|
||||
const seed =
|
||||
gameMode === "daily" ? getTodaySeed() : getOrCreateUnlimitedSeed();
|
||||
const target = getRandomChampion(seed);
|
||||
const maxGuesses = gameMode === "daily" ? 6 : 0;
|
||||
|
||||
game = createGame({
|
||||
target,
|
||||
compareFn: compareChampions,
|
||||
maxGuesses,
|
||||
mode: gameMode,
|
||||
seed,
|
||||
});
|
||||
stats = gameMode === "unlimited" ? loadUnlimitedStats() : null;
|
||||
}
|
||||
|
||||
// Browser-only and once-only by construction: the prerender pass has no
|
||||
// localStorage and no champions.json to fetch.
|
||||
onMount(async () => {
|
||||
try {
|
||||
await loadChampions();
|
||||
clearExpiredCache(getTodaySeed());
|
||||
initGame("daily");
|
||||
} catch (err) {
|
||||
error = err.message;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
});
|
||||
|
||||
function switchMode(newMode) {
|
||||
mode = newMode;
|
||||
initGame(newMode);
|
||||
}
|
||||
|
||||
function handleGuess(champion) {
|
||||
if (!game) return;
|
||||
|
||||
const updated = submitGuess(game, champion);
|
||||
if (!updated) return;
|
||||
|
||||
// Always reassign — submitGuess returns new objects, so mutation would
|
||||
// silently lose reactivity.
|
||||
game = updated;
|
||||
|
||||
if (updated.isOver && updated.mode === "unlimited") {
|
||||
saveUnlimitedStats(updated);
|
||||
stats = loadUnlimitedStats();
|
||||
}
|
||||
}
|
||||
|
||||
function handleNewGame() {
|
||||
clearUnlimitedState();
|
||||
initGame("unlimited");
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if loading}
|
||||
<p class="text-center text-[var(--color-text-muted)] py-8">
|
||||
Loading champions...
|
||||
</p>
|
||||
{:else if error}
|
||||
<p class="text-center text-[var(--color-wrong)] py-8">
|
||||
Failed to load champion data. Please refresh.
|
||||
</p>
|
||||
{:else if game}
|
||||
<!-- Mode toggle -->
|
||||
<div
|
||||
class="flex justify-center gap-1 my-3 bg-[var(--color-surface)] rounded-lg p-1 w-fit mx-auto"
|
||||
>
|
||||
<button
|
||||
onclick={() => switchMode("daily")}
|
||||
class="px-5 py-2 rounded-md border-none text-sm font-medium cursor-pointer transition-all {mode ===
|
||||
'daily'
|
||||
? 'bg-[var(--color-accent)] text-[var(--color-text)]'
|
||||
: 'bg-transparent text-[var(--color-text-muted)] hover:text-[var(--color-text)] hover:bg-[var(--color-surface-hover)]'}"
|
||||
>
|
||||
Daily
|
||||
</button>
|
||||
<button
|
||||
onclick={() => switchMode("unlimited")}
|
||||
class="px-5 py-2 rounded-md border-none text-sm font-medium cursor-pointer transition-all {mode ===
|
||||
'unlimited'
|
||||
? 'bg-[var(--color-accent)] text-[var(--color-text)]'
|
||||
: 'bg-transparent text-[var(--color-text-muted)] hover:text-[var(--color-text)] hover:bg-[var(--color-surface-hover)]'}"
|
||||
>
|
||||
Unlimited
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if stats}
|
||||
<StatsDisplay {stats} />
|
||||
{/if}
|
||||
|
||||
<ChampionSearch
|
||||
{excludeNames}
|
||||
onSelect={handleGuess}
|
||||
disabled={game.isOver}
|
||||
/>
|
||||
|
||||
<p class="text-center text-[var(--color-text-muted)] text-sm my-3">
|
||||
{game.maxGuesses > 0
|
||||
? `${game.guesses.length} / ${game.maxGuesses} guesses`
|
||||
: `${game.guesses.length} guess${game.guesses.length !== 1 ? "es" : ""}`}
|
||||
</p>
|
||||
|
||||
<GuessGrid guesses={game.guesses} results={game.results} />
|
||||
|
||||
{#if game.isOver}
|
||||
<GameOver
|
||||
isWon={game.isWon}
|
||||
target={game.target}
|
||||
guessCount={game.guesses.length}
|
||||
onNewGame={mode === "unlimited" ? handleNewGame : undefined}
|
||||
/>
|
||||
{/if}
|
||||
{/if}
|
||||
@@ -0,0 +1,46 @@
|
||||
<script>
|
||||
import { getChampionImageUrl } from "$lib/champion-data";
|
||||
|
||||
/** Win/loss message with optional new game button */
|
||||
let { isWon, target, guessCount, onNewGame } = $props();
|
||||
</script>
|
||||
|
||||
<div class="text-center py-6 px-4 mx-auto max-w-[400px]">
|
||||
<h2
|
||||
class="text-2xl font-bold mb-2 {isWon
|
||||
? 'text-[var(--color-correct)]'
|
||||
: 'text-[var(--color-wrong)]'}"
|
||||
>
|
||||
{isWon ? "You got it!" : "Game Over"}
|
||||
</h2>
|
||||
|
||||
{#if isWon}
|
||||
<p class="text-[var(--color-text-muted)] mb-3">
|
||||
You found <strong class="text-[var(--color-text)]">{target.name}</strong> in
|
||||
<strong class="text-[var(--color-text)]">{guessCount}</strong>
|
||||
guess{guessCount > 1 ? "es" : ""}!
|
||||
</p>
|
||||
{:else}
|
||||
<p class="text-[var(--color-text-muted)] mb-3">
|
||||
The champion was <strong class="text-[var(--color-text)]"
|
||||
>{target.name}</strong
|
||||
>
|
||||
</p>
|
||||
<img
|
||||
src={getChampionImageUrl(target.id)}
|
||||
alt={target.name}
|
||||
width="80"
|
||||
height="80"
|
||||
class="rounded-lg object-cover mx-auto"
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if onNewGame}
|
||||
<button
|
||||
onclick={onNewGame}
|
||||
class="mt-4 px-6 py-2.5 rounded-lg bg-[var(--color-accent)] text-[var(--color-text)] font-semibold cursor-pointer transition-opacity hover:opacity-85"
|
||||
>
|
||||
New Game
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,28 @@
|
||||
<script>
|
||||
import { CLASSIC_ATTRIBUTES } from "$lib/classic-mode";
|
||||
import GuessRow from "./guess-row.svelte";
|
||||
|
||||
/** Grid with header row + guess rows */
|
||||
let { guesses, results } = $props();
|
||||
|
||||
// Newest guess first. .reverse() acts on the fresh array from .map(),
|
||||
// so `guesses` itself is never mutated.
|
||||
let rows = $derived(
|
||||
guesses.map((champion, i) => ({ champion, results: results[i] })).reverse(),
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="w-full max-w-[900px] px-4 overflow-x-auto">
|
||||
<!-- Header row -->
|
||||
<div class="guess-row header-row">
|
||||
<div class="guess-cell header-cell">Champion</div>
|
||||
{#each CLASSIC_ATTRIBUTES as attr (attr.key)}
|
||||
<div class="guess-cell header-cell">{attr.label}</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Guess rows — newest first -->
|
||||
{#each rows as row (row.champion.id)}
|
||||
<GuessRow champion={row.champion} results={row.results} />
|
||||
{/each}
|
||||
</div>
|
||||
@@ -0,0 +1,35 @@
|
||||
<script>
|
||||
import { getChampionImageUrl } from "$lib/champion-data";
|
||||
|
||||
/** Single guess row with colored attribute cells */
|
||||
let { champion, results } = $props();
|
||||
</script>
|
||||
|
||||
<div class="guess-row">
|
||||
<!-- Champion name + image -->
|
||||
<div class="guess-cell champion-cell" style="animation-delay: 0s">
|
||||
<img
|
||||
src={getChampionImageUrl(champion.id)}
|
||||
alt={champion.name}
|
||||
width="40"
|
||||
height="40"
|
||||
class="rounded object-cover shrink-0"
|
||||
/>
|
||||
<span class="text-xs font-semibold">{champion.name}</span>
|
||||
</div>
|
||||
|
||||
<!-- Attribute cells: the within-row reveal stagger -->
|
||||
{#each results as r, i (r.key)}
|
||||
<div
|
||||
class="guess-cell cell-{r.result}"
|
||||
style="animation-delay: {(i + 1) * 0.08}s"
|
||||
>
|
||||
<span>{r.guessValue}</span>
|
||||
{#if r.direction}
|
||||
<span class="font-bold text-base">
|
||||
{r.direction === "up" ? " ↑" : " ↓"}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -0,0 +1,29 @@
|
||||
<script>
|
||||
/** Unlimited mode stats display */
|
||||
let { stats } = $props();
|
||||
|
||||
let winRate = $derived(
|
||||
stats && stats.gamesPlayed
|
||||
? Math.round((stats.gamesWon / stats.gamesPlayed) * 100)
|
||||
: 0,
|
||||
);
|
||||
|
||||
let items = $derived([
|
||||
{ value: stats?.gamesPlayed, label: "Played" },
|
||||
{ value: `${winRate}%`, label: "Win Rate" },
|
||||
{ value: stats?.gamesWon, label: "Won" },
|
||||
]);
|
||||
</script>
|
||||
|
||||
{#if stats && stats.gamesPlayed > 0}
|
||||
<div class="flex justify-center gap-6 py-3 mb-4">
|
||||
{#each items as { value, label } (label)}
|
||||
<div class="flex flex-col items-center gap-0.5">
|
||||
<span class="text-xl font-bold text-[var(--color-text)]">{value}</span>
|
||||
<span class="text-[0.7rem] text-[var(--color-text-muted)] uppercase"
|
||||
>{label}</span
|
||||
>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -1,9 +1,12 @@
|
||||
<!-- Placeholder: header only, so the build is verifiable before components exist.
|
||||
Phase 4 mounts the real game board here. -->
|
||||
<script>
|
||||
import GameBoard from "$lib/components/game-board.svelte";
|
||||
</script>
|
||||
|
||||
<main class="flex flex-col items-center min-h-screen">
|
||||
<header class="text-center pt-6 pb-2 w-full max-w-[900px]">
|
||||
<h1 class="text-3xl font-bold tracking-wider">
|
||||
Lo<span class="text-[var(--color-accent)]">L</span>dle
|
||||
</h1>
|
||||
</header>
|
||||
<GameBoard />
|
||||
</main>
|
||||
|
||||
Reference in New Issue
Block a user