wpmx 1.0.1 → 1.0.2
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/components/Game.tsx +57 -65
- package/src/hooks/useGame.ts +19 -25
- package/video/src/CLAUDE.md +7 -0
- package/video/src/WpmxDemo.tsx +46 -0
- package/video/src/components/CLAUDE.md +7 -0
- package/video/src/scenes/CLAUDE.md +22 -0
- package/video/src/scenes/GameScene.tsx +193 -0
- package/video/src/scenes/InstallScene.tsx +48 -0
- package/video/src/scenes/MenuScene.tsx +78 -0
- package/video/src/scenes/ResultsScene.tsx +112 -0
package/package.json
CHANGED
package/src/components/Game.tsx
CHANGED
|
@@ -1,6 +1,33 @@
|
|
|
1
1
|
import { Box, Text, useInput } from "ink";
|
|
2
2
|
import { useGame, type GameResults } from "../hooks/useGame.ts";
|
|
3
|
-
import { useEffect } from "react";
|
|
3
|
+
import { memo, useEffect, useMemo } from "react";
|
|
4
|
+
|
|
5
|
+
const PastWord = memo(({ word, inputChars }: { word: string; inputChars: string[] }) => (
|
|
6
|
+
<Box marginRight={1}>
|
|
7
|
+
{word.split("").map((char, charIndex) => {
|
|
8
|
+
const inputChar = inputChars[charIndex];
|
|
9
|
+
return (
|
|
10
|
+
<Text key={charIndex} color={inputChar === char ? "green" : "red"}>
|
|
11
|
+
{char}
|
|
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
31
|
|
|
5
32
|
type GameProps = {
|
|
6
33
|
duration: number;
|
|
@@ -31,32 +58,33 @@ export function Game({ duration, onFinish, onExit, onRestart }: GameProps) {
|
|
|
31
58
|
|
|
32
59
|
if (key.backspace || key.delete) {
|
|
33
60
|
game.handleBackspace();
|
|
34
|
-
} else if (input
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
61
|
+
} else if (input && !key.ctrl && !key.meta) {
|
|
62
|
+
for (const char of input) {
|
|
63
|
+
if (char === " ") {
|
|
64
|
+
game.handleSpace();
|
|
65
|
+
} else if (char >= "!" && char <= "~") {
|
|
66
|
+
game.handleChar(char);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
38
69
|
}
|
|
39
70
|
});
|
|
40
71
|
|
|
41
|
-
const liveWpm =
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
})()
|
|
58
|
-
)
|
|
59
|
-
: 0;
|
|
72
|
+
const liveWpm = useMemo(() => {
|
|
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
|
+
const elapsed = (duration - game.timeLeft) || 1;
|
|
86
|
+
return Math.round((correct / 5) / (elapsed / 60));
|
|
87
|
+
}, [game.currentWordIndex, game.timeLeft, game.isRunning, game.isFinished]);
|
|
60
88
|
|
|
61
89
|
return (
|
|
62
90
|
<Box flexDirection="column" paddingX={2} paddingY={1}>
|
|
@@ -70,36 +98,11 @@ export function Game({ duration, onFinish, onExit, onRestart }: GameProps) {
|
|
|
70
98
|
</Box>
|
|
71
99
|
<Box marginTop={1} flexWrap="wrap">
|
|
72
100
|
{game.words.slice(0, Math.min(game.words.length, game.currentWordIndex + 30)).map((word, wordIndex) => {
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
const wordResult = game.wordResults[wordIndex];
|
|
76
|
-
|
|
77
|
-
if (isPast) {
|
|
78
|
-
const inputChars = game.charInputs[wordIndex];
|
|
79
|
-
return (
|
|
80
|
-
<Box key={wordIndex} marginRight={1}>
|
|
81
|
-
{word.split("").map((char, charIndex) => {
|
|
82
|
-
const inputChar = inputChars[charIndex];
|
|
83
|
-
const isCorrect = inputChar === char;
|
|
84
|
-
return (
|
|
85
|
-
<Text key={charIndex} color={isCorrect ? "green" : "red"}>
|
|
86
|
-
{char}
|
|
87
|
-
</Text>
|
|
88
|
-
);
|
|
89
|
-
})}
|
|
90
|
-
{inputChars.length > word.length &&
|
|
91
|
-
inputChars
|
|
92
|
-
.slice(word.length)
|
|
93
|
-
.map((char, i) => (
|
|
94
|
-
<Text key={`extra-${i}`} color="red" strikethrough>
|
|
95
|
-
{char}
|
|
96
|
-
</Text>
|
|
97
|
-
))}
|
|
98
|
-
</Box>
|
|
99
|
-
);
|
|
101
|
+
if (wordIndex < game.currentWordIndex) {
|
|
102
|
+
return <PastWord key={wordIndex} word={word} inputChars={game.charInputs[wordIndex]} />;
|
|
100
103
|
}
|
|
101
104
|
|
|
102
|
-
if (
|
|
105
|
+
if (wordIndex === game.currentWordIndex) {
|
|
103
106
|
return (
|
|
104
107
|
<Box key={wordIndex} marginRight={1}>
|
|
105
108
|
{word.split("").map((char, charIndex) => {
|
|
@@ -118,15 +121,8 @@ export function Game({ duration, onFinish, onExit, onRestart }: GameProps) {
|
|
|
118
121
|
</Text>
|
|
119
122
|
);
|
|
120
123
|
}
|
|
121
|
-
if (inputChar === char) {
|
|
122
|
-
return (
|
|
123
|
-
<Text key={charIndex} color="green">
|
|
124
|
-
{char}
|
|
125
|
-
</Text>
|
|
126
|
-
);
|
|
127
|
-
}
|
|
128
124
|
return (
|
|
129
|
-
<Text key={charIndex} color="red">
|
|
125
|
+
<Text key={charIndex} color={inputChar === char ? "green" : "red"}>
|
|
130
126
|
{char}
|
|
131
127
|
</Text>
|
|
132
128
|
);
|
|
@@ -144,11 +140,7 @@ export function Game({ duration, onFinish, onExit, onRestart }: GameProps) {
|
|
|
144
140
|
);
|
|
145
141
|
}
|
|
146
142
|
|
|
147
|
-
return
|
|
148
|
-
<Box key={wordIndex} marginRight={1}>
|
|
149
|
-
<Text dimColor>{word}</Text>
|
|
150
|
-
</Box>
|
|
151
|
-
);
|
|
143
|
+
return <FutureWord key={wordIndex} word={word} />;
|
|
152
144
|
})}
|
|
153
145
|
</Box>
|
|
154
146
|
</Box>
|
package/src/hooks/useGame.ts
CHANGED
|
@@ -9,7 +9,15 @@ export type GameState = {
|
|
|
9
9
|
isRunning: boolean;
|
|
10
10
|
isFinished: boolean;
|
|
11
11
|
wordResults: Array<"correct" | "incorrect" | "pending">;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export type GameHook = GameState & {
|
|
12
15
|
charInputs: string[][];
|
|
16
|
+
startGame: () => void;
|
|
17
|
+
handleChar: (char: string) => void;
|
|
18
|
+
handleBackspace: () => void;
|
|
19
|
+
handleSpace: () => void;
|
|
20
|
+
getResults: () => GameResults;
|
|
13
21
|
};
|
|
14
22
|
|
|
15
23
|
export type GameResults = {
|
|
@@ -28,7 +36,10 @@ function generateWords(count: number): string[] {
|
|
|
28
36
|
|
|
29
37
|
export function useGame(duration: number) {
|
|
30
38
|
const wordCount = Math.max(duration * 3, 50);
|
|
31
|
-
const
|
|
39
|
+
const charInputsRef = useRef<string[][]>(
|
|
40
|
+
Array.from({ length: wordCount }, () => [])
|
|
41
|
+
);
|
|
42
|
+
const [state, setState] = useState<GameState>(() => ({
|
|
32
43
|
words: generateWords(wordCount),
|
|
33
44
|
currentWordIndex: 0,
|
|
34
45
|
currentInput: "",
|
|
@@ -36,8 +47,7 @@ export function useGame(duration: number) {
|
|
|
36
47
|
isRunning: false,
|
|
37
48
|
isFinished: false,
|
|
38
49
|
wordResults: Array(wordCount).fill("pending"),
|
|
39
|
-
|
|
40
|
-
});
|
|
50
|
+
}));
|
|
41
51
|
|
|
42
52
|
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
43
53
|
const startTimeRef = useRef<number>(0);
|
|
@@ -68,29 +78,18 @@ export function useGame(duration: number) {
|
|
|
68
78
|
const handleChar = useCallback((char: string) => {
|
|
69
79
|
setState((prev) => {
|
|
70
80
|
if (prev.isFinished) return prev;
|
|
81
|
+
charInputsRef.current[prev.currentWordIndex].push(char);
|
|
71
82
|
if (!prev.isRunning) {
|
|
72
83
|
startTimeRef.current = Date.now();
|
|
73
|
-
const newCharInputs = [...prev.charInputs];
|
|
74
|
-
newCharInputs[prev.currentWordIndex] = [
|
|
75
|
-
...newCharInputs[prev.currentWordIndex],
|
|
76
|
-
char,
|
|
77
|
-
];
|
|
78
84
|
return {
|
|
79
85
|
...prev,
|
|
80
86
|
isRunning: true,
|
|
81
87
|
currentInput: prev.currentInput + char,
|
|
82
|
-
charInputs: newCharInputs,
|
|
83
88
|
};
|
|
84
89
|
}
|
|
85
|
-
const newCharInputs = [...prev.charInputs];
|
|
86
|
-
newCharInputs[prev.currentWordIndex] = [
|
|
87
|
-
...newCharInputs[prev.currentWordIndex],
|
|
88
|
-
char,
|
|
89
|
-
];
|
|
90
90
|
return {
|
|
91
91
|
...prev,
|
|
92
92
|
currentInput: prev.currentInput + char,
|
|
93
|
-
charInputs: newCharInputs,
|
|
94
93
|
};
|
|
95
94
|
});
|
|
96
95
|
}, []);
|
|
@@ -99,11 +98,10 @@ export function useGame(duration: number) {
|
|
|
99
98
|
setState((prev) => {
|
|
100
99
|
if (prev.isFinished) return prev;
|
|
101
100
|
|
|
102
|
-
// If current input is empty, go back to previous word
|
|
103
101
|
if (prev.currentInput.length === 0) {
|
|
104
102
|
if (prev.currentWordIndex === 0) return prev;
|
|
105
103
|
const prevIndex = prev.currentWordIndex - 1;
|
|
106
|
-
const prevInput =
|
|
104
|
+
const prevInput = charInputsRef.current[prevIndex].join("");
|
|
107
105
|
const newWordResults = [...prev.wordResults];
|
|
108
106
|
newWordResults[prevIndex] = "pending";
|
|
109
107
|
return {
|
|
@@ -114,15 +112,10 @@ export function useGame(duration: number) {
|
|
|
114
112
|
};
|
|
115
113
|
}
|
|
116
114
|
|
|
117
|
-
|
|
118
|
-
const newCharInputs = [...prev.charInputs];
|
|
119
|
-
newCharInputs[prev.currentWordIndex] = newCharInputs[
|
|
120
|
-
prev.currentWordIndex
|
|
121
|
-
].slice(0, -1);
|
|
115
|
+
charInputsRef.current[prev.currentWordIndex].pop();
|
|
122
116
|
return {
|
|
123
117
|
...prev,
|
|
124
118
|
currentInput: prev.currentInput.slice(0, -1),
|
|
125
|
-
charInputs: newCharInputs,
|
|
126
119
|
};
|
|
127
120
|
});
|
|
128
121
|
}, []);
|
|
@@ -151,7 +144,7 @@ export function useGame(duration: number) {
|
|
|
151
144
|
|
|
152
145
|
for (let i = 0; i < state.currentWordIndex; i++) {
|
|
153
146
|
const word = state.words[i];
|
|
154
|
-
const input =
|
|
147
|
+
const input = charInputsRef.current[i].join("");
|
|
155
148
|
totalChars += Math.max(word.length, input.length) + 1;
|
|
156
149
|
if (input === word) {
|
|
157
150
|
correctChars += word.length + 1;
|
|
@@ -167,10 +160,11 @@ export function useGame(duration: number) {
|
|
|
167
160
|
const accuracy = totalChars > 0 ? Math.round((correctChars / totalChars) * 1000) / 10 : 0;
|
|
168
161
|
|
|
169
162
|
return { wpm, accuracy, time: duration };
|
|
170
|
-
}, [state.currentWordIndex, state.
|
|
163
|
+
}, [state.currentWordIndex, state.words, duration]);
|
|
171
164
|
|
|
172
165
|
return {
|
|
173
166
|
...state,
|
|
167
|
+
charInputs: charInputsRef.current,
|
|
174
168
|
startGame,
|
|
175
169
|
handleChar,
|
|
176
170
|
handleBackspace,
|
|
@@ -0,0 +1,46 @@
|
|
|
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
|
+
};
|
|
@@ -0,0 +1,22 @@
|
|
|
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>
|
|
@@ -0,0 +1,193 @@
|
|
|
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
|
+
};
|
|
@@ -0,0 +1,48 @@
|
|
|
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
|
+
};
|
|
@@ -0,0 +1,78 @@
|
|
|
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
|
+
};
|
|
@@ -0,0 +1,112 @@
|
|
|
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
|
+
};
|