diff options
| author | srdusr <trevorgray@srdusr.com> | 2025-09-26 13:39:28 +0200 |
|---|---|---|
| committer | srdusr <trevorgray@srdusr.com> | 2025-09-26 13:39:28 +0200 |
| commit | 8d60c7f93407988ee0232ea90980028f299cb0f3 (patch) | |
| tree | b343b691d1bce64fb3bc9b40324857486f2be244 /web | |
| parent | 76f0d0e902e6ed164704572bd81faa5e5e560cf3 (diff) | |
| download | typerpunk-8d60c7f93407988ee0232ea90980028f299cb0f3.tar.gz typerpunk-8d60c7f93407988ee0232ea90980028f299cb0f3.zip | |
Initial Commit
Diffstat (limited to 'web')
| -rw-r--r-- | web/index.html | 16 | ||||
| -rwxr-xr-x | web/launch.sh | 81 | ||||
| -rw-r--r-- | web/package.json | 38 | ||||
| -rw-r--r-- | web/src/App.tsx | 436 | ||||
| -rw-r--r-- | web/src/components/EndScreen.tsx | 686 | ||||
| -rw-r--r-- | web/src/components/MainMenu.tsx | 63 | ||||
| -rw-r--r-- | web/src/components/ThemeToggle.tsx | 32 | ||||
| -rw-r--r-- | web/src/components/TypingGame.tsx | 591 | ||||
| -rw-r--r-- | web/src/contexts/ThemeContext.tsx | 63 | ||||
| -rw-r--r-- | web/src/hooks/useCoreGame.ts | 80 | ||||
| -rw-r--r-- | web/src/hooks/useTheme.ts | 21 | ||||
| -rw-r--r-- | web/src/main.tsx | 13 | ||||
| -rw-r--r-- | web/src/styles.css | 910 | ||||
| -rw-r--r-- | web/src/styles/ThemeToggle.css | 70 | ||||
| -rw-r--r-- | web/src/styles/TypingGame.css | 19 | ||||
| -rw-r--r-- | web/src/types.ts | 54 | ||||
| -rw-r--r-- | web/src/types/typerpunk.d.ts | 27 | ||||
| -rw-r--r-- | web/src/wasm.d.ts | 25 | ||||
| -rw-r--r-- | web/tailwind.config.js | 19 | ||||
| -rw-r--r-- | web/tsconfig.json | 28 | ||||
| -rw-r--r-- | web/tsconfig.node.json | 10 | ||||
| -rw-r--r-- | web/vite.config.ts | 43 |
22 files changed, 3325 insertions, 0 deletions
diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..e011e2d --- /dev/null +++ b/web/index.html @@ -0,0 +1,16 @@ +<!DOCTYPE html> +<html lang="en" data-theme="light"> +<head> + <meta charset="UTF-8"> + <link rel="icon" type="image/svg+xml" href="/vite.svg" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>TyperPunk - Typing Test</title> + <link rel="preconnect" href="https://fonts.googleapis.com"> + <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> + <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;700&display=swap" rel="stylesheet"> +</head> +<body> + <div id="root"></div> + <script type="module" src="/src/main.tsx"></script> +</body> +</html>
\ No newline at end of file diff --git a/web/launch.sh b/web/launch.sh new file mode 100755 index 0000000..8b2fd92 --- /dev/null +++ b/web/launch.sh @@ -0,0 +1,81 @@ +#!/bin/bash + +# Colors for output +GREEN='\033[0;32m' +BLUE='\033[0;34m' +RED='\033[0;31m' +NC='\033[0m' # No Color +YELLOW='\033[0;33m' + +echo -e "${BLUE}Starting TyperPunk Web...${NC}" + +# Resolve directories +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="${SCRIPT_DIR}/.." + +# Ensure wasm-pack is available (auto-install if possible) +if ! command -v wasm-pack &> /dev/null; then + echo -e "${YELLOW}wasm-pack not found. Attempting to install via cargo...${NC}" + if command -v cargo &> /dev/null; then + cargo install wasm-pack || { + echo -e "${RED}Failed to install wasm-pack automatically. Please install manually: cargo install wasm-pack${NC}" + exit 1 + } + else + echo -e "${RED}Rust cargo is not installed. Please install Rust and then run: cargo install wasm-pack${NC}" + exit 1 + fi +fi + +# Ensure dataset exists by merging packs at repo root (best-effort) +if command -v npm &> /dev/null; then + echo "Ensuring dataset (texts.json) exists by merging packs..." + (cd "$ROOT_DIR" && npm install && npm run --silent merge-packs) \ + && echo "Merged packs into texts.json" \ + || echo -e "${YELLOW}Warning:${NC} Could not merge packs; continuing with existing texts.json" +else + echo -e "${YELLOW}Warning:${NC} npm not found; skipping dataset merge. Ensure texts.json exists at repo root." +fi + +# Build the WASM module +echo "Building WASM module..." +cd "$ROOT_DIR/crates/wasm" + +# Clean previous build +rm -rf pkg target + +# Build with wasm-pack +wasm-pack build --target web --release + +# Check if build was successful +if [ $? -ne 0 ]; then + echo -e "${RED}Failed to build WASM module${NC}" + exit 1 +fi + +cd "$SCRIPT_DIR" + +# Clean previous build +rm -rf dist node_modules/.vite + +# Copy shared texts.json into web/src/data if present +mkdir -p src/data +if [ -f "$ROOT_DIR/texts.json" ]; then + cp "$ROOT_DIR/texts.json" src/data/texts.json + echo "Copied shared texts.json into web/src/data/" +else + echo -e "${YELLOW}Warning:${NC} ../texts.json not found. Using fallback web/src/data/texts.json" +fi + +# Install dependencies +echo "Installing dependencies..." +npm install + +# Type check +echo "Type checking..." +npm run type-check + +# Start the development server +echo -e "${GREEN}Starting development server...${NC}" +echo -e "${GREEN}Website will be available at: http://localhost:3000${NC}" +npm run dev diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..76fff94 --- /dev/null +++ b/web/package.json @@ -0,0 +1,38 @@ +{ + "name": "typerpunk-web", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview", + "lint": "eslint src --ext ts,tsx --report-unused-disable-directives --max-warnings 0", + "type-check": "tsc --noEmit", + "build:wasm": "cd ../crates/wasm && wasm-pack build --target web" + }, + "dependencies": { + "@typerpunk/wasm": "file:../crates/wasm/pkg", + "@types/react-router-dom": "^5.3.3", + "chart.js": "^4.5.0", + "react": "^18.2.0", + "react-chartjs-2": "^5.3.0", + "react-dom": "^18.2.0", + "react-router-dom": "^7.5.3" + }, + "devDependencies": { + "@types/node": "^20.19.0", + "@types/react": "^18.2.56", + "@types/react-dom": "^18.2.19", + "@typescript-eslint/eslint-plugin": "^7.0.2", + "@typescript-eslint/parser": "^7.0.2", + "@vitejs/plugin-react": "^4.5.1", + "eslint": "^8.56.0", + "eslint-plugin-react-hooks": "^4.6.0", + "eslint-plugin-react-refresh": "^0.4.5", + "typescript": "^5.8.3", + "vite": "^6.3.5", + "vite-plugin-top-level-await": "^1.4.1", + "vite-plugin-wasm": "^3.3.0" + } +} diff --git a/web/src/App.tsx b/web/src/App.tsx new file mode 100644 index 0000000..b34c4b4 --- /dev/null +++ b/web/src/App.tsx @@ -0,0 +1,436 @@ +import { useEffect, useState } from 'react'; +import { TypingGame } from './components/TypingGame'; +import MainMenu from './components/MainMenu'; +import { EndScreen } from './components/EndScreen'; +import { GameState, Stats } from './types'; +import { ThemeProvider } from './contexts/ThemeContext'; +import { useCoreGame } from './hooks/useCoreGame'; +import './styles.css'; +import textsData from './data/texts.json'; + +type TextItem = { category: string; content: string; attribution: string }; +const LOCAL_TEXTS: TextItem[] = Array.isArray(textsData) ? (textsData as TextItem[]) : []; +function uniqueCategories(items: TextItem[]): string[] { + const set = new Set<string>(); + for (const t of items) if (t.category) set.add(t.category); + return Array.from(set).sort(); +} +function pickRandom<T>(arr: T[]): T { + return arr[Math.floor(Math.random() * arr.length)]; +} + +function getRandomTextItemFrom(items: TextItem[], category?: string): TextItem { + const pool = category && category !== 'random' + ? items.filter(t => t.category === category) + : items; + if (!pool.length) return { category: 'general', content: "The quick brown fox jumps over the lazy dog.", attribution: 'Traditional pangram' }; + return pickRandom(pool); +} + +function calculateStats(input: string, text: string, elapsedTime: number): Stats { + let correct = 0; + let incorrect = 0; + let currentStreak = 0; + let bestStreak = 0; + let totalErrors = 0; + let hasStartedTyping = input.length > 0; + + // Calculate raw WPM using MonkeyType's approach + const windowSize = 0.5; // 500ms window for more granular peak detection + let maxWpm = 0; + let windowChars = 0; + let windowStartTime = 0; + + // Track character timings and errors + const charTimings: { time: number; isCorrect: boolean }[] = []; + for (let i = 0; i < input.length; i++) { + const charTime = (i / input.length) * elapsedTime; + const isCorrect = input[i] === text[i]; + charTimings.push({ time: charTime, isCorrect }); + + // Update window + while (charTime - windowStartTime > windowSize && charTimings.length > 0) { + if (charTimings[0].isCorrect) { + windowChars--; + } + windowStartTime = charTimings[1]?.time ?? charTime; + charTimings.shift(); + } + + if (isCorrect) { + windowChars++; + const windowTime = charTime - windowStartTime; + if (windowTime > 0) { + const windowWpm = (windowChars / 5) / (windowTime / 60); + maxWpm = Math.max(maxWpm, windowWpm); + } + } + } + + const rawWpm = maxWpm; // Peak typing speed + + // Calculate other stats + let totalTyped = 0; + for (let i = 0; i < input.length; i++) { + totalTyped++; + if (i < text.length && input[i] === text[i]) { + correct++; + currentStreak++; + bestStreak = Math.max(bestStreak, currentStreak); + } else { + incorrect++; + totalErrors++; + currentStreak = 0; + } + } + + const totalChars = text.length; + // Only show 100% accuracy before typing starts + const accuracy = !hasStartedTyping ? 100 : Math.max(0, Math.min(100, (correct / totalTyped) * 100)); + const wpm = elapsedTime === 0 ? 0 : (correct / 5) / (elapsedTime / 60); + + return { + wpm, + rawWpm, + accuracy, + time: elapsedTime, + correctChars: correct, + incorrectChars: totalErrors, + totalChars, + currentStreak, + bestStreak, + }; +} + +export type Screen = 'main-menu' | 'typing-game' | 'end-screen'; + +function App() { + const { game, resetGame, cleanupGame } = useCoreGame(); + const [allTexts, setAllTexts] = useState<TextItem[]>(LOCAL_TEXTS); + const [categories, setCategories] = useState<string[]>(uniqueCategories(LOCAL_TEXTS)); + const [selectedCategory, setSelectedCategory] = useState<string>('random'); + const [gameState, setGameState] = useState<GameState>({ + screen: 'main-menu', + currentText: '', + input: '', + startTime: null, + isRunning: false, + stats: { + wpm: 0, + rawWpm: 0, + accuracy: 100, + time: 0, + correctChars: 0, + incorrectChars: 0, + totalChars: 0, + currentStreak: 0, + bestStreak: 0, + }, + }); + const [gameKey, setGameKey] = useState<number>(0); // For remounting TypingGame + // Track last WPM history only for end-screen payloads (retain local var at finish) + const testStats: Stats = { + wpm: 85, rawWpm: 90, accuracy: 95, time: 60, + correctChars: 425, incorrectChars: 21, totalChars: 446, + currentStreak: 50, bestStreak: 100 + }; + const testWpmHistory = Array.from({ length: 60 }, (_, i) => ({ + time: i, wpm: 80 + Math.sin(i / 5) * 10, raw: 85 + Math.sin(i / 5) * 10, + isError: Math.random() > 0.95 + })); + const testText = "This is a test sentence for the end screen. It has some text to display and check for errors."; + const testUserInput = "This is a test sentance for the end screen. It has sone text to display and check for erors."; + const _testCharTimings = testText.split('').map((char, i) => ({ + time: (i / testText.length) * 60, + isCorrect: char === (testUserInput[i] || ''), + char: char, + index: i + })); + const [lastTest, setLastTest] = useState<{ stats: Stats; wpmHistory: Array<{ time: number; wpm: number; raw: number; isError: boolean }>; text: string; userInput: string; charTimings?: Array<{ time: number; isCorrect: boolean; char: string; index: number }>; keypressHistory?: Array<{ time: number; index: number; isCorrect: boolean }> } | null>(null); + + // Removed unused Enter key end-screen toggle handler + + // Optional online dataset fetch (fallback to local). Configure URL via window.TYPERPUNK_TEXTS_URL. + useEffect(() => { + const url = (window as any).TYPERPUNK_TEXTS_URL as string | undefined; + if (!url) return; // no online dataset configured + (async () => { + try { + const res = await fetch(url, { cache: 'no-store' }); + if (!res.ok) return; + const data = await res.json(); + if (Array.isArray(data)) { + setAllTexts(data as TextItem[]); + setCategories(uniqueCategories(data as TextItem[])); + } + } catch {} + })(); + }, []); + + const handleStartGame = async () => { + try { + // Reset game state first + const item = getRandomTextItemFrom(allTexts, selectedCategory); + setGameState((prev: GameState) => ({ + ...prev, + screen: 'typing-game', + currentText: item.content, + currentAttribution: item.attribution, + input: '', + startTime: null, + isRunning: false, + stats: { + wpm: 0, + rawWpm: 0, + accuracy: 100, + time: 0, + correctChars: 0, + incorrectChars: 0, + totalChars: 0, + currentStreak: 0, + bestStreak: 0, + }, + })); + + // Then reset WASM game instance + await resetGame(); + setGameKey((k: number) => k + 1); // Force remount + + // Ensure focus after a short delay + setTimeout(() => { + const inputElement = document.querySelector('.typing-input') as HTMLInputElement; + if (inputElement) { + inputElement.disabled = false; + inputElement.focus(); + } + }, 200); + } catch (err) { + console.error('Error starting game:', err); + // If start fails, stay in main menu + setGameState((prev: GameState) => ({ + ...prev, + screen: 'main-menu', + })); + } + }; + + const handleResetGame = async () => { + try { + // Reset game state first + const item = getRandomTextItemFrom(allTexts, selectedCategory); + setGameState((prev: GameState) => ({ + ...prev, + screen: 'typing-game', + currentText: item.content, + currentAttribution: item.attribution, + input: '', + startTime: null, + isRunning: false, + stats: { + wpm: 0, + rawWpm: 0, + accuracy: 100, + time: 0, + correctChars: 0, + incorrectChars: 0, + totalChars: 0, + currentStreak: 0, + bestStreak: 0, + }, + })); + + // Then reset WASM game instance + await resetGame(); + setGameKey((k: number) => k + 1); // Force remount + + // Ensure focus after a short delay + setTimeout(() => { + const inputElement = document.querySelector('.typing-input') as HTMLInputElement; + if (inputElement) { + inputElement.disabled = false; + inputElement.focus(); + } + }, 200); + } catch (err) { + console.error('Error resetting game:', err); + // If reset fails, go back to main menu + handleMainMenu(); + } + }; + + const handleInput = (input: string, accuracy: number, mistakes: number) => { + setGameState((prev: GameState) => { + // Don't update if input hasn't changed + if (prev.input === input) { + return prev; + } + + const newState = { + ...prev, + input, + stats: { + ...prev.stats, + accuracy, + incorrectChars: mistakes + } + }; + + // Only update running state and start time once + if (!prev.isRunning) { + newState.isRunning = true; + newState.startTime = Date.now(); + } + + // Check if the game is finished using WASM game's is_finished method + if (game && game.is_finished() && prev.screen === 'typing-game') { + newState.isRunning = false; + newState.screen = 'end-screen'; + // Calculate final stats but preserve WASM accuracy and mistakes + const elapsedTime = (Date.now() - (newState.startTime || Date.now())) / 1000; + const stats = calculateStats(input, prev.currentText, elapsedTime); + // Preserve WASM accuracy and mistakes instead of recalculating + stats.accuracy = accuracy; + stats.incorrectChars = mistakes; + newState.stats = stats; + } + + return newState; + }); + }; + + const handleFinish = ( + finalStats: Stats, + wpmHistory: Array<{ time: number; wpm: number; raw: number; isError: boolean }>, + userInput: string, + charTimings: Array<{ time: number; isCorrect: boolean; char: string; index: number }>, + keypressHistory: Array<{ time: number; index: number; isCorrect: boolean }> + ) => { + setLastTest({ stats: finalStats, wpmHistory, text: gameState.currentText, userInput, charTimings, keypressHistory }); + setGameState(prev => ({ ...prev, isRunning: false, screen: 'end-screen' })); + }; + + const handleMainMenu = async () => { + try { + // Reset game state first + setGameState((prev: GameState) => ({ + ...prev, + screen: 'main-menu', + input: '', + currentText: '', + currentAttribution: undefined, + startTime: null, + isRunning: false, + stats: { + wpm: 0, + rawWpm: 0, + accuracy: 100, + time: 0, + correctChars: 0, + incorrectChars: 0, + totalChars: 0, + currentStreak: 0, + bestStreak: 0, + }, + })); + + // Then cleanup WASM game instance + cleanupGame(); + setGameKey((k: number) => k + 1); // Force remount + } catch (err) { + console.error('Error going to main menu:', err); + // If cleanup fails, still try to go to main menu + setGameState((prev: GameState) => ({ + ...prev, + screen: 'main-menu', + input: '', + currentText: '', + startTime: null, + isRunning: false, + stats: { + wpm: 0, + rawWpm: 0, + accuracy: 100, + time: 0, + correctChars: 0, + incorrectChars: 0, + totalChars: 0, + currentStreak: 0, + bestStreak: 0, + }, + })); + } + }; + + useEffect(() => { + let interval: ReturnType<typeof setInterval>; + if (gameState.isRunning && gameState.screen === 'typing-game' && gameState.startTime) { + interval = setInterval(() => { + setGameState((prev: GameState) => { + if (!prev.isRunning || !prev.startTime) return prev; + + const elapsedTime = (Date.now() - prev.startTime) / 1000; + const stats = calculateStats(prev.input, prev.currentText, elapsedTime); + + // Only update if stats have changed significantly + const hasSignificantChange = + Math.abs(prev.stats.wpm - stats.wpm) > 0.1 || + Math.abs(prev.stats.rawWpm - stats.rawWpm) > 0.1 || + Math.abs(prev.stats.time - stats.time) > 0.1; + + if (!hasSignificantChange) { + return prev; + } + + return { + ...prev, + stats, + }; + }); + }, 100); + } + return () => { + if (interval) clearInterval(interval); + }; + }, [gameState.isRunning, gameState.screen, gameState.startTime]); + + return ( + <ThemeProvider> + <div className="app"> + {gameState.screen === 'main-menu' ? ( + <MainMenu + onStartGame={handleStartGame} + categories={categories} + selectedCategory={selectedCategory} + onSelectCategory={setSelectedCategory} + /> + ) : gameState.screen === 'end-screen' ? ( + <EndScreen + stats={lastTest?.stats || testStats} + wpmHistory={lastTest?.wpmHistory || testWpmHistory} + text={lastTest?.text || testText} + userInput={lastTest?.userInput || testUserInput} + charTimings={lastTest?.charTimings} + keypressHistory={lastTest?.keypressHistory} + onPlayAgain={handleResetGame} + onMainMenu={handleMainMenu} + /> + ) : ( + <TypingGame + key={gameKey} + game={game as any} + text={gameState.currentText} + input={gameState.input} + stats={gameState.stats} + attribution={gameState.currentAttribution} + onInput={handleInput} + onFinish={handleFinish} + onReset={handleResetGame} + onMainMenu={handleMainMenu} + /> + )} + </div> + </ThemeProvider> + ); +} + +export default App;
\ No newline at end of file diff --git a/web/src/components/EndScreen.tsx b/web/src/components/EndScreen.tsx new file mode 100644 index 0000000..3c70e95 --- /dev/null +++ b/web/src/components/EndScreen.tsx @@ -0,0 +1,686 @@ +import { useEffect, useRef, useState, FC } from 'react'; +import { useTheme } from '../contexts/ThemeContext'; +import { Stats, Theme } from '../types'; +import { Line } from 'react-chartjs-2'; +import { + Chart as ChartJS, + LineElement, + PointElement, + LinearScale, + Title, + Tooltip, + Legend, + CategoryScale, +} from 'chart.js'; +import type { ChartData } from 'chart.js'; +ChartJS.register(LineElement, PointElement, LinearScale, Title, Tooltip, Legend, CategoryScale); + +interface Props { + stats: Stats; + wpmHistory: Array<{ time: number; wpm: number; raw: number; isError: boolean }>; + onPlayAgain: () => void; + onMainMenu: () => void; + text: string; + userInput: string; + charTimings?: Array<{ time: number; isCorrect: boolean; char: string; index: number }>; + keypressHistory?: Array<{ time: number; index: number; isCorrect: boolean }>; +} + +export const EndScreen: FC<Props> = ({ stats, wpmHistory, onPlayAgain, onMainMenu, text, userInput, charTimings, keypressHistory }) => { + // Responsive flag must be declared first + const canvasRef = useRef<HTMLCanvasElement>(null); + const containerRef = useRef<HTMLDivElement>(null); + const [tooltip, setTooltip] = useState<{ x: number; y: number; content: string } | null>(null); + const { theme, toggleTheme } = useTheme(); + const [isMobileScreen, setIsMobileScreen] = useState(window.innerWidth < 700); + + // Debug log + console.log('EndScreen wpmHistory:', wpmHistory); + console.log('EndScreen stats:', stats); + console.log('EndScreen charTimings:', charTimings); + console.log('EndScreen userInput:', userInput); + console.log('EndScreen text:', text); + + // --- Monkeytype-style rolling window graph data for WPM and RAW --- + const graphInterval = 1.0; // seconds, for 1s intervals + const wpmWindow = 2.0; // seconds (WPM window) + const rawWindow = 0.5; // seconds (RAW window) + let graphPoints: { time: number; wpm: number; raw: number }[] = []; + if (charTimings && charTimings.length > 0) { + const maxTime = Math.max(charTimings[charTimings.length - 1].time, stats.time); + for (let t = 1; t <= Math.ceil(maxTime); t += graphInterval) { // start at 1s, step by 1s + // WPM: correct chars in last 2.0s + const wpmChars = charTimings.filter(c => c.time > t - wpmWindow && c.time <= t); + const wpmCorrect = wpmChars.filter(c => c.isCorrect).length; + const wpm = wpmCorrect > 0 ? (wpmCorrect / 5) / (wpmWindow / 60) : 0; + // RAW: all chars in last 0.5s + const rawChars = charTimings.filter(c => c.time > t - rawWindow && c.time <= t); + const raw = rawChars.length > 0 ? (rawChars.length / 5) / (rawWindow / 60) : 0; + graphPoints.push({ time: t, wpm, raw }); + } + // Apply moving average smoothing to WPM and RAW + const smoothWPM: number[] = []; + const smoothRAW: number[] = []; + const smoothWindow = 10; // more smoothing + for (let i = 0; i < graphPoints.length; i++) { + let sumWPM = 0, sumRAW = 0, count = 0; + for (let j = Math.max(0, i - smoothWindow + 1); j <= i; j++) { + sumWPM += graphPoints[j].wpm; + sumRAW += graphPoints[j].raw; + count++; + } + smoothWPM.push(sumWPM / count); + smoothRAW.push(sumRAW / count); + } + graphPoints = graphPoints.map((p, i) => ({ ...p, wpm: smoothWPM[i], raw: smoothRAW[i] })); + } else if (stats.time > 0 && text.length > 0) { + // fallback: simulate timings + const charTimingsSim: { time: number; isCorrect: boolean }[] = []; + for (let i = 0; i < stats.correctChars + stats.incorrectChars; i++) { + const charTime = (i / (stats.correctChars + stats.incorrectChars)) * stats.time; + const isCorrect = i < stats.correctChars; + charTimingsSim.push({ time: charTime, isCorrect }); + } + for (let t = 1; t <= Math.ceil(stats.time); t += graphInterval) { + const wpmChars = charTimingsSim.filter(c => c.time > t - wpmWindow && c.time <= t); + const wpmCorrect = wpmChars.filter(c => c.isCorrect).length; + const wpm = wpmCorrect > 0 ? (wpmCorrect / 5) / (wpmWindow / 60) : 0; + const rawChars = charTimingsSim.filter(c => c.time > t - rawWindow && c.time <= t); + const raw = rawChars.length > 0 ? (rawChars.length / 5) / (rawWindow / 60) : 0; + graphPoints.push({ time: t, wpm, raw }); + } + // Apply moving average smoothing to WPM and RAW + const smoothWPM: number[] = []; + const smoothRAW: number[] = []; + const smoothWindow = 10; + for (let i = 0; i < graphPoints.length; i++) { + let sumWPM = 0, sumRAW = 0, count = 0; + for (let j = Math.max(0, i - smoothWindow + 1); j <= i; j++) { + sumWPM += graphPoints[j].wpm; + sumRAW += graphPoints[j].raw; + count++; + } + smoothWPM.push(sumWPM / count); + smoothRAW.push(sumRAW / count); + } + graphPoints = graphPoints.map((p, i) => ({ ...p, wpm: smoothWPM[i], raw: smoothRAW[i] })); + } + // --- Chart.js data and options --- + // Build labels for every second + const xMax = Math.ceil(stats.time); + const allLabels = Array.from({length: xMax}, (_, i) => (i+1).toString()); + // Map graphPoints by time for quick lookup + const graphPointsByTime = Object.fromEntries(graphPoints.map(p => [Math.round(p.time), p])); + // --- Error points for the graph (Monkeytype style, per-error, at closest WPM value, using keypressHistory) --- + let errorPoints: { x: number; y: number }[] = []; + if (keypressHistory && keypressHistory.length > 0) { + keypressHistory.forEach(({ time, isCorrect }) => { + if (!isCorrect) { + let p = graphPoints.reduce((prev, curr) => + Math.abs(curr.time - time) < Math.abs(prev.time - time) ? curr : prev, graphPoints[0]); + if (p) { + errorPoints.push({ x: p.time, y: p.wpm }); + } + } + }); + } else if (charTimings && charTimings.length > 0) { + charTimings.forEach(({ time, isCorrect }) => { + if (!isCorrect) { + let p = graphPoints.reduce((prev, curr) => + Math.abs(curr.time - time) < Math.abs(prev.time - time) ? curr : prev, graphPoints[0]); + if (p) { + errorPoints.push({ x: p.time, y: p.wpm }); + } + } + }); + } + console.log('EndScreen errorPoints:', errorPoints); + console.log('EndScreen graphPoints:', graphPoints); + const chartData = { + labels: allLabels, + datasets: [ + { + label: 'WPM', + data: graphPoints.map((p) => ({ x: p.time, y: p.wpm })), + borderColor: '#00ff9d', + backgroundColor: 'rgba(0,255,157,0.1)', + borderWidth: 2, + pointRadius: 0, + tension: 0.4, // smoother line + type: 'line', + order: 1, + yAxisID: 'y', + pointStyle: 'line', + }, + { + label: 'RAW', + data: graphPoints.map((p) => ({ x: p.time, y: p.raw })), + borderColor: '#00cc8f', + backgroundColor: 'rgba(0,204,143,0.1)', + borderWidth: 1, + borderDash: [10, 8], + pointRadius: 0, + tension: 0.25, + type: 'line', + order: 2, + yAxisID: 'y', + pointStyle: 'line', + }, + { + label: 'Errors', + data: errorPoints, + borderColor: '#ff3b3b', + borderWidth: 0, + backgroundColor: '#ff3b3b', + pointRadius: 3, + type: 'scatter', + showLine: false, + order: 3, + yAxisID: 'y', + pointStyle: 'circle', + }, + ], + } as ChartData<'line'>; + // --- Dynamic X-axis step size for time (responsive, long time) --- + let xStep = 1; + let autoSkip = false; + let maxTicksLimit = 100; + let xMin = 1; + if (window.innerWidth < 700) { + xStep = 2; + autoSkip = true; + maxTicksLimit = 10; + xMin = 2; // start at 2 for even spacing if skipping by 2 + } + // Calculate max number of x labels that fit in the viewport (assume 40px per label) + const maxLabels = Math.floor((isMobileScreen ? window.innerWidth : 600) / 40); // 600px for graph area on desktop + if (xMax > maxLabels) { + xStep = Math.ceil(xMax / maxLabels); + } else if (xMax > 60) xStep = 10; + else if (xMax > 30) xStep = 5; + const chartOptions: any = { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { + display: true, + position: 'top', + align: 'center', + labels: { + color: '#00ff9d', + font: { family: 'JetBrains Mono, monospace', size: 12 }, + boxWidth: 24, + boxHeight: 12, + usePointStyle: false, + symbol: (ctx: any) => { + const {dataset} = ctx; + return function customLegendSymbol(ctx2: any, x: any, y: any, width: any, height: any) { + ctx2.save(); + if (dataset.label === 'RAW') { + // Dashed line + ctx2.strokeStyle = dataset.borderColor || '#00cc8f'; + ctx2.lineWidth = 3; + ctx2.setLineDash([8, 5]); + ctx2.beginPath(); + ctx2.moveTo(x, y + height / 2); + ctx2.lineTo(x + width, y + height / 2); + ctx2.stroke(); + ctx2.setLineDash([]); + } else if (dataset.label === 'Errors') { + // Small dot + ctx2.fillStyle = dataset.borderColor || '#ff3b3b'; + ctx2.beginPath(); + ctx2.arc(x + width / 2, y + height / 2, 4, 0, 2 * Math.PI); + ctx2.fill(); + } else if (dataset.label === 'WPM') { + // Solid line + ctx2.strokeStyle = dataset.borderColor || '#00ff9d'; + ctx2.lineWidth = 3; + ctx2.beginPath(); + ctx2.moveTo(x, y + height / 2); + ctx2.lineTo(x + width, y + height / 2); + ctx2.stroke(); + } + ctx2.restore(); + }; + }, + }, + }, + tooltip: { + enabled: true, + mode: 'nearest', + intersect: false, + usePointStyle: true, + callbacks: { + labelPointStyle: function(context: any) { + if (context.dataset.label === 'WPM') { + return { pointStyle: 'line', rotation: 0, borderWidth: 2, borderDash: [] }; + } + if (context.dataset.label === 'RAW') { + // Chart.js does not support dashed line in tooltip, so use line + return { pointStyle: 'line', rotation: 0, borderWidth: 2, borderDash: [] }; + } + if (context.dataset.label === 'Errors') { + return { pointStyle: 'circle', rotation: 0, borderWidth: 0, radius: 4 }; + } + return { pointStyle: 'circle', rotation: 0 }; + }, + label: function(context: any) { + if (context.dataset.label === 'WPM') { + return `WPM: ${Math.round(context.parsed.y)}`; + } + if (context.dataset.label === 'RAW') { + return `Raw: ${Math.round(context.parsed.y)}`; + } + if (context.dataset.label === 'Errors') { + if (context.chart.tooltip?._errorShown) return ''; + context.chart.tooltip._errorShown = true; + let errorText = ''; + if (charTimings && charTimings.length > 0 && text) { + const errorPoint = context.raw; + const closest = charTimings.reduce((prev, curr) => + Math.abs(curr.time - errorPoint.x) < Math.abs(prev.time - errorPoint.x) ? curr : prev, charTimings[0]); + if (!closest.isCorrect) { + const idx = closest.index; + let start = idx, end = idx; + while (start > 0 && text[start-1] !== ' ') start--; + while (end < text.length && text[end] !== ' ') end++; + const word = text.slice(start, end).trim(); + if (word.length > 0) { + errorText = `Error: "${word}"`; + } else { + errorText = `Error: '${closest.char}'`; + } + } + } + return errorText || 'Error'; + } + return ''; + }, + title: function() { return ''; }, + }, + backgroundColor: 'rgba(30,30,30,0.97)', + titleColor: '#fff', + bodyColor: '#fff', + borderColor: '#00ff9d', + borderWidth: 1, + caretSize: 6, + padding: 10, + external: function(context: any) { + if (context && context.tooltip) { + context.tooltip._errorShown = false; + } + }, + }, + }, + scales: { + x: { + title: { + display: true, + text: 'Time (s)', + color: '#646669', + font: { family: 'JetBrains Mono, monospace', size: 13, weight: 'bold' }, + align: 'center', + }, + min: xMin, + max: xMax, + type: 'linear', + offset: false, // no extra space/lines before 1 or after xMax, even spacing + ticks: { + color: '#646669', + font: { family: 'JetBrains Mono, monospace', size: 12 }, + stepSize: xStep, + autoSkip: autoSkip, + maxTicksLimit: maxTicksLimit, + callback: function(val: string | number) { + const tickNum = Number(val); + return Number.isInteger(tickNum) ? tickNum : ''; + }, + maxRotation: 0, + minRotation: 0, + }, + grid: { color: 'rgba(100,102,105,0.15)' }, + beginAtZero: false, + }, + y: { + title: { + display: true, + text: 'WPM', + color: '#646669', + font: { family: 'JetBrains Mono, monospace', size: 13, weight: 'bold' }, + }, + beginAtZero: true, + ticks: { + color: '#646669', + font: { family: 'JetBrains Mono, monospace', size: 12 }, + }, + grid: { color: 'rgba(100,102,105,0.15)' }, + position: 'left', + }, + }, + }; + // --- Axis scaling and responsive graph width --- + const minGraphWidth = 320; + const maxGraphWidth = 1200; + const pxPerSecond = 60; + const graphHeight = isMobileScreen ? 160 : 220; + const graphWidth = Math.min(Math.max(minGraphWidth, Math.min((xMax) * pxPerSecond, maxGraphWidth)), window.innerWidth - 32); + const margin = 40; + const axisFont = '12px JetBrains Mono, monospace'; + const maxTime = stats.time || (graphPoints.length > 0 ? graphPoints[graphPoints.length - 1].time : 1); + const maxWPM = graphPoints.length > 0 ? Math.max(...graphPoints.map(p => p.wpm)) : 0; + + // --- Dynamic Y-axis step size for WPM --- + let yStep = xMax > 60 ? 10 : (maxWPM > 50 ? 10 : 5); + let yMax = Math.max(yStep, Math.ceil(maxWPM / yStep) * yStep); + if (window.innerWidth < 700 && yMax > 60) { + yStep = 20; + yMax = Math.max(yStep, Math.ceil(maxWPM / yStep) * yStep); + } + + useEffect(() => { + const canvas = canvasRef.current; + const container = containerRef.current; + if (!canvas || !container || graphPoints.length === 0) return; + const ctx = canvas.getContext('2d'); + if (!ctx) return; + // Set canvas size + canvas.width = graphWidth; + canvas.height = graphHeight; + ctx.clearRect(0, 0, canvas.width, canvas.height); + // --- Draw axes --- + ctx.strokeStyle = '#646669'; + ctx.lineWidth = 1; + ctx.beginPath(); + // Y axis + ctx.moveTo(margin, margin); + ctx.lineTo(margin, canvas.height - margin); + // X axis + ctx.moveTo(margin, canvas.height - margin); + ctx.lineTo(canvas.width - margin, canvas.height - margin); + ctx.stroke(); + // --- Draw Y ticks and labels --- + ctx.font = axisFont; + ctx.fillStyle = '#646669'; + ctx.textAlign = 'right'; + ctx.textBaseline = 'middle'; + for (let yValue = 0; yValue <= yMax; yValue += yStep) { + const y = canvas.height - margin - (yValue / yMax) * (canvas.height - 2 * margin); + ctx.beginPath(); + ctx.moveTo(margin - 6, y); + ctx.lineTo(margin, y); + ctx.stroke(); + ctx.fillText(Math.round(yValue).toString(), margin - 8, y); + } + // --- Draw X ticks and labels (every whole second) --- + ctx.textAlign = 'center'; + ctx.textBaseline = 'top'; + for (let xValue = 0; xValue <= xMax; xValue += 1) { + const x = margin + xValue * ((canvas.width - 2 * margin) / (xMax || 1)); + ctx.beginPath(); + ctx.moveTo(x, canvas.height - margin); + ctx.lineTo(x, canvas.height - margin + 6); + ctx.stroke(); + if (xValue % 5 === 0 || xValue === 0 || xValue === xMax) { + ctx.fillText(xValue.toString(), x, canvas.height - margin + 8); + } + } + // --- Draw WPM line --- + ctx.strokeStyle = '#00ff9d'; + ctx.lineWidth = 2; + ctx.beginPath(); + graphPoints.forEach((point, i) => { + const x = margin + point.time * ((canvas.width - 2 * margin) / (xMax || 1)); + const y = canvas.height - margin - (point.wpm / yMax) * (canvas.height - 2 * margin); + if (i === 0) ctx.moveTo(x, y); + else ctx.lineTo(x, y); + }); + ctx.stroke(); + // --- Axis labels --- + ctx.save(); + ctx.font = 'bold 13px JetBrains Mono, monospace'; + ctx.fillStyle = '#646669'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'bottom'; + ctx.fillText('Time (s)', canvas.width / 2, canvas.height - 2); + ctx.save(); + ctx.translate(10, canvas.height / 2); + ctx.rotate(-Math.PI / 2); + ctx.textAlign = 'center'; + ctx.textBaseline = 'top'; + ctx.fillText('WPM', 0, 0); + ctx.restore(); + ctx.restore(); + }, [graphPoints]); + + useEffect(() => { + const handleResize = () => setIsMobileScreen(window.innerWidth < 700); + window.addEventListener('resize', handleResize); + return () => window.removeEventListener('resize', handleResize); + }, []); + + // --- Text highlighting (per-letter, using charTimings) --- + const renderText = () => { + if (!text) return null; + const inputChars = userInput ? userInput.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 + } + } + chars.push( + <span key={`char-${charIndex}`} className={className}>{displayChar}</span> + ); + charIndex++; + } + return <span key={`word-${wIdx}`}>{chars}</span>; + }); + }; + // --- Layout --- + return ( + <div className="end-screen" style={{ maxWidth: 900, margin: '0 auto', padding: '2rem 1rem', minHeight: '100vh', position: 'relative', display: 'flex', flexDirection: 'column', alignItems: 'center', height: '100vh', boxSizing: 'border-box', overflow: 'hidden' }}> + {/* Logo at top, same as TypingGame */} + <div className="logo" onClick={onMainMenu}>TyperPunk</div> + {/* Main content area, all in one flex column, no fixed elements */} + <div style={{ + width: '100%', + flex: '1 0 auto', + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + overflow: 'hidden', + maxHeight: 'calc(100vh - 220px)', + boxSizing: 'border-box', + }}> + {/* Text */} + <div className="end-screen-text" style={{ margin: '0 auto 1.5rem auto', fontSize: '1.25rem', lineHeight: 1.7, maxWidth: 700, width: '100%', display: 'flex', justifyContent: 'center', alignItems: 'center', position: 'relative', background: 'rgba(0,0,0,0.04)', borderRadius: 6, padding: '1rem 1.5rem', textAlign: 'left', wordBreak: 'break-word', height: 'auto' }}> + <div className="text-display" style={{ whiteSpace: 'pre-wrap', textAlign: 'left', width: '100%' }}>{renderText()}</div> + </div> + {/* Desktop: WPM | Graph | ACC */} + {!isMobileScreen && ( + <> + {/* WPM far left, fixed to viewport edge */} + <div style={{ position: 'fixed', left: '2rem', top: '50%', transform: 'translateY(-50%)', zIndex: 10, minWidth: 120, display: 'flex', flexDirection: 'column', alignItems: 'flex-start', justifyContent: 'center' }}> + <div className="end-screen-stat wpm" style={{ textAlign: 'left', alignItems: 'flex-start', justifyContent: 'center', display: 'flex', flexDirection: 'column' }}> + <div className="stat-label" style={{ textAlign: 'left', width: '100%' }}>WPM</div> + <div className="stat-value" style={{ color: '#00ff9d', fontSize: '2.5rem', fontWeight: 700, letterSpacing: '0.05em', lineHeight: 1.1, textAlign: 'left', width: '100%' }}>{Math.round(stats.wpm)}</div> + <div className="stat-label" style={{ fontSize: '0.8rem', color: 'var(--neutral-color)', textAlign: 'left', width: '100%' }}>RAW</div> + <div className="stat-value" style={{ color: 'var(--primary-color)', fontSize: '1.2rem', textAlign: 'left', width: '100%' }}>{Math.round(stats.rawWpm)}</div> + </div> + </div> + {/* ACC far right, fixed to viewport edge */} + <div style={{ position: 'fixed', right: '2rem', top: '50%', transform: 'translateY(-50%)', zIndex: 10, minWidth: 120, display: 'flex', flexDirection: 'column', alignItems: 'flex-end', justifyContent: 'center' }}> + <div className="end-screen-stat acc" style={{ textAlign: 'right', alignItems: 'flex-end', justifyContent: 'center', display: 'flex', flexDirection: 'column' }}> + <div className="stat-label" style={{ textAlign: 'right', width: '100%' }}>ACC</div> + <div className="stat-value" style={{ color: '#00ff9d', fontSize: '2.5rem', fontWeight: 700, letterSpacing: '0.05em', lineHeight: 1.1, textAlign: 'right', width: '100%' }}>{Math.round(stats.accuracy)}%</div> + <div className="stat-label" style={{ fontSize: '0.8rem', color: 'var(--neutral-color)', textAlign: 'right', width: '100%' }}>ERR</div> + <div className="stat-value" style={{ color: 'var(--primary-color)', fontSize: '1.2rem', textAlign: 'right', width: '100%' }}>{stats.incorrectChars}</div> + </div> + </div> + {/* Graph center, take all available space with margin for stats */} + <div style={{ margin: '0 auto 1.5rem auto', width: '100%', maxWidth: 900, display: 'flex', flexDirection: 'column', alignItems: 'center', minWidth: 0 }}> + {graphPoints.length > 0 && ( + <div className="graph-container" style={{ flex: '1 1 0', minWidth: 0, width: '100%', maxWidth: '100%', maxHeight: graphHeight, minHeight: graphHeight, height: graphHeight, margin: '0 auto', position: 'relative', background: 'rgba(0,0,0,0.02)', borderRadius: 8, display: 'flex', alignItems: 'center', justifyContent: 'center', overflow: 'hidden' }}> + <div style={{ width: '100%', display: 'flex', flexDirection: 'column', alignItems: 'center' }}> + <Line data={chartData} options={chartOptions} style={{ width: '100%', height: graphHeight }} /> + </div> + </div> + )} + {/* TIME stat below graph */} + <div className="stat-label" style={{ fontSize: '0.8rem', color: 'var(--neutral-color)', textAlign: 'center', width: '100%', marginTop: 12 }}>TIME</div> + <div className="stat-value" style={{ color: '#00ff9d', fontSize: '2.5rem', fontWeight: 700, letterSpacing: '0.05em', lineHeight: 1.1, textAlign: 'center', width: '100%' }}>{stats.time.toFixed(1)}</div> + </div> + </> + )} + {isMobileScreen && ( + <> + {/* Graph at top, legend centered */} + {graphPoints.length > 0 && ( + <div className="graph-container" style={{ flex: 'none', minWidth: 0, width: '100%', maxWidth: '100%', maxHeight: graphHeight, minHeight: graphHeight, height: graphHeight, margin: '0 auto 0.5rem auto', position: 'relative', background: 'rgba(0,0,0,0.02)', borderRadius: 8, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', overflow: 'hidden' }}> + {/* Center legend above chart by wrapping chart in a flex column */} + <div style={{ width: '100%', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center' }}> + <Line data={chartData} options={chartOptions} style={{ width: '100%', height: graphHeight }} /> + </div> + </div> + )} + {/* WPM, TIME, ACC in a row below graph */} + <div className="end-screen-stats" style={{ + display: 'grid', + gridTemplateColumns: '1fr 1fr 1fr', + alignItems: 'center', + width: '100%', + maxWidth: 700, + margin: '0.5rem auto 0.2rem auto', + gap: '0.3rem', + }}> + {/* WPM (left) */} + <div className="end-screen-stat wpm" style={{ textAlign: 'left', alignItems: 'flex-start', justifyContent: 'center' }}> + <div className="stat-label" style={{ textAlign: 'left', width: '100%' }}>WPM</div> + <div className="stat-value" style={{ color: '#00ff9d', fontSize: '1.5rem', fontWeight: 700, letterSpacing: '0.05em', lineHeight: 1.1, textAlign: 'left', width: '100%' }}>{Math.round(stats.wpm)}</div> + <div className="stat-label" style={{ fontSize: '0.8rem', color: 'var(--neutral-color)', textAlign: 'left', width: '100%' }}>RAW</div> + <div className="stat-value" style={{ color: 'var(--primary-color)', fontSize: '1.2rem', textAlign: 'left', width: '100%' }}>{Math.round(stats.rawWpm)}</div> + </div> + {/* TIME (center, big) */} + <div className="end-screen-stat time" style={{ textAlign: 'center', alignItems: 'center', justifyContent: 'center' }}> + <div className="stat-label" style={{ fontSize: '0.8rem', color: 'var(--neutral-color)', textAlign: 'center', width: '100%' }}>TIME</div> + <div className="stat-value" style={{ color: '#00ff9d', fontSize: '1.5rem', fontWeight: 700, letterSpacing: '0.05em', lineHeight: 1.1, textAlign: 'center', width: '100%' }}>{stats.time.toFixed(1)}</div> + </div> + {/* ACC (right) */} + <div className="end-screen-stat acc" style={{ textAlign: 'right', alignItems: 'flex-end', justifyContent: 'center' }}> + <div className="stat-label" style={{ textAlign: 'right', width: '100%' }}>ACC</div> + <div className="stat-value" style={{ color: '#00ff9d', fontSize: '1.5rem', fontWeight: 700, letterSpacing: '0.05em', lineHeight: 1.1, textAlign: 'right', width: '100%' }}>{Math.round(stats.accuracy)}%</div> + <div className="stat-label" style={{ fontSize: '0.8rem', color: 'var(--neutral-color)', textAlign: 'right', width: '100%' }}>ERR</div> + <div className="stat-value" style={{ color: 'var(--primary-color)', fontSize: '1.2rem', textAlign: 'right', width: '100%' }}>{stats.incorrectChars}</div> + </div> + </div> + {/* Buttons closer to stats */} + <div className="end-screen-buttons" style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '1rem', marginTop: '0.5rem', width: '100%' }}> + <button + className="end-screen-button" + style={{ + width: '100%', + maxWidth: 250, + fontSize: '1rem', + padding: '0.7rem 1.2rem', + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', + }} + onClick={() => { setTimeout(onPlayAgain, 0); }} + > + Play Again + </button> + <button + className="end-screen-button" + style={{ + width: '100%', + maxWidth: 250, + fontSize: '1rem', + padding: '0.7rem 1.2rem', + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', + }} + onClick={() => { setTimeout(onMainMenu, 0); }} + > + Main Menu + </button> + </div> + </> + )} + </div> + {/* Desktop: Move the button row outside the main content and make it fixed at the bottom */} + {!isMobileScreen && ( + <div className="end-screen-buttons" style={{ position: 'fixed', bottom: '5rem', left: '50%', transform: 'translateX(-50%)', display: 'flex', gap: '1.5rem', zIndex: 100, marginBottom: '8rem' }}> + <button + className="end-screen-button" + style={{ + width: 180, + maxWidth: 250, + fontSize: '1.2rem', + padding: '1rem 2rem', + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', + }} + onClick={() => { setTimeout(onPlayAgain, 0); }} + > + Play Again + </button> + <button + className="end-screen-button" + style={{ + width: 180, + maxWidth: 250, + fontSize: '1.2rem', + padding: '1rem 2rem', + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', + }} + onClick={() => { setTimeout(onMainMenu, 0); }} + > + Main Menu + </button> + </div> + )} + {/* Theme button at the bottom, not fixed, match TypingGame */} + <div className="future-modes-placeholder" /> + <button onClick={toggleTheme} className="theme-toggle"> + {theme === Theme.Dark ? ( + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> + <circle cx="12" cy="12" r="5"/> + <line x1="12" y1="1" x2="12" y2="3"/> + <line x1="12" y1="21" x2="12" y2="23"/> + <line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/> + <line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/> + <line x1="1" y1="12" x2="3" y2="12"/> + <line x1="21" y1="12" x2="23" y2="12"/> + <line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/> + <line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/> + </svg> + ) : ( + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> + <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/> + </svg> + )} + </button> + </div> + ); +}; + +export default EndScreen; diff --git a/web/src/components/MainMenu.tsx b/web/src/components/MainMenu.tsx new file mode 100644 index 0000000..b8daea0 --- /dev/null +++ b/web/src/components/MainMenu.tsx @@ -0,0 +1,63 @@ +import React from 'react'; +import { useTheme } from '../contexts/ThemeContext'; +import { Theme } from '../types'; + +interface Props { + onStartGame: () => void; + categories?: string[]; + selectedCategory?: string; + onSelectCategory?: (cat: string) => void; +} + +const MainMenu: React.FC<Props> = ({ onStartGame, categories = [], selectedCategory = 'random', onSelectCategory }) => { + const { theme, toggleTheme } = useTheme(); + + return ( + <div className="main-menu"> + <h1>TyperPunk</h1> + <div className="menu-options"> + <div style={{ marginBottom: '0.75rem' }}> + <label htmlFor="category" style={{ marginRight: 8 }}>Category:</label> + <select + id="category" + value={selectedCategory} + onChange={(e) => onSelectCategory && onSelectCategory(e.target.value)} + style={{ padding: '0.4rem 0.6rem' }} + > + <option value="random">Random</option> + {categories.map((c) => ( + <option key={c} value={c}>{c}</option> + ))} + </select> + </div> + <button className="menu-button" onClick={onStartGame}> + Start Typing Test + </button> + <button className="menu-button" onClick={toggleTheme}> + Toggle {theme === Theme.Dark ? 'Light' : 'Dark'} Mode + </button> + </div> + <button onClick={toggleTheme} className="theme-toggle"> + {theme === Theme.Dark ? ( + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> + <circle cx="12" cy="12" r="5"/> + <line x1="12" y1="1" x2="12" y2="3"/> + <line x1="12" y1="21" x2="12" y2="23"/> + <line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/> + <line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/> + <line x1="1" y1="12" x2="3" y2="12"/> + <line x1="21" y1="12" x2="23" y2="12"/> + <line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/> + <line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/> + </svg> + ) : ( + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> + <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/> + </svg> + )} + </button> + </div> + ); +}; + +export default MainMenu;
\ No newline at end of file diff --git a/web/src/components/ThemeToggle.tsx b/web/src/components/ThemeToggle.tsx new file mode 100644 index 0000000..1d635e9 --- /dev/null +++ b/web/src/components/ThemeToggle.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import '../styles/ThemeToggle.css'; + +interface ThemeToggleProps { + isDark: boolean; + onToggle: () => void; +} + +export const ThemeToggle: React.FC<ThemeToggleProps> = ({ isDark, onToggle }) => { + return ( + <button + className={`theme-toggle ${isDark ? 'dark' : 'light'}`} + onClick={onToggle} + aria-label={`Switch to ${isDark ? 'light' : 'dark'} theme`} + > + <div className="toggle-track"> + <div className="toggle-thumb"> + {isDark ? ( + <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> + <circle cx="12" cy="12" r="5" /> + <path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42" /> + </svg> + ) : ( + <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> + <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" /> + </svg> + )} + </div> + </div> + </button> + ); +};
\ No newline at end of file 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 <div style={{ color: 'red', padding: 32, background: '#111', minHeight: '100vh' }}>TypingGame Error: {this.state.error.message}</div>; + } + return this.props.children; + } +} + +export const TypingGame: React.FC<Props> = 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<Array<{ time: number; wpm: number; raw: number; isError: boolean }>>([]); + const [finalStats, setFinalStats] = useState<Stats | null>(null); + const [finalUserInput, setFinalUserInput] = useState<string>(''); + const [localInput, setLocalInput] = useState<string>(''); + const inputRef = useRef<HTMLInputElement>(null); + const [wasmAccuracy, setWasmAccuracy] = useState<number>(100); + const [wasmMistakes, setWasmMistakes] = useState<number>(0); + const gameRef = useRef(game); + const isInitialized = useRef(false); + const lastInputRef = useRef(''); + const inputQueueRef = useRef<string[]>([]); + const isProcessingQueueRef = useRef(false); + const [charTimings, setCharTimings] = useState<Array<{ time: number; isCorrect: boolean; char: string; index: number }>>([]); + const gameStartTimeRef = useRef<number | null>(null); + const [finalCharTimings, setFinalCharTimings] = useState<Array<{ time: number; isCorrect: boolean; char: string; index: number }>>([]); + // Persistent mistake tracking + const [allMistakes, setAllMistakes] = useState<Array<{ time: number; index: number }>>([]); + const [finalAllMistakes, setFinalAllMistakes] = useState<Array<{ time: number; index: number }>>([]); + // Persistent keypress history tracking + const [keypressHistory, setKeypressHistory] = useState<Array<{ time: number, index: number, isCorrect: boolean }>>([]); + const [finalKeypressHistory, setFinalKeypressHistory] = useState<Array<{ time: number, index: number, isCorrect: boolean }>>([]); + 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<HTMLInputElement>) => { + 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<HTMLInputElement>) => { + 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( + <span key={`char-${charIndex}`} className={className}>{displayChar}</span> + ); + charIndex++; + } + return <span key={`word-${wIdx}`}>{chars}</span>; + }); + }; + + useEffect(() => { + const handleResize = () => setIsMobileScreen(window.innerWidth < 700); + window.addEventListener('resize', handleResize); + return () => window.removeEventListener('resize', handleResize); + }, []); + + if (isFinished && finalStats) { + return ( + <EndScreen + stats={finalStats} + wpmHistory={wpmHistory} + text={text} + charTimings={finalCharTimings} + userInput={finalUserInput} + onPlayAgain={handlePlayAgain} + onMainMenu={handleLogoClick} + keypressHistory={finalKeypressHistory} + /> + ); + } + + return ( + <TypingGameErrorBoundary> + <div className="typing-game" style={{ maxWidth: 900, margin: '0 auto', padding: '2rem 1rem', minHeight: '100vh', position: 'relative', display: 'flex', flexDirection: 'column', alignItems: 'center', height: '100vh', boxSizing: 'border-box', overflow: 'hidden' }}> + {/* Logo at top */} + <div className="logo" onClick={handleLogoClick}>TyperPunk</div> + {/* Main content area */} + <div style={{ + width: '100%', + flex: '1 0 auto', + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + overflow: 'hidden', + maxHeight: 'calc(100vh - 220px)', + boxSizing: 'border-box', + }}> + {/* Text */} + <div className="end-screen-text" style={{ margin: '0 auto 0.5rem auto', fontSize: '1.25rem', lineHeight: 1.7, maxWidth: 700, width: '100%', display: 'flex', justifyContent: 'center', alignItems: 'center', position: 'relative' }}> + <div className="text-display" style={{ whiteSpace: 'pre-wrap', textAlign: 'left', width: '100%' }}>{renderText()}</div> + <input + ref={inputRef} + type="text" + value={localInput} + onChange={handleInput} + onKeyDown={handleKeyDown} + className="typing-input" + autoFocus + onBlur={(e) => { + 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' + }} + /> + </div> + {attribution && ( + <div style={{ maxWidth: 700, width: '100%', margin: '0 auto 1.5rem auto', textAlign: 'right', color: 'var(--neutral-color)', fontSize: '0.9rem' }}> + — {attribution} + </div> + )} + {/* Desktop: WPM | Graph | ACC */} + {!isMobileScreen && ( + <> + {/* WPM far left, fixed to viewport edge */} + <div style={{ position: 'fixed', left: '2rem', top: '50%', transform: 'translateY(-50%)', zIndex: 10, minWidth: 120, display: 'flex', flexDirection: 'column', alignItems: 'flex-start', justifyContent: 'center' }}> + <div className="end-screen-stat wpm" style={{ textAlign: 'left', alignItems: 'flex-start', justifyContent: 'center', display: 'flex', flexDirection: 'column' }}> + <div className="stat-label" style={{ textAlign: 'left', width: '100%' }}>WPM</div> + <div className="stat-value" style={{ color: '#00ff9d', fontSize: '2.5rem', fontWeight: 700, letterSpacing: '0.05em', lineHeight: 1.1, textAlign: 'left', width: '100%' }}>{Math.round(stats.wpm)}</div> + </div> + </div> + {/* ACC far right, fixed to viewport edge */} + <div style={{ position: 'fixed', right: '2rem', top: '50%', transform: 'translateY(-50%)', zIndex: 10, minWidth: 120, display: 'flex', flexDirection: 'column', alignItems: 'flex-end', justifyContent: 'center' }}> + <div className="end-screen-stat acc" style={{ textAlign: 'right', alignItems: 'flex-end', justifyContent: 'center', display: 'flex', flexDirection: 'column' }}> + <div className="stat-label" style={{ textAlign: 'right', width: '100%' }}>ACC</div> + <div className="stat-value" style={{ color: '#00ff9d', fontSize: '2.5rem', fontWeight: 700, letterSpacing: '0.05em', lineHeight: 1.1, textAlign: 'right', width: '100%' }}>{Math.round(wasmAccuracy)}%</div> + </div> + </div> + {/* Graph center, take all available space with margin for stats */} + <div style={{ margin: '0 auto 1.5rem auto', width: '100%', maxWidth: 900, display: 'flex', flexDirection: 'column', alignItems: 'center', minWidth: 0 }}> + <div className="graph-container" style={{ flex: '1 1 0', minWidth: 0, width: '100%', maxWidth: '100%', maxHeight: 220, minHeight: 220, height: 220, margin: '0 auto', position: 'relative', background: 'rgba(0,0,0,0.02)', borderRadius: 8, display: 'flex', alignItems: 'center', justifyContent: 'center', overflow: 'hidden' }} /> + {/* TIME stat below graph */} + <div className="stat-label" style={{ fontSize: '0.8rem', color: 'var(--neutral-color)', textAlign: 'center', width: '100%', marginTop: 12 }}>TIME</div> + <div className="stat-value" style={{ color: '#00ff9d', fontSize: '2.5rem', fontWeight: 700, letterSpacing: '0.05em', lineHeight: 1.1, textAlign: 'center', width: '100%' }}>{stats.time.toFixed(1)}</div> + </div> + </> + )} + {/* Mobile: Graph at top, then WPM & ACC in a row, then TIME below */} + {isMobileScreen && ( + <> + <div className="graph-container" style={{ flex: 'none', minWidth: 0, width: '100%', maxWidth: '100%', maxHeight: 220, minHeight: 220, height: 220, margin: '0 auto 0.5rem auto', position: 'relative', background: 'rgba(0,0,0,0.02)', borderRadius: 8, display: 'flex', alignItems: 'center', justifyContent: 'center', overflow: 'hidden' }} /> + <div className="end-screen-stats" style={{ + display: 'grid', + gridTemplateColumns: '1fr 1fr', + alignItems: 'center', + width: '100%', + maxWidth: 700, + margin: '0.5rem auto 0.2rem auto', + gap: '0.3rem', + }}> + {/* WPM (left) */} + <div className="end-screen-stat wpm" style={{ textAlign: 'left', alignItems: 'flex-start', justifyContent: 'center' }}> + <div className="stat-label" style={{ textAlign: 'left', width: '100%' }}>WPM</div> + <div className="stat-value" style={{ color: '#00ff9d', fontSize: '1.5rem', fontWeight: 700, letterSpacing: '0.05em', lineHeight: 1.1, textAlign: 'left', width: '100%' }}>{Math.round(stats.wpm)}</div> + </div> + {/* ACC (right) */} + <div className="end-screen-stat acc" style={{ textAlign: 'right', alignItems: 'flex-end', justifyContent: 'center' }}> + <div className="stat-label" style={{ textAlign: 'right', width: '100%' }}>ACC</div> + <div className="stat-value" style={{ color: '#00ff9d', fontSize: '1.5rem', fontWeight: 700, letterSpacing: '0.05em', lineHeight: 1.1, textAlign: 'right', width: '100%' }}>{Math.round(wasmAccuracy)}%</div> + </div> + </div> + {/* TIME stat below WPM/ACC */} + <div className="stat-label" style={{ fontSize: '0.8rem', color: 'var(--neutral-color)', textAlign: 'center', width: '100%', marginTop: 8 }}>TIME</div> + <div className="stat-value" style={{ color: '#00ff9d', fontSize: '1.5rem', fontWeight: 700, letterSpacing: '0.05em', lineHeight: 1.1, textAlign: 'center', width: '100%' }}>{stats.time.toFixed(1)}</div> + </> + )} + </div> + {/* Empty space for future game modes, matches EndScreen */} + <div className="future-modes-placeholder" /> + <button onClick={toggleTheme} className="theme-toggle"> + {theme === Theme.Dark ? ( + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> + <circle cx="12" cy="12" r="5"/> + <line x1="12" y1="1" x2="12" y2="3"/> + <line x1="12" y1="21" x2="12" y2="23"/> + <line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/> + <line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/> + <line x1="1" y1="12" x2="3" y2="12"/> + <line x1="21" y1="12" x2="23" y2="12"/> + <line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/> + <line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/> + </svg> + ) : ( + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> + <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/> + </svg> + )} + </button> + </div> + </TypingGameErrorBoundary> + ); +});
\ No newline at end of file diff --git a/web/src/contexts/ThemeContext.tsx b/web/src/contexts/ThemeContext.tsx new file mode 100644 index 0000000..f876269 --- /dev/null +++ b/web/src/contexts/ThemeContext.tsx @@ -0,0 +1,63 @@ +import React, { createContext, useContext, useState, useEffect } from 'react'; +import { Theme, ThemeColors } from '../types'; + +interface ThemeContextType { + theme: Theme; + colors: ThemeColors; + toggleTheme: () => void; +} + +const lightColors: ThemeColors = { + primary: '#00ff9d', + secondary: '#00cc8f', + background: '#ffffff', + text: '#333333', + error: '#ca4754', + success: '#2ecc71' +}; + +const darkColors: ThemeColors = { + primary: '#00ff9d', + secondary: '#00cc8f', + background: '#000000', + text: '#646669', + error: '#ef5350', + success: '#66bb6a' +}; + +const ThemeContext = createContext<ThemeContextType | undefined>(undefined); + +export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const [theme, setTheme] = useState<Theme>(() => { + const savedTheme = localStorage.getItem('theme'); + return (savedTheme as Theme) || Theme.Dark; + }); + + const colors = theme === Theme.Light ? lightColors : darkColors; + + useEffect(() => { + localStorage.setItem('theme', theme); + document.documentElement.setAttribute('data-theme', theme.toLowerCase()); + if (!window.location.pathname.includes('typing-game')) { + document.body.style.backgroundColor = theme === Theme.Light ? '#ffffff' : '#000000'; + } + }, [theme]); + + const toggleTheme = () => { + setTheme(prevTheme => prevTheme === Theme.Light ? Theme.Dark : Theme.Light); + }; + + return ( + <ThemeContext.Provider value={{ theme, colors, toggleTheme }}> + {children} + </ThemeContext.Provider> + ); +}; + +export const useTheme = () => { + const context = useContext(ThemeContext); + if (context === undefined) { + throw new Error('useTheme must be used within a ThemeProvider'); + } + return context; +};
\ No newline at end of file diff --git a/web/src/hooks/useCoreGame.ts b/web/src/hooks/useCoreGame.ts new file mode 100644 index 0000000..3aa69ec --- /dev/null +++ b/web/src/hooks/useCoreGame.ts @@ -0,0 +1,80 @@ +import { useEffect, useRef, useState } from 'react'; +import init, { TyperPunkGame as Game } from '@typerpunk/wasm'; + +export function useCoreGame() { + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState<string | null>(null); + const gameRef = useRef<Game | null>(null); + + useEffect(() => { + let mounted = true; + + const initGame = async () => { + try { + await init(); + if (!mounted) return; + + const game = new Game(); + gameRef.current = game; + setIsLoading(false); + } catch (err) { + console.error('Failed to initialize game:', err); + if (mounted) { + setError('Failed to initialize game'); + setIsLoading(false); + } + } + }; + + initGame(); + + return () => { + mounted = false; + if (gameRef.current) { + try { + gameRef.current.free(); + } catch (err) { + console.error('Error cleaning up game:', err); + } + gameRef.current = null; + } + }; + }, []); + + const resetGame = async () => { + if (gameRef.current) { + try { + gameRef.current.free(); + } catch (err) { + console.error('Error freeing old game:', err); + } + } + + try { + const game = new Game(); + gameRef.current = game; + } catch (err) { + console.error('Error resetting game:', err); + setError('Failed to reset game'); + } + }; + + const cleanupGame = () => { + if (gameRef.current) { + try { + gameRef.current.free(); + } catch (err) { + console.error('Error cleaning up game:', err); + } + gameRef.current = null; + } + }; + + return { + game: gameRef.current, + isLoading, + error, + resetGame, + cleanupGame + }; +}
\ No newline at end of file diff --git a/web/src/hooks/useTheme.ts b/web/src/hooks/useTheme.ts new file mode 100644 index 0000000..a5457b6 --- /dev/null +++ b/web/src/hooks/useTheme.ts @@ -0,0 +1,21 @@ +import { useState, useEffect } from 'react'; +import { Theme } from '../types'; + +export const useTheme = () => { + const [theme, setTheme] = useState<Theme>(() => { + const savedTheme = localStorage.getItem('theme'); + return (savedTheme as Theme) || Theme.Light; + }); + + useEffect(() => { + localStorage.setItem('theme', theme); + document.documentElement.classList.remove(Theme.Light, Theme.Dark); + document.documentElement.classList.add(theme); + }, [theme]); + + const toggleTheme = () => { + setTheme(prev => prev === Theme.Light ? Theme.Dark : Theme.Light); + }; + + return { theme, toggleTheme }; +};
\ No newline at end of file diff --git a/web/src/main.tsx b/web/src/main.tsx new file mode 100644 index 0000000..f987afc --- /dev/null +++ b/web/src/main.tsx @@ -0,0 +1,13 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import App from './App'; +import { ThemeProvider } from './contexts/ThemeContext'; +import './styles.css'; + +ReactDOM.createRoot(document.getElementById('root')!).render( + <React.StrictMode> + <ThemeProvider> + <App /> + </ThemeProvider> + </React.StrictMode> +);
\ No newline at end of file diff --git a/web/src/styles.css b/web/src/styles.css new file mode 100644 index 0000000..d9130b4 --- /dev/null +++ b/web/src/styles.css @@ -0,0 +1,910 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +:root { + --primary-color: #00ff9d; + --secondary-color: #00cc8f; + --background-color: #000000; + --text-color: #646669; + --error-color: #ca4754; + --correct-color: #00ff9d; + --neutral-color: #646669; + --caret-color: #00ff9d; + --sub-color: #646669; +} + +[data-theme="light"] { + --primary-color: #00ff9d; + --secondary-color: #00cc8f; + --background-color: #ffffff; + --text-color: #333333; + --error-color: #ca4754; + --correct-color: #00ff9d; + --neutral-color: #646669; + --caret-color: #00ff9d; + --sub-color: #646669; +} + +[data-theme="dark"] { + --primary-color: #00ff9d; + --secondary-color: #00cc8f; + --background-color: #000000; + --text-color: #646669; + --error-color: #ca4754; + --correct-color: #00ff9d; + --neutral-color: #646669; + --caret-color: #00ff9d; + --sub-color: #646669; +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + margin: 0; + padding: 0; + font-family: 'JetBrains Mono', monospace; + background-color: var(--background-color); + color: var(--text-color); + height: 100vh; + overflow: hidden; +} + +.app { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100vh; + padding: 1rem; + overflow: hidden; +} + +.logo { + position: fixed; + top: 1rem; + left: 1rem; + font-size: 1.2rem; + color: var(--primary-color); + font-family: 'JetBrains Mono', monospace; + text-transform: uppercase; + letter-spacing: 2px; + cursor: pointer; + transition: color 0.2s ease; +} + +.logo:hover { + color: var(--secondary-color); +} + +/* Main Menu */ +.main-menu { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 100vh; + width: 100%; + padding: 2rem; +} + +.main-menu h1 { + font-size: 4rem; + color: var(--primary-color); + margin-bottom: 2rem; + text-transform: uppercase; + letter-spacing: 4px; +} + +.menu-options { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.menu-button { + background: none; + border: 2px solid var(--primary-color); + color: var(--text-color); + padding: 1rem 2rem; + font-size: 1.2rem; + cursor: pointer; + transition: all 0.2s ease; + font-family: inherit; + text-transform: uppercase; + letter-spacing: 2px; + min-width: 250px; +} + +.menu-button:hover { + background-color: var(--primary-color); + color: var(--background-color); +} + +/* Typing Game */ +.typing-game { + display: flex; + flex-direction: column; + align-items: center; + width: 100%; + max-width: 800px; + margin: 0 auto; + padding: 1rem; + position: relative; + height: 100vh; + overflow: hidden; +} + +.text-container { + position: relative; + width: 100%; + max-width: 800px; + margin: 0 auto; + padding: 1rem 2rem 0 2rem; + margin-top: 2.5rem; + display: flex; + justify-content: center; + align-items: flex-start; +} + +.text-display { + font-family: 'JetBrains Mono', monospace; + font-size: 1.5rem; + line-height: 1.5; + min-height: 6rem; + color: var(--text-color); + position: relative; + white-space: pre-wrap; + word-break: keep-all; + overflow-wrap: break-word; + width: fit-content; + max-width: 800px; + tab-size: 4; + hyphens: none; + text-align: left; + padding: 1rem; +} + +.text-display span { + white-space: pre; + position: relative; + display: inline-block; +} + +.text-display span.correct { + color: var(--correct-color); +} + +.text-display span.incorrect { + color: var(--error-color); +} + +.text-display span.neutral { + color: var(--neutral-color); +} + +.text-display span.current { + position: relative; +} + +.text-display span.current::after { + content: '▏'; + position: absolute; + left: 0; + top: 0; + bottom: 0; + color: var(--caret-color); + animation: blink 1s step-end infinite; +} + +.typing-input { + position: relative; + width: 100%; + opacity: 1 !important; + z-index: 1; + cursor: text; + caret-color: auto !important; + background: transparent !important; + color: transparent !important; + border: none !important; + outline: none !important; + box-shadow: none !important; +} + +.typing-input:focus { + background: transparent !important; + color: transparent !important; + border: none !important; + outline: none !important; + box-shadow: none !important; +} + +.theme-toggle { + position: fixed; + top: 1rem; + right: 1rem; + background: rgba(255,255,255,0.08); + border: none !important; + outline: none !important; + box-shadow: 0 2px 12px 0 rgba(0,0,0,0.12), 0 1.5px 4px 0 rgba(0,0,0,0.10); + color: var(--primary-color); + padding: 0.7rem 1rem; + font-size: 1.2rem; + cursor: pointer; + transition: all 0.2s cubic-bezier(.4,0,.2,1); + display: flex; + align-items: center; + gap: 0.5rem; + z-index: 100; + border-radius: 1.5rem; + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); +} + +.theme-toggle:focus, +.theme-toggle:active { + border: none !important; + outline: none !important; + box-shadow: 0 4px 16px 0 rgba(0,0,0,0.18); + background: rgba(255,255,255,0.16); +} + +.theme-toggle:hover { + color: var(--secondary-color); + background: rgba(255,255,255,0.18); + box-shadow: 0 6px 24px 0 rgba(0,0,0,0.18); + transform: scale(1.06); +} + +.theme-toggle svg { + width: 1.5rem; + height: 1.5rem; + transition: color 0.2s, filter 0.2s; + filter: drop-shadow(0 1px 2px rgba(0,0,0,0.10)); +} + +@keyframes blink { + 0%, 100% { opacity: 1; } + 50% { opacity: 0; } +} + +/* Mobile adjustments */ +@media (max-width: 768px) { + .main-menu h1 { + font-size: 2.5rem; + margin-bottom: 1.5rem; + } + + .menu-button { + padding: 0.8rem 1.5rem; + font-size: 1rem; + min-width: 200px; + } + + .typing-game { + padding: 1rem; + } + + .text-container { + padding: 1rem; + margin-top: 5rem; + } + + .stats-container { + padding: 0 1rem; + } + + .wpm-stat { + left: 1rem; + top: 50%; + } + + .acc-stat { + right: 1rem; + top: 50%; + } + + .time-stat { + bottom: 0.5rem; + font-size: 1rem; + } + + .text-display { + font-size: 1.2rem; + } + + .end-screen-stats { + gap: 1rem; + padding: 0 1rem; + } + + .end-screen-stat { + padding: 0.5rem; + } + + .end-screen-stat-value { + font-size: 1.5rem; + } + + .graph-container { + height: 200px; + padding: 1rem; + } + + .graph-axis.x { + left: 1rem; + right: 1rem; + } + + .graph-axis.y { + bottom: 1rem; + } + + .end-screen-buttons { + bottom: 1rem; + } +} + +@media (max-height: 600px) { + .stats-container { + top: 0.5rem; + } + + .wpm-stat { + top: 50%; + } + + .acc-stat { + top: 50%; + } + + .time-stat { + bottom: 0.25rem; + font-size: 0.9rem; + } + + .text-container { + margin-top: 4.5rem; + padding: 1rem; + } + + .text-display { + font-size: 1.2rem; + min-height: 4.5rem; + } + + .end-screen-stats { + gap: 0.75rem; + margin: 1rem 0; + } + + .end-screen-stat { + padding: 0.25rem; + } + + .end-screen-stat-value { + font-size: 1.2rem; + } + + .graph-container { + height: 200px; + } +} + +@media (max-width: 600px) { + .typing-game { + padding: 0.5rem; + height: 100vh; + min-width: 0; + } + .text-container { + padding: 0.5rem 0.5rem 0 0.5rem; + margin-top: 1.5rem; + min-width: 0; + } + .text-display { + font-size: 1rem; + padding: 0.5rem; + min-height: 3rem; + max-width: 100vw; + word-break: break-word; + } +} + +.loading { + display: flex; + align-items: center; + justify-content: center; + min-height: 100vh; + font-size: 1.5rem; + color: var(--primary-color); + font-family: 'JetBrains Mono', monospace; + text-transform: uppercase; + letter-spacing: 2px; +} + +#root { + display: flex; + flex-direction: column; + min-height: 100vh; +} + +/* Typing Game */ +.stats-bar { + display: none; +} + +.end-screen { + display: flex; + flex-direction: column; + align-items: center; + justify-content: flex-start; + width: 100%; + max-width: 800px; + margin: 0 auto; + padding: 2rem; + padding-top: 6rem; + min-height: 100vh; + position: relative; +} + +.end-screen-stats { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 2rem; + width: 100%; + margin-bottom: 3rem; +} + +.end-screen-stat { + text-align: center; + padding: 1.5rem; + transition: transform 0.2s ease; +} + +.end-screen-stat.wpm { + grid-column: 1; + grid-row: 1; + text-align: left; +} + +.end-screen-stat.errors { + grid-column: 3; + grid-row: 1; + text-align: right; +} + +.end-screen-stat.time { + grid-column: 2; + grid-row: 1; + text-align: center; +} + +.end-screen-stat:hover { + transform: translateY(-2px); +} + +.end-screen-stat-label { + font-size: 0.8rem; + color: var(--text-color); + text-transform: uppercase; + letter-spacing: 2px; + margin-bottom: 0.5rem; +} + +.end-screen-stat-value { + font-size: 2.5rem; + color: var(--primary-color); + font-weight: bold; +} + +.end-screen-buttons { + position: fixed; + bottom: 2rem; + left: 50%; + transform: translateX(-50%); + display: flex; + gap: 1.5rem; +} + +.end-screen-button { + background: none; + border: 2px solid var(--primary-color); + color: var(--text-color); + padding: 1rem 2rem; + font-size: 1.2rem; + cursor: pointer; + transition: all 0.2s ease; + font-family: inherit; + text-transform: uppercase; + letter-spacing: 2px; + min-width: 250px; + border-radius: 8px; +} + +.end-screen-button:hover { + background-color: var(--primary-color); + color: var(--background-color); + transform: translateY(-2px); +} + +/* Character styling */ +.correct { + color: var(--correct-color); +} + +.incorrect { + color: var(--error-color); + text-decoration: underline; +} + +.current { + background-color: rgba(0, 0, 0, 0.1); + border-radius: 2px; +} + +.error-screen { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 100vh; + padding: 2rem; + text-align: center; +} + +.error-screen h2 { + margin-bottom: 1rem; +} + +.error-screen p { + margin-bottom: 2rem; +} + +.stats-container { + position: fixed; + top: 1rem; + left: 0; + right: 0; + display: flex; + justify-content: space-between; + width: 100%; + padding: 0 2rem; + z-index: 10; +} + +.wpm-stat { + position: fixed; + left: 2rem; + top: 50%; + transform: translateY(-50%); + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + width: 6rem; +} + +.acc-stat { + position: fixed; + right: 2rem; + top: 50%; + transform: translateY(-50%); + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + width: 6rem; +} + +.time-stat { + position: fixed; + left: 50%; + transform: translateX(-50%); + bottom: 1rem; + z-index: 100; +} + +.stat-label { + font-size: 0.8rem; + color: var(--text-color); + text-transform: uppercase; + letter-spacing: 2px; + margin-bottom: 0.5rem; +} + +.stat-value { + font-size: 2rem; + color: var(--primary-color); + font-weight: bold; +} + +.graph-container { + width: 100%; + height: 300px; + margin: 2rem 0; + position: relative; + padding: 2rem; + margin-top: 6rem; +} + +.graph-container canvas { + width: 100%; + height: 100%; +} + +.graph-axis { + position: absolute; + color: var(--text-color); + font-size: 0.8rem; + font-family: 'JetBrains Mono', monospace; +} + +.graph-axis.x { + bottom: 0; + left: 2rem; + right: 2rem; + display: flex; + justify-content: space-between; + padding: 0.5rem 0; +} + +.graph-axis.y { + top: 0; + bottom: 2rem; + left: 0; + display: flex; + flex-direction: column; + justify-content: space-between; + padding: 0 0.5rem; + width: 3rem; + text-align: right; +} + +body, .app, #root { + overflow-x: hidden; + scrollbar-width: none; /* Firefox */ +} +body::-webkit-scrollbar, .app::-webkit-scrollbar, #root::-webkit-scrollbar { + display: none; /* Chrome, Safari, Opera */ +} + +.future-modes-placeholder { + width: 100%; + min-height: 3rem; + background: transparent; + margin-top: 1.5rem; +} + +.end-screen-text { + width: 100%; + max-width: 800px; + margin: 0 auto 2rem auto; + font-family: 'JetBrains Mono', monospace; + font-size: 1.2rem; + color: var(--text-color); + background: rgba(0,0,0,0.04); + border-radius: 6px; + padding: 1rem 1.5rem; + text-align: left; + word-break: break-word; +} + +.end-screen-graph-row { + display: flex; + flex-direction: row; + align-items: stretch; + justify-content: space-between; + width: 100%; + max-width: 900px; + margin: 0 auto 1.5rem auto; +} + +.end-screen-stat.wpm { + flex: 0 0 120px; + text-align: left; + display: flex; + flex-direction: column; + justify-content: flex-start; + align-items: flex-start; + margin-right: 1.5rem; +} + +.end-screen-stat.errors { + flex: 0 0 120px; + text-align: right; + display: flex; + flex-direction: column; + justify-content: flex-start; + align-items: flex-end; + margin-left: 1.5rem; +} + +.graph-container { + flex: 1 1 0; + min-width: 0; + margin: 0; + padding: 2rem 0; + height: 300px; + position: relative; + background: rgba(0,0,0,0.02); + border-radius: 8px; +} + +.end-screen-graph-row.end-screen-time-row { + display: flex; + justify-content: center; + align-items: flex-start; + margin: 0 auto 2rem auto; +} + +.end-screen-stat.time { + text-align: center; + margin: 0 auto; + font-size: 1.1rem; +} + +.end-screen-rawwpm { + width: 100%; + max-width: 900px; + margin: 0 auto 2rem auto; + display: flex; + flex-direction: row; + justify-content: center; + align-items: center; + gap: 1.5rem; + font-size: 1.3rem; +} +.end-screen-rawwpm .stat-label { + font-size: 1rem; + color: var(--text-color); + text-transform: uppercase; + letter-spacing: 2px; + margin-right: 0.5rem; +} +.end-screen-rawwpm .stat-value { + font-size: 2rem; + color: var(--primary-color); + font-weight: bold; +} + +.end-screen-stat.acc { + flex: 0 0 120px; + text-align: right; + display: flex; + flex-direction: column; + justify-content: flex-start; + align-items: flex-end; + margin-left: 1.5rem; +} + +.end-screen-time { + width: 100%; + max-width: 900px; + margin: 0 auto; + text-align: center; + padding: 1rem 0; +} + +.end-screen-time .stat-label { + font-size: 0.9rem; + margin-bottom: 0.3rem; +} + +.end-screen-time .stat-value { + font-size: 1.5rem; +} + +@media (max-width: 900px) { + .end-screen-graph-row { + flex-direction: column !important; + align-items: stretch !important; + max-width: 100vw !important; + position: relative !important; + } + .endscreen-side-stat { + position: static !important; + left: unset !important; + right: unset !important; + top: unset !important; + transform: none !important; + width: 100% !important; + margin-bottom: 0.5rem !important; + z-index: 10; + display: flex !important; + flex-direction: row !important; + justify-content: space-between !important; + align-items: center !important; + } + .graph-container { + max-width: 100vw !important; + min-width: 0 !important; + width: 100% !important; + overflow-x: auto !important; + margin: 0 auto 1rem auto !important; + height: 160px !important; + } + .end-screen-buttons { + position: static !important; + left: unset !important; + bottom: unset !important; + transform: none !important; + width: 100% !important; + margin: 1.5rem 0 0 0 !important; + flex-direction: column !important; + gap: 1rem !important; + z-index: 10; + } + .end-screen-time { + position: static !important; + left: unset !important; + transform: none !important; + bottom: unset !important; + width: 100% !important; + margin-top: 1rem !important; + } +} + +@media (max-width: 600px) { + .end-screen-graph-row { + padding: 0 0.5rem !important; + } + .graph-container { + height: 180px !important; + padding: 0.5rem !important; + } + .end-screen-stat.wpm, .end-screen-stat.acc { + font-size: 1rem !important; + padding: 0.5rem !important; + } + .end-screen-time .stat-value { + font-size: 1.2rem !important; + } +} + +@media (max-width: 900px) { + .text-container { + padding: 0.5rem !important; + min-height: 120px !important; + } +} +@media (max-width: 600px) { + .text-container { + min-height: 80px !important; + font-size: 1rem !important; + } +} + +@media (max-width: 700px) { + body, #root, .app, .end-screen { + overflow-y: auto !important; + overflow-x: hidden !important; + } + .end-screen { + min-height: 0 !important; + height: auto !important; + padding-top: 1.5rem !important; + padding-bottom: 0.5rem !important; + display: flex !important; + flex-direction: column !important; + } + .end-screen-buttons { + margin-top: auto !important; + margin-bottom: 0 !important; + } + .end-screen-main-content { + flex: 1 0 auto !important; + width: 100% !important; + display: flex !important; + flex-direction: column !important; + } + .end-screen-buttons { + margin-top: auto !important; + margin-bottom: 0 !important; + width: 100% !important; + } +}
\ No newline at end of file diff --git a/web/src/styles/ThemeToggle.css b/web/src/styles/ThemeToggle.css new file mode 100644 index 0000000..818a1ba --- /dev/null +++ b/web/src/styles/ThemeToggle.css @@ -0,0 +1,70 @@ +.theme-toggle { + position: fixed; + top: 1rem; + right: 1rem; + background: none; + border: none; + padding: 0.5rem; + cursor: pointer; + z-index: 1000; + transition: transform 0.3s ease; +} + +.theme-toggle:hover { + transform: scale(1.1); +} + +.toggle-track { + width: 3rem; + height: 1.5rem; + background-color: var(--background-color); + border: 2px solid var(--primary-color); + border-radius: 1rem; + position: relative; + transition: background-color 0.3s ease; +} + +.toggle-thumb { + width: 1.2rem; + height: 1.2rem; + background-color: var(--primary-color); + border-radius: 50%; + position: absolute; + top: 0.1rem; + left: 0.1rem; + transition: transform 0.3s ease; + display: flex; + align-items: center; + justify-content: center; +} + +.theme-toggle.dark .toggle-thumb { + transform: translateX(1.5rem); +} + +.theme-toggle svg { + width: 1rem; + height: 1rem; + color: var(--background-color); +} + +@media (max-width: 768px) { + .theme-toggle { + top: 0.5rem; + right: 0.5rem; + } + + .toggle-track { + width: 2.5rem; + height: 1.2rem; + } + + .toggle-thumb { + width: 1rem; + height: 1rem; + } + + .theme-toggle.dark .toggle-thumb { + transform: translateX(1.2rem); + } +}
\ No newline at end of file diff --git a/web/src/styles/TypingGame.css b/web/src/styles/TypingGame.css new file mode 100644 index 0000000..e5f5c8e --- /dev/null +++ b/web/src/styles/TypingGame.css @@ -0,0 +1,19 @@ +.space { + color: #666; + opacity: 0.7; +} + +.space.correct { + color: #4CAF50; + opacity: 1; +} + +.space.incorrect { + color: #f44336; + opacity: 1; +} + +.space.current { + background-color: rgba(255, 255, 255, 0.1); + border-radius: 2px; +}
\ No newline at end of file diff --git a/web/src/types.ts b/web/src/types.ts new file mode 100644 index 0000000..07871c2 --- /dev/null +++ b/web/src/types.ts @@ -0,0 +1,54 @@ +export type Screen = 'main-menu' | 'typing-game' | 'end-screen'; + +export interface Stats { + wpm: number; + rawWpm: number; + accuracy: number; + time: number; + correctChars: number; + incorrectChars: number; + totalChars: number; + currentStreak: number; + bestStreak: number; +} + +export interface GameState { + screen: Screen; + currentText: string; + currentAttribution?: string; + input: string; + startTime: number | null; + isRunning: boolean; + stats: Stats; +} + +export enum Theme { + Light = 'light', + Dark = 'dark' +} + +export interface ThemeColors { + primary: string; + secondary: string; + background: string; + text: string; + error: string; + success: string; +} + +export interface TyperPunkGame { + handle_input(input: string): void; + handle_backspace(ctrl: boolean): boolean; + get_stats(): [number, number]; + get_stats_and_input(): [string, number, number]; + is_finished(): boolean; + get_text(): string; + get_input(): string; + set_text(text: string): void; + start(): void; + get_wpm(): number; + get_time_elapsed(): number; + get_raw_wpm(): number; +} + +export type TyperPunk = TyperPunkGame;
\ No newline at end of file diff --git a/web/src/types/typerpunk.d.ts b/web/src/types/typerpunk.d.ts new file mode 100644 index 0000000..a21ddd5 --- /dev/null +++ b/web/src/types/typerpunk.d.ts @@ -0,0 +1,27 @@ +declare module 'typerpunk' { + export class TyperPunkGame { + free(): void; + set_text(text: string): void; + get_text(): string; + get_input(): string; + start(): void; + handle_input(input: string): void; + handle_backspace(is_word_deletion: boolean): boolean; + is_finished(): boolean; + get_error_positions(): Uint32Array; + get_current_streak(): number; + get_best_streak(): number; + get_theme(): string; + set_theme(theme: string): void; + get_wpm(): number; + get_accuracy(): number; + get_time_elapsed(): number; + get_raw_wpm(): number; + can_backspace(): boolean; + can_ctrl_backspace(): boolean; + handle_backspace(ctrl: boolean): boolean; + get_total_mistakes(): number; + } + + export default function init(): Promise<void>; +}
\ No newline at end of file diff --git a/web/src/wasm.d.ts b/web/src/wasm.d.ts new file mode 100644 index 0000000..6692774 --- /dev/null +++ b/web/src/wasm.d.ts @@ -0,0 +1,25 @@ +export class TyperPunkGame { + constructor(text: string); + handle_input(input: string): Result<void, string>; + handle_backspace(ctrl: boolean): Result<boolean, string>; + get_stats(): Result<[number, number], string>; + get_stats_and_input(): Result<[string, number, number], string>; + is_finished(): boolean; + can_backspace_to_position(position: number): boolean; + get_current_word_start(): number; + set_text(text: string): Result<void, string>; + get_text(): string; + get_input(): string; + start(): void; + get_wpm(): number; + get_time_elapsed(): number; + get_raw_wpm(): number; + free(): void; +} + +export type Result<T, E> = { + isOk(): boolean; + isErr(): boolean; + unwrap(): T; + unwrapErr(): E; +};
\ No newline at end of file diff --git a/web/tailwind.config.js b/web/tailwind.config.js new file mode 100644 index 0000000..8a2477d --- /dev/null +++ b/web/tailwind.config.js @@ -0,0 +1,19 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: [ + "./index.html", + "./src/**/*.{js,ts,jsx,tsx}", + ], + theme: { + extend: { + colors: { + primary: 'var(--primary)', + background: 'var(--background)', + text: 'var(--text)', + error: 'var(--error)', + success: 'var(--success)', + }, + }, + }, + plugins: [], +}
\ No newline at end of file diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 0000000..ac0731c --- /dev/null +++ b/web/tsconfig.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "allowJs": true, + "esModuleInterop": true, + "baseUrl": ".", + "paths": { + "@/*": ["src/*"], + "@typerpunk/wasm": ["../crates/wasm/pkg"] + } + }, + "include": ["src", "../crates/wasm/pkg"], + "references": [{ "path": "./tsconfig.node.json" }] +}
\ No newline at end of file diff --git a/web/tsconfig.node.json b/web/tsconfig.node.json new file mode 100644 index 0000000..862dfb2 --- /dev/null +++ b/web/tsconfig.node.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true + }, + "include": ["vite.config.ts"] +}
\ No newline at end of file diff --git a/web/vite.config.ts b/web/vite.config.ts new file mode 100644 index 0000000..b0c5a26 --- /dev/null +++ b/web/vite.config.ts @@ -0,0 +1,43 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import path from 'path'; +import wasm from 'vite-plugin-wasm'; +import topLevelAwait from 'vite-plugin-top-level-await'; + +export default defineConfig({ + plugins: [ + react(), + wasm(), + topLevelAwait() + ], + resolve: { + alias: { + '@': path.resolve(__dirname, 'src'), + '@typerpunk/wasm': path.resolve(__dirname, '../crates/wasm/pkg') + } + }, + server: { + port: 3000, + fs: { + allow: [ + path.resolve(__dirname, 'src'), + path.resolve(__dirname, 'node_modules'), + path.resolve(__dirname, '../crates/wasm/pkg'), + path.resolve(__dirname, '../crates/wasm/target'), + ] + } + }, + optimizeDeps: { + exclude: ['@typerpunk/wasm'] + }, + build: { + target: 'esnext', + rollupOptions: { + output: { + manualChunks: { + 'wasm': ['@typerpunk/wasm'] + } + } + } + } +});
\ No newline at end of file |
