【教師オリジナルアプリを作ろう】ソフトウェアキーボードで語句を入力する/クラスとオブジェクトの概念(JavaScript)

この連載では,スマホを使って生徒に英語のトレーニングを行わせるwebアプリを開発し,その作り方を学びます。今回は,ソフトウェアキーボードを表示して,空欄に当てはまる語句を入力させるwebアプリを作ります。使用する言語はJavaScriptです。

クラスを定義する

これまでいくつかのwebアプリを作ってきましたが,コードの規模が小さく,とりあえず動けば良いという考えで作ってきたので,あまり良いコードとは言えませんでした。

そこで,クラスという概念を用いてコードをより構造化し,パーツとして使いまわしができるようにしていきます。

クラスとは,簡単に言えば,いくつかの機能を一つにまとめたものです。

例えば自動車をイメージしてみましょう。自動車の中にはエンジンや変速機,ブレーキなどいくつかのまとまった部品によって構成されています。そしてエンジンはさらに小さな部品によって作られています。

こうして,小さな部品を組み合わせて作ったエンジンのような部品を,ここではメソッドと言います。また,それらの部品をまとめたものを,クラスと言います。

ここでは,英文法のトレーニングを行う製品にFasgramという名前を付けます。これがクラスです。

そして,その中には英文を表示したり,ソフトウェアキーボードを表示するなど,それぞれ役割を持ったメソッドを定義していきます。

このように,いったんそれぞれの部品を作り,最終的にそれらの部品を呼び出すことで,プログラムとして実行していきます。

今回,作成していくクラスの概要を見てみましょう。

class Fasgram {         //クラス名
  constructor         //コンストラクタ
  filling {           //穴埋め問題
    input_processing  //文字入力時の処理
  }
  starting            //スタート画面
  closing             //終了画面
  button_home         //最初に戻るボタン
  button_confirm      //次に進むボタン
  keyboard            //ソフトウェアキーボード
}

クラスFasgramの中に,fillingstartingなどのメソッドが定義されています。fillingの中にはさらにinput_processingという関数が入っています。このようにメソッドの中にさらに部品を作ることもできます。もちろん,これをメソッドとして分離しても構いません。

クラスを作る際には,なるべくそれぞれの部品の役割が明確であり,構造として理解しやすいものを目指していくことになります。

クラスを呼び出す

クラスを呼び出すコードを書いてみましょう。

async function main() {
    const texts = await load_texts('./data/filling.json'); //問題文の読み込み
    const selector = '#display';
    fasgram = new Fasgram(selector); //fasgramのインスタンス
    await fasgram.starting(); //スタート画面
    //穴埋め問題
    for(let i = 0; i < 3; i++) {
        await fasgram.filling(texts[i]);
    }
    await fasgram.closing(selector); //終了画面
    location.reload(); //ページを読み直して最初に戻る
}

プログラム全体の流れを関数main()の中に書きます。

asyncawaitはコードを非同期で行うための記述です。関数の前にawaitを付けておくと,処理をいったんストップして次に進まないようにできます。ここでは,たとえばボタンをクリックするなどの動作を行うと,ストップが解除され次に進むようになっています。その指示は関数の中に書きます。

中身を一つずつ見ていきましょう。

データの読み込み

    const texts = await load_texts('./data/filling.json'); //問題文の読み込み

関数load_textsで問題文のjsonファイルを読み込みます。jsonファイルの中身は以下です。

filling.json

[
    {
        "ja_sentence": "彼は自分の親から自立したいと思っている。",
        "filling_sentence": "He # be independent of his parents.",
        "filling_answer": ["wants to"]
    },
    {
        "ja_sentence": "野外での長い徒歩のあと,私は冷たい飲み物が欲しかった。",
        "filling_sentence": "After a long walk in the fields I wanted something #.",
        "filling_answer": ["cold to drink"]
    },
    {
        "ja_sentence": "医者は患者にジョギングが体重を減らすのに良い方法であると説明した。",
        "filling_sentence": "The doctor explained to his patient that jogging is a good way #.",
        "filling_answer": ["to lose"]
    },
    {
        "ja_sentence": "私の母は私に自分の問題から目を背けないように言った。",
        "filling_sentence": "My mother told me # walk away from my problems.",
        "filling_answer": ["not to"]
    },
    {
        "ja_sentence": "ドアに鍵をかけないままにするとはあなたは不注意だ。",
        "filling_sentence": "It was careless # the door unlocked.",
        "filling_answer": ["of you to"]
    }
]

ja_sentenceが日本文です。filling_sentence英文で,あとで#の部分を下線に置き換えます。また,filling_answerは解答の文字列です。あとで,入力した文字列がこれに一致するかどうかで,正解と不正解を判定する仕組みです。

今回は問題を5つ用意しましたが,同じようにして継ぎ足していけば,問題数を増やすことができます。

async function load_texts(URL) {
    const data = await fetch(URL, {cache: "no-cache"});
    const obj = await data.json();
    const obj_json = JSON.stringify(obj); //json文字列に変換
    const texts = JSON.parse(obj_json); //配列に格納
    //データをシャッフル
    for(let i = 0; i < 1000; i++) {
        let a = Math.floor(Math.random() * texts.length);
        let b = Math.floor(Math.random() * texts.length);
        [texts[a], texts[b]] = [texts[b], texts[a]];
    }
    return texts;
}

問題文を読み込む関数です。読み込んだ文字列を配列に格納していき,シャッフルしてランダムに表示されるようにしています。

クラスのインスタンス

    const selector = '#display';

問題文を表示するセレクタを指定します。

いったん,html上で <div id="display"></div>というブロック要素を書いておくと,あとでjqueryを使ってこのブロック要素を#displayという名前で呼び出すことができます。

    fasgram = new Fasgram(selector); //fasgramのインスタンス

ここで,クラスFasgramのインスタンスを行います。

インスタンスは実体化とも訳されるようですが,実はクラスはコードを書いただけでは使うことができず,インスタンスによってはじめて使えるようになります。

コードの意味としてはクラスFasgramをインスタンス化してオブジェクトとしてfasgramに格納する,ということなのですが(一般的にプログラミング言語では大文字と小文字は区別されます),非常に抽象的な概念に思えるかもしれません。

難しければ,機械の起動スイッチを押している,というくらいに理解しておけば良いでしょう。「インスタンス=起動スイッチON」です。

また,クラスに初期値としてselector='#display'を与えます。クラス内のメソッドは,この情報をもとにして,ブロック要素displayの中に問題文などを表示していきます。

awaitの必要性

    await fasgram.starting(); //スタート画面

スタート画面を表示します。

最初に説明した通り,クラスの中にstarting()というメソッドが書いてあり,これを呼び出すにはfasgram.starting()と書きます。starting()の内容は最後に紹介します。

関数の前にawaitがあります。関数starting()の中には,ボタンをクリックしたときにストップを解除する命令が書いてあり,ボタンをクリックする(またはエンターキーを押す)とコードが次の行に進んでいきます。

ちなみにいくつか設定されているawaitを書かなかったとしたら,どうなるでしょう。

この場合,画面は表示された瞬間に消えて,関数の最後の処理まで一気に進んでしまいます。結果的に,画面には何も表示されず,開発者は頭を抱えることになります。

asyncやawaitはInternet Explorerなどの古いブラウザでは対応していませんが,webアプリを書く上では非常に便利な機能なので,今回のコードで多く使われています。

問題文の表示

for(let i = 0; i < 3; i++) {
        await fasgram.filling(texts[i]);
}

for文で3回繰り返し処理を行い,問題文を3つ連続で表示させます。ここでもawaitを用いて,一つの問題を解き終えてから,次の問題に進むようにしています。

fasgram.filling()が問題文を表示するメソッドです。中身はあとで紹介します。

メソッドには先ほど読み込んだ問題文のデータtextsの一部を渡します。最初にtexts[0]を渡して,次にtexts[1]texts[2]と,問題文を一つずつ渡していきます。

配列の中には3つのデータが入っている点に注意してください。例えば

 texts[0] = {
        "ja_sentence": "彼は自分の親から自立したいと思っている。",
        "filling_sentence": "He # be independent of his parents.",
        "filling_answer": ["wants to"]
    },

のようになっています。従って,メソッドにデータを渡す際には,これらのデータが一組になって渡されています。

終了画面

    await fasgram.closing(selector); //終了画面
    location.reload(); //ページを読み直して最初に戻る

スタート画面と同じように,メソッドclosing()を用意して,終了画面を表示します。

ボタンがクリックされると,reload()に進み,ページを読み直して最初の画面に戻ります。

クラスを用いる利点

クラスを用いることで,関数main()の中にwebアプリの動作の手順を簡潔にまとめることができました。

もちろん,これらのメソッドを,単に関数として書いても良いのですが,クラスにまとめることによって,他のプログラムからこれらの機能を使うときに,混乱を防ぐことができる利点があります。コードが大規模になってくると,その関数がどこにあるのかだんだん分からなくなってくるので,クラスとしてまとめておけば,コードの全体像もつかみやすくなります。

クラスの使い方の学習が終わりました。次に,クラスの中にメソッドを書いていきましょう。

クラスの定義とコンストラクタ

class Fasgram {

クラスの宣言です。ここでは,クラスFasgramを定義します。

    constructor(selector) {
        this.selector = selector;
        this.id = 1;
        this.mp3 = {
            start: new Audio('./mp3/start.mp3'),
            correct: new Audio('./mp3/correct.mp3'),
            incorrect: new Audio('./mp3/incorrect.mp3'),
            cursor: new Audio('./mp3/cursor.mp3'),
            cancel: new Audio('./mp3/cancel.mp3')
        }
    }

次に,コンストラクタを定義します。コンストラクタはクラスのインスタンスを実行したときに設定される初期値を書き込む部分です。ここで設定した初期値はその下に記述するメソッドの中で共有されます。constructor()の引数selectorは,先ほど紹介したコードでnew Fasgram(selector)として渡した引数を受け取ります。インスタンス化するときに,selector='#display'としていたので,this.selector='#display'となります。

また,this.idは問題番号で,1から始めます。

this.mp3は効果音を格納したオブジェクトです。効果音のmp3ファイルを読み込んでメソッドの中で使います。たとえば,this.mp3.start.play()と書くと,スタートボタンを押したときのクリック音を再生することができます。

this問題の回避

コンストラクタの下にそれぞれのメソッドを書いていきます。

    async filling(texts) {
        let self = this;

まずは,穴埋め問題を表示するメソッドfilling()です。はじめにlet self = thisとしてselfがオブジェクトfilling()を指すように固定します。

先ほども登場したthisですが,thisはそれを内包するオブジェクトを指しています。しかし,このthisはしばしば問題を起こします。

たとえば,先ほど効果音を再生するにはthis.mp3.start.play()のように書けばよいと言いましたが,もしメソッド内に定義された関数の中でこのように書くとエラーを起こします。このときthisはメソッドではなく定義された関数を指すからです。

今回のコードでは,イベントリスナーやPromise()のように,メソッドの中にいくつもの関数を用いているので,その中でthisを使ってもうまくいきません。そこで,いったんメソッドを指定するselfを作って,あとで混乱が起きないようにしているのです。

この辺りも,JavaScript学習者にとっては非常に理解が困難な部分かもしれません。

今回のコードで言えば,問題となるのはコンストラクタ内で定義された変数やオブジェクトを参照する場合に限るので,とりあえずコンストラクタ内の変数などを使うときには,メソッドの先頭でselfを設定しておけばよい,というくらいに理解しておけば良いでしょう。あとになって,「あれ?このthisって何を指すの?」と頭を抱える可能性を減らすための工夫です。

問題文の表示

        const content = '<div id="btn-home"></div>'
          //「最初に戻る」ボタン
            + '<p id="instruction" class="size-4">'
          //指示文
            + '適切な英文になるように,語句を入力しなさい。'
            + '</p>'
            + '<p id="jp-sentence" class="size-3"></p>'
  //日本文
            + '<p id="en-sentence" class="size-2"></p>'
  //英文
            + '<p id="comment-box" class="size-3"></p>'
  //正解・不正解の表示
            + '<div id="keyboard"></div>'
  //ソフトウェアキーボード
            + '<div id="btn-confirm"></div>';
  //「次に進む」ボタン
        $(self.selector).html(content);

ブロック要素を準備します。ブロック要素は言わば中身を入れる箱であり,あとでこれらの箱の中に実際に内容を入れることによって画面上に文字を表示します。

ブロック要素はid=によって識別されます。たとえば,btn-homeは「最初に戻る」ボタンを表示する部分です。

こうして,いったんhtmlのコードを文字列contentに格納して,.html()で表示します。$(self.selector).html(content)はjqueryのコードです。self.selectorはhtmlを表示するセレクタを指します。selfは上で述べた通りメソッドfilling()を指していて,結果的にコンストラクタの中で定義されたthis.selectorを指します。つまり,self.selector='#display'ということになります。

話がややこしくなってきたので,ここでおさらいしましょう。

もともと,selector = '#display'として,その次の行において,クラスのインスタンス化でnew Fasgram(selector)というコードを書き,クラスにselectorを渡しました。そして,コンストラクタでconstructor(selector)としてselectorという文字列を受け取り,this.selector = selectorで,文字列をさらに受け取りました。そして,メソッドの中でself=thisとしたので,self.selectorをひたすら遡っていくと,self.selector='#display'である,ということになるのです。

文字列オブジェクト

        const ja_sentence = texts.ja_sentence;
        let filling_sentence = new String(texts.filling_sentence);
        var answer = new String(texts.filling_answer);

メソッドが受け取ったオブジェクトtextsからそれぞれの要素を取り出します。ja_sentenceは日本文,filling_sentenceは英文,anserは解答の文字列です。

new String()は指定した文字列から文字列オブジェクトを生成します。英文と解答を文字列オブジェクトにしているのは,あとで文字列の文字数などを取得するためです。文字列に操作を加えたい場合には文字列オブジェクトとして扱うと良いでしょう。

        self.button_home('#btn-home'); //最初に戻るボタン
        $('#jp-sentence').text('('+self.id+') '+ja_sentence); //日本文

「最初に戻る」のボタンと,日本文を表示します。button_home()はメソッドで,あとで紹介します。

英文に下線部を入れる

英文の一部を下線部に置き換えて表示します。下線部は単語の文字数に応じて用意する必要があります。

        const answer_length = answer.length; //解答の文字数を取得

まず,解答の文字数を取得します。.lengthは文字数を取得します。たとえば,answer='wants to'なら,answer.length=8です。

次に,下線部をインライン要素<span>~</span>で囲みます。一般的にブロック要素は<div>~</div>を使いますが,文字列の中など,行を変えずにブロック要素を作りたいときには<span>~</span>を使います。

        blank += '&ensp;<span>';
        var count = 0;
        for(let i = 0; i < answer_length; i++) {
            if(answer.charAt(i) != ' ') {
                blank += ' <span id="blank'+count+'" class="bold color-blue">_</span> ';
                count += 1;
            } else {
                blank += '</span>&emsp;<span>';
            }
        }
        blank += '</span>&ensp;';

charAt()は,指定した位置の文字を取り出します。たとえば,charAt(3)なら,先頭を0番目として,4文字目を取り出します。つまり,answer='wants to'ならば,answer.charAt(3)='t'です。

取り出した文字が空白のとき,それを単語の句切れとして<span>を閉じます。少し動きが分かりにくいので,出来上がる文字列の概要を示します。

answer = 'wants to'
blank =
'<span>
    <span id="blank0">w</span>
    <span id="blank1">a</span>
    <span id="blank2">n</span>
    <span id="blank3">t</span>
    <span id="blank4">s</span>
</span>
<span>
    <span id="blank5">t</span>
    <span id="blank6">o</span>
</span>

このように,それぞれの文字にidを設定し,あとで下線を文字に置き換えるときの目印にします。単語の間の空白は無視しますが,実際には&emsp;という空白文字を入れます。

構造を見ると,それぞれの単語がさらに<span>~</span>で囲まれているのが分かります。これは単語を一つのかたまりとするためで,単語の途中で改行されるのを防ぐためです。

また,countは空白の除いた文字数を表します。

        filling_sentence = filling_sentence.replace('#', blank);
        $('#en-sentence').html(filling_sentence); //英文

.replace()で英文の文字列の#の部分を,上で作った下線に置き換え,.html()で表示します。

ソフトウェアキーボード

        const key_selectors = self.keyboard('#keyboard'); //ソフトウェアキーボード

画面の下に,ソフトウェアキーボードを表示します。.keyboard()はメソッドで,返り値としてそれぞれのキーのセレクタを格納した配列を返します。これを,key_selectorsに格納します。

ここから,ソフトウェアキーボードを作る部分を見ていきましょう。

keyboard(selector) {
        const key_set = [
            ['q','w','e','r','t','y','u','i','o','p'],
            ['a','s','d','f','g','h','j','k','l'],
            ['z','x','c','v','b','n','m','BS']
            ];

まず,selectorを受け取ります。これは,ソフトウェアキーボードを表示するセレクタです。

次に,それぞれのキーを二次元配列として用意します。たとえば,key_set[1][0]='a'となります。最後の'BS'はバックスペースキーで,一文字戻るためのキーです。

for(let i = 0; i < key_set.length; i++) {
            content += '<div class="keyboard">';
            for(let j = 0; j < key_set[i].length; j++) {
                selectors.push('#key-' + key_set[i][j]);
                if(key_set[i][j] == 'BS'){
                    //backspaceアイコンの表示
                    content += '<button type="button" '
                    + 'id="key-BS" class="btn-key" '
                    + 'value="BS">'
                    + '<i class="fas fa-backspace"></i>'
                    + '</button>';
                } else {
                    //アルファベットの表示
                    content += '<button type="button" '
                        + 'id="key-' + key_set[i][j] + '" '
                        + 'class="btn-key" '
                        + 'value="' + key_set[i][j] + '">'
                        + key_set[i][j]
                        + '</button>';
                }
            }
            content += '</div>';
        }

ソフトウェアキーボードのhtmlコードを作ります。for文による二重ループで二次元配列を順番に操作していきます。

4行目の.push()は,配列に要素を追加します。配列selectorsはあとで返り値として使用します。

配列に要素が追加され,selectors[0]='#key-q'selectors[1]='#key-w',・・・となります。これらのセレクタは,あとでどのキーが押されたかを判別するために使われます。

ループの中ではif文によってバックスペースとそれ以外のアルファベットの処理を分けています。

<button>タグの中にあるvalueには実際に押されたキーのアルファベットが格納されており,idとは異なるものです。あとでこれらのvalueを取り出して,実際に押されたアルファベットを確定します。

たとえば,セレクタ#key-qが押されたときに,そのvalueqとなるので,それを入力された文字とします。

また,バックスペースにある<i class="fas fa-backspace"></i>Font Awesomeというアイコンフォントです。Font Awesomeは簡単にアイコンを表示することができるので良く使われます。

それぞれのキーにはbtn-keyというcssのクラスが設定されています。cssの設定を見てみましょう。

.btn-key {
    width: 10%;
    padding: 0.75em 0em;
    border: 1px solid #ddd;
    background-color: #fff;
    font-weight: 700;
    font-size: 0.75em;
    outline: none;
}

キーボードは最大で横に10個のキーが並ぶので,width: 10%として一つのキーが画面幅の10分の1になるようにしています。

その他にキーの枠線などを設定しています。この辺りは,好みに合わせてデザインを変更すると良いでしょう。

        $(selector).html(content);
        return selectors;

最後にhtmlを表示し,それぞれキーのセレクタを格納した配列をreturnで返します。

ここで作成したメソッドはあくまでソフトウェアキーボードを画面に表示するだけです。キーが押されたときの動作は,あとで記述していきます。

キーをクリックした時の処理

        await new Promise((resolve) => {

キーボードのキーがクリックされた時の処理を書いていきます。まずは,Promise()を使って,入力された文字列が決められた数に達した時点で次の処理に進むようにします。

            for(let i = 0; i < key_selectors.length; i++) {
                $(key_selectors[i]).on('click', function() {
                    $(this).effect("highlight",{ color: "#ccdeff"}, 250);
                    input = $(this).val(); //入力された文字を取得
                    finished = input_processing(input);
                    if(finished) {
                        resolve();
                    }
                });
            }

on()を使ってイベントリスナーを設置します。配列key_selectorにはソフトウェアキーボードのそれぞれのキーのセレクタが格納されており,for文でキーの数だけイベントリスナーを設置します。

イベントリスナーとは,ホームページ上で何かの出来事が起こったときに,特定の処理を行う仕組みです。ここでは'click'を用いて,キーがクリックされたときに処理を行うようにしています。

$(セレクタ).on('click', function() {
    セレクタの要素がクリックされたときの処理
    ・・・・・・
});
                    $(this).effect("highlight",{ color: "#ccdeff"}, 250);

キーのボタンに視覚エフェクトを施します。これはjquery UIの機能です。サンプルページで実際にキーを押してみると分かりますが,キーを押したときにそのキーが一瞬青く光るのが確認できます。これを実現する機能が.effect()です。

ここでセレクタにthisと書かれていますが,thisは押されたキーのことを指しています。

                    input = $(this).val(); //入力された文字を取得
                    finished = input_processing(input);

.val()はキーに設定されたvalue値を返します。このvalue値は,ソフトウェアキーボードを作るときに設定しました。もし,押されたキーがaであれば,input='a'となります。

そして,inputを関数input_processing()に渡します。この関数はあとで紹介しますが,入力されたアルファベットを英文の下線部に表示し,入力された文字が下線部の文字数に達したかどうかを判定します。例えば,解答がwants toのとき,下線は7個存在しているので,7文字目が入力されたときに,すべての文字が入力されたという判定を返します。

その結果,変数finishedに真偽値という値が返ってきます。真偽値とは真ならばtrue,偽ならばfalseで表される値のことです。

一般的に,変数には数値か文字列が格納されているイメージかもしれませんが,このように「trueかfalseで表す変数」というものもあるのです。ここで用いたケースのように,何かの数値というより,「すべての文字が入力されたかどうか」という二択の判断が必要なときに,真偽値は便利な存在です。

finishedには,すべての文字が入力されたならtrue,そうでなければfalseが返ってきます。

                    if(finished) {
                        resolve();
                    }

if(finished)if(finished == true)と同じことです。下線部の数だけ文字が入力されたらresolve()を実行してPromise()関数を抜け出し,次の処理に進みます。

ちなみに,このwebアプリではマウスのクリックやスマートフォン上でのタップの他に,PCでのキーボードの入力も受け付けます。そのためのコードを示します。コードの説明は省略します。

            $(document).on('keydown', async function(e) {
                if(e.keyCode >= 65 && e.keyCode <= 90) {
                    input = String.fromCharCode(e.keyCode).toLowerCase();
                    finished = input_processing(input);
                }
                if(e.keyCode == 8) {
                    input = 'BS';
                    input_processing(input);
                }
                if(finished) {
                    resolve();
                }
            });

正解と不正解の判定

        if(chars == answer.replace(/ /g, '')) {

下線の最後の文字が入力された段階で,正解と不正解の判定に移ります。charsが入力された文字列,answerが正解の文字列です。

.replace()に正規表現を用いていますが,説明を省略します。たとえば,answer='wants to'なら,answer.replace(/ /g, '')=wantstoとなります。要するに単語の間の空白を削除している,ということです。

こうして,入力された文字列と正解から空白を除いた文字列が一致したなら,それを正解として判定します。正解の場合の処理に進みましょう。

            self.mp3.correct.play();
            const content = '<p><i class="fas fa-check color-red"></i>'
                +' 正解</p>';
            $('#comment-box').html(content);

まず,self.mp3.correct.play()で正解の効果音を再生し,正解のメッセージを表示します。<i class="fas fa-check color-red"></i>は先ほど紹介したアイコンフォントです。

        } else {
            self.mp3.incorrect.play();
            const content = '<p><i class="fas fa-times-circle color-red"></i>'
                + ' 不正解</p>'
                + '<p>正解は '
                + answer
                + '</p>';
            $('#comment-box').html(content);
        }

次に,不正解の場合の処理です。self.mp3.incorrect.play()で不正解の効果音を再生し,不正解であることと正解のメッセージを表示します。ここに,文法的な解説を加えても良いかもしれません。

        $('#keyboard').hide();

入力が終了したので,.hide()でソフトウェアキーボードを消します。

        await self.button_confirm('#btn-confirm');

「次に進む」ボタンを表示します。awaitを設定することによって,ボタンが押されたときに次の処理に進むようにします。

        self.id += 1;

問題番号を1つ増やして,次の問題に進みます。

        $(document).off(); //イベントリスナーの解除

最後に,.on()を使ってキーに設定したイベントリスナーを.off()で解除します。イベントリスナーを解除しておかないと,次に進んだときにイベントリスナーが二重に設定されることになり,問題を起こします。具体的に言えば,次の問題ではキーを一度クリックしたら,同じ文字が2つ入力されます。そこで,いったんイベントリスナーをリセットして次に進むようにします。

アルファベット入力時の処理

先ほど定義した関数input_processing()を見てみましょう。この関数には入力された文字が渡され,下線の文字がすべて入力されたときにtrueを返す仕組みになっています。

        function input_processing(input) {
            if(input != 'BS') {
                self.mp3.cursor.currentTime = 0;
                self.mp3.cursor.play();
                chars += input;
                $('#blank' + count).text(' '+input+' ');
                count += 1;

まず,関数input_processing()は入力された文字inputを受け取ります。たとえば,ソフトウェアキーボードのaをクリックしたとき,input='a'となります。

まず,入力された文字が'BS'以外のアルファベットの場合,効果音を再生して,文字列charsに入力された文字列を加えます。charsには入力された文字列が先頭から積み上げられていきます。そして,.text()で入力された文字を下線と置き換え,何文字目まで入力したかを表す変数countを1つ増やします。

            } else {
                if(count > 0) {
                    self.mp3.cancel.currentTime = 0;
                    self.mp3.cancel.play();
                    chars = chars.slice(0,-1);
                    count -= 1;
                    $('#blank' + count).text('_');
                }
            }

バックスペースが押された場合は,上と逆の操作を行うことになります。.slice(0,-1)とすると,文字列の最後の1文字を除いた文字列が得られます。そして,countを減らすことで1つ前の状態に戻る仕組みです。

            if(chars.length == answer.replace(/ /g, '').length) {
                return true;
            }

こうして,入力された文字列charsと解答から空白を除いた文字列の長さが一致したら,「すべての文字が入力された」と判断してtrueを返します。

先ほど述べたように,trueを受け取ることで,正解と不正解の判定処理に進んでいきます。

解説は以上です。他に,スタート画面などの処理がありますが,過去の記事を参考にしてください。

まとめ

ここでは,画面上にソフトウェアキーボード表示して,空欄にアルファベットを入力するwebアプリを作成する方法を学びました。

文字が入力されたときに,その結果を画面に反映させ,正解と不正解を判定する方法を具体的に示しました。

また,クラスの概念について学びました。クラスを用いることによってコードが構造化され,それぞれの役割を明確にすることができました。それによって,コードがより大規模なものになっても情報の混乱を防ぐことができそうです。

最後に,コード全体を示します。まず,htmlです。

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width,initial-scale=1">
    <title>fasgram 英文法トレーニングwebアプリ</title>
    <script src="https://ajax.googleapis.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>
    <script src="https://ajax.googleapis.com/ajax/libs/jqueryui/1.12.1/jquery-ui.min.js"></script>
    <link rel="stylesheet" href="https://ajax.googleapis.com/ajax/libs/jqueryui/1.12.1/themes/smoothness/jquery-ui.css">
    <link rel="preconnect" href="https://fonts.gstatic.com">
    <link href="https://fonts.googleapis.com/css2?family=Noto+Serif&display=swap" rel="stylesheet">
    <link rel="stylesheet" href="https://use.fontawesome.com/releases/v5.1.0/css/all.css" integrity="sha384-lKuwvrZot6UHsBSfcMvOkWwlCMgc0TaWr+30HWe3a4ltaBwTZhyTEggF5tJv8tbt" crossorigin="anonymous">
    <script src="fasgram-flow.js"></script>
    <script src="fasgram-lib.js"></script>
    <link rel="stylesheet" href="./fasgram-filling.css">
</head>
<body>
    <div class="container">
        <header>
            <p class="size-5">fasgram 英文法トレーニングwebアプリ<span class="badge">動作サンプル</span></p>
        </header>
        <article>
            <div id="display"></div>
            <div id="log"></div>
        </article>
        <footer></footer>
    </div>
<script>
    main();
</script>
</body>
</html>

htmlは,基本的なブロック要素を設定したあと,関数main()を呼び出すだけです。

次に,関数main()のコードです。

fasgram-flow.js

//全体のフローチャート
async function main() {
    const texts = await load_texts('./data/filling.json'); //問題文の読み込み
    const selector = '#display';
    fasgram = new Fasgram(selector); //fasgramのインスタンス
    await fasgram.starting(); //スタート画面
    //穴埋め問題
    for(let i = 0; i < 3; i++) {
        await fasgram.filling(texts[i]);
    }
    await fasgram.closing(selector); //終了画面
    location.reload(); //ページを読み直して最初に戻る
}
//データの読み込み
async function load_texts(URL) {
    const data = await fetch(URL, {cache: "no-cache"});
    const obj = await data.json();
    const obj_json = JSON.stringify(obj); //json文字列に変換
    const texts = JSON.parse(obj_json); //配列に格納
    //データをシャッフル
    for(let i = 0; i < 1000; i++) {
        let a = Math.floor(Math.random() * texts.length);
        let b = Math.floor(Math.random() * texts.length);
        [texts[a], texts[b]] = [texts[b], texts[a]];
    }
    return texts;
}

クラスの設定です。

fasgram-lib.js

class Fasgram {
    constructor(selector) {
        this.selector = selector;
        this.id = 1;
        this.mp3 = {
            start: new Audio('./mp3/start.mp3'),
            correct: new Audio('./mp3/correct.mp3'),
            incorrect: new Audio('./mp3/incorrect.mp3'),
            cursor: new Audio('./mp3/cursor.mp3'),
            cancel: new Audio('./mp3/cancel.mp3')
        }
    }
    //穴埋め問題
    async filling(texts) {
        let self = this;
        //画面の初期化とレイアウトの設定
        const content = '<div id="btn-home"></div>'
            + '<p id="instruction" class="size-4">'
            + '適切な英文になるように,語句を入力しなさい。'
            + '</p>'
            + '<p id="jp-sentence" class="size-3"></p>'
            + '<p id="en-sentence" class="size-2"></p>'
            + '<p id="comment-box" class="size-3"></p>'
            + '<div id="keyboard"></div>'
            + '<div id="btn-confirm"></div>';
        $(self.selector).html(content);
        //問題を表示する
        const ja_sentence = texts.ja_sentence;
        let filling_sentence = new String(texts.filling_sentence);
        var answer = new String(texts.filling_answer);
        self.button_home('#btn-home'); //最初に戻るボタン
        $('#jp-sentence').text('('+self.id+') '+ja_sentence); //日本文
        //英文の空欄部を下線に置換する
        var chars = ''; //解答から空白を除いた文字列
        let blank = '';
        const answer_length = answer.length; //解答の文字数を取得
        blank += '&ensp;<span>';
        var count = 0;
        for(let i = 0; i < answer_length; i++) {
            if(answer.charAt(i) != ' ') {
                blank += ' <span id="blank'+count+'" class="bold color-blue">_</span> ';
                count += 1;
            } else {
                blank += '</span>&emsp;<span>';
            }
        }
        blank += '</span>&ensp;';
        filling_sentence = filling_sentence.replace('#', blank);
        $('#en-sentence').html(filling_sentence); //英文
        const key_selectors = self.keyboard('#keyboard'); //ソフトウェアキーボード
        //イベントリスナーの設置
        count = 0;
        let input;
        let finished;
        await new Promise((resolve) => {
            //クリックのとき
            for(let i = 0; i < key_selectors.length; i++) {
                $(key_selectors[i]).on('click', function() {
                    $(this).effect("highlight",{ color: "#ccdeff"}, 250);
                    input = $(this).val(); //入力された文字を取得
                    finished = input_processing(input);
                    if(finished) {
                        resolve();
                    }
                });
            }
            //キーボードが押されたとき
            $(document).on('keydown', async function(e) {
                if(e.keyCode >= 65 && e.keyCode <= 90) {
                    input = String.fromCharCode(e.keyCode).toLowerCase();
                    finished = input_processing(input);
                }
                if(e.keyCode == 8) {
                    input = 'BS';
                    input_processing(input);
                }
                if(finished) {
                    resolve();
                }
            });
        });
        //正解不正解の判定
        if(chars == answer.replace(/ /g, '')) {
            self.mp3.correct.play();
            const content = '<p><i class="fas fa-check color-red"></i>'
                +' 正解</p>';
            $('#comment-box').html(content);
        } else {
            self.mp3.incorrect.play();
            const content = '<p><i class="fas fa-times-circle color-red"></i>'
                + ' 不正解</p>'
                + '<p>正解は '
                + answer
                + '</p>';
            $('#comment-box').html(content);
        }
        $('#keyboard').hide();
        await self.button_confirm('#btn-confirm');
        self.id += 1;
        $(document).off(); //イベントリスナーの解除
        //アルファベット入力時の処理
        function input_processing(input) {
            //通常の文字のとき
            if(input != 'BS') {
                self.mp3.cursor.currentTime = 0;
                self.mp3.cursor.play();
                chars += input;
                $('#blank' + count).text(' '+input+' ');
                count += 1;
            //バックスペースのとき
            } else {
                if(count > 0) {
                    self.mp3.cancel.currentTime = 0;
                    self.mp3.cancel.play();
                    chars = chars.slice(0,-1);
                    count -= 1;
                    $('#blank' + count).text('_');
                }
            }
            if(chars.length == answer.replace(/ /g, '').length) {
                return true;
            }
        }
    }
    //スタート画面   
    async starting() {
        let self = this;
        const content = '<p class="size-2">'
            + 'ボタンを押して、トレーニングを開始しましょう。'
            + '</p>'
            + '<div class="btn-wrapper">'
            + '<button type="button" id="start-button"'
            + ' class="btn-start size-1">語句穴埋め'
            + '</button>'
            + '</div>';
        $(self.selector).html(content);
        await new Promise((resolve) => {
            $('#start-button').on('click', function() {
                self.mp3.start.play();
                setTimeout(() => {
                    $(document).off();
                    resolve();
                },1500);
            });
            $(document).on('keydown', function(e) {
                if(e.keyCode == 13) {
                    self.mp3.start.play();
                    setTimeout(() => {
                        $(document).off();
                        resolve();
                    },1500);
                }
            });
        });
    }
    //終了画面
    async closing() {
        const content = '<p class="size-1">'
            + 'トレーニング終了です。'
            + '</p>'
            + '<div class="btn-wrapper">'
            + '<button type="button" id="closing" class="btn-confirm size-1">'
            + '最初に戻る'
            + '</button>'
            + '</div>';
        $(this.selector).html(content);
        await new Promise((resolve) => {
            //ボタンが押されたらスタート画面へ
            $('#closing').on('click', function() {
                resolve();
            });
            $(document).on('keydown', function(e) {
                if(e.keyCode == 13) {
                    resolve();
                }
            });

        });
    }
    //最初に戻るボタン
    button_home(selector) {
        const content = '<div class="btn-wrapper-right">'
            + '<button type="button" id="home"'
            + ' class="btn-home size-3">'
            + '<i class="fas fa-home"></i>'
            + ' 最初に戻る'
            + '</button>'
            + '</div>';
        $(selector).html(content);
        $('#home').on('click', function(){
            const result = window.confirm('スタート画面に戻ります。');
            if(result) {
                location.reload();
            }
        });
    }
    //次に進むボタン
    async button_confirm(selector) {
        const content = '<div class="btn-wrapper">'
            + '<button type="button" id="confirm"'
            + ' class="btn-confirm size-1">'
            + '<i class="fas fa-caret-right"></i>'
            + ' 次に進む'
            + '</button>'
            + '</div>';
        $(selector).html(content);
        await new Promise((resolve) => {
            $('#confirm').on('click', function() {
                $(document).off();
                resolve();
            })
            $(document).on('keydown', function(e) {
                if(e.keyCode == 13) {
                    $(document).off();
                    resolve();
                }
            }); 
        });
    }
    //ソフトウェアキーボードの表示
    //戻り値:それぞれのボタンのセレクタを格納した配列
    keyboard(selector) {
        const key_set = [
            ['q','w','e','r','t','y','u','i','o','p'],
            ['a','s','d','f','g','h','j','k','l'],
            ['z','x','c','v','b','n','m','BS']
            ];
        let content = '';
        let selectors = [];
        for(let i = 0; i < key_set.length; i++) {
            content += '<div class="keyboard">';
            for(let j = 0; j < key_set[i].length; j++) {
                selectors.push('#key-' + key_set[i][j]);
                if(key_set[i][j] == 'BS'){
                    //backspaceアイコンの表示
                    content += '<button type="button" '
                    + 'id="key-BS" class="btn-key" '
                    + 'value="BS">'
                    + '<i class="fas fa-backspace"></i>'
                    + '</button>';
                } else {
                    //アルファベットの表示
                    content += '<button type="button" '
                        + 'id="key-' + key_set[i][j] + '" '
                        + 'class="btn-key" '
                        + 'value="' + key_set[i][j] + '">'
                        + key_set[i][j]
                        + '</button>';
                }
            }
            content += '</div>';
        }
        //キーボードの表示
        $(selector).html(content);
        return selectors;
    }
}

最後に,cssです。今回のコードでは使用されていないものも含みます。

/*フォントファミリー*/
body {
    font-family: 'Noto Serif', sans-serif;
}
/*スマートフォン用の基準文字サイズ*/
body {
    font-size: 24px;
}
/*コンテナの幅*/
.container {
    max-width: 680px;
}    
/*デスクトップ用の基準文字サイズ*/
@media screen and (min-width:680px) {
    body {
        display: flex;
        justify-content: center;
        font-size: 18px;
    }
    /*コンテナの幅*/
    .container {
        width: 680px;
    }
}
#display {
    width: auto;
}
p {
    margin: 0.5rem;
}
span {
    display: inline-block;
}
/*フォントサイズ*/
.size-1 {
    font-size: 1.5rem;
    font-weight: 400;
}
.size-2 {
    font-size: 1.2rem;
    font-weight: 400;
}
.size-3 {
    font-size: 1.0rem;
    font-weight: 400;
}
.size-4 {
    font-size: 0.75rem;
}
.size-5 {
    font-size: 0.5rem;
}
.bold {
    font-weight: 700;
}
/*文字色*/
.color-blue {
    color: #3366CC;
}
.color-red {
    color: #FF6600;
}
/*ボタンの共通設定*/
.btn-wrapper {
    display: flex;
    justify-content: center;
    align-items: center;
    flex-wrap: wrap;
    width : auto;
}
.btn-wrapper-right {
    display: flex;
    justify-content: flex-end;
    width: auto;
}
/*スタートボタン*/
.btn-start {
    font-weight: 700;
    padding: 1rem 4rem;
    margin: 1px;
    cursor: pointer;
    transition: all 0.3s;
    text-align: center;
    vertical-align: middle;
    border: solid 2px #ffffff;
    box-shadow: 0 2px 3px rgba(0, 0, 0, 0.16);
    outline: none; /*ボタンを押したときの枠線を消す*/
    color: #fff;
    background-color: #6699ff;
    letter-spacing: 0.25rem;
    border-radius: 2.5rem;
}
/*次に進むボタン*/
.btn-confirm {
    font-weight: 700;
    padding: 1rem 4rem;
    margin: 1px;
    cursor: pointer;
    transition: all 0.3s;
    text-align: center;
    vertical-align: middle;
    border: solid 2px #ffffff;
    border-radius: 1.0rem;
    box-shadow: 0 2px 3px rgba(0, 0, 0, 0.16);
    outline: none; /*ボタンを押したときの枠線を消す*/
    color: #fff;
    background-color: #99cc33;
}
/*大問表示*/
#question-number {
    font-weight: 700;
}
/*選択肢の枠線*/
.box-1 {
    padding: 0.5em 1em;
    margin: 0.25em 0;
    font-weight: 700;
    vertical-align: middle;
    color: black;
    background-color: #f2f2f2;
    border: solid 2px #ffffff;
    border-radius: 1.0em;
    box-shadow: 0 2px 3px rgba(0, 0, 0, 0.16);
}
.box-1 p {
    margin: 0; 
    padding: 0;
}
/*バッジ*/
.badge {
    padding: 2px 5px;
    margin-left: 1px;
    font-size: 75%;
    color: white;
    border-radius: 6px;
    box-shadow: 0 0 3px #ddd;
    white-space: nowrap;
    background-color: #58ACFA;
}
.btn-home {
    border: 1px solid #ccc;
    border-radius: 5px;
    padding: 5px 10px;;
    background: #f1e767;
    background: -webkit-gradient(linear, left top, left bottom, from(#fdfbfb), to(#ebedee));
    background: -webkit-linear-gradient(top, #fdfbfb 0%, #ebedee 100%);
    background: linear-gradient(to bottom, #fdfbfb 0%, #ebedee 100%);
    -webkit-box-shadow: inset 1px 1px 1px #fff;
    box-shadow: inset 1px 1px 1px #fff;
    outline: none;
}
#en-sentence {
    width: auto;
    padding: 0.75rem;
    font-weight: 400;
    line-height: 2.0;
    background: #e5eeff;
    border-radius: 0.5rem;
}
#keyboard {
    text-align: center;
}
.btn-key {
    width: 10%;
    padding: 0.75em 0em;
    border: 1px solid #ddd;
    background-color: #fff;
    font-weight: 700;
    font-size: 0.75em;
    outline: none;
}

ここで示したコードは,著作権者の許諾なしに自由に改変・再配布できます。