Posted at

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

More than 1 year has passed since last update.


はじめに

Vue.jsでマインスイーパー実装してみました.

初めて書いたQiitaの記事もターミナル上で遊べるマインスイーパ実装でした.

前回はターミナルで操作するものでしたが,今回はブラウザでプレイできます.

本記事の構成


  • はじめに

  • プレイ動画

  • 実装コード

  • ポイント

  • おわりに


プレイ動画

作成したマインスーパーのプレイ動画を載せます.

height, width, ratio を入力して start ボタンを押すとゲームが始まります.

動画を見るといい感じにプレイできていそうです.

mine.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 の場合,さらにその周辺をオープンします.

本節では,子で発生したイベントから親のメソッドをどのように呼び出すかを説明します.

(再帰処理のメインロジックは次節で説明します)

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);
},
...

<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) を実行しています.

この openv-for を使ってテンプレートを生成する際,親メソッド open を渡したものです.

従って,子で push が呼ばれた時に親メソッドの open が呼ばれることになります.

Vue.js では子で発生したイベントをトリガとして親メソッドを呼ぶ場合 $emit を使用します.


地雷数0マスの再帰処理

周辺地雷数 0 マスの周りのマスをどんどんオープンしていくのが処理の目的となります.

以下のように再帰を用いることで実現しています.

  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 では怪しい場所に仮で ? のフラグを立てる処理をしています.

<script type="text/x-template" id="box-template">

<td @click="push" @contextmenu="flag">{{ b.char }}</td>
</script>

他にダブルクリック @dblclick や 右クリック @click.right があるようですが,

前者は単純なクリックと混同されてしまうこと,後者は動作しないことが問題となります.


おわりに

以前実装したターミナルで遊ぶタイプのものはキーボードからマスを指定するのが辛かったですが,

今回は Vue.js のおかげでクリックで操作できるのでプレイしやすくなりました.

是非ブラウザで動かしてみてください.