【vanillaJS】バーチャルキーボードのタイピングゲーム作ってみた(クソゲー)

## 概要

普段PHPとLaravelを用いたバックエンドの開発していませんでしたが、現在参加しているベトナム企業のインターンでどうやらフロントもやることになりそう。。ということでJSのお勉強がてらバーチャルキーボードでタイピングゲームをするというとんでもないクソゲーを作ったのでそのアウトプットとして今回記事を書きます。（笑）
JSにフォーカスしたいのでその他は割愛します。また、かなり考えて作ったつもりですがフロントに疎いのでこうした方が良い等ありましたらご教授いただきたいです！

## 使用技術

今回はJSのみ記事に書きますが一応以下で作りました。
- HTML
- SCSS
- JavaScript (cookie使用)

## 完成物

### リポジトリ

https://github.com/yuta-2001/VirtualTypingGame

### デモ

![ダウンロード.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3470041/d92c977b-c4a5-b8a9-4c8d-e05eac68143b.gif)

### 仕様

**Welcomeページ**

- 問題を選択
- 名前とメアドを入力(validationの練習として)
- スタートボタンを押す。

**CountDownページ**

- 3カウントしてゲームをスタートする

**Gameページ**

- PCの画面上に自作のキーボードを用意する。
この動画を参考にしました（少し変えてます）:
[Virtual Keyboard in JavaScript - HTML, CSS & JavaScript Tutorial (Project Video)](https://youtu.be/N3cq0BHDMOY)
- 画面上にお題が出される。
- エンターを画面上のキーボードで押すと回答が完了。

**Resultページ**

- かかった時間を表示
- 間違えた問題＆これまで解けた時間のランキングを表示。(Cookieを使用)

## 実装

main.jsをページに読み込ませ、機能ごとにmodulesフォルダに対してファイルを分割していくイメージです。
実装内容と使っている関数等について少しずつ触れられたらと思います。

### 全体方針

処理を細分化し、以下のモジュール群に分けました。

- キーボード操作関連
- ゲームの操作関連
- ページ遷移関連
- カウント
- バリデーション関連

### Main.js

このファイル内でモジュールを呼び出し、htmlファイルに読み込ませます。
一つのページですべての表示を担当させているので(SPA的な)、それをオブジェクトで格納してPageManagerクラスに処理をさせます。
スタートボタンを押した後に、バリデーション、キーボードの表示、ゲーム開始の処理を呼び出しています。

```jsx
import { keyboard } from './modules/keyboard.js';
import { Game } from './modules/game.js';
import { PageManager } from './modules/pageManager.js';
import { Counter } from './modules/counter.js';
import { currentErrors } from './modules/validation.js';

window.addEventListener("DOMContentLoaded", function () {
    const pages = {
        welcomePage: document.getElementById('welcome-page'),
        counterPage: document.getElementById('counter-page'),
        gamePage: document.getElementById('game-page'),
        resultPage: document.getElementById('result-page'),
    };
    const pageManager = new PageManager(pages);

    pageManager.showPage('welcomePage');

    document.getElementById('start-button').addEventListener('click', () => {
        const level = document.querySelector('input[name="type"]:checked').value;

        // 全フィールドのバリデーション
        if (Object.keys(currentErrors).length > 0) {
            alert('Please check your input.');
            return;
        }

        pageManager.showPage('counterPage');
        const counter = new Counter(document.getElementById('count'), 3, () => {
            pageManager.showPage('gamePage');
            const game = new Game(level, pageManager);
            keyboard.init(game);
        });

        counter.start();

        document.addEventListener('keydown', function(event) {
            event.preventDefault();
        });
    });
});
```

### Modules/counter.js

main.jsの以下の部分で呼び出されています。

```jsx
const counter = new Counter(document.getElementById('count'), 3, () => {
    pageManager.showPage('gamePage');
    const game = new Game(level, pageManager);
    keyboard.init(game);
});
counter.start();
```

counterクラスでは第一引数にcountのテキストを表示させるDOM要素、第二引数にカウント数、第三匹数にカウント後にしたい処理をカスタムできるように設計しています。
特に変わったことはしていませんので次に進みます。

```jsx
export class Counter {
    constructor(element, startValue, callback) {
        this.element = element;
        this.count = startValue;
        this.callback = callback;
    }

    start() {
        const timer = setInterval(() => {
            this.element.textContent = this.count;
            this.count--;
            if (this.count < 0) {
                clearInterval(timer);
                this.callback();
            }
        }, 1000);
    }
}
```

### Modules/game.js

game.jsで定義したクラスに関してはcounterが終わった後に実行されるコールバック内で初期化処理を実行しています。

```jsx
const counter = new Counter(document.getElementById('count'), 3, () => {
    pageManager.showPage('gamePage');
    const game = new Game(level, pageManager);
    keyboard.init(game);
});
counter.start();
```

問題のタイプとページ管理のクラスにのみ依存させるようにしました。もう少し良い設計があったかも。。。
中身の処理としては問題の設定や回答の正誤判定等などを行なっています。

```jsx
import { keyboard } from './keyboard.js';
import { ScoreManager } from './scoreManager.js';

class Game {

    constructor(type, pageManager) {
        this.type = type;
        this.pageManager = pageManager;
        this.questions = this._getQuestions();
        this._shuffleQuestions();
        this.currentQuestionIndex = 0;
        this._setQuestion();
        this.startTime = Date.now();
        this.endTime = null;
        this.misstypeQuestions = [];
    }

    checkAnswer(answer) {
        if (answer === this.questions[this.currentQuestionIndex]) {
            this.currentQuestionIndex++;
            if (this._setQuestion()) {
                return true;
            }
        } else {
            alert("Wrong answer!");
            this._recordMisstypeQuestion();
            return false;
        }
    }

    _getQuestions() {
        if (this.type == 1) {
            return ['PHP', 'JavaScript', 'GO', 'Python', 'Java'];
        } else if(this.type == 2) {
            return ['Svelte', 'React', 'Vue', 'Next', 'Nuxt'];
        } else {
            return ['AWS', 'GCP', 'Azure', 'Heroku', 'Firebase'];
        }
    }

    _shuffleQuestions() {
        for (let i = this.questions.length - 1; i > 0; i--) {
            const j = Math.floor(Math.random() * (i + 1));
            [this.questions[i], this.questions[j]] = [this.questions[j], this.questions[i]];
        }
    }

    _setQuestion() {
        if (this.currentQuestionIndex >= this.questions.length) {
            this._endGame();
            return false;
        }
        document.getElementById('question').textContent = this.questions[this.currentQuestionIndex];
        console.log(this.questions[this.currentQuestionIndex]);
        return true;
    }

    _endGame() {
        this._setEndTime();
        const elapsedTime = this._getElapsedTime();
        this.pageManager.showPage('resultPage');

        const result = document.getElementById('result');
        result.textContent = `${elapsedTime}s.`;

        ScoreManager.setScores(elapsedTime);
        const ranking = ScoreManager.getScores();

        const scoreList = document.getElementById('score-list');
        ranking.forEach((time, index) => {
            const scoreItem = document.createElement('li');
            scoreItem.textContent = `${index+1} place: ${time}s.`;
            scoreList.appendChild(scoreItem);
        });

        const mistakes = document.getElementById('mistakes');

        if (this.misstypeQuestions.length > 0) {
            this.misstypeQuestions.forEach((questionIndex) => {
                const question = document.createElement('li');
                question.textContent = this.questions[questionIndex];
                mistakes.appendChild(question);
            });
        } else {
            const mistakeTitle = document.getElementById('mistake-title');
            mistakeTitle.style.display = 'none';
            mistakes.style.display = 'none';
        }

        keyboard.close();
    }

    _getElapsedTime() {
        if (this.endTime === null) {
            this._setEndTime();
        }
        return Math.floor((this.endTime - this.startTime) / 1000);
    }

    _setEndTime() {
        this.endTime = Date.now();
    }

    _recordMisstypeQuestion() {
        if (this.misstypeQuestions.includes(this.currentQuestionIndex) === false) {
            this.misstypeQuestions.push(this.currentQuestionIndex);
        }
    }

}

export { Game };
```

### Modules/keyboard.js

これもまたmain.jsでcounterクラスをインスタンス化する際のコールバック内で初期化処理を実行させています。

```jsx
const counter = new Counter(document.getElementById('count'), 3, () => {
    pageManager.showPage('gamePage');
    const game = new Game(level, pageManager);
    keyboard.init(game);
});
counter.start();
```

こちらは以下の動画を参考にしました。
https://youtu.be/N3cq0BHDMOY
少し変えているところもありますが基本的には同じ設計にしているので割愛します！ぜひみてみてください！

```jsx
export { keyboard };

const keyboard = {
    elements: {
        main: null,
        keysContainer: null,
        keys: []
    },

    properties: {
        value: "",
        capsLock: false
    },

    init(game) {
        this.elements.main = document.createElement("div");
        this.elements.keysContainer = document.createElement('div');

        this.elements.main.classList.add('keyboard');
        this.elements.keysContainer.classList.add('keyboard__keys');
        this.elements.keysContainer.appendChild(this._createKeys());

        this.elements.keys = this.elements.keysContainer.querySelectorAll(".keyboard__key");

        // Add to DOM
        this.elements.main.appendChild(this.elements.keysContainer);
        document.body.appendChild(this.elements.main);

        // game
        this.game = game;
    },

    close() {
        this.properties.value = "";
        this.elements.main.classList.add("keyboard--hidden");
    },

    _createKeys() {
        const fragment = document.createDocumentFragment();
        const keyLayout = [
            "1", "2", "3", "4", "5", "6", "7", "8", "9", "0", "backspace",
            "q", "w", "e", "r", "t", "y", "u", "i", "o", "p",
            "caps", "a", "s", "d", "f", "g", "h", "j", "k", "l", "enter",
            "z", "x", "c", "v", "b", "n", "m", ",", ".", "?",
            "space",
        ];

        // create HTML for an icon
        const createIconHTML = (icon_name) => {
            return `<i class="material-icons">${icon_name}</i>`;
        };

        keyLayout.forEach(key => {
            const keyElement = document.createElement("button");
            const insertLineBreak = ["backspace", "p", "enter", "?"].indexOf(key) != -1;

            // add attributes/classes
            keyElement.setAttribute("type", "button");
            keyElement.classList.add("keyboard__key");

            switch (key) {
                case "backspace":
                    keyElement.classList.add("keyboard__key--wide");
                    keyElement.innerHTML = createIconHTML("backspace");

                    keyElement.addEventListener("click", () => {
                        this.properties.value = this.properties.value.substring(0, this.properties.value.length - 1);
                        document.getElementById("textarea").value = this.properties.value;
                    });

                    break;

                case "caps":
                    keyElement.classList.add("keyboard__key--wide", "keyboard__key--activatable");
                    keyElement.innerHTML = createIconHTML("keyboard_capslock");

                    keyElement.addEventListener("click", () => {
                        this._toggleCapsLock();
                        keyElement.classList.toggle("keyboard__key--active", this.properties.capsLock);
                    });

                    break;
                
                case "enter":
                    keyElement.classList.add("keyboard__key--wide");
                    keyElement.innerHTML = createIconHTML("keyboard_return");

                    keyElement.addEventListener("click", () => {
                        if(this.game.checkAnswer(this.properties.value)) {
                            this.properties.value = "";
                            document.getElementById("textarea").value = this.properties.value;
                        };
                    });

                    break;

                case "space":
                    keyElement.classList.add("keyboard__key--extra-wide");
                    keyElement.innerHTML = createIconHTML("space_bar");

                    keyElement.addEventListener("click", () => {
                        this.properties.value += " ";
                        document.getElementById("textarea").value = this.properties.value;
                    });

                    break;

                default:
                    keyElement.textContent = key.toLowerCase();

                    keyElement.addEventListener("click", () => {
                        this.properties.value += this.properties.capsLock ? key.toUpperCase() : key.toLowerCase();
                        document.getElementById("textarea").value = this.properties.value;
                    });

                    break;
            }

            fragment.appendChild(keyElement);

            if (insertLineBreak) {
                fragment.appendChild(document.createElement("br"));
            }
        });

        return fragment;
    },

    _toggleCapsLock() {
        this.properties.capsLock = !this.properties.capsLock;

        for (let key of this.elements.keys) {
            if (key.childElementCount === 0) {
                key.textContent = this.properties.capsLock ? key.textContent.toUpperCase() : key.textContent.toLowerCase();
            }
        }
    },
};
```

### Modules/pageManager.js

ページ遷移を管轄するpageManagerクラスはmain.jsの最初でインスタンス化しています。

```jsx
const pages = {
    welcomePage: document.getElementById('welcome-page'),
    counterPage: document.getElementById('counter-page'),
    gamePage: document.getElementById('game-page'),
    resultPage: document.getElementById('result-page'),
};
const pageManager = new PageManager(pages);

pageManager.showPage('welcomePage');
```

引数に表示したいページ名をいれてshowPageを実行することでそのページを表示させることができます。

```jsx
export class PageManager {
    constructor(pages) {
        this.pages = pages;
    }

    showPage(name) {
        for (let page of Object.values(this.pages)) {
            page.style.display = 'none';
        }
        this.pages[name].style.display = 'block';
    }
}
```

### Modules/scoreManager.js

scoreManagerはgameクラスの_endGameメソッド内で呼び出されています。

```jsx
ScoreManager.setScores(elapsedTime);
const ranking = ScoreManager.getScores();

const scoreList = document.getElementById('score-list');
ranking.forEach((time, index) => {
    const scoreItem = document.createElement('li');
    scoreItem.textContent = `${index+1} place: ${time}s.`;
    scoreList.appendChild(scoreItem);
});
```

機能としては、ゲーム終了時にその回のスコアとcookieに残っているスコアを比較し、もし良い結果であればランキングに入れる。また、result画面でのランキング表示でのランキング取得に使用しています。

```jsx
export const ScoreManager = {
    setScores: function(newScore) {
        let scores = this.getScores();
  
        // insert new score to the right position
        for (let i = 0; i < 3; i++) {
            if (!scores[i] || newScore < scores[i]) {
                scores.splice(i, 0, newScore);
                break;
            }
        }

        // store only top 3 scores
        scores = scores.slice(0, 3);
  
        let d = new Date();
        d.setTime(d.getTime() + (30 * 24 * 60 * 60 * 1000)); // 30 days
        let expires = "expires=" + d.toUTCString();
        document.cookie = "scores=" + JSON.stringify(scores) + ";" + expires + ";path=/";
    },
  
    getScores: function() {
        const name = "scores=";
        const decodedCookie = decodeURIComponent(document.cookie);
        const ca = decodedCookie.split(';');
        for (var i = 0; i < ca.length; i++) {
            let c = ca[i];
            while (c.charAt(0) == ' ') {
                c = c.substring(1);
            }
            if (c.indexOf(name) == 0) {
                return JSON.parse(c.substring(name.length, c.length));
            }
        }
        return [];
    }
};
```

### Modules/validation.js

こちらはinputを検知してリアルタイムで監視しつつ、main.jsのスタートボタンクッリクのイベント内で使用しています。

```jsx
// 全フィールドのバリデーション
if (Object.keys(currentErrors).length > 0) {
    alert('Please check your input.');
    return;
}
```

validationRules内に、プロパティごとに、ルールとエラー時に出力する文字列を返す関数を定義しておきます。その関数をinputイベントが走るごとに発火させるという仕組みです。
また、エラーはcurrentErrorsオブジェクトに格納され、スタートボタンをクッリクした際に要素がまだ入っている場合にはゲームをスタートできないようにしています。

```jsx
export { currentErrors };

const currentErrors = {
    name: 'Name is required.',
    email: 'Email is required.',
};

const validationRules = {
    name: function(value) {
      return value ? '' : 'Name is required.';
    },
    email: function(value) {
      if (!value) {
        return 'Email is required.';
      }
      if (!/^[\w-]+(\.[\w-]+)*@([\w-]+\.)+[a-zA-Z]{2,7}$/.test(value)) {
        return 'Email is not valid.';
      }
      return '';
    }
};

const validate = (field) => {
    let value = document.getElementById(field).value;
    let error = validationRules[field](value);
  
    // エラーメッセージの表示または削除
    document.getElementById(field + '-error').innerText = error;
  
    return error;
}

for (let field in validationRules) {
    document.getElementById(field).addEventListener('keyup', (function(field) {
        return  function() {
                    let error = validate(field);

                    if (error) {
                        currentErrors[field] = error;
                    } else {
                        delete currentErrors[field];
                    }
                };
    })(field));
}
```

## 登場した関数等

メモ程度に記録しています。

### Object.keys() & Object.values()

配列またはオブジェクトを引数に入れることでkeyまたはvalueを出力することができるものです。

```jsx
const fruits = ["Banana", "Orange", "Apple", "Mango"];
console.log(Object.keys(fruits));

// 結果
0, 1, 2, 3

const person = {
  firstName: "John",
  lastName: "Doe",
  age: 50,
  eyeColor: "blue"
};
const keys = Object.keys(person);

// 結果
firstName, lastName, age, eyeColor
```

### Dateオブジェクト

Dateオブジェクトは日付操作系のメソッドを大量に持っているオブジェクトです。

```jsx
const d = new Date();

// 1~31を返す
d.getDate();

// 0~11を返却(1~12月)
d.getMonth();
```

### substring()

第一引数にスタート、第二引数に終了地点を設定することでその間の文字列を抽出できる。

```jsx
let text = "Hello world!";
console(text.substring(1, 4));

// indexが1から4までの文字列を返す
ell
```

### indexOf()

引数に特定の文字列を入れて呼び出すことで、その文字列がどこから始まるかを返してくれます。ない場合は0を返します。
includeと同様に存在チェックをするときのよく使われます。

```jsx
let text = "Hello world, welcome to the universe.";
console.log(text.indexOf("welcome"));

// indexが13からの部分にwelcomeがあるのでその始まりの位置を返す
13
```

### includes()

includesはその名の通り、引数に指定したものが含まれているかを検証します。含まれていた場合はtrue, その他の場合にはfalseを返します。
個人的にindexOfより直感的で良いかと思います。

```jsx
let text = "Hello world, welcome to the universe.";
console.log(text.includes("world"));

// 含まれているのでtrueを返す
true
```

### charAt()

これは単純で、引数で指定したindexの文字を取得します。

```jsx
let text = "HELLO WORLD";
console.log(text.charAt(0));

// indexが0の文字を取得
H
```

### Cookie

ブラウザ側に期間を決めてデータを保存させる機能です。
取得も代入もできるのでその特性を用いて色々な設定が可能です。
通常 プロパティ=データ, といった具合に保存します。

```jsx
console.log(document.cookie);

// クッキーに保存されている内容が;で区切られて返ってくる
"username=John Doe; expires=Thu, 18 Dec 2013 12:00:00 UTC; path=/";

// 以下のような方式でcookieに保存する期間を設定可能
document.cookie = "max-age=残存期間";
document.cookie = "expires=日付";
```

ちなみにchromeの検証でデータを確認することができます

![スクリーンショット 2023-08-02 16.49.50.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3470041/137e2edb-ab79-f995-7f4a-ff7b94bf7d4b.png)


## 最後に

今回はちょっとしたゲームですが、やっぱり作るのが一番勉強になるなと思いました。
重ねて間違いやこうした方が良い等ありましたらご指摘ください！
フロント・バック・インフラをマスターして少し大きめなSPAアプリ作れるように頑張ろうと思います。

## 参考
https://youtu.be/N3cq0BHDMOY
https://www.w3schools.com/