const STORAGE_KEY = "levelup-typing-save-v1"; const WORD_BANKS = { easy: [ "apple", "river", "light", "happy", "quick", "smile", "cloud", "dream", "friend", "learn", "typing", "music", "garden", "planet", "window", "ticket", "puzzle", "silver", "orange", "pepper" ], normal: [ "speed", "skill", "sprint", "rhythm", "tempo", "focus", "kinetic", "logic", "phrase", "arcade", "streak", "vector", "groove", "momentum", "signal", "catalyst", "syntax", "drift", "horizon", "quantum" ], hard: [ "architect", "backspace", "fragment", "notation", "velocity", "parallel", "hexadecimal", "interface", "algorithm", "synchronize", "compilation", "resolution", "parameter", "validation", "hyperlink", "generator", "trajectory", "oscillation", "framework", "benchmark" ], expert: [ "extraordinary", "counterclockwise", "asymptotically", "deconstruction", "characteristically", "electromagnetic", "microarchitecture", "misconfiguration", "representational", "interoperability", "cryptographically", "institutionalize", "uncharacteristic", "transcontinental", "hyperparameter", "instrumentation", "multiprocessing", "disproportionate", "comprehensibility", "photosynthesis" ] }; const DIFFICULTY = { easy: { label: "easy", xpMult: 0.9, roundWords: 38 }, normal: { label: "normal", xpMult: 1, roundWords: 45 }, hard: { label: "hard", xpMult: 1.25, roundWords: 50 }, expert: { label: "expert", xpMult: 1.5, roundWords: 54 } }; const PERKS = [ { id: "combo-boost", name: "combo boost", unlockLevel: 2, cost: 1, description: "Earn 15% more XP while your best combo is 5 or higher." }, { id: "accuracy-bounty", name: "accuracy bounty", unlockLevel: 4, cost: 2, description: "If you finish with 96%+ accuracy, gain +30 bonus XP." }, { id: "time-warp", name: "time warp", unlockLevel: 6, cost: 2, description: "Start rounds with +8 seconds for easier high-streak runs." }, { id: "focus-engine", name: "focus engine", unlockLevel: 8, cost: 3, description: "At 98%+ accuracy, your WPM contributes an extra 25% XP." } ]; const defaultState = { profileName: "rookie typer", difficulty: "normal", words: [], index: 0, typedChars: 0, correctWords: 0, wrongWords: 0, combo: 0, bestCombo: 0, timer: null, elapsedSeconds: 0, roundSeconds: 60, running: false, xp: 0, level: 1, shards: 0, lastRoundXp: 0, ownedPerks: [], stats: { totalRounds: 0, bestWpm: 0, bestAccuracy: 0, averageWpm: 0, averageAccuracy: 0 }, recentRounds: [] }; const state = { ...defaultState }; const els = { textDisplay: document.getElementById("textDisplay"), typeInput: document.getElementById("typeInput"), newRoundBtn: document.getElementById("newRoundBtn"), settingsBtn: document.getElementById("settingsBtn"), closeSettingsBtn: document.getElementById("closeSettingsBtn"), settingsModal: document.getElementById("settingsModal"), resetConfirmModal: document.getElementById("resetConfirmModal"), cancelResetBtn: document.getElementById("cancelResetBtn"), confirmResetBtn: document.getElementById("confirmResetBtn"), playAgainBtn: document.getElementById("playAgainBtn"), results: document.getElementById("results"), resultsSummary: document.getElementById("resultsSummary"), wpm: document.getElementById("wpm"), accuracy: document.getElementById("accuracy"), combo: document.getElementById("combo"), time: document.getElementById("time"), profileTitle: document.getElementById("profileTitle"), levelText: document.getElementById("levelText"), xpText: document.getElementById("xpText"), shardText: document.getElementById("shardText"), xpFill: document.getElementById("xpFill"), xpGain: document.getElementById("xpGain"), perks: document.getElementById("perks"), profileName: document.getElementById("profileName"), difficultySelect: document.getElementById("difficultySelect"), difficultyBonus: document.getElementById("difficultyBonus"), resetProgressBtn: document.getElementById("resetProgressBtn") }; function xpNeededForLevel(level) { return 120 + (level - 1) * 60; } function sanitizeDifficulty(value) { return DIFFICULTY[value] ? value : "normal"; } function saveProgress() { const payload = { profileName: state.profileName, difficulty: state.difficulty, xp: state.xp, level: state.level, shards: state.shards, lastRoundXp: state.lastRoundXp, ownedPerks: state.ownedPerks, stats: state.stats, recentRounds: state.recentRounds }; localStorage.setItem(STORAGE_KEY, JSON.stringify(payload)); } function loadProgress() { const raw = localStorage.getItem(STORAGE_KEY); if (!raw) return; try { const parsed = JSON.parse(raw); state.profileName = typeof parsed.profileName === "string" && parsed.profileName.trim() ? parsed.profileName.trim().slice(0, 24) : defaultState.profileName; state.difficulty = sanitizeDifficulty(parsed.difficulty); state.xp = Number.isFinite(parsed.xp) ? Math.max(0, Math.floor(parsed.xp)) : 0; state.level = Number.isFinite(parsed.level) ? Math.max(1, Math.floor(parsed.level)) : 1; state.shards = Number.isFinite(parsed.shards) ? Math.max(0, Math.floor(parsed.shards)) : 0; state.lastRoundXp = Number.isFinite(parsed.lastRoundXp) ? Math.max(0, Math.floor(parsed.lastRoundXp)) : 0; state.ownedPerks = Array.isArray(parsed.ownedPerks) ? parsed.ownedPerks.filter((id) => PERKS.some((perk) => perk.id === id)) : []; const parsedStats = parsed.stats || {}; state.stats = { totalRounds: Number.isFinite(parsedStats.totalRounds) ? Math.max(0, Math.floor(parsedStats.totalRounds)) : 0, bestWpm: Number.isFinite(parsedStats.bestWpm) ? Math.max(0, Math.floor(parsedStats.bestWpm)) : 0, bestAccuracy: Number.isFinite(parsedStats.bestAccuracy) ? Math.max(0, Math.floor(parsedStats.bestAccuracy)) : 0, averageWpm: Number.isFinite(parsedStats.averageWpm) ? Math.max(0, Number(parsedStats.averageWpm)) : 0, averageAccuracy: Number.isFinite(parsedStats.averageAccuracy) ? Math.max(0, Number(parsedStats.averageAccuracy)) : 0 }; state.recentRounds = Array.isArray(parsed.recentRounds) ? parsed.recentRounds.slice(0, 20) : []; } catch (error) { localStorage.removeItem(STORAGE_KEY); } } function trackRound(summary, earnedXp) { state.stats.totalRounds += 1; state.stats.bestWpm = Math.max(state.stats.bestWpm, summary.wpm); state.stats.bestAccuracy = Math.max(state.stats.bestAccuracy, summary.accuracy); const rounds = state.stats.totalRounds; state.stats.averageWpm = ((state.stats.averageWpm * (rounds - 1)) + summary.wpm) / rounds; state.stats.averageAccuracy = ((state.stats.averageAccuracy * (rounds - 1)) + summary.accuracy) / rounds; const snapshot = { stamp: Date.now(), wpm: summary.wpm, accuracy: summary.accuracy, bestCombo: summary.bestCombo, xp: earnedXp, difficulty: state.difficulty }; state.recentRounds.unshift(snapshot); if (state.recentRounds.length > 20) { state.recentRounds.length = 20; } } function openSettings() { els.settingsModal.hidden = false; } function closeSettings() { els.settingsModal.hidden = true; } function openResetConfirm() { els.resetConfirmModal.hidden = false; } function closeResetConfirm() { els.resetConfirmModal.hidden = true; } function pickWords() { const config = DIFFICULTY[state.difficulty]; const pool = WORD_BANKS[state.difficulty]; const result = []; for (let i = 0; i < config.roundWords; i += 1) { result.push(pool[Math.floor(Math.random() * pool.length)]); } return result; } function hasPerk(id) { return state.ownedPerks.includes(id); } function renderWords() { els.textDisplay.innerHTML = ""; state.words.forEach((word, idx) => { const span = document.createElement("span"); span.className = "word"; if (idx === state.index) span.classList.add("current"); span.textContent = `${word} `; els.textDisplay.appendChild(span); }); } function renderPerks() { els.perks.innerHTML = ""; PERKS.forEach((perk) => { const levelReady = state.level >= perk.unlockLevel; const owned = hasPerk(perk.id); const canBuy = levelReady && !owned && state.shards >= perk.cost; const card = document.createElement("article"); card.className = "perk"; card.innerHTML = `

${perk.name} ยท Lv ${perk.unlockLevel}

${perk.description}

Cost: ${perk.cost} shard${perk.cost > 1 ? "s" : ""}
${owned ? "active" : levelReady ? "shop open" : "locked"} `; els.perks.appendChild(card); }); } function renderSettings() { const config = DIFFICULTY[state.difficulty]; els.profileName.value = state.profileName; els.difficultySelect.value = state.difficulty; els.difficultyBonus.textContent = `xp bonus: x${config.xpMult.toFixed(2)}`; } function renderProgress() { const levelNeed = xpNeededForLevel(state.level); const ratio = Math.min((state.xp / levelNeed) * 100, 100); els.profileTitle.textContent = state.profileName; els.levelText.textContent = `level ${state.level}`; els.xpText.textContent = `${state.xp} / ${levelNeed} xp`; els.shardText.textContent = `perk shards: ${state.shards}`; els.xpFill.style.width = `${ratio}%`; els.xpGain.textContent = `+${state.lastRoundXp} xp last round`; renderSettings(); renderPerks(); } function updateLiveStats() { const elapsed = Math.max(state.elapsedSeconds / 60, 1 / 60); const wordsTypedEstimate = state.typedChars / 5; const wpm = Math.round(wordsTypedEstimate / elapsed); const totalWords = state.correctWords + state.wrongWords; const acc = totalWords === 0 ? 100 : Math.round((state.correctWords / totalWords) * 100); els.wpm.textContent = String(wpm); els.accuracy.textContent = `${acc}%`; els.combo.textContent = `x${state.combo}`; } function updateTimer() { const remaining = Math.max(state.roundSeconds - state.elapsedSeconds, 0); els.time.textContent = `${remaining}s`; els.time.style.color = remaining <= 10 ? "#ffd587" : ""; } function startTimer() { if (state.running) return; state.running = true; state.timer = setInterval(() => { state.elapsedSeconds += 1; updateTimer(); updateLiveStats(); if (state.elapsedSeconds >= state.roundSeconds) { finishRound(); } }, 1000); } function awardXp(summary) { let xp = 35; xp += Math.floor(summary.wpm * 1.4); xp += Math.floor(summary.accuracy * 0.55); xp += Math.floor(summary.bestCombo * 2.5); if (hasPerk("combo-boost") && summary.bestCombo >= 5) { xp = Math.floor(xp * 1.15); } if (hasPerk("accuracy-bounty") && summary.accuracy >= 96) { xp += 30; } if (hasPerk("focus-engine") && summary.accuracy >= 98) { xp += Math.floor(summary.wpm * 0.25); } xp = Math.floor(xp * DIFFICULTY[state.difficulty].xpMult); let pending = xp; let levelUps = 0; while (pending > 0) { const need = xpNeededForLevel(state.level) - state.xp; if (pending >= need) { pending -= need; state.level += 1; levelUps += 1; state.xp = 0; } else { state.xp += pending; pending = 0; } } state.shards += levelUps; state.lastRoundXp = xp; return xp; } function markWordResult(correct) { const wordNodes = els.textDisplay.querySelectorAll(".word"); const node = wordNodes[state.index]; if (!node) return; node.classList.remove("current"); node.classList.add(correct ? "correct" : "incorrect"); } function setCurrentWord() { const wordNodes = els.textDisplay.querySelectorAll(".word"); const current = wordNodes[state.index]; if (current) { current.classList.add("current"); current.scrollIntoView({ block: "center", behavior: "smooth" }); } } function finishRound() { clearInterval(state.timer); state.running = false; els.typeInput.disabled = true; const elapsedMinutes = Math.max(state.elapsedSeconds / 60, 1 / 60); const wpm = Math.round((state.typedChars / 5) / elapsedMinutes); const totalWords = state.correctWords + state.wrongWords; const accuracy = totalWords === 0 ? 100 : Math.round((state.correctWords / totalWords) * 100); const summary = { wpm, accuracy, bestCombo: state.bestCombo }; const earnedXp = awardXp(summary); trackRound(summary, earnedXp); renderProgress(); updateLiveStats(); saveProgress(); els.results.hidden = false; els.resultsSummary.textContent = `you reached ${wpm} wpm with ${accuracy}% accuracy and earned ${earnedXp} xp!`; } function resetRound() { clearInterval(state.timer); state.words = pickWords(); state.index = 0; state.typedChars = 0; state.correctWords = 0; state.wrongWords = 0; state.combo = 0; state.bestCombo = 0; state.elapsedSeconds = 0; state.roundSeconds = hasPerk("time-warp") ? 68 : 60; state.running = false; renderWords(); updateLiveStats(); updateTimer(); els.typeInput.disabled = false; els.typeInput.value = ""; els.typeInput.focus(); els.results.hidden = true; } function setProfileName(value) { const clean = value.trim().slice(0, 24); state.profileName = clean || defaultState.profileName; renderProgress(); saveProgress(); } function setDifficulty(value) { state.difficulty = sanitizeDifficulty(value); renderProgress(); resetRound(); saveProgress(); } function buyPerk(perkId) { const perk = PERKS.find((item) => item.id === perkId); if (!perk) return; if (state.level < perk.unlockLevel) return; if (hasPerk(perkId)) return; if (state.shards < perk.cost) return; state.shards -= perk.cost; state.ownedPerks.push(perk.id); renderProgress(); resetRound(); saveProgress(); } function resetProgress() { localStorage.removeItem(STORAGE_KEY); clearInterval(state.timer); Object.assign(state, { ...defaultState, ownedPerks: [], stats: { ...defaultState.stats }, recentRounds: [] }); renderProgress(); resetRound(); saveProgress(); } els.typeInput.addEventListener("input", () => { const value = els.typeInput.value; if (!state.running && value.length > 0) { startTimer(); } if (!value.endsWith(" ")) return; const typedWord = value.trim(); const targetWord = state.words[state.index]; const correct = typedWord === targetWord; state.typedChars += typedWord.length + 1; if (correct) { state.correctWords += 1; state.combo += 1; if (state.combo > state.bestCombo) { state.bestCombo = state.combo; } } else { state.wrongWords += 1; state.combo = 0; } markWordResult(correct); state.index += 1; els.typeInput.value = ""; if (state.index >= state.words.length) { finishRound(); return; } setCurrentWord(); updateLiveStats(); }); els.newRoundBtn.addEventListener("click", resetRound); els.playAgainBtn.addEventListener("click", resetRound); els.settingsBtn.addEventListener("click", openSettings); els.closeSettingsBtn.addEventListener("click", closeSettings); els.profileName.addEventListener("change", (event) => setProfileName(event.target.value)); els.difficultySelect.addEventListener("change", (event) => setDifficulty(event.target.value)); els.resetProgressBtn.addEventListener("click", openResetConfirm); els.cancelResetBtn.addEventListener("click", closeResetConfirm); els.confirmResetBtn.addEventListener("click", () => { resetProgress(); closeResetConfirm(); closeSettings(); }); els.settingsModal.addEventListener("click", (event) => { if (event.target === els.settingsModal) { closeSettings(); } }); els.resetConfirmModal.addEventListener("click", (event) => { if (event.target === els.resetConfirmModal) { closeResetConfirm(); } }); document.addEventListener("keydown", (event) => { if (event.key === "Escape" && !els.resetConfirmModal.hidden) { closeResetConfirm(); return; } if (event.key === "Escape" && !els.settingsModal.hidden) { closeSettings(); } }); els.perks.addEventListener("click", (event) => { const target = event.target; if (!(target instanceof HTMLButtonElement)) return; const perkId = target.dataset.perkId; if (!perkId) return; buyPerk(perkId); }); loadProgress(); renderProgress(); resetRound();