【Vue3でwebアプリをつくろう6】英語のディクテーションをするアプリを作る

記事の一覧はこちらから。この連載はプログラミングに興味を持つ学生を対象とした学習用コンテンツです。今回はVue3で英語学習アプリを作ってみます。

英語のディクテーションをするアプリを作る

今回は英語のディクテーション(書き取り)をするアプリを作ります。

一般的にディクテーションは英語の音声を聞きながら単語を書き取っていく作業を行います。ここでは,空白になっている単語を選択肢から選んで当てはめていくアプリを作ってみようと思います。

See the Pen dictation by Masato Takamaru (@masato-takamaru) on CodePen.

今回はコードをなるべくシンプルにするために音声を読み上げる部分は省略しました。音声による読み上げについては,【Vue3でwebアプリをつくろう4】英文読み上げ機能を追加するを参考にしてください。

上のサンプルコードを触ってみると,実際の動きが確認できると思います。

ここでやりたいことは

  • 英文の一部の単語をランダムに選び下線に置き換える(単語をマスクする)。
  • マクスした単語をボタンで表示する。
  • ボタンを押したら下線部を選んだ単語で置き換える。
  • すべての単語を選び終えたときに正解か不正解かを判定する。
  • 「戻る」ボタンを設置し,一つ前の状態に戻れるようにする。

やりたいことが多いので,コードもそれなりに複雑になります。これから紹介するコードは筆者なりにアルゴリズムを考えて作成したものですが,もっと効率の良いアルゴリズムがあるかもしれません。より効率の良いアルゴリズムを考えることは,プログラミングの醍醐味の一つです。是非チャレンジしてみてください。

初期値の設定

<html lang="ja">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width,initial-scale=1">
  <script src="https://unpkg.com/vue@next"></script>
</head>
<body>
  <div id="root"></div>
</body>
</html>

HTMLのコードです。<div id="root"></div>の部分にVueで作成したコンポーネントが表示されます。

次に,JavsScriptのコードを見ていきましょう。

const en = 'He wants to be independent of his parents.'

今回使用する英文の文字列をenに格納します。

const RootComponent = { ・・・・・・ }
const app = Vue.createApp(RootComponent);
app.mount('#root');

RootComponentにVueのコンポーネントを記述し,createAppでインスタンスした上で,mountで表示します。RootComponentの中身を見ていきましょう。

  data () { return {
    origin: en,
    text: setData(),    //初期設定
    p: [],              //正解・不正解
    showBackward: true, //「戻る」ボタンの表示・非表示
    message: ''         //メッセージの本文
  }},

setData()は初期設定を行うメソッドであとで述べます。setData()はオブジェクトを返してくるので,それをtextに格納します。オブジェクトは色々なものを指す抽象的な物体のようなものなのですが,ここでは何らかのデータのかたまりであると思ってください。

pは正解・不正解の情報を格納します。たとえば

____ wants to ____ independent ____ his parents.

となっているとします。このときheのボタンを押したとすると,これは正解なのでp=['correct']となります。このとき,p.length=1です。画面は次のように変わります。

Hecorrect wants to ____ independent ____ his parents.

次にofのボタンを押すと,これは誤りなので,p=['correct', 'incorrect']となります。このとき,p.length=2です。画面は次のように変わるでしょう。

He correct wants to ofincorrect independent ____ his parents.

.lengthは配列の大きさを表します。これによってボタンがいくつ押されたかが分かります。これを利用して,次にどの下線部を単語に置き換えるかを判断します。

setData()を見ていきましょう。

function setData() {
  let obj = {
    words: en.split(' '),   //本文を単語ごとに分割
    record: [],             //ボタンを押した順番の記録
    mask: [],               //マスクする単語の位置
    opt: [],                //選択肢の文字列
    optMask: [],            //選択肢が表す単語の位置
    optShow: []             //ボタンの表示・非表示
  }
  let n = Array.from(Array(obj.words.length).keys()); //連番の配列
  //シャッフル
  for(let i = 0; i < 100; i++) {
    let a = Math.floor(Math.random() * n.length);
    let b = Math.floor(Math.random() * n.length);
    [n[a], n[b]] = [n[b], n[a]];
  }
  //マスクする単語数:1語+全体の25%
  let amount = 1 + Math.round(obj.words.length * 0.25);
  //マスクする単語数にしたがって設定を行う
  for(let i = 0; i < amount; i++) {
    obj.mask.push(n[i]);            //マスクする単語の位置を追加
    obj.opt.push(obj.words[n[i]]);  //選択肢の文字列を追加
    obj.optMask.push(n[i]);         //選択肢が表す単語の位置を追加
    obj.optShow.push('true');       //ボタンの表示を追加
  }
  obj.mask.sort((a, b) => a - b); //昇順に並べ替え
  //マスクされた単語を下線に置き換える
  for(let i = 0; i < obj.opt.length; i++) {
    obj.words[obj.mask[i]] = ' ____ ';
  }
  return obj;
}

ここでは,初期設定を行います。データを格納するオブジェクトobjを作ります。

  let obj = {
    words: en.split(' '),   //本文を単語ごとに分割
    record: [],             //ボタンを押した順番の記録
    mask: [],               //マスクする単語の位置
    opt: [],                //選択肢の文字列
    optMask: [],            //選択肢が表す単語の位置
    optShow: []             //ボタンの表示・非表示
  }

wordsは.split()によって英文を分割した配列です。

words = ['He','wants','to','be','independent','of','his','parents.']

配列に分割することで,それぞれの単語を番号で表すことができます。

He0 wants1 to2 be3 independent4 of5 his6 parents.7

たとえば,words[3]='be' です。また,words.length = 8 となります。

  let n = Array.from(Array(obj.words.length).keys()); //連番の配列

上の命令を実行すると,配列の長さに応じた連番の配列が作られます。

n = [0,1,2,3,4,5,6,7]

これをシャッフルします。

//シャッフル
  for(let i = 0; i < 100; i++) {
    let a = Math.floor(Math.random() * n.length);
    let b = Math.floor(Math.random() * n.length);
    [n[a], n[b]] = [n[b], n[a]];
  }

aで指定された番号とbで指定された番号を入れ替えるという作業を100回行うことでシャッフルを行います。

n = [3,5,0,1,4,7,2,6]

次に,マスクする単語数を決めます。

  //マスクする単語数:1語+全体の25%
  let amount = 1 + Math.round(obj.words.length * 0.25);

マスクする単語数が0だと問題が成り立たないので,最低でも1語はマスクすることにします。その上で全体の単語数の25%を加えて,マスクする単語数を決定します。ここで挙げている例文では,3つの単語をマスクし,amount = 3 となります。

  //マスクする単語数にしたがって設定を行う
  for(let i = 0; i < amount; i++) {
    obj.mask.push(n[i]);            //マスクする単語の位置を追加
    obj.opt.push(obj.words[n[i]]);  //選択肢の文字列を追加
    obj.optMask.push(n[i]);         //選択肢が表す単語の位置を追加
    obj.optShow.push('true');       //ボタンの表示を追加
  }

--->

obj.mask = [3, 5, 0]
obj.opt = ['be', 'of', 'He']
obj.optMask = [3, 5, 0]
obj.optShow = [true, true, true]

for文で情報を配列に格納していきます。このように,シャッフルした配列の先頭から番号を取り出すことでどの位置の単語をマスクするかを決めます。画面上の表示は以下のようになります。

____0 wants1 to2 ____3 independent4 ____5 his6 parents7.

選択肢のボタンは,be,of,he となり,またoptMaskの値,3, 5, 0 がそれぞれの単語が本来あるべき位置を表しています。

さらに,最初の段階ではすべてのボタンを表示するので,optShowはすべてtrueにしておきます。

  obj.mask.sort((a, b) => a - b); //昇順に並べ替え

---> obj.mask = [0, 3, 5]

sort()で値を小さい順にならべかえます。ボタンを押したときに0番目,3番目,5番目の順にマスクされた部分を単語で埋めていきます。

maskoptMaskの関係が分かりづらいかもしれません。maskは本文側が持っている情報で,optMaskは選択肢のボタンが持っている情報です。あとでボタンを押したときにmaskoptMaskの値が一致するかどうかで正解・不正解の判定を行います。

  //マスクされた単語を下線に置き換える
  for(let i = 0; i < obj.opt.length; i++) {
    obj.words[obj.mask[i]] = ' ____ ';
  }

words = ['He','wants','to','be','independent','of','his','parents.']

--->

words = ['____','wants','to','____','independent','____','his','parents.']

maskの値にもとづいて本文の一部を下線に置き換えます。

  return obj;

最後にreturnでオブジェクトを返します。data()の中でtext: setData()としていました。これによってオブジェクトがtextに格納され,たとえば関数の中ではobj.wordsとしていたものが,Vueの中ではtext.wordsとして扱われることになります。

テンプレート

  template: `
    <p>{{origin}}</p>
    <p>
      <span v-for="word in text.words">
        {{word+" "}}
      </span>
    </p>
    <div>
      <button
        v-for="(option, index) in text.opt"
        v-show="text.optShow[index]"
        @click="input(index)">
        {{option}}
      </button>
      <button v-show="showBackward" @click="backward">戻る</button>
    </div>
    <div>{{message}}</div>`,

テンプレートです。

{{origin}}は参考としてマスクされていない本文を表示する部分です。本来は必要ありません。

次に,本文を表示します。v-for="word in text.words"は繰り返し処理を表します。配列wordsにはそれぞれの単語が格納されているので,それらを順番に表示していきます。

さらに,<button></button>でボタンを表示します。v-for="(option, index) in text.optoptに格納された文字列 ['be', 'of', 'He'] を一つずつ取り出してoptionに格納します。indexは番号で0,1,2と変化していきます。

v-show="text.optShow[index]"は,要素の表示・非表示を判断します。最初はoptShow=[true, true, true]だから,すべてのボタンが表示されます。ここで,たとえば一番右のボタンを押すと,index=2をもとにoptShow=[true, true, false]となり一番右のボタンは表示されなくなります。

このようにindexは配列の何番目を操作するかを表しています。

@click="input(index)"はボタンをクリックしたときの処理で,メソッドinput()を呼び出します。

{{option.toLowerCase()}}は表示するボタンの文字列です。

最後に「戻る」ボタンを設置します。ボタンを押すとメソッドbackwardを呼び出します。問題をとき終わったときのメッセージを表示する部分として{{message}}を設置しておきます。

単語の入力,正解・不正解のメソッド

  methods: {
    input(index) {
      this.text.optShow[index] = false;  //押されたボタンを非表示に
      //マスクされた部分を選択肢から選んだ単語に置き換える
      this.text.words[this.text.mask[this.p.length]] = this.text.opt[index];
      this.text.record.push(index);   //押されたボタンの番号を記録
      //選択肢の文字列と本文の文字列が一致すれば正解
      if(this.text.optMask[index] == this.text.mask[this.p.length]) { 
        this.p.push('correct');   //正解なら配列にcorrectを加える
      } else { 
        this.p.push('incorrect'); //不正解なら配列にincorrectを加える
      }
      //終了時
      if(this.p.length == this.text.mask.length) {
        this.showBackward = false;  //「戻る」ボタンを非表示にする
        //配列のすべての要素がcorrectであるかどうかを調べる
        if(this.p.every(value => value == 'correct')) {
          this.message = 'すべて正解です。';
        } else {
          this.message = '誤りがあります。';
        }
      }
    },

メソッドinput()です。indexには左から何番目のボタンを押したかという情報が入っています。

まず,押されたボタンを非表示にしたあと,下線部をボタンの文字列に置き換えます。

具体例でコードを理解してみましょう。オブジェクト名のtextは省略して話を進めす。

words = ['____','wants','to','____','independent','____','his','parents.']

mask = [0, 3, 5]

p = []

opt = ['be', 'of', 'He']

optMask = [3, 5, 0]

最初に配列が上のような状態になっているとします。pは空の状態なので,p.length=0です。よって,mask[p.length]=0です。さらに,words[mask[p.length]]wordsの0番目となるので,'____'です。

ここに,opt[index]を代入します。一番右のボタンを押したとき index=2となるのでopt[2]='He'です。したがって

words = ['He','wants','to','____','independent','____','his','parents.']

となります。

そのあとに,recordに何番目のボタンを押したかを記録します。

if(this.text.optMask[index] == this.text.mask[this.p.length]) { 
  this.p.push('correct');   //正解なら配列にcorrectを加える
 } else { 
  this.p.push('incorrect'); //不正解なら配列にincorrectを加える
}

if文で正解・不正解を判定します。optMask[2]=0で,mask[p.length]=0なので正解ということになります。このとき,push()で配列に文字列'correct'を追加します。これでp=['correct']となります。

次に,ofのボタンを押したとします。これは左から2番目のボタンなのでindex=1です。

このとき,p.length=1となっているのでmask[p.length]=3です。上と同様に考えると,words[3]は左から2つ目の下線部を指しています。また,opt[1]='of'なので,これを代入すると

words = ['He','wants','to','of','independent','____','his','parents.']

となります。これは誤った英語です。

判定を行います。optMask[1]=5で,mask[1]=3なので一致しません。よって,p=['correct', 'incorrect']となります。

こうして3つのボタンをすべて入力し終わると,最後の判定に入ります。

//終了時
if(this.p.length == this.text.mask.length) {
  this.showBackward = false;  //「戻る」ボタンを非表示にする
  //配列のすべての要素がcorrectであるかどうかを調べる
  if(this.p.every(value => value == 'correct')) {
    this.message = 'すべて正解です。';
  } else {
    this.message = '誤りがあります。';
  }
}

every()は配列のすべての要素がある値になっているかどうかを調べるもので,ここではすべてcorrectになっているかどうかを調べます。要素がすべてcorrectになっているとevery()trueを返し,一つでも異なるものがあればfalseを返します。これをif文で判断して,trueならすべて正解,falseなら誤りがあると判定します。

一つ前の状態に戻るメソッド

backward() {
  if(this.p.length > 0) {
    this.p.pop();   //配列の最後の要素を削除して一つ戻る
    //入力された単語を下線に戻す
    this.text.words[this.text.mask[this.p.length]] = ' ____ ';
    //ボタンを再度表示する
    this.text.optShow[this.text.record.pop()] = true;
  }
}

「戻る」ボタンを押すと,一つ前の状態に戻ります。

まず,pop()で配列pの一番最後の要素を削除します。次に,単語を下線部に戻し,ボタンを再度表示します。

例えばrecord = [1,0,2]となっていたとします。これは最初に左から2番目のボタンを押して次に1番めのボタン,最後に3番目のボタンを押したということです。

このとき,record.pop()=2となり,同時に配列の最後の要素が削除され record=[1,0]となります。よって,optShow[2]=trueとすると,左から3番目のボタンが再び表示されることになるのです。

まとめ

ここでは,英語のディクテーションを行うwebアプリのアルゴリズムを作成してみました。初めにどの単語をマスクするかということ自体をランダムに決定しようとすると,話が一気に複雑になります。

実際に触ってみると分かりますが,文頭の大文字や文末のピリオドがそのままボタンの文字に反映されるため,違和感を感じるでしょう。今回はこの問題には対処せず,将来の課題として残しておきます。