Vue.jsでマインスイーパ実装


# はじめに

Vue.jsでマインスイーパー実装してみました．
初めて書いたQiitaの記事も[ターミナル上で遊べるマインスイーパ実装](http://qiita.com/ta-ka/items/40b56722da43d1ba2e26)でした．
前回はターミナルで操作するものでしたが，今回はブラウザでプレイできます．

本記事の構成

* はじめに
* プレイ動画
* 実装コード
* ポイント
* おわりに


# プレイ動画

作成したマインスーパーのプレイ動画を載せます．
`height`, `width`, `ratio` を入力して `start` ボタンを押すとゲームが始まります．
動画を見るといい感じにプレイできていそうです．

<img width="500" alt="mine.gif" src="https://qiita-image-store.s3.amazonaws.com/0/82527/277f0e46-1a27-27b9-746e-c92f14cbf10d.gif">


# 実装コード

実装コードは以下になります．
js, html, css をコピペすれば利用できると思います．

```mine.js
var app = new Vue({
  el: '#mineApp',
  data: {
    height: 10,
    width: 10,
    ratio: 0.1,
    boxes: [],
    numBoms: 0,
    numPushedBoxes: 0,
    isStart: false,
    isSuccess: false,
    isFailure: false,
    timer: '',
    begin: '',
    duration: '',
    classes: ['zero', 'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'unknown', 'flag', 'bomb']
  },
  methods: {
    start: function() {
      this._init(false);
      this.isStart = true;
      this._setBombs();
      this._countBombs();
      this.begin = new Date();
      this.timer = setInterval(() => {
        this.duration = ((new Date() - this.begin) / 1000).toFixed(1)
      }, 1 / 10);
    },
    reset: function() {
      this._init(true);
    },
    _init: function(isAll) {
      if (isAll) {
        this.height = 10;
        this.width = 10;
        this.ratio = 0.1;
      }
      this.boxes = [];
      this.numBoms = 0;
      this.numPushedBoxes = 0;
      this.isStart = false;
      this.isSuccess = false;
      this.isFailure = false;
      clearInterval(this.timer);
      this.begin = '';
      this.duration = '';
    },
    _setBombs: function() {
      for (var y = 0; y < this.height; y++) {
        var row = [];
        for (var x = 0; x < this.width; x++) {
          row.push({
            index: y * this.width + x,
            hasBomb: Math.random() < this.ratio,
            isPushed: false,
            isFlagged: false,
            numNeighborBombs: 0,
            char: ''
          });
        }
        this.boxes.push(row);
      }
    },
    _countBombs: function() {
      for (var y = 0; y < this.height; y++) {
        for (var x = 0; x < this.width; x++) {
          var box = this.boxes[y][x]
          if(box.hasBomb) {
            this.numBoms += 1;
            box.numNeighborBombs = 9;
            continue;
          }
          for (var j = (y > 0 ? -1 : 0); j <= ( y < this.height - 1 ? 1 : 0); j++) {
            for (var i = (x > 0 ? -1 : 0); i <= (x < this.width - 1 ? 1 : 0); i++) {
              if (j === 0 && i === 0) {
                continue;
              } else if (this.boxes[y + j][x + i].hasBomb) {
                box.numNeighborBombs += 1;
              }
            }
          }
        }
      }
    },
    open: function(index) {
      var y = index / this.width | 0;
      var x = index % this.width;
      var box = this.boxes[y][x];
      if (box.isPushed || box.isFlagged) {
        return;
      }
      if (box.hasBomb) {
        this.isFailure = true;
        this._openAll();
        clearInterval(this.timer);
        return;
      }

      box.isPushed = true;
      this.numPushedBoxes += 1;
      this._replaceChar(box);
      if (box.numNeighborBombs === 0) {
        this._recursive(index, y, x);
      }
      if (this.numPushedBoxes === this.height * this.width - this.numBoms) {
        this.isSuccess = true;
        this._openAll();
        clearInterval(this.timer);
      }
    },
    _replaceChar: function(box) {
      if (box.hasBomb) {
        box.char = 'x';
        box.classIndex = 11;
      } else if (box.numNeighborBombs > 0) {
        box.char = box.numNeighborBombs;
        box.classIndex = box.numNeighborBombs;
      } else {
        box.char = '-';
        box.classIndex = 0;
      }
    },
    _openAll: function() {
      for (var y = 0; y < this.height; y++) {
        for (var x = 0; x < this.width; x++) {
          var box = this.boxes[y][x];
          if (!box.isPushed) {
            this._replaceChar(box);
          }
        }
      }
    },
    _recursive: function(index, y, x) {
      for (var j = (y > 0 ? -1 : 0); j <= (y < this.height - 1 ? 1 : 0); j++) {
        for (var i = (x > 0 ? -1 : 0); i <= (x < this.width - 1 ? 1 : 0); i++) {
          if (j === 0 && i === 0) {
            continue;
          }
          var neighbor_box = this.boxes[y + j][x + i];
          if (!neighbor_box.isPushed) {
              this.open(index + j * this.width + i);
          }
        }
      }
    }
  }
});

Vue.component("box-template", {
  template: "#box-template",
  props: {
    b: Object
  },
  methods: {
    push: function() {
      this.$emit("open", this.b.index);
    },
    flag: function(e) {
      if (this.b.isPushed) {
        return;
      }
      this.b.isFlagged = !this.b.isFlagged;
      if (this.b.isFlagged) {
        this.b.char = '?';
        this.b.classIndex = 10;
      } else {
        this.b.char = '';
        this.b.classIndex = 9;
      }
      e.preventDefault();
    }
  }
});
```

```mine.html
<!DOCTYPE html>
<html>
<head>
  <title>Mine Sweeper</title>
  <link rel="stylesheet" type="text/css" href="mine.css">
</head>

<script type="text/x-template" id="box-template">
  <td @click="push" @contextmenu="flag">{{ b.char }}</td>
</script>

<body>
  <div id="mineApp">
    <h3>Mine Sweeper</h3>
    <div>
      <div vertical-align="middle">
        <li>
          <label>height</label>
          <input type="number" min="2" max="40" v-model="height">
        </li>
        <li>
          <label>widht</label>
          <input type="number" min="2" max="40" v-model="width"></li>
        </li>
        <li>
          <label>ratio</label>
          <input type="number" min="0.0" max="1.0" step="0.01" v-model="ratio">
        </li>
        <li>
          <a href="#" class="square_btn" @click="start">start</a>
          <a href="#" class="square_btn" @click="reset">reset</a>
        </li>
      </div>
    </div>
    <div v-if="isStart">
      <table>
        <tr v-for="row in boxes">
          <td is="box-template" v-for="box in row" :class="classes[box.classIndex]" :b="box" @open="open"></td>
        </tr>
      </table>
    </div>
    <div>
      <p v-if="isStart">{{ duration }}</p>
      <p v-if="isSuccess">SUCCESS</p>
      <p v-if="isFailure">FAILURE</p>
    </div>
  </div>

  <script src="https://cdnjs.cloudflare.com/ajax/libs/vue/2.4.2/vue.js"></script>
  <script src="mine.js"></script>
</body>

</html>
```

```mine.css
/* font */
* {
  font-family: 'arial';
  font-weight: bold;
}

/* margin */
/* font */
* {
  font-family: 'arial';
  font-weight: bold;
}

/* margin */
div + div {
  margin-top: 1.2em;
}
li + li {
  margin-top: 1.2em;
}

/* h3 */
h3 {
  display: inline-block;
  padding: 0.3em 1.0em;
  color: dimgray;
  background: lightgrey;
}

/* ul li */
li {
  list-style: none;
  margin: 0px;
}
label {
  width: 50px;
  margin-right: 10px;
  display: inline-block;
  padding: 0.3em 1.0em;
  float: left;
  text-align: center;
  color: dimgray;
  background: lightgrey;
}

/* input */
input {
  height: 18px;
  width: 50px;
  color: dimgray;
  display: inline-block;
  padding: 0.3em 1.0em;
  text-align: center;
  font-size: 16px;
  border: none;
  background: lightgrey;
}

/* a */
a.square_btn{
  width: 50px;
  display: inline-block;
  margin-right: 6px;
  padding: 0.3em 1.0em;
  text-decoration: none;
  text-align: center;
  font-weight: bold;
  color: dimgray;
  background: lightgrey;
}
a.square_btn:hover {
  color: lightgrey;
  background: dimgray;
}

/* table */
table {
  border-collapse: collapse;
  border: solid thick;
}

/* td */
td {
  height: 2.0em;
  width: 2.0em;
  padding: 0;
  border: solid thin;
  border-color: black;
  text-align: center;
  font-weight: bold;
  background-color: lightgrey;
}
td.zero {
  color: black;
}
td.one {
  color: blue;
}
td.two {
  color: green;
}
td.three {
  color: red;
}
td.four {
  color: navy;
}
td.five {
  color: olive;
}
td.six {
  color: cyan;
}
td.seven {
  color: black;
}
td.eight {
  color: gray;
}
td.unknown {
  color: black;
}
td.flag {
  color: black;
}
td.bomb {
  color: mediumvioletred;
}
```


# ポイント

以下の3点について簡単に説明します．

* 子で発生したイベントから親メソッド呼び出し
* 地雷数0マスの再帰処理
* 2種類のクリック操作の使い分け

## 子で発生したイベントから親メソッド呼び出し

オープンしたマスの周辺の地雷数が `0` の場合，周辺のマスもオープンします．
このときオープンした周辺マスの周辺地雷数も `0` の場合，さらにその周辺をオープンします．
本節では，子で発生したイベントから親のメソッドをどのように呼び出すかを説明します．
（再帰処理のメインロジックは次節で説明します）

```js
var app = new Vue({
  el: '#mineApp',
  ...
  mthods: {
    open: function(index) {
      var y = index / this.width | 0;
      var x = index % this.width;
      var box = this.boxes[y][x];
      if (box.isPushed || box.isFlagged) {
        return;
      }
      if (box.hasBomb) {
        this.isFailure = true;
        this._openAll();
        clearInterval(this.timer);
        return;
      }

      box.isPushed = true;
      this.numPushedBoxes += 1;
      this._replaceChar(box);
      if (box.numNeighborBombs === 0) {
        this._recursive(index, y, x);
      }
      if (this.numPushedBoxes === this.height * this.width - this.numBoms) {
        this.isSuccess = true;
        this._openAll();
        clearInterval(this.timer);
      }
    },
    ...

Vue.component("box-template", {
  template: "#box-template",
  props: {
    b: Object
  },
  methods: {
    push: function() {
      this.$emit("open", this.b.index);
    },
    ...
```

```html
<script type="text/x-template" id="box-template">
  <td @click="push" @contextmenu="flag">{{ b.char }}</td>
</script>

<td is="box-template" v-for="box in row" :class="classes[box.classIndex]" :b="box" @open="open"></td>
```

子がクリックされたとき，子メソッド `push` が呼び出されます．
`push` は内部で `this.$emit("open", this.b.index)` を実行しています．
この `open` は `v-for` を使ってテンプレートを生成する際，親メソッド `open` を渡したものです．
従って，子で `push` が呼ばれた時に親メソッドの `open` が呼ばれることになります．
Vue.js では子で発生したイベントをトリガとして親メソッドを呼ぶ場合 `$emit` を使用します．

## 地雷数0マスの再帰処理

周辺地雷数 `0` マスの周りのマスをどんどんオープンしていくのが処理の目的となります．
以下のように再帰を用いることで実現しています．

```js
  methods: {
    open: function(index) {
      var y = index / this.width | 0;
      var x = index % this.width;
      var box = this.boxes[y][x];
      if (box.isPushed || box.isFlagged) {
        return;
      }
      if (box.hasBomb) {
        this.isFailure = true;
        this._openAll();
        clearInterval(this.timer);
        return;
      }

      box.isPushed = true;
      this.numPushedBoxes += 1;
      this._replaceChar(box);
      if (box.numNeighborBombs === 0) {
        this._recursive(index, y, x);
      }
      if (this.numPushedBoxes === this.height * this.width - this.numBoms) {
        this.isSuccess = true;
        this._openAll();
        clearInterval(this.timer);
      }
    },
    _recursive: function(index, y, x) {
      for (var j = (y > 0 ? -1 : 0); j <= (y < this.height - 1 ? 1 : 0); j++) {
        for (var i = (x > 0 ? -1 : 0); i <= (x < this.width - 1 ? 1 : 0); i++) {
          if (j === 0 && i === 0) {
            continue;
          }
          var neighbor_box = this.boxes[y + j][x + i];
          if (!neighbor_box.isPushed) {
              this.open(index + j * this.width + i);
          }
        }
      }
    }
```

`open` メソッドの中で `_recursive` を呼んでいるのが味噌になります．

## 2種類のクリックの使い分け

`@click`, `@contextmenu` の2種類を使い分けています．
`@click` ではマスのオープンを処理しており，
`@contextmenu` では怪しい場所に仮で `?` のフラグを立てる処理をしています．

```html
<script type="text/x-template" id="box-template">
  <td @click="push" @contextmenu="flag">{{ b.char }}</td>
</script>
```

他にダブルクリック `@dblclick` や 右クリック `@click.right` があるようですが，
前者は単純なクリックと混同されてしまうこと，後者は動作しないことが問題となります．


# おわりに

以前実装したターミナルで遊ぶタイプのものはキーボードからマスを指定するのが辛かったですが，
今回は Vue.js のおかげでクリックで操作できるのでプレイしやすくなりました．
是非ブラウザで動かしてみてください．