效果图展示
本次扫雷游戏可分为初中高级,效果图如下:
html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
<link rel="stylesheet" href="./css/index.css">
</head>
<body>
<div class="mine">
<div class="level">
<button class="active">初级</button>
<button>中级</button>
<button>高级</button>
<button>重新开始</button>
</div>
<div class="game-box">
</div>
<div class="info">剩余雷数:<span class="mine-num">10</span></div>
</div>
</body>
<script src="./js/index.js"></script>
</html>
css
.mine {
margin: 50px auto;
}
.level {
text-align: center;
margin-bottom: 10px;
}
.level button {
padding: 5px 15px;
background: #02a4ad;
border: none;
outline: none;
color: #fff;
border-radius: 3px;
cursor: pointer;
}
.level button.active {
background: #00abff;
}
.info {
margin: 10px auto;
text-align: center;
}
table {
border-spacing: 1px;
background: #929196;
margin: 0 auto;
}
td {
padding: 0;
width: 20px;
height: 20px;
background-color: #bbb;
border: 2px solid;
border-color: #fff #a1a1a1 #a1a1a1 #fff;
text-align: center;
line-height: 20px;
font-weight: bold;
}
.mine-item {
background: #d9d9d9 url("../imgs/mine.png") no-repeat center center / cover;
}
.flag {
background: #d9d9d9 url("../imgs/flag.png") no-repeat center center / cover;
}
td.zero {
border-color: #d9d9d9;
background: #d9d9d9;
}
td.one {
border-color: #d9d9d9;
background: #d9d9d9;
color: #0332fe;
}
td.two {
border-color: #d9d9d9;
background: #d9d9d9;
color: #019f02;
}
td.three {
border-color: #d9d9d9;
background: #d9d9d9;
color: #ff2600;
}
td.four {
border-color: #d9d9d9;
background: #d9d9d9;
color: #93208f;
}
td.five {
border-color: #d9d9d9;
background: #d9d9d9;
color: #ff7f29;
}
td.six {
border-color: #d9d9d9;
background: #d9d9d9;
color: #ff3fff;
}
td.seven {
border-color: #d9d9d9;
background: #d9d9d9;
color: #3fffbf;
}
td.eight {
border-color: #d9d9d9;
background: #d9d9d9;
color: #22ee0f;
}
js部分
js部分采用Typescript进行书写,Typescript可以检查一个变量的类型,可以在开发时避免不少bug。
确定行、列和雷数
表格我们根据传入的行和列进行渲染,传入一个mineNumber表示雷的数量。再constructor里面使用入口函数init进行后续操作。
class MineSweeping {
private readonly rows: number; // 行数
private readonly cols: number; // 列数
private readonly mineNumber: number; // 总的雷数量
private readonly restMineNumber: number; //剩余雷数
/**
*
* @param rows
* @param cols
* @param mineNumber 雷数
*/
constructor(rows: number, cols: number, mineNumber: number) {
this.rows = rows;
this.cols = cols;
this.mineNumber = mineNumber;
this.restMineNumber = mineNumber;
this.init()
}
}
确定数字和雷
在此之前,我们思考一个问题。我们怎么区分数字和雷?这里我是用一个数据结构进行区分。
interface MineInterface {
// 类型是数字或雷
type: 'mine' | 'number',
// 在dom中的坐标,与二维数组中的行和列反着的
x: number,
y: number,
// 在二维数组中的行和列
row: number,
col: number,
// 如果是数字的话,value即数字的值
value?: number,
// 如果是雷的话,这个字段表示雷是否被标记
flag?: boolean
}
生成随机位置表示雷
生成雷的时候,我们取this.rows * this,cols
这么打个数组打乱顺序后的前mineNumber
个元素为雷。
/**
* 生成mineNumber个不重复的数字,代表雷的位置
*/
private randNum(): number[] {
let square: number[] = new Array(this.cols * this.rows);
for (let i: number = 0; i < square.length; i++) {
square[i] = i;
}
// 打乱这个数组
square.sort(() => 0.5 - Math.random());
// 数组的前面mineNumber个元素当作雷
return square.slice(0, this.mineNumber)
}
初始化雷和数字的数组
雷和数字的数组是一个非常重要的数据结构。雷的数组是一个以为数组,数字的数组由于还要关联到dom所以是个二维数组
// 雷的二维数组
private container: HTMLDivElement = document.querySelector('.game-box');
private mines: MineInterface[] = [];
// 数字的二维数组,用于存储每个格子的信息
private square: MineInterface[][] = [];
public init(): void {
// 每次调用开始游戏将以前的内容情况
this.container.innerHTML = '';
// 初始化数字的二维数组
let randNum: number[] = this.randNum();
// 这个是个标志,每次循环+1,
// 如果这个数字在生成的随机雷的数字数组找得到的话,那么就在这个循环回合添加雷
let num: number = 0;
for (let i: number = 0; i < this.rows; i++) {
this.square[i] = [];
for (let j: number = 0; j < this.cols; j++) {
// ~randNum,indexOf(num) => -(randNum.indexOf(num) + 1)
if (~randNum.indexOf(num)) { // 有雷
let mine: MineInterface = {
type: "mine",
x: j,
y: i,
row: i,
col: j,
flag: false
};
// 向数字数组和雷的数组分别添加这个雷
this.square[i][j] = mine;
this.mines.push(mine);
} else { // 在随机数字数组中找不到num,则无雷
// 无雷则直接添加到数字数组
this.square[i][j] = {
type: "number",
x: j,
y: i,
row: i,
col: j,
value: 0
}
}
num++;
}
}
this.updateNumber();
this.createDom();
this.addListener();
this.mineNum.innerHTML = this.mineNumber + '';
}
初始化数字数组数字的值
在做这个操作之前我们需要考虑。我们怎么确定数字的值。玩过扫雷游戏的人都知道扫雷的规则,数字的大小是由在这个数字周围的八个格子中雷的数量来确定的。所以我们可以根据这个规则来设置数字的大小。
我们的思路:我们循环每一个格子,找到其中的雷,然后让周围的数字格子数值都+1。这样下来我们就可以的到所有数字了
private updateNumber() {
for (let i = 0; i < this.rows; i++) {
for (let j = 0; j < this.cols; j++) {
// 非雷的格子我们不加数值
if (this.square[i][j].type === 'number') {
continue;
}
// 找到一个雷了之后,将其周围的数字格子value+1
// 每一个雷周围的数字集合
let num: number[][] = this.getAround(this.square[i][j]);
// 遍历这个周围非雷格子的数组,使其value+1
for (let k = 0; k < num.length; k++) {
this.square[num[k][0]][num[k][1]].value += 1
}
}
}
}
/**
* 获取当前格子周围所有的格子
* @param square 当前格子
*/
private getAround(square: MineInterface): number[][] {
// res找到的格子的坐标返回出去,这里x和y是dom中的坐标
let {x, y} = square, res: number[][] = [];
for (let i = x - 1; i <= x + 1; i++) {
for (let j = y - 1; j <= y + 1; j++) {
// 做判断,排除四角、自身是雷、不遍历自己
if (i < 0 || // 左边格子超出了范围
j < 0 || // 格子超出了上边的范围
i > this.rows - 1 || // 格子超出了右边的范围
j > this.cols - 1 || // 各自超出了下边的范围
(i === x && j === y) || // 当前遍历到的格子是自己
// j和i相反得出row,col
this.square[j][i].type === 'mine' // 周围的格子是个雷
) {
continue
}
res.push([j, i]) // 要以行和列的方式进行返回
}
}
return res
}
渲染表格
表格是根据我们传入的行和列进行渲染的。
private doms: HTMLTableCellElement[][] = []; //doms的二维数组
private createDom(): void {
let table: HTMLTableElement = document.createElement('table');
for (let i = 0; i < this.rows; i++) {
let tr: HTMLTableRowElement = document.createElement('tr');
this.doms[i] = [];
for (let j = 0; j < this.cols; j++) {
let td: HTMLTableCellElement = document.createElement('td');
// 给每一个td增加行和列的属性,
// 方便点击的时候获取当前点击的dom的在二维数组中的位置
(td as any).row = i;
(td as any).col = j;
this.doms[i][j] = td;
// 给当前中心找到的格子添加check标志,
// 使用扩散算法的时候赵国的以后不再找这个
(this.doms[i][j] as any).check = false;
tr.append(td)
}
table.append(tr)
}
this.container.append(table)
}
绑定事件
由于我们格子过多,给每一个格子在循环的时候绑定事件的话,效率低,所以这里我们采用事件委托的方式进行绑定。
private addListener() {
this.container.addEventListener('mousedown', this.handleMouseDown)
}
public removeListener() {
this.container.removeEventListener('mousedown', this.handleMouseDown);
}
处理点击
点击分为左键点击和右键点击,左键点击显示数字或雷,右键点击插旗
private handleMouseDown = (e: MouseEvent) => {
if ((e.target as any).tagName.toLowerCase() === 'td') {
if (e.which === 1) { // 左键
this.play(e.target, 1)
} else if (e.which === 3) {
// 阻止右键的默认事件
this.container.oncontextmenu = function () {
return false
};
this.play(e.target, 3)
}
}
};
逻辑判断
这里代码过多,我们使用分开讨论。首先我们来判断左键点击
左键点击
// 不同数字的样式类名
private readonly cls: string[] = ['zero', 'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight'];
/**
*
* @param target 点击的目标对象
* @param flag 区分左键和右键
*/
private play(target: EventTarget, flag: 1 | 3): void {
let {row, col} = <any>target;
// 在数字数组中获取到点击的dom的位置的元素
let curSquare: MineInterface = this.square[row][col];
// 左键点击显示数字或者雷,或者扩散
if (flag === 1) {
// 标红旗的格子不能左击
if (this.doms[row][col] && this.doms[row][col].className && this.doms[row][col].className === 'flag') {
return;
}
// 处理行为不同,点击的是数字
if (curSquare.type === 'number') {
// 向对应的target添加样式类名
(target as any).classList.add(this.cls[curSquare.value]);
// 如果点击到的数字是0,则使用递归扩散找到一个非0的停止
if (curSquare.value === 0) { // 点到了数字0,扩散
/**
* 点到了数字0:
* 1. 显示自己
* 2. 找四周
* a. 显示四周(只显示到非0的为止)
* b. 四周再扩散找四周....
*/
this.getAllZero(curSquare)
} else { // 点击的是非0数字,则显示
(target as any).innerHTML = curSquare.value + '';
}
// 点到的是雷,游戏结束
} else if (curSquare.type === 'mine') {
this.gameOver(curSquare)
}
}
else if (flag === 3) {
......
}
}
private getAllZero(square: MineInterface) {
// 找到0格子周围的所有格子
let around: number[][] = this.getAround(square);
// 遍历这些格子,进行递归调用
for (let i = 0; i < around.length; i++) {
let x: number = around[i][0], y: number = around[i][1];
// 给周围的格子添加class
this.doms[x][y].classList.add(this.cls[this.square[x][y].value]);
// 再以周围的某个格子为中心找到该格子的四周某个格子为0的格子
// 如果找得到就再次调用该方法,找不到就退出递归
if (this.square[x][y].value === 0) {
// 当前格子没有被找过
if (!(this.doms[x][y] as any).check) {
setTimeout(() => {
// 找过的格子check标志为true,代表他已经被找过了
(this.doms[x][y] as any).check = true;
// 再从周围的格子找其周围的格子......
this.getAllZero(this.square[x][y])
}, 0)
}
// 如果找到某一个格子周围有数字格子并且其value不为0
// 那么停止以该格子为中心的递归
} else if (this.square[x][y].type === 'number' && this.square[x][y].value !== 0) {
this.doms[x][y].innerHTML = this.square[x][y].value + ''
}
}
}
/**
* 游戏失败函数
* @param square 点到的格子
*/
private gameOver(square: MineInterface) {
/**
* 如果当前点击的是雷,则给当前点击的雷添加一个特殊的样式
* 然后其他所有的雷显示
* 取消所有格子的事件
* 游戏结束
*/
// 给当前雷添加特殊样式
if (square.type === 'mine') {
this.doms[square.row][square.col].style.backgroundColor = 'red';
}
// 显示所有雷
for (let i = 0; i < this.mines.length; i++) {
let mineX: number = this.mines[i].row, mineY: number = this.mines[i].col;
// 如果游戏结束时,如果有雷的位置已经插了旗子,就将其背景颜色改变为绿色
if (this.doms[mineX][mineY].className === 'flag') {
this.doms[mineX][mineY].style.backgroundColor = 'green';
// 如果游戏结束时,有雷的位置没插旗子,就直接改变其class为mine-item
} else {
this.doms[mineX][mineY].className = '';
this.doms[mineX][mineY].classList.add('mine-item');
}
}
// 取消table的事件
this.removeListener()
}
右键插旗
该函数紧接着上一个play函数
private restMineNumber: number; // 剩下的雷的数量
private readonly mineNum: HTMLSpanElement = document.querySelector('.mine-num');
private play(target: EventTarget, flag: 1 | 3) {
......
else if (flag === 3) {
// 只能是盖着的格子才能够标记
if (this.doms[row][col].className && this.doms[row][col].className !== 'flag') {
return
}
// 每次点击切换其class类名
this.doms[row][col].className = this.doms[row][col].className === 'flag' ? '' : 'flag';
// 用户标记的是正确的雷就将雷的数组对那个的雷的flag置为true
if (curSquare.type === 'mine') {
for (let i = 0; i < this.mines.length; i++) {
if (this.mines[i].row == row && this.mines[i].col === col) {
this.mines[i].flag = this.doms[row][col].className === 'flag'
}
}
}
// 用户每次插旗,则剩下雷数减少;取旗剩下雷数增加
if (this.doms[row][col].className === 'flag') {
this.restMineNumber--;
} else {
this.restMineNumber++;
}
this.mineNum.innerHTML = this.restMineNumber + '';
// 如果剩余的雷数量<=0时,说明用户已经标完小红旗,判断游戏胜利与否
if (this.restMineNumber <= 0) {
if (this.checkGameOver()) {
alert('恭喜你,游戏胜利');
this.gameOver(curSquare)
} else {
alert('游戏失败');
this.gameOver(curSquare)
}
}
}
}
/**
* 检查游戏是否结束
*/
private checkGameOver() {
// 遍历雷的数组,查看每一个雷是否被标记,如果有一个没有标记则游戏还没结束
for (let i = 0; i < this.mines.length; i++) {
if (this.mines[i].flag === false) {
return false
}
}
return true
}
切换难度
// 难度dom的nodelist
let levels: NodeListOf<HTMLButtonElement> = document.querySelectorAll('.level button');
let activeIndex: number = 0;
// 难度的等级,二维数组的第一个为初级,第二个为中级,第三个为高级
// 每一个等级有三个参数:行、列、雷数
let arr: number[][] = [[10, 10, 10], [16, 16, 40], [28, 28, 99]];
// 默认是初级
let mine: MineSweeping = new MineSweeping(arr[0][0], arr[0][1], arr[0][2]);
// 给每个等级增加点击事件,每次点击改变class类名
// 每次点击还会重新创建一个该等级的对象,并将上一个的事件去除掉
for (let i = 0; i < levels.length - 1; i++) {
levels[i].onclick = function () {
levels[activeIndex].className = '';
levels[i].className = 'active';
// 一定要记得去除事件,因为我们使用的事件监听是事件委托,是针对将来的dom
mine.removeListener();
mine = new MineSweeping(arr[i][0], arr[i][1], arr[i][2]);
mine.init();
activeIndex = i;
}
}
// 给重新游戏一个点击事件
levels[levels.length - 1].onclick = function () {
mine.removeListener();
mine.init();
};
结语
代码主要使用注释的方式进行讲解。如果有需要的可以克隆我的github仓库扫雷游戏