fortnite sauce
This commit is contained in:
1 parent
649574ec11
commit
25dd6281ce
6 files changed
+396
-964
No files matched your search
@@ -1,45 +1,10 @@
|
|||||||
const http = require("http");
|
const http = require("http");
|
||||||
const fs = require("fs");
|
const { readFile, stat } = require("fs");
|
||||||
const path = require("path");
|
const { extname, join, normalize } = require("path");
|
||||||
const { createLogger, format, transports } = require("winston");
|
|
||||||
const { consoleFormat } = require("winston-console-format");
|
|
||||||
const util = require("util");
|
|
||||||
|
|
||||||
const logger = createLogger({
|
const port = process.env.PORT || 3000;
|
||||||
level: "silly",
|
const public_dir = join(__dirname, "public");
|
||||||
format: format.combine(
|
const mime_types = {
|
||||||
format.timestamp(),
|
|
||||||
format.ms(),
|
|
||||||
format.errors({ stack: true }),
|
|
||||||
format.splat(),
|
|
||||||
format.json()
|
|
||||||
),
|
|
||||||
defaultMeta: { service: "Test" },
|
|
||||||
transports: [
|
|
||||||
new transports.Console({
|
|
||||||
format: format.combine(
|
|
||||||
format.colorize({ all: true }),
|
|
||||||
format.padLevels(),
|
|
||||||
consoleFormat({
|
|
||||||
showMeta: true,
|
|
||||||
metaStrip: ["timestamp", "service"],
|
|
||||||
inspectOptions: {
|
|
||||||
depth: Infinity,
|
|
||||||
colors: true,
|
|
||||||
maxArrayLength: Infinity,
|
|
||||||
breakLength: 120,
|
|
||||||
compact: Infinity,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
const PORT = process.env.PORT || 3000;
|
|
||||||
const PUBLIC_DIR = path.join(__dirname, "public");
|
|
||||||
|
|
||||||
const MIME_TYPES = {
|
|
||||||
".html": "text/html; charset=UTF-8",
|
".html": "text/html; charset=UTF-8",
|
||||||
".css": "text/css; charset=UTF-8",
|
".css": "text/css; charset=UTF-8",
|
||||||
".js": "application/javascript; charset=UTF-8",
|
".js": "application/javascript; charset=UTF-8",
|
||||||
@@ -54,51 +19,35 @@ const MIME_TYPES = {
|
|||||||
".ico": "image/x-icon"
|
".ico": "image/x-icon"
|
||||||
};
|
};
|
||||||
|
|
||||||
function resolveFilePath(urlPath) {
|
const safe_path = (url_path) => {
|
||||||
if (!urlPath || urlPath === "/") {
|
if (!url_path || url_path === "/") return join(public_dir, "index.html");
|
||||||
return path.join(PUBLIC_DIR, "index.html");
|
const cleaned = normalize(url_path).replace(/^(\.\.([/\\]|$))+/, "");
|
||||||
}
|
return join(public_dir, cleaned);
|
||||||
|
};
|
||||||
|
|
||||||
const safePath = path.normalize(urlPath).replace(/^(\.\.([/\\]|$))+/, "");
|
const send = (file_path, res) => {
|
||||||
return path.join(PUBLIC_DIR, safePath);
|
readFile(file_path, (err, data) => {
|
||||||
}
|
if (err?.code === "ENOENT") {
|
||||||
|
|
||||||
function sendFile(filePath, res) {
|
|
||||||
fs.readFile(filePath, (err, data) => {
|
|
||||||
if (err) {
|
|
||||||
if (err.code === "ENOENT") {
|
|
||||||
res.writeHead(404, { "Content-Type": "text/plain; charset=UTF-8" });
|
res.writeHead(404, { "Content-Type": "text/plain; charset=UTF-8" });
|
||||||
res.end("404 Not Found");
|
res.end("404 not found");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (err) {
|
||||||
res.writeHead(500, { "Content-Type": "text/plain; charset=UTF-8" });
|
res.writeHead(500, { "Content-Type": "text/plain; charset=UTF-8" });
|
||||||
res.end("500 Internal Server Error");
|
res.end("500 internal server error");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const type = mime_types[extname(file_path).toLowerCase()] || "application/octet-stream";
|
||||||
const ext = path.extname(filePath).toLowerCase();
|
res.writeHead(200, { "Content-Type": type });
|
||||||
const contentType = MIME_TYPES[ext] || "application/octet-stream";
|
|
||||||
|
|
||||||
res.writeHead(200, { "Content-Type": contentType });
|
|
||||||
res.end(data);
|
res.end(data);
|
||||||
});
|
});
|
||||||
}
|
};
|
||||||
|
|
||||||
const server = http.createServer((req, res) => {
|
http
|
||||||
const reqPath = (req.url || "/").split("?")[0];
|
.createServer((req, res) => {
|
||||||
const filePath = resolveFilePath(reqPath);
|
const file_path = safe_path((req.url || "/").split("?")[0]);
|
||||||
|
stat(file_path, (err, info) => send(!err && info.isDirectory() ? join(file_path, "index.html") : file_path, res));
|
||||||
fs.stat(filePath, (err, stat) => {
|
})
|
||||||
if (!err && stat.isDirectory()) {
|
.listen(port, () => {
|
||||||
sendFile(path.join(filePath, "index.html"), res);
|
console.log(`web running at http://localhost:${port}`);
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
sendFile(filePath, res);
|
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
server.listen(PORT, () => {
|
|
||||||
logger.info(`web running at http://localhost:${PORT}`);
|
|
||||||
});
|
|
||||||
+250
-425
@@ -1,562 +1,387 @@
|
|||||||
const STORAGE_KEY = "levelup-typing-save-v1";
|
const STORAGE_KEY = "levelup-typing-save-v1";
|
||||||
|
|
||||||
const WORD_BANKS = {
|
const WORD_BANKS = {
|
||||||
easy: [
|
easy: ["apple", "river", "light", "happy", "quick", "smile", "cloud", "dream", "friend", "learn", "typing", "music", "garden", "planet", "window", "ticket", "puzzle", "silver", "orange", "pepper"],
|
||||||
"apple", "river", "light", "happy", "quick", "smile", "cloud", "dream", "friend", "learn",
|
normal: ["speed", "skill", "sprint", "rhythm", "tempo", "focus", "kinetic", "logic", "phrase", "arcade", "streak", "vector", "groove", "momentum", "signal", "catalyst", "syntax", "drift", "horizon", "quantum"],
|
||||||
"typing", "music", "garden", "planet", "window", "ticket", "puzzle", "silver", "orange", "pepper"
|
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"]
|
||||||
normal: [
|
|
||||||
"speed", "skill", "sprint", "rhythm", "tempo", "focus", "kinetic", "logic", "phrase", "arcade",
|
|
||||||
"streak", "vector", "groove", "momentum", "signal", "catalyst", "syntax", "drift", "horizon", "quantum"
|
|
||||||
],
|
|
||||||
hard: [
|
|
||||||
"architect", "backspace", "fragment", "notation", "velocity", "parallel", "hexadecimal", "interface", "algorithm", "synchronize",
|
|
||||||
"compilation", "resolution", "parameter", "validation", "hyperlink", "generator", "trajectory", "oscillation", "framework", "benchmark"
|
|
||||||
],
|
|
||||||
expert: [
|
|
||||||
"extraordinary", "counterclockwise", "asymptotically", "deconstruction", "characteristically", "electromagnetic", "microarchitecture", "misconfiguration", "representational", "interoperability",
|
|
||||||
"cryptographically", "institutionalize", "uncharacteristic", "transcontinental", "hyperparameter", "instrumentation", "multiprocessing", "disproportionate", "comprehensibility", "photosynthesis"
|
|
||||||
]
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const DIFFICULTY = {
|
const DIFFICULTY_CONFIG = {
|
||||||
easy: { label: "easy", xpMult: 0.9, roundWords: 38 },
|
easy: { xp_mult: 0.9, round_words: 38 },
|
||||||
normal: { label: "normal", xpMult: 1, roundWords: 45 },
|
normal: { xp_mult: 1, round_words: 45 },
|
||||||
hard: { label: "hard", xpMult: 1.25, roundWords: 50 },
|
hard: { xp_mult: 1.25, round_words: 50 },
|
||||||
expert: { label: "expert", xpMult: 1.5, roundWords: 54 }
|
expert: { xp_mult: 1.5, round_words: 54 }
|
||||||
};
|
};
|
||||||
|
|
||||||
const PERKS = [
|
const PERKS = [
|
||||||
{
|
{ id: "combo-boost", name: "combo boost", unlock_level: 2, cost: 1, description: "earn 15% more xp while your best combo is 5 or higher." },
|
||||||
id: "combo-boost",
|
{ id: "accuracy-bounty", name: "accuracy bounty", unlock_level: 4, cost: 2, description: "if you finish with 96%+ accuracy, gain +30 bonus xp." },
|
||||||
name: "combo boost",
|
{ id: "time-warp", name: "time warp", unlock_level: 6, cost: 2, description: "start rounds with +8 seconds for easier high-streak runs." },
|
||||||
unlockLevel: 2,
|
{ id: "focus-engine", name: "focus engine", unlock_level: 8, cost: 3, description: "at 98%+ accuracy, your wpm contributes an extra 25% xp." }
|
||||||
cost: 1,
|
|
||||||
description: "Earn 15% more XP while your best combo is 5 or higher."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "accuracy-bounty",
|
|
||||||
name: "accuracy bounty",
|
|
||||||
unlockLevel: 4,
|
|
||||||
cost: 2,
|
|
||||||
description: "If you finish with 96%+ accuracy, gain +30 bonus XP."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "time-warp",
|
|
||||||
name: "time warp",
|
|
||||||
unlockLevel: 6,
|
|
||||||
cost: 2,
|
|
||||||
description: "Start rounds with +8 seconds for easier high-streak runs."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "focus-engine",
|
|
||||||
name: "focus engine",
|
|
||||||
unlockLevel: 8,
|
|
||||||
cost: 3,
|
|
||||||
description: "At 98%+ accuracy, your WPM contributes an extra 25% XP."
|
|
||||||
}
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const defaultState = {
|
const DEFAULT_STATE = {
|
||||||
profileName: "rookie typer",
|
profile_name: "rookie typer",
|
||||||
difficulty: "normal",
|
difficulty: "normal",
|
||||||
words: [],
|
words: [],
|
||||||
index: 0,
|
index: 0,
|
||||||
typedChars: 0,
|
typed_chars: 0,
|
||||||
correctWords: 0,
|
correct_words: 0,
|
||||||
wrongWords: 0,
|
wrong_words: 0,
|
||||||
combo: 0,
|
combo: 0,
|
||||||
bestCombo: 0,
|
best_combo: 0,
|
||||||
timer: null,
|
timer: null,
|
||||||
elapsedSeconds: 0,
|
elapsed_seconds: 0,
|
||||||
roundSeconds: 60,
|
round_seconds: 60,
|
||||||
running: false,
|
running: false,
|
||||||
xp: 0,
|
xp: 0,
|
||||||
level: 1,
|
level: 1,
|
||||||
shards: 0,
|
shards: 0,
|
||||||
lastRoundXp: 0,
|
last_round_xp: 0,
|
||||||
ownedPerks: [],
|
owned_perks: [],
|
||||||
stats: {
|
stats: { total_rounds: 0, best_wpm: 0, best_accuracy: 0, average_wpm: 0, average_accuracy: 0 },
|
||||||
totalRounds: 0,
|
recent_rounds: []
|
||||||
bestWpm: 0,
|
|
||||||
bestAccuracy: 0,
|
|
||||||
averageWpm: 0,
|
|
||||||
averageAccuracy: 0
|
|
||||||
},
|
|
||||||
recentRounds: []
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const state = { ...defaultState };
|
const state = { ...DEFAULT_STATE, stats: { ...DEFAULT_STATE.stats }, recent_rounds: [] };
|
||||||
|
const q = (id) => document.getElementById(id);
|
||||||
|
const num = (v, d = 0) => (Number.isFinite(v) ? Number(v) : d);
|
||||||
|
const snake_or_camel = (obj, snake, camel, fallback = undefined) => (obj?.[snake] ?? obj?.[camel] ?? fallback);
|
||||||
|
|
||||||
const els = {
|
const dom = {
|
||||||
textDisplay: document.getElementById("textDisplay"),
|
text_display: q("textDisplay"), type_input: q("typeInput"), new_round_btn: q("newRoundBtn"), settings_btn: q("settingsBtn"),
|
||||||
typeInput: document.getElementById("typeInput"),
|
close_settings_btn: q("closeSettingsBtn"), settings_modal: q("settingsModal"), reset_confirm_modal: q("resetConfirmModal"),
|
||||||
newRoundBtn: document.getElementById("newRoundBtn"),
|
cancel_reset_btn: q("cancelResetBtn"), confirm_reset_btn: q("confirmResetBtn"), play_again_btn: q("playAgainBtn"),
|
||||||
settingsBtn: document.getElementById("settingsBtn"),
|
results: q("results"), results_summary: q("resultsSummary"), wpm: q("wpm"), accuracy: q("accuracy"), combo: q("combo"),
|
||||||
closeSettingsBtn: document.getElementById("closeSettingsBtn"),
|
time: q("time"), profile_title: q("profileTitle"), level_text: q("levelText"), xp_text: q("xpText"), shard_text: q("shardText"),
|
||||||
settingsModal: document.getElementById("settingsModal"),
|
xp_fill: q("xpFill"), xp_gain: q("xpGain"), perks: q("perks"), profile_name: q("profileName"), difficulty_select: q("difficultySelect"),
|
||||||
resetConfirmModal: document.getElementById("resetConfirmModal"),
|
difficulty_bonus: q("difficultyBonus"), reset_progress_btn: q("resetProgressBtn")
|
||||||
cancelResetBtn: document.getElementById("cancelResetBtn"),
|
|
||||||
confirmResetBtn: document.getElementById("confirmResetBtn"),
|
|
||||||
playAgainBtn: document.getElementById("playAgainBtn"),
|
|
||||||
results: document.getElementById("results"),
|
|
||||||
resultsSummary: document.getElementById("resultsSummary"),
|
|
||||||
wpm: document.getElementById("wpm"),
|
|
||||||
accuracy: document.getElementById("accuracy"),
|
|
||||||
combo: document.getElementById("combo"),
|
|
||||||
time: document.getElementById("time"),
|
|
||||||
profileTitle: document.getElementById("profileTitle"),
|
|
||||||
levelText: document.getElementById("levelText"),
|
|
||||||
xpText: document.getElementById("xpText"),
|
|
||||||
shardText: document.getElementById("shardText"),
|
|
||||||
xpFill: document.getElementById("xpFill"),
|
|
||||||
xpGain: document.getElementById("xpGain"),
|
|
||||||
perks: document.getElementById("perks"),
|
|
||||||
profileName: document.getElementById("profileName"),
|
|
||||||
difficultySelect: document.getElementById("difficultySelect"),
|
|
||||||
difficultyBonus: document.getElementById("difficultyBonus"),
|
|
||||||
resetProgressBtn: document.getElementById("resetProgressBtn")
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function xpNeededForLevel(level) {
|
const xp_needed_for_level = (level) => 120 + (level - 1) * 60;
|
||||||
return 120 + (level - 1) * 60;
|
const sanitize_difficulty = (v) => (DIFFICULTY_CONFIG[v] ? v : "normal");
|
||||||
}
|
const has_perk = (id) => state.owned_perks.includes(id);
|
||||||
|
const open_settings = () => { dom.settings_modal.hidden = false; };
|
||||||
|
const close_settings = () => { dom.settings_modal.hidden = true; };
|
||||||
|
const open_reset_confirm = () => { dom.reset_confirm_modal.hidden = false; };
|
||||||
|
const close_reset_confirm = () => { dom.reset_confirm_modal.hidden = true; };
|
||||||
|
|
||||||
function sanitizeDifficulty(value) {
|
function save_progress() {
|
||||||
return DIFFICULTY[value] ? value : "normal";
|
localStorage.setItem(STORAGE_KEY, JSON.stringify({
|
||||||
}
|
profile_name: state.profile_name,
|
||||||
|
|
||||||
function saveProgress() {
|
|
||||||
const payload = {
|
|
||||||
profileName: state.profileName,
|
|
||||||
difficulty: state.difficulty,
|
difficulty: state.difficulty,
|
||||||
xp: state.xp,
|
xp: state.xp,
|
||||||
level: state.level,
|
level: state.level,
|
||||||
shards: state.shards,
|
shards: state.shards,
|
||||||
lastRoundXp: state.lastRoundXp,
|
last_round_xp: state.last_round_xp,
|
||||||
ownedPerks: state.ownedPerks,
|
owned_perks: state.owned_perks,
|
||||||
stats: state.stats,
|
stats: state.stats,
|
||||||
recentRounds: state.recentRounds
|
recent_rounds: state.recent_rounds
|
||||||
};
|
}));
|
||||||
|
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(payload));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadProgress() {
|
function load_progress() {
|
||||||
const raw = localStorage.getItem(STORAGE_KEY);
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
if (!raw) return;
|
if (!raw) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(raw);
|
const parsed = JSON.parse(raw);
|
||||||
state.profileName = typeof parsed.profileName === "string" && parsed.profileName.trim()
|
state.profile_name = String(snake_or_camel(parsed, "profile_name", "profileName", DEFAULT_STATE.profile_name)).trim().slice(0, 24) || DEFAULT_STATE.profile_name;
|
||||||
? parsed.profileName.trim().slice(0, 24)
|
state.difficulty = sanitize_difficulty(parsed.difficulty);
|
||||||
: defaultState.profileName;
|
state.xp = Math.max(0, Math.floor(num(parsed.xp)));
|
||||||
state.difficulty = sanitizeDifficulty(parsed.difficulty);
|
state.level = Math.max(1, Math.floor(num(parsed.level, 1)));
|
||||||
state.xp = Number.isFinite(parsed.xp) ? Math.max(0, Math.floor(parsed.xp)) : 0;
|
state.shards = Math.max(0, Math.floor(num(parsed.shards)));
|
||||||
state.level = Number.isFinite(parsed.level) ? Math.max(1, Math.floor(parsed.level)) : 1;
|
state.last_round_xp = Math.max(0, Math.floor(num(snake_or_camel(parsed, "last_round_xp", "lastRoundXp"))));
|
||||||
state.shards = Number.isFinite(parsed.shards) ? Math.max(0, Math.floor(parsed.shards)) : 0;
|
|
||||||
state.lastRoundXp = Number.isFinite(parsed.lastRoundXp) ? Math.max(0, Math.floor(parsed.lastRoundXp)) : 0;
|
|
||||||
state.ownedPerks = Array.isArray(parsed.ownedPerks)
|
|
||||||
? parsed.ownedPerks.filter((id) => PERKS.some((perk) => perk.id === id))
|
|
||||||
: [];
|
|
||||||
|
|
||||||
const parsedStats = parsed.stats || {};
|
const owned = snake_or_camel(parsed, "owned_perks", "ownedPerks", []);
|
||||||
|
state.owned_perks = Array.isArray(owned) ? owned.filter((id) => PERKS.some((p) => p.id === id)) : [];
|
||||||
|
|
||||||
|
const stats = parsed.stats || {};
|
||||||
state.stats = {
|
state.stats = {
|
||||||
totalRounds: Number.isFinite(parsedStats.totalRounds) ? Math.max(0, Math.floor(parsedStats.totalRounds)) : 0,
|
total_rounds: Math.max(0, Math.floor(num(snake_or_camel(stats, "total_rounds", "totalRounds")))),
|
||||||
bestWpm: Number.isFinite(parsedStats.bestWpm) ? Math.max(0, Math.floor(parsedStats.bestWpm)) : 0,
|
best_wpm: Math.max(0, Math.floor(num(snake_or_camel(stats, "best_wpm", "bestWpm")))),
|
||||||
bestAccuracy: Number.isFinite(parsedStats.bestAccuracy) ? Math.max(0, Math.floor(parsedStats.bestAccuracy)) : 0,
|
best_accuracy: Math.max(0, Math.floor(num(snake_or_camel(stats, "best_accuracy", "bestAccuracy")))),
|
||||||
averageWpm: Number.isFinite(parsedStats.averageWpm) ? Math.max(0, Number(parsedStats.averageWpm)) : 0,
|
average_wpm: Math.max(0, num(snake_or_camel(stats, "average_wpm", "averageWpm"))),
|
||||||
averageAccuracy: Number.isFinite(parsedStats.averageAccuracy) ? Math.max(0, Number(parsedStats.averageAccuracy)) : 0
|
average_accuracy: Math.max(0, num(snake_or_camel(stats, "average_accuracy", "averageAccuracy")))
|
||||||
};
|
};
|
||||||
|
|
||||||
state.recentRounds = Array.isArray(parsed.recentRounds)
|
const rounds = snake_or_camel(parsed, "recent_rounds", "recentRounds", []);
|
||||||
? parsed.recentRounds.slice(0, 20)
|
state.recent_rounds = Array.isArray(rounds)
|
||||||
|
? rounds.slice(0, 20).map((r) => ({
|
||||||
|
stamp: num(r.stamp, Date.now()),
|
||||||
|
wpm: num(r.wpm),
|
||||||
|
accuracy: num(r.accuracy),
|
||||||
|
best_combo: num(snake_or_camel(r, "best_combo", "bestCombo")),
|
||||||
|
xp: num(r.xp),
|
||||||
|
difficulty: sanitize_difficulty(r.difficulty)
|
||||||
|
}))
|
||||||
: [];
|
: [];
|
||||||
} catch (error) {
|
} catch {
|
||||||
localStorage.removeItem(STORAGE_KEY);
|
localStorage.removeItem(STORAGE_KEY);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function trackRound(summary, earnedXp) {
|
function pick_words() {
|
||||||
state.stats.totalRounds += 1;
|
const cfg = DIFFICULTY_CONFIG[state.difficulty];
|
||||||
state.stats.bestWpm = Math.max(state.stats.bestWpm, summary.wpm);
|
|
||||||
state.stats.bestAccuracy = Math.max(state.stats.bestAccuracy, summary.accuracy);
|
|
||||||
|
|
||||||
const rounds = state.stats.totalRounds;
|
|
||||||
state.stats.averageWpm = ((state.stats.averageWpm * (rounds - 1)) + summary.wpm) / rounds;
|
|
||||||
state.stats.averageAccuracy = ((state.stats.averageAccuracy * (rounds - 1)) + summary.accuracy) / rounds;
|
|
||||||
|
|
||||||
const snapshot = {
|
|
||||||
stamp: Date.now(),
|
|
||||||
wpm: summary.wpm,
|
|
||||||
accuracy: summary.accuracy,
|
|
||||||
bestCombo: summary.bestCombo,
|
|
||||||
xp: earnedXp,
|
|
||||||
difficulty: state.difficulty
|
|
||||||
};
|
|
||||||
|
|
||||||
state.recentRounds.unshift(snapshot);
|
|
||||||
if (state.recentRounds.length > 20) {
|
|
||||||
state.recentRounds.length = 20;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function openSettings() {
|
|
||||||
els.settingsModal.hidden = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeSettings() {
|
|
||||||
els.settingsModal.hidden = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function openResetConfirm() {
|
|
||||||
els.resetConfirmModal.hidden = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeResetConfirm() {
|
|
||||||
els.resetConfirmModal.hidden = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function pickWords() {
|
|
||||||
const config = DIFFICULTY[state.difficulty];
|
|
||||||
const pool = WORD_BANKS[state.difficulty];
|
const pool = WORD_BANKS[state.difficulty];
|
||||||
const result = [];
|
return Array.from({ length: cfg.round_words }, () => pool[Math.floor(Math.random() * pool.length)]);
|
||||||
|
|
||||||
for (let i = 0; i < config.roundWords; i += 1) {
|
|
||||||
result.push(pool[Math.floor(Math.random() * pool.length)]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasPerk(id) {
|
function render_words() {
|
||||||
return state.ownedPerks.includes(id);
|
dom.text_display.innerHTML = "";
|
||||||
}
|
|
||||||
|
|
||||||
function renderWords() {
|
|
||||||
els.textDisplay.innerHTML = "";
|
|
||||||
state.words.forEach((word, idx) => {
|
state.words.forEach((word, idx) => {
|
||||||
const span = document.createElement("span");
|
const span = document.createElement("span");
|
||||||
span.className = "word";
|
span.className = `word${idx === state.index ? " current" : ""}`;
|
||||||
if (idx === state.index) span.classList.add("current");
|
|
||||||
span.textContent = `${word} `;
|
span.textContent = `${word} `;
|
||||||
els.textDisplay.appendChild(span);
|
dom.text_display.appendChild(span);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderPerks() {
|
function render_perks() {
|
||||||
els.perks.innerHTML = "";
|
dom.perks.innerHTML = "";
|
||||||
|
|
||||||
PERKS.forEach((perk) => {
|
PERKS.forEach((perk) => {
|
||||||
const levelReady = state.level >= perk.unlockLevel;
|
const level_ready = state.level >= perk.unlock_level;
|
||||||
const owned = hasPerk(perk.id);
|
const owned = has_perk(perk.id);
|
||||||
const canBuy = levelReady && !owned && state.shards >= perk.cost;
|
const can_buy = level_ready && !owned && state.shards >= perk.cost;
|
||||||
|
|
||||||
const card = document.createElement("article");
|
const card = document.createElement("article");
|
||||||
card.className = "perk";
|
card.className = "perk";
|
||||||
card.innerHTML = `
|
card.innerHTML = `<h3>${perk.name} · lv ${perk.unlock_level}</h3>
|
||||||
<h3>${perk.name} · Lv ${perk.unlockLevel}</h3>
|
|
||||||
<p>${perk.description}</p>
|
<p>${perk.description}</p>
|
||||||
<div class="perk-actions">
|
<div class="perk-actions">
|
||||||
<span class="perk-cost">Cost: ${perk.cost} shard${perk.cost > 1 ? "s" : ""}</span>
|
<span class="perk-cost">cost: ${perk.cost} shard${perk.cost > 1 ? "s" : ""}</span>
|
||||||
<button class="perk-buy" data-perk-id="${perk.id}" ${canBuy ? "" : "disabled"}>
|
<button class="perk-buy" data-perk-id="${perk.id}" ${can_buy ? "" : "disabled"}>${owned ? "owned" : level_ready ? "buy" : "locked"}</button>
|
||||||
${owned ? "owned" : levelReady ? "buy" : "locked"}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<span class="status ${owned ? "active" : levelReady ? "unlocked" : "locked"}">
|
<span class="status ${owned ? "active" : level_ready ? "unlocked" : "locked"}">${owned ? "active" : level_ready ? "shop open" : "locked"}</span>`;
|
||||||
${owned ? "active" : levelReady ? "shop open" : "locked"}
|
dom.perks.appendChild(card);
|
||||||
</span>
|
|
||||||
`;
|
|
||||||
|
|
||||||
els.perks.appendChild(card);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderSettings() {
|
function render_settings() {
|
||||||
const config = DIFFICULTY[state.difficulty];
|
dom.profile_name.value = state.profile_name;
|
||||||
els.profileName.value = state.profileName;
|
dom.difficulty_select.value = state.difficulty;
|
||||||
els.difficultySelect.value = state.difficulty;
|
dom.difficulty_bonus.textContent = `xp bonus: x${DIFFICULTY_CONFIG[state.difficulty].xp_mult.toFixed(2)}`;
|
||||||
els.difficultyBonus.textContent = `xp bonus: x${config.xpMult.toFixed(2)}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderProgress() {
|
function render_progress() {
|
||||||
const levelNeed = xpNeededForLevel(state.level);
|
const level_need = xp_needed_for_level(state.level);
|
||||||
const ratio = Math.min((state.xp / levelNeed) * 100, 100);
|
dom.profile_title.textContent = state.profile_name;
|
||||||
|
dom.level_text.textContent = `level ${state.level}`;
|
||||||
els.profileTitle.textContent = state.profileName;
|
dom.xp_text.textContent = `${state.xp} / ${level_need} xp`;
|
||||||
els.levelText.textContent = `level ${state.level}`;
|
dom.shard_text.textContent = `perk shards: ${state.shards}`;
|
||||||
els.xpText.textContent = `${state.xp} / ${levelNeed} xp`;
|
dom.xp_fill.style.width = `${Math.min((state.xp / level_need) * 100, 100)}%`;
|
||||||
els.shardText.textContent = `perk shards: ${state.shards}`;
|
dom.xp_gain.textContent = `+${state.last_round_xp} xp last round`;
|
||||||
els.xpFill.style.width = `${ratio}%`;
|
render_settings();
|
||||||
els.xpGain.textContent = `+${state.lastRoundXp} xp last round`;
|
render_perks();
|
||||||
|
|
||||||
renderSettings();
|
|
||||||
renderPerks();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateLiveStats() {
|
function update_live_stats() {
|
||||||
const elapsed = Math.max(state.elapsedSeconds / 60, 1 / 60);
|
const elapsed = Math.max(state.elapsed_seconds / 60, 1 / 60);
|
||||||
const wordsTypedEstimate = state.typedChars / 5;
|
const wpm = Math.round((state.typed_chars / 5) / elapsed);
|
||||||
const wpm = Math.round(wordsTypedEstimate / elapsed);
|
const total = state.correct_words + state.wrong_words;
|
||||||
const totalWords = state.correctWords + state.wrongWords;
|
const accuracy = total ? Math.round((state.correct_words / total) * 100) : 100;
|
||||||
const acc = totalWords === 0 ? 100 : Math.round((state.correctWords / totalWords) * 100);
|
dom.wpm.textContent = String(wpm);
|
||||||
|
dom.accuracy.textContent = `${accuracy}%`;
|
||||||
els.wpm.textContent = String(wpm);
|
dom.combo.textContent = `x${state.combo}`;
|
||||||
els.accuracy.textContent = `${acc}%`;
|
|
||||||
els.combo.textContent = `x${state.combo}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateTimer() {
|
function update_timer() {
|
||||||
const remaining = Math.max(state.roundSeconds - state.elapsedSeconds, 0);
|
const remaining = Math.max(state.round_seconds - state.elapsed_seconds, 0);
|
||||||
els.time.textContent = `${remaining}s`;
|
dom.time.textContent = `${remaining}s`;
|
||||||
els.time.style.color = remaining <= 10 ? "#ffd587" : "";
|
dom.time.style.color = remaining <= 10 ? "#ffd587" : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
function startTimer() {
|
function start_timer() {
|
||||||
if (state.running) return;
|
if (state.running) return;
|
||||||
|
|
||||||
state.running = true;
|
state.running = true;
|
||||||
state.timer = setInterval(() => {
|
state.timer = setInterval(() => {
|
||||||
state.elapsedSeconds += 1;
|
state.elapsed_seconds += 1;
|
||||||
updateTimer();
|
update_timer();
|
||||||
updateLiveStats();
|
update_live_stats();
|
||||||
|
if (state.elapsed_seconds >= state.round_seconds) finish_round();
|
||||||
if (state.elapsedSeconds >= state.roundSeconds) {
|
|
||||||
finishRound();
|
|
||||||
}
|
|
||||||
}, 1000);
|
}, 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
function awardXp(summary) {
|
function award_xp(summary) {
|
||||||
let xp = 35;
|
let xp = 35 + Math.floor(summary.wpm * 1.4) + Math.floor(summary.accuracy * 0.55) + Math.floor(summary.best_combo * 2.5);
|
||||||
xp += Math.floor(summary.wpm * 1.4);
|
if (has_perk("combo-boost") && summary.best_combo >= 5) xp = Math.floor(xp * 1.15);
|
||||||
xp += Math.floor(summary.accuracy * 0.55);
|
if (has_perk("accuracy-bounty") && summary.accuracy >= 96) xp += 30;
|
||||||
xp += Math.floor(summary.bestCombo * 2.5);
|
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);
|
||||||
if (hasPerk("combo-boost") && summary.bestCombo >= 5) {
|
|
||||||
xp = Math.floor(xp * 1.15);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hasPerk("accuracy-bounty") && summary.accuracy >= 96) {
|
|
||||||
xp += 30;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hasPerk("focus-engine") && summary.accuracy >= 98) {
|
|
||||||
xp += Math.floor(summary.wpm * 0.25);
|
|
||||||
}
|
|
||||||
|
|
||||||
xp = Math.floor(xp * DIFFICULTY[state.difficulty].xpMult);
|
|
||||||
|
|
||||||
let pending = xp;
|
let pending = xp;
|
||||||
let levelUps = 0;
|
let level_ups = 0;
|
||||||
|
|
||||||
while (pending > 0) {
|
while (pending > 0) {
|
||||||
const need = xpNeededForLevel(state.level) - state.xp;
|
const need = xp_needed_for_level(state.level) - state.xp;
|
||||||
if (pending >= need) {
|
if (pending >= need) {
|
||||||
pending -= need;
|
pending -= need;
|
||||||
state.level += 1;
|
state.level += 1;
|
||||||
levelUps += 1;
|
|
||||||
state.xp = 0;
|
state.xp = 0;
|
||||||
|
level_ups += 1;
|
||||||
} else {
|
} else {
|
||||||
state.xp += pending;
|
state.xp += pending;
|
||||||
pending = 0;
|
pending = 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
state.shards += level_ups;
|
||||||
state.shards += levelUps;
|
state.last_round_xp = xp;
|
||||||
state.lastRoundXp = xp;
|
|
||||||
return xp;
|
return xp;
|
||||||
}
|
}
|
||||||
|
|
||||||
function markWordResult(correct) {
|
function track_round(summary, earned_xp) {
|
||||||
const wordNodes = els.textDisplay.querySelectorAll(".word");
|
const s = state.stats;
|
||||||
const node = wordNodes[state.index];
|
s.total_rounds += 1;
|
||||||
if (!node) return;
|
s.best_wpm = Math.max(s.best_wpm, summary.wpm);
|
||||||
|
s.best_accuracy = Math.max(s.best_accuracy, summary.accuracy);
|
||||||
|
s.average_wpm = ((s.average_wpm * (s.total_rounds - 1)) + summary.wpm) / s.total_rounds;
|
||||||
|
s.average_accuracy = ((s.average_accuracy * (s.total_rounds - 1)) + summary.accuracy) / s.total_rounds;
|
||||||
|
state.recent_rounds.unshift({ stamp: Date.now(), wpm: summary.wpm, accuracy: summary.accuracy, best_combo: summary.best_combo, xp: earned_xp, difficulty: state.difficulty });
|
||||||
|
state.recent_rounds = state.recent_rounds.slice(0, 20);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mark_word_result(correct) {
|
||||||
|
const node = dom.text_display.querySelectorAll(".word")[state.index];
|
||||||
|
if (!node) return;
|
||||||
node.classList.remove("current");
|
node.classList.remove("current");
|
||||||
node.classList.add(correct ? "correct" : "incorrect");
|
node.classList.add(correct ? "correct" : "incorrect");
|
||||||
}
|
}
|
||||||
|
|
||||||
function setCurrentWord() {
|
function set_current_word() {
|
||||||
const wordNodes = els.textDisplay.querySelectorAll(".word");
|
const node = dom.text_display.querySelectorAll(".word")[state.index];
|
||||||
const current = wordNodes[state.index];
|
if (!node) return;
|
||||||
if (current) {
|
node.classList.add("current");
|
||||||
current.classList.add("current");
|
node.scrollIntoView({ block: "center", behavior: "smooth" });
|
||||||
current.scrollIntoView({ block: "center", behavior: "smooth" });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function finishRound() {
|
function finish_round() {
|
||||||
clearInterval(state.timer);
|
clearInterval(state.timer);
|
||||||
state.running = false;
|
state.running = false;
|
||||||
els.typeInput.disabled = true;
|
dom.type_input.disabled = true;
|
||||||
|
|
||||||
const elapsedMinutes = Math.max(state.elapsedSeconds / 60, 1 / 60);
|
const elapsed_m = Math.max(state.elapsed_seconds / 60, 1 / 60);
|
||||||
const wpm = Math.round((state.typedChars / 5) / elapsedMinutes);
|
const wpm = Math.round((state.typed_chars / 5) / elapsed_m);
|
||||||
const totalWords = state.correctWords + state.wrongWords;
|
const total = state.correct_words + state.wrong_words;
|
||||||
const accuracy = totalWords === 0 ? 100 : Math.round((state.correctWords / totalWords) * 100);
|
const accuracy = total ? Math.round((state.correct_words / total) * 100) : 100;
|
||||||
|
const summary = { wpm, accuracy, best_combo: state.best_combo };
|
||||||
|
|
||||||
const summary = {
|
const earned_xp = award_xp(summary);
|
||||||
wpm,
|
track_round(summary, earned_xp);
|
||||||
accuracy,
|
render_progress();
|
||||||
bestCombo: state.bestCombo
|
update_live_stats();
|
||||||
};
|
save_progress();
|
||||||
|
|
||||||
const earnedXp = awardXp(summary);
|
dom.results.hidden = false;
|
||||||
trackRound(summary, earnedXp);
|
dom.results_summary.textContent = `you reached ${wpm} wpm with ${accuracy}% accuracy and earned ${earned_xp} xp!`;
|
||||||
renderProgress();
|
|
||||||
updateLiveStats();
|
|
||||||
saveProgress();
|
|
||||||
|
|
||||||
els.results.hidden = false;
|
|
||||||
els.resultsSummary.textContent = `you reached ${wpm} wpm with ${accuracy}% accuracy and earned ${earnedXp} xp!`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetRound() {
|
function reset_round() {
|
||||||
clearInterval(state.timer);
|
clearInterval(state.timer);
|
||||||
state.words = pickWords();
|
Object.assign(state, {
|
||||||
state.index = 0;
|
words: pick_words(),
|
||||||
state.typedChars = 0;
|
index: 0,
|
||||||
state.correctWords = 0;
|
typed_chars: 0,
|
||||||
state.wrongWords = 0;
|
correct_words: 0,
|
||||||
state.combo = 0;
|
wrong_words: 0,
|
||||||
state.bestCombo = 0;
|
combo: 0,
|
||||||
state.elapsedSeconds = 0;
|
best_combo: 0,
|
||||||
state.roundSeconds = hasPerk("time-warp") ? 68 : 60;
|
elapsed_seconds: 0,
|
||||||
state.running = false;
|
round_seconds: has_perk("time-warp") ? 68 : 60,
|
||||||
|
running: false
|
||||||
renderWords();
|
});
|
||||||
updateLiveStats();
|
render_words();
|
||||||
updateTimer();
|
update_live_stats();
|
||||||
|
update_timer();
|
||||||
els.typeInput.disabled = false;
|
dom.type_input.disabled = false;
|
||||||
els.typeInput.value = "";
|
dom.type_input.value = "";
|
||||||
els.typeInput.focus();
|
dom.type_input.focus();
|
||||||
els.results.hidden = true;
|
dom.results.hidden = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function setProfileName(value) {
|
function set_profile_name(value) {
|
||||||
const clean = value.trim().slice(0, 24);
|
state.profile_name = value.trim().slice(0, 24) || DEFAULT_STATE.profile_name;
|
||||||
state.profileName = clean || defaultState.profileName;
|
render_progress();
|
||||||
renderProgress();
|
save_progress();
|
||||||
saveProgress();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function setDifficulty(value) {
|
function set_difficulty(value) {
|
||||||
state.difficulty = sanitizeDifficulty(value);
|
state.difficulty = sanitize_difficulty(value);
|
||||||
renderProgress();
|
render_progress();
|
||||||
resetRound();
|
reset_round();
|
||||||
saveProgress();
|
save_progress();
|
||||||
}
|
}
|
||||||
|
|
||||||
function buyPerk(perkId) {
|
function buy_perk(perk_id) {
|
||||||
const perk = PERKS.find((item) => item.id === perkId);
|
const perk = PERKS.find((p) => p.id === perk_id);
|
||||||
if (!perk) return;
|
if (!perk || has_perk(perk_id) || state.level < perk.unlock_level || state.shards < perk.cost) return;
|
||||||
if (state.level < perk.unlockLevel) return;
|
|
||||||
if (hasPerk(perkId)) return;
|
|
||||||
if (state.shards < perk.cost) return;
|
|
||||||
|
|
||||||
state.shards -= perk.cost;
|
state.shards -= perk.cost;
|
||||||
state.ownedPerks.push(perk.id);
|
state.owned_perks.push(perk.id);
|
||||||
renderProgress();
|
render_progress();
|
||||||
resetRound();
|
reset_round();
|
||||||
saveProgress();
|
save_progress();
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetProgress() {
|
function reset_progress() {
|
||||||
localStorage.removeItem(STORAGE_KEY);
|
localStorage.removeItem(STORAGE_KEY);
|
||||||
clearInterval(state.timer);
|
clearInterval(state.timer);
|
||||||
|
Object.assign(state, { ...DEFAULT_STATE, stats: { ...DEFAULT_STATE.stats }, recent_rounds: [], owned_perks: [] });
|
||||||
Object.assign(state, { ...defaultState, ownedPerks: [], stats: { ...defaultState.stats }, recentRounds: [] });
|
render_progress();
|
||||||
renderProgress();
|
reset_round();
|
||||||
resetRound();
|
save_progress();
|
||||||
saveProgress();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
els.typeInput.addEventListener("input", () => {
|
dom.type_input.addEventListener("input", () => {
|
||||||
const value = els.typeInput.value;
|
const value = dom.type_input.value;
|
||||||
|
if (!state.running && value.length) start_timer();
|
||||||
if (!state.running && value.length > 0) {
|
|
||||||
startTimer();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!value.endsWith(" ")) return;
|
if (!value.endsWith(" ")) return;
|
||||||
|
|
||||||
const typedWord = value.trim();
|
const typed_word = value.trim();
|
||||||
const targetWord = state.words[state.index];
|
const correct = typed_word === state.words[state.index];
|
||||||
const correct = typedWord === targetWord;
|
state.typed_chars += typed_word.length + 1;
|
||||||
|
|
||||||
state.typedChars += typedWord.length + 1;
|
|
||||||
|
|
||||||
if (correct) {
|
if (correct) {
|
||||||
state.correctWords += 1;
|
state.correct_words += 1;
|
||||||
state.combo += 1;
|
state.combo += 1;
|
||||||
if (state.combo > state.bestCombo) {
|
state.best_combo = Math.max(state.best_combo, state.combo);
|
||||||
state.bestCombo = state.combo;
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
state.wrongWords += 1;
|
state.wrong_words += 1;
|
||||||
state.combo = 0;
|
state.combo = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
markWordResult(correct);
|
mark_word_result(correct);
|
||||||
state.index += 1;
|
state.index += 1;
|
||||||
els.typeInput.value = "";
|
dom.type_input.value = "";
|
||||||
|
if (state.index >= state.words.length) return finish_round();
|
||||||
if (state.index >= state.words.length) {
|
set_current_word();
|
||||||
finishRound();
|
update_live_stats();
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setCurrentWord();
|
|
||||||
updateLiveStats();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
els.newRoundBtn.addEventListener("click", resetRound);
|
dom.new_round_btn.addEventListener("click", reset_round);
|
||||||
els.playAgainBtn.addEventListener("click", resetRound);
|
dom.play_again_btn.addEventListener("click", reset_round);
|
||||||
els.settingsBtn.addEventListener("click", openSettings);
|
dom.settings_btn.addEventListener("click", open_settings);
|
||||||
els.closeSettingsBtn.addEventListener("click", closeSettings);
|
dom.close_settings_btn.addEventListener("click", close_settings);
|
||||||
els.profileName.addEventListener("change", (event) => setProfileName(event.target.value));
|
dom.profile_name.addEventListener("change", (e) => set_profile_name(e.target.value));
|
||||||
els.difficultySelect.addEventListener("change", (event) => setDifficulty(event.target.value));
|
dom.difficulty_select.addEventListener("change", (e) => set_difficulty(e.target.value));
|
||||||
els.resetProgressBtn.addEventListener("click", openResetConfirm);
|
dom.reset_progress_btn.addEventListener("click", open_reset_confirm);
|
||||||
els.cancelResetBtn.addEventListener("click", closeResetConfirm);
|
dom.cancel_reset_btn.addEventListener("click", close_reset_confirm);
|
||||||
els.confirmResetBtn.addEventListener("click", () => {
|
dom.confirm_reset_btn.addEventListener("click", () => {
|
||||||
resetProgress();
|
reset_progress();
|
||||||
closeResetConfirm();
|
close_reset_confirm();
|
||||||
closeSettings();
|
close_settings();
|
||||||
|
});
|
||||||
|
dom.settings_modal.addEventListener("click", (e) => e.target === dom.settings_modal && close_settings());
|
||||||
|
dom.reset_confirm_modal.addEventListener("click", (e) => e.target === dom.reset_confirm_modal && close_reset_confirm());
|
||||||
|
document.addEventListener("keydown", (e) => {
|
||||||
|
if (e.key !== "Escape") return;
|
||||||
|
if (!dom.reset_confirm_modal.hidden) return close_reset_confirm();
|
||||||
|
if (!dom.settings_modal.hidden) close_settings();
|
||||||
});
|
});
|
||||||
|
|
||||||
els.settingsModal.addEventListener("click", (event) => {
|
dom.perks.addEventListener("click", (e) => {
|
||||||
if (event.target === els.settingsModal) {
|
const target = e.target;
|
||||||
closeSettings();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
els.resetConfirmModal.addEventListener("click", (event) => {
|
|
||||||
if (event.target === els.resetConfirmModal) {
|
|
||||||
closeResetConfirm();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
document.addEventListener("keydown", (event) => {
|
|
||||||
if (event.key === "Escape" && !els.resetConfirmModal.hidden) {
|
|
||||||
closeResetConfirm();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.key === "Escape" && !els.settingsModal.hidden) {
|
|
||||||
closeSettings();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
els.perks.addEventListener("click", (event) => {
|
|
||||||
const target = event.target;
|
|
||||||
if (!(target instanceof HTMLButtonElement)) return;
|
if (!(target instanceof HTMLButtonElement)) return;
|
||||||
const perkId = target.dataset.perkId;
|
const perk_id = target.dataset.perkId;
|
||||||
if (!perkId) return;
|
if (perk_id) buy_perk(perk_id);
|
||||||
buyPerk(perkId);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
loadProgress();
|
load_progress();
|
||||||
renderProgress();
|
render_progress();
|
||||||
resetRound();
|
reset_round();
|
||||||
+9
-40
@@ -4,16 +4,9 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>typeup</title>
|
<title>typeup</title>
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
|
||||||
<link
|
|
||||||
href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;700&family=Sora:wght@600;700;800&display=swap"
|
|
||||||
rel="stylesheet"
|
|
||||||
/>
|
|
||||||
<link rel="stylesheet" href="styles.css" />
|
<link rel="stylesheet" href="styles.css" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
<main class="layout">
|
<main class="layout">
|
||||||
<section class="panel panel-main">
|
<section class="panel panel-main">
|
||||||
<header class="game-head">
|
<header class="game-head">
|
||||||
@@ -25,22 +18,10 @@
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="stats-grid">
|
<div class="stats-grid">
|
||||||
<article class="stat-card">
|
<article class="stat-card"><p>wpm</p><strong id="wpm">0</strong></article>
|
||||||
<p>wpm</p>
|
<article class="stat-card"><p>accuracy</p><strong id="accuracy">100%</strong></article>
|
||||||
<strong id="wpm">0</strong>
|
<article class="stat-card"><p>combo</p><strong id="combo">x0</strong></article>
|
||||||
</article>
|
<article class="stat-card"><p>time</p><strong id="time">60s</strong></article>
|
||||||
<article class="stat-card">
|
|
||||||
<p>accuracy</p>
|
|
||||||
<strong id="accuracy">100%</strong>
|
|
||||||
</article>
|
|
||||||
<article class="stat-card">
|
|
||||||
<p>combo</p>
|
|
||||||
<strong id="combo">x0</strong>
|
|
||||||
</article>
|
|
||||||
<article class="stat-card">
|
|
||||||
<p>time</p>
|
|
||||||
<strong id="time">60s</strong>
|
|
||||||
</article>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section class="text-shell" id="textDisplay" aria-live="polite"></section>
|
<section class="text-shell" id="textDisplay" aria-live="polite"></section>
|
||||||
@@ -67,21 +48,13 @@
|
|||||||
<aside class="panel panel-side">
|
<aside class="panel panel-side">
|
||||||
<section class="profile">
|
<section class="profile">
|
||||||
<h2 id="profileTitle">rookie typer</h2>
|
<h2 id="profileTitle">rookie typer</h2>
|
||||||
<div class="level-line">
|
<div class="level-line"><span id="levelText">level 1</span><span id="xpText">0 / 120 xp</span></div>
|
||||||
<span id="levelText">level 1</span>
|
<div class="level-line"><span id="shardText">shards: 0</span></div>
|
||||||
<span id="xpText">0 / 120 xp</span>
|
<div class="xp-bar"><div id="xpFill"></div></div>
|
||||||
</div>
|
|
||||||
<div class="level-line">
|
|
||||||
<span id="shardText">shards: 0</span>
|
|
||||||
</div>
|
|
||||||
<div class="xp-bar">
|
|
||||||
<div id="xpFill"></div>
|
|
||||||
</div>
|
|
||||||
<p id="xpGain" class="xp-gain">+0 xp last round</p>
|
<p id="xpGain" class="xp-gain">+0 xp last round</p>
|
||||||
</section>
|
</section>
|
||||||
|
<ul></ul>
|
||||||
<section>
|
<section>
|
||||||
<p class="label">shop</p>
|
|
||||||
<div id="perks" class="perks"></div>
|
<div id="perks" class="perks"></div>
|
||||||
</section>
|
</section>
|
||||||
</aside>
|
</aside>
|
||||||
@@ -96,7 +69,6 @@
|
|||||||
<div class="settings-grid">
|
<div class="settings-grid">
|
||||||
<label for="profileName">name</label>
|
<label for="profileName">name</label>
|
||||||
<input id="profileName" type="text" maxlength="24" placeholder="enter your name..." />
|
<input id="profileName" type="text" maxlength="24" placeholder="enter your name..." />
|
||||||
|
|
||||||
<label for="difficultySelect">difficulty</label>
|
<label for="difficultySelect">difficulty</label>
|
||||||
<select id="difficultySelect">
|
<select id="difficultySelect">
|
||||||
<option value="easy">easy</option>
|
<option value="easy">easy</option>
|
||||||
@@ -104,7 +76,6 @@
|
|||||||
<option value="hard">hard</option>
|
<option value="hard">hard</option>
|
||||||
<option value="expert">expert</option>
|
<option value="expert">expert</option>
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<p id="difficultyBonus" class="setting-note">xp bonus: x1.00</p>
|
<p id="difficultyBonus" class="setting-note">xp bonus: x1.00</p>
|
||||||
<button id="resetProgressBtn" class="btn btn-ghost" type="button">reset save</button>
|
<button id="resetProgressBtn" class="btn btn-ghost" type="button">reset save</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -113,9 +84,7 @@
|
|||||||
|
|
||||||
<div id="resetConfirmModal" class="modal" hidden>
|
<div id="resetConfirmModal" class="modal" hidden>
|
||||||
<div class="modal-card" role="dialog" aria-modal="true" aria-labelledby="resetConfirmTitle">
|
<div class="modal-card" role="dialog" aria-modal="true" aria-labelledby="resetConfirmTitle">
|
||||||
<div class="modal-head">
|
<div class="modal-head"><h2 id="resetConfirmTitle">reset progress?</h2></div>
|
||||||
<h2 id="resetConfirmTitle">reset progress?</h2>
|
|
||||||
</div>
|
|
||||||
<p class="setting-note">this will erase your level, xp, perks, and history.</p>
|
<p class="setting-note">this will erase your level, xp, perks, and history.</p>
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
<button id="cancelResetBtn" class="btn btn-ghost" type="button">cancel</button>
|
<button id="cancelResetBtn" class="btn btn-ghost" type="button">cancel</button>
|
||||||
|
|||||||
+7
-28
@@ -8,41 +8,20 @@
|
|||||||
<body>
|
<body>
|
||||||
<main class="stats-layout">
|
<main class="stats-layout">
|
||||||
<section class="panel panel-main">
|
<section class="panel panel-main">
|
||||||
<header class="stats-header">
|
<header class="stats-header"><a href="index.html" class="btn">back</a></header>
|
||||||
<a href="index.html" class="btn">back</a>
|
|
||||||
</header>
|
|
||||||
<ul></ul>
|
|
||||||
<div class="stats-grid-large">
|
<div class="stats-grid-large">
|
||||||
<article class="stat-card">
|
<article class="stat-card"><p>rounds played</p><strong id="sTotalRounds">0</strong></article>
|
||||||
<p>rounds played</p>
|
<article class="stat-card"><p>top wpm</p><strong id="sBestWpm">0</strong></article>
|
||||||
<strong id="sTotalRounds">0</strong>
|
<article class="stat-card"><p>best accuracy</p><strong id="sBestAcc">0%</strong></article>
|
||||||
</article>
|
<article class="stat-card"><p>average wpm</p><strong id="sAvgWpm">0</strong></article>
|
||||||
<article class="stat-card">
|
<article class="stat-card"><p>average accuracy</p><strong id="sAvgAcc">0%</strong></article>
|
||||||
<p>top wpm</p>
|
|
||||||
<strong id="sBestWpm">0</strong>
|
|
||||||
</article>
|
|
||||||
<article class="stat-card">
|
|
||||||
<p>base accuracy</p>
|
|
||||||
<strong id="sBestAcc">0%</strong>
|
|
||||||
</article>
|
|
||||||
<article class="stat-card">
|
|
||||||
<p>average wpm</p>
|
|
||||||
<strong id="sAvgWpm">0</strong>
|
|
||||||
</article>
|
|
||||||
<article class="stat-card">
|
|
||||||
<p>average accuracy</p>
|
|
||||||
<strong id="sAvgAcc">0%</strong>
|
|
||||||
</article>
|
|
||||||
</div>
|
</div>
|
||||||
|
<section class="results mt-1">
|
||||||
<section class="results" style="margin-top: 1rem">
|
|
||||||
<h2>recents</h2>
|
<h2>recents</h2>
|
||||||
<ul></ul>
|
|
||||||
<ul id="historyList" class="history-list"></ul>
|
<ul id="historyList" class="history-list"></ul>
|
||||||
</section>
|
</section>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<script src="stats.js"></script>
|
<script src="stats.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
+25
-37
@@ -1,58 +1,46 @@
|
|||||||
const STORAGE_KEY = "levelup-typing-save-v1";
|
const STORAGE_KEY = "levelup-typing-save-v1";
|
||||||
|
const q = (id) => document.getElementById(id);
|
||||||
function readSave() {
|
const set_text = (id, value) => {
|
||||||
const raw = localStorage.getItem(STORAGE_KEY);
|
const el = q(id);
|
||||||
if (!raw) return null;
|
if (el) el.textContent = value;
|
||||||
|
};
|
||||||
|
const read_save = () => {
|
||||||
try {
|
try {
|
||||||
return JSON.parse(raw);
|
return JSON.parse(localStorage.getItem(STORAGE_KEY) || "null");
|
||||||
} catch (error) {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
function setText(id, value) {
|
function render_history(recent_rounds) {
|
||||||
const el = document.getElementById(id);
|
const list = q("historyList");
|
||||||
if (el) el.textContent = value;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderHistory(recentRounds) {
|
|
||||||
const list = document.getElementById("historyList");
|
|
||||||
if (!list) return;
|
if (!list) return;
|
||||||
|
|
||||||
list.innerHTML = "";
|
list.innerHTML = "";
|
||||||
|
if (!recent_rounds?.length) {
|
||||||
if (!recentRounds || recentRounds.length === 0) {
|
|
||||||
const item = document.createElement("li");
|
const item = document.createElement("li");
|
||||||
item.className = "history-item";
|
item.className = "history-item";
|
||||||
item.textContent = "no rounds played yet... complete a run to see your history!";
|
item.textContent = "no rounds played yet... complete a run to see your history!";
|
||||||
list.appendChild(item);
|
list.appendChild(item);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
recent_rounds.forEach((round) => {
|
||||||
recentRounds.forEach((round) => {
|
|
||||||
const item = document.createElement("li");
|
const item = document.createElement("li");
|
||||||
item.className = "history-item";
|
|
||||||
|
|
||||||
const date = new Date(round.stamp || Date.now());
|
const date = new Date(round.stamp || Date.now());
|
||||||
const when = `${date.toLocaleDateString()} ${date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}`;
|
const when = `${date.toLocaleDateString()} ${date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}`;
|
||||||
|
const best_combo = round.best_combo ?? round.bestCombo ?? 0;
|
||||||
item.textContent = `${when} | ${round.wpm || 0} wpm / ${round.accuracy || 0}% acc / combo ${round.bestCombo || 0} / +${round.xp || 0} xp / ${(round.difficulty || "normal").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()}`;
|
||||||
list.appendChild(item);
|
list.appendChild(item);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function boot() {
|
(function boot() {
|
||||||
const save = readSave() || {};
|
const save = read_save() || {};
|
||||||
const stats = save.stats || {};
|
const stats = save.stats || {};
|
||||||
|
set_text("sTotalRounds", String(stats.total_rounds ?? stats.totalRounds ?? 0));
|
||||||
setText("sTotalRounds", String(stats.totalRounds || 0));
|
set_text("sBestWpm", String(stats.best_wpm ?? stats.bestWpm ?? 0));
|
||||||
setText("sBestWpm", String(stats.bestWpm || 0));
|
set_text("sBestAcc", `${Math.round(stats.best_accuracy ?? stats.bestAccuracy ?? 0)}%`);
|
||||||
setText("sBestAcc", `${Math.round(stats.bestAccuracy || 0)}%`);
|
set_text("sAvgWpm", String(Math.round(stats.average_wpm ?? stats.averageWpm ?? 0)));
|
||||||
setText("sAvgWpm", String(Math.round(stats.averageWpm || 0)));
|
set_text("sAvgAcc", `${Math.round(stats.average_accuracy ?? stats.averageAccuracy ?? 0)}%`);
|
||||||
setText("sAvgAcc", `${Math.round(stats.averageAccuracy || 0)}%`);
|
render_history(Array.isArray(save.recent_rounds ?? save.recentRounds) ? save.recent_rounds ?? save.recentRounds : []);
|
||||||
|
})();
|
||||||
renderHistory(Array.isArray(save.recentRounds) ? save.recentRounds : []);
|
|
||||||
}
|
|
||||||
|
|
||||||
boot();
|
|
||||||
+79
-357
@@ -1,3 +1,5 @@
|
|||||||
|
@import url('https://fonts.googleapis.com/css2?family=Fira+Code:wght@300..700&display=swap');
|
||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: "CozetteCrossedSevenVector";
|
font-family: "CozetteCrossedSevenVector";
|
||||||
src: url("fonts/CozetteCrossedSevenVector.woff2") format("woff2");
|
src: url("fonts/CozetteCrossedSevenVector.woff2") format("woff2");
|
||||||
@@ -7,8 +9,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--bg-1: #04070d;
|
--bg: #04070d;
|
||||||
--bg-2: #0a1021;
|
|
||||||
--card: rgba(8, 12, 22, 0.9);
|
--card: rgba(8, 12, 22, 0.9);
|
||||||
--border: rgba(111, 151, 172, 0.28);
|
--border: rgba(111, 151, 172, 0.28);
|
||||||
--accent: #8af6c9;
|
--accent: #8af6c9;
|
||||||
@@ -20,7 +21,7 @@
|
|||||||
|
|
||||||
* {
|
* {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
font-family: "CozetteCrossedSevenVector", monospace;
|
font-family: "Fira Code", monospace;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,145 +29,89 @@ body {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
background: var(--bg-1);
|
background: var(--bg);
|
||||||
overflow-x: hidden;
|
|
||||||
text-transform: lowercase;
|
text-transform: lowercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
.orb {
|
.layout,
|
||||||
position: fixed;
|
.stats-layout {
|
||||||
width: 320px;
|
width: min(1140px, 94vw);
|
||||||
height: 320px;
|
margin: 2rem auto;
|
||||||
filter: blur(80px);
|
display: grid;
|
||||||
opacity: 0.25;
|
gap: 1rem;
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.orb-left {
|
|
||||||
top: -80px;
|
|
||||||
left: -60px;
|
|
||||||
background: #8fe8d0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.orb-right {
|
|
||||||
bottom: -120px;
|
|
||||||
right: -40px;
|
|
||||||
background: #ffcf70;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.layout {
|
.layout {
|
||||||
width: min(1140px, 94vw);
|
|
||||||
margin: 2.2rem auto;
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1.6fr 1fr;
|
grid-template-columns: 1.6fr 1fr;
|
||||||
gap: 1.2rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel {
|
.panel {
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
background: var(--card);
|
background: var(--card);
|
||||||
backdrop-filter: blur(10px);
|
|
||||||
box-shadow: 0 16px 36px rgba(0, 0, 0, 0.45);
|
box-shadow: 0 16px 36px rgba(0, 0, 0, 0.45);
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel-main {
|
.panel-main { padding: 1.2rem; }
|
||||||
padding: 1.4rem;
|
.panel-side { padding: 1rem; }
|
||||||
}
|
|
||||||
|
|
||||||
.panel-side {
|
.game-head,
|
||||||
padding: 1.2rem;
|
.stats-header,
|
||||||
}
|
.modal-head,
|
||||||
|
.level-line,
|
||||||
.game-head {
|
.perk-actions,
|
||||||
|
.modal-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 1rem;
|
justify-content: space-between;
|
||||||
}
|
|
||||||
|
|
||||||
.head-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.label {
|
.head-actions { display: flex; gap: 0.5rem; }
|
||||||
color: var(--accent-2);
|
.label { margin: 0; color: var(--accent-2); letter-spacing: 0.14em; }
|
||||||
text-transform: lowercase;
|
h1, h2 { margin: 0; }
|
||||||
letter-spacing: 0.14em;
|
|
||||||
font-size: 0.72rem;
|
|
||||||
margin: 0 0 0.2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1,
|
|
||||||
h2 {
|
|
||||||
margin: 0;
|
|
||||||
font-family: "CozetteCrossedSevenVector", monospace;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
font-size: clamp(1.35rem, 2vw, 2rem);
|
|
||||||
}
|
|
||||||
|
|
||||||
h2 {
|
|
||||||
font-size: 1.16rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn {
|
.btn {
|
||||||
border: none;
|
border: none;
|
||||||
padding: 0.58rem 1.1rem;
|
padding: 0.58rem 1.1rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-family: inherit;
|
|
||||||
color: #052218;
|
color: #052218;
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: transform 170ms ease, filter 170ms ease;
|
transition: transform 170ms ease, filter 170ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
a.btn {
|
.btn:hover { transform: translateY(-2px); filter: brightness(1.05); }
|
||||||
text-decoration: none;
|
.btn-ghost { color: var(--text); background: rgba(255, 255, 255, 0.05); border: 1px solid rgba(194, 228, 210, 0.2); }
|
||||||
display: inline-flex;
|
.btn-ghost:hover { filter: brightness(1.12); }
|
||||||
align-items: center;
|
a.btn { text-decoration: none; display: inline-flex; align-items: center; }
|
||||||
}
|
|
||||||
|
|
||||||
.btn-ghost {
|
.stats-grid,
|
||||||
color: var(--text);
|
.stats-grid-large,
|
||||||
background: rgba(255, 255, 255, 0.05);
|
.perks,
|
||||||
border: 1px solid rgba(194, 228, 210, 0.2);
|
.history-list,
|
||||||
}
|
.settings-grid {
|
||||||
|
|
||||||
.btn-ghost:hover {
|
|
||||||
filter: brightness(1.12);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn:hover {
|
|
||||||
transform: translateY(-2px);
|
|
||||||
filter: brightness(1.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-grid {
|
|
||||||
margin-top: 1.15rem;
|
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
gap: 0.55rem;
|
||||||
gap: 0.7rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-card {
|
.stats-grid { margin-top: 1rem; grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
||||||
border: 1px solid rgba(148, 196, 231, 0.24);
|
.stats-grid-large { grid-template-columns: repeat(5, minmax(0, 1fr)); }
|
||||||
padding: 0.66rem;
|
|
||||||
background: rgba(5, 10, 18, 0.6);
|
.stat-card,
|
||||||
|
.perk,
|
||||||
|
.history-item {
|
||||||
|
border: 1px solid rgba(130, 160, 188, 0.24);
|
||||||
|
background: rgba(8, 14, 24, 0.66);
|
||||||
|
padding: 0.7rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-card p {
|
.stat-card p,
|
||||||
margin: 0;
|
.setting-note,
|
||||||
color: var(--muted);
|
.perk p,
|
||||||
font-size: 0.8rem;
|
.perk-cost,
|
||||||
}
|
.input-wrap span { margin: 0; color: var(--muted); }
|
||||||
|
|
||||||
.stat-card strong {
|
.stat-card strong { display: inline-block; margin-top: 0.2rem; font-size: 1.3rem; }
|
||||||
font-size: 1.42rem;
|
|
||||||
display: inline-block;
|
|
||||||
margin-top: 0.18rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.text-shell {
|
.text-shell {
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
@@ -177,51 +122,28 @@ a.btn {
|
|||||||
background: rgba(4, 8, 15, 0.72);
|
background: rgba(4, 8, 15, 0.72);
|
||||||
padding: 0.95rem;
|
padding: 0.95rem;
|
||||||
line-height: 1.8;
|
line-height: 1.8;
|
||||||
font-size: clamp(1rem, 1.5vw, 1.2rem);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.word {
|
.word { padding-right: 0.45rem; transition: color 100ms ease; }
|
||||||
padding-right: 0.45rem;
|
.word.current { color: #d6ff84; }
|
||||||
transition: color 100ms ease;
|
.word.correct { color: #a8f7bf; }
|
||||||
}
|
.word.incorrect { color: var(--bad); text-decoration: underline; text-decoration-thickness: 2px; }
|
||||||
|
|
||||||
.word.current {
|
.input-wrap { margin-top: 0.95rem; display: block; }
|
||||||
color: #d6ff84;
|
#typeInput,
|
||||||
}
|
.settings-grid input,
|
||||||
|
.settings-grid select {
|
||||||
.word.correct {
|
|
||||||
color: #a8f7bf;
|
|
||||||
}
|
|
||||||
|
|
||||||
.word.incorrect {
|
|
||||||
color: var(--bad);
|
|
||||||
text-decoration: underline;
|
|
||||||
text-decoration-thickness: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.input-wrap {
|
|
||||||
margin-top: 0.95rem;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.input-wrap span {
|
|
||||||
display: block;
|
|
||||||
margin-bottom: 0.38rem;
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 0.84rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
#typeInput {
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border: 1px solid rgba(145, 205, 218, 0.32);
|
border: 1px solid rgba(145, 205, 218, 0.32);
|
||||||
background: rgba(6, 14, 18, 0.86);
|
background: rgba(6, 14, 18, 0.86);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
font-size: 1.04rem;
|
padding: 0.6rem;
|
||||||
padding: 0.8rem 0.85rem;
|
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
#typeInput:focus {
|
#typeInput:focus,
|
||||||
|
.settings-grid input:focus,
|
||||||
|
.settings-grid select:focus {
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
box-shadow: 0 0 0 3px rgba(159, 255, 169, 0.18);
|
box-shadow: 0 0 0 3px rgba(159, 255, 169, 0.18);
|
||||||
}
|
}
|
||||||
@@ -229,166 +151,30 @@ a.btn {
|
|||||||
.results {
|
.results {
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
border: 1px solid rgba(158, 228, 164, 0.32);
|
border: 1px solid rgba(158, 228, 164, 0.32);
|
||||||
padding: 0.9rem;
|
|
||||||
background: rgba(8, 16, 18, 0.62);
|
background: rgba(8, 16, 18, 0.62);
|
||||||
animation: rise 240ms ease;
|
padding: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes rise {
|
.mt-1 { margin-top: 1rem; }
|
||||||
from {
|
.xp-bar { margin-top: 0.5rem; height: 12px; overflow: hidden; background: rgba(255, 255, 255, 0.12); }
|
||||||
opacity: 0;
|
#xpFill { height: 100%; width: 0; background: #87f5b0; transition: width 320ms ease; }
|
||||||
transform: translateY(5px);
|
.xp-gain { color: #baf1ff; margin-bottom: 0; }
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.level-line {
|
.perk-buy { border: none; padding: 0.28rem 0.74rem; cursor: pointer; background: #b6f8ba; color: #193027; }
|
||||||
margin-top: 0.35rem;
|
.perk-buy:disabled { cursor: not-allowed; opacity: 0.44; }
|
||||||
display: flex;
|
.status { margin-top: 0.44rem; display: inline-block; padding: 0.18rem 0.54rem; }
|
||||||
justify-content: space-between;
|
.status.locked { background: rgba(255, 255, 255, 0.12); color: #c0ccc6; }
|
||||||
color: var(--muted);
|
.status.unlocked { background: rgba(167, 255, 141, 0.18); color: #b9ffad; }
|
||||||
font-size: 0.88rem;
|
.status.active { background: rgba(255, 231, 118, 0.2); color: #ffe58f; }
|
||||||
}
|
|
||||||
|
|
||||||
.xp-bar {
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
height: 12px;
|
|
||||||
width: 100%;
|
|
||||||
overflow: hidden;
|
|
||||||
background: rgba(255, 255, 255, 0.12);
|
|
||||||
}
|
|
||||||
|
|
||||||
#xpFill {
|
|
||||||
height: 100%;
|
|
||||||
width: 0;
|
|
||||||
background: #87f5b0;
|
|
||||||
transition: width 320ms ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.xp-gain {
|
|
||||||
color: #baf1ff;
|
|
||||||
font-size: 0.86rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-grid {
|
|
||||||
margin-top: 0.45rem;
|
|
||||||
display: grid;
|
|
||||||
gap: 0.45rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-grid label {
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 0.8rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-grid input,
|
|
||||||
.settings-grid select {
|
|
||||||
width: 100%;
|
|
||||||
border: 1px solid rgba(145, 205, 218, 0.32);
|
|
||||||
background: rgba(6, 14, 18, 0.86);
|
|
||||||
color: var(--text);
|
|
||||||
font-size: 0.92rem;
|
|
||||||
padding: 0.56rem 0.62rem;
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-grid input:focus,
|
|
||||||
.settings-grid select:focus {
|
|
||||||
border-color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.setting-note {
|
|
||||||
margin: 0.2rem 0;
|
|
||||||
color: #b9f4ff;
|
|
||||||
font-size: 0.82rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.perks {
|
|
||||||
margin-top: 0.7rem;
|
|
||||||
display: grid;
|
|
||||||
gap: 0.55rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.perk {
|
|
||||||
border: 1px solid rgba(130, 160, 188, 0.24);
|
|
||||||
padding: 0.7rem;
|
|
||||||
background: rgba(8, 14, 24, 0.66);
|
|
||||||
}
|
|
||||||
|
|
||||||
.perk h3 {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 0.94rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.perk p {
|
|
||||||
margin: 0.28rem 0 0;
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 0.82rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.perk-actions {
|
|
||||||
margin-top: 0.6rem;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.perk-cost {
|
|
||||||
color: #c8eecf;
|
|
||||||
font-size: 0.77rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.perk-buy {
|
|
||||||
border: none;
|
|
||||||
padding: 0.28rem 0.74rem;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
font-weight: 700;
|
|
||||||
font-family: inherit;
|
|
||||||
cursor: pointer;
|
|
||||||
background: #b6f8ba;
|
|
||||||
color: #193027;
|
|
||||||
}
|
|
||||||
|
|
||||||
.perk-buy:disabled {
|
|
||||||
cursor: not-allowed;
|
|
||||||
opacity: 0.44;
|
|
||||||
}
|
|
||||||
|
|
||||||
.perk .status {
|
|
||||||
margin-top: 0.44rem;
|
|
||||||
display: inline-block;
|
|
||||||
padding: 0.18rem 0.54rem;
|
|
||||||
font-size: 0.72rem;
|
|
||||||
letter-spacing: 0.03em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status.locked {
|
|
||||||
background: rgba(255, 255, 255, 0.12);
|
|
||||||
color: #c0ccc6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status.unlocked {
|
|
||||||
background: rgba(167, 255, 141, 0.18);
|
|
||||||
color: #b9ffad;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status.active {
|
|
||||||
background: rgba(255, 231, 118, 0.2);
|
|
||||||
color: #ffe58f;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal {
|
.modal {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
|
z-index: 20;
|
||||||
|
padding: 1rem;
|
||||||
background: rgba(2, 4, 10, 0.72);
|
background: rgba(2, 4, 10, 0.72);
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
z-index: 20;
|
|
||||||
padding: 1rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-card {
|
.modal-card {
|
||||||
@@ -398,76 +184,12 @@ a.btn {
|
|||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-head {
|
[hidden] { display: none !important; }
|
||||||
display: flex;
|
.history-list { margin: 0; padding: 0; list-style: none; }
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.6rem;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-actions {
|
|
||||||
margin-top: 0.7rem;
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
[hidden] {
|
|
||||||
display: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-layout {
|
|
||||||
width: min(1040px, 94vw);
|
|
||||||
margin: 2rem auto;
|
|
||||||
display: grid;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.8rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-grid-large {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
|
||||||
gap: 0.8rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.history-list {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
list-style: none;
|
|
||||||
display: grid;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.history-item {
|
|
||||||
border: 1px solid rgba(130, 160, 188, 0.24);
|
|
||||||
padding: 0.62rem;
|
|
||||||
background: rgba(8, 14, 24, 0.66);
|
|
||||||
font-size: 0.9rem;
|
|
||||||
color: var(--text);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 980px) {
|
@media (max-width: 980px) {
|
||||||
.layout {
|
.layout { grid-template-columns: 1fr; }
|
||||||
grid-template-columns: 1fr;
|
.stats-grid,
|
||||||
}
|
.stats-grid-large { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
|
.head-actions { flex-wrap: wrap; justify-content: flex-end; }
|
||||||
.stats-grid {
|
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-grid-large {
|
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
||||||
}
|
|
||||||
|
|
||||||
.head-actions {
|
|
||||||
flex-wrap: wrap;
|
|
||||||
justify-content: flex-end;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user