diff --git a/web/README.md b/web/README.md index dd8a1f0..8f16b2d 100644 --- a/web/README.md +++ b/web/README.md @@ -1,2 +1,5 @@ # loldle Just a rewritten LoLdle + +## Credits +- [champions.json](assets/champions.json) is from [Kerrders's gist](https://gist.github.com/Kerrders/0067d88dfd982c272e20dcb496f4dbc7). Checkout how he export it in [this repo](https://github.com/Kerrders/LoLdleData). diff --git a/web/css/styles.css b/web/css/styles.css new file mode 100644 index 0000000..a68e0dc --- /dev/null +++ b/web/css/styles.css @@ -0,0 +1,403 @@ +/* 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; + } +} diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..eb521fc --- /dev/null +++ b/web/index.html @@ -0,0 +1,35 @@ + + + + + + LoLdle Classic + + + +
+

LoLdle

+
+ + +
+
+ +
+ +
+ +
+
+ +

+ +
+
+
+ +
+ + + + diff --git a/web/js/app.js b/web/js/app.js new file mode 100644 index 0000000..020625c --- /dev/null +++ b/web/js/app.js @@ -0,0 +1,196 @@ +// 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(); diff --git a/web/js/classic-mode.js b/web/js/classic-mode.js new file mode 100644 index 0000000..b13204a --- /dev/null +++ b/web/js/classic-mode.js @@ -0,0 +1,134 @@ +// Classic mode: compare two champions across 7 attributes + +/** Attributes displayed in order */ +export const CLASSIC_ATTRIBUTES = [ + { key: "gender", label: "Gender", type: "exact" }, + { key: "genre", label: "Genre", type: "multi" }, + { key: "attackType", label: "Range", type: "exact" }, + { key: "resource", label: "Resource", type: "exact" }, + { key: "region", label: "Region", type: "exact" }, + { key: "lane", label: "Lane", type: "multi" }, + { key: "releaseDate", label: "Year", type: "year" }, +]; + +/** + * Compare guess champion against target champion + * @returns {Array} Array of { attribute, label, guessValue, targetValue, result, direction? } + */ +export function compareChampions(guess, target) { + return CLASSIC_ATTRIBUTES.map((attr) => { + const guessVal = guess[attr.key] || ""; + const targetVal = target[attr.key] || ""; + + switch (attr.type) { + case "exact": + return { + ...attr, + guessValue: formatValue(attr.key, guessVal), + targetValue: formatValue(attr.key, targetVal), + result: guessVal.toLowerCase() === targetVal.toLowerCase() ? "correct" : "wrong", + }; + + case "multi": + return { + ...attr, + guessValue: formatValue(attr.key, guessVal), + targetValue: formatValue(attr.key, targetVal), + result: compareMultiValue(guessVal, targetVal), + }; + + case "year": + return { + ...attr, + guessValue: guessVal || "?", + targetValue: targetVal || "?", + ...compareYear(guessVal, targetVal), + }; + + default: + return { ...attr, guessValue: guessVal, targetValue: targetVal, result: "wrong" }; + } + }); +} + +/** 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 + }; +} + +/** 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), + ); +} + +/** Check if two Sets are equal */ +function setsEqual(a, b) { + if (a.size !== b.size) return false; + for (const val of a) { + if (!b.has(val)) return false; + } + return true; +} + +/** Format display values for readability */ +function formatValue(key, value) { + if (!value) return "—"; + + switch (key) { + case "gender": + return capitalize(value); + case "attackType": + return value === "close" ? "Melee" : "Ranged"; + case "region": + return value + .split("-") + .map(capitalize) + .join(" "); + case "genre": + case "lane": + return value + .split(",") + .map((s) => capitalize(s.trim())) + .join(", "); + default: + return value; + } +} + +function capitalize(str) { + return str.charAt(0).toUpperCase() + str.slice(1); +} diff --git a/web/js/data-loader.js b/web/js/data-loader.js new file mode 100644 index 0000000..d038175 --- /dev/null +++ b/web/js/data-loader.js @@ -0,0 +1,52 @@ +// 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`; +} diff --git a/web/js/game-engine.js b/web/js/game-engine.js new file mode 100644 index 0000000..d94de73 --- /dev/null +++ b/web/js/game-engine.js @@ -0,0 +1,164 @@ +// Mode-agnostic game state machine with localStorage persistence + +const STORAGE_KEY_PREFIX = "loldle_"; + +/** + * Create a new game instance + * @param {Object} config + * @param {Object} config.target - Target champion to guess + * @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 + */ +export function createGame(config) { + const { target, compareFn, maxGuesses = 6, mode = "daily", seed = "" } = config; + + // Try to restore saved state for daily mode + const saved = loadState(mode, seed); + if (saved && saved.targetName === target.name) { + return { + target, + compareFn, + maxGuesses, + mode, + seed, + guesses: saved.guesses, + results: saved.results, + isOver: saved.isOver, + isWon: saved.isWon, + }; + } + + return { + target, + compareFn, + maxGuesses, + mode, + seed, + guesses: [], + results: [], + isOver: false, + isWon: false, + }; +} + +/** + * Submit a guess and return comparison result + * @returns {Object|null} Comparison result, or null if game is over + */ +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); + + // 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; + } + + saveState(game); + return result; +} + +/** Get names of already-guessed champions */ +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(), + }; + try { + localStorage.setItem(key, JSON.stringify(data)); + } catch { + // Storage full or unavailable — silently ignore + } +} + +/** Load saved game state from localStorage */ +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; + } +} + +/** 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`; +} + +/** Clear unlimited mode saved state (for new game) */ +export function clearUnlimitedState() { + try { + localStorage.removeItem(`${STORAGE_KEY_PREFIX}unlimited_current`); + } catch { + // Ignore + } +} + +/** Save unlimited mode stats (wins, total games, guess distribution) */ +export function saveUnlimitedStats(game) { + if (game.mode !== "unlimited" || !game.isOver) return; + + const key = `${STORAGE_KEY_PREFIX}unlimited_stats`; + let stats; + try { + stats = JSON.parse(localStorage.getItem(key)) || createEmptyStats(); + } catch { + stats = createEmptyStats(); + } + + stats.gamesPlayed++; + if (game.isWon) { + stats.gamesWon++; + const guessCount = game.guesses.length; + stats.guessDistribution[guessCount] = (stats.guessDistribution[guessCount] || 0) + 1; + } + stats.lastPlayed = Date.now(); + + try { + localStorage.setItem(key, JSON.stringify(stats)); + } catch { + // Ignore + } +} + +/** Load unlimited stats */ +export function loadUnlimitedStats() { + try { + return JSON.parse(localStorage.getItem(`${STORAGE_KEY_PREFIX}unlimited_stats`)) || createEmptyStats(); + } catch { + return createEmptyStats(); + } +} + +function createEmptyStats() { + return { gamesPlayed: 0, gamesWon: 0, guessDistribution: {}, lastPlayed: null }; +} diff --git a/web/js/ui-renderer.js b/web/js/ui-renderer.js new file mode 100644 index 0000000..85610ac --- /dev/null +++ b/web/js/ui-renderer.js @@ -0,0 +1,272 @@ +// 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); +}