mirror of
https://github.com/tiennm99/loldle.git
synced 2026-10-20 02:17:45 +00:00
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:
1 parent
9572f39f59
commit
d9ae9c97d2
25 files changed
+7323
-1036
No files matched your search
@@ -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`;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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`;
|
||||
}
|
||||
Reference in new issue
Block a user