问题介绍
最近突发奇想,想要自己开发个扫雷游戏玩玩。想起当年校招培训的第一道题目就是开发一个扫雷游戏,不过当时刚毕业做得比较烂,正好趁现在有空思考下当年的问题。 开发扫雷游戏面临的第一个问题就是:如何高效地生成一张扫雷游戏地图?
问题分析
扫雷地图生成大致可以分为三步:
- 初始化地图: 创建一个二维数组表示地图,每个元素代表一个格子。初始状态下,所有格子都是关闭状态。
- 放置地雷: 使用随机数生成器来确定地雷的位置。
- 计算数字: 对于每个非地雷的格子,统计其周围的8个格子中地雷的数量,并将这个数量存储在该格子中。
最简单的方法
通过上述三步,我们可以想到一个最简单的生成算法:首先创建一张空白地图,生产指定数量的地雷,并随机填充到地图中,接着遍历每个非地雷格子周围的八个格子,并统计周围的地雷数量,在该格子中填入数量。
具体步骤如下:
-
生成空白地图并填充地雷
-
遍历所有格子并统计对应地雷数量
进一步思考
上述方法虽然可以按照要求生成扫雷地图,但是可以发现,每次操作一个非地雷格子时都需要 遍历一次其全部邻近的格子,即使有的格子在之前已经被遍历过,明确知道是否为地雷。
如上述步骤的前两步中,红色格子即为重复遍历格子:
基于这种思路,我们可以想办法将上一次遍历的结果先缓存下来,当下次需要遍历这些格子时直接 读取之前遍历计算的结果,从而实现减少重复计算的目的。
因此需要解决的首要问题是,如上图第二步操作,当操作绿色格子时怎么利用上一步的两个红色格子 的计算结果?换个角度来说,当前的操作要怎么缓存到下一步计算?
具体的实现思路如下:
- 相关定义
1、前进方向
如上图,我们将一个格子的:右上、右、右下和下四个方向定义为前进方向,剩余格子为后退方向。
2、开放列表
开放列表中存放当前需要遍历的所有格子。
3、准备列表
开放列表中所有格子的前进方向总和为准备列表,当前开放列表遍历完毕后,准备列表升级为开放列表。
-
生成空白地图并填充地雷
-
利用定义好的前进方向逐步将地图推进,如同逐步向前扩散一般。具体分如下几步:
- 将初始格子(左上角第一个格子)放入开放列表;
- 获取当前开放列表中的所有前进方向对应的格子,放入准备列表中
- 遍历开放列表中所有格子
- 如果格子为地雷,则将其所有前进方向中的非地雷格子数字 + 1;
- 如果格子不是地雷,则统计其前进方向中地雷的数量,并将自身数字增加对应值;
- 开放列表遍历完毕后,清空原开放列表,将准备列表提升为开放列表,并清空原准备列表;
- 重复2、3、4步骤,直到准备列表为空;
代码实现
- 公共部分
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
-
方法一
-
方法二