feat: implement LoLdle Classic mode with daily and unlimited play

Vanilla JS web game with modular architecture:
- data-loader: champion data fetching, seeded random selection
- game-engine: mode-agnostic state machine with localStorage persistence
- classic-mode: 7-attribute comparison logic (gender, genre, range, resource, region, lane, year)
- ui-renderer: search autocomplete, colored feedback grid, stats display
- Dark theme responsive UI with Data Dragon CDN champion images
This commit is contained in:
2026-04-04 11:17:47 +07:00
parent d8d7d8e721
commit 9572f39f59
8 changed files with 1259 additions and 0 deletions
+3
View File
@@ -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).
+403
View File
@@ -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;
}
}
+35
View File
@@ -0,0 +1,35 @@
<!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
View File
@@ -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();
+134
View File
@@ -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);
}
+52
View File
@@ -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`;
}
+164
View File
@@ -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 };
}
+272
View File
@@ -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);
}