wpmx 1.0.2 → 1.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/settings.local.json +6 -0
- package/package.json +1 -1
- package/src/app.tsx +7 -4
- package/src/components/Game.tsx +138 -106
- package/src/hooks/useGame.ts +104 -85
- package/video/src/CLAUDE.md +0 -7
- package/video/src/WpmxDemo.tsx +0 -46
- package/video/src/components/CLAUDE.md +0 -7
- package/video/src/scenes/CLAUDE.md +0 -22
- package/video/src/scenes/GameScene.tsx +0 -193
- package/video/src/scenes/InstallScene.tsx +0 -48
- package/video/src/scenes/MenuScene.tsx +0 -78
- package/video/src/scenes/ResultsScene.tsx +0 -112
package/package.json
CHANGED
package/src/app.tsx
CHANGED
|
@@ -21,9 +21,12 @@ export type GameResults = {
|
|
|
21
21
|
|
|
22
22
|
export function App() {
|
|
23
23
|
const { exit } = useApp();
|
|
24
|
-
const
|
|
24
|
+
const handleQuit = useCallback(() => {
|
|
25
|
+
exit();
|
|
26
|
+
process.exit(0);
|
|
27
|
+
}, [exit]);
|
|
25
28
|
const [screen, setScreen] = useState<Screen>("menu");
|
|
26
|
-
const [duration, setDuration] = useState<Duration>(
|
|
29
|
+
const [duration, setDuration] = useState<Duration>(() => loadSettings().lastDuration);
|
|
27
30
|
const [results, setResults] = useState<GameResults | null>(null);
|
|
28
31
|
const [gameKey, setGameKey] = useState(0);
|
|
29
32
|
|
|
@@ -58,7 +61,7 @@ export function App() {
|
|
|
58
61
|
}, []);
|
|
59
62
|
|
|
60
63
|
if (screen === "menu") {
|
|
61
|
-
return <Menu onStart={handleStart} onQuit={
|
|
64
|
+
return <Menu onStart={handleStart} onQuit={handleQuit} defaultDuration={duration} />;
|
|
62
65
|
}
|
|
63
66
|
|
|
64
67
|
if (screen === "game") {
|
|
@@ -81,7 +84,7 @@ export function App() {
|
|
|
81
84
|
personalBest={pb}
|
|
82
85
|
onRestart={handleRestart}
|
|
83
86
|
onMenu={handleMenu}
|
|
84
|
-
onQuit={
|
|
87
|
+
onQuit={handleQuit}
|
|
85
88
|
/>
|
|
86
89
|
);
|
|
87
90
|
}
|
package/src/components/Game.tsx
CHANGED
|
@@ -1,33 +1,15 @@
|
|
|
1
|
-
import { Box, Text, useInput } from "ink";
|
|
1
|
+
import { Box, Text, useInput, useStdout } from "ink";
|
|
2
|
+
import type { Key } from "ink";
|
|
2
3
|
import { useGame, type GameResults } from "../hooks/useGame.ts";
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
</Text>
|
|
13
|
-
);
|
|
14
|
-
})}
|
|
15
|
-
{inputChars.length > word.length &&
|
|
16
|
-
inputChars
|
|
17
|
-
.slice(word.length)
|
|
18
|
-
.map((char, i) => (
|
|
19
|
-
<Text key={`extra-${i}`} color="red" strikethrough>
|
|
20
|
-
{char}
|
|
21
|
-
</Text>
|
|
22
|
-
))}
|
|
23
|
-
</Box>
|
|
24
|
-
));
|
|
25
|
-
|
|
26
|
-
const FutureWord = memo(({ word }: { word: string }) => (
|
|
27
|
-
<Box marginRight={1}>
|
|
28
|
-
<Text dimColor>{word}</Text>
|
|
29
|
-
</Box>
|
|
30
|
-
));
|
|
4
|
+
import { useEffect, useMemo, useCallback, useState } from "react";
|
|
5
|
+
|
|
6
|
+
type StyledChar = {
|
|
7
|
+
char: string;
|
|
8
|
+
color?: string;
|
|
9
|
+
dimColor?: boolean;
|
|
10
|
+
strikethrough?: boolean;
|
|
11
|
+
isCursor?: boolean;
|
|
12
|
+
};
|
|
31
13
|
|
|
32
14
|
type GameProps = {
|
|
33
15
|
duration: number;
|
|
@@ -38,6 +20,7 @@ type GameProps = {
|
|
|
38
20
|
|
|
39
21
|
export function Game({ duration, onFinish, onExit, onRestart }: GameProps) {
|
|
40
22
|
const game = useGame(duration);
|
|
23
|
+
const { stdout } = useStdout();
|
|
41
24
|
|
|
42
25
|
useEffect(() => {
|
|
43
26
|
if (game.isFinished) {
|
|
@@ -45,50 +28,119 @@ export function Game({ duration, onFinish, onExit, onRestart }: GameProps) {
|
|
|
45
28
|
}
|
|
46
29
|
}, [game.isFinished]);
|
|
47
30
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
31
|
+
const handleInput = useCallback(
|
|
32
|
+
(input: string, key: Key) => {
|
|
33
|
+
if (key.escape) {
|
|
34
|
+
onExit();
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
if (key.tab) {
|
|
38
|
+
onRestart();
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
if (game.isFinished) return;
|
|
42
|
+
|
|
43
|
+
if (key.backspace || key.delete) {
|
|
44
|
+
game.handleBackspace();
|
|
45
|
+
} else if (input && !key.ctrl && !key.meta) {
|
|
46
|
+
for (const char of input) {
|
|
47
|
+
if (char === " ") {
|
|
48
|
+
game.handleSpace();
|
|
49
|
+
} else if (char >= "!" && char <= "~") {
|
|
50
|
+
game.handleChar(char);
|
|
51
|
+
}
|
|
67
52
|
}
|
|
68
53
|
}
|
|
69
|
-
}
|
|
70
|
-
|
|
54
|
+
},
|
|
55
|
+
[onExit, onRestart, game.isFinished, game.handleBackspace, game.handleSpace, game.handleChar],
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
useInput(handleInput);
|
|
59
|
+
|
|
60
|
+
const [cursorVisible, setCursorVisible] = useState(true);
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
const id = setInterval(() => setCursorVisible((v) => !v), 530);
|
|
63
|
+
return () => clearInterval(id);
|
|
64
|
+
}, []);
|
|
71
65
|
|
|
72
66
|
const liveWpm = useMemo(() => {
|
|
73
67
|
if (!game.isRunning || game.isFinished) return 0;
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
68
|
+
const elapsed = (duration - game.timeLeft) || 1;
|
|
69
|
+
return Math.round((game.correctCharsAcc / 5) / (elapsed / 60));
|
|
70
|
+
}, [game.correctCharsAcc, game.timeLeft, game.isRunning, game.isFinished, duration]);
|
|
71
|
+
|
|
72
|
+
const termWidth = stdout.columns || 80;
|
|
73
|
+
const availableWidth = termWidth - 4;
|
|
74
|
+
const centerCol = Math.floor(availableWidth / 2);
|
|
75
|
+
const currentWordIdx = game.currentWordIndex;
|
|
76
|
+
|
|
77
|
+
// Build flat styled character array for all words (cursor style applied at render time)
|
|
78
|
+
const { chars, cursorPos } = useMemo(() => {
|
|
79
|
+
const chars: StyledChar[] = [];
|
|
80
|
+
let cursorPos = 0;
|
|
81
|
+
|
|
82
|
+
for (let wi = 0; wi < game.words.length; wi++) {
|
|
83
|
+
const word = game.words[wi];
|
|
84
|
+
const isPast = wi < currentWordIdx;
|
|
85
|
+
const isCurrent = wi === currentWordIdx;
|
|
86
|
+
|
|
87
|
+
if (isPast) {
|
|
88
|
+
const inputs = game.charInputs[wi] || [];
|
|
89
|
+
for (let ci = 0; ci < word.length; ci++) {
|
|
90
|
+
chars.push({ char: word[ci], color: inputs[ci] === word[ci] ? "green" : "red" });
|
|
91
|
+
}
|
|
92
|
+
for (let ci = word.length; ci < inputs.length; ci++) {
|
|
93
|
+
chars.push({ char: inputs[ci], color: "red", strikethrough: true });
|
|
82
94
|
}
|
|
95
|
+
chars.push({ char: " " });
|
|
96
|
+
} else if (isCurrent) {
|
|
97
|
+
const typedInWord = Math.min(game.currentInput.length, word.length);
|
|
98
|
+
for (let ci = 0; ci < typedInWord; ci++) {
|
|
99
|
+
chars.push({ char: word[ci], color: game.currentInput[ci] === word[ci] ? "green" : "red" });
|
|
100
|
+
}
|
|
101
|
+
if (game.currentInput.length < word.length) {
|
|
102
|
+
// Cursor on next untyped character
|
|
103
|
+
cursorPos = chars.length;
|
|
104
|
+
chars.push({ char: word[game.currentInput.length], isCursor: true });
|
|
105
|
+
for (let ci = game.currentInput.length + 1; ci < word.length; ci++) {
|
|
106
|
+
chars.push({ char: word[ci], dimColor: true });
|
|
107
|
+
}
|
|
108
|
+
chars.push({ char: " " });
|
|
109
|
+
} else {
|
|
110
|
+
// Extra typed chars beyond word length
|
|
111
|
+
for (let ci = word.length; ci < game.currentInput.length; ci++) {
|
|
112
|
+
chars.push({ char: game.currentInput[ci], color: "red" });
|
|
113
|
+
}
|
|
114
|
+
// Cursor at space position
|
|
115
|
+
cursorPos = chars.length;
|
|
116
|
+
chars.push({ char: " ", isCursor: true });
|
|
117
|
+
}
|
|
118
|
+
} else {
|
|
119
|
+
// Future word
|
|
120
|
+
for (let ci = 0; ci < word.length; ci++) {
|
|
121
|
+
chars.push({ char: word[ci], dimColor: true });
|
|
122
|
+
}
|
|
123
|
+
chars.push({ char: " " });
|
|
83
124
|
}
|
|
84
125
|
}
|
|
85
|
-
|
|
86
|
-
return
|
|
87
|
-
}, [game.
|
|
126
|
+
|
|
127
|
+
return { chars, cursorPos };
|
|
128
|
+
}, [game.words, game.charInputs, currentWordIdx, game.currentInput]);
|
|
129
|
+
|
|
130
|
+
// Extract visible window centered on cursor
|
|
131
|
+
const windowStart = cursorPos - centerCol;
|
|
132
|
+
const visibleChars: StyledChar[] = [];
|
|
133
|
+
for (let i = windowStart; i < windowStart + availableWidth; i++) {
|
|
134
|
+
if (i >= 0 && i < chars.length) {
|
|
135
|
+
visibleChars.push(chars[i]);
|
|
136
|
+
} else {
|
|
137
|
+
visibleChars.push({ char: " " });
|
|
138
|
+
}
|
|
139
|
+
}
|
|
88
140
|
|
|
89
141
|
return (
|
|
90
142
|
<Box flexDirection="column" paddingX={2} paddingY={1}>
|
|
91
|
-
<Box justifyContent="
|
|
143
|
+
<Box justifyContent="center" gap={2}>
|
|
92
144
|
<Text color="yellow" bold>
|
|
93
145
|
{game.timeLeft}s
|
|
94
146
|
</Text>
|
|
@@ -96,52 +148,32 @@ export function Game({ duration, onFinish, onExit, onRestart }: GameProps) {
|
|
|
96
148
|
{liveWpm} wpm
|
|
97
149
|
</Text>
|
|
98
150
|
</Box>
|
|
99
|
-
<Box marginTop={1}
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
151
|
+
<Box marginTop={1}>
|
|
152
|
+
<Text>
|
|
153
|
+
{visibleChars.map((sc, i) => {
|
|
154
|
+
if (sc.isCursor) {
|
|
155
|
+
return cursorVisible ? (
|
|
156
|
+
<Text key={i} backgroundColor="white" color="black">
|
|
157
|
+
{sc.char}
|
|
158
|
+
</Text>
|
|
159
|
+
) : (
|
|
160
|
+
<Text key={i} underline>
|
|
161
|
+
{sc.char}
|
|
162
|
+
</Text>
|
|
163
|
+
);
|
|
164
|
+
}
|
|
106
165
|
return (
|
|
107
|
-
<
|
|
108
|
-
{
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
);
|
|
116
|
-
}
|
|
117
|
-
if (inputChar === undefined) {
|
|
118
|
-
return (
|
|
119
|
-
<Text key={charIndex} dimColor>
|
|
120
|
-
{char}
|
|
121
|
-
</Text>
|
|
122
|
-
);
|
|
123
|
-
}
|
|
124
|
-
return (
|
|
125
|
-
<Text key={charIndex} color={inputChar === char ? "green" : "red"}>
|
|
126
|
-
{char}
|
|
127
|
-
</Text>
|
|
128
|
-
);
|
|
129
|
-
})}
|
|
130
|
-
{game.currentInput.length > word.length &&
|
|
131
|
-
game.currentInput
|
|
132
|
-
.slice(word.length)
|
|
133
|
-
.split("")
|
|
134
|
-
.map((char, i) => (
|
|
135
|
-
<Text key={`extra-${i}`} color="red">
|
|
136
|
-
{char}
|
|
137
|
-
</Text>
|
|
138
|
-
))}
|
|
139
|
-
</Box>
|
|
166
|
+
<Text
|
|
167
|
+
key={i}
|
|
168
|
+
color={sc.color}
|
|
169
|
+
dimColor={sc.dimColor}
|
|
170
|
+
strikethrough={sc.strikethrough}
|
|
171
|
+
>
|
|
172
|
+
{sc.char}
|
|
173
|
+
</Text>
|
|
140
174
|
);
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
return <FutureWord key={wordIndex} word={word} />;
|
|
144
|
-
})}
|
|
175
|
+
})}
|
|
176
|
+
</Text>
|
|
145
177
|
</Box>
|
|
146
178
|
</Box>
|
|
147
179
|
);
|
package/src/hooks/useGame.ts
CHANGED
|
@@ -8,11 +8,11 @@ export type GameState = {
|
|
|
8
8
|
timeLeft: number;
|
|
9
9
|
isRunning: boolean;
|
|
10
10
|
isFinished: boolean;
|
|
11
|
-
wordResults: Array<"correct" | "incorrect" | "pending">;
|
|
12
11
|
};
|
|
13
12
|
|
|
14
13
|
export type GameHook = GameState & {
|
|
15
14
|
charInputs: string[][];
|
|
15
|
+
correctCharsAcc: number;
|
|
16
16
|
startGame: () => void;
|
|
17
17
|
handleChar: (char: string) => void;
|
|
18
18
|
handleBackspace: () => void;
|
|
@@ -39,15 +39,27 @@ export function useGame(duration: number) {
|
|
|
39
39
|
const charInputsRef = useRef<string[][]>(
|
|
40
40
|
Array.from({ length: wordCount }, () => [])
|
|
41
41
|
);
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
42
|
+
const wordsRef = useRef<string[]>([]);
|
|
43
|
+
const currentWordIndexRef = useRef(0);
|
|
44
|
+
const isFinishedRef = useRef(false);
|
|
45
|
+
const isRunningRef = useRef(false);
|
|
46
|
+
const correctCharsAccRef = useRef(0);
|
|
47
|
+
const totalCharsAccRef = useRef(0);
|
|
48
|
+
const wordCorrectCharsRef = useRef<number[]>([]);
|
|
49
|
+
const wordTotalCharsRef = useRef<number[]>([]);
|
|
50
|
+
|
|
51
|
+
const [state, setState] = useState<GameState>(() => {
|
|
52
|
+
const generatedWords = generateWords(wordCount);
|
|
53
|
+
wordsRef.current = generatedWords;
|
|
54
|
+
return {
|
|
55
|
+
words: generatedWords,
|
|
56
|
+
currentWordIndex: 0,
|
|
57
|
+
currentInput: "",
|
|
58
|
+
timeLeft: duration,
|
|
59
|
+
isRunning: false,
|
|
60
|
+
isFinished: false,
|
|
61
|
+
};
|
|
62
|
+
});
|
|
51
63
|
|
|
52
64
|
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
53
65
|
const startTimeRef = useRef<number>(0);
|
|
@@ -55,10 +67,14 @@ export function useGame(duration: number) {
|
|
|
55
67
|
useEffect(() => {
|
|
56
68
|
if (state.isRunning && !state.isFinished) {
|
|
57
69
|
timerRef.current = setInterval(() => {
|
|
70
|
+
const elapsed = Math.floor((Date.now() - startTimeRef.current) / 1000);
|
|
71
|
+
const newTimeLeft = Math.max(duration - elapsed, 0);
|
|
58
72
|
setState((prev) => {
|
|
59
|
-
|
|
73
|
+
if (prev.timeLeft === newTimeLeft) return prev;
|
|
60
74
|
if (newTimeLeft <= 0) {
|
|
61
75
|
if (timerRef.current) clearInterval(timerRef.current);
|
|
76
|
+
isFinishedRef.current = true;
|
|
77
|
+
isRunningRef.current = false;
|
|
62
78
|
return { ...prev, timeLeft: 0, isRunning: false, isFinished: true };
|
|
63
79
|
}
|
|
64
80
|
return { ...prev, timeLeft: newTimeLeft };
|
|
@@ -68,103 +84,106 @@ export function useGame(duration: number) {
|
|
|
68
84
|
return () => {
|
|
69
85
|
if (timerRef.current) clearInterval(timerRef.current);
|
|
70
86
|
};
|
|
71
|
-
}, [state.isRunning, state.isFinished]);
|
|
87
|
+
}, [state.isRunning, state.isFinished, duration]);
|
|
72
88
|
|
|
73
89
|
const startGame = useCallback(() => {
|
|
74
90
|
startTimeRef.current = Date.now();
|
|
91
|
+
isRunningRef.current = true;
|
|
75
92
|
setState((prev) => ({ ...prev, isRunning: true }));
|
|
76
93
|
}, []);
|
|
77
94
|
|
|
78
95
|
const handleChar = useCallback((char: string) => {
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
return {
|
|
91
|
-
...prev,
|
|
92
|
-
currentInput: prev.currentInput + char,
|
|
93
|
-
};
|
|
94
|
-
});
|
|
96
|
+
if (isFinishedRef.current) return;
|
|
97
|
+
charInputsRef.current[currentWordIndexRef.current].push(char);
|
|
98
|
+
if (!isRunningRef.current) {
|
|
99
|
+
startTimeRef.current = Date.now();
|
|
100
|
+
isRunningRef.current = true;
|
|
101
|
+
}
|
|
102
|
+
setState((prev) => ({
|
|
103
|
+
...prev,
|
|
104
|
+
isRunning: true,
|
|
105
|
+
currentInput: prev.currentInput + char,
|
|
106
|
+
}));
|
|
95
107
|
}, []);
|
|
96
108
|
|
|
97
109
|
const handleBackspace = useCallback(() => {
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
110
|
+
if (isFinishedRef.current) return;
|
|
111
|
+
|
|
112
|
+
const currentIdx = currentWordIndexRef.current;
|
|
113
|
+
const currentChars = charInputsRef.current[currentIdx];
|
|
114
|
+
|
|
115
|
+
if (currentChars.length === 0) {
|
|
116
|
+
if (currentIdx === 0) return;
|
|
117
|
+
const prevIndex = currentIdx - 1;
|
|
118
|
+
currentWordIndexRef.current = prevIndex;
|
|
119
|
+
const lastCorrect = wordCorrectCharsRef.current.pop() || 0;
|
|
120
|
+
const lastTotal = wordTotalCharsRef.current.pop() || 0;
|
|
121
|
+
correctCharsAccRef.current -= lastCorrect;
|
|
122
|
+
totalCharsAccRef.current -= lastTotal;
|
|
123
|
+
const prevInput = charInputsRef.current[prevIndex].join("");
|
|
124
|
+
setState((prev) => ({
|
|
125
|
+
...prev,
|
|
126
|
+
currentWordIndex: prevIndex,
|
|
127
|
+
currentInput: prevInput,
|
|
128
|
+
}));
|
|
129
|
+
} else {
|
|
130
|
+
charInputsRef.current[currentIdx].pop();
|
|
131
|
+
setState((prev) => ({
|
|
117
132
|
...prev,
|
|
118
133
|
currentInput: prev.currentInput.slice(0, -1),
|
|
119
|
-
};
|
|
120
|
-
}
|
|
134
|
+
}));
|
|
135
|
+
}
|
|
121
136
|
}, []);
|
|
122
137
|
|
|
123
138
|
const handleSpace = useCallback(() => {
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
};
|
|
138
|
-
});
|
|
139
|
-
}, []);
|
|
140
|
-
|
|
141
|
-
const getResults = useCallback((): GameResults => {
|
|
142
|
-
let correctChars = 0;
|
|
143
|
-
let totalChars = 0;
|
|
144
|
-
|
|
145
|
-
for (let i = 0; i < state.currentWordIndex; i++) {
|
|
146
|
-
const word = state.words[i];
|
|
147
|
-
const input = charInputsRef.current[i].join("");
|
|
148
|
-
totalChars += Math.max(word.length, input.length) + 1;
|
|
149
|
-
if (input === word) {
|
|
150
|
-
correctChars += word.length + 1;
|
|
151
|
-
} else {
|
|
152
|
-
for (let j = 0; j < word.length; j++) {
|
|
153
|
-
if (input[j] === word[j]) correctChars++;
|
|
154
|
-
}
|
|
139
|
+
if (isFinishedRef.current || !isRunningRef.current) return;
|
|
140
|
+
|
|
141
|
+
const currentIdx = currentWordIndexRef.current;
|
|
142
|
+
const word = wordsRef.current[currentIdx];
|
|
143
|
+
const inputChars = charInputsRef.current[currentIdx];
|
|
144
|
+
const input = inputChars.join("");
|
|
145
|
+
|
|
146
|
+
let correct = 0;
|
|
147
|
+
if (input === word) {
|
|
148
|
+
correct = word.length + 1;
|
|
149
|
+
} else {
|
|
150
|
+
for (let j = 0; j < word.length; j++) {
|
|
151
|
+
if (inputChars[j] === word[j]) correct++;
|
|
155
152
|
}
|
|
156
153
|
}
|
|
154
|
+
const total = Math.max(word.length, input.length) + 1;
|
|
155
|
+
|
|
156
|
+
wordCorrectCharsRef.current.push(correct);
|
|
157
|
+
wordTotalCharsRef.current.push(total);
|
|
158
|
+
correctCharsAccRef.current += correct;
|
|
159
|
+
totalCharsAccRef.current += total;
|
|
160
|
+
currentWordIndexRef.current = currentIdx + 1;
|
|
161
|
+
|
|
162
|
+
setState((prev) => ({
|
|
163
|
+
...prev,
|
|
164
|
+
currentWordIndex: currentIdx + 1,
|
|
165
|
+
currentInput: "",
|
|
166
|
+
}));
|
|
167
|
+
}, []);
|
|
157
168
|
|
|
158
|
-
|
|
159
|
-
const
|
|
160
|
-
const
|
|
161
|
-
|
|
169
|
+
const getResults = useCallback((): GameResults => {
|
|
170
|
+
const correctChars = correctCharsAccRef.current;
|
|
171
|
+
const totalChars = totalCharsAccRef.current;
|
|
172
|
+
const wpm =
|
|
173
|
+
totalChars > 0
|
|
174
|
+
? Math.round((correctChars / 5) / (duration / 60))
|
|
175
|
+
: 0;
|
|
176
|
+
const accuracy =
|
|
177
|
+
totalChars > 0
|
|
178
|
+
? Math.round((correctChars / totalChars) * 1000) / 10
|
|
179
|
+
: 0;
|
|
162
180
|
return { wpm, accuracy, time: duration };
|
|
163
|
-
}, [
|
|
181
|
+
}, [duration]);
|
|
164
182
|
|
|
165
183
|
return {
|
|
166
184
|
...state,
|
|
167
185
|
charInputs: charInputsRef.current,
|
|
186
|
+
correctCharsAcc: correctCharsAccRef.current,
|
|
168
187
|
startGame,
|
|
169
188
|
handleChar,
|
|
170
189
|
handleBackspace,
|
package/video/src/CLAUDE.md
DELETED
package/video/src/WpmxDemo.tsx
DELETED
|
@@ -1,46 +0,0 @@
|
|
|
1
|
-
import React from "react";
|
|
2
|
-
import { TransitionSeries, linearTiming } from "@remotion/transitions";
|
|
3
|
-
import { fade } from "@remotion/transitions/fade";
|
|
4
|
-
import { InstallScene } from "./scenes/InstallScene";
|
|
5
|
-
import { MenuScene } from "./scenes/MenuScene";
|
|
6
|
-
import { GameScene } from "./scenes/GameScene";
|
|
7
|
-
import { ResultsScene } from "./scenes/ResultsScene";
|
|
8
|
-
|
|
9
|
-
const FADE_DURATION = 10;
|
|
10
|
-
|
|
11
|
-
export const WpmxDemo: React.FC = () => {
|
|
12
|
-
return (
|
|
13
|
-
<TransitionSeries>
|
|
14
|
-
<TransitionSeries.Sequence durationInFrames={90}>
|
|
15
|
-
<InstallScene />
|
|
16
|
-
</TransitionSeries.Sequence>
|
|
17
|
-
|
|
18
|
-
<TransitionSeries.Transition
|
|
19
|
-
presentation={fade()}
|
|
20
|
-
timing={linearTiming({ durationInFrames: FADE_DURATION })}
|
|
21
|
-
/>
|
|
22
|
-
|
|
23
|
-
<TransitionSeries.Sequence durationInFrames={60}>
|
|
24
|
-
<MenuScene />
|
|
25
|
-
</TransitionSeries.Sequence>
|
|
26
|
-
|
|
27
|
-
<TransitionSeries.Transition
|
|
28
|
-
presentation={fade()}
|
|
29
|
-
timing={linearTiming({ durationInFrames: FADE_DURATION })}
|
|
30
|
-
/>
|
|
31
|
-
|
|
32
|
-
<TransitionSeries.Sequence durationInFrames={300}>
|
|
33
|
-
<GameScene />
|
|
34
|
-
</TransitionSeries.Sequence>
|
|
35
|
-
|
|
36
|
-
<TransitionSeries.Transition
|
|
37
|
-
presentation={fade()}
|
|
38
|
-
timing={linearTiming({ durationInFrames: FADE_DURATION })}
|
|
39
|
-
/>
|
|
40
|
-
|
|
41
|
-
<TransitionSeries.Sequence durationInFrames={90}>
|
|
42
|
-
<ResultsScene />
|
|
43
|
-
</TransitionSeries.Sequence>
|
|
44
|
-
</TransitionSeries>
|
|
45
|
-
);
|
|
46
|
-
};
|
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
<claude-mem-context>
|
|
2
|
-
# Recent Activity
|
|
3
|
-
|
|
4
|
-
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
|
|
5
|
-
|
|
6
|
-
### Feb 20, 2026
|
|
7
|
-
|
|
8
|
-
| ID | Time | T | Title | Read |
|
|
9
|
-
|----|------|---|-------|------|
|
|
10
|
-
| #1722 | 6:31 PM | 🟣 | MenuScene created with frame-driven duration selector animation | ~423 |
|
|
11
|
-
| #1723 | " | 🟣 | ResultsScene created with spring-animated WPM counter and cascading fade-in effects | ~496 |
|
|
12
|
-
| #1724 | " | 🟣 | GameScene created with character-level color feedback and live WPM calculation | ~569 |
|
|
13
|
-
| #1720 | 6:30 PM | 🟣 | MenuScene created with frame-driven duration selection animation | ~440 |
|
|
14
|
-
| #1721 | " | 🟣 | ResultsScene created with spring-animated WPM counter and staggered fade-ins | ~508 |
|
|
15
|
-
| #1719 | " | 🟣 | MenuScene created with frame-driven duration selector animation | ~525 |
|
|
16
|
-
| #1718 | 6:29 PM | 🟣 | GameScene implements sophisticated typing simulation with real-time feedback | ~580 |
|
|
17
|
-
| #1717 | " | 🟣 | Results scene implemented with animated score and staggered fade-ins | ~476 |
|
|
18
|
-
| #1716 | " | 🟣 | Install scene implements typewriter command entry animation | ~455 |
|
|
19
|
-
| #1715 | " | 🟣 | InstallScene created with typewriter effect for terminal command entry | ~446 |
|
|
20
|
-
| #1714 | 6:28 PM | 🟣 | MenuScene component displays duration selector with animated selection change | ~497 |
|
|
21
|
-
| #1713 | " | 🟣 | InstallScene component implements typewriter command prompt animation | ~494 |
|
|
22
|
-
</claude-mem-context>
|
|
@@ -1,193 +0,0 @@
|
|
|
1
|
-
import React from "react";
|
|
2
|
-
import { useCurrentFrame, useVideoConfig } from "remotion";
|
|
3
|
-
import { Terminal } from "../components/Terminal";
|
|
4
|
-
import { monoFont } from "../Root";
|
|
5
|
-
import { GAME_WORDS, TYPING_EVENTS } from "../data/typing-script";
|
|
6
|
-
|
|
7
|
-
interface WordState {
|
|
8
|
-
typed: string[];
|
|
9
|
-
submitted: boolean;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
interface FrameState {
|
|
13
|
-
words: WordState[];
|
|
14
|
-
currentWordIndex: number;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
function computeStateAtFrame(frame: number): FrameState {
|
|
18
|
-
const words: WordState[] = GAME_WORDS.map(() => ({
|
|
19
|
-
typed: [],
|
|
20
|
-
submitted: false,
|
|
21
|
-
}));
|
|
22
|
-
|
|
23
|
-
let currentWordIndex = 0;
|
|
24
|
-
|
|
25
|
-
for (const event of TYPING_EVENTS) {
|
|
26
|
-
if (event.frame > frame) break;
|
|
27
|
-
|
|
28
|
-
if (event.type === "char" && event.char !== undefined) {
|
|
29
|
-
words[currentWordIndex].typed.push(event.char);
|
|
30
|
-
} else if (event.type === "space") {
|
|
31
|
-
words[currentWordIndex].submitted = true;
|
|
32
|
-
currentWordIndex = Math.min(currentWordIndex + 1, GAME_WORDS.length - 1);
|
|
33
|
-
} else if (event.type === "backspace") {
|
|
34
|
-
if (words[currentWordIndex].typed.length > 0) {
|
|
35
|
-
words[currentWordIndex].typed.pop();
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
return { words, currentWordIndex };
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
export const GameScene: React.FC = () => {
|
|
44
|
-
const frame = useCurrentFrame();
|
|
45
|
-
const { fps } = useVideoConfig();
|
|
46
|
-
|
|
47
|
-
const timeLeft = Math.max(0, Math.ceil(15 - frame / fps));
|
|
48
|
-
|
|
49
|
-
const { words, currentWordIndex } = computeStateAtFrame(frame);
|
|
50
|
-
|
|
51
|
-
// Live WPM calculation
|
|
52
|
-
const elapsed = frame / fps;
|
|
53
|
-
let correctChars = 0;
|
|
54
|
-
for (let wi = 0; wi < currentWordIndex; wi++) {
|
|
55
|
-
const target = GAME_WORDS[wi];
|
|
56
|
-
const typedStr = words[wi].typed.join("");
|
|
57
|
-
if (typedStr === target) {
|
|
58
|
-
correctChars += target.length + 1; // +1 for space
|
|
59
|
-
} else {
|
|
60
|
-
for (let ci = 0; ci < target.length; ci++) {
|
|
61
|
-
if (ci < words[wi].typed.length && words[wi].typed[ci] === target[ci]) {
|
|
62
|
-
correctChars++;
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
const wpm = elapsed > 0 ? Math.round((correctChars / 5) / (elapsed / 60)) : 0;
|
|
68
|
-
|
|
69
|
-
return (
|
|
70
|
-
<Terminal title="wpmx">
|
|
71
|
-
<div
|
|
72
|
-
style={{
|
|
73
|
-
fontFamily: monoFont,
|
|
74
|
-
color: "#cdd6f4",
|
|
75
|
-
display: "flex",
|
|
76
|
-
flexDirection: "column",
|
|
77
|
-
height: "100%",
|
|
78
|
-
}}
|
|
79
|
-
>
|
|
80
|
-
{/* Top bar */}
|
|
81
|
-
<div
|
|
82
|
-
style={{
|
|
83
|
-
display: "flex",
|
|
84
|
-
flexDirection: "row",
|
|
85
|
-
justifyContent: "space-between",
|
|
86
|
-
marginBottom: 24,
|
|
87
|
-
fontSize: 20,
|
|
88
|
-
fontWeight: 700,
|
|
89
|
-
color: "#f9e2af",
|
|
90
|
-
}}
|
|
91
|
-
>
|
|
92
|
-
<span>{timeLeft}s</span>
|
|
93
|
-
<span>{wpm} wpm</span>
|
|
94
|
-
</div>
|
|
95
|
-
|
|
96
|
-
{/* Word display */}
|
|
97
|
-
<div
|
|
98
|
-
style={{
|
|
99
|
-
display: "flex",
|
|
100
|
-
flexDirection: "row",
|
|
101
|
-
flexWrap: "wrap",
|
|
102
|
-
gap: 8,
|
|
103
|
-
fontSize: 22,
|
|
104
|
-
lineHeight: 1.8,
|
|
105
|
-
}}
|
|
106
|
-
>
|
|
107
|
-
{GAME_WORDS.map((word, wi) => {
|
|
108
|
-
const wordState = words[wi];
|
|
109
|
-
const typed = wordState.typed;
|
|
110
|
-
|
|
111
|
-
const isFuture = wi > currentWordIndex;
|
|
112
|
-
const isCurrent = wi === currentWordIndex;
|
|
113
|
-
const isPast = wi < currentWordIndex;
|
|
114
|
-
|
|
115
|
-
const chars: React.ReactNode[] = [];
|
|
116
|
-
|
|
117
|
-
// Render each target character
|
|
118
|
-
for (let ci = 0; ci < word.length; ci++) {
|
|
119
|
-
let color: string;
|
|
120
|
-
let fontWeight: number | undefined;
|
|
121
|
-
let textDecoration: string | undefined;
|
|
122
|
-
|
|
123
|
-
if (isFuture) {
|
|
124
|
-
color = "#6c7086";
|
|
125
|
-
} else if (isCurrent) {
|
|
126
|
-
if (ci < typed.length) {
|
|
127
|
-
if (typed[ci] === word[ci]) {
|
|
128
|
-
color = "#a6e3a1";
|
|
129
|
-
} else {
|
|
130
|
-
color = "#f38ba8";
|
|
131
|
-
}
|
|
132
|
-
} else if (ci === typed.length) {
|
|
133
|
-
color = "#cdd6f4";
|
|
134
|
-
fontWeight = 700;
|
|
135
|
-
textDecoration = "underline";
|
|
136
|
-
} else {
|
|
137
|
-
color = "#6c7086";
|
|
138
|
-
}
|
|
139
|
-
} else {
|
|
140
|
-
// isPast
|
|
141
|
-
if (ci < typed.length) {
|
|
142
|
-
if (typed[ci] === word[ci]) {
|
|
143
|
-
color = "#a6e3a1";
|
|
144
|
-
} else {
|
|
145
|
-
color = "#f38ba8";
|
|
146
|
-
}
|
|
147
|
-
} else {
|
|
148
|
-
color = "#6c7086";
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
chars.push(
|
|
153
|
-
<span
|
|
154
|
-
key={`${wi}-${ci}`}
|
|
155
|
-
style={{
|
|
156
|
-
color,
|
|
157
|
-
fontWeight,
|
|
158
|
-
textDecoration,
|
|
159
|
-
}}
|
|
160
|
-
>
|
|
161
|
-
{word[ci]}
|
|
162
|
-
</span>
|
|
163
|
-
);
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
// Render extra characters beyond word length
|
|
167
|
-
if ((isCurrent || isPast) && typed.length > word.length) {
|
|
168
|
-
for (let ei = word.length; ei < typed.length; ei++) {
|
|
169
|
-
chars.push(
|
|
170
|
-
<span
|
|
171
|
-
key={`${wi}-extra-${ei}`}
|
|
172
|
-
style={{
|
|
173
|
-
color: "#f38ba8",
|
|
174
|
-
textDecoration: isPast ? "line-through" : undefined,
|
|
175
|
-
}}
|
|
176
|
-
>
|
|
177
|
-
{typed[ei]}
|
|
178
|
-
</span>
|
|
179
|
-
);
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
return (
|
|
184
|
-
<span key={wi}>
|
|
185
|
-
{chars}
|
|
186
|
-
</span>
|
|
187
|
-
);
|
|
188
|
-
})}
|
|
189
|
-
</div>
|
|
190
|
-
</div>
|
|
191
|
-
</Terminal>
|
|
192
|
-
);
|
|
193
|
-
};
|
|
@@ -1,48 +0,0 @@
|
|
|
1
|
-
import React from "react";
|
|
2
|
-
import { useCurrentFrame } from "remotion";
|
|
3
|
-
import { Terminal } from "../components/Terminal";
|
|
4
|
-
import { Cursor } from "../components/Cursor";
|
|
5
|
-
import { monoFont } from "../Root";
|
|
6
|
-
|
|
7
|
-
const COMMAND = "npx wpmx";
|
|
8
|
-
const TYPING_START_FRAME = 10;
|
|
9
|
-
const FRAMES_PER_CHAR = 4;
|
|
10
|
-
|
|
11
|
-
export const InstallScene: React.FC = () => {
|
|
12
|
-
const frame = useCurrentFrame();
|
|
13
|
-
|
|
14
|
-
const charsTyped = Math.min(
|
|
15
|
-
Math.max(0, Math.floor((frame - TYPING_START_FRAME) / FRAMES_PER_CHAR)),
|
|
16
|
-
COMMAND.length
|
|
17
|
-
);
|
|
18
|
-
|
|
19
|
-
const typingComplete = charsTyped >= COMMAND.length;
|
|
20
|
-
const typedText = COMMAND.slice(0, charsTyped);
|
|
21
|
-
|
|
22
|
-
return (
|
|
23
|
-
<Terminal title="Terminal">
|
|
24
|
-
<div
|
|
25
|
-
style={{
|
|
26
|
-
fontFamily: monoFont,
|
|
27
|
-
fontSize: 22,
|
|
28
|
-
color: "#cdd6f4",
|
|
29
|
-
lineHeight: 1.6,
|
|
30
|
-
}}
|
|
31
|
-
>
|
|
32
|
-
<span style={{ color: "#a6e3a1" }}>$ </span>
|
|
33
|
-
<span>{typedText}</span>
|
|
34
|
-
{!typingComplete && <Cursor />}
|
|
35
|
-
{typingComplete && (
|
|
36
|
-
<div
|
|
37
|
-
style={{
|
|
38
|
-
color: "#6c7086",
|
|
39
|
-
marginTop: 8,
|
|
40
|
-
}}
|
|
41
|
-
>
|
|
42
|
-
Loading wpmx...
|
|
43
|
-
</div>
|
|
44
|
-
)}
|
|
45
|
-
</div>
|
|
46
|
-
</Terminal>
|
|
47
|
-
);
|
|
48
|
-
};
|
|
@@ -1,78 +0,0 @@
|
|
|
1
|
-
import React from "react";
|
|
2
|
-
import { useCurrentFrame } from "remotion";
|
|
3
|
-
import { Terminal } from "../components/Terminal";
|
|
4
|
-
import { monoFont } from "../Root";
|
|
5
|
-
|
|
6
|
-
const DURATIONS = ["15s", "30s", "60s"];
|
|
7
|
-
|
|
8
|
-
export const MenuScene: React.FC = () => {
|
|
9
|
-
const frame = useCurrentFrame();
|
|
10
|
-
|
|
11
|
-
// Before frame 25: index 1 ("30s") is selected; at/after frame 25: index 0 ("15s")
|
|
12
|
-
const selectedIndex = frame < 25 ? 1 : 0;
|
|
13
|
-
|
|
14
|
-
return (
|
|
15
|
-
<Terminal title="wpmx">
|
|
16
|
-
<div
|
|
17
|
-
style={{
|
|
18
|
-
display: "flex",
|
|
19
|
-
flexDirection: "column",
|
|
20
|
-
alignItems: "center",
|
|
21
|
-
justifyContent: "center",
|
|
22
|
-
flex: 1,
|
|
23
|
-
gap: 24,
|
|
24
|
-
}}
|
|
25
|
-
>
|
|
26
|
-
{/* Title */}
|
|
27
|
-
<div
|
|
28
|
-
style={{
|
|
29
|
-
fontFamily: monoFont,
|
|
30
|
-
fontWeight: 700,
|
|
31
|
-
fontSize: 36,
|
|
32
|
-
color: "#f9e2af",
|
|
33
|
-
}}
|
|
34
|
-
>
|
|
35
|
-
wpmx
|
|
36
|
-
</div>
|
|
37
|
-
|
|
38
|
-
{/* Duration selector */}
|
|
39
|
-
<div
|
|
40
|
-
style={{
|
|
41
|
-
display: "flex",
|
|
42
|
-
flexDirection: "row",
|
|
43
|
-
gap: 24,
|
|
44
|
-
fontSize: 20,
|
|
45
|
-
fontFamily: monoFont,
|
|
46
|
-
}}
|
|
47
|
-
>
|
|
48
|
-
{DURATIONS.map((label, i) => {
|
|
49
|
-
const isSelected = i === selectedIndex;
|
|
50
|
-
return (
|
|
51
|
-
<span
|
|
52
|
-
key={label}
|
|
53
|
-
style={{
|
|
54
|
-
color: isSelected ? "#cdd6f4" : "#6c7086",
|
|
55
|
-
fontWeight: isSelected ? 700 : 400,
|
|
56
|
-
textDecoration: isSelected ? "underline" : "none",
|
|
57
|
-
}}
|
|
58
|
-
>
|
|
59
|
-
{label}
|
|
60
|
-
</span>
|
|
61
|
-
);
|
|
62
|
-
})}
|
|
63
|
-
</div>
|
|
64
|
-
|
|
65
|
-
{/* Hint text */}
|
|
66
|
-
<div
|
|
67
|
-
style={{
|
|
68
|
-
fontFamily: monoFont,
|
|
69
|
-
fontSize: 14,
|
|
70
|
-
color: "#6c7086",
|
|
71
|
-
}}
|
|
72
|
-
>
|
|
73
|
-
h / l or ← / → to choose, Enter to start
|
|
74
|
-
</div>
|
|
75
|
-
</div>
|
|
76
|
-
</Terminal>
|
|
77
|
-
);
|
|
78
|
-
};
|
|
@@ -1,112 +0,0 @@
|
|
|
1
|
-
import React from "react";
|
|
2
|
-
import {
|
|
3
|
-
useCurrentFrame,
|
|
4
|
-
useVideoConfig,
|
|
5
|
-
spring,
|
|
6
|
-
interpolate,
|
|
7
|
-
} from "remotion";
|
|
8
|
-
import { Terminal } from "../components/Terminal";
|
|
9
|
-
import { monoFont } from "../Root";
|
|
10
|
-
|
|
11
|
-
export const ResultsScene: React.FC = () => {
|
|
12
|
-
const frame = useCurrentFrame();
|
|
13
|
-
const { fps } = useVideoConfig();
|
|
14
|
-
|
|
15
|
-
const springValue = spring({ frame, fps, config: { damping: 200 } });
|
|
16
|
-
const wpm = Math.round(interpolate(springValue, [0, 1], [0, 87]));
|
|
17
|
-
|
|
18
|
-
const personalBestOpacity = interpolate(frame, [15, 30], [0, 1], {
|
|
19
|
-
extrapolateLeft: "clamp",
|
|
20
|
-
extrapolateRight: "clamp",
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
const statsOpacity = interpolate(frame, [25, 40], [0, 1], {
|
|
24
|
-
extrapolateLeft: "clamp",
|
|
25
|
-
extrapolateRight: "clamp",
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
return (
|
|
29
|
-
<Terminal title="wpmx">
|
|
30
|
-
<div
|
|
31
|
-
style={{
|
|
32
|
-
display: "flex",
|
|
33
|
-
flexDirection: "column",
|
|
34
|
-
alignItems: "center",
|
|
35
|
-
justifyContent: "center",
|
|
36
|
-
flex: 1,
|
|
37
|
-
gap: 12,
|
|
38
|
-
fontFamily: monoFont,
|
|
39
|
-
}}
|
|
40
|
-
>
|
|
41
|
-
{/* Title */}
|
|
42
|
-
<div
|
|
43
|
-
style={{
|
|
44
|
-
fontSize: 28,
|
|
45
|
-
fontWeight: 700,
|
|
46
|
-
color: "#f9e2af",
|
|
47
|
-
marginBottom: 16,
|
|
48
|
-
}}
|
|
49
|
-
>
|
|
50
|
-
wpmx
|
|
51
|
-
</div>
|
|
52
|
-
|
|
53
|
-
{/* WPM number */}
|
|
54
|
-
<div
|
|
55
|
-
style={{
|
|
56
|
-
fontSize: 48,
|
|
57
|
-
fontWeight: 700,
|
|
58
|
-
color: "#cdd6f4",
|
|
59
|
-
}}
|
|
60
|
-
>
|
|
61
|
-
{wpm}
|
|
62
|
-
</div>
|
|
63
|
-
|
|
64
|
-
{/* New personal best */}
|
|
65
|
-
<div
|
|
66
|
-
style={{
|
|
67
|
-
fontSize: 18,
|
|
68
|
-
fontWeight: 700,
|
|
69
|
-
color: "#a6e3a1",
|
|
70
|
-
opacity: personalBestOpacity,
|
|
71
|
-
}}
|
|
72
|
-
>
|
|
73
|
-
new personal best!
|
|
74
|
-
</div>
|
|
75
|
-
|
|
76
|
-
{/* Accuracy */}
|
|
77
|
-
<div
|
|
78
|
-
style={{
|
|
79
|
-
fontSize: 20,
|
|
80
|
-
color: "#cdd6f4",
|
|
81
|
-
opacity: statsOpacity,
|
|
82
|
-
}}
|
|
83
|
-
>
|
|
84
|
-
96.2% accuracy
|
|
85
|
-
</div>
|
|
86
|
-
|
|
87
|
-
{/* Duration */}
|
|
88
|
-
<div
|
|
89
|
-
style={{
|
|
90
|
-
fontSize: 16,
|
|
91
|
-
color: "#6c7086",
|
|
92
|
-
opacity: statsOpacity,
|
|
93
|
-
}}
|
|
94
|
-
>
|
|
95
|
-
15s
|
|
96
|
-
</div>
|
|
97
|
-
|
|
98
|
-
{/* Hints */}
|
|
99
|
-
<div
|
|
100
|
-
style={{
|
|
101
|
-
fontSize: 14,
|
|
102
|
-
color: "#6c7086",
|
|
103
|
-
marginTop: 24,
|
|
104
|
-
opacity: statsOpacity,
|
|
105
|
-
}}
|
|
106
|
-
>
|
|
107
|
-
{`Tab → restart Esc → menu q → quit`}
|
|
108
|
-
</div>
|
|
109
|
-
</div>
|
|
110
|
-
</Terminal>
|
|
111
|
-
);
|
|
112
|
-
};
|