【JavaScript】日本語のローマ字の複数の入力判定ができるタイピングゲームのライブラリと簡単な使用例【typing-jp】


## 前書き

タイピングゲームのライブラリを作ったのでタイピングゲームを作りたいと思う。

https://www.npmjs.com/package/@mogamoga1024/typing-jp

## 成果物

https://mogamoga1024.github.io/TypingGameQ/

## 雑な設計

・日本語のテキストとローマ字を表示する。
・入力に応じてローマ字の色を変える。
・複数のローマ字入力に対応する。
・ミス数、タイムを計測する。

## 作る

### ライブラリの読み込み

ありがたいことにCDNが使えるので使わせてもらう。
（皆さんは最新バージョンを使ってください！）

```html
<script src="https://cdn.jsdelivr.net/npm/@mogamoga1024/typing-jp@1.0.2/dist/cdn/typing-jp.js"></script>
```

### 問題

問題は適当にChatGPT君に考えてもらう。

<img width=400 src=https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/649673/1d6c4753-3cbe-8086-e4dd-734d812c0b2d.png>

表示用の文字列とTypingTextオブジェクトをオブジェクトに突っ込んで配列に格納する。

```js:question.js
const allQuestionList = [
    { text: "りんご", typingText: new TypingText("りんご") },
    { text: "バナナ", typingText: new TypingText("バナナ") },
    { text: "オレンジ", typingText: new TypingText("オレンジ") },
    { text: "いちご", typingText: new TypingText("いちご") },
    { text: "ブドウ", typingText: new TypingText("ブドウ") },
    { text: "パイナップル", typingText: new TypingText("パイナップル") },
    { text: "みかん", typingText: new TypingText("みかん") },
    { text: "レモン", typingText: new TypingText("レモン") },
    { text: "メロン", typingText: new TypingText("メロン") },
    { text: "スイカ", typingText: new TypingText("スイカ") },
    { text: "トマト", typingText: new TypingText("トマト") },
    { text: "きゅうり", typingText: new TypingText("きゅうり") },
    { text: "キャベツ", typingText: new TypingText("キャベツ") },
    { text: "レタス", typingText: new TypingText("レタス") },
    { text: "ほうれん草", typingText: new TypingText("ほうれんそう") },
    { text: "かぼちゃ", typingText: new TypingText("かぼちゃ") },
    { text: "じゃがいも", typingText: new TypingText("じゃがいも") },
    { text: "さつまいも", typingText: new TypingText("さつまいも") },
    { text: "玉ねぎ", typingText: new TypingText("たまねぎ") },
    { text: "にんじん", typingText: new TypingText("にんじん") },
    { text: "大根", typingText: new TypingText("だいこん") },
    { text: "しいたけ", typingText: new TypingText("しいたけ") },
    { text: "まいたけ", typingText: new TypingText("まいたけ") },
    { text: "しめじ", typingText: new TypingText("しめじ") },
    { text: "えのき", typingText: new TypingText("えのき") },
    { text: "なす", typingText: new TypingText("なす") },
    { text: "ピーマン", typingText: new TypingText("ピーマン") },
    { text: "トウモロコシ", typingText: new TypingText("トウモロコシ") },
    { text: "ブロッコリー", typingText: new TypingText("ブロッコリー") },
    { text: "カリフラワー", typingText: new TypingText("カリフラワー") },
    { text: "もやし", typingText: new TypingText("もやし") },
    { text: "にら", typingText: new TypingText("にら") },
    { text: "ねぎ", typingText: new TypingText("ねぎ") },
    { text: "セロリ", typingText: new TypingText("セロリ") },
    { text: "パセリ", typingText: new TypingText("パセリ") },
    { text: "アスパラガス", typingText: new TypingText("アスパラガス") },
    { text: "オクラ", typingText: new TypingText("オクラ") },
    { text: "ズッキーニ", typingText: new TypingText("ズッキーニ") },
    { text: "ごぼう", typingText: new TypingText("ごぼう") },
    { text: "れんこん", typingText: new TypingText("れんこん") },
    { text: "枝豆", typingText: new TypingText("えだまめ") },
    { text: "小松菜", typingText: new TypingText("こまつな") },
    { text: "三つ葉", typingText: new TypingText("みつば") },
    { text: "春菊", typingText: new TypingText("しゅんぎく") },
    { text: "山芋", typingText: new TypingText("やまいも") },
    { text: "とうがん", typingText: new TypingText("とうがん") },
    { text: "鯖（サバ）", typingText: new TypingText("さば") },
    { text: "鰯（イワシ）", typingText: new TypingText("いわし") },
    { text: "鮭（サケ）", typingText: new TypingText("さけ") },
    { text: "鯛（タイ）", typingText: new TypingText("たい") },
    { text: "ブリ", typingText: new TypingText("ブリ") },
    { text: "タコ", typingText: new TypingText("タコ") },
    { text: "イカ", typingText: new TypingText("イカ") },
    { text: "アサリ", typingText: new TypingText("アサリ") },
    { text: "ハマグリ", typingText: new TypingText("ハマグリ") },
    { text: "シジミ", typingText: new TypingText("シジミ") },
    { text: "カニ", typingText: new TypingText("カニ") },
    { text: "エビ", typingText: new TypingText("エビ") },
    { text: "ウニ", typingText: new TypingText("ウニ") },
    { text: "カキ", typingText: new TypingText("カキ") },
    { text: "ホタテ", typingText: new TypingText("ホタテ") },
    { text: "鶏肉", typingText: new TypingText("とりにく") },
    { text: "牛肉", typingText: new TypingText("ぎゅうにく") },
    { text: "豚肉", typingText: new TypingText("ぶたにく") },
    { text: "羊肉", typingText: new TypingText("ようにく") },
    { text: "馬肉", typingText: new TypingText("ばにく") },
    { text: "鹿肉", typingText: new TypingText("しかにく") },
    { text: "カモ肉", typingText: new TypingText("カモにく") },
    { text: "鶉（ウズラ）", typingText: new TypingText("うずら") },
    { text: "納豆", typingText: new TypingText("なっとう") },
    { text: "うなぎ", typingText: new TypingText("うなぎ") },
    { text: "たらこ", typingText: new TypingText("たらこ") },
    { text: "いくら", typingText: new TypingText("いくら") },
    { text: "数の子", typingText: new TypingText("かずのこ") },
    { text: "筍（タケノコ）", typingText: new TypingText("たけのこ") },
    { text: "ワカメ", typingText: new TypingText("ワカメ") },
    { text: "昆布", typingText: new TypingText("こんぶ") },
    { text: "のり", typingText: new TypingText("のり") },
    { text: "ひじき", typingText: new TypingText("ひじき") },
    { text: "サバ缶", typingText: new TypingText("サバかん") },
    { text: "ツナ缶", typingText: new TypingText("ツナかん") },
    { text: "うずらの卵", typingText: new TypingText("うずらのたまご") },
    { text: "鳥の唐揚げ", typingText: new TypingText("とりのからあげ") },
    { text: "天ぷら", typingText: new TypingText("てんぷら") },
    { text: "すき焼き", typingText: new TypingText("すきやき") },
    { text: "鍋物", typingText: new TypingText("なべもの") },
    { text: "味噌汁", typingText: new TypingText("みそしる") },
    { text: "煮物", typingText: new TypingText("にもの") },
    { text: "焼き魚", typingText: new TypingText("やきざかな") },
    { text: "焼肉", typingText: new TypingText("やきにく") },
    { text: "牛丼", typingText: new TypingText("ぎゅうどん") },
    { text: "寿司", typingText: new TypingText("すし") },
    { text: "さしみ", typingText: new TypingText("さしみ") },
    { text: "ラーメン", typingText: new TypingText("ラーメン") },
    { text: "うどん", typingText: new TypingText("うどん") },
    { text: "そば", typingText: new TypingText("そば") },
    { text: "パスタ", typingText: new TypingText("パスタ") },
    { text: "カレーライス", typingText: new TypingText("カレーライス") },
    { text: "ピザ", typingText: new TypingText("ピザ") },
    { text: "ハンバーガー", typingText: new TypingText("ハンバーガー") },
];

function createRandomQuestionList(count) {
    const tmpQuestionList = [...allQuestionList];
    const randomQuestionList = [];

    for (let i = 0; i < count; i++) {
        const randomIndex = Math.floor(Math.random() * tmpQuestionList.length);
        const question = tmpQuestionList.splice(randomIndex, 1)[0];
        randomQuestionList.push(question);
    }
    
    return randomQuestionList;
}
```

## タイピングの処理

100行程度のコードなんで特に言うことなし。
gameMain関数だけ読めばいいです。

```js
const domText = document.querySelector("#text");
const domRomanContainer = document.querySelector("#roman-container");
const domRoman1 = document.querySelector("#roman1");
const domRoman2 = document.querySelector("#roman2");
const domResult = document.querySelector("#result");

domResult.style.display = "none";

let questionList = createRandomQuestionList(10);
let questionIndex = 0;
let typingText = questionList[questionIndex].typingText;

let missCount = 0;
let typeCount = 0;
let startTime = 0;
let elapsedTime = 0;

gameStart();

function gameStart() {
    domText.innerText = "Spaceで開始";

    window.onkeydown = e => {
        if (e.key === " ") {
            gameMain();
        }
    }
}

function gameMain() {
    domText.innerText = `${questionIndex + 1}:${questionList[questionIndex].text}`;
    domRoman2.innerText = typingText.remainingRoman;

    startTime = performance.now();

    window.onkeydown = e => {
        if (e.repeat) {
            return;
        }
    
        // ShiftやF11のような入力を弾く
        if (!TypingText.isValidInputKey(e.key)) {
            return;
        }

        typeCount++;
    
        const isCapsLock = e.getModifierState("CapsLock");
    
        // キー入力の更新
        const result = typingText.inputKey(e.key, isCapsLock);
        
        switch (result) {
            // 不一致の場合
            case "unmatch":
                missCount++;
                return;
    
            // 一致しているが文章が未完成の場合
            case "incomplete":
                domRoman1.innerText += e.key;
                domRoman2.innerText = typingText.remainingRoman;
                return;
    
            // 文章が完成した場合
            case "complete":
                // クリアしたか
                if (++questionIndex >= questionList.length) {
                    elapsedTime = performance.now() - startTime;
                    domRoman1.innerText += e.key;
                    domRoman2.innerText = "";
                    gameEnd();
                    return;
                }
    
                // 次の文章へ
                domText.innerText = `${questionIndex + 1}:${questionList[questionIndex].text}`;
                typingText = questionList[questionIndex].typingText;
            
                domRoman1.innerText = "";
                domRoman2.innerText = typingText.remainingRoman;
    
                return;
        }
    }
}

function floor(num, decimalPlaces = 0) {
    const factor = Math.pow(10, decimalPlaces);
    return Math.floor(num * factor) / factor;
}

function gameEnd() {
    window.onkeydown = null;

    domText.innerText = "リザルト";
    domRomanContainer.style.display = "none";
    domResult.style.display = "";

    const epm = missCount / (elapsedTime / (1000 * 60));
    const kpm = typeCount / (elapsedTime / (1000 * 60));

    domResult.innerText  = `スコア:${floor(kpm * Math.pow(1 - missCount / typeCount, 3), 2)}\n`;
    domResult.innerText += `クリアタイム:${floor(elapsedTime / 1000, 2)}秒\n`;
    domResult.innerText += `誤タイプ率:${floor(missCount / typeCount * 100, 2)}%\n`;
    domResult.innerText += `1分毎のタイプ数:${floor(kpm, 2)}\n`;
    domResult.innerText += `1分毎の誤タイプ数:${floor(epm, 2)}\n`;
}
```

<details><summary>HTML & CSS</summary>

```html:index.html
<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>タイピングゲーム</title>
  <link rel="stylesheet" href="style.css">
  <script src="https://cdn.jsdelivr.net/npm/@mogamoga1024/typing-jp@1.0.5/dist/cdn/typing-jp.js"></script>
</head>
<body>
  <div id="text"></div>
  <div id="roman-container">
    <span id="roman1"></span><span id="roman2"></span>
  </div>
  <div id="result"></div>

  <script src="question.js"></script>
  <script src="main.js"></script>
</body>
</html>
```

```css:style.css
* {
  font-variant-ligatures: none;
}

#text {
  font-size: 2rem;
}

#roman-container, #result {
  font-size: 1.5rem;
}

#roman1 {
  color: red;
}
```

</details>

## 最後に

使ってね～☆

## ライブラリを作ろうと思ったきっかけの記事

https://qiita.com/toyohisa/items/6fa7b4e9ed5d33b7f295

## 昔書いたやつ

https://qiita.com/mogamoga1337/items/587cb5db145a9a7a5aa6