【思考】如何高效地生成一张扫雷地图

490 阅读5分钟

问题介绍

  最近突发奇想,想要自己开发个扫雷游戏玩玩。想起当年校招培训的第一道题目就是开发一个扫雷游戏,不过当时刚毕业做得比较烂,正好趁现在有空思考下当年的问题。   开发扫雷游戏面临的第一个问题就是:如何高效地生成一张扫雷游戏地图?

问题分析

  扫雷地图生成大致可以分为三步:

  • 初始化地图: 创建一个二维数组表示地图,每个元素代表一个格子。初始状态下,所有格子都是关闭状态。
  • 放置地雷: 使用随机数生成器来确定地雷的位置。
  • 计算数字: 对于每个非地雷的格子,统计其周围的8个格子中地雷的数量,并将这个数量存储在该格子中。

最简单的方法

  通过上述三步,我们可以想到一个最简单的生成算法:首先创建一张空白地图,生产指定数量的地雷,并随机填充到地图中,接着遍历每个非地雷格子周围的八个格子,并统计周围的地雷数量,在该格子中填入数量。

  具体步骤如下:

  • 生成空白地图并填充地雷 image.png

  • 遍历所有格子并统计对应地雷数量 image.png

进一步思考

  上述方法虽然可以按照要求生成扫雷地图,但是可以发现,每次操作一个非地雷格子时都需要 遍历一次其全部邻近的格子,即使有的格子在之前已经被遍历过,明确知道是否为地雷。

  如上述步骤的前两步中,红色格子即为重复遍历格子: image.png

  基于这种思路,我们可以想办法将上一次遍历的结果先缓存下来,当下次需要遍历这些格子时直接 读取之前遍历计算的结果,从而实现减少重复计算的目的。

  因此需要解决的首要问题是,如上图第二步操作,当操作绿色格子时怎么利用上一步的两个红色格子 的计算结果?换个角度来说,当前的操作要怎么缓存到下一步计算?

  具体的实现思路如下:

  • 相关定义

1、前进方向 image.png   如上图,我们将一个格子的:右上、右、右下和下四个方向定义为前进方向,剩余格子为后退方向。

2、开放列表

  开放列表中存放当前需要遍历的所有格子。

3、准备列表

  开放列表中所有格子的前进方向总和为准备列表,当前开放列表遍历完毕后,准备列表升级为开放列表。

  • 生成空白地图并填充地雷 image.png

  • 利用定义好的前进方向逐步将地图推进,如同逐步向前扩散一般。具体分如下几步:

  1. 将初始格子(左上角第一个格子)放入开放列表;
  2. 获取当前开放列表中的所有前进方向对应的格子,放入准备列表中
  3. 遍历开放列表中所有格子
    • 如果格子为地雷,则将其所有前进方向中的非地雷格子数字 + 1;
    • 如果格子不是地雷,则统计其前进方向中地雷的数量,并将自身数字增加对应值;
  4. 开放列表遍历完毕后,清空原开放列表,将准备列表提升为开放列表,并清空原准备列表;
  5. 重复2、3、4步骤,直到准备列表为空;

image.png

代码实现

  • 公共部分
const DESNITY = 0.2;    // 地雷密度

const CELL = {
    EMPTY: -1,             // 空地
    MINE: 90,              // 地雷
    FLAG: 91,              // 标记
    QUESTION: 92,          // 问号
    ONE: 1,
    TWO: 2,
    THREE: 3,
    FOUR: 4,
    FIVE: 5,
    SIX: 6,
    SEVEN: 7,
    EIGHT: 8,
}

const EIGHT_DIRECTION = [
    [-1, 0],        // 左
    [-1, -1],       // 左上
    [0, -1],        // 上
    [1, -1],        // 右上
    [1, 0],         // 右
    [1, 1],         // 右下
    [0, 1],         // 下
    [-1, 1],        // 左下
];

/**
 * Fisher-Yates 算法生成N个不重复的随机数
 * @param min   范围下限
 * @param max   范围上限
 * @param n     生成数量
 * @returns 
 */
const generateUniqueRandoms = (min, max, n) => {
    const range = [];
    for (let i = min; i <= max; i++) {
        range.push(i);
    }
    // 使用 Fisher-Yates 算法打乱数组顺序
    for (let i = range.length - 1; i > 0; i--) {
        const j = Math.floor(Math.random() * (i + 1));
        [range[i], range[j]] = [range[j], range[i]]; // 交换元素位置
    }
    return range.slice(0, n);
}

// 计算地雷数量
const calcMineCount = (width) => {
    return Math.floor(width * width * DESNITY);
}

const createMap = (width) => {
    // 生成空白的地图
    const map = [];
    for (let i = 0; i < width; i++) {
        map[i] = [];
        for (let j = 0; j < width; j++) {
            map[i][j] = CELL.EMPTY;
        }
    }
    return map;
}
  • 方法一
/**
 * 获取相邻的8个坐标
 * @param x         当前x坐标
 * @param y         当前y坐标
 * @param width     地图宽度
 */
const getAdjoin8Pos = (x, y, width) => {
    const positons = [];
    for (const delta of EIGHT_DIRECTION) {
        const nx = x + delta[0];
        const ny = y + delta[1];
        if (nx >= 0 && nx < width && ny >= 0 && ny < width) {
            positons.push([nx, ny]);
        }
    }
    return positons;
}


const genSquareMap = (width) => {
    // 生成空白的地图
    const map = createMap(width);
    const mCount = calcMineCount(width);
    const totalCells = width * width;
    const mineLocations = generateUniqueRandoms(0, totalCells - 1, mCount);
    for (let x = 0; x < width; x++) {
        for (let y = 0; y < width; y++) {
            const curIdx = y * width + x;
            if (mineLocations.includes(curIdx)) {
                map[y][x] = CELL.MINE;
                continue;
            }
            const positons = getAdjoin8Pos(x, y, width);
            if (map[y][x] === CELL.EMPTY) {
                map[y][x] = 0;
            }
            positons.map(([nx, ny]) => {
                const nIdx = ny * width + nx;
                if (mineLocations.includes(nIdx)) {
                    map[y][x] += 1;
                }
            })
        }
    }
    return map;
}
  • 方法二
/**
 * 获取前进方向的五个坐标
 * @param x         当前x坐标
 * @param y         当前y坐标
 * @param width     地图宽度
 * @returns 
 */
const getForwardPos = (x, y, width) => {
    const positons = [];
    for (const delta of FORWARD_DIRECTION) {
        const nx = x + delta[0];
        const ny = y + delta[1];
        if (nx >= 0 && nx < width && ny >= 0 && ny < width) {
            positons.push([nx, ny]);
        }
    }
    return positons;
}

const genSquareMap = (width) => {
    // 生成空白的地图
    const map = createMap(width);
    const mCount = calcMineCount(width);
    const totalCells = width * width;
    const mineLocations = generateUniqueRandoms(0, totalCells - 1, mCount);
    // 填充地图
    let openList = [];      // 开放列表
    let readyList = [];     // 准备列表
    const startPos = [0, 0];
    openList.push(startPos);
    while (true) {
        if (!openList.length && !readyList.length) {
            break;
        }
        readyList = [];
        for (const curPos of openList) {
            const [x, y] = curPos;
            const curIdx = y * width + x;
            if (map[y][x] === CELL.EMPTY) {
                map[y][x] = 0;
            }
            const positons = getForwardPos(x, y, width);        // 获取当前坐标的前进方向
            if (mineLocations.includes(curIdx)) {       // 当前坐标是地雷
                map[y][x] = CELL.MINE;
                positons.map(([nx, ny]) => {
                    const nIdx = ny * width + nx;
                    if (map[ny][nx] === CELL.EMPTY) {
                        map[ny][nx] = 0;
                        readyList.push([nx, ny])
                    }
                    if (mineLocations.includes(nIdx)) {
                        map[ny][nx] = CELL.MINE;
                    } else {
                        map[ny][nx] += 1;
                    }
                })
            } else {
                positons.map(([nx, ny]) => {
                    const nIdx = ny * width + nx;
                    if (map[ny][nx] === CELL.EMPTY) {
                        map[ny][nx] = 0;
                        readyList.push([nx, ny]);
                    }
                    if (mineLocations.includes(nIdx)) {
                        map[ny][nx] = CELL.MINE;
                        map[y][x] += 1;
                    }
                })
            }
        }
        openList = [];      // 清空开放列表
        openList = readyList;
    }
    return map;
}

运行时间对比

  各自生成一张宽为240个格子的正方形地图,地雷密度为0.2

  • 方法一 image.png

  • 方法二 image.png

最终效果

9f6b38284cf30e4f5f4aed6d616f323d_raw.gif