大家好,我是大胆的番茄。本文是「挑战 100 款小游戏」的第二款游戏:「扫雷」的总结与分享。
演示地址:wanghaida.com/demo/2202-m…
Github 仓库:github.com/wanghaida/g…
一个游戏要想好玩,除了玩法以外,设计也是必须的。但我不会设计怎么办?那就把 Microsoft Store 里的 Microsoft Minesweeper 下载下来翻翻目录,就发现了个这个:
嚯~,这不是我们熟悉的雪碧图吗?虽然不是全部的,但基本的都在。爱了爱了~
地图
雪碧图上每个方块正好 84x84 像素,为了高清屏,除以 2,每个方块是 42x42。游戏有 9x9、16x16、30x16 三种难度,按 30x16 来算,游戏区域大小达到了 1260x672,再包括一些小间距的话,小屏幕稍显吃力,所以除了 9x9 采用 84/2 = 42 像素以外,其他的用 84/2.4 = 35 像素。
用网格简单的把架子搭一下:
: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;
}
}
// 游戏区域
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();
得到下面一张图:
CSS 上使用了三个变量,--base
用来缩放每个方块大小的,--row
表示行,--col
表示列。还是咱们熟悉的网格系统,通过 repeat()
函数来画出行和列。
直接出来可不行,人家是有加载动效的,我准备加个关键帧动画和定时器:
#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));
}
}
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 进游戏区同时添加样式:
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 上只存数据对应坐标:
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:
基础动效
再把基础的空格、数字、旗子、问号效果弄出来:
.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));
}
}
效果如下:
随机生成地雷
随机生成地雷比较简单,在 0 - row * col
生成 N 个不重复数字,再转换为二维坐标即可:
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
方法就是用来返回周围坐标的:
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 格,主要用来游戏结束后的爆炸效果,要不显得有点呆。
游戏逻辑分析
鼠标事件有按下、抬起、移动和双击。
这里为啥不把按下和抬起合并成单击事件呢?因为鼠标在一个方块左键按下不松手,移出当前方块的则需要进行恢复,主要是防止误点。而右键按下时再抬起时,需要进行标记变换(正常->旗子->问号->正常),所以不能简单的处理成单击事件,而且还得缓存一下按下的方块,在移动后、抬起时判定还是不是原来的方块。
至于双击事件,主要存在于双击数字时快速将未标记旗子的方块进行打开操作。
鼠标按下
// 方块缓存
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
填充。
先判断当前这个方块没有打开,然后缓存当前点击的方块,给方块添加一个按下的效果。
鼠标移动
// 鼠标移动
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 存在且和当前元素不相等,且有按下的样式,就把按下样式变为了抬起。
鼠标抬起
// 鼠标抬起
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
,则添加抬起事件不做任何处理。
右击事件,先将方块标记状态变更,添加抬起样式。如果变成了旗子就把地雷数量减一,旗子变成其他就把地雷数量加一。
鼠标双击
// 双击
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
方法。
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
对应数据后,修改它的打开状态、递归状态和方块样式。
处理空白方块
如果是一个空白方块,那么除了它自身变化以外,还要向外扩展将所有空白方块和相邻的数字方块展示出来。
点击红点之后,判断红色方框内所有方格,碰到数字则跳过(1/2/3/9),碰到空白则递归(4/6/7)。
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]);
}
}
}
},
};
处理地雷方块
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 弄了张雪碧图:
当爆炸动画结束后执行游戏结束动画 explodeMines
方法:
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
标记就变红一下,碰到地雷就执行爆炸效果,递归完成所有方块的遍历。
呆呆的 findPos
方法:
处理数字方块
还记得双击中进行了数字方块的处理吗?原本我也是放到 handleClick
中了,但刚一翻开就会进行逻辑处理很明显是个 bug 啊!
那数字模块都要处理什么?我们先看代码:
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
,其实内容通过 pos
在 map
中取也行,不过外面我查到了,就顺手传进来了。
循环查找标记的数量,当旗子 flag
>= 数字 type
时,才能将剩余 normal
标记做点击处理。
先判断标记是否正确,因为都是一个顺序 push,所以可以简单的转字符串后比对,对比成功则将当前数字方块当一个空白方块处理。
标记错误就将错误的小旗子显示出来,将错误的地雷坐标扔到处理地雷方块的逻辑里去。
判断游戏胜利
胜利的条件是 未打开的方块 === 旗子的数量 + 地雷的数量
,所以单击一个方块和双击一个数字方块都应进行游戏胜利的判定:
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 翻翻源码,对比着看会更好理解一点。
包括花园主题和所有音频其实也在游戏文件夹中,可以自己添加。这里再放一张花园主题的雪碧图:
方法都一样,就是 css 的关键帧多写一些。
以上。