wpmx 1.0.3 → 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 +6 -2
- package/src/components/Game.tsx +138 -95
- package/src/hooks/useGame.ts +1 -1
package/package.json
CHANGED
package/src/app.tsx
CHANGED
|
@@ -21,6 +21,10 @@ export type GameResults = {
|
|
|
21
21
|
|
|
22
22
|
export function App() {
|
|
23
23
|
const { exit } = useApp();
|
|
24
|
+
const handleQuit = useCallback(() => {
|
|
25
|
+
exit();
|
|
26
|
+
process.exit(0);
|
|
27
|
+
}, [exit]);
|
|
24
28
|
const [screen, setScreen] = useState<Screen>("menu");
|
|
25
29
|
const [duration, setDuration] = useState<Duration>(() => loadSettings().lastDuration);
|
|
26
30
|
const [results, setResults] = useState<GameResults | null>(null);
|
|
@@ -57,7 +61,7 @@ export function App() {
|
|
|
57
61
|
}, []);
|
|
58
62
|
|
|
59
63
|
if (screen === "menu") {
|
|
60
|
-
return <Menu onStart={handleStart} onQuit={
|
|
64
|
+
return <Menu onStart={handleStart} onQuit={handleQuit} defaultDuration={duration} />;
|
|
61
65
|
}
|
|
62
66
|
|
|
63
67
|
if (screen === "game") {
|
|
@@ -80,7 +84,7 @@ export function App() {
|
|
|
80
84
|
personalBest={pb}
|
|
81
85
|
onRestart={handleRestart}
|
|
82
86
|
onMenu={handleMenu}
|
|
83
|
-
onQuit={
|
|
87
|
+
onQuit={handleQuit}
|
|
84
88
|
/>
|
|
85
89
|
);
|
|
86
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,29 +28,40 @@ 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;
|
|
@@ -75,9 +69,78 @@ export function Game({ duration, onFinish, onExit, onRestart }: GameProps) {
|
|
|
75
69
|
return Math.round((game.correctCharsAcc / 5) / (elapsed / 60));
|
|
76
70
|
}, [game.correctCharsAcc, game.timeLeft, game.isRunning, game.isFinished, duration]);
|
|
77
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 });
|
|
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: " " });
|
|
124
|
+
}
|
|
125
|
+
}
|
|
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
|
+
}
|
|
140
|
+
|
|
78
141
|
return (
|
|
79
142
|
<Box flexDirection="column" paddingX={2} paddingY={1}>
|
|
80
|
-
<Box justifyContent="
|
|
143
|
+
<Box justifyContent="center" gap={2}>
|
|
81
144
|
<Text color="yellow" bold>
|
|
82
145
|
{game.timeLeft}s
|
|
83
146
|
</Text>
|
|
@@ -85,52 +148,32 @@ export function Game({ duration, onFinish, onExit, onRestart }: GameProps) {
|
|
|
85
148
|
{liveWpm} wpm
|
|
86
149
|
</Text>
|
|
87
150
|
</Box>
|
|
88
|
-
<Box marginTop={1}
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
+
}
|
|
95
165
|
return (
|
|
96
|
-
<
|
|
97
|
-
{
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
);
|
|
105
|
-
}
|
|
106
|
-
if (inputChar === undefined) {
|
|
107
|
-
return (
|
|
108
|
-
<Text key={charIndex} dimColor>
|
|
109
|
-
{char}
|
|
110
|
-
</Text>
|
|
111
|
-
);
|
|
112
|
-
}
|
|
113
|
-
return (
|
|
114
|
-
<Text key={charIndex} color={inputChar === char ? "green" : "red"}>
|
|
115
|
-
{char}
|
|
116
|
-
</Text>
|
|
117
|
-
);
|
|
118
|
-
})}
|
|
119
|
-
{game.currentInput.length > word.length &&
|
|
120
|
-
game.currentInput
|
|
121
|
-
.slice(word.length)
|
|
122
|
-
.split("")
|
|
123
|
-
.map((char, i) => (
|
|
124
|
-
<Text key={`extra-${i}`} color="red">
|
|
125
|
-
{char}
|
|
126
|
-
</Text>
|
|
127
|
-
))}
|
|
128
|
-
</Box>
|
|
166
|
+
<Text
|
|
167
|
+
key={i}
|
|
168
|
+
color={sc.color}
|
|
169
|
+
dimColor={sc.dimColor}
|
|
170
|
+
strikethrough={sc.strikethrough}
|
|
171
|
+
>
|
|
172
|
+
{sc.char}
|
|
173
|
+
</Text>
|
|
129
174
|
);
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
return <FutureWord key={wordIndex} word={word} />;
|
|
133
|
-
})}
|
|
175
|
+
})}
|
|
176
|
+
</Text>
|
|
134
177
|
</Box>
|
|
135
178
|
</Box>
|
|
136
179
|
);
|