使用HTML5 Canvas与JavaScript实现简易扫雷游戏

作者:小圆点时间:2026-10-23 15:59:05浏览:521

本文详细介绍如何利用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经典游戏 玩不停》。

以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持本站。

作者最新文章