From 8d60c7f93407988ee0232ea90980028f299cb0f3 Mon Sep 17 00:00:00 2001 From: srdusr Date: Fri, 26 Sep 2025 13:39:28 +0200 Subject: Initial Commit --- web/src/components/TypingGame.tsx | 591 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 591 insertions(+) create mode 100644 web/src/components/TypingGame.tsx (limited to 'web/src/components/TypingGame.tsx') diff --git a/web/src/components/TypingGame.tsx b/web/src/components/TypingGame.tsx new file mode 100644 index 0000000..d815420 --- /dev/null +++ b/web/src/components/TypingGame.tsx @@ -0,0 +1,591 @@ +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 */} +
+ +
+ + ); +}); \ No newline at end of file -- cgit v1.2.3