160 lines
5.4 KiB
JavaScript
160 lines
5.4 KiB
JavaScript
|
|
(() => {
|
||
|
|
const sourceLine = document.getElementById('source-line');
|
||
|
|
const codeDisplay = document.getElementById('code-display');
|
||
|
|
const input = document.getElementById('typing-input');
|
||
|
|
const statWpm = document.getElementById('stat-wpm');
|
||
|
|
const statAcc = document.getElementById('stat-acc');
|
||
|
|
const statKps = document.getElementById('stat-kps');
|
||
|
|
const statTime = document.getElementById('stat-time');
|
||
|
|
const restartBtn = document.getElementById('restart-btn');
|
||
|
|
const gameCard = document.getElementById('game-card');
|
||
|
|
const helpBtn = document.getElementById('help-btn');
|
||
|
|
const closeHelp = document.getElementById('close-help');
|
||
|
|
const helpModal = document.getElementById('help-modal');
|
||
|
|
|
||
|
|
let snippets = [];
|
||
|
|
let target = '';
|
||
|
|
let startTime = null;
|
||
|
|
let finished = false;
|
||
|
|
let totalKeystrokes = 0;
|
||
|
|
let correctKeystrokes = 0;
|
||
|
|
let tickHandle = null;
|
||
|
|
let resultBanner = null;
|
||
|
|
|
||
|
|
function escapeHtml(s) {
|
||
|
|
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||
|
|
}
|
||
|
|
|
||
|
|
function pickSnippet() {
|
||
|
|
return snippets[Math.floor(Math.random() * snippets.length)];
|
||
|
|
}
|
||
|
|
|
||
|
|
function loadSnippet() {
|
||
|
|
const s = pickSnippet();
|
||
|
|
// Tabs shown as two spaces so the player never needs to press Tab --
|
||
|
|
// a plain textarea's default Tab behavior is "move focus," and
|
||
|
|
// preventDefault-ing that just to insert a literal tab is more
|
||
|
|
// fragile (IME/mobile edge cases) than sidestepping it entirely.
|
||
|
|
target = s.code.replace(/\t/g, ' ');
|
||
|
|
sourceLine.innerHTML = `typing: <span class="accent">${escapeHtml(s.repo)}/${escapeHtml(s.file)}</span>`;
|
||
|
|
|
||
|
|
startTime = null;
|
||
|
|
finished = false;
|
||
|
|
totalKeystrokes = 0;
|
||
|
|
correctKeystrokes = 0;
|
||
|
|
input.value = '';
|
||
|
|
if (resultBanner) { resultBanner.remove(); resultBanner = null; }
|
||
|
|
render();
|
||
|
|
input.disabled = false;
|
||
|
|
input.focus();
|
||
|
|
stopTick();
|
||
|
|
startTick();
|
||
|
|
}
|
||
|
|
|
||
|
|
function render() {
|
||
|
|
const typed = input.value;
|
||
|
|
let html = '';
|
||
|
|
for (let i = 0; i < target.length; i++) {
|
||
|
|
const targetChar = target[i] === '\n' ? '\n' : target[i];
|
||
|
|
let cls = 'char-pending';
|
||
|
|
if (i < typed.length) {
|
||
|
|
cls = typed[i] === target[i] ? 'char-correct' : 'char-incorrect';
|
||
|
|
} else if (i === typed.length) {
|
||
|
|
cls = 'char-pending char-cursor';
|
||
|
|
}
|
||
|
|
html += `<span class="${cls}">${escapeHtml(targetChar)}</span>`;
|
||
|
|
}
|
||
|
|
codeDisplay.innerHTML = html;
|
||
|
|
updateStats();
|
||
|
|
}
|
||
|
|
|
||
|
|
function elapsedSeconds() {
|
||
|
|
if (startTime === null) return 0;
|
||
|
|
return (performance.now() - startTime) / 1000;
|
||
|
|
}
|
||
|
|
|
||
|
|
function updateStats() {
|
||
|
|
const typed = input.value;
|
||
|
|
let correctSoFar = 0;
|
||
|
|
for (let i = 0; i < typed.length && i < target.length; i++) {
|
||
|
|
if (typed[i] === target[i]) correctSoFar++;
|
||
|
|
}
|
||
|
|
const secs = elapsedSeconds();
|
||
|
|
const mins = secs / 60;
|
||
|
|
const wpm = mins > 0 ? Math.round((correctSoFar / 5) / mins) : 0;
|
||
|
|
const acc = totalKeystrokes > 0 ? Math.round((correctKeystrokes / totalKeystrokes) * 100) : 100;
|
||
|
|
const kps = secs > 0 ? (totalKeystrokes / secs) : 0;
|
||
|
|
|
||
|
|
statWpm.textContent = wpm;
|
||
|
|
statAcc.textContent = `${acc}%`;
|
||
|
|
statKps.textContent = kps.toFixed(1);
|
||
|
|
statTime.textContent = `${secs.toFixed(1)}s`;
|
||
|
|
}
|
||
|
|
|
||
|
|
function startTick() {
|
||
|
|
tickHandle = setInterval(() => { if (!finished) updateStats(); }, 200);
|
||
|
|
}
|
||
|
|
function stopTick() {
|
||
|
|
if (tickHandle) clearInterval(tickHandle);
|
||
|
|
tickHandle = null;
|
||
|
|
}
|
||
|
|
|
||
|
|
function finish() {
|
||
|
|
finished = true;
|
||
|
|
input.disabled = true;
|
||
|
|
stopTick();
|
||
|
|
updateStats();
|
||
|
|
resultBanner = document.createElement('div');
|
||
|
|
resultBanner.className = 'result-banner';
|
||
|
|
resultBanner.textContent = `Done — ${statWpm.textContent} wpm, ${statAcc.textContent} accuracy, ${statTime.textContent}. Nice.`;
|
||
|
|
gameCard.insertBefore(resultBanner, gameCard.querySelector('.actions'));
|
||
|
|
}
|
||
|
|
|
||
|
|
input.addEventListener('keydown', (e) => {
|
||
|
|
if (finished) return;
|
||
|
|
const isBackspace = e.key === 'Backspace';
|
||
|
|
const isNewline = e.key === 'Enter';
|
||
|
|
const isChar = e.key.length === 1;
|
||
|
|
if (!isBackspace && !isNewline && !isChar) return; // ignore Shift/Control/Arrow/Tab/etc
|
||
|
|
|
||
|
|
if (startTime === null && !isBackspace) startTime = performance.now();
|
||
|
|
|
||
|
|
totalKeystrokes++;
|
||
|
|
if (!isBackspace) {
|
||
|
|
const expected = target[input.value.length];
|
||
|
|
const pressed = isNewline ? '\n' : e.key;
|
||
|
|
if (expected !== undefined && pressed === expected) correctKeystrokes++;
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
input.addEventListener('input', () => {
|
||
|
|
render();
|
||
|
|
if (input.value === target) finish();
|
||
|
|
});
|
||
|
|
|
||
|
|
// mousedown + preventDefault, not click -- a click handler runs after the
|
||
|
|
// browser's own default-focus behavior has already fired, which was
|
||
|
|
// stealing focus away from the hidden textarea whenever this had a
|
||
|
|
// native tabindex. preventDefault on mousedown stops that entirely, so
|
||
|
|
// our explicit focus() call is the only thing that happens.
|
||
|
|
codeDisplay.addEventListener('mousedown', (e) => {
|
||
|
|
e.preventDefault();
|
||
|
|
if (!finished) input.focus();
|
||
|
|
});
|
||
|
|
restartBtn.addEventListener('click', loadSnippet);
|
||
|
|
|
||
|
|
helpBtn.addEventListener('click', () => { helpModal.hidden = false; });
|
||
|
|
closeHelp.addEventListener('click', () => { helpModal.hidden = true; });
|
||
|
|
helpModal.addEventListener('click', (e) => { if (e.target === helpModal) helpModal.hidden = true; });
|
||
|
|
|
||
|
|
fetch('snippets.json')
|
||
|
|
.then(r => r.json())
|
||
|
|
.then(data => {
|
||
|
|
snippets = data;
|
||
|
|
loadSnippet();
|
||
|
|
})
|
||
|
|
.catch(() => {
|
||
|
|
sourceLine.textContent = 'Could not load snippets.';
|
||
|
|
});
|
||
|
|
})();
|