From 7bf520096edb81d45eccb98f9e0477e35b3c03ae Mon Sep 17 00:00:00 2001 From: st5 Date: Sun, 19 Apr 2026 18:35:34 -0500 Subject: [PATCH] g --- index.js | 214 ++++++++++++++++++- public/app.js | 521 ++++++++++++++++++++++++++++++++++++++++------ public/index.html | 121 +++++++---- public/stats.js | 6 +- public/styles.css | 298 ++++++++++++++++++++++++-- 5 files changed, 1036 insertions(+), 124 deletions(-) diff --git a/index.js b/index.js index 0c61104..7a21176 100644 --- a/index.js +++ b/index.js @@ -1,5 +1,6 @@ const http = require("http"); const { readFile, stat } = require("fs"); +const https = require("https"); const { extname, join, normalize } = require("path"); const port = process.env.PORT || 3000; @@ -19,6 +20,208 @@ const mime_types = { ".ico": "image/x-icon" }; +const github_sources_by_track = { + frontend: [ + { repo: "vuejs/core", ref: "main", files: ["packages/reactivity/src/effect.ts", "packages/reactivity/src/ref.ts"] }, + { repo: "twbs/bootstrap", ref: "main", files: ["js/src/modal.js", "js/src/collapse.js"] }, + { repo: "vercel/next.js", ref: "canary", files: ["packages/next/src/client/app-router.ts", "packages/next/src/client/components/router-reducer/router-reducer.ts"] }, + { repo: "microsoft/TypeScript", ref: "main", files: ["src/services/services.ts", "src/compiler/utilities.ts"] } + ], + backend: [ + { repo: "nodejs/node", ref: "main", files: ["lib/fs.js", "lib/http.js"] }, + { repo: "python/cpython", ref: "main", files: ["Lib/pathlib.py", "Lib/http/server.py"] }, + { repo: "golang/go", ref: "master", files: ["src/net/http/server.go", "src/database/sql/sql.go"] }, + { repo: "laravel/framework", ref: "master", files: ["src/Illuminate/Routing/Router.php", "src/Illuminate/Database/Query/Builder.php"] } + ], + systems: [ + { repo: "rust-lang/rust", ref: "master", files: ["library/std/src/fs.rs", "library/std/src/path.rs"] }, + { repo: "redis/redis", ref: "unstable", files: ["src/server.c", "src/networking.c"] }, + { repo: "torvalds/linux", ref: "master", files: ["kernel/sched/core.c", "kernel/fork.c"] }, + { repo: "llvm/llvm-project", ref: "main", files: ["llvm/lib/Support/CommandLine.cpp", "llvm/lib/Support/Path.cpp"] } + ], + "full-stack": [ + { repo: "vercel/next.js", ref: "canary", files: ["packages/next/src/server/base-server.ts", "packages/next/src/client/app-router.ts"] }, + { repo: "remix-run/remix", ref: "main", files: ["packages/remix-server-runtime/server.ts", "packages/remix-react/components.tsx"] }, + { repo: "microsoft/TypeScript", ref: "main", files: ["src/compiler/checker.ts", "src/compiler/watch.ts"] }, + { repo: "nodejs/node", ref: "main", files: ["lib/path.js", "lib/http.js"] } + ] +}; + +const all_tracks = Object.values(github_sources_by_track).flat(); +const sanitize_track = (value) => { + if (github_sources_by_track[value]) return value; + if (value === "easy") return "frontend"; + if (value === "normal") return "backend"; + if (value === "hard" || value === "systems programming" || value === "systems-programming") return "systems"; + if (value === "expert" || value === "fullstack") return "full-stack"; + return "full-stack"; +}; + +const pick = (arr) => arr[Math.floor(Math.random() * arr.length)]; +const count_indent = (line) => (line.match(/^\s*/)?.[0].length || 0); + +const fetch_text = (url) => + new Promise((resolve, reject) => { + https + .get(url, { headers: { "User-Agent": "typeup-snippet-fetcher" } }, (res) => { + if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { + fetch_text(res.headers.location).then(resolve).catch(reject); + return; + } + + if (res.statusCode !== 200) { + reject(new Error(`status ${res.statusCode}`)); + return; + } + + let body = ""; + res.setEncoding("utf8"); + res.on("data", (chunk) => { + body += chunk; + }); + res.on("end", () => resolve(body)); + }) + .on("error", reject); + }); + +const extension_from_source = (source) => (source.split(".").pop() || "").toLowerCase(); + +const normalize_lines = (text) => text.split("\n").map((line) => line.replace(/\t/g, " ").replace(/\r$/, "")); + +const push_candidate = (list, lines, start, end, min_lines, max_lines) => { + if (start < 0 || end <= start || end >= lines.length) return; + while (start <= end && !lines[start].trim()) start += 1; + while (end >= start && !lines[end].trim()) end -= 1; + const count = end - start + 1; + if (count < min_lines || count > max_lines) return; + const snippet = lines.slice(start, end + 1).join("\n").trim(); + if (!snippet) return; + list.push(snippet); +}; + +const extract_brace_blocks = (lines, min_lines, max_lines) => { + const out = []; + for (let i = 0; i < lines.length; i += 1) { + if (!lines[i].includes("{")) continue; + let depth = 0; + let started = false; + for (let j = i; j < lines.length; j += 1) { + const opens = (lines[j].match(/\{/g) || []).length; + const closes = (lines[j].match(/\}/g) || []).length; + if (opens > 0) started = true; + depth += opens; + depth -= closes; + if (started && depth === 0 && j > i) { + push_candidate(out, lines, i, j, min_lines, max_lines); + break; + } + if (depth < 0) break; + } + } + return out; +}; + +const extract_python_blocks = (lines, min_lines, max_lines) => { + const out = []; + const header = /^\s*(def|class|if|elif|else|for|while|try|except|finally|with|match|case)\b.*:\s*$/; + for (let i = 0; i < lines.length; i += 1) { + if (!header.test(lines[i])) continue; + const base = count_indent(lines[i]); + let started_body = false; + let end = i; + for (let j = i + 1; j < lines.length; j += 1) { + const line = lines[j]; + if (!line.trim()) { + if (started_body) end = j; + continue; + } + const indent = count_indent(line); + if (indent > base) { + started_body = true; + end = j; + continue; + } + break; + } + if (started_body) push_candidate(out, lines, i, end, min_lines, max_lines); + } + return out; +}; + +const extract_paragraph_chunks = (lines, min_lines, max_lines) => { + const out = []; + let start = -1; + for (let i = 0; i <= lines.length; i += 1) { + const is_blank = i === lines.length || !lines[i].trim(); + if (!is_blank && start === -1) start = i; + if (is_blank && start !== -1) { + push_candidate(out, lines, start, i - 1, min_lines, max_lines); + start = -1; + } + } + return out; +}; + +const dedupe_candidates = (items) => Array.from(new Set(items)); + +const snippet_from_text = (text, source = "") => { + const lines = normalize_lines(text); + const min_lines = 6; + const max_lines = 36; + const ext = extension_from_source(source); + + let candidates = []; + + if (["js", "mjs", "cjs", "ts", "tsx", "jsx", "css", "json"].includes(ext)) { + candidates = candidates.concat(extract_brace_blocks(lines, min_lines, max_lines)); + } + + if (ext === "py") { + candidates = candidates.concat(extract_python_blocks(lines, min_lines, max_lines)); + } + + candidates = dedupe_candidates(candidates); + if (!candidates.length) { + candidates = dedupe_candidates(extract_paragraph_chunks(lines, min_lines, max_lines)); + } + + if (!candidates.length) return ""; + return pick(candidates); +}; + +const send_json = (res, status, payload) => { + res.writeHead(status, { + "Content-Type": "application/json; charset=UTF-8", + "Cache-Control": "no-store, no-cache, must-revalidate", + Pragma: "no-cache", + Expires: "0" + }); + res.end(JSON.stringify(payload)); +}; + +const send_github_snippet = async (res, track) => { + const selected_track = sanitize_track(track); + const source_pool = github_sources_by_track[selected_track] || all_tracks; + + for (let i = 0; i < 8; i += 1) { + const source = pick(source_pool); + const file = pick(source.files); + const url = `https://raw.githubusercontent.com/${source.repo}/${source.ref}/${file}`; + + try { + const text = await fetch_text(url); + const snippet = snippet_from_text(text, file); + if (!snippet) continue; + send_json(res, 200, { snippet, source: `${source.repo}/${file}`, track: selected_track }); + return; + } catch { + // Try another repo/file. + } + } + + send_json(res, 502, { error: "could not fetch snippet" }); +}; + const safe_path = (url_path) => { if (!url_path || url_path === "/") return join(public_dir, "index.html"); const cleaned = normalize(url_path).replace(/^(\.\.([/\\]|$))+/, ""); @@ -45,7 +248,16 @@ const send = (file_path, res) => { http .createServer((req, res) => { - const file_path = safe_path((req.url || "/").split("?")[0]); + const request_url = new URL(req.url || "/", "http://localhost"); + const requested_track = request_url.searchParams.get("d") || request_url.searchParams.get("track") || "full-stack"; + const path_only = (req.url || "/").split("?")[0]; + + if (path_only === "/api/snippet") { + send_github_snippet(res, requested_track); + return; + } + + const file_path = safe_path(path_only); stat(file_path, (err, info) => send(!err && info.isDirectory() ? join(file_path, "index.html") : file_path, res)); }) .listen(port, () => { diff --git a/public/app.js b/public/app.js index a27e734..737fc35 100644 --- a/public/app.js +++ b/public/app.js @@ -1,17 +1,24 @@ -const STORAGE_KEY = "levelup-typing-save-v1"; +const STORAGE_KEY = "speedcode-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 FALLBACK_WORD_BANKS = { + frontend: ["component", "props", "state", "effect", "dom", "render", "hook", "event", "layout", "style", "selector", "responsive", "hydration", "bundle", "asset", "router", "transition", "animation", "markup", "stylesheet"], + backend: ["endpoint", "middleware", "request", "response", "session", "database", "query", "migration", "service", "worker", "queue", "cache", "auth", "token", "serializer", "controller", "schema", "webhook", "latency", "throughput"], + systems: ["pointer", "thread", "mutex", "allocator", "kernel", "register", "syscall", "segment", "stack", "heap", "compiler", "linker", "interrupt", "buffer", "runtime", "ownership", "lifetime", "scheduler", "pipeline", "throughput"], + "full-stack": ["frontend", "backend", "contract", "gateway", "orchestrator", "deployment", "container", "observability", "tracing", "monorepo", "api", "client", "server", "session", "render", "streaming", "database", "replica", "pipeline", "integration"] }; 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 } + frontend: { xp_mult: 1.05, round_words: 44 }, + backend: { xp_mult: 1.15, round_words: 46 }, + systems: { xp_mult: 1.3, round_words: 50 }, + "full-stack": { xp_mult: 1.4, round_words: 54 } +}; + +const CHALLENGE_CONFIG = { + casual: { xp_mult: 0.9, round_seconds: 75, words_mult: 0.9 }, + standard: { xp_mult: 1, round_seconds: 60, words_mult: 1 }, + intense: { xp_mult: 1.2, round_seconds: 52, words_mult: 1.15 }, + nightmare: { xp_mult: 1.4, round_seconds: 45, words_mult: 1.3 } }; const PERKS = [ @@ -21,10 +28,18 @@ const PERKS = [ { id: "focus-engine", name: "focus engine", unlock_level: 8, cost: 3, description: "at 98%+ accuracy, your wpm contributes an extra 25% xp." } ]; +const TRACK_REPO_PREFIXES = { + frontend: ["vuejs/core/", "twbs/bootstrap/", "vercel/next.js/", "microsoft/TypeScript/"], + backend: ["nodejs/node/", "python/cpython/", "golang/go/", "laravel/framework/"], + systems: ["rust-lang/rust/", "redis/redis/", "torvalds/linux/", "llvm/llvm-project/"], + "full-stack": ["vercel/next.js/", "remix-run/remix/", "microsoft/TypeScript/", "nodejs/node/"] +}; + const DEFAULT_STATE = { profile_name: "rookie typer", - difficulty: "normal", - words: [], + difficulty: "full-stack", + challenge: "standard", + target_chars: [], index: 0, typed_chars: 0, correct_words: 0, @@ -40,6 +55,7 @@ const DEFAULT_STATE = { shards: 0, last_round_xp: 0, owned_perks: [], + round_nonce: 0, stats: { total_rounds: 0, best_wpm: 0, best_accuracy: 0, average_wpm: 0, average_accuracy: 0 }, recent_rounds: [] }; @@ -50,27 +66,167 @@ 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 dom = { - text_display: q("textDisplay"), type_input: q("typeInput"), new_round_btn: q("newRoundBtn"), settings_btn: q("settingsBtn"), + text_display: q("textDisplay"), 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") + difficulty_bonus: q("difficultyBonus"), challenge_select: q("challengeSelect"), challenge_bonus: q("challengeBonus"), + reset_progress_btn: q("resetProgressBtn"), snippet_source: q("snippetSource"), + snippet_language: q("snippetLanguage"), code_editor: q("codeEditor"), code_snippet: q("codeSnippet"), + tab_type_btn: q("tabTypeBtn"), tab_perks_btn: q("tabPerksBtn"), + type_tab: q("typeTab"), perks_tab: q("perksTab") }; +if (!dom.text_display) dom.text_display = dom.code_snippet; + +const detect_language = (source) => { + const ext = (source || "").split(".").pop()?.toLowerCase(); + if (ext === "js" || ext === "mjs" || ext === "cjs") return "javascript"; + if (ext === "ts" || ext === "tsx") return "typescript"; + if (ext === "py") return "python"; + if (ext === "json") return "json"; + if (ext === "html") return "html"; + if (ext === "css") return "css"; + if (ext === "c" || ext === "h") return "c"; + if (ext === "cpp" || ext === "cc" || ext === "cxx" || ext === "hpp" || ext === "hh" || ext === "hxx") return "cpp"; + if (ext === "rs") return "rust"; + if (ext === "go") return "go"; + if (ext === "java") return "java"; + if (ext === "php") return "php"; + if (ext === "sql") return "sql"; + if (ext === "sh") return "shell"; + return "plaintext"; +}; + +const prism_language_for = (language) => ( + language === "shell" ? "bash" : + language === "html" ? "markup" : + language +); + +async function set_code_display(snippet, source) { + const language = detect_language(source); + const prism_language = prism_language_for(language); + + dom.snippet_language.textContent = `language: ${language}`; + dom.code_snippet.className = `language-${prism_language || "plaintext"}`; +} + const xp_needed_for_level = (level) => 120 + (level - 1) * 60; -const sanitize_difficulty = (v) => (DIFFICULTY_CONFIG[v] ? v : "normal"); +const sanitize_difficulty = (v) => { + if (DIFFICULTY_CONFIG[v]) return v; + if (v === "easy") return "frontend"; + if (v === "normal") return "backend"; + if (v === "hard") return "systems"; + if (v === "expert") return "full-stack"; + return "full-stack"; +}; +const sanitize_challenge = (v) => (CHALLENGE_CONFIG[v] ? v : "standard"); +const source_matches_track = (source, track) => { + const prefixes = TRACK_REPO_PREFIXES[track] || []; + if (!source || !prefixes.length) return true; + return prefixes.some((prefix) => source.startsWith(prefix)); +}; const has_perk = (id) => state.owned_perks.includes(id); +const get_track_xp_mult = () => DIFFICULTY_CONFIG[state.difficulty].xp_mult; +const get_challenge_xp_mult = () => CHALLENGE_CONFIG[state.challenge].xp_mult; +const get_total_xp_mult = () => get_track_xp_mult() * get_challenge_xp_mult(); +const get_round_word_count = () => Math.max(24, Math.floor(DIFFICULTY_CONFIG[state.difficulty].round_words * CHALLENGE_CONFIG[state.challenge].words_mult)); +const get_round_seconds = () => CHALLENGE_CONFIG[state.challenge].round_seconds + (has_perk("time-warp") ? 8 : 0); 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; }; +let levelup_animation_queue = Promise.resolve(); +let xp_bar_animation_queue = Promise.resolve(); + +function set_side_tab(tab) { + const show_type = tab === "type"; + const show_perks = tab === "perks"; + + if (dom.type_tab) dom.type_tab.hidden = !show_type; + if (dom.perks_tab) dom.perks_tab.hidden = !show_perks; + + if (dom.tab_type_btn) { + dom.tab_type_btn.classList.toggle("active", show_type); + dom.tab_type_btn.setAttribute("aria-selected", show_type ? "true" : "false"); + } + if (dom.tab_perks_btn) { + dom.tab_perks_btn.classList.toggle("active", show_perks); + dom.tab_perks_btn.setAttribute("aria-selected", show_perks ? "true" : "false"); + } +} + +function play_levelup_milestone(level) { + const overlay = q("levelUpOverlay"); + const text = q("levelUpText"); + const kicker = q("levelUpKicker"); + if (!overlay || !text) return Promise.resolve(); + + return new Promise((resolve) => { + const tier = Math.min(4, Math.max(1, Math.floor(level / 5))); + overlay.dataset.tier = String(tier); + text.textContent = `level ${level}`; + if (kicker) kicker.textContent = level >= 20 ? "legend milestone unlocked" : "milestone unlocked"; + overlay.hidden = false; + overlay.classList.remove("active"); + void overlay.offsetWidth; + overlay.classList.add("active"); + + setTimeout(() => { + overlay.classList.remove("active"); + overlay.hidden = true; + resolve(); + }, 2250); + }); +} + +function queue_levelup_milestones(levels) { + if (!Array.isArray(levels) || !levels.length) return; + levelup_animation_queue = levelup_animation_queue.then(async () => { + for (const level of levels) { + await play_levelup_milestone(level); + } + }); +} + +function play_xp_level_ping() { + const bar = dom.xp_fill?.parentElement; + if (!bar || !dom.xp_fill) return Promise.resolve(); + + return new Promise((resolve) => { + bar.classList.remove("level-ping"); + dom.xp_fill.classList.remove("level-ping"); + void bar.offsetWidth; + bar.classList.add("level-ping"); + dom.xp_fill.classList.add("level-ping"); + + setTimeout(() => { + bar.classList.remove("level-ping"); + dom.xp_fill.classList.remove("level-ping"); + resolve(); + }, 340); + }); +} + +function queue_xp_level_pings(count) { + const pings = Math.max(0, Math.floor(count)); + if (!pings) return; + + xp_bar_animation_queue = xp_bar_animation_queue.then(async () => { + for (let i = 0; i < pings; i += 1) { + await play_xp_level_ping(); + } + }); +} function save_progress() { localStorage.setItem(STORAGE_KEY, JSON.stringify({ profile_name: state.profile_name, difficulty: state.difficulty, + challenge: state.challenge, xp: state.xp, level: state.level, shards: state.shards, @@ -88,6 +244,7 @@ function load_progress() { const parsed = JSON.parse(raw); 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.challenge = sanitize_challenge(parsed.challenge); 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))); @@ -113,7 +270,8 @@ function load_progress() { accuracy: num(r.accuracy), best_combo: num(snake_or_camel(r, "best_combo", "bestCombo")), xp: num(r.xp), - difficulty: sanitize_difficulty(r.difficulty) + difficulty: sanitize_difficulty(r.difficulty), + challenge: sanitize_challenge(r.challenge) })) : []; } catch { @@ -122,19 +280,169 @@ function load_progress() { } function pick_words() { - const cfg = DIFFICULTY_CONFIG[state.difficulty]; - const pool = WORD_BANKS[state.difficulty]; - return Array.from({ length: cfg.round_words }, () => pool[Math.floor(Math.random() * pool.length)]); + const round_words = get_round_word_count(); + const pool = FALLBACK_WORD_BANKS[state.difficulty]; + return Array.from({ length: round_words }, () => pool[Math.floor(Math.random() * pool.length)]); +} + +function tokenize_snippet(snippet, max_words) { + const language = "plaintext"; + return tokenize_snippet_with_syntax(snippet, max_words, language); +} + +function tokenize_snippet_with_syntax(snippet, max_words, language) { + const source = String(snippet || "").replace(/\r/g, ""); + const words = []; + const tokens = []; + const prism_language = prism_language_for(language || "plaintext"); + const grammar = window.Prism?.languages?.[prism_language]; + + const tokenize_plain = () => { + const parts = source.split(/(\s+)/); + for (const part of parts) { + if (!part) continue; + if (/^\s+$/.test(part)) { + if (tokens.length) tokens.push({ type: "space", text: part }); + continue; + } + if (words.length >= max_words) break; + const word = part.trim(); + if (!word) continue; + const index = words.length; + words.push(word); + tokens.push({ type: "word", text: word, index, syntax_class: "" }); + } + while (tokens.length && tokens[tokens.length - 1].type === "space") tokens.pop(); + return { words, tokens }; + }; + + if (!window.Prism || !grammar) { + return tokenize_plain(); + } + + let highlighted = ""; + try { + highlighted = window.Prism.highlight(source, grammar, prism_language); + } catch { + return tokenize_plain(); + } + const root = document.createElement("div"); + root.innerHTML = highlighted; + + const push_text = (text, syntax_class) => { + const parts = text.split(/(\s+)/); + for (const part of parts) { + if (!part) continue; + if (/^\s+$/.test(part)) { + if (tokens.length) tokens.push({ type: "space", text: part }); + continue; + } + if (words.length >= max_words) return false; + const word = part.trim(); + if (!word) continue; + const index = words.length; + words.push(word); + tokens.push({ type: "word", text: word, index, syntax_class: syntax_class || "" }); + } + return words.length < max_words; + }; + + const walk = (node, active_class) => { + if (words.length >= max_words) return; + if (node.nodeType === Node.TEXT_NODE) { + push_text(node.nodeValue || "", active_class); + return; + } + if (node.nodeType !== Node.ELEMENT_NODE) return; + const element = node; + const class_name = element.className && String(element.className).includes("token") ? String(element.className) : active_class; + for (const child of Array.from(element.childNodes)) { + if (words.length >= max_words) break; + walk(child, class_name); + } + }; + + for (const child of Array.from(root.childNodes)) { + if (words.length >= max_words) break; + walk(child, ""); + } + + while (tokens.length && tokens[tokens.length - 1].type === "space") tokens.pop(); + return { words, tokens }; +} + +function chars_from_tokens(tokens) { + const chars = []; + for (const token of tokens || []) { + const text = String(token.text || ""); + const syntax_class = token.type === "word" ? (token.syntax_class || "") : ""; + for (const ch of text) { + chars.push({ ch, syntax_class }); + } + } + return chars; +} + +const normalize_token = (value) => String(value || "") + .normalize("NFKC") + .replace(/[\u200B-\u200D\uFEFF]/g, "") + .replace(/[‘’]/g, "'") + .replace(/[“”]/g, '"') + .replace(/[–—]/g, "-") + .trim() + .toLowerCase(); + +async function load_snippet_words() { + const max_words = get_round_word_count(); + + for (let i = 0; i < 6; i += 1) { + try { + const params = new URLSearchParams({ track: state.difficulty, d: state.difficulty, t: `${Date.now()}-${i}` }); + const res = await fetch(`/api/snippet?${params.toString()}`, { cache: "no-store" }); + if (!res.ok) continue; + const data = await res.json(); + const source = data.source || "github"; + if (!source_matches_track(source, state.difficulty)) continue; + + const snippet = String(data.snippet || ""); + const language = detect_language(source); + const parsed = tokenize_snippet_with_syntax(snippet, max_words, language); + const words = parsed.words; + const tokens = parsed.tokens; + if (words.length < 12) continue; + + return { words, tokens, snippet, source, track: data.track || state.difficulty }; + } catch { + continue; + } + } + + return null; } function render_words() { dom.text_display.innerHTML = ""; - state.words.forEach((word, idx) => { + const fragment = document.createDocumentFragment(); + state.target_chars.forEach((item, idx) => { const span = document.createElement("span"); - span.className = `word${idx === state.index ? " current" : ""}`; - span.textContent = `${word} `; - dom.text_display.appendChild(span); + span.className = `char-unit${item.syntax_class ? ` ${item.syntax_class}` : ""}`; + + if (idx < state.index) { + const typed = state.user_chars?.[idx] || ""; + const correct = normalize_token(typed) === normalize_token(item.ch); + span.classList.add(correct ? "char-ok" : "char-bad"); + span.textContent = item.ch; + } else if (idx === state.index) { + span.classList.add("char-current"); + span.textContent = item.ch; + } else { + span.textContent = item.ch; + } + + fragment.appendChild(span); }); + + dom.text_display.appendChild(fragment); } function render_perks() { @@ -159,7 +467,12 @@ function render_perks() { 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)}`; + if (dom.challenge_select) dom.challenge_select.value = state.challenge; + dom.difficulty_bonus.textContent = `track xp bonus: x${get_track_xp_mult().toFixed(2)}`; + if (dom.challenge_bonus) { + const words_percent = Math.round(CHALLENGE_CONFIG[state.challenge].words_mult * 100); + dom.challenge_bonus.textContent = `challenge xp bonus: x${get_challenge_xp_mult().toFixed(2)} · round: ${get_round_seconds()}s · snippet size: ${words_percent}%`; + } } function render_progress() { @@ -206,10 +519,11 @@ function award_xp(summary) { 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); + xp = Math.floor(xp * get_total_xp_mult()); let pending = xp; let level_ups = 0; + const milestone_levels = []; while (pending > 0) { const need = xp_needed_for_level(state.level) - state.xp; if (pending >= need) { @@ -217,6 +531,7 @@ function award_xp(summary) { state.level += 1; state.xp = 0; level_ups += 1; + if (state.level % 5 === 0) milestone_levels.push(state.level); } else { state.xp += pending; pending = 0; @@ -224,7 +539,7 @@ function award_xp(summary) { } state.shards += level_ups; state.last_round_xp = xp; - return xp; + return { xp, milestone_levels, level_ups }; } function track_round(summary, earned_xp) { @@ -234,28 +549,19 @@ function track_round(summary, earned_xp) { 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.unshift({ stamp: Date.now(), wpm: summary.wpm, accuracy: summary.accuracy, best_combo: summary.best_combo, xp: earned_xp, difficulty: state.difficulty, challenge: state.challenge }); state.recent_rounds = state.recent_rounds.slice(0, 20); } -function mark_word_result(correct) { - const node = dom.text_display.querySelectorAll(".word")[state.index]; +function set_current_word(jump_to_next_line = false) { + const node = dom.text_display.querySelectorAll(".char-unit")[state.index]; if (!node) return; - node.classList.remove("current"); - node.classList.add(correct ? "correct" : "incorrect"); -} - -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" }); + node.scrollIntoView({ block: jump_to_next_line ? "start" : "nearest", behavior: "smooth" }); } function finish_round() { clearInterval(state.timer); state.running = false; - dom.type_input.disabled = true; const elapsed_m = Math.max(state.elapsed_seconds / 60, 1 / 60); const wpm = Math.round((state.typed_chars / 5) / elapsed_m); @@ -263,7 +569,10 @@ function finish_round() { const accuracy = total ? Math.round((state.correct_words / total) * 100) : 100; const summary = { wpm, accuracy, best_combo: state.best_combo }; - const earned_xp = award_xp(summary); + const reward = award_xp(summary); + const earned_xp = reward.xp; + queue_levelup_milestones(reward.milestone_levels); + queue_xp_level_pings(reward.level_ups); track_round(summary, earned_xp); render_progress(); update_live_stats(); @@ -273,26 +582,41 @@ function finish_round() { dom.results_summary.textContent = `you reached ${wpm} wpm with ${accuracy}% accuracy and earned ${earned_xp} xp!`; } -function reset_round() { +async function reset_round() { clearInterval(state.timer); + state.round_nonce += 1; + const nonce = state.round_nonce; + + const loaded = await load_snippet_words(); + const fallback_words = pick_words(); + const fallback_snippet = fallback_words.join(" "); + const fallback_parsed = tokenize_snippet_with_syntax(fallback_snippet, get_round_word_count(), "plaintext"); + const tokens = loaded?.tokens || fallback_parsed.tokens; + const chars = chars_from_tokens(tokens); + const source_label = loaded?.source ? `source: ${loaded.source}${loaded.track ? ` · track: ${loaded.track}` : ""}` : "source: local fallback"; + const snippet = loaded?.snippet || fallback_snippet; + + if (nonce !== state.round_nonce) return; + Object.assign(state, { - words: pick_words(), + target_chars: chars, index: 0, + user_chars: [], 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, + round_seconds: get_round_seconds(), running: false }); + + set_code_display(snippet, loaded?.source || "fallback.txt"); + dom.snippet_source.textContent = source_label; render_words(); update_live_stats(); update_timer(); - dom.type_input.disabled = false; - dom.type_input.value = ""; - dom.type_input.focus(); dom.results.hidden = true; } @@ -309,6 +633,13 @@ function set_difficulty(value) { save_progress(); } +function set_challenge(value) { + state.challenge = sanitize_challenge(value); + render_progress(); + reset_round(); + save_progress(); +} + 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; @@ -322,21 +653,67 @@ function buy_perk(perk_id) { function reset_progress() { localStorage.removeItem(STORAGE_KEY); clearInterval(state.timer); - Object.assign(state, { ...DEFAULT_STATE, stats: { ...DEFAULT_STATE.stats }, recent_rounds: [], owned_perks: [] }); + Object.assign(state, { + ...DEFAULT_STATE, + stats: { ...DEFAULT_STATE.stats }, + recent_rounds: [], + owned_perks: [] + }); render_progress(); reset_round(); save_progress(); } -dom.type_input.addEventListener("input", () => { - const value = dom.type_input.value; - if (!state.running && value.length) start_timer(); - if (!value.endsWith(" ")) return; +function submit_typed_word() { + return; +} - const typed_word = value.trim(); - const correct = typed_word === state.words[state.index]; - state.typed_chars += typed_word.length + 1; +function can_capture_typing(target) { + if (target instanceof HTMLInputElement) return true; + if (target instanceof HTMLTextAreaElement) return true; + if (target instanceof HTMLSelectElement) return true; + if (target instanceof HTMLElement && target.isContentEditable) return true; + return false; +} +function handle_typing_keydown(e) { + if (!dom.settings_modal.hidden || !dom.reset_confirm_modal.hidden) return; + if (!dom.results.hidden || state.index >= state.target_chars.length) return; + if (can_capture_typing(e.target)) return; + if (e.ctrlKey || e.metaKey || e.altKey) return; + + if (e.key === "Backspace") { + e.preventDefault(); + if (state.index <= 0) return; + state.index -= 1; + const removed = state.user_chars[state.index]; + state.user_chars[state.index] = ""; + if (removed) { + const expected = state.target_chars[state.index]?.ch || ""; + const correct = normalize_token(removed) === normalize_token(expected); + if (correct) state.correct_words = Math.max(0, state.correct_words - 1); + else state.wrong_words = Math.max(0, state.wrong_words - 1); + } + state.combo = 0; + render_words(); + return; + } + + let typed_char = ""; + if (e.key === "Enter") typed_char = "\n"; + else if (e.key === "Tab") typed_char = "\t"; + else if (e.key.length === 1) typed_char = e.key; + else return; + + e.preventDefault(); + if (!state.running) start_timer(); + + const expected = state.target_chars[state.index]?.ch || ""; + state.user_chars[state.index] = typed_char; + state.typed_chars += 1; + let completed_line = false; + + const correct = normalize_token(typed_char) === normalize_token(expected); if (correct) { state.correct_words += 1; state.combo += 1; @@ -346,20 +723,44 @@ dom.type_input.addEventListener("input", () => { state.combo = 0; } - mark_word_result(correct); state.index += 1; - dom.type_input.value = ""; - if (state.index >= state.words.length) return finish_round(); - set_current_word(); + + while (state.index < state.target_chars.length && state.target_chars[state.index].ch === "\n") { + state.user_chars[state.index] = "\n"; + state.correct_words += 1; + state.combo += 1; + state.best_combo = Math.max(state.best_combo, state.combo); + state.index += 1; + completed_line = true; + } + + if (completed_line) { + while (state.index < state.target_chars.length) { + const ch = state.target_chars[state.index].ch; + if (ch !== " " && ch !== "\t") break; + state.user_chars[state.index] = ch; + state.correct_words += 1; + state.combo += 1; + state.best_combo = Math.max(state.best_combo, state.combo); + state.index += 1; + } + } + + render_words(); update_live_stats(); -}); + if (state.index >= state.target_chars.length) return finish_round(); + set_current_word(completed_line); +} 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.tab_type_btn?.addEventListener("click", () => set_side_tab("type")); +dom.tab_perks_btn?.addEventListener("click", () => set_side_tab("perks")); dom.profile_name.addEventListener("change", (e) => set_profile_name(e.target.value)); dom.difficulty_select.addEventListener("change", (e) => set_difficulty(e.target.value)); +dom.challenge_select?.addEventListener("change", (e) => set_challenge(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", () => { @@ -374,6 +775,7 @@ document.addEventListener("keydown", (e) => { if (!dom.reset_confirm_modal.hidden) return close_reset_confirm(); if (!dom.settings_modal.hidden) close_settings(); }); +document.addEventListener("keydown", handle_typing_keydown); dom.perks.addEventListener("click", (e) => { const target = e.target; @@ -384,4 +786,5 @@ dom.perks.addEventListener("click", (e) => { load_progress(); render_progress(); +set_side_tab("type"); reset_round(); diff --git a/public/index.html b/public/index.html index 040d956..a206903 100644 --- a/public/index.html +++ b/public/index.html @@ -3,7 +3,8 @@ - typeup + speedcode + @@ -13,51 +14,50 @@
stats - + +
+
+ +
-
-

wpm

0
-

accuracy

100%
-

combo

x0
-

time

60s
-
+
+
+

rookie typer

+
shards: 0
+
-
+
+

wpm

0
+

accuracy

100%
+

combo

x0
+

time

60s
+
- +
+
+
+

language: loading...

+

source: loading...

- -
- + @@ -93,6 +101,35 @@ + + + + + + + + + + + + + + + + + + diff --git a/public/stats.js b/public/stats.js index 5a20e5e..089e1f2 100644 --- a/public/stats.js +++ b/public/stats.js @@ -1,4 +1,4 @@ -const STORAGE_KEY = "levelup-typing-save-v1"; +const STORAGE_KEY = "speedcode-typing-save-v1"; const q = (id) => document.getElementById(id); const set_text = (id, value) => { const el = q(id); @@ -28,8 +28,10 @@ function render_history(recent_rounds) { const date = new Date(round.stamp || Date.now()); const when = `${date.toLocaleDateString()} ${date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}`; const best_combo = round.best_combo ?? round.bestCombo ?? 0; + const track = String(round.difficulty || "full-stack").toLowerCase(); + const challenge = String(round.challenge || "standard").toLowerCase(); item.className = "history-item"; - item.textContent = `${when} | ${round.wpm || 0} wpm / ${round.accuracy || 0}% acc / combo ${best_combo} / +${round.xp || 0} xp / ${(round.difficulty || "normal").toLowerCase()}`; + item.textContent = `${when} | ${round.wpm || 0} wpm / ${round.accuracy || 0}% acc / combo ${best_combo} / +${round.xp || 0} xp / ${track} / ${challenge}`; list.appendChild(item); }); } diff --git a/public/styles.css b/public/styles.css index 9137db3..76e8d5e 100644 --- a/public/styles.css +++ b/public/styles.css @@ -42,24 +42,33 @@ body { } .layout { - grid-template-columns: 1.6fr 1fr; + grid-template-columns: 1fr; + background: radial-gradient(circle at center, rgba(12, 40, 34, 0.62) 0%, rgba(3, 7, 13, 0.85) 56%, rgba(2, 5, 10, 0.96) 100%); } .panel { border: 1px solid var(--border); background: var(--card); - box-shadow: 0 16px 36px rgba(0, 0, 0, 0.45); +} + +.levelup-grid { + position: absolute; + inset: 0; + background-image: + linear-gradient(rgba(130, 255, 213, 0.07) 1px, transparent 1px), + linear-gradient(90deg, rgba(130, 255, 213, 0.07) 1px, transparent 1px); + background-size: 40px 40px; + mask-image: radial-gradient(circle at center, black 18%, transparent 82%); + opacity: 0; +} + +.levelup-overlay.active .levelup-grid { + animation: levelup-grid 2200ms linear forwards; } .panel-main { padding: 1.2rem; } -.panel-side { padding: 1rem; } -.game-head, -.stats-header, -.modal-head, -.level-line, -.perk-actions, -.modal-actions { +.modal-head { display: flex; align-items: center; justify-content: space-between; @@ -68,26 +77,56 @@ body { .head-actions { display: flex; gap: 0.5rem; } .label { margin: 0; color: var(--accent-2); letter-spacing: 0.14em; } -h1, h2 { margin: 0; } + +.levelup-shockwave { + position: absolute; + width: 20vmin; + height: 20vmin; + border-radius: 50%; + border: 3px solid rgba(245, 204, 128, 0.8); + box-shadow: 0 0 24px rgba(245, 204, 128, 0.45); + opacity: 0; +} .btn { border: none; - padding: 0.58rem 1.1rem; font-weight: 700; color: #052218; background: var(--accent); - cursor: pointer; transition: transform 170ms ease, filter 170ms ease; } +.levelup-overlay.active .levelup-shockwave { + animation: levelup-shockwave 2200ms cubic-bezier(0.16, 0.9, 0.2, 1) forwards; +} + +.levelup-overlay.active .levelup-particles span { + animation: levelup-particle 1200ms ease-out forwards; +} + +.levelup-overlay.active .levelup-particles span:nth-child(odd) { animation-delay: 90ms; } +.levelup-overlay.active .levelup-particles span:nth-child(even) { animation-delay: 160ms; } + +.levelup-overlay[data-tier="3"] .levelup-ring, +.levelup-overlay[data-tier="4"] .levelup-ring { + border-width: 5px; +} + +.levelup-overlay[data-tier="3"] #levelUpText, +.levelup-overlay[data-tier="4"] #levelUpText { + letter-spacing: 0.12em; +} + +.levelup-overlay[data-tier="4"] #levelUpText { + text-shadow: 0 0 14px rgba(255, 248, 219, 0.96), 0 0 46px rgba(138, 246, 201, 0.68), 0 0 90px rgba(245, 204, 128, 0.3); +} + .btn:hover { transform: translateY(-2px); filter: brightness(1.05); } .btn-ghost { color: var(--text); background: rgba(255, 255, 255, 0.05); border: 1px solid rgba(194, 228, 210, 0.2); } -.btn-ghost:hover { filter: brightness(1.12); } a.btn { text-decoration: none; display: inline-flex; align-items: center; } .stats-grid, .stats-grid-large, -.perks, .history-list, .settings-grid { display: grid; @@ -99,7 +138,8 @@ a.btn { text-decoration: none; display: inline-flex; align-items: center; } .stat-card, .perk, -.history-item { +.history-item, +.incentive-item { border: 1px solid rgba(130, 160, 188, 0.24); background: rgba(8, 14, 24, 0.66); padding: 0.7rem; @@ -113,14 +153,26 @@ a.btn { text-decoration: none; display: inline-flex; align-items: center; } .stat-card strong { display: inline-block; margin-top: 0.2rem; font-size: 1.3rem; } -.text-shell { +.code-editor { margin-top: 1rem; - min-height: 170px; - max-height: 250px; - overflow-y: auto; + min-height: 260px; + max-height: 520px; border: 1px solid rgba(145, 175, 218, 0.25); background: rgba(4, 8, 15, 0.72); + overflow: auto; +} + +.code-pre { + margin: 0; + min-height: 140px; + background: transparent !important; padding: 0.95rem; +} + +#codeSnippet { + display: block; + white-space: pre-wrap; + text-transform: none; line-height: 1.8; } @@ -128,6 +180,24 @@ a.btn { text-decoration: none; display: inline-flex; align-items: center; } .word.current { color: #d6ff84; } .word.correct { color: #a8f7bf; } .word.incorrect { color: var(--bad); text-decoration: underline; text-decoration-thickness: 2px; } +.char-current { + color: #ffffff; + font-weight: 800; + background: rgba(138, 246, 201, 0.22); + outline: 1px solid rgba(138, 246, 201, 0.75); + box-shadow: 0 0 0 2px rgba(138, 246, 201, 0.22), 0 0 12px rgba(138, 246, 201, 0.45); + text-shadow: 0 0 10px rgba(255, 255, 255, 0.5); +} +.char-ok { color: #d6ff84; } +.char-bad { + color: #ff4d4d; + background: rgba(255, 66, 66, 0.28); + outline: 1px solid rgba(255, 99, 99, 0.92); + box-shadow: 0 0 0 2px rgba(255, 77, 77, 0.2); + text-decoration: underline; + text-decoration-thickness: 2px; + text-decoration-color: rgba(255, 138, 138, 0.95); +} .input-wrap { margin-top: 0.95rem; display: block; } #typeInput, @@ -155,11 +225,26 @@ a.btn { text-decoration: none; display: inline-flex; align-items: center; } padding: 0.9rem; } +.xp-dock { + margin-top: 1rem; + border: 1px solid rgba(138, 246, 201, 0.35); + background: rgba(7, 14, 20, 0.92); + padding: 0.75rem; +} + .mt-1 { margin-top: 1rem; } .xp-bar { margin-top: 0.5rem; height: 12px; overflow: hidden; background: rgba(255, 255, 255, 0.12); } #xpFill { height: 100%; width: 0; background: #87f5b0; transition: width 320ms ease; } .xp-gain { color: #baf1ff; margin-bottom: 0; } +.xp-bar.level-ping { + animation: xp-bar-ping 420ms ease-out; +} + +#xpFill.level-ping { + animation: xp-fill-ping 420ms ease-out; +} + .perk-buy { border: none; padding: 0.28rem 0.74rem; cursor: pointer; background: #b6f8ba; color: #193027; } .perk-buy:disabled { cursor: not-allowed; opacity: 0.44; } .status { margin-top: 0.44rem; display: inline-block; padding: 0.18rem 0.54rem; } @@ -167,6 +252,22 @@ a.btn { text-decoration: none; display: inline-flex; align-items: center; } .status.unlocked { background: rgba(167, 255, 141, 0.18); color: #b9ffad; } .status.active { background: rgba(255, 231, 118, 0.2); color: #ffe58f; } +.tab-switch { display: flex; gap: 0.5rem; margin-bottom: 0; } +.head-tabs { justify-content: flex-end; } +.tab-btn { + border: 1px solid rgba(145, 205, 218, 0.32); + background: rgba(8, 14, 24, 0.66); + color: var(--text); + padding: 0.5rem 0.65rem; + cursor: pointer; +} +.tab-btn.active { border-color: var(--accent); color: #c9ffe8; background: rgba(13, 31, 26, 0.85); } +.side-tab-panel { margin-top: 0.9rem; } +.side-tab-panel.profile, +.side-tab-panel .perks { + margin-top: 0; +} + .modal { position: fixed; inset: 0; @@ -184,6 +285,163 @@ a.btn { text-decoration: none; display: inline-flex; align-items: center; } padding: 1rem; } +.levelup-overlay { + position: fixed; + inset: 0; + z-index: 60; + display: grid; + place-items: center; + pointer-events: none; + background: radial-gradient(circle at center, rgba(21, 52, 46, 0.4) 0%, rgba(3, 7, 13, 0.78) 58%, rgba(2, 5, 10, 0.92) 100%); + opacity: 0; +} + +.levelup-overlay.active { + animation: levelup-fade 1900ms ease forwards; +} + +.levelup-glow { + position: absolute; + width: 42vmin; + height: 42vmin; + border-radius: 50%; + background: radial-gradient(circle, rgba(138, 246, 201, 0.88) 0%, rgba(138, 246, 201, 0.28) 38%, rgba(138, 246, 201, 0.04) 70%, transparent 100%); + filter: blur(3px); + transform: scale(0.58); +} + +.levelup-ring { + position: absolute; + width: 56vmin; + height: 56vmin; + border-radius: 50%; + border: 4px solid rgba(245, 204, 128, 0.8); + box-shadow: 0 0 36px rgba(245, 204, 128, 0.5), inset 0 0 24px rgba(245, 204, 128, 0.35); + transform: scale(0.6) rotate(0deg); +} + +.levelup-overlay.active .levelup-glow { + animation: levelup-glow 1900ms ease forwards; +} + +.levelup-overlay.active .levelup-ring { + animation: levelup-ring 1900ms cubic-bezier(0.2, 0.84, 0.2, 1) forwards; +} + +.levelup-core { + position: relative; + text-align: center; + transform: translateY(12px) scale(0.9); + opacity: 0; +} + +.levelup-overlay.active .levelup-core { + animation: levelup-text 1900ms ease forwards; +} + +.levelup-kicker { + margin: 0; + font-size: 0.95rem; + letter-spacing: 0.24em; + color: #b8ffe2; + text-transform: uppercase; +} + +#levelUpText { + margin: 0.28rem 0 0; + font-size: clamp(2.6rem, 9vw, 7rem); + letter-spacing: 0.1em; + color: #fff8db; + text-transform: uppercase; + text-shadow: 0 0 12px rgba(255, 248, 219, 0.9), 0 0 34px rgba(138, 246, 201, 0.56); +} + +.levelup-particles { + position: absolute; + width: 80vmin; + height: 80vmin; +} + +.levelup-particles span { + position: absolute; + top: 50%; + left: 50%; + width: 4px; + height: 18vmin; + transform-origin: center 0; + background: linear-gradient(to bottom, rgba(245, 204, 128, 0.95), rgba(138, 246, 201, 0.58), transparent); + opacity: 0; +} + +.levelup-particles span:nth-child(1) { transform: rotate(12deg) translate(-50%, -96%); } +.levelup-particles span:nth-child(2) { transform: rotate(56deg) translate(-50%, -96%); } +.levelup-particles span:nth-child(3) { transform: rotate(102deg) translate(-50%, -96%); } +.levelup-particles span:nth-child(4) { transform: rotate(145deg) translate(-50%, -96%); } +.levelup-particles span:nth-child(5) { transform: rotate(196deg) translate(-50%, -96%); } +.levelup-particles span:nth-child(6) { transform: rotate(242deg) translate(-50%, -96%); } +.levelup-particles span:nth-child(7) { transform: rotate(286deg) translate(-50%, -96%); } +.levelup-particles span:nth-child(8) { transform: rotate(332deg) translate(-50%, -96%); } + +@keyframes levelup-fade { + 0% { opacity: 0; } + 6% { opacity: 1; } + 90% { opacity: 1; } + 100% { opacity: 0; } +} + +@keyframes levelup-grid { + 0% { opacity: 0; transform: perspective(800px) rotateX(78deg) translateY(28%); } + 16% { opacity: 0.44; } + 72% { opacity: 0.22; } + 100% { opacity: 0; transform: perspective(800px) rotateX(78deg) translateY(12%); } +} + +@keyframes levelup-glow { + 0% { transform: scale(0.42); opacity: 0; } + 18% { transform: scale(1); opacity: 1; } + 78% { transform: scale(1.28); opacity: 0.86; } + 100% { transform: scale(1.36); opacity: 0; } +} + +@keyframes levelup-shockwave { + 0% { transform: scale(0.18); opacity: 0; } + 16% { transform: scale(1); opacity: 1; } + 84% { transform: scale(2.5); opacity: 0.18; } + 100% { transform: scale(2.8); opacity: 0; } +} + +@keyframes levelup-ring { + 0% { transform: scale(0.3) rotate(0deg); opacity: 0; } + 15% { transform: scale(0.92) rotate(86deg); opacity: 1; } + 82% { transform: scale(1.14) rotate(224deg); opacity: 0.88; } + 100% { transform: scale(1.22) rotate(280deg); opacity: 0; } +} + +@keyframes levelup-particle { + 0% { opacity: 0; filter: blur(1px); } + 16% { opacity: 1; filter: blur(0); } + 100% { opacity: 0; transform: translate(-50%, -160%) scaleY(0.32); } +} + +@keyframes levelup-text { + 0% { transform: translateY(28px) scale(0.78); opacity: 0; letter-spacing: 0.24em; } + 20% { transform: translateY(0) scale(1.08); opacity: 1; letter-spacing: 0.08em; } + 72% { transform: translateY(0) scale(1); opacity: 1; } + 100% { transform: translateY(-10px) scale(0.94); opacity: 0; } +} + +@keyframes xp-bar-ping { + 0% { box-shadow: 0 0 0 rgba(138, 246, 201, 0); transform: scaleY(1); } + 38% { box-shadow: 0 0 0 2px rgba(138, 246, 201, 0.55), 0 0 20px rgba(138, 246, 201, 0.45); transform: scaleY(1.06); } + 100% { box-shadow: 0 0 0 rgba(138, 246, 201, 0); transform: scaleY(1); } +} + +@keyframes xp-fill-ping { + 0% { filter: brightness(1) saturate(1); } + 42% { filter: brightness(1.7) saturate(1.28); } + 100% { filter: brightness(1) saturate(1); } +} + [hidden] { display: none !important; } .history-list { margin: 0; padding: 0; list-style: none; } @@ -192,4 +450,4 @@ a.btn { text-decoration: none; display: inline-flex; align-items: center; } .stats-grid, .stats-grid-large { grid-template-columns: repeat(2, minmax(0, 1fr)); } .head-actions { flex-wrap: wrap; justify-content: flex-end; } -} +} \ No newline at end of file