563 lines
16 KiB
JavaScript
563 lines
16 KiB
JavaScript
const STORAGE_KEY = "levelup-typing-save-v1";
|
|
|
|
const WORD_BANKS = {
|
|
easy: [
|
|
"apple", "river", "light", "happy", "quick", "smile", "cloud", "dream", "friend", "learn",
|
|
"typing", "music", "garden", "planet", "window", "ticket", "puzzle", "silver", "orange", "pepper"
|
|
],
|
|
normal: [
|
|
"speed", "skill", "sprint", "rhythm", "tempo", "focus", "kinetic", "logic", "phrase", "arcade",
|
|
"streak", "vector", "groove", "momentum", "signal", "catalyst", "syntax", "drift", "horizon", "quantum"
|
|
],
|
|
hard: [
|
|
"architect", "backspace", "fragment", "notation", "velocity", "parallel", "hexadecimal", "interface", "algorithm", "synchronize",
|
|
"compilation", "resolution", "parameter", "validation", "hyperlink", "generator", "trajectory", "oscillation", "framework", "benchmark"
|
|
],
|
|
expert: [
|
|
"extraordinary", "counterclockwise", "asymptotically", "deconstruction", "characteristically", "electromagnetic", "microarchitecture", "misconfiguration", "representational", "interoperability",
|
|
"cryptographically", "institutionalize", "uncharacteristic", "transcontinental", "hyperparameter", "instrumentation", "multiprocessing", "disproportionate", "comprehensibility", "photosynthesis"
|
|
]
|
|
};
|
|
|
|
const DIFFICULTY = {
|
|
easy: { label: "easy", xpMult: 0.9, roundWords: 38 },
|
|
normal: { label: "normal", xpMult: 1, roundWords: 45 },
|
|
hard: { label: "hard", xpMult: 1.25, roundWords: 50 },
|
|
expert: { label: "expert", xpMult: 1.5, roundWords: 54 }
|
|
};
|
|
|
|
const PERKS = [
|
|
{
|
|
id: "combo-boost",
|
|
name: "combo boost",
|
|
unlockLevel: 2,
|
|
cost: 1,
|
|
description: "Earn 15% more XP while your best combo is 5 or higher."
|
|
},
|
|
{
|
|
id: "accuracy-bounty",
|
|
name: "accuracy bounty",
|
|
unlockLevel: 4,
|
|
cost: 2,
|
|
description: "If you finish with 96%+ accuracy, gain +30 bonus XP."
|
|
},
|
|
{
|
|
id: "time-warp",
|
|
name: "time warp",
|
|
unlockLevel: 6,
|
|
cost: 2,
|
|
description: "Start rounds with +8 seconds for easier high-streak runs."
|
|
},
|
|
{
|
|
id: "focus-engine",
|
|
name: "focus engine",
|
|
unlockLevel: 8,
|
|
cost: 3,
|
|
description: "At 98%+ accuracy, your WPM contributes an extra 25% XP."
|
|
}
|
|
];
|
|
|
|
const defaultState = {
|
|
profileName: "rookie typer",
|
|
difficulty: "normal",
|
|
words: [],
|
|
index: 0,
|
|
typedChars: 0,
|
|
correctWords: 0,
|
|
wrongWords: 0,
|
|
combo: 0,
|
|
bestCombo: 0,
|
|
timer: null,
|
|
elapsedSeconds: 0,
|
|
roundSeconds: 60,
|
|
running: false,
|
|
xp: 0,
|
|
level: 1,
|
|
shards: 0,
|
|
lastRoundXp: 0,
|
|
ownedPerks: [],
|
|
stats: {
|
|
totalRounds: 0,
|
|
bestWpm: 0,
|
|
bestAccuracy: 0,
|
|
averageWpm: 0,
|
|
averageAccuracy: 0
|
|
},
|
|
recentRounds: []
|
|
};
|
|
|
|
const state = { ...defaultState };
|
|
|
|
const els = {
|
|
textDisplay: document.getElementById("textDisplay"),
|
|
typeInput: document.getElementById("typeInput"),
|
|
newRoundBtn: document.getElementById("newRoundBtn"),
|
|
settingsBtn: document.getElementById("settingsBtn"),
|
|
closeSettingsBtn: document.getElementById("closeSettingsBtn"),
|
|
settingsModal: document.getElementById("settingsModal"),
|
|
resetConfirmModal: document.getElementById("resetConfirmModal"),
|
|
cancelResetBtn: document.getElementById("cancelResetBtn"),
|
|
confirmResetBtn: document.getElementById("confirmResetBtn"),
|
|
playAgainBtn: document.getElementById("playAgainBtn"),
|
|
results: document.getElementById("results"),
|
|
resultsSummary: document.getElementById("resultsSummary"),
|
|
wpm: document.getElementById("wpm"),
|
|
accuracy: document.getElementById("accuracy"),
|
|
combo: document.getElementById("combo"),
|
|
time: document.getElementById("time"),
|
|
profileTitle: document.getElementById("profileTitle"),
|
|
levelText: document.getElementById("levelText"),
|
|
xpText: document.getElementById("xpText"),
|
|
shardText: document.getElementById("shardText"),
|
|
xpFill: document.getElementById("xpFill"),
|
|
xpGain: document.getElementById("xpGain"),
|
|
perks: document.getElementById("perks"),
|
|
profileName: document.getElementById("profileName"),
|
|
difficultySelect: document.getElementById("difficultySelect"),
|
|
difficultyBonus: document.getElementById("difficultyBonus"),
|
|
resetProgressBtn: document.getElementById("resetProgressBtn")
|
|
};
|
|
|
|
function xpNeededForLevel(level) {
|
|
return 120 + (level - 1) * 60;
|
|
}
|
|
|
|
function sanitizeDifficulty(value) {
|
|
return DIFFICULTY[value] ? value : "normal";
|
|
}
|
|
|
|
function saveProgress() {
|
|
const payload = {
|
|
profileName: state.profileName,
|
|
difficulty: state.difficulty,
|
|
xp: state.xp,
|
|
level: state.level,
|
|
shards: state.shards,
|
|
lastRoundXp: state.lastRoundXp,
|
|
ownedPerks: state.ownedPerks,
|
|
stats: state.stats,
|
|
recentRounds: state.recentRounds
|
|
};
|
|
|
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(payload));
|
|
}
|
|
|
|
function loadProgress() {
|
|
const raw = localStorage.getItem(STORAGE_KEY);
|
|
if (!raw) return;
|
|
|
|
try {
|
|
const parsed = JSON.parse(raw);
|
|
state.profileName = typeof parsed.profileName === "string" && parsed.profileName.trim()
|
|
? parsed.profileName.trim().slice(0, 24)
|
|
: defaultState.profileName;
|
|
state.difficulty = sanitizeDifficulty(parsed.difficulty);
|
|
state.xp = Number.isFinite(parsed.xp) ? Math.max(0, Math.floor(parsed.xp)) : 0;
|
|
state.level = Number.isFinite(parsed.level) ? Math.max(1, Math.floor(parsed.level)) : 1;
|
|
state.shards = Number.isFinite(parsed.shards) ? Math.max(0, Math.floor(parsed.shards)) : 0;
|
|
state.lastRoundXp = Number.isFinite(parsed.lastRoundXp) ? Math.max(0, Math.floor(parsed.lastRoundXp)) : 0;
|
|
state.ownedPerks = Array.isArray(parsed.ownedPerks)
|
|
? parsed.ownedPerks.filter((id) => PERKS.some((perk) => perk.id === id))
|
|
: [];
|
|
|
|
const parsedStats = parsed.stats || {};
|
|
state.stats = {
|
|
totalRounds: Number.isFinite(parsedStats.totalRounds) ? Math.max(0, Math.floor(parsedStats.totalRounds)) : 0,
|
|
bestWpm: Number.isFinite(parsedStats.bestWpm) ? Math.max(0, Math.floor(parsedStats.bestWpm)) : 0,
|
|
bestAccuracy: Number.isFinite(parsedStats.bestAccuracy) ? Math.max(0, Math.floor(parsedStats.bestAccuracy)) : 0,
|
|
averageWpm: Number.isFinite(parsedStats.averageWpm) ? Math.max(0, Number(parsedStats.averageWpm)) : 0,
|
|
averageAccuracy: Number.isFinite(parsedStats.averageAccuracy) ? Math.max(0, Number(parsedStats.averageAccuracy)) : 0
|
|
};
|
|
|
|
state.recentRounds = Array.isArray(parsed.recentRounds)
|
|
? parsed.recentRounds.slice(0, 20)
|
|
: [];
|
|
} catch (error) {
|
|
localStorage.removeItem(STORAGE_KEY);
|
|
}
|
|
}
|
|
|
|
function trackRound(summary, earnedXp) {
|
|
state.stats.totalRounds += 1;
|
|
state.stats.bestWpm = Math.max(state.stats.bestWpm, summary.wpm);
|
|
state.stats.bestAccuracy = Math.max(state.stats.bestAccuracy, summary.accuracy);
|
|
|
|
const rounds = state.stats.totalRounds;
|
|
state.stats.averageWpm = ((state.stats.averageWpm * (rounds - 1)) + summary.wpm) / rounds;
|
|
state.stats.averageAccuracy = ((state.stats.averageAccuracy * (rounds - 1)) + summary.accuracy) / rounds;
|
|
|
|
const snapshot = {
|
|
stamp: Date.now(),
|
|
wpm: summary.wpm,
|
|
accuracy: summary.accuracy,
|
|
bestCombo: summary.bestCombo,
|
|
xp: earnedXp,
|
|
difficulty: state.difficulty
|
|
};
|
|
|
|
state.recentRounds.unshift(snapshot);
|
|
if (state.recentRounds.length > 20) {
|
|
state.recentRounds.length = 20;
|
|
}
|
|
}
|
|
|
|
function openSettings() {
|
|
els.settingsModal.hidden = false;
|
|
}
|
|
|
|
function closeSettings() {
|
|
els.settingsModal.hidden = true;
|
|
}
|
|
|
|
function openResetConfirm() {
|
|
els.resetConfirmModal.hidden = false;
|
|
}
|
|
|
|
function closeResetConfirm() {
|
|
els.resetConfirmModal.hidden = true;
|
|
}
|
|
|
|
function pickWords() {
|
|
const config = DIFFICULTY[state.difficulty];
|
|
const pool = WORD_BANKS[state.difficulty];
|
|
const result = [];
|
|
|
|
for (let i = 0; i < config.roundWords; i += 1) {
|
|
result.push(pool[Math.floor(Math.random() * pool.length)]);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
function hasPerk(id) {
|
|
return state.ownedPerks.includes(id);
|
|
}
|
|
|
|
function renderWords() {
|
|
els.textDisplay.innerHTML = "";
|
|
state.words.forEach((word, idx) => {
|
|
const span = document.createElement("span");
|
|
span.className = "word";
|
|
if (idx === state.index) span.classList.add("current");
|
|
span.textContent = `${word} `;
|
|
els.textDisplay.appendChild(span);
|
|
});
|
|
}
|
|
|
|
function renderPerks() {
|
|
els.perks.innerHTML = "";
|
|
|
|
PERKS.forEach((perk) => {
|
|
const levelReady = state.level >= perk.unlockLevel;
|
|
const owned = hasPerk(perk.id);
|
|
const canBuy = levelReady && !owned && state.shards >= perk.cost;
|
|
|
|
const card = document.createElement("article");
|
|
card.className = "perk";
|
|
card.innerHTML = `
|
|
<h3>${perk.name} · Lv ${perk.unlockLevel}</h3>
|
|
<p>${perk.description}</p>
|
|
<div class="perk-actions">
|
|
<span class="perk-cost">Cost: ${perk.cost} shard${perk.cost > 1 ? "s" : ""}</span>
|
|
<button class="perk-buy" data-perk-id="${perk.id}" ${canBuy ? "" : "disabled"}>
|
|
${owned ? "owned" : levelReady ? "buy" : "locked"}
|
|
</button>
|
|
</div>
|
|
<span class="status ${owned ? "active" : levelReady ? "unlocked" : "locked"}">
|
|
${owned ? "active" : levelReady ? "shop open" : "locked"}
|
|
</span>
|
|
`;
|
|
|
|
els.perks.appendChild(card);
|
|
});
|
|
}
|
|
|
|
function renderSettings() {
|
|
const config = DIFFICULTY[state.difficulty];
|
|
els.profileName.value = state.profileName;
|
|
els.difficultySelect.value = state.difficulty;
|
|
els.difficultyBonus.textContent = `xp bonus: x${config.xpMult.toFixed(2)}`;
|
|
}
|
|
|
|
function renderProgress() {
|
|
const levelNeed = xpNeededForLevel(state.level);
|
|
const ratio = Math.min((state.xp / levelNeed) * 100, 100);
|
|
|
|
els.profileTitle.textContent = state.profileName;
|
|
els.levelText.textContent = `level ${state.level}`;
|
|
els.xpText.textContent = `${state.xp} / ${levelNeed} xp`;
|
|
els.shardText.textContent = `perk shards: ${state.shards}`;
|
|
els.xpFill.style.width = `${ratio}%`;
|
|
els.xpGain.textContent = `+${state.lastRoundXp} xp last round`;
|
|
|
|
renderSettings();
|
|
renderPerks();
|
|
}
|
|
|
|
function updateLiveStats() {
|
|
const elapsed = Math.max(state.elapsedSeconds / 60, 1 / 60);
|
|
const wordsTypedEstimate = state.typedChars / 5;
|
|
const wpm = Math.round(wordsTypedEstimate / elapsed);
|
|
const totalWords = state.correctWords + state.wrongWords;
|
|
const acc = totalWords === 0 ? 100 : Math.round((state.correctWords / totalWords) * 100);
|
|
|
|
els.wpm.textContent = String(wpm);
|
|
els.accuracy.textContent = `${acc}%`;
|
|
els.combo.textContent = `x${state.combo}`;
|
|
}
|
|
|
|
function updateTimer() {
|
|
const remaining = Math.max(state.roundSeconds - state.elapsedSeconds, 0);
|
|
els.time.textContent = `${remaining}s`;
|
|
els.time.style.color = remaining <= 10 ? "#ffd587" : "";
|
|
}
|
|
|
|
function startTimer() {
|
|
if (state.running) return;
|
|
|
|
state.running = true;
|
|
state.timer = setInterval(() => {
|
|
state.elapsedSeconds += 1;
|
|
updateTimer();
|
|
updateLiveStats();
|
|
|
|
if (state.elapsedSeconds >= state.roundSeconds) {
|
|
finishRound();
|
|
}
|
|
}, 1000);
|
|
}
|
|
|
|
function awardXp(summary) {
|
|
let xp = 35;
|
|
xp += Math.floor(summary.wpm * 1.4);
|
|
xp += Math.floor(summary.accuracy * 0.55);
|
|
xp += Math.floor(summary.bestCombo * 2.5);
|
|
|
|
if (hasPerk("combo-boost") && summary.bestCombo >= 5) {
|
|
xp = Math.floor(xp * 1.15);
|
|
}
|
|
|
|
if (hasPerk("accuracy-bounty") && summary.accuracy >= 96) {
|
|
xp += 30;
|
|
}
|
|
|
|
if (hasPerk("focus-engine") && summary.accuracy >= 98) {
|
|
xp += Math.floor(summary.wpm * 0.25);
|
|
}
|
|
|
|
xp = Math.floor(xp * DIFFICULTY[state.difficulty].xpMult);
|
|
|
|
let pending = xp;
|
|
let levelUps = 0;
|
|
|
|
while (pending > 0) {
|
|
const need = xpNeededForLevel(state.level) - state.xp;
|
|
if (pending >= need) {
|
|
pending -= need;
|
|
state.level += 1;
|
|
levelUps += 1;
|
|
state.xp = 0;
|
|
} else {
|
|
state.xp += pending;
|
|
pending = 0;
|
|
}
|
|
}
|
|
|
|
state.shards += levelUps;
|
|
state.lastRoundXp = xp;
|
|
return xp;
|
|
}
|
|
|
|
function markWordResult(correct) {
|
|
const wordNodes = els.textDisplay.querySelectorAll(".word");
|
|
const node = wordNodes[state.index];
|
|
if (!node) return;
|
|
|
|
node.classList.remove("current");
|
|
node.classList.add(correct ? "correct" : "incorrect");
|
|
}
|
|
|
|
function setCurrentWord() {
|
|
const wordNodes = els.textDisplay.querySelectorAll(".word");
|
|
const current = wordNodes[state.index];
|
|
if (current) {
|
|
current.classList.add("current");
|
|
current.scrollIntoView({ block: "center", behavior: "smooth" });
|
|
}
|
|
}
|
|
|
|
function finishRound() {
|
|
clearInterval(state.timer);
|
|
state.running = false;
|
|
els.typeInput.disabled = true;
|
|
|
|
const elapsedMinutes = Math.max(state.elapsedSeconds / 60, 1 / 60);
|
|
const wpm = Math.round((state.typedChars / 5) / elapsedMinutes);
|
|
const totalWords = state.correctWords + state.wrongWords;
|
|
const accuracy = totalWords === 0 ? 100 : Math.round((state.correctWords / totalWords) * 100);
|
|
|
|
const summary = {
|
|
wpm,
|
|
accuracy,
|
|
bestCombo: state.bestCombo
|
|
};
|
|
|
|
const earnedXp = awardXp(summary);
|
|
trackRound(summary, earnedXp);
|
|
renderProgress();
|
|
updateLiveStats();
|
|
saveProgress();
|
|
|
|
els.results.hidden = false;
|
|
els.resultsSummary.textContent = `you reached ${wpm} wpm with ${accuracy}% accuracy and earned ${earnedXp} xp!`;
|
|
}
|
|
|
|
function resetRound() {
|
|
clearInterval(state.timer);
|
|
state.words = pickWords();
|
|
state.index = 0;
|
|
state.typedChars = 0;
|
|
state.correctWords = 0;
|
|
state.wrongWords = 0;
|
|
state.combo = 0;
|
|
state.bestCombo = 0;
|
|
state.elapsedSeconds = 0;
|
|
state.roundSeconds = hasPerk("time-warp") ? 68 : 60;
|
|
state.running = false;
|
|
|
|
renderWords();
|
|
updateLiveStats();
|
|
updateTimer();
|
|
|
|
els.typeInput.disabled = false;
|
|
els.typeInput.value = "";
|
|
els.typeInput.focus();
|
|
els.results.hidden = true;
|
|
}
|
|
|
|
function setProfileName(value) {
|
|
const clean = value.trim().slice(0, 24);
|
|
state.profileName = clean || defaultState.profileName;
|
|
renderProgress();
|
|
saveProgress();
|
|
}
|
|
|
|
function setDifficulty(value) {
|
|
state.difficulty = sanitizeDifficulty(value);
|
|
renderProgress();
|
|
resetRound();
|
|
saveProgress();
|
|
}
|
|
|
|
function buyPerk(perkId) {
|
|
const perk = PERKS.find((item) => item.id === perkId);
|
|
if (!perk) return;
|
|
if (state.level < perk.unlockLevel) return;
|
|
if (hasPerk(perkId)) return;
|
|
if (state.shards < perk.cost) return;
|
|
|
|
state.shards -= perk.cost;
|
|
state.ownedPerks.push(perk.id);
|
|
renderProgress();
|
|
resetRound();
|
|
saveProgress();
|
|
}
|
|
|
|
function resetProgress() {
|
|
localStorage.removeItem(STORAGE_KEY);
|
|
clearInterval(state.timer);
|
|
|
|
Object.assign(state, { ...defaultState, ownedPerks: [], stats: { ...defaultState.stats }, recentRounds: [] });
|
|
renderProgress();
|
|
resetRound();
|
|
saveProgress();
|
|
}
|
|
|
|
els.typeInput.addEventListener("input", () => {
|
|
const value = els.typeInput.value;
|
|
|
|
if (!state.running && value.length > 0) {
|
|
startTimer();
|
|
}
|
|
|
|
if (!value.endsWith(" ")) return;
|
|
|
|
const typedWord = value.trim();
|
|
const targetWord = state.words[state.index];
|
|
const correct = typedWord === targetWord;
|
|
|
|
state.typedChars += typedWord.length + 1;
|
|
|
|
if (correct) {
|
|
state.correctWords += 1;
|
|
state.combo += 1;
|
|
if (state.combo > state.bestCombo) {
|
|
state.bestCombo = state.combo;
|
|
}
|
|
} else {
|
|
state.wrongWords += 1;
|
|
state.combo = 0;
|
|
}
|
|
|
|
markWordResult(correct);
|
|
state.index += 1;
|
|
els.typeInput.value = "";
|
|
|
|
if (state.index >= state.words.length) {
|
|
finishRound();
|
|
return;
|
|
}
|
|
|
|
setCurrentWord();
|
|
updateLiveStats();
|
|
});
|
|
|
|
els.newRoundBtn.addEventListener("click", resetRound);
|
|
els.playAgainBtn.addEventListener("click", resetRound);
|
|
els.settingsBtn.addEventListener("click", openSettings);
|
|
els.closeSettingsBtn.addEventListener("click", closeSettings);
|
|
els.profileName.addEventListener("change", (event) => setProfileName(event.target.value));
|
|
els.difficultySelect.addEventListener("change", (event) => setDifficulty(event.target.value));
|
|
els.resetProgressBtn.addEventListener("click", openResetConfirm);
|
|
els.cancelResetBtn.addEventListener("click", closeResetConfirm);
|
|
els.confirmResetBtn.addEventListener("click", () => {
|
|
resetProgress();
|
|
closeResetConfirm();
|
|
closeSettings();
|
|
});
|
|
|
|
els.settingsModal.addEventListener("click", (event) => {
|
|
if (event.target === els.settingsModal) {
|
|
closeSettings();
|
|
}
|
|
});
|
|
|
|
els.resetConfirmModal.addEventListener("click", (event) => {
|
|
if (event.target === els.resetConfirmModal) {
|
|
closeResetConfirm();
|
|
}
|
|
});
|
|
|
|
document.addEventListener("keydown", (event) => {
|
|
if (event.key === "Escape" && !els.resetConfirmModal.hidden) {
|
|
closeResetConfirm();
|
|
return;
|
|
}
|
|
|
|
if (event.key === "Escape" && !els.settingsModal.hidden) {
|
|
closeSettings();
|
|
}
|
|
});
|
|
|
|
els.perks.addEventListener("click", (event) => {
|
|
const target = event.target;
|
|
if (!(target instanceof HTMLButtonElement)) return;
|
|
const perkId = target.dataset.perkId;
|
|
if (!perkId) return;
|
|
buyPerk(perkId);
|
|
});
|
|
|
|
loadProgress();
|
|
renderProgress();
|
|
resetRound();
|