import * as React from 'react'; import { useState, useEffect, useRef } from 'react'; import { Stats, Theme, TyperPunkGame } from '../types'; import { useTheme } from '../contexts/ThemeContext'; import { EndScreen } from './EndScreen'; interface Props { game: TyperPunkGame | null; text: string; input: string; stats: Stats; attribution?: string; onInput: (input: string, accuracy: number, mistakes: number) => void; onFinish: ( finalStats: Stats, wpmHistory: Array<{ time: number; wpm: number; raw: number; isError: boolean }>, finalUserInput: string, charTimings: Array<{ time: number; isCorrect: boolean; char: string; index: number }>, keypressHistory: Array<{ time: number; index: number; isCorrect: boolean }> ) => void; onReset: () => void; onMainMenu: () => void; } // Error boundary for TypingGame class TypingGameErrorBoundary extends React.Component<{ children: React.ReactNode }, { error: Error | null }> { constructor(props: { children: React.ReactNode }) { super(props); this.state = { error: null }; } static getDerivedStateFromError(error: Error) { return { error }; } componentDidCatch(error: Error, errorInfo: any) { console.error('TypingGame error boundary caught:', error, errorInfo); } render() { if (this.state.error) { return
TypingGame Error: {this.state.error.message}
; } return this.props.children; } } export const TypingGame: React.FC = React.memo((props: Props): JSX.Element => { const { game, text, stats, attribution, onInput, onFinish, onReset, onMainMenu } = props; const { theme, toggleTheme } = useTheme(); const [isFinished, setIsFinished] = useState(false); const [wpmHistory, setWpmHistory] = useState>([]); const [finalStats, setFinalStats] = useState(null); const [finalUserInput, setFinalUserInput] = useState(''); const [localInput, setLocalInput] = useState(''); const inputRef = useRef(null); const [wasmAccuracy, setWasmAccuracy] = useState(100); const [wasmMistakes, setWasmMistakes] = useState(0); const gameRef = useRef(game); const isInitialized = useRef(false); const lastInputRef = useRef(''); const inputQueueRef = useRef([]); const isProcessingQueueRef = useRef(false); const [charTimings, setCharTimings] = useState>([]); const gameStartTimeRef = useRef(null); const [finalCharTimings, setFinalCharTimings] = useState>([]); // Persistent mistake tracking const [allMistakes, setAllMistakes] = useState>([]); const [finalAllMistakes, setFinalAllMistakes] = useState>([]); // Persistent keypress history tracking const [keypressHistory, setKeypressHistory] = useState>([]); const [finalKeypressHistory, setFinalKeypressHistory] = useState>([]); const [isMobileScreen, setIsMobileScreen] = useState(window.innerWidth < 700); // Initialize game only once useEffect(() => { if (game) { gameRef.current = game; isInitialized.current = true; // Reset local state when game changes setIsFinished(false); setFinalStats(null); setFinalUserInput(''); setLocalInput(''); lastInputRef.current = ''; setWpmHistory([]); inputQueueRef.current = []; isProcessingQueueRef.current = false; // Ensure input is enabled and focused setTimeout(() => { if (inputRef.current) { inputRef.current.disabled = false; inputRef.current.focus(); } }, 100); } }, [game]); // Cleanup processing timeout useEffect(() => { return () => { // No cleanup needed anymore }; }, []); // Focus input on mount and when component updates useEffect(() => { const focusInput = () => { if (inputRef.current && !isFinished) { inputRef.current.disabled = false; inputRef.current.focus(); } }; const handleVisibilityChange = () => { if (!document.hidden && !isFinished) { // Small delay to ensure the page is fully visible setTimeout(focusInput, 100); } }; const handleWindowFocus = () => { if (!isFinished) { focusInput(); } }; const handleClick = (e: MouseEvent) => { const target = e.target as HTMLElement; if (target.closest('.typing-game') && !isFinished) { focusInput(); } }; const handleKeyDown = () => { // If user presses any key and input is not focused, focus it if (!isFinished && document.activeElement !== inputRef.current) { focusInput(); } }; // Initial focus with delay to ensure component is mounted setTimeout(focusInput, 50); // Add event listeners window.addEventListener('focus', handleWindowFocus); window.addEventListener('visibilitychange', handleVisibilityChange); document.addEventListener('click', handleClick); document.addEventListener('keydown', handleKeyDown); return () => { window.removeEventListener('focus', handleWindowFocus); window.removeEventListener('visibilitychange', handleVisibilityChange); document.removeEventListener('click', handleClick); document.removeEventListener('keydown', handleKeyDown); }; }, [isFinished]); // Update WPM history on every input/time change useEffect(() => { if (stats.time > 0 && !isFinished && gameRef.current) { const game = gameRef.current; const time = typeof game.get_time_elapsed === 'function' ? game.get_time_elapsed() : 0; const wpm = typeof game.get_wpm === 'function' ? game.get_wpm() : 0; const raw = typeof game.get_raw_wpm === 'function' ? game.get_raw_wpm() : 0; // Track error positions for this input let isError = false; if (typeof game.get_stats_and_input === 'function') { const [wasmInput, accuracy, mistakes] = game.get_stats_and_input(); // If mistakes increased, mark as error if (mistakes > 0) isError = true; } setWpmHistory(prev => [ ...prev, { time, wpm, raw, isError } ]); } }, [stats.time, isFinished]); // Update game text useEffect(() => { if (gameRef.current && text) { try { gameRef.current.set_text(text); } catch (err) { console.error('Error updating game text:', err); } } }, [text]); // Process input queue const processInputQueue = React.useCallback(() => { if (isProcessingQueueRef.current || inputQueueRef.current.length === 0) return; isProcessingQueueRef.current = true; try { const game = gameRef.current; if (!game) return; const nextInput = inputQueueRef.current[0]; // Process input try { game.handle_input(nextInput); const [wasmInput, accuracy, mistakes] = game.get_stats_and_input(); setLocalInput(wasmInput); lastInputRef.current = wasmInput; setWasmAccuracy(accuracy); setWasmMistakes(mistakes); onInput(wasmInput, accuracy, mistakes); // Check if game is finished using WASM game's is_finished method if (game.is_finished()) { setIsFinished(true); // Get latest stats from WASM let accuracy = 100, mistakes = 0; if (typeof game.get_stats === 'function') { try { [accuracy, mistakes] = game.get_stats(); } catch (err) { // fallback to last known } } const finalStats = { ...stats, accuracy, incorrectChars: mistakes }; setFinalStats(finalStats); setFinalUserInput(wasmInput); // Rebuild charTimings from final input and text const rebuiltCharTimings = []; for (let i = 0; i < wasmInput.length; i++) { rebuiltCharTimings.push({ time: (i / wasmInput.length) * stats.time, isCorrect: wasmInput[i] === text[i], char: wasmInput[i], index: i, }); } setFinalCharTimings(rebuiltCharTimings); setFinalAllMistakes([...allMistakes]); // snapshot mistakes if (inputRef.current) inputRef.current.disabled = true; setFinalKeypressHistory(keypressHistory); onFinish(finalStats, [...wpmHistory], wasmInput, rebuiltCharTimings, keypressHistory); return; // Exit early to prevent further processing } } catch (err) { console.error('WASM operation error:', err); setLocalInput(nextInput); lastInputRef.current = nextInput; } // Remove processed input from queue inputQueueRef.current.shift(); } catch (err) { console.error('WASM operation error:', err); if (inputQueueRef.current.length > 0) { const nextInput = inputQueueRef.current[0]; setLocalInput(nextInput); lastInputRef.current = nextInput; inputQueueRef.current.shift(); } } isProcessingQueueRef.current = false; // Process next input if any if (inputQueueRef.current.length > 0) { requestAnimationFrame(processInputQueue); } }, [onInput, text, stats, onFinish, wpmHistory, allMistakes, keypressHistory]); // Handle input changes const handleInput = React.useCallback((e: React.ChangeEvent) => { if (!gameRef.current || isFinished || isProcessingQueueRef.current) return; const newInput = e.target.value; if (newInput.length > text.length) return; inputQueueRef.current.push(newInput); if (!isProcessingQueueRef.current) { processInputQueue(); } }, [isFinished, text.length, processInputQueue]); // Handle backspace const handleKeyDown = React.useCallback(async (e: React.KeyboardEvent) => { if (!gameRef.current || isFinished || isProcessingQueueRef.current) return; if (e.key === 'Backspace') { e.preventDefault(); try { const ctrl = e.ctrlKey || e.metaKey; const success = await gameRef.current.handle_backspace(ctrl); if (success) { const [wasmInput, accuracy, mistakes] = await gameRef.current.get_stats_and_input(); setLocalInput(wasmInput); lastInputRef.current = wasmInput; setWasmAccuracy(accuracy); setWasmMistakes(mistakes); onInput(wasmInput, accuracy, mistakes); } } catch (err) { console.error('WASM operation error:', err); setLocalInput(lastInputRef.current); } } }, [isFinished, onInput]); // Remove the text-based reset effect since we're handling it in the game effect useEffect(() => { if (!gameRef.current || isFinished) return; try { const [accuracy, mistakes] = gameRef.current.get_stats(); setWasmAccuracy(accuracy); setWasmMistakes(mistakes); } catch (err) { console.error('WASM stats update error:', err); } }, [isFinished]); // Track per-character timing and correctness as user types useEffect(() => { if (!isFinished && localInput.length > 0) { if (gameStartTimeRef.current === null) { gameStartTimeRef.current = Date.now(); } const now = Date.now(); const elapsed = (now - gameStartTimeRef.current) / 1000; const idx = localInput.length - 1; const char = localInput[idx]; const isCorrect = text[idx] === char; // Log every keypress event (not just new chars) setKeypressHistory(prev => [...prev, { time: elapsed, index: idx, isCorrect }]); setCharTimings(prev => { // If user backspaced, trim timings if (prev.length > localInput.length) { return prev.slice(0, localInput.length); } // If user added a char, append if (prev.length < localInput.length) { return [ ...prev, { time: elapsed, isCorrect, char, index: idx } ]; } // If user replaced a char, update if (prev.length === localInput.length) { const updated = [...prev]; updated[idx] = { time: elapsed, isCorrect, char, index: idx }; return updated; } return prev; }); } else if (!isFinished && localInput.length === 0) { setCharTimings([]); setKeypressHistory([]); } lastInputRef.current = localInput; }, [localInput, isFinished, text]); // Reset charTimings and keypressHistory on new game useEffect(() => { setCharTimings([]); setKeypressHistory([]); gameStartTimeRef.current = null; setAllMistakes([]); }, [game]); // On finish, set finalCharTimings and finalKeypressHistory useEffect(() => { if (isFinished && charTimings.length > 0 && finalCharTimings.length === 0) { setFinalCharTimings(charTimings); } if (isFinished && keypressHistory.length > 0 && finalKeypressHistory.length === 0) { setFinalKeypressHistory(keypressHistory); } }, [isFinished, charTimings, finalCharTimings.length, keypressHistory, finalKeypressHistory.length]); const handleLogoClick = () => { onMainMenu(); }; const handlePlayAgain = () => { setIsFinished(false); setFinalStats(null); setFinalUserInput(''); setWpmHistory([]); setCharTimings([]); setLocalInput(''); gameStartTimeRef.current = null; onReset(); }; const renderText = () => { if (!text) return null; const inputChars = localInput ? localInput.split('') : []; // Split text into words with trailing spaces (so spaces stay with words) const wordRegex = /[^\s]+\s*/g; const wordMatches = text.match(wordRegex) || []; let charIndex = 0; return wordMatches.map((word, wIdx) => { const chars = []; for (let i = 0; i < word.length; i++) { const char = word[i]; const inputChar = inputChars[charIndex]; let className = 'neutral'; let displayChar = char; if (charIndex < inputChars.length) { if (inputChar === char) { className = 'correct'; } else { className = 'incorrect'; displayChar = inputChar; // Show the mistyped character } } if (!isFinished && charIndex === localInput.length && localInput.length <= text.length) { className += ' current'; } chars.push( {displayChar} ); charIndex++; } return {chars}; }); }; useEffect(() => { const handleResize = () => setIsMobileScreen(window.innerWidth < 700); window.addEventListener('resize', handleResize); return () => window.removeEventListener('resize', handleResize); }, []); if (isFinished && finalStats) { return ( ); } return (
{/* Logo at top */}
TyperPunk
{/* Main content area */}
{/* Text */}
{renderText()}
{ if (!isFinished) { setTimeout(() => e.target.focus(), 10); } }} disabled={isFinished} style={{ opacity: 0, caretColor: 'transparent', width: '100%', height: '2.5rem', position: 'absolute', left: 0, top: 0, zIndex: 9999, backgroundColor: 'transparent', border: 'none', outline: 'none', pointerEvents: isFinished ? 'none' : 'auto' }} />
{attribution && (
— {attribution}
)} {/* Desktop: WPM | Graph | ACC */} {!isMobileScreen && ( <> {/* WPM far left, fixed to viewport edge */}
WPM
{Math.round(stats.wpm)}
{/* ACC far right, fixed to viewport edge */}
ACC
{Math.round(wasmAccuracy)}%
{/* Graph center, take all available space with margin for stats */}
{/* TIME stat below graph */}
TIME
{stats.time.toFixed(1)}
)} {/* Mobile: Graph at top, then WPM & ACC in a row, then TIME below */} {isMobileScreen && ( <>
{/* WPM (left) */}
WPM
{Math.round(stats.wpm)}
{/* ACC (right) */}
ACC
{Math.round(wasmAccuracy)}%
{/* TIME stat below WPM/ACC */}
TIME
{stats.time.toFixed(1)}
)}
{/* Empty space for future game modes, matches EndScreen */}
); });