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/src/components | |
| parent | 76f0d0e902e6ed164704572bd81faa5e5e560cf3 (diff) | |
| download | typerpunk-8d60c7f93407988ee0232ea90980028f299cb0f3.tar.gz typerpunk-8d60c7f93407988ee0232ea90980028f299cb0f3.zip | |
Initial Commit
Diffstat (limited to 'web/src/components')
| -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 |
4 files changed, 1372 insertions, 0 deletions
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 |
