wpmx 1.0.2 → 1.0.3
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/package.json +1 -1
- package/src/app.tsx +1 -2
- package/src/components/Game.tsx +2 -13
- package/src/hooks/useGame.ts +105 -86
- 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,8 @@ export type GameResults = {
|
|
|
21
21
|
|
|
22
22
|
export function App() {
|
|
23
23
|
const { exit } = useApp();
|
|
24
|
-
const settings = loadSettings();
|
|
25
24
|
const [screen, setScreen] = useState<Screen>("menu");
|
|
26
|
-
const [duration, setDuration] = useState<Duration>(
|
|
25
|
+
const [duration, setDuration] = useState<Duration>(() => loadSettings().lastDuration);
|
|
27
26
|
const [results, setResults] = useState<GameResults | null>(null);
|
|
28
27
|
const [gameKey, setGameKey] = useState(0);
|
|
29
28
|
|
package/src/components/Game.tsx
CHANGED
|
@@ -71,20 +71,9 @@ export function Game({ duration, onFinish, onExit, onRestart }: GameProps) {
|
|
|
71
71
|
|
|
72
72
|
const liveWpm = useMemo(() => {
|
|
73
73
|
if (!game.isRunning || game.isFinished) return 0;
|
|
74
|
-
let correct = 0;
|
|
75
|
-
for (let i = 0; i < game.currentWordIndex; i++) {
|
|
76
|
-
const word = game.words[i];
|
|
77
|
-
const input = game.charInputs[i].join("");
|
|
78
|
-
if (input === word) correct += word.length + 1;
|
|
79
|
-
else {
|
|
80
|
-
for (let j = 0; j < word.length; j++) {
|
|
81
|
-
if (input[j] === word[j]) correct++;
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
74
|
const elapsed = (duration - game.timeLeft) || 1;
|
|
86
|
-
return Math.round((
|
|
87
|
-
}, [game.
|
|
75
|
+
return Math.round((game.correctCharsAcc / 5) / (elapsed / 60));
|
|
76
|
+
}, [game.correctCharsAcc, game.timeLeft, game.isRunning, game.isFinished, duration]);
|
|
88
77
|
|
|
89
78
|
return (
|
|
90
79
|
<Box flexDirection="column" paddingX={2} paddingY={1}>
|
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,116 +67,123 @@ 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 };
|
|
65
81
|
});
|
|
66
|
-
},
|
|
82
|
+
}, 200);
|
|
67
83
|
}
|
|
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
|
-
};
|