扫雷玩过吗?自己动手做一个

扫雷(Minesweeper)可能是很多刚接触电脑的同学玩的第一款电脑游戏,这次使用网页前端编程自己动手制作一个扫雷游戏,主要代码大约140行(不包括注释)。

怀旧一下,下图为 Windows 3.1 时候的扫雷游戏

Windows 3.1 上面的扫雷游戏

没有玩过这个游戏的同学,玩法规则很简单:只有两个操作,1挖开,2标记地雷,把所有的地雷用小旗子标出后胜出,挖到地雷算输,挖开格中带的数字代表它周围方格中地雷的数量,中间的方格周围有8格,边上的方格周围有5格,角落的方格周围有3格。

下图是我们这次自己制作的扫雷游戏,点击这里可以体验完成版本的效果。左键点击代表挖开操作;右键或长按代表标记操作。

网页版扫雷游戏

准备阶段

本文中需要用到 HTML 语言基础,JavaScript 语言基础,CSS 基础,不过不用担心,我会在编写过程中对关键点做相关介绍,没有信心的同学,可以先查阅我之前发布的文章来补充一下。

代码编辑器可以使用VSCode或者Atom,当然理论上记事本也是可以的,但是没着色,没辅助提示,写起来就比较累了。

创建一个文件夹来存放项目文件,命名为js-minesweeper或其他你喜欢的名字,不过尽量不用中文名,里面创建以下3个文件(整个项目也只需要这3个文件):

文件1 index.html

里面写入这些内容

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Minesweeper</title>
  <script src="app.js"></script>
  <link rel="stylesheet" href="style.css">
</head>
<body>
  <div class="grid"></div>
</body>
</html>

这个文件保存好之后就基本不需要动了,第1行通常用来申明文件的格式,<!DOCTYPE html>告诉浏览器这是个符合HTML5规范的文件,html文件是个嵌套结构,根节点html包住了所有内容,其次就是headbody节点。

head节点内定义了页面的编码格式,标题,以及需要引用的外部文件等。

body节点中我们定义了 class 名为griddiv节点,整个扫雷游戏的页面元素会用javascript代码动态插入到这个节点之内,相当于是一个容器(大框框)包住了所有的元素,这个容器的大小取决于里面元素的多少以及css的定义来决定。

文件2 app.js

主要代码文件,它被index.html文件引用了(见上段代码第7行),下面章节中会详细介绍里面编写的内容。

文件3 style.css

主要的样式文件,用来定义页面元素的颜色外观,它也是被index.html文件引用了。

*, body {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
  font-family: Arial, Helvetica, sans-serif;
}
.grid {
  white-space: nowrap;
  user-select: none;
  -moz-user-select: none;
  -ms-user-select: none;
  -webkit-user-select: none;
  -webkit-touch-callout: none;
}

首先定义全局的外观,去掉了边距(margin)、填充(padding)和设置为无衬线字体等;其次定义方格容器不自动换行,因为手机上要长按操作,所以取消文字选择功能。

进入正题

接下来就是正式开工了,先打开app.js写下如下代码

// app.js
document.addEventListener('DOMContentLoaded', () => {
  // 在所有文件加载完成后执行,剩下所有js代码都写在这对大括号内
});

这个document就是指整个页面,addEventListener是添加一个事件侦听函数,侦听的是DOMContentLoaded事件,document对象会在页面所有引用的文件都加载完成之后发布这个事件,然后右边箭头函数就会被执行。

申明全局变量

// app.js
// 接上文,省略部分代码...
const cols = 10; // 棋盘方格列数,暂定为10列
const rows = 10; // 棋盘方格行数,暂定为10行
const ratio = 0.2; // 地雷占比,暂定为20%,即100格中有20格雷
const blocks = []; // 所有方格的数组,用来存放所有生成的方格
let isGameOver = false; // 游戏是否结束的标志,初始为未结束

上段代码定义好接下来需要用到的常量(const)和变量(let),在游戏过程中不会变的量我们申明为常量,比如地雷占比,棋盘行数和列数;可能会变化的声明为变量;

着重介绍一下blocks数组,一个用来存放所有方格的容器,单个方格在接下来代码中称作block,这个单词是块的意思,所以它们的容器就用复数。那么每个方方格就是这个游戏的基本元素,它里面可能有雷,可能没有雷,要能显示数字,旗子,炸雷的效果,还有不同的颜色。

创建棋盘

接下来就轮到声明一个创建棋盘的函数了,把整个棋盘的方格按照给定的行数,列数给生成出来,看下面的代码。

// app.js
// 接上文,省略部分代码...
function createBoard() {
  const grid = document.querySelector('.grid');
  for (let i = 0; i < cols * rows; i++) {
    let block = document.createElement('div');
    block.id = i;
    block.innerText = i;// 在方格中显示序号
    block.addEventListener('click', clickHandler);
    block.addEventListener('contextmenu', rightClickHandler);
    grid.appendChild(block);
    blocks.push(block);
    if (i % cols == cols - 1)
      grid.appendChild(document.createElement('br'));
  }
}
createBoard();

函数名叫做createBoard,第4行获取到HTML页面中的div容器grid,方便在接下来的代码中将方格添加到容器中;第5行编写一个循环创建所有方格,目前10行×10列总共会创建100个方格,变量i的值会从0加到99

循环体内第6行,使用createElement方法创建一个页面元素;第7行将方格的序号id属性设置为序号i;第8行是将序号显示在方格中,在开发过程中方便调试纠正错误,确认没问题之后再掉;第9,10行给方块添加两个事件处理函数,clickHandler函数处理click点击事件,rightClickHandler函数处理contextmenu右键菜单事件,这两个函数目前都还没申明,稍后的**用户操作事件处理**章节中再编写;第11,12行,将方格添加到HTML页面的容器中,同时添加到JS代码的blocks容器中,前者是为了让它在页面中显示出来,后者是为了接下来在代码中方便寻找控制它,因为添加顺序和序号一致,所以数组索引和方格序号是相同的。

第13,14行,在每次添加完10个(取决于cols的值)方块后,给方块容器增加一个换行元素,比如在i等于9时,9 % 10 就是9除10取余数,等于9,每次余数为9时就是每行的最后一个元素。

第17行,createBoard函数申明完后就马上被调用执行了。

浏览器打开index.html查看运行效果,看到的还是空白页面,那是因为我们还没有给这些格子设置外观。编辑器打开style.css文件输入保存以下内容。

/* style.css */
/* 接上文,省略部分代码... */
.grid div {
  width: 35px;
  height: 35px;
  color: darkblue;
  background-color: lightblue;
  display: inline-block;
  text-align: center;
  vertical-align: middle;
  line-height: 35px;
  border: 1px dashed grey;
  cursor: pointer;
}

上段代码定义方格宽高为35像素,文字颜色(color)为深蓝,背景颜色(background-color)为浅蓝,显示方式(display)为内联块(div元素默认是块元素会独占一行),文字居中显示,边框(border)1像素灰色虚线,鼠标指针(cursor)显示为手指。

刷新浏览器,此时页面应看到10×10个带虚线浅蓝色方格,数字代表它们的序号。

布雷

接下来就需要随机给这些方格中撒上指定数量的雷了,总体思路是先算出雷的总数,非雷的总数,按数量生成这些状态放入到同一个数组中,然后把他们次序打乱。

// app.js
// 接上文,省略部分代码...
function setupBomb() {
  let amount = Math.floor(cols * rows * ratio);
  let array = new Array(cols * rows - amount).fill(false);
  array = array.concat(new Array(amount).fill(true));
  array = array.sort(() => Math.random() - 0.5);
  blocks.forEach((block, id) => {
    block.hasBomb = array[id];
    block.total = 0;
    // 计算周围雷的总数
    getAroundIds(id).forEach((_id) => {
      block.total += array[_id] ? 1 : 0;
    });
  });
}
setupBomb();

上段代码第4行,amount是雷总数;第5行创建数组并填充非雷状态false;第6行填充是雷状态true;第7行将所有元素随机打乱;

第8行开始遍历所有方格,把是否含雷的状态设置到每个方格的hasBomb属性中,forEach函数会把blocks中的每个方格对象block以及它的序号id依次放到箭头函数中执行;

第10行开始设置每个方格周围的总雷数total,初始值为0;第12行是个自定义函数getAroundIds,顾名思义我们期望这个函数的作用是:根据当前棋盘的行列布局输入任一序号就可以得出其周围所有格子的序号数组;比如输入0,就得出[1,11,10],输入11就得出[0,1,2,12,22,21,20,10];稍后来实现这个函数,现在我们就假定它可以顺利工作,在箭头函数中我们检查这些周围方格是否有雷,如果有就给total加1,否则就加0(第13行)。

getAroundIds 函数

在编写此函数前,查看之前生成的棋盘效果,我们注意到:

大部分靠近棋盘中央的方格周围总是有,上左、上、上右、右、右下、下、下左,左8个方格;而最边上最多5个方格,和四角落上最多只有3个方格,我们需要特别留意这些情况。

实现这个函数功能有很多不同写法,下面是我实现的版本:

// app.js
// 接上文,省略部分代码...
function getAroundIds(id) {
  const ids = [];
  const notTopEdge = (id) => id >= cols;
  const notLeftEdge = (id) => id % cols != 0;
  const notRightEdge = (id) => id % cols != cols - 1;
  const notBottomEdge = (id) => id < cols * rows - cols;
  if (notTopEdge(id)) {
    if (notLeftEdge(id)) {
      ids.push(id - cols - 1); // 上左
    }
    ids.push(id - cols); // 上
    if (notRightEdge(id)) {
      ids.push(id - cols + 1); // 上右
    }
  }
  if (notRightEdge(id)) {
    ids.push(id + 1); // 右
  }
  if (notBottomEdge(id)) {
    if (notRightEdge(id)) {
      ids.push(id + cols + 1); // 下右
    }
    ids.push(id + cols); // 下
    if (notLeftEdge(id)) {
      ids.push(id + cols - 1); // 下左
    }
  }
  if (notLeftEdge(id)) {
    ids.push(id - 1); // 左
  }
  return ids;
}

第4行定义了个空数组,用来存放函数返回结果,从函数体最后一行(33行)可以看到,不管执行结果如何,ids被返回了出去;

第5-8行是一些内部做辅助判断的函数,目的是提高代码质量和可读性,如果参数id满足条件就返回true(逻辑真值),notTopEdge表示不是最上边,notLeftEdge表示不是最左边,notRightEdge表示不是最右边,notBottomEdge表示不是最底边;

从第9行开始,就是按顺时针方向来判断和计算方格周围的格子序号,比如:只要输入的方格序号不是左上角,就会有上左的方格,每次算得的序号就会插入(push)到结果数组中。

用户操作事件处理

现在就轮到处理用户操作的事件了,第1个操作是挖开方格,第2个操作是标记地雷;分别对应我们上文提到的clickHandlerrightClickHandler两个回调函数该做的事;

clickHandler 回调函数

这是上文中方格单击click事件的回调函数,用来响应用户的挖雷操作,直接上代码,里面加了注释,逻辑应该挺清晰的了

// app.js
// 接上文,省略部分代码...
function clickHandler(e) {
  if (isGameOver) {
    return; // 如果游戏结束,不再往下执行
  }
  let block = e.currentTarget;
  if (block.checked || block.marked) {
    return; // 如果挖开过或标记过,不再往下执行
  }
  if (block.hasBomb) {
    // 本格有雷,游戏结束
    isGameOver = true;
    blocks.forEach(block => {
      // 把每颗雷都爆出来,显示emoji爆炸符号
      if (block.hasBomb) {
        block.classList.add("boom");
        block.innerText = '💥';
      }
    });
    setTimeout(() => {
      // 显示游戏结束提示,询问是否再玩
      if (confirm('BOOM...GAME OVER! Play Again?')) {
        // 确认再玩,重新加载页面
        location.reload();
      }
    }, 50);
  } else {
    // 本格无雷的情况
    if (block.total == 0) {
      // 周围无雷,进入安全格递归检测,下文详细介绍
      checkBlock(block);
    } else {
      // 周围有雷,显示为挖开状态及雷数
      block.classList.add("checked");
      block.innerText = block.total;
    }
  }
  // 标记为挖过
  block.checked = true;
}

上段代码第17行和第35行给方块加了两种 CSS class,一个是boom对应炸雷,另个是checked对应挖开,再增加一种safe对应挖开后周围无雷的安全格,在style.css中加上对应的效果,添加如下 CSS 代码

/* style.css */
/* 接上文,省略部分代码... */
.grid div.boom {
  background-color: red;
  font-size: larger;
  cursor: default;
}

.grid div.checked {
  background-color: darkgray;
  cursor: default;
}

.grid div.safe {
  background-color: lightgreen;
  cursor: default;
}

这里的做法是爆炸的时候方格背景色设红色,挖开无雷的背景色设为灰色,挖开周围无雷的安全格背景色设为浅绿色。

checkBlock 安全格检测

这个函数的作用是每次挖到一个周围无雷的安全格时(total值为0),要把所有相连的周围也无雷的方格全部自动挖开,里面涉及到递归的使用。

function checkBlock(block) {
  // 能进入该函数的 block 周围都无雷,所以显示为 safe 效果
  block.classList.add("safe");
  // 遍历周围的方格
  getAroundIds(Number(block.id)).forEach(id => {
    let a_block = blocks[id];
    if (a_block.checked || a_block.marked)
      return; // 跳过挖开过或标记过的方格
    a_block.checked = true;
    if (a_block.total == 0) {
      // 周围无雷,递归检查周围的周围是否也无雷
      checkBlock(a_block);
    } else {
      // 周围有雷,显示为挖开状态及雷数
      a_block.classList.add("checked");
      a_block.innerText = a_block.total;
    }
  });
}

如下图为例,彩色数字显示了checkBlock函数递归检查安全格的次序,同种颜色代表的是同一次的函数调用,从右上角开始,可以看到总共递归调用了8次,直到周围再没有符合条件的安全格后终止;递归次序:始->(1,2,3),1->(4,5),2->(6,7,8),6->(9),9->(10,11),10->(12,13),13->(终)。

安全格检测递归

rightClickHandler 回调函数

这是上文中方格右击contextmenu事件的回调函数,用来响应用户的标雷操作,直接上带有注释的代码

function rightClickHandler(e) {
  e.preventDefault();
  if (isGameOver) {
    return; // 如果游戏结束,不再往下执行
  }
  let block = e.currentTarget || e.target;
  if (block.checked) {
    return; // 如果挖开过过,不再往下执行
  }
  // 切换方格的标记状态,已标记的换不标记,不标记的换已标记
  block.marked = !block.marked;
  block.classList.toggle('marked');、
  // 已标记时给方格显示一个emoji旗子符号
  block.innerText = block.marked ? '🚩' : '';
  if (blocks.every(block => (block.hasBomb && block.marked) || (!block.hasBomb && !block.marked))) {
    // 每个雷都标记了,不该标记的没标记,则判定为游戏胜利
    isGameOver = true;
    setTimeout(() => {
      alert('YOU WIN!'); // 提示你赢了
    }, 50);
  }
}

到这里所有代码编写完毕,大功告成!打开浏览器试玩一把吧。