g
This commit is contained in:
1 parent
25dd6281ce
commit
7bf520096e
5 files changed
+1023
-111
No files matched your search
@@ -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, () => {
|
||||
|
||||
+462
-59
@@ -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();
|
||||
+67
-30
@@ -3,7 +3,8 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>typeup</title>
|
||||
<title>speedcode</title>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/themes/prism-tomorrow.min.css" />
|
||||
<link rel="stylesheet" href="styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
@@ -13,10 +14,20 @@
|
||||
<div class="head-actions">
|
||||
<a href="stats.html" class="btn btn-ghost">stats</a>
|
||||
<button id="settingsBtn" class="btn btn-ghost" type="button">settings</button>
|
||||
<button id="newRoundBtn" class="btn" type="button">reset</button>
|
||||
<button id="newRoundBtn" class="btn" type="button">reset prompt</button>
|
||||
</div>
|
||||
<div class="tab-switch head-tabs" role="tablist" aria-label="main tabs">
|
||||
<button id="tabTypeBtn" class="tab-btn active" type="button" role="tab" aria-selected="true">type</button>
|
||||
<button id="tabPerksBtn" class="tab-btn" type="button" role="tab" aria-selected="false">perks</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section id="typeTab" class="side-tab-panel" role="tabpanel">
|
||||
<section class="profile">
|
||||
<h2 id="profileTitle">rookie typer</h2>
|
||||
<div class="level-line"><span id="shardText">shards: 0</span></div>
|
||||
</section>
|
||||
|
||||
<div class="stats-grid">
|
||||
<article class="stat-card"><p>wpm</p><strong id="wpm">0</strong></article>
|
||||
<article class="stat-card"><p>accuracy</p><strong id="accuracy">100%</strong></article>
|
||||
@@ -24,40 +35,29 @@
|
||||
<article class="stat-card"><p>time</p><strong id="time">60s</strong></article>
|
||||
</div>
|
||||
|
||||
<section class="text-shell" id="textDisplay" aria-live="polite"></section>
|
||||
|
||||
<label class="input-wrap" for="typeInput">
|
||||
<input
|
||||
id="typeInput"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
autocorrect="off"
|
||||
autocapitalize="off"
|
||||
spellcheck="false"
|
||||
placeholder="start typing to begin..."
|
||||
/>
|
||||
</label>
|
||||
<div id="codeEditor" class="code-editor" aria-label="formatted code snippet and typing target">
|
||||
<pre class="code-pre"><code id="codeSnippet" class="language-plaintext"></code></pre>
|
||||
</div>
|
||||
<p id="snippetLanguage" class="setting-note">language: loading...</p>
|
||||
<p id="snippetSource" class="setting-note">source: loading...</p>
|
||||
|
||||
<section class="results" id="results" hidden>
|
||||
<h2>round complete!</h2>
|
||||
<p id="resultsSummary"></p>
|
||||
<button id="playAgainBtn" class="btn">go again</button>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<aside class="panel panel-side">
|
||||
<section class="profile">
|
||||
<h2 id="profileTitle">rookie typer</h2>
|
||||
<div class="level-line"><span id="levelText">level 1</span><span id="xpText">0 / 120 xp</span></div>
|
||||
<div class="level-line"><span id="shardText">shards: 0</span></div>
|
||||
<section class="xp-dock" aria-label="level progress">
|
||||
<div class="level-line"><span id="levelText" style="font-weight: bold;">level 1</span><ul></ul><span id="xpText">0 / 120 xp</span></div>
|
||||
<div class="xp-bar"><div id="xpFill"></div></div>
|
||||
<p id="xpGain" class="xp-gain">+0 xp last round</p>
|
||||
</section>
|
||||
<ul></ul>
|
||||
<section>
|
||||
</section>
|
||||
|
||||
<section id="perksTab" class="side-tab-panel" role="tabpanel" hidden>
|
||||
<div id="perks" class="perks"></div>
|
||||
</section>
|
||||
</aside>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<div id="settingsModal" class="modal" hidden>
|
||||
@@ -69,14 +69,22 @@
|
||||
<div class="settings-grid">
|
||||
<label for="profileName">name</label>
|
||||
<input id="profileName" type="text" maxlength="24" placeholder="enter your name..." />
|
||||
<label for="difficultySelect">difficulty</label>
|
||||
<label for="difficultySelect">experience level</label>
|
||||
<select id="difficultySelect">
|
||||
<option value="easy">easy</option>
|
||||
<option value="normal" selected>normal</option>
|
||||
<option value="hard">hard</option>
|
||||
<option value="expert">expert</option>
|
||||
<option value="frontend">frontend</option>
|
||||
<option value="backend">backend</option>
|
||||
<option value="systems">systems programming</option>
|
||||
<option value="full-stack" selected>full-stack</option>
|
||||
</select>
|
||||
<p id="difficultyBonus" class="setting-note">xp bonus: x1.00</p>
|
||||
<label for="challengeSelect">challenge level</label>
|
||||
<select id="challengeSelect">
|
||||
<option value="casual">casual</option>
|
||||
<option value="standard" selected>standard</option>
|
||||
<option value="intense">intense</option>
|
||||
<option value="nightmare">nightmare</option>
|
||||
</select>
|
||||
<p id="difficultyBonus" class="setting-note">track xp bonus: x1.00</p>
|
||||
<p id="challengeBonus" class="setting-note">challenge xp bonus: x1.00 · round: 60s · snippet size: 100%</p>
|
||||
<button id="resetProgressBtn" class="btn btn-ghost" type="button">reset save</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -93,6 +101,35 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="levelUpOverlay" class="levelup-overlay" hidden aria-hidden="true">
|
||||
<div class="levelup-grid"></div>
|
||||
<div class="levelup-glow"></div>
|
||||
<div class="levelup-shockwave"></div>
|
||||
<div class="levelup-particles" aria-hidden="true">
|
||||
<span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span>
|
||||
</div>
|
||||
<div class="levelup-core">
|
||||
<p id="levelUpKicker" class="levelup-kicker">milestone unlocked!</p>
|
||||
<h2 id="levelUpText">level up!</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/prism.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-javascript.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-typescript.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-python.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-json.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-css.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-markup.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-bash.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-c.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-cpp.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-rust.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-go.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-java.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-markup-templating.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-php.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-sql.min.js"></script>
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+4
-2
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
+277
-19
@@ -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; }
|
||||
|
||||
|
||||
Reference in new issue
Block a user