Files
loldle/web/js/ui-renderer.js
T
tiennm99 9572f39f59 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
2026-04-04 11:17:47 +07:00

273 lines
8.4 KiB
JavaScript

// 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);
}