console.log()でスネークゲームを出力する

#きっかけ
[【2019年4月版】JavaScriptのconsoleがすごいことになってた。](https://qiita.com/koinori/items/83f119cb2d82c0ca2c1e)

こちらの記事で`console.log()`の出力結果にCSSを効かせられるという話を知りました。
CSSが効くということはlogに何か派手なものを出力できるはず！ということで、手始めにスネークゲームを作成し開発ツールのログに出力してみました。
# できたもの

|![game.gif](https://qiita-image-store.s3.amazonaws.com/0/378060/198c85fa-3464-b7cf-19de-fcb7d2414803.gif)|
|:-:|

動作ブラウザ：Google Chrome (v73.0.3683.86)

# 実装方法
### 基本的な考え方
`console.log()`の出力結果を後から変更はできないので、~~力業~~古典的な描画の仕方で動くゲーム画面を出力します。

```
・ゲーム画面の状態をCSSの装飾で作成する
　↓
・console.log()の出力を定期的に行う
　↓
・log上ではパラパラ漫画の要領で画面が動いて見える
　↓
・ゲーム画面の出力ができた！！
````

### 描画の実装
ゲーム画面の出力コードは以下のようになっています。
これをsetIntervalで定期的に呼び出すことでゲーム画面が出力されます。

```javascript:log-game.html(抜粋)
  function drawMap(fields) {
    str = new Array();
    css = new Array();

    // fields: array[y][x], ゲーム座標の状態を格納
    fields.forEach((line, y) => {
      var line_str = new Array(line.length).fill('%c ');
      var line_css = new Array(line.length).fill(
        'padding-left: 20px; padding-top: 10px; line-height: 10px; font-size:0px;'
      );
      line.forEach((element, x) => {
        switch (element) {
          case 0:
            line_css[x] = [line_css[x], 'background-color: black;'].join('');
            break;
          case 1:
            line_css[x] = [line_css[x], 'background-color: yellow;'].join('');
            break;
          case 2:
            line_css[x] = [line_css[x], 'background-color: red;'].join('');
            break;
        }
      });
      str = str.concat([...line_str, '%c\n']);
      css = css.concat([...line_css, '']);
    });

    console.log(str.join(''), ...css);
  }
```

上記処理では、fieldsのサイズに合わせて以下のような出力文字列を生成します。

```
%c %c %c ... %c %c\n
%c %c %c ... %c %c\n
...
%c %c %c ... %c %c\n
```

上記の`"%c "`は「CSS用置換文字＋CSSを適用させる文字」の組み合わせです。
そこに座標の状態に応じたCSSを生成し、出力時に適用させることで画面表示を実装しています。
（コードのCSSはChromeの開発者ツールでそれなりに見えるように調整したCSSです。）
`\n`に対して空のCSSを適用しているのは、改行コードに対して直前のCSSが適用されてしまうことを避けるためです。

### ログ画面のリフレッシュ
過剰にログを出力すると描画が遅くなりますので、`console.clear()`で定期的にログのリフレッシュを実施する必要があります。
毎回リフレッシュを行うと画面がちらついてしまうので、以下の例のようにある程度ログが溜まったらclearする方がよさそうです。

```javascript
    timer = setInterval(() => {
      ...
      // ログの数による描画速度低下を防ぐため定期的にリフレッシュ
      frame++;
      if(frame > 300 ){
        frame = 0;
        console.clear();
      }
      drawMap(fields);
    }, 1000 / frame_rate);
      
```


# 実装

<details><summary>折り畳み内に記載</summary><div>

```html:log-game.html
F12 <button id="start" onclick="onStart()">start</button><br />
<br />
操作方法<br />
・ArrowKey: 上下左右の方向転換<br />
注：この画面にフォーカスが当たっていないと操作不可！<br />
<script>
  // game info
  var field_x = 20;
  var field_y = 16;
  var fields = Array.from(new Array(field_y), () => new Array(field_x).fill(0));
  var move = Array(new Array(), () => new Array(2));
  var moveVector = Array(2);
  var food = Array(2);
  // control game
  var isPlay = false;
  var timer;
  // frame
  var frame = 0;
  var frame_rate = 3; // fps
  var refresh_frame = 300;

  console.log('%cPress Click Start Button', 'font-size:20px;');

  // draw Map to Log
  function drawMap(fields) {
    str = new Array();
    css = new Array();

    fields.forEach((line, y) => {
      var line_str = new Array(line.length).fill('%c ');
      var line_css = new Array(line.length).fill(
        'padding-left: 20px; padding-top: 10px; line-height: 10px; font-size:0px;'
      );
      line.forEach((element, x) => {
        switch (element) {
          case 0:
            line_css[x] = [line_css[x], 'background-color: black;'].join('');
            break;
          case 1:
            line_css[x] = [line_css[x], 'background-color: yellow;'].join('');
            break;
          case 2:
            line_css[x] = [line_css[x], 'background-color: red;'].join('');
            break;
        }
      });
      str = str.concat([...line_str, '%c\n']);
      css = css.concat([...line_css, '']);
    });
    console.log(str.join(''), ...css);
  }
  // Game start
  function onStart() {
    // init game
    move = [[10, 8],[10, 9],[10, 10]];
    moveVector = [0, -1];
    changeGameStartStatus(true);
    spawnFood();
    // game main
    timer = setInterval(() => {
      fields = Array.from(new Array(field_y), () => new Array(field_x).fill(0));
      // sneak move
      sneakMove(move[0][0] + moveVector[0], move[0][1] + moveVector[1]);
      // check crash
      if (checkCrash(move[0][1], move[0][0])) {
        gameOver();
        return;
      }
      // get food
      if (move[0][0] == food[0] && move[0][1] == food[1]) {
        move.push([move[0][0], move[0][1]]);
        spawnFood();
      }
      // Prepare draw
      setSneak();
      setFood();
      
      // ログの数による描画速度低下を防ぐため定期的にリフレッシュ
      frame++;
      if(frame > refresh_frame ){
        frame = 0;
        console.clear();
      }

      drawMap(fields);
    }, 1000 / frame_rate);
  }
  //ゲームの開始終了を変更
  function changeGameStartStatus(flag) {
    isPlay = flag;
    document.getElementById("start").disabled = flag;
  }
  // sneakの衝突判定
  function checkCrash(x, y) {
    sneakCrashCnt = 0;
    move.forEach(element => {
      if (element[0] == y && element[1] == x) {
        sneakCrashCnt++;
      }
    });

    return x < 0 || x >= field_x || y < 0 || y >= field_y || sneakCrashCnt > 1;
  }
  // sneak全長をフィールドにセット
  function setSneak() {
    move.forEach(point => {
      fields[point[0]][point[1]] = 1;
    });
  }
  // エサをフィールドにセット
  function setFood() {
    fields[food[0]][food[1]] = 2;
  }
  // エサをランダムな位置に出現させる
  function spawnFood() {
    food = [
      Math.floor(Math.random() * field_y),
      Math.floor(Math.random() * field_x)
    ];
  }
  // sneakを動かす
  function sneakMove(x, y) {
    move.pop();
    move.unshift([x, y]);
  }
  // ゲームオーバー処理＆表示
  function gameOver() {
    clearInterval(timer);
    changeGameStartStatus(false);
    console.log('%cGame Over Score: %d', 'font-size:20px;', move.length - 3);
  }
  //キー検知
  window.document.onkeydown = event => {
    if (!isPlay) return;
    switch (event.key) {
      case 'ArrowRight':
        moveVector = [0, 1];
        break;
      case 'ArrowLeft':
        moveVector = [0, -1];
        break;
      case 'ArrowUp':
        moveVector = [-1, 0];
        break;
      case 'ArrowDown':
        moveVector = [1, 0];
        break;
    }
  };
</script>
```

</div></details>

# 実装時の注意点
- Chromeの開発ツールは同一のログを出力すると1件にまとめてしまいます。
    - 左上にまとめた件数が出力され、それにより描画位置がずれてしまうので同一の画面を連続して出さないよう意識する必要があります。
- 二行以上の表示をしようとすると、描画のタイミングによってログの前後関係が変わり、大きく画面がずれるのでなるべく一行で全データを出力したほうが良いです。
- リフレッシュが必要なこともあり、あまりリアルタイム性の強いゲームを出力するのは向いてなさそうです。~~（そもそもログはゲーム画面を出力する場所ではないですが）~~

# 発展できそうなところ
- CSSとしてbackground-imageが使用可能なので、現状background-colorで指定している描画を画像に変換可能（なはず）です。