Spaces:
Sleeping
Sleeping
| 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(); | |