|
| 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 | +]; |
0 commit comments