はじめに
Vue.jsでマインスイーパー実装してみました.
初めて書いたQiitaの記事もターミナル上で遊べるマインスイーパ実装でした.
前回はターミナルで操作するものでしたが,今回はブラウザでプレイできます.
本記事の構成
- はじめに
- プレイ動画
- 実装コード
- ポイント
- おわりに
プレイ動画
作成したマインスーパーのプレイ動画を載せます.
height
, width
, ratio
を入力して start
ボタンを押すとゲームが始まります.
動画を見るといい感じにプレイできていそうです.
実装コード
実装コードは以下になります.
js, html, css をコピペすれば利用できると思います.
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();
}
}
});
<!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>
/* 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)
を実行しています.
この open
は v-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 のおかげでクリックで操作できるのでプレイしやすくなりました.
是非ブラウザで動かしてみてください.