mirror of
https://github.com/tiennm99/loldle.git
synced 2026-10-11 03:13:42 +00:00
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
273 lines
8.4 KiB
JavaScript
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);
|
|
}
|