wpmx 1.0.1 → 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 +46 -65
- package/src/hooks/useGame.ts +116 -103
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
|
@@ -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,22 @@ 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
|
-
const word = game.words[i];
|
|
47
|
-
const input = game.charInputs[i].join("");
|
|
48
|
-
if (input === word) correct += word.length + 1;
|
|
49
|
-
else {
|
|
50
|
-
for (let j = 0; j < word.length; j++) {
|
|
51
|
-
if (input[j] === word[j]) correct++;
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
const elapsed = (duration - game.timeLeft) || 1;
|
|
56
|
-
return (correct / 5) / (elapsed / 60);
|
|
57
|
-
})()
|
|
58
|
-
)
|
|
59
|
-
: 0;
|
|
72
|
+
const liveWpm = useMemo(() => {
|
|
73
|
+
if (!game.isRunning || game.isFinished) return 0;
|
|
74
|
+
const elapsed = (duration - game.timeLeft) || 1;
|
|
75
|
+
return Math.round((game.correctCharsAcc / 5) / (elapsed / 60));
|
|
76
|
+
}, [game.correctCharsAcc, game.timeLeft, game.isRunning, game.isFinished, duration]);
|
|
60
77
|
|
|
61
78
|
return (
|
|
62
79
|
<Box flexDirection="column" paddingX={2} paddingY={1}>
|
|
@@ -70,36 +87,11 @@ export function Game({ duration, onFinish, onExit, onRestart }: GameProps) {
|
|
|
70
87
|
</Box>
|
|
71
88
|
<Box marginTop={1} flexWrap="wrap">
|
|
72
89
|
{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
|
-
);
|
|
90
|
+
if (wordIndex < game.currentWordIndex) {
|
|
91
|
+
return <PastWord key={wordIndex} word={word} inputChars={game.charInputs[wordIndex]} />;
|
|
100
92
|
}
|
|
101
93
|
|
|
102
|
-
if (
|
|
94
|
+
if (wordIndex === game.currentWordIndex) {
|
|
103
95
|
return (
|
|
104
96
|
<Box key={wordIndex} marginRight={1}>
|
|
105
97
|
{word.split("").map((char, charIndex) => {
|
|
@@ -118,15 +110,8 @@ export function Game({ duration, onFinish, onExit, onRestart }: GameProps) {
|
|
|
118
110
|
</Text>
|
|
119
111
|
);
|
|
120
112
|
}
|
|
121
|
-
if (inputChar === char) {
|
|
122
|
-
return (
|
|
123
|
-
<Text key={charIndex} color="green">
|
|
124
|
-
{char}
|
|
125
|
-
</Text>
|
|
126
|
-
);
|
|
127
|
-
}
|
|
128
113
|
return (
|
|
129
|
-
<Text key={charIndex} color="red">
|
|
114
|
+
<Text key={charIndex} color={inputChar === char ? "green" : "red"}>
|
|
130
115
|
{char}
|
|
131
116
|
</Text>
|
|
132
117
|
);
|
|
@@ -144,11 +129,7 @@ export function Game({ duration, onFinish, onExit, onRestart }: GameProps) {
|
|
|
144
129
|
);
|
|
145
130
|
}
|
|
146
131
|
|
|
147
|
-
return
|
|
148
|
-
<Box key={wordIndex} marginRight={1}>
|
|
149
|
-
<Text dimColor>{word}</Text>
|
|
150
|
-
</Box>
|
|
151
|
-
);
|
|
132
|
+
return <FutureWord key={wordIndex} word={word} />;
|
|
152
133
|
})}
|
|
153
134
|
</Box>
|
|
154
135
|
</Box>
|
package/src/hooks/useGame.ts
CHANGED
|
@@ -8,8 +8,16 @@ export type GameState = {
|
|
|
8
8
|
timeLeft: number;
|
|
9
9
|
isRunning: boolean;
|
|
10
10
|
isFinished: boolean;
|
|
11
|
-
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export type GameHook = GameState & {
|
|
12
14
|
charInputs: string[][];
|
|
15
|
+
correctCharsAcc: number;
|
|
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,15 +36,29 @@ 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
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
39
|
+
const charInputsRef = useRef<string[][]>(
|
|
40
|
+
Array.from({ length: wordCount }, () => [])
|
|
41
|
+
);
|
|
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
|
+
};
|
|
40
62
|
});
|
|
41
63
|
|
|
42
64
|
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
@@ -45,132 +67,123 @@ export function useGame(duration: number) {
|
|
|
45
67
|
useEffect(() => {
|
|
46
68
|
if (state.isRunning && !state.isFinished) {
|
|
47
69
|
timerRef.current = setInterval(() => {
|
|
70
|
+
const elapsed = Math.floor((Date.now() - startTimeRef.current) / 1000);
|
|
71
|
+
const newTimeLeft = Math.max(duration - elapsed, 0);
|
|
48
72
|
setState((prev) => {
|
|
49
|
-
|
|
73
|
+
if (prev.timeLeft === newTimeLeft) return prev;
|
|
50
74
|
if (newTimeLeft <= 0) {
|
|
51
75
|
if (timerRef.current) clearInterval(timerRef.current);
|
|
76
|
+
isFinishedRef.current = true;
|
|
77
|
+
isRunningRef.current = false;
|
|
52
78
|
return { ...prev, timeLeft: 0, isRunning: false, isFinished: true };
|
|
53
79
|
}
|
|
54
80
|
return { ...prev, timeLeft: newTimeLeft };
|
|
55
81
|
});
|
|
56
|
-
},
|
|
82
|
+
}, 200);
|
|
57
83
|
}
|
|
58
84
|
return () => {
|
|
59
85
|
if (timerRef.current) clearInterval(timerRef.current);
|
|
60
86
|
};
|
|
61
|
-
}, [state.isRunning, state.isFinished]);
|
|
87
|
+
}, [state.isRunning, state.isFinished, duration]);
|
|
62
88
|
|
|
63
89
|
const startGame = useCallback(() => {
|
|
64
90
|
startTimeRef.current = Date.now();
|
|
91
|
+
isRunningRef.current = true;
|
|
65
92
|
setState((prev) => ({ ...prev, isRunning: true }));
|
|
66
93
|
}, []);
|
|
67
94
|
|
|
68
95
|
const handleChar = useCallback((char: string) => {
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
isRunning: true,
|
|
81
|
-
currentInput: prev.currentInput + char,
|
|
82
|
-
charInputs: newCharInputs,
|
|
83
|
-
};
|
|
84
|
-
}
|
|
85
|
-
const newCharInputs = [...prev.charInputs];
|
|
86
|
-
newCharInputs[prev.currentWordIndex] = [
|
|
87
|
-
...newCharInputs[prev.currentWordIndex],
|
|
88
|
-
char,
|
|
89
|
-
];
|
|
90
|
-
return {
|
|
91
|
-
...prev,
|
|
92
|
-
currentInput: prev.currentInput + char,
|
|
93
|
-
charInputs: newCharInputs,
|
|
94
|
-
};
|
|
95
|
-
});
|
|
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
|
+
}));
|
|
96
107
|
}, []);
|
|
97
108
|
|
|
98
109
|
const handleBackspace = useCallback(() => {
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
].slice(0, -1);
|
|
122
|
-
return {
|
|
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) => ({
|
|
123
132
|
...prev,
|
|
124
133
|
currentInput: prev.currentInput.slice(0, -1),
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
});
|
|
134
|
+
}));
|
|
135
|
+
}
|
|
128
136
|
}, []);
|
|
129
137
|
|
|
130
138
|
const handleSpace = useCallback(() => {
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
};
|
|
145
|
-
});
|
|
146
|
-
}, []);
|
|
147
|
-
|
|
148
|
-
const getResults = useCallback((): GameResults => {
|
|
149
|
-
let correctChars = 0;
|
|
150
|
-
let totalChars = 0;
|
|
151
|
-
|
|
152
|
-
for (let i = 0; i < state.currentWordIndex; i++) {
|
|
153
|
-
const word = state.words[i];
|
|
154
|
-
const input = state.charInputs[i].join("");
|
|
155
|
-
totalChars += Math.max(word.length, input.length) + 1;
|
|
156
|
-
if (input === word) {
|
|
157
|
-
correctChars += word.length + 1;
|
|
158
|
-
} else {
|
|
159
|
-
for (let j = 0; j < word.length; j++) {
|
|
160
|
-
if (input[j] === word[j]) correctChars++;
|
|
161
|
-
}
|
|
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++;
|
|
162
152
|
}
|
|
163
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
|
+
}, []);
|
|
164
168
|
|
|
165
|
-
|
|
166
|
-
const
|
|
167
|
-
const
|
|
168
|
-
|
|
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;
|
|
169
180
|
return { wpm, accuracy, time: duration };
|
|
170
|
-
}, [
|
|
181
|
+
}, [duration]);
|
|
171
182
|
|
|
172
183
|
return {
|
|
173
184
|
...state,
|
|
185
|
+
charInputs: charInputsRef.current,
|
|
186
|
+
correctCharsAcc: correctCharsAccRef.current,
|
|
174
187
|
startGame,
|
|
175
188
|
handleChar,
|
|
176
189
|
handleBackspace,
|