const codeDisplay = document.getElementById('code-display'); const codeContainer = document.getElementById('code-container'); const hiddenInput = document.getElementById('hidden-input'); const wpmDisplay = document.getElementById('wpm'); const accuracyDisplay = document.getElementById('accuracy'); const progressDisplay = document.getElementById('progress'); const langSelect = document.getElementById('lang-select'); const resultOverlay = document.getElementById('result-overlay'); const finalWpmDisplay = document.getElementById('final-wpm'); const finalAccuracyDisplay = document.getElementById('final-accuracy'); const restartBtn = document.getElementById('restart-btn'); const soundToggleBtn = document.getElementById('sound-toggle'); const soundIcon = document.getElementById('sound-icon'); const focusHint = document.getElementById('focus-hint'); const bestWpmDisplay = document.getElementById('best-wpm'); // Custom Code Elements const customCodeBtn = document.getElementById('custom-code-btn'); const customModal = document.getElementById('custom-modal'); const customInput = document.getElementById('custom-input'); const customConfirm = document.getElementById('custom-confirm'); const customCancel = document.getElementById('custom-cancel'); // State let currentCode = ""; let currentIndex = 0; let startTime = null; let timer = null; let mistakes = 0; let totalTyped = 0; let isFinished = false; let soundEnabled = localStorage.getItem('code-typing-sound-enabled') !== 'false'; // Default true let audioCtx = null; let bestWpm = parseInt(localStorage.getItem('code-typing-best-wpm') || '0'); // Initialize async function init() { updateSoundIcon(); bestWpmDisplay.textContent = bestWpm; await fetchLanguages(); // Check if we just finished a custom game? No, always fetch new or use custom logic if implemented later. // For now, default to fetch snippet. fetchSnippet(); setupEventListeners(); } async function fetchLanguages() { try { const response = await fetch('/api/languages'); const languages = await response.json(); // Preserve selection if possible const currentSelection = langSelect.value; langSelect.innerHTML = ''; languages.forEach(lang => { const option = document.createElement('option'); option.value = lang; option.textContent = lang; // Style options (black text on white background for visibility in dropdown) option.className = "text-slate-900 bg-slate-200"; langSelect.appendChild(option); }); if (languages.includes(currentSelection)) { langSelect.value = currentSelection; } } catch (e) { console.error("Failed to fetch languages", e); } } async function fetchSnippet() { const lang = langSelect.value; try { const response = await fetch(`/api/snippet?lang=${lang}`); const data = await response.json(); setupGame(data.code, data.language); } catch (e) { console.error("Failed to fetch snippet", e); setupGame("print('Hello World')", "Python"); } } function setupGame(code, language) { // Reset state currentCode = code.replace(/\t/g, " "); // Replace tabs with spaces currentIndex = 0; startTime = null; mistakes = 0; totalTyped = 0; isFinished = false; if (timer) clearInterval(timer); // Update UI wpmDisplay.textContent = '0'; accuracyDisplay.textContent = '100%'; progressDisplay.textContent = '0%'; resultOverlay.classList.add('hidden'); // Render Code renderCode(); // Scroll to top codeContainer.scrollTop = 0; hiddenInput.value = ''; hiddenInput.focus(); } function renderCode() { codeDisplay.innerHTML = ''; currentCode.split('').forEach((char, index) => { const span = document.createElement('span'); span.innerText = char; if (index === 0) span.classList.add('cursor'); // Visual tweak for spaces/newlines if (char === '\n') { span.innerHTML = '↵\n'; span.classList.add('text-slate-700', 'opacity-50'); } codeDisplay.appendChild(span); }); } function startTimer() { if (!startTime) { startTime = new Date(); timer = setInterval(updateStats, 1000); initAudio(); // Ensure audio context is ready } } function updateStats() { if (!startTime) return; const now = new Date(); const timeDiff = (now - startTime) / 1000 / 60; // in minutes let wpm = 0; if (timeDiff > 0) { // WPM = (Characters / 5) / Minutes wpm = Math.round((currentIndex / 5) / timeDiff); wpmDisplay.textContent = wpm; } const accuracy = totalTyped === 0 ? 100 : Math.round(((totalTyped - mistakes) / totalTyped) * 100); accuracyDisplay.textContent = accuracy + '%'; const progress = Math.round((currentIndex / currentCode.length) * 100); progressDisplay.textContent = progress + '%'; } function finishGame() { isFinished = true; clearInterval(timer); updateStats(); const finalWpm = parseInt(wpmDisplay.textContent); finalWpmDisplay.textContent = finalWpm; finalAccuracyDisplay.textContent = accuracyDisplay.textContent; // Update Best WPM if (finalWpm > bestWpm) { bestWpm = finalWpm; localStorage.setItem('code-typing-best-wpm', bestWpm); bestWpmDisplay.textContent = bestWpm; // Could play a victory sound here } resultOverlay.classList.remove('hidden'); restartBtn.focus(); } // Audio System function initAudio() { if (!audioCtx) { const AudioContext = window.AudioContext || window.webkitAudioContext; audioCtx = new AudioContext(); } if (audioCtx.state === 'suspended') { audioCtx.resume(); } } function playClickSound() { if (!soundEnabled || !audioCtx) return; const osc = audioCtx.createOscillator(); const gain = audioCtx.createGain(); // Mechanical switch sound simulation (High pitch short burst) osc.type = 'triangle'; osc.frequency.setValueAtTime(600, audioCtx.currentTime); osc.frequency.exponentialRampToValueAtTime(300, audioCtx.currentTime + 0.05); gain.gain.setValueAtTime(0.05, audioCtx.currentTime); gain.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + 0.05); osc.connect(gain); gain.connect(audioCtx.destination); osc.start(); osc.stop(audioCtx.currentTime + 0.05); } function playErrorSound() { if (!soundEnabled || !audioCtx) return; const osc = audioCtx.createOscillator(); const gain = audioCtx.createGain(); // Low thud osc.type = 'sine'; osc.frequency.setValueAtTime(150, audioCtx.currentTime); osc.frequency.exponentialRampToValueAtTime(50, audioCtx.currentTime + 0.1); gain.gain.setValueAtTime(0.1, audioCtx.currentTime); gain.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + 0.1); osc.connect(gain); gain.connect(audioCtx.destination); osc.start(); osc.stop(audioCtx.currentTime + 0.1); } function updateSoundIcon() { soundIcon.textContent = soundEnabled ? '🔊' : '🔇'; soundToggleBtn.classList.toggle('text-blue-400', soundEnabled); soundToggleBtn.classList.toggle('text-slate-500', !soundEnabled); } // Event Listeners function setupEventListeners() { // Focus management document.addEventListener('click', (e) => { // Don't autofocus if clicking on buttons or modal if (e.target.closest('button') || e.target.closest('select') || e.target.closest('textarea') || e.target.closest('#custom-modal')) return; if (!isFinished) { hiddenInput.focus(); initAudio(); // Initialize audio on first user interaction } }); hiddenInput.addEventListener('blur', () => { if (!isFinished) focusHint.classList.remove('opacity-0'); }); hiddenInput.addEventListener('focus', () => { focusHint.classList.add('opacity-0'); }); // Language Change langSelect.addEventListener('change', () => { fetchSnippet(); hiddenInput.focus(); }); // Sound Toggle soundToggleBtn.addEventListener('click', () => { soundEnabled = !soundEnabled; localStorage.setItem('code-typing-sound-enabled', soundEnabled); updateSoundIcon(); hiddenInput.focus(); }); // Custom Code Logic customCodeBtn.addEventListener('click', () => { customModal.classList.remove('hidden'); customInput.focus(); }); customCancel.addEventListener('click', () => { customModal.classList.add('hidden'); hiddenInput.focus(); }); customConfirm.addEventListener('click', () => { const code = customInput.value.trim(); if (code) { setupGame(code, "Custom"); customModal.classList.add('hidden'); } }); // Restart restartBtn.addEventListener('click', () => { if (langSelect.value === 'Custom') { // Rerun the custom code setupGame(currentCode, "Custom"); } else { fetchSnippet(); } }); // Typing Logic window.addEventListener('keydown', handleKeydown); } function handleKeydown(e) { if (customModal.classList.contains('hidden') === false) return; // Don't type if modal is open if (isFinished) { if (e.key === 'Enter') { restartBtn.click(); } return; } // Shortcuts if (e.key === 'Tab') { e.preventDefault(); fetchSnippet(); // Quick restart return; } // Prevent default scrolling for Space if (e.key === ' ' && e.target === document.body) { e.preventDefault(); } // Ignore non-character keys if (e.key.length > 1 && e.key !== 'Enter' && e.key !== 'Backspace') return; hiddenInput.focus(); startTimer(); const charToType = currentCode[currentIndex]; const spans = codeDisplay.querySelectorAll('span'); if (e.key === 'Backspace') { if (currentIndex > 0) { currentIndex--; const span = spans[currentIndex]; span.className = ''; // Reset classes // Restore visual tweak for newline if (span.innerText.includes('↵')) { span.classList.add('text-slate-700', 'opacity-50'); } span.classList.add('cursor'); if (currentIndex + 1 < spans.length) { spans[currentIndex + 1].classList.remove('cursor'); } } return; } let typedChar = e.key; if (typedChar === 'Enter') typedChar = '\n'; totalTyped++; const currentSpan = spans[currentIndex]; if (typedChar === charToType) { playClickSound(); currentSpan.classList.add('text-emerald-400', 'opacity-100'); currentSpan.classList.remove('text-red-500', 'bg-red-900/50', 'text-slate-700', 'opacity-50', 'cursor'); currentIndex++; if (currentIndex >= currentCode.length) { finishGame(); } else { spans[currentIndex].classList.add('cursor'); // Auto-scroll logic const cursorRect = spans[currentIndex].getBoundingClientRect(); const containerRect = codeContainer.getBoundingClientRect(); // Keep cursor in the middle third of the screen vertically const relativeTop = cursorRect.top - containerRect.top; if (relativeTop > containerRect.height * 0.6) { codeContainer.scrollTop += 30; // Smooth scroll handled by CSS } } } else { mistakes++; playErrorSound(); currentSpan.classList.add('text-red-500', 'bg-red-900/50', 'opacity-100'); } } // Start init();