挑战 100 款小游戏之「扫雷」

介绍

大家好,我是大胆的番茄。本文是「挑战 100 款小游戏」的第二款游戏:「扫雷」的总结与分享。

演示地址:https://wanghaida.com/demo/2202-minesweeper/index.html

Github 仓库:https://github.com/wanghaida/games/tree/master/2202-minesweeper

一个游戏要想好玩,除了玩法以外,设计也是必须的。但我不会设计怎么办?那就把 Microsoft Store 里的 Microsoft Minesweeper 下载下来翻翻目录,就发现了个这个:

sprite_state.png

嚯~,这不是我们熟悉的雪碧图吗?虽然不是全部的,但基本的都在。爱了爱了~

地图

雪碧图上每个方块正好 84x84 像素,为了高清屏,除以 2,每个方块是 42x42。游戏有 9x9、16x16、30x16 三种难度,按 30x16 来算,游戏区域大小达到了 1260x672,再包括一些小间距的话,小屏幕稍显吃力,所以除了 9x9 采用 84/2 = 42 像素以外,其他的用 84/2.4 = 35 像素。

用网格简单的把架子搭一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
:root {
--base: 2;

--row: 9;
--col: 9;
}

// 游戏区
#game {
display: grid;
grid-template-rows: repeat(var(--row), calc(84px / var(--base)));
grid-template-columns: repeat(var(--col), calc(84px / var(--base)));
gap: 2px;

div {
background: url('./images/sprite_state.png') calc(-84px / var(--base)) 0 / calc(1344px / var(--base)) calc(420px / var(--base)) no-repeat;
}
}
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
// 游戏区域
const oGame = document.getElementById('game');

const minesweeper = {
/**
* 初始化地图
*/
initMap(row = 9, col = 9, mines = 10) {
// 清除原有地图
oGame.innerHTML = '';

// 调整地图布局
document.documentElement.style.setProperty('--base', row > 9 ? '2.4' : '2');

document.documentElement.style.setProperty('--row', row);
document.documentElement.style.setProperty('--col', col);

// 虚拟节点用来承载 dom 节点,方便一次性添加
const oFragment = document.createDocumentFragment();

for (let i = 0; i < row; i++) {
for (let j = 0; j < col; j++) {
// 创建坐标节点
const oDiv = document.createElement('div');

// 将坐标节点放入虚拟节点
oFragment.appendChild(oDiv);
}
}

// 将虚拟节点放入游戏区
oGame.appendChild(oFragment);
},
};
// 默认开始 9x9 游戏
minesweeper.initMap();

得到下面一张图:

image.png

CSS 上使用了三个变量,--base 用来缩放每个方块大小的,--row 表示行,--col 表示列。还是咱们熟悉的网格系统,通过 repeat() 函数来画出行和列。

直接出来可不行,人家是有加载动效的,我准备加个关键帧动画和定时器:

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
#game {
.state-loading {
background-position: calc(-84px / var(--base)) 0;
animation: state-loading 0.2s steps(1);
}
.state-closed {
background-position: calc(-84px / var(--base)) 0;
}
}

@keyframes state-loading {
0% {
background-position: calc(-504px / var(--base)) calc(-84px / var(--base));
}
25% {
background-position: calc(-672px / var(--base)) calc(-84px / var(--base));
}
50% {
background-position: calc(-840px / var(--base)) calc(-84px / var(--base));
}
75% {
background-position: calc(-924px / var(--base)) calc(-84px / var(--base));
}
100% {
background-position: calc(-1260px / var(--base)) calc(-84px / var(--base));
}
}
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
42
43
44
45
46
47
48
49
50
const minesweeper = {
mapCount: 0,
mapTimer: null,
initMap(row = 9, col = 9, mines = 10) {
...

document.documentElement.style.setProperty('--col', col);

// 虚拟节点用来承载 dom 节点,方便一次性添加
const oFragment = document.createDocumentFragment();

for (let i = 0; i < row; i++) {
for (let j = 0; j < col; j++) {
// 创建坐标节点
const oDiv = document.createElement('div');

// 将坐标节点放入虚拟节点
oFragment.appendChild(oDiv);
}
}

// 将虚拟节点放入游戏区
oGame.appendChild(oFragment);

// 加载动画
clearInterval(this.mapTimer);
this.mapCount = 0;
this.mapTimer = setInterval(() => {
// 所有行数已经遍历完成
if (this.mapCount >= row) {
// 清除定时器
return clearInterval(this.mapTimer);
}

for (let i = 0; i < col; i++) {
oDiv = oGame.children[this.mapCount * col + i];

// 加载动画样式
oDiv.className = 'state-loading';
oDiv.addEventListener('animationend', function fn() {
oDiv.className = 'state-closed';
oDiv.removeEventListener('animationend', fn);
});
}

// 增加遍历行数
this.mapCount++;
}, 100);
},
};

理论上是没有问题的,但可能是 dom 的原因,在方块数量过多的时候,会有明显的从左向右加载的延迟,所以我由原来的先添加 div 进游戏区再给每行添加样式改成了每次添加一行 div 进游戏区同时添加样式

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
42
43
44
const minesweeper = {
mapCount: 0,
mapTimer: null,
initMap(row = 9, col = 9, mines = 10) {
...

document.documentElement.style.setProperty('--col', col);

// 加载动画
clearInterval(this.mapTimer);
this.mapCount = 0;
this.mapTimer = setInterval(() => {
// 所有行数已经遍历完成
if (this.mapCount >= row) {
// 清除定时器
return clearInterval(this.mapTimer);
}

// 虚拟节点用来承载 dom 节点,方便一次性添加
const oFragment = document.createDocumentFragment();

for (let i = 0; i < col; i++) {
// 创建坐标节点
const oDiv = document.createElement('div');

// 加载动画样式
oDiv.className = 'state-loading';
oDiv.addEventListener('animationend', function fn() {
oDiv.className = 'state-closed';
oDiv.removeEventListener('animationend', fn);
});

// 将坐标节点放入虚拟节点
oFragment.appendChild(oDiv);
}

// 将虚拟节点放入游戏区
oGame.appendChild(oFragment);

// 增加遍历行数
this.mapCount++;
}, 100);
},
};

为了方便操作方块,也为了性能稍微快一点,咱们用个 map 来存一下方块对应状态,dom 上只存数据对应坐标:

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
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
const minesweeper = {
/**
* 游戏数据
*
* @desc 通过二维数组来表示每个方块的属性
* @example
* [
* [item, item, item],
* [item, item, item],
* [item, item, item],
* ]
*
* item = {
* // 是否打开过
* isOpen: boolean,
* // 是否递归过
* isCheck: boolean,
* // 是否爆炸过(同 isCheck,用于游戏结束后的递归判定)
* isExplode: boolean,
* // 标记 flag normal question
* sign: string,
* // 类型 0: 空白, 1-8: 数字, 9: 地雷
* type: number,
* }
*/
map: [],
/**
* 游戏状态
*
* loaded: 加载完成, loading: 加载中, ongoing: 进行中, over: 游戏结束
*/
state: 'loading',
/**
* 初始化地图
*/
row: 9,
col: 9,
mines: 10,
initMap(row = 9, col = 9, mines = 10) {
// 游戏地图尺寸
this.map = [];
this.row = row;
this.col = col;
this.mines = row * col === 256 ? 40 : row * col === 480 ? 99 : mines; // 16x16 ? 40 : 30x16 ? 99 : mines;

// 修改游戏状态
this.state = 'loading';
// 地雷数量
document.getElementById('mines').innerHTML = this.mines;
// 游戏时间(简单的定时器,这里不展示了)

...

this.mapTimer = setInterval(() => {
// 所有行数已经遍历完成
if (this.mapCount >= row) {
// 状态变更
this.state = 'loaded';
// 清除定时器
return clearInterval(this.mapTimer);
}

// 虚拟节点用来承载 dom 节点,方便一次性添加
const oFragment = document.createDocumentFragment();

const mapTemp = [];
for (let i = 0; i < col; i++) {
// 创建坐标节点
const oDiv = document.createElement('div');

...

// 坐标
oDiv.pos = [this.mapCount, i];
// 方块
mapTemp.push({
// 是否打开过
isOpen: false,
// 是否递归过
isCheck: false,
// 是否爆炸过
isExplode: false,
// 标记 flag normal question
sign: 'normal',
// 类型 0: 空白, 1-8: 数字, 9: 地雷
type: 0,
});

// 将坐标节点放入虚拟节点
oFragment.appendChild(oDiv);
}
this.map.push(mapTemp);

// 将虚拟节点放入游戏区
oGame.appendChild(oFragment);

// 增加遍历行数
this.mapCount++;
}, 100);
},
};

大体 ok:

Jietu20220121-155740-HD.gif

基础动效

再把基础的空格、数字、旗子、问号效果弄出来:

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
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
.state-flag-down {
background-position: calc(-1260px / var(--base)) calc(-168px / var(--base));
animation: state-flag-down 0.1s steps(1);
}
.state-flag-up {
background-position: calc(-252px / var(--base)) calc(-252px / var(--base));
animation: state-flag-up 0.1s steps(1);
}
.state-normal-down {
background-position: calc(-588px / var(--base)) calc(-168px / var(--base));
animation: state-normal-down 0.1s steps(1);
}
.state-normal-up {
background-position: calc(-924px / var(--base)) calc(-168px / var(--base));
animation: state-normal-up 0.1s steps(1);
}
.state-question-down {
background-position: calc(-588px / var(--base)) calc(-252px / var(--base));
animation: state-question-down 0.1s steps(1);
}
.state-question-up {
background-position: calc(-924px / var(--base)) calc(-252px / var(--base));
animation: state-question-up 0.1s steps(1);
}

.state-0 {
background-position: 0 0;
}
.state-1 {
background-position: calc(-504px / var(--base)) 0;
}
.state-2 {
background-position: calc(-588px / var(--base)) 0;
}
.state-3 {
background-position: calc(-672px / var(--base)) 0;
}
.state-4 {
background-position: calc(-756px / var(--base)) 0;
}
.state-5 {
background-position: calc(-840px / var(--base)) 0;
}
.state-6 {
background-position: calc(-924px / var(--base)) 0;
}
.state-7 {
background-position: calc(-1008px / var(--base)) 0;
}
.state-8 {
background-position: calc(-1092px / var(--base)) 0;
}
.state-9 {
background-position: calc(-336px / var(--base)) 0;
}

@keyframes state-flag-down {
0% {
background-position: calc(-1008px / var(--base)) calc(-168px / var(--base));
}
33.33% {
background-position: calc(-1092px / var(--base)) calc(-168px / var(--base));
}
66.66% {
background-position: calc(-1176px / var(--base)) calc(-168px / var(--base));
}
100% {
background-position: calc(-1260px / var(--base)) calc(-168px / var(--base));
}
}
@keyframes state-flag-up {
0% {
background-position: 0 calc(-252px / var(--base));
}
33.33% {
background-position: calc(-84px / var(--base)) calc(-252px / var(--base));
}
66.66% {
background-position: calc(-168px / var(--base)) calc(-252px / var(--base));
}
100% {
background-position: calc(-252px / var(--base)) calc(-252px / var(--base));
}
}
@keyframes state-normal-down {
0% {
background-position: calc(-336px / var(--base)) calc(-168px / var(--base));
}
33.33% {
background-position: calc(-420px / var(--base)) calc(-168px / var(--base));
}
66.66% {
background-position: calc(-504px / var(--base)) calc(-168px / var(--base));
}
100% {
background-position: calc(-588px / var(--base)) calc(-168px / var(--base));
}
}
@keyframes state-normal-up {
0% {
background-position: calc(-672px / var(--base)) calc(-168px / var(--base));
}
33.33% {
background-position: calc(-756px / var(--base)) calc(-168px / var(--base));
}
66.66% {
background-position: calc(-840px / var(--base)) calc(-168px / var(--base));
}
100% {
background-position: calc(-924px / var(--base)) calc(-168px / var(--base));
}
}
@keyframes state-question-down {
0% {
background-position: calc(-336px / var(--base)) calc(-252px / var(--base));
}
33.33% {
background-position: calc(-420px / var(--base)) calc(-252px / var(--base));
}
66.66% {
background-position: calc(-504px / var(--base)) calc(-252px / var(--base));
}
100% {
background-position: calc(-588px / var(--base)) calc(-252px / var(--base));
}
}
@keyframes state-question-up {
0% {
background-position: calc(-672px / var(--base)) calc(-252px / var(--base));
}
33.33% {
background-position: calc(-756px / var(--base)) calc(-252px / var(--base));
}
66.66% {
background-position: calc(-840px / var(--base)) calc(-252px / var(--base));
}
100% {
background-position: calc(-924px / var(--base)) calc(-252px / var(--base));
}
}

效果如下:

Jietu20220124-104919-HD.gif

随机生成地雷

随机生成地雷比较简单,在 0 - row * col 生成 N 个不重复数字,再转换为二维坐标即可:

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
42
43
44
45
46
47
48
49
const minesweeper = {
initMap(row = 9, col = 9, mines = 10) {
...
this.mapTimer = setInterval(() => {
// 所有行数已经遍历完成
if (this.mapCount >= row) {
// 状态变更
this.state = 'loaded';
// 生成地雷
this.generateMines();
// 清除定时器
return clearInterval(this.mapTimer);
}
}, 100);
},
/**
* 生成地雷
*/
generateMines() {
// 先生成 N 个不重复的数字
let pos = [];
while (pos.length !== this.mines) {
pos = [...new Set([...pos, Math.floor(Math.random() * this.row * this.col)])];
}

// 将数字转为坐标数组
for (let i = 0; i < pos.length; i++) {
const x = Math.floor(pos[i] / this.col);
const y = pos[i] % this.col;

pos[i] = [x, y];
// 将对应数据 type 改为 9(地雷)
this.map[x][y].type = 9;
}

// 计算地雷周围坐标数字
for (let i = 0; i < pos.length; i++) {
// 查找周围坐标
const around = this.findPos(pos[i]);
for (let j = 0; j < around.length; j++) {
const grid = this.map[around[j][0]][around[j][1]];
// 不是地雷则数字加 1
if (grid.type !== 9) {
grid.type++;
}
}
}
},
};

查找周围坐标

扫雷的数字标注是指示周围 8 格里的地雷数量,所以上面用到了个 findPos 方法就是用来返回周围坐标的:

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
const minesweeper = {
/**
* 查找周围坐标,并去除边界值
*
* @example
* 假设坐标为 [x, y],那么周围坐标:
* [
* [x - 1, y - 1], [x - 1, y], [x - 1, y + 1],
* [x, y - 1], ..., [x, y + 1],
* [x + 1, y - 1], [x + 1, y], [x + 1, y + 1],
* ]
*/
findPos([x, y]) {
// 周围坐标
const pos = [
[x - 1, y - 1], [x - 1, y], [x - 1, y + 1],
[x, y - 1], [x, y + 1],
[x + 1, y - 1], [x + 1, y], [x + 1, y + 1],
];
// 简单的碰撞检测去除边界值
return pos.filter(([x, y]) => !(x < 0 || y < 0 || x >= this.row || y >= this.col));
},
findPosUDLR([x, y]) {
// 周围坐标
const pos = [
[x - 1, y], // 上
[x + 1, y], // 下
[x, y - 1], // 左
[x, y + 1], // 右
];
// 简单的碰撞检测去除边界值
return pos.filter(([x, y]) => !(x < 0 || y < 0 || x >= this.row || y >= this.col));
},
};

findPosUDLR 用来返回上下左右 4 格,主要用来游戏结束后的爆炸效果,要不显得有点呆。

游戏逻辑分析

鼠标事件有按下、抬起、移动和双击。

这里为啥不把按下和抬起合并成单击事件呢?因为鼠标在一个方块左键按下不松手,移出当前方块的则需要进行恢复,主要是防止误点。而右键按下时再抬起时,需要进行标记变换(正常->旗子->问号->正常),所以不能简单的处理成单击事件,而且还得缓存一下按下的方块,在移动后、抬起时判定还是不是原来的方块。

至于双击事件,主要存在于双击数字时快速将未标记旗子的方块进行打开操作。

鼠标按下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 方块缓存
let oTemp = null;

// 鼠标从方块按下
oGame.addEventListener('mousedown', (ev) => {
// 没点中方块 或 游戏加载中/游戏已结束
if (oGame === ev.target || ['loading', 'over'].includes(minesweeper.state)) return;

const [x, y] = ev.target.pos;
if (false === minesweeper.map[x][y].isOpen) {
// 缓存按下元素
oTemp = ev.target;
// 给缓存的元素添加按下样式
oTemp.className = 'state-' + minesweeper.map[x][y].sign + '-down';
}
});

如果没有点中方块,或者游戏加载中/游戏已结束则不再进行逻辑处理。其实这里有个 loading 时单击直接完成的效果,我这里也懒得写了,方法就是清掉 initMap 里的定时器,然后拿到 mapCount 直接进行剩下的 div.state-closed 填充。

先判断当前这个方块没有打开,然后缓存当前点击的方块,给方块添加一个按下的效果。

鼠标移动

1
2
3
4
5
6
7
8
9
10
11
12
13
// 鼠标移动
oGame.addEventListener('mousemove', (ev) => {
// 缓存的 oTemp 和当前元素不一致
if (oTemp && oTemp !== ev.target) {
// 如果缓存的元素为按下样式
if (oTemp.className.match(/state\-.+\-down/)) {
// 给缓存的元素添加抬起样式
oTemp.className = oTemp.className.replace('-down', '-up');
}
// 删除缓存元素
oTemp = null;
}
});

移动就是判定缓存 dom 存在且和当前元素不相等,且有按下的样式,就把按下样式变为了抬起。

鼠标抬起

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
42
43
44
45
46
47
// 鼠标抬起
oGame.addEventListener('mouseup', (ev) => {
// 缓存的 oTemp 和当前元素不一致
if (oTemp !== ev.target) {
// 删除缓存元素
oTemp = null;
return;
}

const [x, y] = ev.target.pos;

// 单击
if (ev.button === 0) {
// 没有标记
if (minesweeper.map[x][y].sign === 'normal') {
// 处理点击事件
minesweeper.handleClick(oTemp);
} else {
// 给缓存的元素添加抬起样式
oTemp.className = 'state-' + minesweeper.map[x][y].sign + '-up';
}
}
// 右击 且 未打开过
if (ev.button === 2) {
// 修改标记状态
minesweeper.map[x][y].sign = {
flag: 'question',
normal: 'flag',
question: 'normal',
}[minesweeper.map[x][y].sign];

// 地雷数量
if (minesweeper.map[x][y].sign === 'flag') {
minesweeper.mines -= 1;
}
if (minesweeper.map[x][y].sign === 'question') {
minesweeper.mines += 1;
}
document.getElementById('mines').innerHTML = minesweeper.mines;

// 给缓存的元素添加抬起样式
oTemp.className = 'state-' + minesweeper.map[x][y].sign + '-up';
}

// 删除缓存元素
oTemp = null;
});

先看单击事件,如果标记为 normal,则进行逻辑处理,如果是 flag 或者 question,则添加抬起事件不做任何处理。

右击事件,先将方块标记状态变更,添加抬起样式。如果变成了旗子就把地雷数量减一,旗子变成其他就把地雷数量加一。

鼠标双击

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 双击
oGame.addEventListener('dblclick', (ev) => {
// 没点中方块
if (oGame === ev.target) return;

const [x, y] = ev.target.pos;
const grid = minesweeper.map[x][y];

// 打开 且 为数字
if (grid.isOpen && grid.type > 0 && grid.type < 9) {
minesweeper.handleNumber([x, y], grid.type);

// 判断游戏胜利
minesweeper.judgeVictory();
}
});

游戏逻辑处理

这里我们看看具体的 handleClick 方法和 handleNumber 方法。

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
const minesweeper = {
/**
* 处理点击事件
*/
handleClick(dom) {
// 修改状态
if (this.state !== 'ongoing') {
this.state = 'ongoing';
this.startTime = +new Date();
this.startInterval();
}

const grid = this.map[dom.pos[0]][dom.pos[1]];

// 修改打开状态
grid.isOpen = true;
// 修改递归状态
grid.isCheck = true;
// 修改方块样式
dom.className = 'state-' + grid.type;

// 处理空白方块
if (grid.type === 0) {
this.handleSpace(dom.pos);
}
// 处理地雷方块
else if (grid.type === 9) {
this.handleMines([dom.pos]);
}
// // 处理数字方块(这里改为双击触发)
// else if (grid.type > 0 && grid.type < 9) {
// this.handleNumber(dom.pos, grid.type);
// }

// 判断游戏胜利
this.judgeVictory();
},
};

首先就是判定状态了,如果当前不是游戏状态,就修改状态并开启计时。

拿到方块 grid 对应数据后,修改它的打开状态、递归状态和方块样式。

处理空白方块

如果是一个空白方块,那么除了它自身变化以外,还要向外扩展将所有空白方块和相邻的数字方块展示出来。

image.png

点击红点之后,判断红色方框内所有方格,碰到数字则跳过(1/2/3/9),碰到空白则递归(4/6/7)。

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
const minesweeper = {
/**
* 处理空白方块
*/
handleSpace(pos) {
// 查找周围坐标
const around = this.findPos(pos);
for (let i = 0; i < around.length; i++) {
// 坐标
const [x, y] = around[i];
// 对应方块
const grid = this.map[x][y];

// 未递归过 且 标记为 normal
if (false === grid.isCheck && 'normal' === grid.sign) {
// 修改打开状态
grid.isOpen = true;
// 修改递归状态
grid.isCheck = true;

// 加载动画样式
const oDiv = oGame.children[x * this.col + y];
oDiv.className = 'state-' + grid.sign + '-down';
oDiv.addEventListener('animationend', function fn() {
oDiv.className = 'state-' + grid.type;
oDiv.removeEventListener('animationend', fn);
});

// 如果为数字则跳过
if (grid.type > 0 && grid.type < 9) {
continue;
}
// 如果为空白则递归
if (grid.type === 0) {
this.handleSpace(around[i]);
}
}
}
},
};

处理地雷方块

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
42
43
44
45
46
47
const minesweeper = {
/**
* 处理地雷方块
*/
handleMines(pos) {
// 修改状态
this.state = 'over';
oGame.className = 'fail';
// 清除时间
clearInterval(this.startTimer);

// 标记所有地雷和错误旗子
for (let i = 0; i < this.map.length; i++) {
for (let j = 0; j < this.map[i].length; j++) {
// 是地雷 且 不是旗子
if (this.map[i][j].type === 9 && this.map[i][j].sign !== 'flag') {
oGame.children[i * this.col + j].className = 'state-9';
}
// 不是地雷 且 是旗子
if (this.map[i][j].type !== 9 && this.map[i][j].sign === 'flag') {
oGame.children[i * this.col + j].className = 'state-flag-error';
}
}
}

for (let i = 0; i < pos.length; i++) {
// 坐标
const [x, y] = pos[i];
// 当前方块
const grid = this.map[x][y];

// 修改打开状态
grid.isOpen = true;
// 修改爆炸状态
grid.isExplode = true;

// 加载动画样式
const oDiv = oGame.children[x * this.col + y];
oDiv.className = 'state-over';
oDiv.addEventListener('animationend', function fn() {
// 游戏结束动画
minesweeper.explodeMines(pos[i]);
oDiv.removeEventListener('animationend', fn);
});
}
},
};

触碰到地雷肯定就 over 了,先标记出所有没有标记旗子的地雷,和所有错误的旗子。这个方法传递的坐标数组,因为双击数字触发有猜错多个地雷的可能,所以循环位置,给地雷添加打开、爆炸状态,并添加爆炸动画。

话说雪碧图里没有爆炸动画,哪来的素材呢?MSN 有这个游戏的 canvas 版本😉

我自己把素材保存后用 ps 弄了张雪碧图:

sprite_over.png

当爆炸动画结束后执行游戏结束动画 explodeMines 方法:

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
42
43
44
45
const minesweeper = {
/**
* 处理地雷爆炸
*/
explodeMines(pos) {
setTimeout(() => {
// 查找周围坐标
const around = this.findPosUDLR(pos);
for (let i = 0; i < around.length; i++) {
// 坐标
const [x, y] = around[i];
// 对应方块
const grid = this.map[x][y];

// 未爆炸过
if (grid.isExplode === false && grid.sign !== 'flag') {
// 修改爆炸状态
grid.isExplode = true;

const oDiv = oGame.children[x * this.col + y];

// 如果是地雷
if (grid.type === 9) {
// 修改打开状态
grid.isOpen = true;
// 加载动画样式
oDiv.className = 'state-over';
}
// 如果未打开
else if (!grid.isOpen) {
// 加载动画样式
oDiv.className = 'state-explode';
oDiv.addEventListener('animationend', function fn() {
oDiv.className = 'state-closed';
oDiv.removeEventListener('animationend', fn);
});
}

// 爆炸递归
this.explodeMines(around[i]);
}
}
}, 100);
},
};

通过 findPosUDLR 来进行菱形扩散,碰到 normal 标记就变红一下,碰到地雷就执行爆炸效果,递归完成所有方块的遍历。

Jietu20220125-122140-HD.gif

呆呆的 findPos 方法:

Jietu20220125-122427-HD.gif

处理数字方块

还记得双击中进行了数字方块的处理吗?原本我也是放到 handleClick 中了,但刚一翻开就会进行逻辑处理很明显是个 bug 啊!

那数字模块都要处理什么?我们先看代码:

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
42
43
44
45
46
47
48
49
50
51
const minesweeper = {
/**
* 处理数字方块
*/
handleNumber(pos, type) {
// 查找周围坐标
const around = this.findPos(pos);

// 标记的数量,当旗子(flag) >= 数字(type)时,才能将剩余 normal 标记做点击处理
let flag = 0;
// 旗子方块
const flags = [];
// 地雷方块
const mines = [];

for (let i = 0; i < around.length; i++) {
// 坐标
const [x, y] = around[i];
// 对应方块
const grid = this.map[x][y];

// 旗子
if (grid.sign === 'flag') {
flag++;
flags.push({ ...grid, pos: around[i] });
}
// 地雷
if (grid.type === 9) {
mines.push({ ...grid, pos: around[i] });
}
}

if (flag >= type) {
// 判断标记是否正确,因为都是一个顺序 push,所以可以简单的转字符串后比对
if (JSON.stringify(flags) === JSON.stringify(mines)) {
this.handleSpace(pos);
}
// 标记错误
else {
// 处理错误旗子
for (let i = 0; i < flags.length; i++) {
if (flags[i].type === 9) continue;

oGame.children[flags[i].pos[0] * this.col + flags[i].pos[1]].className = 'state-flag-error';
}
// 处理错误地雷
this.handleMines(mines.filter((item) => item.sign !== 'flag').map((item) => item.pos));
}
}
},
};

首先拿到数字的坐标 pos 和内容 type,其实内容通过 posmap 中取也行,不过外面我查到了,就顺手传进来了。

循环查找标记的数量,当旗子 flag >= 数字 type 时,才能将剩余 normal 标记做点击处理。

先判断标记是否正确,因为都是一个顺序 push,所以可以简单的转字符串后比对,对比成功则将当前数字方块当一个空白方块处理。

标记错误就将错误的小旗子显示出来,将错误的地雷坐标扔到处理地雷方块的逻辑里去。

判断游戏胜利

胜利的条件是 未打开的方块 === 旗子的数量 + 地雷的数量,所以单击一个方块和双击一个数字方块都应进行游戏胜利的判定:

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
const minesweeper = {
/**
* 判断游戏胜利
*
* 未打开的方块 === 旗子的数量 + 地雷的数量
*/
judgeVictory() {
let count = 0;
let flags = 0;

for (let i = 0; i < this.map.length; i++) {
for (let j = 0; j < this.map[i].length; j++) {
// 未打开的方块
if (this.map[i][j].isOpen === false) {
count++;
}
// 旗子的数量
if (this.map[i][j].sign === 'flag') {
flags++;
}
}
}

if (count === flags + this.mines) {
// 修改状态
this.state = 'over';
oGame.className = 'success';
// 清除时间
clearInterval(this.startTimer);
}
},
};

结尾

其实还有亿点点细节我觉得简单就没有在文章当中展示,感兴趣可以去 github 翻翻源码,对比着看会更好理解一点。

包括花园主题和所有音频其实也在游戏文件夹中,可以自己添加。这里再放一张花园主题的雪碧图:

TileStates-classic-combined.png

方法都一样,就是 css 的关键帧多写一些。

以上。