本文详细介绍如何利用HTML5 Canvas标签结合JavaScript脚本,从零开始构建一个基础的扫雷游戏。内容涵盖游戏初始化、网格绘制、鼠标点击事件处理(左键打开、右键标记)、递归展开空白区域以及胜负判定逻辑,并提供完整的代码实现思路与关键代码片段解析。
扫雷游戏简介与实现目标
扫雷游戏是Windows系统自带的经典小游戏,深受玩家喜爱。本文将演示如何结合HTML5的Canvas绘图能力与JavaScript逻辑,开发一款简易的扫雷游戏。
开发游戏前需明确规则:这是一款基于鼠标操作的游戏。玩家通过点击方块,根据方块显示的数字推断地雷位置。目标是标记所有地雷并打开所有非雷方块以获胜;若点击到地雷或错误标记,则判定失败。
游戏规则与交互操作定义
具体的游戏交互操作如下:
1. 使用鼠标左键点击未打开的方块,若该位置不是地雷,则自动向四周相邻方块扩展显示。
2. 使用鼠标右键点击未打开的方块可标记地雷,再次右键点击可取消标记。
3. 使用鼠标右键点击已打开且显示数字的方块,可快速检查该方块四周的标记是否正确(部分实现中用于快速验证)。
HTML结构与初始化配置
接下来进入代码编写阶段。
首先构建HTML结构,此处仅使用一个Canvas标签作为绘图区域,其他功能如游戏规则配置和难度设置将在后续代码中扩展实现。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Document</title>
<style>
#canvas {
display: block;
margin: 0 auto;
}
</style>
</head>
<body>
<div id="play">
<canvas id="canvas"></canvas>
</div>
<script src="js/game.js"></script>
</body>
</html>
核心数据结构与绘制逻辑
随后进行初始化配置,包括设置Canvas画布的宽高、定义游戏的行列数、地雷总数以及每个格子的大小参数。
//获取canvas画布
var canvas = document.getElementById('canvas');
var context = canvas.getContext('2d');
canvas.width = 480;
canvas.height = 480;
//定义各属性
let R = 3; //格子圆角半径
let L = 15; //每个格子实际长
let P = 16; //每个格子占长
let row = 30; //行数
let col = 30; //列数
let N = 50; //雷数
为了后续逻辑处理,需要定义多个数组来存储状态数据:一个二维数组存储每个方块的内容(是否为雷或数字),一个数组记录方块状态(是否已打开或被标记),一个数组存储地雷的具体坐标,以及一个数组用于记录玩家标记的位置。
var wholeArr = drawInitialize(row, col, N, R, L, P);
var gameArr = wholeArr[0] //位置数组
var bombArr = wholeArr[1] //雷的位置数组
var statusArr = zoneInitialize(row, col); //状态数组 0为未打开且未标记 1为打开 2为标记
var signArr = []; //标记数组
//画出初始界面
function drawInitialize(row, col, n, R, L, P) {
let arr = initialize(row, col, n);
for (let r = 0; r < row; r++) {
for (let c = 0; c < col; c++) {
drawRct(r * P, c * P, L, R, 'rgb(102,102,102)', context);//该方法用于绘制整个画面,下面会写出声明
}
}
return arr;
}
//初始化
function initialize(row, col, n) {
let gameArr = zoneInitialize(row, col); //生成没有标记雷的矩阵
let bomb = bombProduce(n, gameArr, row, col);
gameArr = signArrNum(bomb[0], bomb[1], n, row, col);
return [gameArr, bomb[1]];
}
//界面矩阵初始化
function zoneInitialize(row, col) { //生成row行col列的矩阵
let cArr = new Array(col);
let rArr = new Array(row);
cArr = cArr.fill(0); //将行的每个位置用0填充
for (let i = 0; i < row; i++)
rArr[i] = [...cArr];
return rArr;
}
//随机生成雷
function bombProduce(n, arr, row, col) { //随机生成n个雷
let count = 0;
let bombArr = [];
while (true) {
if (count === n)
break;
let r = Math.floor(Math.random() * row);
let c = Math.floor(Math.random() * col);
if (arr[c][r] === 0) {
arr[c][r] = -1;
bombArr[count] = strProduce(c, r);
count++;
}
}
return [arr, bombArr];
}
//标记数字
function signArrNum(gArr, bArr, n, row, col) {
for (let i = 0; i < n; i++) { //为每个雷的四周的非雷的数字标记加一
let r = parseInt(analyseStr(bArr[i]).row);
let c = parseInt(analyseStr(bArr[i]).col);
if (r > 0 && gArr[c][r - 1] != -1)//判断该位置是否为雷,是则不进行操作
gArr[c][r - 1]++;
if (r < row - 1 && gArr[c][r + 1] !== -1)
gArr[c][r + 1]++;
if (c > 0 && gArr[c - 1][r] !== -1)
gArr[c - 1][r]++;
if (c < col - 1 && gArr[c + 1][r] !== -1)
gArr[c + 1][r]++;
if (r > 0 && c > 0 && gArr[c - 1][r - 1] != -1)
gArr[c - 1][r - 1]++;
if (r < row - 1 && c < col - 1 && gArr[c + 1][r + 1] != -1)
gArr[c + 1][r + 1]++;
if (r > 0 && c < col - 1 && gArr[c + 1][r - 1] != -1)
gArr[c + 1][r - 1]++;
if (r < row - 1 && c > 0 && gArr[c - 1][r + 1] != -1)
gArr[c - 1][r + 1]++;
}
return gArr;
}
//生成字符串
function strProduce(r, c) {
return `row:${c}|col:${r}`;
}
//解析雷数组字符串
function analyseStr(str) {
str = str.split('|');
str[0] = str[0].split(':');
str[1] = str[1].split(':');
return { row: str[0][1], col: str[1][1] };
}
事件处理与递归扩展算法
接下来编写绘制函数,此处使用红色方块代表地雷进行可视化展示。
//画出单个方块
function drawRct(x, y, l, r, color, container = context) {//x,y为绘制的位置,l为方块的边长,r为方块圆角半径,color为方块的填充颜色
container.beginPath();
container.moveTo(x + r, y);
container.lineTo(x + l - r, y);
container.arcTo(x + l, y, x + l, y + r, r);
container.lineTo(x + l, y + l - r);
container.arcTo(x + l, y + l, x + l - r, y + l, r);
container.lineTo(x + r, y + l);
container.arcTo(x, y + l, x, y + l - r, r);
container.lineTo(x, y + r);
container.arcTo(x, y, x + r, y, r);
container.fillStyle = color;
container.closePath();
container.fill();
container.stroke();
}
//画出方块上对应的数字
function drawNum(x, y, l, r, alPha, color = 'rgb(0,0,0)', container = context) {//参数含义与上面的方法一样,alPha为要写的数字
if (alPha === 0)
alPha = "";
container.beginPath();
container.fillStyle = color;
container.textAlign = 'center';
container.textBaseline = 'middle';
container.font = '8Px Adobe Ming Std';
container.fillText(alPha, x + l / 2, y + l / 2);
container.closePath();
}
//画出游戏结束界面
function drawEnd(row, col, R, L, P) {
for (let r = 0; r < row; r++) {
for (let c = 0; c < col; c++) {//将整个界面绘制出来
let num = gameArr[r][c];
let color;
if (num === -1)
color = 'rgb(255,0,0)';
else
color = 'rgb(255,255,255)';
drawRct(r * P, c * P, L, R, color, context);
drawNum(r * P, c * P, L, R, num);
}
}
}
随后处理点击事件,针对点击后向四个方向扩展的逻辑,具体实现参考下图所示的扩散机制。

如上图所示,点击方块后,程序会在该位置向四周扩散;上下方向的扩散沿垂直轴线进行,左右方向的扩散除水平方向外还包含向上下方向的递归扩展,直到遇到显示数字的方块时停止扩散。
canvas.onclick = function(e) {
e = e || window.e;
let x = e.clientX - canvas.offsetLeft;
let y = e.clientY - canvas.offsetTop; //获取鼠标在canvas画布上的坐标
let posX = Math.floor(x / P);
let posY = Math.floor(y / P);//将坐标转化为数组下标
if (gameArr[posX][posY] === -1 && statusArr[posX][posY] !== 2) { //点到雷
alert('error');
drawEnd(row, col, R, L, P);
} else if (statusArr[posX][posY] === 0) {
this.style.cursor = "auto";
statusArr[posX][posY] = 1;//重置状态
drawRct(posX * P, posY * P, L, R, 'rgb(255,255,255)', context);
drawNum(posX * P, posY * P, L, R, gameArr[posX][posY]);
outNum(gameArr, posY, posX, row, col, 'middle');
}
gameComplete();//游戏成功,在下面代码定义
}
//右键标记雷,取消标记,检查四周
canvas.oncontextmenu = function(e) {
e = e || window.e;
let x = e.clientX - canvas.offsetLeft;
let y = e.clientY - canvas.offsetTop; //获取鼠标在canvas画布上的坐标
let posX = Math.floor(x / P);
let posY = Math.floor(y / P);
let str = strProduce(posX, posY);
if (gameArr[posX][posY] > 0 && statusArr[posX][posY] === 1) //检查四周雷数
checkBomb(posX, posY);
if (statusArr[posX][posY] === 0) { //标记雷
drawRct(posX * P, posY * P, L, L / 2, 'rgb(255,0,0)');
statusArr[posX][posY] = 2;
signArr[signArr.length] = str;
} else if (statusArr[posX][posY] === 2) { //取消标记
drawRct(posX * P, posY * P, L, R, 'rgb(102,102,102)');
statusArr[posX][posY] = 0;
signArr = signArr.filter(item => {//使用过滤器方法将当前位置的坐标标记清除
if (item === str)
return false;
return true;
})
}
gameComplete();
return false; //阻止事件冒泡
}
//自动跳出数字
function outNum(arr, x, y, row, col, status) {//arr为传入的数组,x,y为处理的位置,row,col为游戏的行列,status用于储存扩展的方向
if (status === 'middle') {
outNumHandle(arr, x - 1, y, row, col, 'left');
outNumHandle(arr, x + 1, y, row, col, 'right');
outNumHandle(arr, x, y - 1, row, col, 'top');
outNumHandle(arr, x, y + 1, row, col, 'down');
} else if (status === 'left') {
outNumHandle(arr, x - 1, y, row, col, 'left');
outNumHandle(arr, x, y - 1, row, col, 'top');
outNumHandle(arr, x, y + 1, row, col, 'down');
} else if (status === 'right') {
outNumHandle(arr, x + 1, y, row, col, 'right');
outNumHandle(arr, x, y - 1, row, col, 'top');
outNumHandle(arr, x, y + 1, row, col, 'down');
} else if (status === 'top') {
outNumHandle(arr, x, y - 1, row, col, 'top');
} else {
outNumHandle(arr, x, y + 1, row, col, 'down');
}
}
//跳出数字具体操作
function outNumHandle(arr, x, y, row, col, status) {
if (x < 0 || x > row - 1 || y < 0 || y > col - 1) //超出边界的情况
return;
if (arr[y][x] !== 0) {
if (arr[y][x] !== -1) {
drawRct(y * P, x * P, L, R, 'rgb(255,255,255)', context);
drawNum(y * P, x * P, L, R, arr[y][x]);
statusArr[y][x] = 1;
}
return;
}
drawRct(y * P, x * P, L, R, 'rgb(255,255,255)', context);
drawNum(y * P, x * P, L, R, arr[y][x]);
statusArr[y][x] = 1;
outNum(arr, x, y, row, col, status);
}
//检查数字四周的雷的标记并操作
function checkBomb(r, c) {
//1.检查四周是否有被标记确定的位置
//2.记下标记的位置数count
//3.若count为0,则return;若count大于0,检查是否标记正确
//4.如果标记错误,提示游戏失败,若标记正确但数量不够,则return跳出,若标记正确且数量正确,将其余位置显示出来
let bombNum = gameArr[r][c];
let count = 0;
if (r > 0 && statusArr[r - 1][c] === 2) {
if (!(bombArr.includes(strProduce(r - 1, c)))) {
alert('error');
drawEnd(row, col, R, L, P);
return;
}
count++;
}
if (r < row - 1 && statusArr[r + 1][c] === 2) {
if (!(bombArr.includes(strProduce(r + 1, c)))) {
alert('error');
drawEnd(row, col, R, L, P);
return;
}
count++;
}
if (c > 0 && statusArr[r][c - 1] === 2) {
if (!(bombArr.includes(strProduce(r, c - 1)))) {
alert('error');
drawEnd(row, col, R, L, P);
return;
}
count++;
}
if (c < col - 1 && statusArr[r][c + 1] === 2) {
if (!(bombArr.includes(strProduce(r, c + 1)))) {
alert('error');
drawEnd(row, col, R, L, P);
return;
}
count++;
}
if (r > 0 && c > 0 && statusArr[r - 1][c - 1] === 2) {
if (!(bombArr.includes(strProduce(r - 1, c - 1)))) {
alert('error');
drawEnd(row, col, R, L, P);
return;
}
count++;
}
if (r < row - 1 && c < col - 1 && statusArr[r + 1][c + 1] === 2) {
if (!(bombArr.includes(strProduce(r + 1, c + 1)))) {
alert('error');
drawEnd(row, col, R, L, P);
return;
}
count++;
}
if (r > 0 && c < col - 1 && statusArr[r - 1][c + 1] === 2) {
if (!(bombArr.includes(strProduce(r - 1, c + 1)))) {
alert('error');
drawEnd(row, col, R, L, P);
return;
}
count++;
}
if (r < row - 1 && c > 0 && statusArr[r + 1][c - 1] === 2) {
if (!(bombArr.includes(strProduce(r + 1, c - 1)))) {
alert('error');
drawEnd(row, col, R, L, P);
return;
}
count++;
}
if (count !== bombNum)
return;
else {
outNotBomb(c, r);
}
}
//跳出四周非雷的方块
function outNotBomb(c, r) {
if (r > 0 && statusArr[r - 1][c] === 0) {
drawRct((r - 1) * P, c * P, L, R, 'rgb(255,255,255)', context);
drawNum((r - 1) * P, c * P, L, R, gameArr[r - 1][c]);
statusArr[r - 1][c] = 1;
}
if (r < row - 1 && statusArr[r + 1][c] === 0) {
drawRct((r + 1) * P, c * P, L, R, 'rgb(255,255,255)', context);
drawNum((r + 1) * P, c * P, L, R, gameArr[r + 1][c]);
statusArr[r + 1][c] = 1;
}
if (c > 0 && statusArr[r][c - 1] === 0) {
drawRct(r * P, (c - 1) * P, L, R, 'rgb(255,255,255)', context);
drawNum(r * P, (c - 1) * P, L, R, gameArr[r][c - 1]);
statusArr[r][c - 1] = 1;
}
if (c < col - 1 && statusArr[r][c + 1] === 0) {
drawRct(r * P, (c + 1) * P, L, R, 'rgb(255,255,255)', context);
drawNum(r * P, (c + 1) * P, L, R, gameArr[r][c + 1]);
statusArr[r][c + 1] = 1;
}
if (r > 0 && c > 0 && statusArr[r - 1][c - 1] === 0) {
drawRct((r - 1) * P, (c - 1) * P, L, R, 'rgb(255,255,255)', context);
drawNum((r - 1) * P, (c - 1) * P, L, R, gameArr[r - 1][c - 1]);
statusArr[r - 1][c - 1] = 1;
}
if (r < row - 1 && c < col - 1 && statusArr[r + 1][c + 1] === 0) {
drawRct((r + 1) * P, (c + 1) * P, L, R, 'rgb(255,255,255)', context);
drawNum((r + 1) * P, (c + 1) * P, L, R, gameArr[r + 1][c + 1]);
statusArr[r + 1][c + 1] = 1;
}
if (r > 0 && c < col - 1 && statusArr[r - 1][c + 1] === 0) {
drawRct((r - 1) * P, (c + 1) * P, L, R, 'rgb(255,255,255)', context);
drawNum((r - 1) * P, (c + 1) * P, L, R, gameArr[r - 1][c + 1]);
statusArr[r - 1][c + 1] = 1;
}
if (r < row - 1 && c > 0 && statusArr[r + 1][c - 1] === 0) {
drawRct((r + 1) * P, (c - 1) * P, L, R, 'rgb(255,255,255)', context);
drawNum((r + 1) * P, (c - 1) * P, L, R, gameArr[r + 1][c - 1]);
statusArr[r + 1][c - 1] = 1;
}
}
胜负判定与界面渲染
接着实现判断所有地雷是否被正确标记或找到的逻辑,即游戏通关条件。
//成功找出所有的雷
function gameComplete() {
var count = new Set(signArr).size;
if (count != bombArr.length) //雷的数量不对
{
return false;
}
for (let i of signArr) { //雷的位置不对
if (!(bombArr.includes(i))) {
return false;
}
}
for (let i of statusArr) {
if (i.includes(0)) {
return false;
}
}
alert('恭喜你成功了');
canvas.onclick = null;
canvas.onmouseover = null;
canvas.oncontextmenu = null;
}
最后调用绘制方法渲染游戏界面。注意,该调用需放在数组声明之后或合适位置,因为数组初始化过程中也包含绘制逻辑,需确保最终界面渲染不被覆盖。
drawRct(0, 0, 800, 0, 'rgb(0,0,0)', context);
总结与后续优化建议
至此,一个基础的扫雷游戏已实现(注:作者自谦为简陋而非简单)。
这仅是游戏的初步实现。后续可优化增加难度设置、使用图片替代色块表示地雷、添加触雷音效等功能,这些扩展并不复杂,有兴趣的开发者可尝试优化。
希望本文能对大家有所帮助,也欢迎技术专家指出不足之处。
附上游戏运行界面截图。

更多关于JavaScript游戏的精彩文章,请查看专题:《JavaScript经典游戏 玩不停》。
以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持本站。