diff --git a/index.js b/index.js index c5b2fba..0c61104 100644 --- a/index.js +++ b/index.js @@ -1,45 +1,10 @@ const http = require("http"); -const fs = require("fs"); -const path = require("path"); -const { createLogger, format, transports } = require("winston"); -const { consoleFormat } = require("winston-console-format"); -const util = require("util"); +const { readFile, stat } = require("fs"); +const { extname, join, normalize } = require("path"); -const logger = createLogger({ - level: "silly", - format: format.combine( - format.timestamp(), - format.ms(), - format.errors({ stack: true }), - format.splat(), - format.json() - ), - defaultMeta: { service: "Test" }, - transports: [ - new transports.Console({ - format: format.combine( - format.colorize({ all: true }), - format.padLevels(), - consoleFormat({ - showMeta: true, - metaStrip: ["timestamp", "service"], - inspectOptions: { - depth: Infinity, - colors: true, - maxArrayLength: Infinity, - breakLength: 120, - compact: Infinity, - }, - }) - ), - }), - ], -}); - -const PORT = process.env.PORT || 3000; -const PUBLIC_DIR = path.join(__dirname, "public"); - -const MIME_TYPES = { +const port = process.env.PORT || 3000; +const public_dir = join(__dirname, "public"); +const mime_types = { ".html": "text/html; charset=UTF-8", ".css": "text/css; charset=UTF-8", ".js": "application/javascript; charset=UTF-8", @@ -54,51 +19,35 @@ const MIME_TYPES = { ".ico": "image/x-icon" }; -function resolveFilePath(urlPath) { - if (!urlPath || urlPath === "/") { - return path.join(PUBLIC_DIR, "index.html"); - } +const safe_path = (url_path) => { + if (!url_path || url_path === "/") return join(public_dir, "index.html"); + const cleaned = normalize(url_path).replace(/^(\.\.([/\\]|$))+/, ""); + return join(public_dir, cleaned); +}; - const safePath = path.normalize(urlPath).replace(/^(\.\.([/\\]|$))+/, ""); - return path.join(PUBLIC_DIR, safePath); -} - -function sendFile(filePath, res) { - fs.readFile(filePath, (err, data) => { - if (err) { - if (err.code === "ENOENT") { - res.writeHead(404, { "Content-Type": "text/plain; charset=UTF-8" }); - res.end("404 Not Found"); - return; - } - - res.writeHead(500, { "Content-Type": "text/plain; charset=UTF-8" }); - res.end("500 Internal Server Error"); +const send = (file_path, res) => { + readFile(file_path, (err, data) => { + if (err?.code === "ENOENT") { + res.writeHead(404, { "Content-Type": "text/plain; charset=UTF-8" }); + res.end("404 not found"); return; } - - const ext = path.extname(filePath).toLowerCase(); - const contentType = MIME_TYPES[ext] || "application/octet-stream"; - - res.writeHead(200, { "Content-Type": contentType }); + if (err) { + res.writeHead(500, { "Content-Type": "text/plain; charset=UTF-8" }); + res.end("500 internal server error"); + return; + } + const type = mime_types[extname(file_path).toLowerCase()] || "application/octet-stream"; + res.writeHead(200, { "Content-Type": type }); res.end(data); }); -} +}; -const server = http.createServer((req, res) => { - const reqPath = (req.url || "/").split("?")[0]; - const filePath = resolveFilePath(reqPath); - - fs.stat(filePath, (err, stat) => { - if (!err && stat.isDirectory()) { - sendFile(path.join(filePath, "index.html"), res); - return; - } - - sendFile(filePath, res); +http + .createServer((req, res) => { + const file_path = safe_path((req.url || "/").split("?")[0]); + stat(file_path, (err, info) => send(!err && info.isDirectory() ? join(file_path, "index.html") : file_path, res)); + }) + .listen(port, () => { + console.log(`web running at http://localhost:${port}`); }); -}); - -server.listen(PORT, () => { - logger.info(`web running at http://localhost:${PORT}`); -}); diff --git a/public/app.js b/public/app.js index bbb64de..a27e734 100644 --- a/public/app.js +++ b/public/app.js @@ -1,562 +1,387 @@ 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" - ] + 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 DIFFICULTY_CONFIG = { + easy: { xp_mult: 0.9, round_words: 38 }, + normal: { xp_mult: 1, round_words: 45 }, + hard: { xp_mult: 1.25, round_words: 50 }, + expert: { xp_mult: 1.5, round_words: 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." - } + { id: "combo-boost", name: "combo boost", unlock_level: 2, cost: 1, description: "earn 15% more xp while your best combo is 5 or higher." }, + { id: "accuracy-bounty", name: "accuracy bounty", unlock_level: 4, cost: 2, description: "if you finish with 96%+ accuracy, gain +30 bonus xp." }, + { id: "time-warp", name: "time warp", unlock_level: 6, cost: 2, description: "start rounds with +8 seconds for easier high-streak runs." }, + { id: "focus-engine", name: "focus engine", unlock_level: 8, cost: 3, description: "at 98%+ accuracy, your wpm contributes an extra 25% xp." } ]; -const defaultState = { - profileName: "rookie typer", +const DEFAULT_STATE = { + profile_name: "rookie typer", difficulty: "normal", words: [], index: 0, - typedChars: 0, - correctWords: 0, - wrongWords: 0, + typed_chars: 0, + correct_words: 0, + wrong_words: 0, combo: 0, - bestCombo: 0, + best_combo: 0, timer: null, - elapsedSeconds: 0, - roundSeconds: 60, + elapsed_seconds: 0, + round_seconds: 60, running: false, xp: 0, level: 1, shards: 0, - lastRoundXp: 0, - ownedPerks: [], - stats: { - totalRounds: 0, - bestWpm: 0, - bestAccuracy: 0, - averageWpm: 0, - averageAccuracy: 0 - }, - recentRounds: [] + last_round_xp: 0, + owned_perks: [], + stats: { total_rounds: 0, best_wpm: 0, best_accuracy: 0, average_wpm: 0, average_accuracy: 0 }, + recent_rounds: [] }; -const state = { ...defaultState }; +const state = { ...DEFAULT_STATE, stats: { ...DEFAULT_STATE.stats }, recent_rounds: [] }; +const q = (id) => document.getElementById(id); +const num = (v, d = 0) => (Number.isFinite(v) ? Number(v) : d); +const snake_or_camel = (obj, snake, camel, fallback = undefined) => (obj?.[snake] ?? obj?.[camel] ?? fallback); -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") +const dom = { + text_display: q("textDisplay"), type_input: q("typeInput"), new_round_btn: q("newRoundBtn"), settings_btn: q("settingsBtn"), + close_settings_btn: q("closeSettingsBtn"), settings_modal: q("settingsModal"), reset_confirm_modal: q("resetConfirmModal"), + cancel_reset_btn: q("cancelResetBtn"), confirm_reset_btn: q("confirmResetBtn"), play_again_btn: q("playAgainBtn"), + results: q("results"), results_summary: q("resultsSummary"), wpm: q("wpm"), accuracy: q("accuracy"), combo: q("combo"), + time: q("time"), profile_title: q("profileTitle"), level_text: q("levelText"), xp_text: q("xpText"), shard_text: q("shardText"), + xp_fill: q("xpFill"), xp_gain: q("xpGain"), perks: q("perks"), profile_name: q("profileName"), difficulty_select: q("difficultySelect"), + difficulty_bonus: q("difficultyBonus"), reset_progress_btn: q("resetProgressBtn") }; -function xpNeededForLevel(level) { - return 120 + (level - 1) * 60; -} +const xp_needed_for_level = (level) => 120 + (level - 1) * 60; +const sanitize_difficulty = (v) => (DIFFICULTY_CONFIG[v] ? v : "normal"); +const has_perk = (id) => state.owned_perks.includes(id); +const open_settings = () => { dom.settings_modal.hidden = false; }; +const close_settings = () => { dom.settings_modal.hidden = true; }; +const open_reset_confirm = () => { dom.reset_confirm_modal.hidden = false; }; +const close_reset_confirm = () => { dom.reset_confirm_modal.hidden = true; }; -function sanitizeDifficulty(value) { - return DIFFICULTY[value] ? value : "normal"; -} - -function saveProgress() { - const payload = { - profileName: state.profileName, +function save_progress() { + localStorage.setItem(STORAGE_KEY, JSON.stringify({ + profile_name: state.profile_name, difficulty: state.difficulty, xp: state.xp, level: state.level, shards: state.shards, - lastRoundXp: state.lastRoundXp, - ownedPerks: state.ownedPerks, + last_round_xp: state.last_round_xp, + owned_perks: state.owned_perks, stats: state.stats, - recentRounds: state.recentRounds - }; - - localStorage.setItem(STORAGE_KEY, JSON.stringify(payload)); + recent_rounds: state.recent_rounds + })); } -function loadProgress() { +function load_progress() { 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)) - : []; + state.profile_name = String(snake_or_camel(parsed, "profile_name", "profileName", DEFAULT_STATE.profile_name)).trim().slice(0, 24) || DEFAULT_STATE.profile_name; + state.difficulty = sanitize_difficulty(parsed.difficulty); + state.xp = Math.max(0, Math.floor(num(parsed.xp))); + state.level = Math.max(1, Math.floor(num(parsed.level, 1))); + state.shards = Math.max(0, Math.floor(num(parsed.shards))); + state.last_round_xp = Math.max(0, Math.floor(num(snake_or_camel(parsed, "last_round_xp", "lastRoundXp")))); - const parsedStats = parsed.stats || {}; + const owned = snake_or_camel(parsed, "owned_perks", "ownedPerks", []); + state.owned_perks = Array.isArray(owned) ? owned.filter((id) => PERKS.some((p) => p.id === id)) : []; + + const stats = 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 + total_rounds: Math.max(0, Math.floor(num(snake_or_camel(stats, "total_rounds", "totalRounds")))), + best_wpm: Math.max(0, Math.floor(num(snake_or_camel(stats, "best_wpm", "bestWpm")))), + best_accuracy: Math.max(0, Math.floor(num(snake_or_camel(stats, "best_accuracy", "bestAccuracy")))), + average_wpm: Math.max(0, num(snake_or_camel(stats, "average_wpm", "averageWpm"))), + average_accuracy: Math.max(0, num(snake_or_camel(stats, "average_accuracy", "averageAccuracy"))) }; - state.recentRounds = Array.isArray(parsed.recentRounds) - ? parsed.recentRounds.slice(0, 20) + const rounds = snake_or_camel(parsed, "recent_rounds", "recentRounds", []); + state.recent_rounds = Array.isArray(rounds) + ? rounds.slice(0, 20).map((r) => ({ + stamp: num(r.stamp, Date.now()), + wpm: num(r.wpm), + accuracy: num(r.accuracy), + best_combo: num(snake_or_camel(r, "best_combo", "bestCombo")), + xp: num(r.xp), + difficulty: sanitize_difficulty(r.difficulty) + })) : []; - } catch (error) { + } catch { 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]; +function pick_words() { + const cfg = DIFFICULTY_CONFIG[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; + return Array.from({ length: cfg.round_words }, () => pool[Math.floor(Math.random() * pool.length)]); } -function hasPerk(id) { - return state.ownedPerks.includes(id); -} - -function renderWords() { - els.textDisplay.innerHTML = ""; +function render_words() { + dom.text_display.innerHTML = ""; state.words.forEach((word, idx) => { const span = document.createElement("span"); - span.className = "word"; - if (idx === state.index) span.classList.add("current"); + span.className = `word${idx === state.index ? " current" : ""}`; span.textContent = `${word} `; - els.textDisplay.appendChild(span); + dom.text_display.appendChild(span); }); } -function renderPerks() { - els.perks.innerHTML = ""; - +function render_perks() { + dom.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 level_ready = state.level >= perk.unlock_level; + const owned = has_perk(perk.id); + const can_buy = level_ready && !owned && state.shards >= perk.cost; const card = document.createElement("article"); card.className = "perk"; - card.innerHTML = ` -

${perk.name} · Lv ${perk.unlockLevel}

+ card.innerHTML = `

${perk.name} · lv ${perk.unlock_level}

${perk.description}

- Cost: ${perk.cost} shard${perk.cost > 1 ? "s" : ""} - + cost: ${perk.cost} shard${perk.cost > 1 ? "s" : ""} +
- - ${owned ? "active" : levelReady ? "shop open" : "locked"} - - `; - - els.perks.appendChild(card); + ${owned ? "active" : level_ready ? "shop open" : "locked"}`; + dom.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 render_settings() { + dom.profile_name.value = state.profile_name; + dom.difficulty_select.value = state.difficulty; + dom.difficulty_bonus.textContent = `xp bonus: x${DIFFICULTY_CONFIG[state.difficulty].xp_mult.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 render_progress() { + const level_need = xp_needed_for_level(state.level); + dom.profile_title.textContent = state.profile_name; + dom.level_text.textContent = `level ${state.level}`; + dom.xp_text.textContent = `${state.xp} / ${level_need} xp`; + dom.shard_text.textContent = `perk shards: ${state.shards}`; + dom.xp_fill.style.width = `${Math.min((state.xp / level_need) * 100, 100)}%`; + dom.xp_gain.textContent = `+${state.last_round_xp} xp last round`; + render_settings(); + render_perks(); } -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 update_live_stats() { + const elapsed = Math.max(state.elapsed_seconds / 60, 1 / 60); + const wpm = Math.round((state.typed_chars / 5) / elapsed); + const total = state.correct_words + state.wrong_words; + const accuracy = total ? Math.round((state.correct_words / total) * 100) : 100; + dom.wpm.textContent = String(wpm); + dom.accuracy.textContent = `${accuracy}%`; + dom.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 update_timer() { + const remaining = Math.max(state.round_seconds - state.elapsed_seconds, 0); + dom.time.textContent = `${remaining}s`; + dom.time.style.color = remaining <= 10 ? "#ffd587" : ""; } -function startTimer() { +function start_timer() { if (state.running) return; - state.running = true; state.timer = setInterval(() => { - state.elapsedSeconds += 1; - updateTimer(); - updateLiveStats(); - - if (state.elapsedSeconds >= state.roundSeconds) { - finishRound(); - } + state.elapsed_seconds += 1; + update_timer(); + update_live_stats(); + if (state.elapsed_seconds >= state.round_seconds) finish_round(); }, 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); +function award_xp(summary) { + let xp = 35 + Math.floor(summary.wpm * 1.4) + Math.floor(summary.accuracy * 0.55) + Math.floor(summary.best_combo * 2.5); + if (has_perk("combo-boost") && summary.best_combo >= 5) xp = Math.floor(xp * 1.15); + if (has_perk("accuracy-bounty") && summary.accuracy >= 96) xp += 30; + if (has_perk("focus-engine") && summary.accuracy >= 98) xp += Math.floor(summary.wpm * 0.25); + xp = Math.floor(xp * DIFFICULTY_CONFIG[state.difficulty].xp_mult); let pending = xp; - let levelUps = 0; - + let level_ups = 0; while (pending > 0) { - const need = xpNeededForLevel(state.level) - state.xp; + const need = xp_needed_for_level(state.level) - state.xp; if (pending >= need) { pending -= need; state.level += 1; - levelUps += 1; state.xp = 0; + level_ups += 1; } else { state.xp += pending; pending = 0; } } - - state.shards += levelUps; - state.lastRoundXp = xp; + state.shards += level_ups; + state.last_round_xp = xp; return xp; } -function markWordResult(correct) { - const wordNodes = els.textDisplay.querySelectorAll(".word"); - const node = wordNodes[state.index]; - if (!node) return; +function track_round(summary, earned_xp) { + const s = state.stats; + s.total_rounds += 1; + s.best_wpm = Math.max(s.best_wpm, summary.wpm); + s.best_accuracy = Math.max(s.best_accuracy, summary.accuracy); + s.average_wpm = ((s.average_wpm * (s.total_rounds - 1)) + summary.wpm) / s.total_rounds; + s.average_accuracy = ((s.average_accuracy * (s.total_rounds - 1)) + summary.accuracy) / s.total_rounds; + state.recent_rounds.unshift({ stamp: Date.now(), wpm: summary.wpm, accuracy: summary.accuracy, best_combo: summary.best_combo, xp: earned_xp, difficulty: state.difficulty }); + state.recent_rounds = state.recent_rounds.slice(0, 20); +} +function mark_word_result(correct) { + const node = dom.text_display.querySelectorAll(".word")[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 set_current_word() { + const node = dom.text_display.querySelectorAll(".word")[state.index]; + if (!node) return; + node.classList.add("current"); + node.scrollIntoView({ block: "center", behavior: "smooth" }); } -function finishRound() { +function finish_round() { clearInterval(state.timer); state.running = false; - els.typeInput.disabled = true; + dom.type_input.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 elapsed_m = Math.max(state.elapsed_seconds / 60, 1 / 60); + const wpm = Math.round((state.typed_chars / 5) / elapsed_m); + const total = state.correct_words + state.wrong_words; + const accuracy = total ? Math.round((state.correct_words / total) * 100) : 100; + const summary = { wpm, accuracy, best_combo: state.best_combo }; - const summary = { - wpm, - accuracy, - bestCombo: state.bestCombo - }; + const earned_xp = award_xp(summary); + track_round(summary, earned_xp); + render_progress(); + update_live_stats(); + save_progress(); - 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!`; + dom.results.hidden = false; + dom.results_summary.textContent = `you reached ${wpm} wpm with ${accuracy}% accuracy and earned ${earned_xp} xp!`; } -function resetRound() { +function reset_round() { 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; + Object.assign(state, { + words: pick_words(), + index: 0, + typed_chars: 0, + correct_words: 0, + wrong_words: 0, + combo: 0, + best_combo: 0, + elapsed_seconds: 0, + round_seconds: has_perk("time-warp") ? 68 : 60, + running: false + }); + render_words(); + update_live_stats(); + update_timer(); + dom.type_input.disabled = false; + dom.type_input.value = ""; + dom.type_input.focus(); + dom.results.hidden = true; } -function setProfileName(value) { - const clean = value.trim().slice(0, 24); - state.profileName = clean || defaultState.profileName; - renderProgress(); - saveProgress(); +function set_profile_name(value) { + state.profile_name = value.trim().slice(0, 24) || DEFAULT_STATE.profile_name; + render_progress(); + save_progress(); } -function setDifficulty(value) { - state.difficulty = sanitizeDifficulty(value); - renderProgress(); - resetRound(); - saveProgress(); +function set_difficulty(value) { + state.difficulty = sanitize_difficulty(value); + render_progress(); + reset_round(); + save_progress(); } -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; - +function buy_perk(perk_id) { + const perk = PERKS.find((p) => p.id === perk_id); + if (!perk || has_perk(perk_id) || state.level < perk.unlock_level || state.shards < perk.cost) return; state.shards -= perk.cost; - state.ownedPerks.push(perk.id); - renderProgress(); - resetRound(); - saveProgress(); + state.owned_perks.push(perk.id); + render_progress(); + reset_round(); + save_progress(); } -function resetProgress() { +function reset_progress() { localStorage.removeItem(STORAGE_KEY); clearInterval(state.timer); - - Object.assign(state, { ...defaultState, ownedPerks: [], stats: { ...defaultState.stats }, recentRounds: [] }); - renderProgress(); - resetRound(); - saveProgress(); + Object.assign(state, { ...DEFAULT_STATE, stats: { ...DEFAULT_STATE.stats }, recent_rounds: [], owned_perks: [] }); + render_progress(); + reset_round(); + save_progress(); } -els.typeInput.addEventListener("input", () => { - const value = els.typeInput.value; - - if (!state.running && value.length > 0) { - startTimer(); - } - +dom.type_input.addEventListener("input", () => { + const value = dom.type_input.value; + if (!state.running && value.length) start_timer(); if (!value.endsWith(" ")) return; - const typedWord = value.trim(); - const targetWord = state.words[state.index]; - const correct = typedWord === targetWord; - - state.typedChars += typedWord.length + 1; + const typed_word = value.trim(); + const correct = typed_word === state.words[state.index]; + state.typed_chars += typed_word.length + 1; if (correct) { - state.correctWords += 1; + state.correct_words += 1; state.combo += 1; - if (state.combo > state.bestCombo) { - state.bestCombo = state.combo; - } + state.best_combo = Math.max(state.best_combo, state.combo); } else { - state.wrongWords += 1; + state.wrong_words += 1; state.combo = 0; } - markWordResult(correct); + mark_word_result(correct); state.index += 1; - els.typeInput.value = ""; - - if (state.index >= state.words.length) { - finishRound(); - return; - } - - setCurrentWord(); - updateLiveStats(); + dom.type_input.value = ""; + if (state.index >= state.words.length) return finish_round(); + set_current_word(); + update_live_stats(); }); -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(); +dom.new_round_btn.addEventListener("click", reset_round); +dom.play_again_btn.addEventListener("click", reset_round); +dom.settings_btn.addEventListener("click", open_settings); +dom.close_settings_btn.addEventListener("click", close_settings); +dom.profile_name.addEventListener("change", (e) => set_profile_name(e.target.value)); +dom.difficulty_select.addEventListener("change", (e) => set_difficulty(e.target.value)); +dom.reset_progress_btn.addEventListener("click", open_reset_confirm); +dom.cancel_reset_btn.addEventListener("click", close_reset_confirm); +dom.confirm_reset_btn.addEventListener("click", () => { + reset_progress(); + close_reset_confirm(); + close_settings(); +}); +dom.settings_modal.addEventListener("click", (e) => e.target === dom.settings_modal && close_settings()); +dom.reset_confirm_modal.addEventListener("click", (e) => e.target === dom.reset_confirm_modal && close_reset_confirm()); +document.addEventListener("keydown", (e) => { + if (e.key !== "Escape") return; + if (!dom.reset_confirm_modal.hidden) return close_reset_confirm(); + if (!dom.settings_modal.hidden) close_settings(); }); -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; +dom.perks.addEventListener("click", (e) => { + const target = e.target; if (!(target instanceof HTMLButtonElement)) return; - const perkId = target.dataset.perkId; - if (!perkId) return; - buyPerk(perkId); + const perk_id = target.dataset.perkId; + if (perk_id) buy_perk(perk_id); }); -loadProgress(); -renderProgress(); -resetRound(); +load_progress(); +render_progress(); +reset_round(); diff --git a/public/index.html b/public/index.html index 4b7a49d..040d956 100644 --- a/public/index.html +++ b/public/index.html @@ -4,16 +4,9 @@ typeup - - - -
@@ -25,22 +18,10 @@
-
-

wpm

- 0 -
-
-

accuracy

- 100% -
-
-

combo

- x0 -
-
-

time

- 60s -
+

wpm

0
+

accuracy

100%
+

combo

x0
+

time

60s
@@ -67,21 +48,13 @@ @@ -96,7 +69,6 @@
- -

xp bonus: x1.00

@@ -113,9 +84,7 @@