成品 demo:MineSweeper
GitHub:Xav1erSue/mineSweeper
最近没啥项目好写,突发奇想,能不能写点小游戏出来?于是萌生了做个扫雷的念头。js 写游戏看似简单,实际上还是有点小难度的,对于清晰的应用逻辑有比较高的要求,在实际编写中也遇到了许多问题。
首先来分析一下需求:
需要实现的功能
页面路由:
- 首页为 Home 页,在 Home 页中进行难度选择。
- 三种难度的游戏页面:Easy、Middle、Hard(可选)
游戏窗口:
- N×N 的方格矩阵
- 每个格子可以进行
左键点击
和右键标记
- 已经
被标记
或者被打开
的格子不能进行点击操作
- 点到地雷 GameOver
- 排出所有雷后胜利
基本功能如上,下面具体分析下具体的实现逻辑
实现逻辑
路由配置
应用使用Vue3
+VueRouter
构建,路由采用History
模式,主要是不想看到那个"#“号,构建项目时选择 History 模式的 VueRouter 就行,Vue-CLI 的集成度还是很高的,基本上不需要对 webpack 进行太多配置。关于History
和Hash
模式的不同以及在构建和部署时的操作会在后续文章中阐释,这里不多赘述,详见:《History 和 Hash 路由有什么区别》。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
|
// router/index.js配置
...
const routes = [
{
path: "/",
name: "Home",
component: () => import("../views/home.vue"),
},
{
path: "/easy",
name: "Easy",
component: () => import("../views/easy.vue"),
},
{
path: "/middle",
name: "Middle",
component: () => import("../views/middle.vue"),
},
{
path: "/hard",
name: "Hard",
component: () => import("../views/hard.vue"),
},
];
...
|
1
2
3
4
5
6
7
8
9
|
<!-- App.vue模板 -->
<template>
<div id="container">
<header>MineSweeper</header>
<hr />
<router-view></router-view>
</div>
<footer>© 2021 Xav1erSue,All rights reserved.</footer>
</template>
|
1
2
3
4
5
6
7
8
9
|
<!-- Home.vue模板 -->
<template>
<div class="motd">choose A model to play</div>
<div id="model">
<router-link class="btn" to="easy">Easy</router-link>
<router-link class="btn" to="middle">Middle</router-link>
<router-link class="btn" to="hard">Hard</router-link>
</div>
</template>
|
即可实现三种难度页面的路由切换。
由于没有对组件进行复用处理,所以三种难度的代码存在冗余,会在后续的更新中进行复用处理。
生成游戏窗口
添加全局变量blocks
,以二维数组
形式储存格子信息,使用 v-for 对blocks
的Y轴
方向遍历
1
|
<div class="columns" v-for="(itemY, Yindex) in blocks" :key="Yindex"></div>
|
再在每一层继续对X轴
进行遍历,核心代码如下,其他参数之后设定
1
2
3
|
<div class="columns" v-for="(itemY, Yindex) in blocks" :key="Yindex">
<div class="rowBlocks" v-for="(itemX, Xindex) in itemY" :key="Xindex"></div>
</div>
|
配置class
之后就可以生成一个拥有N×N
个格子的窗口。但只有当blocks
不为空时才能生成,所以在挂载之前需要对blocks
进行赋初值,这里涉及到对二维数组的赋值,数组的引用类型会出现一点小 bug,可以参考我的上一篇文章:《二维数组应该怎么赋初值》,这里直接给出代码,在 src 目录下新建utils
文件夹存放全局方法。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
|
// src/utils/setArray.js
// 初始化数组
function setEmptyArray(n) {
let arr = [];
for (let i = 0; i < n; i++) {
arr[i] = new Array(n).fill(0);
}
for (let i = 0; i < n; i++) {
for (let j = 0; j < n; j++) {
arr[i][j] = {
tempFlag: 0, // 下面遍历时用到的临时标记变量
minesNearbBy: 0, // 记录该方块周围八个格子的地雷数
isMine: 0, // 记录该方块是否是地雷
marked: 0, // 记录该方块是否被标记
clickAble: 1, // 记录方块是否可以点击
shown: 0, // 记录方块是否被边缘显露
};
}
}
return arr;
}
// import { setEmptyArray } from "../utils/setArray";
|
扫雷的玩法是:点开格子后,显示出当前格子周围一格距离(周边 9×9 方格中剩下 8 个格子)内地雷的个数,记为minesNearBy
,并通过这个数字判断未打开的格子中是否含有地雷,并且将其标记出来,打开所有非地雷格子从而获胜,所以每一个格子都有自己的独立信息,在上述代码中对格子的初始值进行设定。下文会对这些变量进行解释。
初始值设定后,v-for 的两重嵌套就可以渲染出 N×N 个 div 格子了。接下来对每个格子的点击事件进行相应的设定
左键点击事件:打开格子
1. 第一次左键点击时开始游戏:
2. 左键点击时对该格进行判定,根据判定结果对应不同操作
右键点击事件:标记格子
1. 右键点击时对该格进行判定:
-
只有游戏开始了才能进行右键点击
-
只有标记为可点击的格子才可以右键点击
-
若该格没有被标记,则把该格设定为已标记 // .marked = 1
-
若该格已被标记,则把该格设定为未标记 // .marked = 0
-
记录已被标记的格子数 // .marks++ || .marks--
-
把这个格子标记为不可点击 // .clickAble = 0
基本逻辑差不多就是这些,对应的方法如下,以 Easy 难度为例:
代码实现
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
|
// 左键点击调用方法
open(x, y) {
// 1. 第一次点开:生成地雷
if (this.start == 0 && this.gg == 0) {
this.setMines(x, y);
this.seenMotd = false;
this.start = 1;
// 当前格子一定没有被点击过
this.chainOpen(x, y);
return;
}
// 只有游戏处于开始状态才能进行点击操作
if (this.start == 1) {
// 2. 点到没有被标记的地雷:GG
if (this.blocks[x][y].isMine == 1 && this.blocks[x][y].clickAble == 1) {
this.blocks[x][y].styles = this.styles[2];
this.gg = 1;
this.start = 0;
alert("GAME OVER");
}
// 点到空白
if (this.blocks[x][y].isMine != 1 && this.blocks[x][y].clickAble == 1) {
this.chainOpen(x, y);
this.clickTimes++;
}
if (this.blocksLeft == 10) {
this.gg = 1;
this.start = 0;
alert("you win!");
}
}
},
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
|
// 右键点击调用方法
mark(x, y) {
event.preventDefault(); // 取消默认事件
// 只有游戏开始了才能标记地雷
if (this.start == 1) {
if (
this.blocks[x][y].marked == 0 &&
this.marks < 10 &&
this.blocks[x][y].clickAble == 1
) {
// 没有被标记,标记数量少于10:标记该方块
this.blocks[x][y].marked = 1;
this.blocks[x][y].clickAble = 0;
this.blocks[x][y].styles = this.styles[1];
this.marks++;
this.clickTimes++;
} else if (this.blocks[x][y].marked == 1) {
// 被标记:取消标记
this.blocks[x][y].marked = 0;
this.blocks[x][y].clickAble = 1;
this.blocks[x][y].styles = {};
this.marks--;
this.clickTimes++;
}
}
},
|
在utils/setArray.js
里面封装了其他方法,如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
|
// 输入矩阵阶数n,mines个地雷和初始坐标x0,y0
function setRandomMines(n, mines, x0, y0) {
// 创建初始值为空的n*n矩阵数组
let arr = [];
for (let i = 0; i < n; i++) {
arr[i] = new Array(n).fill(0);
}
while (mines > 0) {
let x = Math.floor(Math.random() * n);
let y = Math.floor(Math.random() * n);
// 当前位置没有被设置过且不是第一次点击的坐标是创建地雷
if (arr[x][y] != 1 && x != x0 && y != y0) {
arr[x][y] = 1;
mines--;
}
}
return arr;
}
// 计算周围地雷数
function computeNearBy(arr, x, y, n) {
// 周围最多存在八个格子
let directions = [
[-1, -1],
[-1, 0],
[-1, 1],
[0, -1],
[0, 1],
[1, -1],
[1, 0],
[1, 1],
];
let sum = 0;
for (let i = 0; i < 8; i++) {
let tx = x + directions[i][0];
let ty = y + directions[i][1];
if (tx < 0 || tx >= n || ty < 0 || ty >= n) continue;
if (arr[tx][ty].isMine == 1) sum++;
}
return sum;
}
|
以及在opne()
和mark()
两个方法内调用的其他方法:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
|
setMines(x, y) {
// nxn的方格定义10个雷
let minesArr = setRandomMines(this.n, 10, x, y);
for (let i = 0; i < this.n; i++) {
for (let j = 0; j < this.n; j++) {
this.blocks[i][j].isMine = minesArr[i][j];
if (minesArr[i][j] == 1) {
this.blocks[i][j].styles = { color: "red" };
}
}
}
for (let i = 0; i < this.n; i++) {
for (let j = 0; j < this.n; j++) {
if (this.blocks[i][j].isMine != 1) {
this.blocks[i][j].minesNearBy = computeNearBy(
this.blocks,
i,
j,
this.n
);
} else {
this.blocks[i][j].minesNearBy = -1;
}
}
}
}
|
对应 template 模板:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
<div class="columns" v-for="(itemY, Yindex) in blocks" :key="Yindex">
<div
class="rowBlocks"
v-for="(itemX, Xindex) in itemY"
:key="Xindex"
:style="this.blocks[Xindex][Yindex].styles"
@click.right="mark(Xindex, Yindex)"
@click.left="open(Xindex, Yindex)"
>
<span v-show="this.blocks[Xindex][Yindex].shown"
>{{ this.blocks[Xindex][Yindex].minesNearBy }}</span
>
<span v-show="this.blocks[Xindex][Yindex].isMine && this.gg">💣</span>
<span v-show="this.blocks[Xindex][Yindex].marked && !this.gg">🚩</span>
</div>
</div>
|
在这里涉及到了方格的连锁点开,效果如下:

如果点击的格子周围没有炸弹,即格子的minesNearBy
值为 0,则把与其相连的minesNearBy
值同为 0 的格子也一同打开,并显示外层格子的minesNearBy
值,这一功能我将在后续的文章中更新,详见:《深度优先搜索(DFS)在扫雷游戏中的应用》
整个应用大体就是这样,此外细枝末节的东西不多做赘述,顺带实现了一手暗黑模式(
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
// 全局配色
@media (prefers-color-scheme: dark) {
:root {
--back-ground-color: #000000;
--base-color: #0a2525;
--block-font-color: #ffffff;
--block-color: #476b6b;
}
}
@media (prefers-color-scheme: light) {
:root {
--back-ground-color: #ffffff;
--base-color: #d1ebeb;
--block-font-color: #ffffff;
--block-color: #61a1a1;
}
}
|
其实就是在根组件(App.vue)下配置全局配色,然后在其他元素的样式下调用变量而已,效果如下:

这一个小游戏断断续续花了大概三天写完,感受还是颇深,对 js 和 vue 的理解又更进了一分,接下来写一手 2048,顺带训练一下 CSS。