mirror of
https://github.com/tiennm99/loldle.git
synced 2026-09-02 16:20:24 +00:00
refactor: migrate from vanilla JS to Next.js 16 with Tailwind CSS
- Replace vanilla HTML/CSS/JS with Next.js App Router + React components - Game logic (champion-data, game-engine, classic-mode) moved to lib/ - UI split into modular components (game-board, champion-search, guess-grid, etc.) - Add Tailwind CSS v4 with CSS variables for theming - Immutable state updates, fetch deduplication, next/image for CDN images - champions.json moved from assets/ to public/
This commit is contained in:
@@ -0,0 +1,38 @@
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files
|
||||
.env*.local
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# eslint
|
||||
.eslintcache
|
||||
@@ -0,0 +1,122 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
--color-bg: #1a1a2e;
|
||||
--color-surface: #16213e;
|
||||
--color-surface-hover: #1a2745;
|
||||
--color-text: #e0e0e0;
|
||||
--color-text-muted: #8a8a9a;
|
||||
--color-correct: #4ecb71;
|
||||
--color-partial: #f4b35e;
|
||||
--color-wrong: #d85b5b;
|
||||
--color-header: #2a2a4a;
|
||||
--color-input-bg: #0f3460;
|
||||
--color-input-border: #533483;
|
||||
--color-accent: #533483;
|
||||
--cell-size: 90px;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
font-family: "Segoe UI", system-ui, -apple-system, sans-serif;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* Guess grid layout */
|
||||
.guess-row {
|
||||
display: grid;
|
||||
grid-template-columns: 130px repeat(7, var(--cell-size));
|
||||
gap: 4px;
|
||||
margin-bottom: 4px;
|
||||
min-width: fit-content;
|
||||
}
|
||||
|
||||
.guess-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
padding: 8px 4px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
min-height: 56px;
|
||||
word-break: break-word;
|
||||
animation: cellReveal 0.3s ease forwards;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.header-row {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.header-cell {
|
||||
background: var(--color-header);
|
||||
color: var(--color-text-muted);
|
||||
font-weight: 600;
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
opacity: 1;
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.champion-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
justify-content: flex-start;
|
||||
padding-left: 8px;
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.cell-correct {
|
||||
background: var(--color-correct);
|
||||
color: #1a1a2e;
|
||||
}
|
||||
|
||||
.cell-partial {
|
||||
background: var(--color-partial);
|
||||
color: #1a1a2e;
|
||||
}
|
||||
|
||||
.cell-wrong {
|
||||
background: var(--color-wrong);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
@keyframes cellReveal {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.8);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
:root {
|
||||
--cell-size: 72px;
|
||||
}
|
||||
.guess-row {
|
||||
grid-template-columns: 100px repeat(7, var(--cell-size));
|
||||
}
|
||||
.guess-cell {
|
||||
font-size: 0.7rem;
|
||||
min-height: 48px;
|
||||
padding: 6px 2px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
:root {
|
||||
--cell-size: 60px;
|
||||
}
|
||||
.guess-row {
|
||||
grid-template-columns: 80px repeat(7, var(--cell-size));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import "./globals.css";
|
||||
|
||||
export const metadata = {
|
||||
title: "LoLdle",
|
||||
description: "Guess the League of Legends champion",
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import GameBoard from "@/components/game-board";
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<main className="flex flex-col items-center min-h-screen">
|
||||
<header className="text-center pt-6 pb-2 w-full max-w-[900px]">
|
||||
<h1 className="text-3xl font-bold tracking-wider">
|
||||
Lo<span className="text-[var(--color-accent)]">L</span>dle
|
||||
</h1>
|
||||
</header>
|
||||
<GameBoard />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect, useCallback, useMemo } from "react";
|
||||
import Image from "next/image";
|
||||
import { searchChampions, getChampionImageUrl } from "@/lib/champion-data";
|
||||
|
||||
/** Autocomplete search input for champion selection */
|
||||
export default function ChampionSearch({ excludeNames, onSelect, disabled }) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [activeIndex, setActiveIndex] = useState(-1);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const inputRef = useRef(null);
|
||||
const dropdownRef = useRef(null);
|
||||
|
||||
// Derive matches from query
|
||||
const matches = useMemo(() => {
|
||||
if (!query.trim()) return [];
|
||||
return searchChampions(query, excludeNames);
|
||||
}, [query, excludeNames]);
|
||||
|
||||
// Derive dropdown open state from matches (no effect needed)
|
||||
const isVisible = isOpen && matches.length > 0;
|
||||
|
||||
// Close dropdown on outside click
|
||||
useEffect(() => {
|
||||
function handleClick(e) {
|
||||
if (
|
||||
inputRef.current &&
|
||||
!inputRef.current.contains(e.target) &&
|
||||
dropdownRef.current &&
|
||||
!dropdownRef.current.contains(e.target)
|
||||
) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleClick);
|
||||
return () => document.removeEventListener("mousedown", handleClick);
|
||||
}, []);
|
||||
|
||||
const selectChampion = useCallback(
|
||||
(champion) => {
|
||||
setQuery("");
|
||||
setIsOpen(false);
|
||||
onSelect(champion);
|
||||
},
|
||||
[onSelect],
|
||||
);
|
||||
|
||||
function handleKeyDown(e) {
|
||||
if (!isVisible || !matches.length) return;
|
||||
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setActiveIndex((prev) => Math.min(prev + 1, matches.length - 1));
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setActiveIndex((prev) => Math.max(prev - 1, 0));
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
if (activeIndex >= 0 && activeIndex < matches.length) {
|
||||
selectChampion(matches[activeIndex]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative w-full max-w-[400px] mx-auto px-4">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => { setQuery(e.target.value); setActiveIndex(-1); setIsOpen(true); }}
|
||||
onKeyDown={handleKeyDown}
|
||||
disabled={disabled}
|
||||
placeholder={disabled ? "Game over" : "Type a champion name..."}
|
||||
autoComplete="off"
|
||||
className="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)]"
|
||||
/>
|
||||
|
||||
{isVisible && (
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
className="absolute top-full left-4 right-4 bg-[var(--color-surface)] border border-[var(--color-input-border)] rounded-b-lg max-h-80 overflow-y-auto z-50"
|
||||
>
|
||||
{matches.map((champion, i) => (
|
||||
<div
|
||||
key={champion.id}
|
||||
onClick={() => selectChampion(champion)}
|
||||
className={`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)]"
|
||||
}`}
|
||||
>
|
||||
<Image
|
||||
src={getChampionImageUrl(champion.id)}
|
||||
alt={champion.name}
|
||||
width={36}
|
||||
height={36}
|
||||
className="rounded object-cover"
|
||||
unoptimized
|
||||
/>
|
||||
<span className="text-sm">{champion.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import {
|
||||
loadChampions,
|
||||
getRandomChampion,
|
||||
getTodaySeed,
|
||||
} from "@/lib/champion-data";
|
||||
import {
|
||||
createGame,
|
||||
submitGuess,
|
||||
getGuessedNames,
|
||||
getOrCreateUnlimitedSeed,
|
||||
clearUnlimitedState,
|
||||
saveUnlimitedStats,
|
||||
loadUnlimitedStats,
|
||||
} from "@/lib/game-engine";
|
||||
import { compareChampions } from "@/lib/classic-mode";
|
||||
import ChampionSearch from "./champion-search";
|
||||
import GuessGrid from "./guess-grid";
|
||||
import GameOver from "./game-over";
|
||||
import StatsDisplay from "./stats-display";
|
||||
|
||||
export default function GameBoard() {
|
||||
const [mode, setMode] = useState("daily");
|
||||
const [game, setGame] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [stats, setStats] = useState(null);
|
||||
const gameRef = useRef(null);
|
||||
|
||||
const initGame = useCallback((gameMode) => {
|
||||
const seed = gameMode === "daily" ? getTodaySeed() : getOrCreateUnlimitedSeed();
|
||||
const target = getRandomChampion(seed);
|
||||
const maxGuesses = gameMode === "daily" ? 6 : 0;
|
||||
|
||||
const newGame = createGame({
|
||||
target,
|
||||
compareFn: compareChampions,
|
||||
maxGuesses,
|
||||
mode: gameMode,
|
||||
seed,
|
||||
});
|
||||
|
||||
gameRef.current = newGame;
|
||||
setGame({ ...newGame });
|
||||
setStats(gameMode === "unlimited" ? loadUnlimitedStats() : null);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadChampions()
|
||||
.then(() => {
|
||||
initGame("daily");
|
||||
setLoading(false);
|
||||
})
|
||||
.catch((err) => {
|
||||
setError(err.message);
|
||||
setLoading(false);
|
||||
});
|
||||
}, [initGame]);
|
||||
|
||||
function switchMode(newMode) {
|
||||
setMode(newMode);
|
||||
initGame(newMode);
|
||||
}
|
||||
|
||||
const handleGuess = useCallback((champion) => {
|
||||
const currentGame = gameRef.current;
|
||||
if (!currentGame) return;
|
||||
|
||||
const updated = submitGuess(currentGame, champion);
|
||||
if (!updated) return;
|
||||
|
||||
gameRef.current = updated;
|
||||
setGame(updated);
|
||||
|
||||
if (updated.isOver && updated.mode === "unlimited") {
|
||||
saveUnlimitedStats(updated);
|
||||
setStats(loadUnlimitedStats());
|
||||
}
|
||||
}, []);
|
||||
|
||||
function handleNewGame() {
|
||||
clearUnlimitedState();
|
||||
initGame("unlimited");
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <p className="text-center text-[var(--color-text-muted)] py-8">Loading champions...</p>;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <p className="text-center text-[var(--color-wrong)] py-8">Failed to load champion data. Please refresh.</p>;
|
||||
}
|
||||
|
||||
if (!game) return null;
|
||||
|
||||
const excludeNames = game.guesses.map((g) => g.name);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Mode toggle */}
|
||||
<div className="flex justify-center gap-1 my-3 bg-[var(--color-surface)] rounded-lg p-1 w-fit mx-auto">
|
||||
<button
|
||||
onClick={() => switchMode("daily")}
|
||||
className={`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")}
|
||||
className={`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>
|
||||
|
||||
{stats && <StatsDisplay stats={stats} />}
|
||||
|
||||
<ChampionSearch
|
||||
excludeNames={excludeNames}
|
||||
onSelect={handleGuess}
|
||||
disabled={game.isOver}
|
||||
/>
|
||||
|
||||
<p className="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} />
|
||||
|
||||
{game.isOver && (
|
||||
<GameOver
|
||||
isWon={game.isWon}
|
||||
target={game.target}
|
||||
guessCount={game.guesses.length}
|
||||
onNewGame={mode === "unlimited" ? handleNewGame : undefined}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { getChampionImageUrl } from "@/lib/champion-data";
|
||||
|
||||
/** Win/loss message with optional new game button */
|
||||
export default function GameOver({ isWon, target, guessCount, onNewGame }) {
|
||||
return (
|
||||
<div className={`text-center py-6 px-4 mx-auto max-w-[400px]`}>
|
||||
<h2
|
||||
className={`text-2xl font-bold mb-2 ${
|
||||
isWon ? "text-[var(--color-correct)]" : "text-[var(--color-wrong)]"
|
||||
}`}
|
||||
>
|
||||
{isWon ? "You got it!" : "Game Over"}
|
||||
</h2>
|
||||
|
||||
{isWon ? (
|
||||
<p className="text-[var(--color-text-muted)] mb-3">
|
||||
You found <strong className="text-[var(--color-text)]">{target.name}</strong> in{" "}
|
||||
<strong className="text-[var(--color-text)]">{guessCount}</strong>{" "}
|
||||
guess{guessCount > 1 ? "es" : ""}!
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-[var(--color-text-muted)] mb-3">
|
||||
The champion was <strong className="text-[var(--color-text)]">{target.name}</strong>
|
||||
</p>
|
||||
<Image
|
||||
src={getChampionImageUrl(target.id)}
|
||||
alt={target.name}
|
||||
width={80}
|
||||
height={80}
|
||||
className="rounded-lg object-cover mx-auto"
|
||||
unoptimized
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{onNewGame && (
|
||||
<button
|
||||
onClick={onNewGame}
|
||||
className="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>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import { CLASSIC_ATTRIBUTES } from "@/lib/classic-mode";
|
||||
import GuessRow from "./guess-row";
|
||||
|
||||
/** Grid with header row + guess rows */
|
||||
export default function GuessGrid({ guesses, results }) {
|
||||
return (
|
||||
<div className="w-full max-w-[900px] px-4 overflow-x-auto">
|
||||
{/* Header row */}
|
||||
<div className="guess-row header-row">
|
||||
<div className="guess-cell header-cell">Champion</div>
|
||||
{CLASSIC_ATTRIBUTES.map((attr) => (
|
||||
<div key={attr.key} className="guess-cell header-cell">
|
||||
{attr.label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Guess rows */}
|
||||
{guesses.map((champion, i) => (
|
||||
<GuessRow key={champion.id} champion={champion} results={results[i]} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { getChampionImageUrl } from "@/lib/champion-data";
|
||||
|
||||
/** Single guess row with colored attribute cells */
|
||||
export default function GuessRow({ champion, results, animationDelay = 0 }) {
|
||||
return (
|
||||
<div className="guess-row">
|
||||
{/* Champion name + image */}
|
||||
<div
|
||||
className="guess-cell champion-cell"
|
||||
style={{ animationDelay: `${animationDelay}s` }}
|
||||
>
|
||||
<Image
|
||||
src={getChampionImageUrl(champion.id)}
|
||||
alt={champion.name}
|
||||
width={40}
|
||||
height={40}
|
||||
className="rounded object-cover shrink-0"
|
||||
unoptimized
|
||||
/>
|
||||
<span className="text-xs font-semibold">{champion.name}</span>
|
||||
</div>
|
||||
|
||||
{/* Attribute cells */}
|
||||
{results.map((r, i) => (
|
||||
<div
|
||||
key={r.key}
|
||||
className={`guess-cell cell-${r.result}`}
|
||||
style={{ animationDelay: `${animationDelay + (i + 1) * 0.08}s` }}
|
||||
>
|
||||
<span>{r.guessValue}</span>
|
||||
{r.direction && (
|
||||
<span className="font-bold text-base">
|
||||
{r.direction === "up" ? " ↑" : " ↓"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
"use client";
|
||||
|
||||
/** Unlimited mode stats display */
|
||||
export default function StatsDisplay({ stats }) {
|
||||
if (!stats || stats.gamesPlayed === 0) return null;
|
||||
|
||||
const winRate = Math.round((stats.gamesWon / stats.gamesPlayed) * 100);
|
||||
|
||||
const items = [
|
||||
{ value: stats.gamesPlayed, label: "Played" },
|
||||
{ value: `${winRate}%`, label: "Win Rate" },
|
||||
{ value: stats.gamesWon, label: "Won" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex justify-center gap-6 py-3 mb-4">
|
||||
{items.map(({ value, label }) => (
|
||||
<div key={label} className="flex flex-col items-center gap-0.5">
|
||||
<span className="text-xl font-bold text-[var(--color-text)]">{value}</span>
|
||||
<span className="text-[0.7rem] text-[var(--color-text-muted)] uppercase">{label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,403 +0,0 @@
|
||||
/* LoLdle Classic — Dark theme, responsive grid */
|
||||
|
||||
:root {
|
||||
--color-bg: #1a1a2e;
|
||||
--color-surface: #16213e;
|
||||
--color-surface-hover: #1a2745;
|
||||
--color-text: #e0e0e0;
|
||||
--color-text-muted: #8a8a9a;
|
||||
--color-correct: #4ecb71;
|
||||
--color-partial: #f4b35e;
|
||||
--color-wrong: #d85b5b;
|
||||
--color-header: #2a2a4a;
|
||||
--color-input-bg: #0f3460;
|
||||
--color-input-border: #533483;
|
||||
--color-accent: #533483;
|
||||
--radius: 8px;
|
||||
--cell-size: 90px;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: "Segoe UI", system-ui, -apple-system, sans-serif;
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.header {
|
||||
text-align: center;
|
||||
padding: 24px 16px 8px;
|
||||
width: 100%;
|
||||
max-width: 900px;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 2px;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.header h1 span {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
/* Mode toggle */
|
||||
.mode-toggle {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
margin: 12px 0 16px;
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius);
|
||||
padding: 4px;
|
||||
width: fit-content;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.mode-btn {
|
||||
padding: 8px 20px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.mode-btn.active {
|
||||
background: var(--color-accent);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.mode-btn:hover:not(.active) {
|
||||
color: var(--color-text);
|
||||
background: var(--color-surface-hover);
|
||||
}
|
||||
|
||||
/* Search */
|
||||
.search-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
margin: 0 auto 20px;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
#search-input {
|
||||
width: 100%;
|
||||
padding: 12px 16px;
|
||||
border: 2px solid var(--color-input-border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--color-input-bg);
|
||||
color: var(--color-text);
|
||||
font-size: 1rem;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
#search-input:focus {
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
#search-input:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
#search-input::placeholder {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
#search-dropdown {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 16px;
|
||||
right: 16px;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-input-border);
|
||||
border-radius: 0 0 var(--radius) var(--radius);
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
z-index: 100;
|
||||
display: none;
|
||||
}
|
||||
|
||||
#search-dropdown.visible {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.dropdown-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.dropdown-item:hover,
|
||||
.dropdown-item.active {
|
||||
background: var(--color-surface-hover);
|
||||
}
|
||||
|
||||
.dropdown-item img {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 4px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.dropdown-item span {
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
/* Guess counter */
|
||||
.guess-counter {
|
||||
text-align: center;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
/* Grid */
|
||||
.grid-container {
|
||||
width: 100%;
|
||||
max-width: 900px;
|
||||
padding: 0 16px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.guess-row {
|
||||
display: grid;
|
||||
grid-template-columns: 130px repeat(7, var(--cell-size));
|
||||
gap: 4px;
|
||||
margin-bottom: 4px;
|
||||
min-width: fit-content;
|
||||
}
|
||||
|
||||
.guess-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
padding: 8px 4px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
min-height: 56px;
|
||||
word-break: break-word;
|
||||
animation: cellReveal 0.3s ease forwards;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* Header row */
|
||||
.header-row {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.header-cell {
|
||||
background: var(--color-header);
|
||||
color: var(--color-text-muted);
|
||||
font-weight: 600;
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
opacity: 1;
|
||||
animation: none;
|
||||
}
|
||||
|
||||
/* Champion cell */
|
||||
.champion-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
justify-content: flex-start;
|
||||
padding-left: 8px;
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.champion-cell img {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 4px;
|
||||
object-fit: cover;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.champion-cell span {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Result colors */
|
||||
.cell-correct {
|
||||
background: var(--color-correct);
|
||||
color: #1a1a2e;
|
||||
}
|
||||
|
||||
.cell-partial {
|
||||
background: var(--color-partial);
|
||||
color: #1a1a2e;
|
||||
}
|
||||
|
||||
.cell-wrong {
|
||||
background: var(--color-wrong);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Direction arrow */
|
||||
.direction-arrow {
|
||||
font-weight: 700;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
/* Cell animation */
|
||||
@keyframes cellReveal {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.8);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
/* Game over */
|
||||
.game-over {
|
||||
text-align: center;
|
||||
padding: 24px 16px;
|
||||
margin: 16px auto;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.game-over h2 {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.game-over.won h2 {
|
||||
color: var(--color-correct);
|
||||
}
|
||||
|
||||
.game-over.lost h2 {
|
||||
color: var(--color-wrong);
|
||||
}
|
||||
|
||||
.game-over p {
|
||||
color: var(--color-text-muted);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.game-over strong {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.reveal-image {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: var(--radius);
|
||||
object-fit: cover;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* New game button (unlimited) */
|
||||
.new-game-btn {
|
||||
display: inline-block;
|
||||
margin-top: 16px;
|
||||
padding: 10px 24px;
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
background: var(--color-accent);
|
||||
color: var(--color-text);
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.new-game-btn:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
/* Stats */
|
||||
.stats {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 24px;
|
||||
padding: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 1.3rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.7rem;
|
||||
color: var(--color-text-muted);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
:root {
|
||||
--cell-size: 72px;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.guess-row {
|
||||
grid-template-columns: 100px repeat(7, var(--cell-size));
|
||||
}
|
||||
|
||||
.guess-cell {
|
||||
font-size: 0.7rem;
|
||||
min-height: 48px;
|
||||
padding: 6px 2px;
|
||||
}
|
||||
|
||||
.champion-cell img {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
:root {
|
||||
--cell-size: 60px;
|
||||
}
|
||||
|
||||
.guess-row {
|
||||
grid-template-columns: 80px repeat(7, var(--cell-size));
|
||||
}
|
||||
|
||||
.champion-cell span {
|
||||
font-size: 0.65rem;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { defineConfig, globalIgnores } from "eslint/config";
|
||||
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||
|
||||
const eslintConfig = defineConfig([
|
||||
...nextVitals,
|
||||
globalIgnores([".next/**", "out/**", "build/**", "next-env.d.ts"]),
|
||||
]);
|
||||
|
||||
export default eslintConfig;
|
||||
@@ -1,35 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>LoLdle Classic</title>
|
||||
<link rel="stylesheet" href="css/styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<header class="header">
|
||||
<h1>Lo<span>L</span>dle</h1>
|
||||
<div class="mode-toggle">
|
||||
<button id="mode-daily" class="mode-btn active">Daily</button>
|
||||
<button id="mode-unlimited" class="mode-btn">Unlimited</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div id="stats"></div>
|
||||
|
||||
<div class="search-container">
|
||||
<input type="text" id="search-input" placeholder="Type a champion name..." autocomplete="off" />
|
||||
<div id="search-dropdown"></div>
|
||||
</div>
|
||||
|
||||
<p id="guess-counter" class="guess-counter"></p>
|
||||
|
||||
<main class="grid-container">
|
||||
<div id="grid"></div>
|
||||
</main>
|
||||
|
||||
<div id="game-over"></div>
|
||||
|
||||
<script type="module" src="js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
-196
@@ -1,196 +0,0 @@
|
||||
// Bootstrap: wire data, engine, classic mode, and UI together
|
||||
|
||||
import { loadChampions, getAllChampions, getRandomChampion, getTodaySeed } from "./data-loader.js";
|
||||
import { createGame, submitGuess, getGuessedNames, clearUnlimitedState, saveUnlimitedStats, loadUnlimitedStats } from "./game-engine.js";
|
||||
import { compareChampions } from "./classic-mode.js";
|
||||
import {
|
||||
initSearch,
|
||||
updateSearchCallbacks,
|
||||
renderGuessRow,
|
||||
renderGridHeader,
|
||||
renderGameOver,
|
||||
updateGuessCounter,
|
||||
setSearchEnabled,
|
||||
renderStats,
|
||||
} from "./ui-renderer.js";
|
||||
|
||||
const UNLIMITED_SEED_KEY = "loldle_unlimited_seed";
|
||||
|
||||
let currentGame = null;
|
||||
let currentMode = "daily";
|
||||
|
||||
async function init() {
|
||||
try {
|
||||
await loadChampions();
|
||||
} catch (err) {
|
||||
document.getElementById("grid").textContent = "Failed to load champion data. Please refresh.";
|
||||
return;
|
||||
}
|
||||
|
||||
const champions = getAllChampions();
|
||||
if (!champions.length) {
|
||||
document.getElementById("grid").textContent = "No champion data available.";
|
||||
return;
|
||||
}
|
||||
|
||||
// Initialize search once with champion list
|
||||
const searchContainer = document.querySelector(".search-container");
|
||||
initSearch(searchContainer, champions);
|
||||
|
||||
// Mode toggle buttons
|
||||
document.getElementById("mode-daily").addEventListener("click", () => switchMode("daily"));
|
||||
document.getElementById("mode-unlimited").addEventListener("click", () => switchMode("unlimited"));
|
||||
|
||||
// Start with daily mode
|
||||
startGame("daily");
|
||||
}
|
||||
|
||||
function switchMode(mode) {
|
||||
currentMode = mode;
|
||||
|
||||
// Update toggle UI
|
||||
document.querySelectorAll(".mode-btn").forEach((btn) => btn.classList.remove("active"));
|
||||
document.getElementById(`mode-${mode}`).classList.add("active");
|
||||
|
||||
startGame(mode);
|
||||
}
|
||||
|
||||
function startGame(mode) {
|
||||
const gridContainer = document.getElementById("grid");
|
||||
const gameOverContainer = document.getElementById("game-over");
|
||||
const statsContainer = document.getElementById("stats");
|
||||
const searchContainer = document.querySelector(".search-container");
|
||||
const guessCounter = document.getElementById("guess-counter");
|
||||
|
||||
// Clear previous state
|
||||
gridContainer.innerHTML = "";
|
||||
gameOverContainer.innerHTML = "";
|
||||
|
||||
// Show/hide stats
|
||||
if (mode === "unlimited") {
|
||||
renderStats(statsContainer, loadUnlimitedStats());
|
||||
} else {
|
||||
statsContainer.innerHTML = "";
|
||||
}
|
||||
|
||||
// Pick target champion
|
||||
let target;
|
||||
let seed = "";
|
||||
if (mode === "daily") {
|
||||
seed = getTodaySeed();
|
||||
target = getRandomChampion(seed);
|
||||
} else {
|
||||
// Unlimited: reuse persisted seed if game is in progress, else create new
|
||||
seed = getOrCreateUnlimitedSeed();
|
||||
target = getRandomChampion(seed);
|
||||
}
|
||||
|
||||
// Create game
|
||||
const maxGuesses = mode === "daily" ? 6 : 0;
|
||||
currentGame = createGame({
|
||||
target,
|
||||
compareFn: compareChampions,
|
||||
maxGuesses,
|
||||
mode,
|
||||
seed,
|
||||
});
|
||||
|
||||
// Render grid header
|
||||
renderGridHeader(gridContainer);
|
||||
|
||||
// Restore previous guesses (from localStorage)
|
||||
if (currentGame.guesses.length > 0) {
|
||||
currentGame.results.forEach((result, i) => {
|
||||
renderGuessRow(gridContainer, currentGame.guesses[i], result);
|
||||
});
|
||||
}
|
||||
|
||||
// Update counter
|
||||
updateGuessCounter(guessCounter, currentGame.guesses.length, currentGame.maxGuesses);
|
||||
|
||||
// Handle already-finished game (restored from storage)
|
||||
if (currentGame.isOver) {
|
||||
setSearchEnabled(searchContainer, false);
|
||||
renderGameOver(gameOverContainer, currentGame.isWon, currentGame.target, currentGame.guesses.length);
|
||||
if (mode === "unlimited") {
|
||||
addNewGameButton(gameOverContainer);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Enable search and update callbacks for this game instance
|
||||
setSearchEnabled(searchContainer, true);
|
||||
const input = searchContainer.querySelector("#search-input");
|
||||
input.placeholder = "Type a champion name...";
|
||||
input.value = "";
|
||||
input.focus();
|
||||
|
||||
updateSearchCallbacks(
|
||||
(champion) => handleGuess(champion, gridContainer, gameOverContainer, searchContainer, guessCounter, statsContainer),
|
||||
() => getGuessedNames(currentGame),
|
||||
);
|
||||
}
|
||||
|
||||
function handleGuess(champion, gridContainer, gameOverContainer, searchContainer, guessCounter, statsContainer) {
|
||||
const result = submitGuess(currentGame, champion);
|
||||
if (!result) return;
|
||||
|
||||
renderGuessRow(gridContainer, champion, result);
|
||||
updateGuessCounter(guessCounter, currentGame.guesses.length, currentGame.maxGuesses);
|
||||
|
||||
if (currentGame.isOver) {
|
||||
setSearchEnabled(searchContainer, false);
|
||||
renderGameOver(gameOverContainer, currentGame.isWon, currentGame.target, currentGame.guesses.length);
|
||||
|
||||
if (currentGame.mode === "unlimited") {
|
||||
saveUnlimitedStats(currentGame);
|
||||
renderStats(statsContainer, loadUnlimitedStats());
|
||||
addNewGameButton(gameOverContainer);
|
||||
}
|
||||
} else {
|
||||
// Refocus input for next guess
|
||||
searchContainer.querySelector("#search-input").focus();
|
||||
}
|
||||
}
|
||||
|
||||
/** Get or create a persistent seed for unlimited mode */
|
||||
function getOrCreateUnlimitedSeed() {
|
||||
try {
|
||||
const saved = localStorage.getItem(UNLIMITED_SEED_KEY);
|
||||
if (saved) return saved;
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
return createNewUnlimitedSeed();
|
||||
}
|
||||
|
||||
/** Create and persist a new unlimited seed */
|
||||
function createNewUnlimitedSeed() {
|
||||
const seed = `unlimited_${Date.now()}_${Math.random()}`;
|
||||
try {
|
||||
localStorage.setItem(UNLIMITED_SEED_KEY, seed);
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
return seed;
|
||||
}
|
||||
|
||||
function addNewGameButton(container) {
|
||||
const btn = document.createElement("button");
|
||||
btn.className = "new-game-btn";
|
||||
btn.textContent = "New Game";
|
||||
btn.addEventListener("click", () => {
|
||||
clearUnlimitedState();
|
||||
// Clear the persisted seed so a new one is generated
|
||||
try {
|
||||
localStorage.removeItem(UNLIMITED_SEED_KEY);
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
startGame("unlimited");
|
||||
});
|
||||
container.appendChild(btn);
|
||||
}
|
||||
|
||||
// Start the app
|
||||
init();
|
||||
@@ -1,52 +0,0 @@
|
||||
// Data layer: load, search, and select champions from champions.json
|
||||
|
||||
let champions = [];
|
||||
|
||||
/** Fetch and parse champions data */
|
||||
export async function loadChampions() {
|
||||
const response = await fetch("assets/champions.json");
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load champions: ${response.status}`);
|
||||
}
|
||||
champions = await response.json();
|
||||
return champions;
|
||||
}
|
||||
|
||||
/** Get all loaded champions */
|
||||
export function getAllChampions() {
|
||||
return champions;
|
||||
}
|
||||
|
||||
/** Case-insensitive lookup by name */
|
||||
export function getChampionByName(name) {
|
||||
const lower = name.toLowerCase();
|
||||
return champions.find((c) => c.name.toLowerCase() === lower) || null;
|
||||
}
|
||||
|
||||
|
||||
/** Seeded random champion selection (deterministic for same seed string) */
|
||||
export function getRandomChampion(seed) {
|
||||
if (!champions.length) return null;
|
||||
const hash = hashString(seed);
|
||||
const index = hash % champions.length;
|
||||
return champions[index];
|
||||
}
|
||||
|
||||
/** Simple string hash (djb2) */
|
||||
function hashString(str) {
|
||||
let hash = 5381;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash = (hash * 33) ^ str.charCodeAt(i);
|
||||
}
|
||||
return hash >>> 0;
|
||||
}
|
||||
|
||||
/** Get today's date string for daily seed */
|
||||
export function getTodaySeed() {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/** Get champion image URL from Data Dragon CDN */
|
||||
export function getChampionImageUrl(championId) {
|
||||
return `https://ddragon.leagueoflegends.com/cdn/img/champion/tiles/${championId}_0.jpg`;
|
||||
}
|
||||
@@ -1,272 +0,0 @@
|
||||
// UI rendering: search autocomplete, guess grid, game state display
|
||||
|
||||
import { getChampionImageUrl } from "./data-loader.js";
|
||||
import { CLASSIC_ATTRIBUTES } from "./classic-mode.js";
|
||||
|
||||
// Mutable callback refs — updated on each startGame, listeners bound only once
|
||||
let _searchCallbacks = { onSelect: null, getExcluded: () => [] };
|
||||
let _searchInitialized = false;
|
||||
|
||||
/** Update search callbacks (call on each new game) */
|
||||
export function updateSearchCallbacks(onSelect, getExcluded) {
|
||||
_searchCallbacks.onSelect = onSelect;
|
||||
_searchCallbacks.getExcluded = getExcluded;
|
||||
}
|
||||
|
||||
/** Initialize search autocomplete (call once) */
|
||||
export function initSearch(container, champions) {
|
||||
if (_searchInitialized) return;
|
||||
_searchInitialized = true;
|
||||
|
||||
const input = container.querySelector("#search-input");
|
||||
const dropdown = container.querySelector("#search-dropdown");
|
||||
|
||||
input.addEventListener("input", () => {
|
||||
const query = input.value.trim();
|
||||
if (!query) {
|
||||
dropdown.innerHTML = "";
|
||||
dropdown.classList.remove("visible");
|
||||
return;
|
||||
}
|
||||
|
||||
const excluded = _searchCallbacks.getExcluded();
|
||||
const lower = query.toLowerCase();
|
||||
const matches = champions
|
||||
.filter(
|
||||
(c) =>
|
||||
c.name.toLowerCase().includes(lower) &&
|
||||
!excluded.includes(c.name),
|
||||
)
|
||||
.sort((a, b) => {
|
||||
const aStarts = a.name.toLowerCase().startsWith(lower) ? 0 : 1;
|
||||
const bStarts = b.name.toLowerCase().startsWith(lower) ? 0 : 1;
|
||||
return aStarts - bStarts || a.name.localeCompare(b.name);
|
||||
})
|
||||
.slice(0, 8);
|
||||
|
||||
renderDropdown(dropdown, matches, (champion) => {
|
||||
input.value = "";
|
||||
dropdown.innerHTML = "";
|
||||
dropdown.classList.remove("visible");
|
||||
if (_searchCallbacks.onSelect) _searchCallbacks.onSelect(champion);
|
||||
});
|
||||
});
|
||||
|
||||
// Close dropdown on outside click
|
||||
document.addEventListener("click", (e) => {
|
||||
if (!container.contains(e.target)) {
|
||||
dropdown.innerHTML = "";
|
||||
dropdown.classList.remove("visible");
|
||||
}
|
||||
});
|
||||
|
||||
// Keyboard navigation
|
||||
input.addEventListener("keydown", (e) => {
|
||||
const items = dropdown.querySelectorAll(".dropdown-item");
|
||||
const active = dropdown.querySelector(".dropdown-item.active");
|
||||
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
if (!active && items.length) {
|
||||
items[0].classList.add("active");
|
||||
} else if (active && active.nextElementSibling) {
|
||||
active.classList.remove("active");
|
||||
active.nextElementSibling.classList.add("active");
|
||||
}
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
if (active && active.previousElementSibling) {
|
||||
active.classList.remove("active");
|
||||
active.previousElementSibling.classList.add("active");
|
||||
}
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
const selected = dropdown.querySelector(".dropdown-item.active");
|
||||
if (selected) selected.click();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Render dropdown options */
|
||||
function renderDropdown(dropdown, matches, onClick) {
|
||||
dropdown.innerHTML = "";
|
||||
if (!matches.length) {
|
||||
dropdown.classList.remove("visible");
|
||||
return;
|
||||
}
|
||||
|
||||
matches.forEach((champion) => {
|
||||
const item = document.createElement("div");
|
||||
item.className = "dropdown-item";
|
||||
|
||||
const img = document.createElement("img");
|
||||
img.src = getChampionImageUrl(champion.id);
|
||||
img.alt = champion.name;
|
||||
img.loading = "lazy";
|
||||
|
||||
const name = document.createElement("span");
|
||||
name.textContent = champion.name;
|
||||
|
||||
item.append(img, name);
|
||||
item.addEventListener("click", () => onClick(champion));
|
||||
dropdown.appendChild(item);
|
||||
});
|
||||
|
||||
dropdown.classList.add("visible");
|
||||
}
|
||||
|
||||
/** Render a single guess row with comparison results */
|
||||
export function renderGuessRow(container, champion, results) {
|
||||
const row = document.createElement("div");
|
||||
row.className = "guess-row";
|
||||
|
||||
// Champion name + image cell
|
||||
const nameCell = document.createElement("div");
|
||||
nameCell.className = "guess-cell champion-cell";
|
||||
const img = document.createElement("img");
|
||||
img.src = getChampionImageUrl(champion.id);
|
||||
img.alt = champion.name;
|
||||
const nameSpan = document.createElement("span");
|
||||
nameSpan.textContent = champion.name;
|
||||
nameCell.append(img, nameSpan);
|
||||
row.appendChild(nameCell);
|
||||
|
||||
// Attribute cells
|
||||
results.forEach((r) => {
|
||||
const cell = document.createElement("div");
|
||||
cell.className = `guess-cell cell-${r.result}`;
|
||||
cell.dataset.attribute = r.key;
|
||||
|
||||
const valueSpan = document.createElement("span");
|
||||
valueSpan.textContent = r.guessValue;
|
||||
cell.appendChild(valueSpan);
|
||||
|
||||
// Direction arrow for year
|
||||
if (r.direction) {
|
||||
const arrow = document.createElement("span");
|
||||
arrow.className = "direction-arrow";
|
||||
arrow.textContent = r.direction === "up" ? " ↑" : " ↓";
|
||||
cell.appendChild(arrow);
|
||||
}
|
||||
|
||||
row.appendChild(cell);
|
||||
});
|
||||
|
||||
// Staggered animation
|
||||
const cells = row.querySelectorAll(".guess-cell");
|
||||
cells.forEach((cell, i) => {
|
||||
cell.style.animationDelay = `${i * 0.08}s`;
|
||||
});
|
||||
|
||||
container.appendChild(row);
|
||||
|
||||
// Scroll to latest guess
|
||||
row.scrollIntoView({ behavior: "smooth", block: "nearest" });
|
||||
}
|
||||
|
||||
/** Render column headers for the guess grid */
|
||||
export function renderGridHeader(container) {
|
||||
const header = document.createElement("div");
|
||||
header.className = "guess-row header-row";
|
||||
|
||||
// Champion column header
|
||||
const champHeader = document.createElement("div");
|
||||
champHeader.className = "guess-cell header-cell";
|
||||
champHeader.textContent = "Champion";
|
||||
header.appendChild(champHeader);
|
||||
|
||||
CLASSIC_ATTRIBUTES.forEach((attr) => {
|
||||
const cell = document.createElement("div");
|
||||
cell.className = "guess-cell header-cell";
|
||||
cell.textContent = attr.label;
|
||||
header.appendChild(cell);
|
||||
});
|
||||
|
||||
container.appendChild(header);
|
||||
}
|
||||
|
||||
/** Show game over message */
|
||||
export function renderGameOver(container, isWon, target, guessCount) {
|
||||
const msg = document.createElement("div");
|
||||
msg.className = `game-over ${isWon ? "won" : "lost"}`;
|
||||
|
||||
const h2 = document.createElement("h2");
|
||||
const p = document.createElement("p");
|
||||
|
||||
if (isWon) {
|
||||
h2.textContent = "You got it!";
|
||||
const nameStrong = document.createElement("strong");
|
||||
nameStrong.textContent = target.name;
|
||||
const countStrong = document.createElement("strong");
|
||||
countStrong.textContent = guessCount;
|
||||
p.append("You found ", nameStrong, " in ", countStrong, ` guess${guessCount > 1 ? "es" : ""}!`);
|
||||
} else {
|
||||
h2.textContent = "Game Over";
|
||||
const nameStrong = document.createElement("strong");
|
||||
nameStrong.textContent = target.name;
|
||||
p.append("The champion was ", nameStrong);
|
||||
const img = document.createElement("img");
|
||||
img.src = getChampionImageUrl(target.id);
|
||||
img.alt = target.name;
|
||||
img.className = "reveal-image";
|
||||
msg.append(h2, p, img);
|
||||
container.appendChild(msg);
|
||||
return;
|
||||
}
|
||||
|
||||
msg.append(h2, p);
|
||||
container.appendChild(msg);
|
||||
}
|
||||
|
||||
/** Update guess counter display */
|
||||
export function updateGuessCounter(element, current, max) {
|
||||
if (max > 0) {
|
||||
element.textContent = `${current} / ${max} guesses`;
|
||||
} else {
|
||||
element.textContent = `${current} guess${current !== 1 ? "es" : ""}`;
|
||||
}
|
||||
}
|
||||
|
||||
/** Show/hide search input */
|
||||
export function setSearchEnabled(container, enabled) {
|
||||
const input = container.querySelector("#search-input");
|
||||
if (input) {
|
||||
input.disabled = !enabled;
|
||||
if (!enabled) {
|
||||
input.placeholder = "Game over";
|
||||
container.querySelector("#search-dropdown").innerHTML = "";
|
||||
container.querySelector("#search-dropdown").classList.remove("visible");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Render unlimited mode stats */
|
||||
export function renderStats(container, stats) {
|
||||
container.innerHTML = "";
|
||||
if (!stats || stats.gamesPlayed === 0) return;
|
||||
|
||||
const winRate = Math.round((stats.gamesWon / stats.gamesPlayed) * 100);
|
||||
const statsDiv = document.createElement("div");
|
||||
statsDiv.className = "stats";
|
||||
|
||||
const items = [
|
||||
{ value: stats.gamesPlayed, label: "Played" },
|
||||
{ value: `${winRate}%`, label: "Win Rate" },
|
||||
{ value: stats.gamesWon, label: "Won" },
|
||||
];
|
||||
|
||||
items.forEach(({ value, label }) => {
|
||||
const item = document.createElement("div");
|
||||
item.className = "stat-item";
|
||||
const valSpan = document.createElement("span");
|
||||
valSpan.className = "stat-value";
|
||||
valSpan.textContent = value;
|
||||
const labelSpan = document.createElement("span");
|
||||
labelSpan.className = "stat-label";
|
||||
labelSpan.textContent = label;
|
||||
item.append(valSpan, labelSpan);
|
||||
statsDiv.appendChild(item);
|
||||
});
|
||||
|
||||
container.appendChild(statsDiv);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
// Data layer: load, search, and select champions
|
||||
|
||||
let champions = [];
|
||||
let loadPromise = null;
|
||||
|
||||
/** Fetch and parse champions data (deduplicated) */
|
||||
export async function loadChampions() {
|
||||
if (champions.length > 0) return champions;
|
||||
if (loadPromise) return loadPromise;
|
||||
|
||||
loadPromise = (async () => {
|
||||
const response = await fetch("/champions.json");
|
||||
if (!response.ok) {
|
||||
loadPromise = null;
|
||||
throw new Error(`Failed to load champions: ${response.status}`);
|
||||
}
|
||||
champions = await response.json();
|
||||
return champions;
|
||||
})();
|
||||
|
||||
return loadPromise;
|
||||
}
|
||||
|
||||
/** Get all loaded champions */
|
||||
export function getAllChampions() {
|
||||
return champions;
|
||||
}
|
||||
|
||||
/** Case-insensitive lookup by name */
|
||||
export function getChampionByName(name) {
|
||||
const lower = name.toLowerCase();
|
||||
return champions.find((c) => c.name.toLowerCase() === lower) || null;
|
||||
}
|
||||
|
||||
/** Filter champions for autocomplete (prefix-first, then substring) */
|
||||
export function searchChampions(query, excludeNames = []) {
|
||||
if (!query) return [];
|
||||
const lower = query.toLowerCase();
|
||||
const excluded = new Set(excludeNames.map((n) => n.toLowerCase()));
|
||||
|
||||
return champions
|
||||
.filter(
|
||||
(c) =>
|
||||
c.name.toLowerCase().includes(lower) &&
|
||||
!excluded.has(c.name.toLowerCase()),
|
||||
)
|
||||
.sort((a, b) => {
|
||||
const aStarts = a.name.toLowerCase().startsWith(lower) ? 0 : 1;
|
||||
const bStarts = b.name.toLowerCase().startsWith(lower) ? 0 : 1;
|
||||
return aStarts - bStarts || a.name.localeCompare(b.name);
|
||||
})
|
||||
.slice(0, 8);
|
||||
}
|
||||
|
||||
/** Seeded random champion selection (deterministic for same seed string) */
|
||||
export function getRandomChampion(seed) {
|
||||
if (!champions.length) return null;
|
||||
const hash = hashString(seed);
|
||||
const index = hash % champions.length;
|
||||
return champions[index];
|
||||
}
|
||||
|
||||
/** Simple string hash (djb2) */
|
||||
function hashString(str) {
|
||||
let hash = 5381;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash = (hash * 33) ^ str.charCodeAt(i);
|
||||
}
|
||||
return hash >>> 0;
|
||||
}
|
||||
|
||||
/** Get today's date string for daily seed */
|
||||
export function getTodaySeed() {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/** Get champion image URL from Data Dragon CDN */
|
||||
export function getChampionImageUrl(championId) {
|
||||
return `https://ddragon.leagueoflegends.com/cdn/img/champion/tiles/${championId}_0.jpg`;
|
||||
}
|
||||
@@ -11,10 +11,7 @@ export const CLASSIC_ATTRIBUTES = [
|
||||
{ key: "releaseDate", label: "Year", type: "year" },
|
||||
];
|
||||
|
||||
/**
|
||||
* Compare guess champion against target champion
|
||||
* @returns {Array} Array of { attribute, label, guessValue, targetValue, result, direction? }
|
||||
*/
|
||||
/** Compare guess champion against target champion */
|
||||
export function compareChampions(guess, target) {
|
||||
return CLASSIC_ATTRIBUTES.map((attr) => {
|
||||
const guessVal = guess[attr.key] || "";
|
||||
@@ -51,51 +48,36 @@ export function compareChampions(guess, target) {
|
||||
});
|
||||
}
|
||||
|
||||
/** Compare comma-separated multi-value fields */
|
||||
function compareMultiValue(guessStr, targetStr) {
|
||||
const guessSet = parseSet(guessStr);
|
||||
const targetSet = parseSet(targetStr);
|
||||
|
||||
if (guessSet.size === 0 && targetSet.size === 0) return "correct";
|
||||
if (guessSet.size === 0 || targetSet.size === 0) return "wrong";
|
||||
|
||||
// Check if sets are equal
|
||||
if (setsEqual(guessSet, targetSet)) return "correct";
|
||||
|
||||
// Check for any overlap
|
||||
for (const val of guessSet) {
|
||||
if (targetSet.has(val)) return "partial";
|
||||
}
|
||||
|
||||
return "wrong";
|
||||
}
|
||||
|
||||
/** Compare release years with direction */
|
||||
function compareYear(guessYear, targetYear) {
|
||||
const g = Number(guessYear);
|
||||
const t = Number(targetYear);
|
||||
|
||||
if (!g || !t) return { result: "wrong" };
|
||||
if (g === t) return { result: "correct" };
|
||||
|
||||
return {
|
||||
result: "wrong",
|
||||
direction: g < t ? "up" : "down", // up = guess is too low
|
||||
};
|
||||
return { result: "wrong", direction: g < t ? "up" : "down" };
|
||||
}
|
||||
|
||||
/** Parse comma-separated string into lowercase Set */
|
||||
function parseSet(str) {
|
||||
if (!str) return new Set();
|
||||
return new Set(
|
||||
str
|
||||
.split(",")
|
||||
.map((s) => s.trim().toLowerCase())
|
||||
.filter(Boolean),
|
||||
str.split(",").map((s) => s.trim().toLowerCase()).filter(Boolean),
|
||||
);
|
||||
}
|
||||
|
||||
/** Check if two Sets are equal */
|
||||
function setsEqual(a, b) {
|
||||
if (a.size !== b.size) return false;
|
||||
for (const val of a) {
|
||||
@@ -104,7 +86,6 @@ function setsEqual(a, b) {
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Format display values for readability */
|
||||
function formatValue(key, value) {
|
||||
if (!value) return "—";
|
||||
|
||||
@@ -114,16 +95,10 @@ function formatValue(key, value) {
|
||||
case "attackType":
|
||||
return value === "close" ? "Melee" : "Ranged";
|
||||
case "region":
|
||||
return value
|
||||
.split("-")
|
||||
.map(capitalize)
|
||||
.join(" ");
|
||||
return value.split("-").map(capitalize).join(" ");
|
||||
case "genre":
|
||||
case "lane":
|
||||
return value
|
||||
.split(",")
|
||||
.map((s) => capitalize(s.trim()))
|
||||
.join(", ");
|
||||
return value.split(",").map((s) => capitalize(s.trim())).join(", ");
|
||||
default:
|
||||
return value;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
// Mode-agnostic game state machine with localStorage persistence
|
||||
|
||||
const STORAGE_KEY_PREFIX = "loldle_";
|
||||
const UNLIMITED_SEED_KEY = `${STORAGE_KEY_PREFIX}unlimited_seed`;
|
||||
|
||||
/**
|
||||
* Create a new game instance
|
||||
@@ -9,12 +10,12 @@ const STORAGE_KEY_PREFIX = "loldle_";
|
||||
* @param {Function} config.compareFn - (guess, target) => comparison results
|
||||
* @param {number} config.maxGuesses - Max allowed guesses (0 = unlimited)
|
||||
* @param {string} config.mode - "daily" or "unlimited"
|
||||
* @param {string} config.seed - Seed string for daily mode
|
||||
* @param {string} config.seed - Seed string for mode
|
||||
*/
|
||||
export function createGame(config) {
|
||||
const { target, compareFn, maxGuesses = 6, mode = "daily", seed = "" } = config;
|
||||
|
||||
// Try to restore saved state for daily mode
|
||||
// Try to restore saved state
|
||||
const saved = loadState(mode, seed);
|
||||
if (saved && saved.targetName === target.name) {
|
||||
return {
|
||||
@@ -43,32 +44,28 @@ export function createGame(config) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit a guess and return comparison result
|
||||
* @returns {Object|null} Comparison result, or null if game is over
|
||||
*/
|
||||
/** Submit a guess and return updated game state (immutable) */
|
||||
export function submitGuess(game, champion) {
|
||||
if (game.isOver) return null;
|
||||
|
||||
// Prevent duplicate guesses
|
||||
if (game.guesses.some((g) => g.name === champion.name)) return null;
|
||||
|
||||
const result = game.compareFn(champion, game.target);
|
||||
game.guesses.push(champion);
|
||||
game.results.push(result);
|
||||
const guesses = [...game.guesses, champion];
|
||||
const results = [...game.results, result];
|
||||
|
||||
let isWon = false;
|
||||
let isOver = false;
|
||||
|
||||
// Check win
|
||||
if (champion.name === game.target.name) {
|
||||
game.isWon = true;
|
||||
game.isOver = true;
|
||||
}
|
||||
// Check loss (only if maxGuesses > 0)
|
||||
else if (game.maxGuesses > 0 && game.guesses.length >= game.maxGuesses) {
|
||||
game.isOver = true;
|
||||
isWon = true;
|
||||
isOver = true;
|
||||
} else if (game.maxGuesses > 0 && guesses.length >= game.maxGuesses) {
|
||||
isOver = true;
|
||||
}
|
||||
|
||||
saveState(game);
|
||||
return result;
|
||||
const updated = { ...game, guesses, results, isWon, isOver };
|
||||
saveState(updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
/** Get names of already-guessed champions */
|
||||
@@ -76,54 +73,39 @@ export function getGuessedNames(game) {
|
||||
return game.guesses.map((g) => g.name);
|
||||
}
|
||||
|
||||
/** Save game state to localStorage */
|
||||
function saveState(game) {
|
||||
const key = getStorageKey(game.mode, game.seed);
|
||||
const data = {
|
||||
targetName: game.target.name,
|
||||
guesses: game.guesses,
|
||||
results: game.results,
|
||||
isOver: game.isOver,
|
||||
isWon: game.isWon,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
/** Get or create a persistent seed for unlimited mode */
|
||||
export function getOrCreateUnlimitedSeed() {
|
||||
try {
|
||||
localStorage.setItem(key, JSON.stringify(data));
|
||||
const saved = localStorage.getItem(UNLIMITED_SEED_KEY);
|
||||
if (saved) return saved;
|
||||
} catch {
|
||||
// Storage full or unavailable — silently ignore
|
||||
// Ignore
|
||||
}
|
||||
return createNewUnlimitedSeed();
|
||||
}
|
||||
|
||||
/** Load saved game state from localStorage */
|
||||
function loadState(mode, seed) {
|
||||
const key = getStorageKey(mode, seed);
|
||||
/** Create and persist a new unlimited seed */
|
||||
function createNewUnlimitedSeed() {
|
||||
const seed = `unlimited_${Date.now()}_${Math.random()}`;
|
||||
try {
|
||||
const raw = localStorage.getItem(key);
|
||||
if (!raw) return null;
|
||||
return JSON.parse(raw);
|
||||
localStorage.setItem(UNLIMITED_SEED_KEY, seed);
|
||||
} catch {
|
||||
return null;
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
|
||||
/** Build storage key based on mode */
|
||||
function getStorageKey(mode, seed) {
|
||||
if (mode === "daily") {
|
||||
return `${STORAGE_KEY_PREFIX}daily_${seed}`;
|
||||
}
|
||||
return `${STORAGE_KEY_PREFIX}unlimited_current`;
|
||||
return seed;
|
||||
}
|
||||
|
||||
/** Clear unlimited mode saved state (for new game) */
|
||||
export function clearUnlimitedState() {
|
||||
try {
|
||||
localStorage.removeItem(`${STORAGE_KEY_PREFIX}unlimited_current`);
|
||||
localStorage.removeItem(UNLIMITED_SEED_KEY);
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
|
||||
/** Save unlimited mode stats (wins, total games, guess distribution) */
|
||||
/** Save unlimited mode stats */
|
||||
export function saveUnlimitedStats(game) {
|
||||
if (game.mode !== "unlimited" || !game.isOver) return;
|
||||
|
||||
@@ -162,3 +144,36 @@ export function loadUnlimitedStats() {
|
||||
function createEmptyStats() {
|
||||
return { gamesPlayed: 0, gamesWon: 0, guessDistribution: {}, lastPlayed: null };
|
||||
}
|
||||
|
||||
function saveState(game) {
|
||||
const key = getStorageKey(game.mode, game.seed);
|
||||
const data = {
|
||||
targetName: game.target.name,
|
||||
guesses: game.guesses,
|
||||
results: game.results,
|
||||
isOver: game.isOver,
|
||||
isWon: game.isWon,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
try {
|
||||
localStorage.setItem(key, JSON.stringify(data));
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
|
||||
function loadState(mode, seed) {
|
||||
const key = getStorageKey(mode, seed);
|
||||
try {
|
||||
const raw = localStorage.getItem(key);
|
||||
if (!raw) return null;
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getStorageKey(mode, seed) {
|
||||
if (mode === "daily") return `${STORAGE_KEY_PREFIX}daily_${seed}`;
|
||||
return `${STORAGE_KEY_PREFIX}unlimited_current`;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "ddragon.leagueoflegends.com",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
Generated
+6525
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "loldle",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint"
|
||||
},
|
||||
"dependencies": {
|
||||
"next": "16.2.2",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.2.2",
|
||||
"tailwindcss": "^4"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
Reference in New Issue
Block a user