Skip to content

Commit a75da00

Browse files
committed
Wip KanaTyper
1 parent ae0f897 commit a75da00

File tree

6 files changed

+845
-2
lines changed

6 files changed

+845
-2
lines changed

bun.lock

Lines changed: 558 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

bun.lockb

-132 KB
Binary file not shown.
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
.\/japanese {
2+
.correct {
3+
background: oklch(0.9 0.05 141)
4+
}
5+
6+
.incorrect {
7+
background: oklch(0.9 0.05 18);
8+
}
9+
10+
.current {
11+
background: oklch(0.9 0.05 244);
12+
}
13+
14+
.prev,.next {
15+
opacity: 0.5;
16+
font-size: 2em;
17+
}
18+
19+
.prev,.cur,.next {
20+
font-size: 3em;
21+
}
22+
}
23+
Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,252 @@
1+
import { ContentBox } from "@src/common/context-box/ContextBox";
2+
import { KanaUtils } from "@src/japanese/utils/kana-utils";
3+
import { makeAutoObservable } from "mobx";
4+
import { SyntheticEvent, useEffect } from "react";
5+
import "./KanaTyper.scss";
6+
7+
type TypedKana = {
8+
idx: number;
9+
kana: string;
10+
expected: string;
11+
typed?: string;
12+
correct?: boolean;
13+
};
14+
15+
const game = makeAutoObservable({
16+
currentIdx: 0,
17+
currentInput: "",
18+
kanasPrev: [] as TypedKana[],
19+
kanas: [] as TypedKana[],
20+
kanasNext: [] as TypedKana[],
21+
allTypedKanas: [] as TypedKana[],
22+
timer: undefined as
23+
| undefined
24+
| { handle: number; startedAtMs: number; timeLeftMs: number },
25+
timeLimitMs: 0,
26+
finished: false,
27+
28+
reset(): void {
29+
this.currentInput = "";
30+
this.currentIdx = 0;
31+
this.kanasPrev = [];
32+
this.kanas = this.generateKanas(0);
33+
this.kanasNext = this.generateKanas(this.kanas.last()!.idx + 1);
34+
clearInterval(this.timer?.handle);
35+
this.timer = undefined;
36+
this.timeLimitMs = 30_000;
37+
this.finished = false;
38+
},
39+
40+
submit(): void {
41+
const currentText = this.currentInput;
42+
const currentIdx = this.currentIdx;
43+
44+
const kana = this.kanas.find((it) => it.idx === currentIdx)!;
45+
kana.typed = currentText.trim().toLowerCase();
46+
kana.correct = kana.typed === kana.expected;
47+
48+
this.currentInput = "";
49+
this.currentIdx++;
50+
51+
if (kana === this.kanas.last()) {
52+
this.kanasPrev = [...this.kanas];
53+
this.kanas = [...this.kanasNext];
54+
this.kanasNext = this.generateKanas(this.kanas.last()!.idx + 1);
55+
}
56+
57+
this.allTypedKanas.push(kana);
58+
},
59+
60+
onInput(evt: SyntheticEvent<HTMLInputElement>): void {
61+
if (!this.timer && !this.finished) {
62+
this.timer = {
63+
handle: setInterval(() => {
64+
this.tick();
65+
}, 100),
66+
startedAtMs: Date.now(),
67+
timeLeftMs: this.timeLimitMs,
68+
};
69+
70+
console.log("started timer", this.timer);
71+
}
72+
73+
const inputElem = evt.target as HTMLInputElement;
74+
75+
if (inputElem.value.endsWith(" ")) this.submit();
76+
else this.currentInput = inputElem.value;
77+
},
78+
79+
tick(): void {
80+
if (!this.timer) return;
81+
82+
const msSinceStart = Date.now() - this.timer.startedAtMs;
83+
this.timer.timeLeftMs = this.timeLimitMs - msSinceStart;
84+
85+
if (this.timer.timeLeftMs <= 0) {
86+
clearInterval(this.timer.handle);
87+
this.timer = undefined;
88+
this.finished = true;
89+
}
90+
},
91+
92+
generateKanas(startingIdx: number): TypedKana[] {
93+
const result: TypedKana[] = [];
94+
95+
for (let i = 0; i < 10; i++) {
96+
const table = hiraganas;
97+
const selectedKana = table[Math.floor(Math.random() * table.length)];
98+
result.push({
99+
idx: startingIdx + i,
100+
kana: selectedKana,
101+
expected: KanaUtils.toRomaji(selectedKana),
102+
});
103+
}
104+
105+
return result;
106+
},
107+
});
108+
109+
export function KanaTyper() {
110+
useEffect(() => {
111+
game.reset();
112+
}, []);
113+
114+
return (
115+
<ContentBox>
116+
<h1>Kana typer</h1>
117+
<div>
118+
{!game.timer
119+
? "Type to start"
120+
: `Time left: ${(game.timer.timeLeftMs / 1000).toFixed(2)}`}
121+
</div>
122+
<br />
123+
<div className="kanas-to-type">
124+
{[game.kanasPrev, game.kanas, game.kanasNext].map((row, idx) => (
125+
<div key={idx} className={{ 0: "prev", 1: "cur", 2: "next" }[idx]}>
126+
<span style={{ opacity: 0 }}>|</span>
127+
{row.map((it) => (
128+
<span
129+
key={it.idx}
130+
className={
131+
{ true: "correct", false: "incorrect", undefined: "" }[
132+
it.correct as any as string
133+
] + (it.idx === game.currentIdx ? " current" : "")
134+
}
135+
>
136+
{it.kana}
137+
</span>
138+
))}
139+
</div>
140+
))}
141+
</div>
142+
<br />
143+
<input
144+
autoFocus
145+
disabled={game.finished}
146+
value={game.currentInput}
147+
onKeyDown={(evt) => {
148+
if (evt.key === "Enter") game.submit();
149+
}}
150+
onInput={(evt) => game.onInput(evt)}
151+
/>
152+
{game.finished && (
153+
<>
154+
<br />
155+
<button onClick={() => game.reset()}>Reset</button>
156+
<br />
157+
<div>
158+
Correct:{" "}
159+
{game.allTypedKanas.filter((it) => it.correct === true).length}
160+
</div>
161+
<div>
162+
Incorrect:{" "}
163+
{game.allTypedKanas.filter((it) => it.correct === false).length}
164+
</div>
165+
<div>
166+
Kana/minute:{" "}
167+
{(
168+
game.allTypedKanas.filter((it) => it.correct != undefined)
169+
.length /
170+
60_000 /
171+
game.timeLimitMs
172+
).toFixed(2)}
173+
</div>
174+
</>
175+
)}
176+
</ContentBox>
177+
);
178+
}
179+
180+
const hiraganas = [
181+
"あ",
182+
"い",
183+
"う",
184+
"え",
185+
"お",
186+
"か",
187+
"き",
188+
"く",
189+
"け",
190+
"こ",
191+
"さ",
192+
"し",
193+
"す",
194+
"せ",
195+
"そ",
196+
"た",
197+
"ち",
198+
"つ",
199+
"て",
200+
"と",
201+
"な",
202+
"に",
203+
"ぬ",
204+
"ね",
205+
"の",
206+
"は",
207+
"ひ",
208+
"ふ",
209+
"へ",
210+
"ほ",
211+
"ま",
212+
"み",
213+
"む",
214+
"め",
215+
"も",
216+
"や",
217+
"ゆ",
218+
"よ",
219+
"ら",
220+
"り",
221+
"る",
222+
"れ",
223+
"ろ",
224+
"わ",
225+
"を",
226+
"ん",
227+
"が",
228+
"ぎ",
229+
"ぐ",
230+
"げ",
231+
"ご",
232+
"ざ",
233+
"じ",
234+
"ず",
235+
"ぜ",
236+
"ぞ",
237+
"だ",
238+
"ぢ",
239+
"づ",
240+
"で",
241+
"ど",
242+
"ば",
243+
"び",
244+
"ぶ",
245+
"べ",
246+
"ぼ",
247+
"ぱ",
248+
"ぴ",
249+
"ぷ",
250+
"ぺ",
251+
"ぽ",
252+
];

src/japanese/japanese-sitemap.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { Dropdown } from "@src/common/windows/dropdown/Dropdown";
1010
import { CheckBox } from "@src/common/input/checkbox/CheckBox";
1111
import { makeAutoObservable, runInAction } from "mobx";
1212
import { AdjectivesOverview } from "@src/japanese/systems/adjectives/AdjectivesOverview";
13+
import { KanaTyper } from "./exercises/kana-typer/KanaTyper";
1314

1415
export const JapaneseSettings = makeAutoObservable({
1516
japanese: true,
@@ -58,6 +59,15 @@ export const JapaneseSiteMap = {
5859
},
5960
},
6061
},
62+
exercises: {
63+
menu: { name: "Exercises" },
64+
nested: {
65+
kanaTyper: {
66+
menu: { name: "Kana Typer" },
67+
element: <KanaTyper />
68+
}
69+
}
70+
}
6171
},
6272
} satisfies RouteDefinition;
6373

src/program.scss

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,12 @@ select {
1818

1919
body {
2020
margin: 0;
21+
font-family: "Noto Sans JP", sans-serif;
2122

2223
// make default font size for phones more bearable
2324
@media (orientation: portrait) {
2425
font-size: 1.5vh;
2526
}
26-
27-
font-family: "Noto Sans JP", sans-serif;
2827
}
2928

3029
mark {
@@ -46,3 +45,4 @@ p {
4645
filter: brightness(2);
4746
}
4847
}
48+

0 commit comments

Comments
 (0)