挑战 100 款小游戏之「扫雷」

682

大家好,我是大胆的番茄。本文是「挑战 100 款小游戏」的第二款游戏:「扫雷」的总结与分享。

演示地址:wanghaida.com/demo/2202-m…

Github 仓库:github.com/wanghaida/g…

一个游戏要想好玩,除了玩法以外,设计也是必须的。但我不会设计怎么办?那就把 Microsoft Store 里的 Microsoft Minesweeper 下载下来翻翻目录,就发现了个这个:

sprite_state.png

嚯~,这不是我们熟悉的雪碧图吗?虽然不是全部的,但基本的都在。爱了爱了~

地图

雪碧图上每个方块正好 84x84 像素,为了高清屏,除以 2,每个方块是 42x42。游戏有 9x9、16x16、30x16 三种难度,按 30x16 来算,游戏区域大小达到了 1260x672,再包括一些小间距的话,小屏幕稍显吃力,所以除了 9x9 采用 84/2 = 42 像素以外,其他的用 84/2.4 = 35 像素。

用网格简单的把架子搭一下:

:root {
    --base: 2;

    --row: 9;
    --col: 9;
}

// 游戏区
#game {
    display: grid;
    grid-template-rows: repeat(var(--row), calc(84px / var(--base)));
    grid-template-columns: repeat(var(--col), calc(84px / var(--base)));
    gap: 2px;

    div {
        background: url('./images/sprite_state.png') calc(-84px / var(--base)) 0 / calc(1344px / var(--base)) calc(420px / var(--base)) no-repeat;
    }
}
// 游戏区域
const oGame = document.getElementById('game');

const minesweeper = {
    /**
     * 初始化地图
     */
    initMap(row = 9, col = 9, mines = 10) {
        // 清除原有地图
        oGame.innerHTML = '';

        // 调整地图布局
        document.documentElement.style.setProperty('--base', row > 9 ? '2.4' : '2');

        document.documentElement.style.setProperty('--row', row);
        document.documentElement.style.setProperty('--col', col);

        // 虚拟节点用来承载 dom 节点,方便一次性添加
        const oFragment = document.createDocumentFragment();

        for (let i = 0; i < row; i++) {
            for (let j = 0; j < col; j++) {
                // 创建坐标节点
                const oDiv = document.createElement('div');

                // 将坐标节点放入虚拟节点
                oFragment.appendChild(oDiv);
            }
        }

        // 将虚拟节点放入游戏区
        oGame.appendChild(oFragment);
    },
};
// 默认开始 9x9 游戏
minesweeper.initMap();

得到下面一张图:

image.png

CSS 上使用了三个变量,--base 用来缩放每个方块大小的,--row 表示行,--col 表示列。还是咱们熟悉的网格系统,通过 repeat() 函数来画出行和列。

直接出来可不行,人家是有加载动效的,我准备加个关键帧动画和定时器:

#game {
    .state-loading {
        background-position: calc(-84px / var(--base)) 0;
        animation: state-loading 0.2s steps(1);
    }
    .state-closed {
        background-position: calc(-84px / var(--base)) 0;
    }
}

@keyframes state-loading {
    0% {
        background-position: calc(-504px / var(--base)) calc(-84px / var(--base));
    }
    25% {
        background-position: calc(-672px / var(--base)) calc(-84px / var(--base));
    }
    50% {
        background-position: calc(-840px / var(--base)) calc(-84px / var(--base));
    }
    75% {
        background-position: calc(-924px / var(--base)) calc(-84px / var(--base));
    }
    100% {
        background-position: calc(-1260px / var(--base)) calc(-84px / var(--base));
    }
}
const minesweeper = {
    mapCount: 0,
    mapTimer: null,
    initMap(row = 9, col = 9, mines = 10) {
        ...
        
        document.documentElement.style.setProperty('--col', col);
        
        // 虚拟节点用来承载 dom 节点,方便一次性添加
        const oFragment = document.createDocumentFragment();

        for (let i = 0; i < row; i++) {
            for (let j = 0; j < col; j++) {
                // 创建坐标节点
                const oDiv = document.createElement('div');

                // 将坐标节点放入虚拟节点
                oFragment.appendChild(oDiv);
            }
        }

        // 将虚拟节点放入游戏区
        oGame.appendChild(oFragment);

        // 加载动画
        clearInterval(this.mapTimer);
        this.mapCount = 0;
        this.mapTimer = setInterval(() => {
            // 所有行数已经遍历完成
            if (this.mapCount >= row) {
                // 清除定时器
                return clearInterval(this.mapTimer);
            }

            for (let i = 0; i < col; i++) {
                oDiv = oGame.children[this.mapCount * col + i];

                // 加载动画样式
                oDiv.className = 'state-loading';
                oDiv.addEventListener('animationend', function fn() {
                    oDiv.className = 'state-closed'; 
                    oDiv.removeEventListener('animationend', fn);
                });
            }

            // 增加遍历行数
            this.mapCount++;
        }, 100);
    },
};

理论上是没有问题的,但可能是 dom 的原因,在方块数量过多的时候,会有明显的从左向右加载的延迟,所以我由原来的先添加 div 进游戏区再给每行添加样式改成了每次添加一行 div 进游戏区同时添加样式

const minesweeper = {
    mapCount: 0,
    mapTimer: null,
    initMap(row = 9, col = 9, mines = 10) {
        ...
        
        document.documentElement.style.setProperty('--col', col);

        // 加载动画
        clearInterval(this.mapTimer);
        this.mapCount = 0;
        this.mapTimer = setInterval(() => {
            // 所有行数已经遍历完成
            if (this.mapCount >= row) {
                // 清除定时器
                return clearInterval(this.mapTimer);
            }

            // 虚拟节点用来承载 dom 节点,方便一次性添加
            const oFragment = document.createDocumentFragment();

            for (let i = 0; i < col; i++) {
                // 创建坐标节点
                const oDiv = document.createElement('div');

                // 加载动画样式
                oDiv.className = 'state-loading';
                oDiv.addEventListener('animationend', function fn() {
                    oDiv.className = 'state-closed';
                    oDiv.removeEventListener('animationend', fn);
                });

                // 将坐标节点放入虚拟节点
                oFragment.appendChild(oDiv);
            }

            // 将虚拟节点放入游戏区
            oGame.appendChild(oFragment);

            // 增加遍历行数
            this.mapCount++;
        }, 100);
    },
};

为了方便操作方块,也为了性能稍微快一点,咱们用个 map 来存一下方块对应状态,dom 上只存数据对应坐标:

const minesweeper = {
    /**
     * 游戏数据
     *
     * @desc 通过二维数组来表示每个方块的属性
     * @example
     * [
     *      [item, item, item],
     *      [item, item, item],
     *      [item, item, item],
     * ]
     *
     * item = {
     *      // 是否打开过
     *      isOpen: boolean,
     *      // 是否递归过
     *      isCheck: boolean,
     *      // 是否爆炸过(同 isCheck,用于游戏结束后的递归判定)
     *      isExplode: boolean,
     *      // 标记 flag normal question
     *      sign: string,
     *      // 类型 0: 空白, 1-8: 数字, 9: 地雷
     *      type: number,
     * }
     */
    map: [],
    /**
     * 游戏状态
     *
     * loaded: 加载完成, loading: 加载中, ongoing: 进行中, over: 游戏结束
     */
    state: 'loading',
    /**
     * 初始化地图
     */
    row: 9,
    col: 9,
    mines: 10,
    initMap(row = 9, col = 9, mines = 10) {
        // 游戏地图尺寸
        this.map = [];
        this.row = row;
        this.col = col;
        this.mines = row * col === 256 ? 40 : row * col === 480 ? 99 : mines; // 16x16 ? 40 : 30x16 ? 99 : mines;

        // 修改游戏状态
        this.state = 'loading';
        // 地雷数量
        document.getElementById('mines').innerHTML = this.mines;
        // 游戏时间(简单的定时器,这里不展示了)
        
        ...
        
        this.mapTimer = setInterval(() => {
            // 所有行数已经遍历完成
            if (this.mapCount >= row) {
                // 状态变更
                this.state = 'loaded';
                // 清除定时器
                return clearInterval(this.mapTimer);
            }

            // 虚拟节点用来承载 dom 节点,方便一次性添加
            const oFragment = document.createDocumentFragment();

            const mapTemp = [];
            for (let i = 0; i < col; i++) {
                // 创建坐标节点
                const oDiv = document.createElement('div');

                ...

                // 坐标
                oDiv.pos = [this.mapCount, i];
                // 方块
                mapTemp.push({
                    // 是否打开过
                    isOpen: false,
                    // 是否递归过
                    isCheck: false,
                    // 是否爆炸过
                    isExplode: false,
                    // 标记 flag normal question
                    sign: 'normal',
                    // 类型 0: 空白, 1-8: 数字, 9: 地雷
                    type: 0,
                });

                // 将坐标节点放入虚拟节点
                oFragment.appendChild(oDiv);
            }
            this.map.push(mapTemp);

            // 将虚拟节点放入游戏区
            oGame.appendChild(oFragment);

            // 增加遍历行数
            this.mapCount++;
        }, 100);
    },
};

大体 ok:

Jietu20220121-155740-HD.gif

基础动效

再把基础的空格、数字、旗子、问号效果弄出来:

.state-flag-down {
    background-position: calc(-1260px / var(--base)) calc(-168px / var(--base));
    animation: state-flag-down 0.1s steps(1);
}
.state-flag-up {
    background-position: calc(-252px / var(--base)) calc(-252px / var(--base));
    animation: state-flag-up 0.1s steps(1);
}
.state-normal-down {
    background-position: calc(-588px / var(--base)) calc(-168px / var(--base));
    animation: state-normal-down 0.1s steps(1);
}
.state-normal-up {
    background-position: calc(-924px / var(--base)) calc(-168px / var(--base));
    animation: state-normal-up 0.1s steps(1);
}
.state-question-down {
    background-position: calc(-588px / var(--base)) calc(-252px / var(--base));
    animation: state-question-down 0.1s steps(1);
}
.state-question-up {
    background-position: calc(-924px / var(--base)) calc(-252px / var(--base));
    animation: state-question-up 0.1s steps(1);
}

.state-0 {
    background-position: 0 0;
}
.state-1 {
    background-position: calc(-504px / var(--base)) 0;
}
.state-2 {
    background-position: calc(-588px / var(--base)) 0;
}
.state-3 {
    background-position: calc(-672px / var(--base)) 0;
}
.state-4 {
    background-position: calc(-756px / var(--base)) 0;
}
.state-5 {
    background-position: calc(-840px / var(--base)) 0;
}
.state-6 {
    background-position: calc(-924px / var(--base)) 0;
}
.state-7 {
    background-position: calc(-1008px / var(--base)) 0;
}
.state-8 {
    background-position: calc(-1092px / var(--base)) 0;
}
.state-9 {
    background-position: calc(-336px / var(--base)) 0;
}

@keyframes state-flag-down {
    0% {
        background-position: calc(-1008px / var(--base)) calc(-168px / var(--base));
    }
    33.33% {
        background-position: calc(-1092px / var(--base)) calc(-168px / var(--base));
    }
    66.66% {
        background-position: calc(-1176px / var(--base)) calc(-168px / var(--base));
    }
    100% {
        background-position: calc(-1260px / var(--base)) calc(-168px / var(--base));
    }
}
@keyframes state-flag-up {
    0% {
        background-position: 0 calc(-252px / var(--base));
    }
    33.33% {
        background-position: calc(-84px / var(--base)) calc(-252px / var(--base));
    }
    66.66% {
        background-position: calc(-168px / var(--base)) calc(-252px / var(--base));
    }
    100% {
        background-position: calc(-252px / var(--base)) calc(-252px / var(--base));
    }
}
@keyframes state-normal-down {
    0% {
        background-position: calc(-336px / var(--base)) calc(-168px / var(--base));
    }
    33.33% {
        background-position: calc(-420px / var(--base)) calc(-168px / var(--base));
    }
    66.66% {
        background-position: calc(-504px / var(--base)) calc(-168px / var(--base));
    }
    100% {
        background-position: calc(-588px / var(--base)) calc(-168px / var(--base));
    }
}
@keyframes state-normal-up {
    0% {
        background-position: calc(-672px / var(--base)) calc(-168px / var(--base));
    }
    33.33% {
        background-position: calc(-756px / var(--base)) calc(-168px / var(--base));
    }
    66.66% {
        background-position: calc(-840px / var(--base)) calc(-168px / var(--base));
    }
    100% {
        background-position: calc(-924px / var(--base)) calc(-168px / var(--base));
    }
}
@keyframes state-question-down {
    0% {
        background-position: calc(-336px / var(--base)) calc(-252px / var(--base));
    }
    33.33% {
        background-position: calc(-420px / var(--base)) calc(-252px / var(--base));
    }
    66.66% {
        background-position: calc(-504px / var(--base)) calc(-252px / var(--base));
    }
    100% {
        background-position: calc(-588px / var(--base)) calc(-252px / var(--base));
    }
}
@keyframes state-question-up {
    0% {
        background-position: calc(-672px / var(--base)) calc(-252px / var(--base));
    }
    33.33% {
        background-position: calc(-756px / var(--base)) calc(-252px / var(--base));
    }
    66.66% {
        background-position: calc(-840px / var(--base)) calc(-252px / var(--base));
    }
    100% {
        background-position: calc(-924px / var(--base)) calc(-252px / var(--base));
    }
}

效果如下:

Jietu20220124-104919-HD.gif

随机生成地雷

随机生成地雷比较简单,在 0 - row * col 生成 N 个不重复数字,再转换为二维坐标即可:

const minesweeper = {
    initMap(row = 9, col = 9, mines = 10) {
        ...
        this.mapTimer = setInterval(() => {
            // 所有行数已经遍历完成
            if (this.mapCount >= row) {
                // 状态变更
                this.state = 'loaded';
                // 生成地雷
                this.generateMines();
                // 清除定时器
                return clearInterval(this.mapTimer);
            }
        }, 100);
    },
    /**
     * 生成地雷
     */
    generateMines() {
        // 先生成 N 个不重复的数字
        let pos = [];
        while (pos.length !== this.mines) {
            pos = [...new Set([...pos, Math.floor(Math.random() * this.row * this.col)])];
        }

        // 将数字转为坐标数组
        for (let i = 0; i < pos.length; i++) {
            const x = Math.floor(pos[i] / this.col);
            const y = pos[i] % this.col;

            pos[i] = [x, y];
            // 将对应数据 type 改为 9(地雷)
            this.map[x][y].type = 9;
        }

        // 计算地雷周围坐标数字
        for (let i = 0; i < pos.length; i++) {
            // 查找周围坐标
            const around = this.findPos(pos[i]);
            for (let j = 0; j < around.length; j++) {
                const grid = this.map[around[j][0]][around[j][1]];
                // 不是地雷则数字加 1
                if (grid.type !== 9) {
                    grid.type++;
                }
            }
        }
    },
};

查找周围坐标

扫雷的数字标注是指示周围 8 格里的地雷数量,所以上面用到了个 findPos 方法就是用来返回周围坐标的:

const minesweeper = {
    /**
     * 查找周围坐标,并去除边界值
     *
     * @example
     * 假设坐标为 [x, y],那么周围坐标:
     * [
     *      [x - 1, y - 1], [x - 1, y], [x - 1, y + 1],
     *      [x,     y - 1], ...,        [x,     y + 1],
     *      [x + 1, y - 1], [x + 1, y], [x + 1, y + 1],
     * ]
     */
    findPos([x, y]) {
        // 周围坐标
        const pos = [
            [x - 1, y - 1], [x - 1, y], [x - 1, y + 1],
            [x,     y - 1],             [x,     y + 1],
            [x + 1, y - 1], [x + 1, y], [x + 1, y + 1],
        ];
        // 简单的碰撞检测去除边界值
        return pos.filter(([x, y]) => !(x < 0 || y < 0 || x >= this.row || y >= this.col));
    },
    findPosUDLR([x, y]) {
        // 周围坐标
        const pos = [
            [x - 1, y], // 上
            [x + 1, y], // 下
            [x, y - 1], // 左
            [x, y + 1], // 右
        ];
        // 简单的碰撞检测去除边界值
        return pos.filter(([x, y]) => !(x < 0 || y < 0 || x >= this.row || y >= this.col));
    },
};

findPosUDLR 用来返回上下左右 4 格,主要用来游戏结束后的爆炸效果,要不显得有点呆。

游戏逻辑分析

鼠标事件有按下、抬起、移动和双击。

这里为啥不把按下和抬起合并成单击事件呢?因为鼠标在一个方块左键按下不松手,移出当前方块的则需要进行恢复,主要是防止误点。而右键按下时再抬起时,需要进行标记变换(正常->旗子->问号->正常),所以不能简单的处理成单击事件,而且还得缓存一下按下的方块,在移动后、抬起时判定还是不是原来的方块。

至于双击事件,主要存在于双击数字时快速将未标记旗子的方块进行打开操作。

鼠标按下

// 方块缓存
let oTemp = null;

// 鼠标从方块按下
oGame.addEventListener('mousedown', (ev) => {
    // 没点中方块 或 游戏加载中/游戏已结束
    if (oGame === ev.target || ['loading', 'over'].includes(minesweeper.state)) return;

    const [x, y] = ev.target.pos;
    if (false === minesweeper.map[x][y].isOpen) {
        // 缓存按下元素
        oTemp = ev.target;
        // 给缓存的元素添加按下样式
        oTemp.className = 'state-' + minesweeper.map[x][y].sign + '-down';
    }
});

如果没有点中方块,或者游戏加载中/游戏已结束则不再进行逻辑处理。其实这里有个 loading 时单击直接完成的效果,我这里也懒得写了,方法就是清掉 initMap 里的定时器,然后拿到 mapCount 直接进行剩下的 div.state-closed 填充。

先判断当前这个方块没有打开,然后缓存当前点击的方块,给方块添加一个按下的效果。

鼠标移动

// 鼠标移动
oGame.addEventListener('mousemove', (ev) => {
    // 缓存的 oTemp 和当前元素不一致
    if (oTemp && oTemp !== ev.target) {
        // 如果缓存的元素为按下样式
        if (oTemp.className.match(/state\-.+\-down/)) {
            // 给缓存的元素添加抬起样式
            oTemp.className = oTemp.className.replace('-down', '-up');
        }
        // 删除缓存元素
        oTemp = null;
    }
});

移动就是判定缓存 dom 存在且和当前元素不相等,且有按下的样式,就把按下样式变为了抬起。

鼠标抬起

// 鼠标抬起
oGame.addEventListener('mouseup', (ev) => {
    // 缓存的 oTemp 和当前元素不一致
    if (oTemp !== ev.target) {
        // 删除缓存元素
        oTemp = null;
        return;
    }

    const [x, y] = ev.target.pos;

    // 单击
    if (ev.button === 0) {
        // 没有标记
        if (minesweeper.map[x][y].sign === 'normal') {
            // 处理点击事件
            minesweeper.handleClick(oTemp);
        } else {
            // 给缓存的元素添加抬起样式
            oTemp.className = 'state-' + minesweeper.map[x][y].sign + '-up';
        }
    }
    // 右击 且 未打开过
    if (ev.button === 2) {
        // 修改标记状态
        minesweeper.map[x][y].sign = {
            flag: 'question',
            normal: 'flag',
            question: 'normal',
        }[minesweeper.map[x][y].sign];

        // 地雷数量
        if (minesweeper.map[x][y].sign === 'flag') {
            minesweeper.mines -= 1;
        }
        if (minesweeper.map[x][y].sign === 'question') {
            minesweeper.mines += 1;
        }
        document.getElementById('mines').innerHTML = minesweeper.mines;

        // 给缓存的元素添加抬起样式
        oTemp.className = 'state-' + minesweeper.map[x][y].sign + '-up';
    }

    // 删除缓存元素
    oTemp = null;
});

先看单击事件,如果标记为 normal,则进行逻辑处理,如果是 flag 或者 question,则添加抬起事件不做任何处理。

右击事件,先将方块标记状态变更,添加抬起样式。如果变成了旗子就把地雷数量减一,旗子变成其他就把地雷数量加一。

鼠标双击

// 双击
oGame.addEventListener('dblclick', (ev) => {
    // 没点中方块
    if (oGame === ev.target) return;

    const [x, y] = ev.target.pos;
    const grid = minesweeper.map[x][y];

    // 打开 且 为数字
    if (grid.isOpen && grid.type > 0 && grid.type < 9) {
        minesweeper.handleNumber([x, y], grid.type);

        // 判断游戏胜利
        minesweeper.judgeVictory();
    }
});

游戏逻辑处理

这里我们看看具体的 handleClick 方法和 handleNumber 方法。

const minesweeper = {
    /**
     * 处理点击事件
     */
    handleClick(dom) {
        // 修改状态
        if (this.state !== 'ongoing') {
            this.state = 'ongoing';
            this.startTime = +new Date();
            this.startInterval();
        }

        const grid = this.map[dom.pos[0]][dom.pos[1]];

        // 修改打开状态
        grid.isOpen = true;
        // 修改递归状态
        grid.isCheck = true;
        // 修改方块样式
        dom.className = 'state-' + grid.type;

        // 处理空白方块
        if (grid.type === 0) {
            this.handleSpace(dom.pos);
        }
        // 处理地雷方块
        else if (grid.type === 9) {
            this.handleMines([dom.pos]);
        }
        // // 处理数字方块(这里改为双击触发)
        // else if (grid.type > 0 && grid.type < 9) {
        //     this.handleNumber(dom.pos, grid.type);
        // }

        // 判断游戏胜利
        this.judgeVictory();
    },
};

首先就是判定状态了,如果当前不是游戏状态,就修改状态并开启计时。

拿到方块 grid 对应数据后,修改它的打开状态、递归状态和方块样式。

处理空白方块

如果是一个空白方块,那么除了它自身变化以外,还要向外扩展将所有空白方块和相邻的数字方块展示出来。

image.png

点击红点之后,判断红色方框内所有方格,碰到数字则跳过(1/2/3/9),碰到空白则递归(4/6/7)。

const minesweeper = {
    /**
     * 处理空白方块
     */
    handleSpace(pos) {
        // 查找周围坐标
        const around = this.findPos(pos);
        for (let i = 0; i < around.length; i++) {
            // 坐标
            const [x, y] = around[i];
            // 对应方块
            const grid = this.map[x][y];

            // 未递归过 且 标记为 normal
            if (false === grid.isCheck && 'normal' === grid.sign) {
                // 修改打开状态
                grid.isOpen = true;
                // 修改递归状态
                grid.isCheck = true;

                // 加载动画样式
                const oDiv = oGame.children[x * this.col + y];
                oDiv.className = 'state-' + grid.sign + '-down';
                oDiv.addEventListener('animationend', function fn() {
                    oDiv.className = 'state-' + grid.type;
                    oDiv.removeEventListener('animationend', fn);
                });

                // 如果为数字则跳过
                if (grid.type > 0 && grid.type < 9) {
                    continue;
                }
                // 如果为空白则递归
                if (grid.type === 0) {
                    this.handleSpace(around[i]);
                }
            }
        }
    },
};

处理地雷方块

const minesweeper = {
    /**
     * 处理地雷方块
     */
    handleMines(pos) {
        // 修改状态
        this.state = 'over';
        oGame.className = 'fail';
        // 清除时间
        clearInterval(this.startTimer);

        // 标记所有地雷和错误旗子
        for (let i = 0; i < this.map.length; i++) {
            for (let j = 0; j < this.map[i].length; j++) {
                // 是地雷 且 不是旗子
                if (this.map[i][j].type === 9 && this.map[i][j].sign !== 'flag') {
                    oGame.children[i * this.col + j].className = 'state-9';
                }
                // 不是地雷 且 是旗子
                if (this.map[i][j].type !== 9 && this.map[i][j].sign === 'flag') {
                    oGame.children[i * this.col + j].className = 'state-flag-error';
                }
            }
        }

        for (let i = 0; i < pos.length; i++) {
            // 坐标
            const [x, y] = pos[i];
            // 当前方块
            const grid = this.map[x][y];

            // 修改打开状态
            grid.isOpen = true;
            // 修改爆炸状态
            grid.isExplode = true;

            // 加载动画样式
            const oDiv = oGame.children[x * this.col + y];
            oDiv.className = 'state-over';
            oDiv.addEventListener('animationend', function fn() {
                // 游戏结束动画
                minesweeper.explodeMines(pos[i]);
                oDiv.removeEventListener('animationend', fn);
            });
        }
    },
};

触碰到地雷肯定就 over 了,先标记出所有没有标记旗子的地雷,和所有错误的旗子。这个方法传递的坐标数组,因为双击数字触发有猜错多个地雷的可能,所以循环位置,给地雷添加打开、爆炸状态,并添加爆炸动画。

话说雪碧图里没有爆炸动画,哪来的素材呢?MSN 有这个游戏的 canvas 版本😉

我自己把素材保存后用 ps 弄了张雪碧图:

sprite_over.png

当爆炸动画结束后执行游戏结束动画 explodeMines 方法:

const minesweeper = {
    /**
     * 处理地雷爆炸
     */
    explodeMines(pos) {
        setTimeout(() => {
            // 查找周围坐标
            const around = this.findPosUDLR(pos);
            for (let i = 0; i < around.length; i++) {
                // 坐标
                const [x, y] = around[i];
                // 对应方块
                const grid = this.map[x][y];

                // 未爆炸过
                if (grid.isExplode === false && grid.sign !== 'flag') {
                    // 修改爆炸状态
                    grid.isExplode = true;

                    const oDiv = oGame.children[x * this.col + y];

                    // 如果是地雷
                    if (grid.type === 9) {
                        // 修改打开状态
                        grid.isOpen = true;
                        // 加载动画样式
                        oDiv.className = 'state-over';
                    }
                    // 如果未打开
                    else if (!grid.isOpen) {
                        // 加载动画样式
                        oDiv.className = 'state-explode';
                        oDiv.addEventListener('animationend', function fn() {
                            oDiv.className = 'state-closed';
                            oDiv.removeEventListener('animationend', fn);
                        });
                    }

                    // 爆炸递归
                    this.explodeMines(around[i]);
                }
            }
        }, 100);
    },
};

通过 findPosUDLR 来进行菱形扩散,碰到 normal 标记就变红一下,碰到地雷就执行爆炸效果,递归完成所有方块的遍历。

Jietu20220125-122140-HD.gif

呆呆的 findPos 方法:

Jietu20220125-122427-HD.gif

处理数字方块

还记得双击中进行了数字方块的处理吗?原本我也是放到 handleClick 中了,但刚一翻开就会进行逻辑处理很明显是个 bug 啊!

那数字模块都要处理什么?我们先看代码:

const minesweeper = {
    /**
     * 处理数字方块
     */
    handleNumber(pos, type) {
        // 查找周围坐标
        const around = this.findPos(pos);

        // 标记的数量,当旗子(flag) >= 数字(type)时,才能将剩余 normal 标记做点击处理
        let flag = 0;
        // 旗子方块
        const flags = [];
        // 地雷方块
        const mines = [];

        for (let i = 0; i < around.length; i++) {
            // 坐标
            const [x, y] = around[i];
            // 对应方块
            const grid = this.map[x][y];

            // 旗子
            if (grid.sign === 'flag') {
                flag++;
                flags.push({ ...grid, pos: around[i] });
            }
            // 地雷
            if (grid.type === 9) {
                mines.push({ ...grid, pos: around[i] });
            }
        }

        if (flag >= type) {
            // 判断标记是否正确,因为都是一个顺序 push,所以可以简单的转字符串后比对
            if (JSON.stringify(flags) === JSON.stringify(mines)) {
                this.handleSpace(pos);
            }
            // 标记错误
            else {
                // 处理错误旗子
                for (let i = 0; i < flags.length; i++) {
                    if (flags[i].type === 9) continue;

                    oGame.children[flags[i].pos[0] * this.col + flags[i].pos[1]].className = 'state-flag-error';
                }
                // 处理错误地雷
                this.handleMines(mines.filter((item) => item.sign !== 'flag').map((item) => item.pos));
            }
        }
    },
};

首先拿到数字的坐标 pos 和内容 type,其实内容通过 posmap 中取也行,不过外面我查到了,就顺手传进来了。

循环查找标记的数量,当旗子 flag >= 数字 type 时,才能将剩余 normal 标记做点击处理。

先判断标记是否正确,因为都是一个顺序 push,所以可以简单的转字符串后比对,对比成功则将当前数字方块当一个空白方块处理。

标记错误就将错误的小旗子显示出来,将错误的地雷坐标扔到处理地雷方块的逻辑里去。

判断游戏胜利

胜利的条件是 未打开的方块 === 旗子的数量 + 地雷的数量,所以单击一个方块和双击一个数字方块都应进行游戏胜利的判定:

const minesweeper = {
    /**
     * 判断游戏胜利
     *
     * 未打开的方块 === 旗子的数量 + 地雷的数量
     */
    judgeVictory() {
        let count = 0;
        let flags = 0;

        for (let i = 0; i < this.map.length; i++) {
            for (let j = 0; j < this.map[i].length; j++) {
                // 未打开的方块
                if (this.map[i][j].isOpen === false) {
                    count++;
                }
                // 旗子的数量
                if (this.map[i][j].sign === 'flag') {
                    flags++;
                }
            }
        }

        if (count === flags + this.mines) {
            // 修改状态
            this.state = 'over';
            oGame.className = 'success';
            // 清除时间
            clearInterval(this.startTimer);
        }
    },
};

结尾

其实还有亿点点细节我觉得简单就没有在文章当中展示,感兴趣可以去 github 翻翻源码,对比着看会更好理解一点。

包括花园主题和所有音频其实也在游戏文件夹中,可以自己添加。这里再放一张花园主题的雪碧图:

TileStates-classic-combined.png

方法都一样,就是 css 的关键帧多写一些。

以上。