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:
tiennm99 committed 2026-04-04 11:52:59 +07:00
1 parent 9572f39f59
commit d9ae9c97d2
25 files changed
+7323 -1036

No files matched your search

+80
View File
@@ -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`;
}
+109
View File
@@ -0,0 +1,109 @@
// 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 */
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" };
}
});
}
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";
if (setsEqual(guessSet, targetSet)) return "correct";
for (const val of guessSet) {
if (targetSet.has(val)) return "partial";
}
return "wrong";
}
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" };
}
function parseSet(str) {
if (!str) return new Set();
return new Set(
str.split(",").map((s) => s.trim().toLowerCase()).filter(Boolean),
);
}
function setsEqual(a, b) {
if (a.size !== b.size) return false;
for (const val of a) {
if (!b.has(val)) return false;
}
return true;
}
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);
}
+179
View File
@@ -0,0 +1,179 @@
// 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
* @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 mode
*/
export function createGame(config) {
const { target, compareFn, maxGuesses = 6, mode = "daily", seed = "" } = config;
// Try to restore saved state
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 updated game state (immutable) */
export function submitGuess(game, champion) {
if (game.isOver) return null;
if (game.guesses.some((g) => g.name === champion.name)) return null;
const result = game.compareFn(champion, game.target);
const guesses = [...game.guesses, champion];
const results = [...game.results, result];
let isWon = false;
let isOver = false;
if (champion.name === game.target.name) {
isWon = true;
isOver = true;
} else if (game.maxGuesses > 0 && guesses.length >= game.maxGuesses) {
isOver = true;
}
const updated = { ...game, guesses, results, isWon, isOver };
saveState(updated);
return updated;
}
/** Get names of already-guessed champions */
export function getGuessedNames(game) {
return game.guesses.map((g) => g.name);
}
/** Get or create a persistent seed for unlimited mode */
export 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;
}
/** 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 */
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 };
}
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`;
}