LoginSignup
1
0

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

Last updated at Posted at 2023-08-02

概要

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

使用技術

今回はJSのみ記事に書きますが一応以下で作りました。

  • HTML
  • SCSS
  • JavaScript (cookie使用)

完成物

リポジトリ

デモ

ダウンロード.gif

仕様

Welcomeページ

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

CountDownページ

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

Gameページ

Resultページ

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

実装

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

全体方針

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

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

Main.js

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

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の以下の部分で呼び出されています。

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要素、第二引数にカウント数、第三匹数にカウント後にしたい処理をカスタムできるように設計しています。
特に変わったことはしていませんので次に進みます。

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が終わった後に実行されるコールバック内で初期化処理を実行しています。

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

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

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クラスをインスタンス化する際のコールバック内で初期化処理を実行させています。

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
少し変えているところもありますが基本的には同じ設計にしているので割愛します!ぜひみてみてください!

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の最初でインスタンス化しています。

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を実行することでそのページを表示させることができます。

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メソッド内で呼び出されています。

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画面でのランキング表示でのランキング取得に使用しています。

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のスタートボタンクッリクのイベント内で使用しています。

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

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

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を出力することができるものです。

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オブジェクトは日付操作系のメソッドを大量に持っているオブジェクトです。

const d = new Date();

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

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

substring()

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

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

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

indexOf()

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

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

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

includes()

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

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

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

charAt()

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

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

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

Cookie

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

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

最後に

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

参考

https://youtu.be/N3cq0BHDMOY
https://www.w3schools.com/

1
0
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0