手把手教你写扫雷小游戏

1,287 阅读11分钟

效果图展示

本次扫雷游戏可分为初中高级,效果图如下:

在这里插入图片描述

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';
            // 如果游戏结束时,有雷的位置没插旗子,就直接改变其classmine-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仓库扫雷游戏