初心者によるJavaScriptでタイピングゲームを作るための学習

この記事はドットインストール「JavaScriptでタイピングゲームを作ろう」を学習するための記事です。
今まで学んできたことを複合的に使うため、初心者にとってはなかなかやりがいのあるプログラムでした。
まだ慣れていないためこのコードはどういう意味なのかなど、引っかかったところがあったので、そのあたりを自分なりにアウトプットすることで、理解を深めたいです。


まずはHTMLです。

```html:
    <p id="target">word</p>
    <p class="info">
      Letter count: <span id="score">0</span>
      Miss count: <span id="miss">0</span>
    </p>
```
target部が打つ文字です。成功数やミスの数をカウントするので、そのエリアも用意します。

次にJavaScript。

タイピングゲームなので、
・打つべき文字列を用意し
・タイプした文字を取得し
・それを表示する
というおおまかな流れがあるのでそれを作ります。


```JavaScript:
  const word = 'apple'; // 打つべき文字列
  const target = document.getElementById('target'); // 表示エリアを取得
  target.textContent = word; // 打った文字列をセット

  window.addEventListener('keydown', e => { // キーを押したら以下の処理を実行
    console.log(e.key); // ひとまずコンソール表示
  });
```

ざっくりこんな感じになります。

次にタイプする文字は、文字列の何番目かを管理する必要があるので、それをlocとして宣言します。
ただしこれは後に値を再代入するので、constではなくletを使います。
同様に、正解数やミス数を管理し、それを表示させるための要素を取得します。

```JavaScript:
  let loc = 0;
  let score = 0;
  let miss = 0;

  const target = document.getElementById('target');
  const scoreLabel = document.getElementById('score');
  const missLabel = document.getElementById('miss');
```
## 正誤判定

次は正誤判定です。
打ったキー(e)がwordのloc番目の文字と同じか判定して、正解ならscoreカウントをプラス1して次の文字へ、不正解ならmissカウントをプラス1し、それらをブラウザ上に表示させます。


```JavaScript:
  window.addEventListener('keydown', e => {
    if (e.key === word[loc]) { // 打ったキー(e)がwordのloc番目の文字と同じなら
      loc++; // 次の文字へ
      score++; // 正解数プラス1
      scoreLabel.textContent = score; // 正解数を表示
    } else { // 違っていたら
      miss++; // ミス数プラス1
      missLabel.textContent = miss; // ミス数を表示
    }
  });
```


## 次に打つ文字をわかりやすくする

次に行うのは、「正解した文字をなにか別のものに変えて、次に打つ文字をわかりやすくする」です。

'apple'とずっと表示されていても、どれを打つべきなのかわからないと困りますよね。3番目まで正解していたのなら**'_ _ _le'**となっていれば、次に打つのは'l'なんだとすぐにわかります。

従って、上記の内容を**今何文字目を打つべきかはlocで管理してるので、0からloc番目までを'_'で埋める**ことで処理します。
そのアンダースコアを格納するための変数をplaceholderと定義して、空の文字列に初期化しておきます。そして、正解した数(locの数)の分だけアンダースコアを連結させます。

それができたらブラウザに**placeholder + wordのインデックスがloc番目以降**と表示させるためにtargetを更新します。

これにはsubstringを使います。
> substring(引数) // 引数以降の部分文字列を取得

```JavaScript:
  function updateTarget() { // 正解した文字を _ に変換させる
    let placeholder = ''; // '_'を格納するための空の変数
    for (let i = 0; i < loc; i++) { 
      placeholder += '_'; // 呼び出された数だけ'_'を連結する
    }
    target.textContent = placeholder + word.substring(loc); // loc番目までは'_'、loc番目以降はそのまま表示
  }

  window.addEventListener('keydown', e => {
    if (e.key === word[loc]) {
      loc++;
      updateTarget(); // 上記を呼び出す
      score++;
      scoreLabel.textContent = score;
    } else {
      miss++;
      missLabel.textContent = miss;
    }
  });
```

これで打つべき文字がわかりやすくなりました。


## 問題を複数用意し、正解したら次の問題へ移行する
問題をwordsという配列に用意し、それをwordにランダムで入れていきます。

```JavaScript:
  const words = [
    'apple',
    'sky',
    'blue',
    'middle',
    'set',
  ];
  let word = words[Math.floor(Math.random() * words.length)];
```
次に問題の文字を全部打ち終わったら次の問題へ移行する、です。
これを実行するタイミングは、正解する（locを更新したタイミング）で、もしlocが問題の文字列数と一致したら、次のwordsに移行する、で良いでしょう。
従って`if (e.key === word[loc])`内にそれを判定するif文を追加します。


```JavaScript:
  window.addEventListener('keydown', e => {
    if (e.key === word[loc]) {
      loc++;
      if (loc === word.length) { // locが問題の文字列数と一致したら
        word = words[Math.floor(Math.random() * words.length)]; // 別の問題を選択する
        loc = 0; // locを0に初期化
      }
      updateTarget();
      score++;
      scoreLabel.textContent = score;
    } else {
      miss++;
      missLabel.textContent = miss;
    }
  });
```
これについて**Math.random()を使った際に、現在と同じものが選択されないのか？**と気になったのですが、どうやらそれはないようです。なので、改めてランダムを呼び出す = 次の問題へ以降ということになります。
次の問題へ移行したら、打つべき文字は新しい問題のインデックス0番目になるので、忘れずに`loc = 0;`しましょう。

## ゲームをスタートさせる

ページを表示したときにまず'click to start'と表示し、クリックしたらゲームが始まるようにします。
html側で

```html:html
    <p id="target">click to start</p>
```
とし、JavaScript側で**ウィンドウをクリックしたら、word(問題)を表示させる**処理を書きます。これもwindowに対してイベントを追加します。


```JavaScript:JavaScript
  window.addEventListener('click', () => {
      target.textContent = word; // 問題を表示
    }
  });
```
これで間違いなく表示されます。

## タイマーを追加する

まずタイマーの初期設定です。


```html:html
    <p class="info">
      Letter count: <span id="score">0</span>
      Miss count: <span id="miss">0</span>
      Time left: <span id="timer">0.00</span> <!--timer表示エリア-->
    </p>
```

```JavaScript:JavaScript
  let loc = 0;
  let score = 0;
  let miss = 0;
  const timeLimit = 3 * 1000; // ミリ秒なので1000倍し、3秒に設定
  let startTime = 0; // ゲームスタート時刻を保持するための変数

  const target = document.getElementById('target');
  const scoreLabel = document.getElementById('score');
  const missLabel = document.getElementById('miss');
  const timerLabel = document.getElementById('timer'); // タイマー要素取得
```

次は最初にウィンドウがクリックされた時にゲームが始まるので、その時刻をstartTimeに代入し、残り時間を表示させます。その関数`updateTimer`とし、別途用意しましょう。

```JavaScript:JavaScript
  window.addEventListener('click', () => {
      target.textContent = word;
      startTime = Date.now(); // 現在時刻を代入
      updateTimer(); // 残り時間表示関数
    }
  });
```

ゲームの残り時間を計算する`updateTimer`関数を設定します。
残り時間は**ゲーム開始時刻 + 制限時間 - 現在時刻**で計算できます。
例えばゲーム開始時刻：0:00:10 , 制限時間：3秒 , 現在時刻 0:00:12なら**10 + 3 - 12 = 残り1秒**とわかりますね。toFixed(n)は小数点以下n位まで表示させるというメソッドです。
そして上記を一定時間ごとに繰り返すことで、残り時間をカウントダウンさせます。

```JavaScript:JavaScript
  function updateTimer() {
    const timeLeft = startTime + timeLimit - Date.now(); // 残り時間を計算
    timerLabel.textContent = (timeLeft / 1000).toFixed(2); // タイマーラベルに秒で表示

    const timeouId = setTimeout(() => { // updateTimerを呼んだ10ミリ秒後に
      updateTimer(); // updateTimerを呼び出す = updateTimerを繰り返す
    }, 10);

    if (timeLeft < 0) { // 残り時間が0以下になったら
      clearTimeout(timeoutId); // timeoutIdを解除する
      alert('Game Over'); // アラートを表示
    }
  }
```
setTimeoutとは**一定時間後に処理を実行する**です。この場合、10ミリ秒後に残り時間を計算し表示する、を繰り返し、残り時間が0になったら`clearTimeout`で処理を終えてアラート表示させます。

## タイマーの不具合を解消する

上記の場合、アラートが表示された際に残り時間が0.00から少しずれてしまうことがあります。
その対策として、ゲームが終わったらtextContentで0.00と表示させます。

```JavaScript:JavaScript
    if (timeLeft < 0) {
      clearTimeout(timeoutId);
      timerLabel.textContent = '0.00'; // 0.00を表示
      alert('Game Over');
    }
```

ただしこれでも正確に表示されません。その原因は**ブラウザによってはアラートの処理が終わるまで画面描画処理がロックされることがある**からです。要はアラート表示が早いためですね。従ってアラート表示を遅らせます。

```JavaScript:JavaScript
    if (timeLeft < 0) {
      clearTimeout(timeoutId);
      timerLabel.textContent = '0.00';
      setTimeout(() => { // 100ミリ秒後にアラートを表示させる
        alert('Game Over');
      }, 100);
    }
```
こういうのは知らないと対処できない問題なので、今後のためにも対策含めてちゃんと覚えておきたいですね。


## 他の不具合を解消する

実行してみるとわかるのですが、
**・ゲーム中クリックを連打するとその度タイマーが戻ってしまうことや、
・最後にそのクリックした数のアラートが出てしてしまうこと、
・さらにクリックしてスタートする前でもタイピング判定が行われる**
などの問題が残っています。

これらは、クリックしたらした分だけゲームが起動しているからなんですね。従って、ゲームを起動したら**ゲーム中**というフラグを持たせて、多重起動しない、つまりupdateTimerが走らないようにします。

まずは最初にフラグをもたせます。当然始まっていないのでfalseですね。

```JavaScript:JavaScript
  let loc = 0;
  let score = 0;
  let miss = 0;
  const timeLimit = 3 * 1000;
  let startTime = 0;
  let isPlaying = false;
```

そしてゲームが始まったら`isPlaying`をtrueにします。ただすでに`isPlaying`がtrueなら処理させないというのも必要ですね。そのあたりを条件分岐します。

```JavaScript:JavaScript
  window.addEventListener('click', () => {
    if (isPlaying === true) { // isPlaying が true なら
      return; // 以下の処理をせずにreturn
    }
    isPlaying = true; // isPlaying を true へ

      target.textContent = word;
      startTime = Date.now();
      updateTimer();
    }
  });
```

同様に、`isPlaying`がfalseになるのはゲームが終わった時なので、

```JavaScript:JavaScript
    if (timeLeft < 0) {
      isPlaying = false; // ゲームが終了したので isPlaying を false へ
      clearTimeout(timeoutId);
      timerLabel.textContent = '0.00';
      setTimeout(() => { // 100ミリ秒後にアラートを表示させる
        alert('Game Over');
      }, 100);
    }
```
となります。

あわせてスタート前でも判定してしまうのをどうにかするには、**keydown時に`isPlaying`を確認してtrueじゃないならreturnする**と付け加えてあげましょう。

```JavaScript:
  window.addEventListener('keydown', e => {
    if (isPlaying !== true) { // タイプ時に isPlaying が true じゃなかったら return する
      return;
    }

    if (e.key === word[loc]) {
      loc++;
      if (loc === word.length) {
        word = words[Math.floor(Math.random() * words.length)];
        loc = 0;
      }
      updateTarget();
      score++;
      scoreLabel.textContent = score;
    } else {
      miss++;
      missLabel.textContent = miss;
    }
  });
```
こうすることで対応します。


## 正解率を表示する
ゲームが終わると現在はアラートでただ'Game Over'と表示されますが、これを正解率表示にします。
計算は正解数 / 正解数 + ミス数 * 100です。
ただ正解数 + ミス数が0だと計算できないので**条件演算子**を使っていきます。
> 条件式 ? Trueの処理 : Falseの処理


```JavaScript:JavaScript
  function updateTimer() {
    const timeLeft = startTime + timeLimit - Date.now();
    timerLabel.textContent = (timeLeft / 1000).toFixed(2);

    const timeoutId = setTimeout(() => {
      updateTimer();
    }, 10);

    if (timeLeft < 0) {
      isPlaying = false;

      clearTimeout(timeoutId);
      timerLabel.textContent = '0.00';
      setTimeout(() => {
        showResult(); // 正解率表示関数を呼び出す
      }, 100);
    }
  }

  function showResult() {
    const accuracy = score + miss === 0 ? 0 : score / (score + miss) * 100; // 正解率計算
    alert(`${score} letters, ${miss} misses, ${accuracy.toFixed(2)}% accuracy!`); // 正解率表示
  }
```
これでうまくいきました。


## リプレイができるようにする

ゲームオーバー後に'click to replay'と表示させます。


```JavaScript:JavaScript
  function updateTimer() {
    const timeLeft = startTime + timeLimit - Date.now();
    timerLabel.textContent = (timeLeft / 1000).toFixed(2);

    const timeoutId = setTimeout(() => {
      updateTimer();
    }, 10);

    if (timeLeft < 0) {
      isPlaying = false;

      clearTimeout(timeoutId);
      timerLabel.textContent = '0.00';
      setTimeout(() => {
        showResult();
      }, 100);

      target.textContent = 'click to replay'; // リプレイを促すメッセージを表示
    }
  }
```

もともとこれはwindowをクリックすることで、ゲームが始まるのでその段階で色々と初期化するようにすれば、リプレイにも対応できそうです。従って`window.addEventListener('click', () =>`に色々書いてしまいましょう。

```JavaScript:JavaScript
  window.addEventListener('click', () => {
    if (isPlaying === true) {
      return;
    }
    isPlaying = true;

    // 以下、各項目の初期化
    loc = 0;
    score = 0;
    miss = 0;
    scoreLabel.textContent = score;
    missLabel.textContent = miss;
    word = words[Math.floor(Math.random() * words.length)];

      target.textContent = word;
      startTime = Date.now();
      updateTimer();
    }
  });
```

あわせて、もともとあった初期化の項目は、宣言のみにしてあげます。

```JavaScript:JavaScript
  let word; // 宣言のみ
  let loc; // 宣言のみ
  let score; // 宣言のみ
  let miss; // 宣言のみ
  const timeLimit = 3 * 1000;
  let startTime; // 宣言のみ
  let isPlaying = false;
```



## 完成


以上ですべてのコードを書き終えました。
完成したコードは以下になります。

```JavaScript:JavaScript

'use strict';

{
  const words = [
    'apple',
    'sky',
    'blue',
    'middle',
    'set',
  ];
  let word;
  let loc;
  let score;
  let miss;
  const timeLimit = 3 * 1000;
  let startTime;
  let isPlaying = false;

  const target = document.getElementById('target');
  const scoreLabel = document.getElementById('score');
  const missLabel = document.getElementById('miss');
  const timerLabel = document.getElementById('timer');

  function updateTarget() {
    let placeholder = '';
    for (let i = 0; i < loc; i++) {
      placeholder += '_';
    }
    target.textContent = placeholder + word.substring(loc);
  }

  function updateTimer() {
    const timeLeft = startTime + timeLimit - Date.now();
    timerLabel.textContent = (timeLeft / 1000).toFixed(2);

    const timeoutId = setTimeout(() => {
      updateTimer();
    }, 10);

    if (timeLeft < 0) {
      isPlaying = false;

      clearTimeout(timeoutId);
      timerLabel.textContent = '0.00';
      setTimeout(() => {
        showResult();
      }, 100);

      target.textContent = 'click to replay';
    }
  }

  function showResult() {
    const accuracy = score + miss === 0 ? 0 : score / (score + miss) * 100;
    alert(`${score} letters, ${miss} misses, ${accuracy.toFixed(2)}% accuracy!`);
  }

  window.addEventListener('click', () => {
    if (isPlaying === true) {
      return;
    }
    isPlaying = true;

    loc = 0;
    score = 0;
    miss = 0;
    scoreLabel.textContent = score;
    missLabel.textContent = miss;
    word = words[Math.floor(Math.random() * words.length)];

    target.textContent = word;
    startTime = Date.now();
    updateTimer();
  });

  window.addEventListener('keydown', e => {
    if (isPlaying !== true) {
      return;
    }

    if (e.key === word[loc]) {
      loc++;
      if (loc === word.length) {
        word = words[Math.floor(Math.random() * words.length)];
        loc = 0;
      }
      updateTarget();
      score++;
      scoreLabel.textContent = score;
    } else {
      miss++;
      missLabel.textContent = miss;
    }
  });
}

```

かなり長い記事になってしまいました。もしかしたら分割するべきだったかもしれません。

一応編集する時はそのブロックごとにしたのですが、正直わかりにくかったと思います。
プログラムのように上に行ったり下に行ったりとする場合、どのように書いていくべきかまだつかめておりません。
行番号でも追加したほうがよかったかな。今後の課題にしたいです。