效果预览
我制作了一个 CodePen,以动画形式展示随机迷宫的创建过程,以便更加直观的观察算法的工作原理。(点击即可访问生成新迷宫)
基本思路
使用javaScript生成随机迷宫的核心思想是使用一个"深度优先搜索"(DFS)算法。该算法可以从一个起点开始,探索未访问过的区域,并通过回溯找到所有可通行的路径。通过这种方式,我们可以在一个预定的网格中随机生成迷宫。
定义约束条件
迷宫有很多不同的类型和生成方法。在开始编写代码之前,首先思考希望得到一个什么样子的迷宫,并考虑如何避免过度复杂化,最后设定了一些基本的约束条件:
- 迷宫是矩形的。虽然六边形和圆形迷宫更美观,但它们的实现相对也 更复杂。
- 迷宫中只有一条路径。
- 这条路径从迷宫的左边缘到右边缘。
- 迷宫中的每个方格都应该是有可能经过的。
有了这些约束条件,就可以准备开始进行具体的规划。
迷宫游戏规划
把生成迷宫的过程分为三个步骤:
- 创建一个矩形网格。
- 找到从左侧到右侧的路径。
- 从主路径上分支,填充剩余的网格。
在开始编写代码之前,可以通过一些草图来理解这些步骤:
- 画一个正方形网格。
- 在这个正方形中画一条锯齿形的路径。
- 用不同的颜色标出从主路径上分出的其他路径,填充整个方格。
步骤 1:创建矩形网格
首先,需要确定网格的大小。为了简化操作从一个10x10的网格开始。将这些数值定义为变量,以便后续修改时更为方便:
const gridHeight = 10;
const gridWidth = 10;
步骤 2:寻找所有经过的路径
接下来需要找到一条从左边缘到右边缘的路径,可以将路径存储为一系列具有 X 和 Y 坐标的点。上图中的主路径如下所示:
const mainPath = [
{x: -1, y: 7},
{x: 0, y: 7},
{x: 0, y: 6},
{x: 0, y: 5},
{x: 1, y: 5},
/* ... 更多点 ... */
{x: 9, y: 1},
{x: 10, y: 1}
];
再为迷宫选择一个起点。可以随机选择一个 Y 坐标,并基于这个坐标设置两个点,一个在网格的左边缘,另一个稍微进入网格:
import { randomInt } from 'https://unpkg.com/randomness-helpers@0.0.1/dist/index.js';
function mainPathStartPoints() {
const yStart = randomInt(0, gridHeight - 1);
return [
{
x: -1,
y: yStart
},
{
x: 0,
y: yStart
}
]
}
注:使用名为randomness-helpers的npm 包中的一些辅助函数,用于帮助执行重复任务,如随机选择整数和数组项
接下来构建路径的其余部分,编写一个函数,该函数接受一个坐标点,并返回该点相邻的一个随机点:
import { randomItemInArray } from 'https://unpkg.com/randomness-helpers@0.0.1/dist/index.js';
function findNextPoint(point) {
// 构建相邻点的数组
const potentialPoints = [];
// 如果"上"在网格内,添加它作为潜在点
if(point.y - 1 >= 0) {
potentialPoints.push({
y: point.y - 1,
x: point.x
});
}
// 如果"下"在网格内,添加它作为潜在点
if(point.y + 1 < gridHeight) {
potentialPoints.push({
y: point.y + 1,
x: point.x
});
}
// 如果"左"在网格内,添加它作为潜在点
if(point.x - 1 >= 0) {
potentialPoints.push({
y: point.y,
x: point.x - 1
});
}
// 如果"右"在网格内,添加它作为潜在点
if(point.x + 1 < gridWidth) {
potentialPoints.push({
y: point.y,
x: point.x + 1
});
}
// 随机选择一个点添加到路径中
return randomItemInArray(potentialPoints);
}
可以通过多次调用此函数来构建路径,直到达到网格的最右端边缘,然后添加一个额外的坐标点,表示位于右边缘外部的终点。
function getMainPathPoints() {
const mainPathPoints = mainPathStartPoints();
// 一直添加点,直到最后一个点位于右边缘
while(mainPathPoints.at(-1).x < gridWidth) {
mainPathPoints.push(findNextPoint(mainPathPoints.at(-1)));
}
// 添加一个点,Y 坐标与最后一个点相同,但位于右边缘外部
mainPathPoints.push({
x: gridWidth,
y: mainPathPoints.at(-1).y
});
}
在上面的代码示例中使用数组方法获取数组中的最后点。
按理说实际效果其实已经接近了,但运作之后我发现逻辑上有一个大问题(点击的 在线预览 查看其实际运行效果)
运行之后总会运行到重复的坐标点 总结下来大概有这几个问题:
- 迷宫内的路径实在是太多了
- 从左边缘起点到达右边缘所需时间过长
- 不知道哪些格子已经走过了,所以无法知道剩下的迷宫该应该走哪些格子。
(这也导致了迷宫显示的问题,不过这些问题解决了其他的就迎经过,确保不会重复走,可以创建一个二维数组,初始时用 0 来表示每个空白格子。如果某个格子被占用,就把 0 改成 1。
const gridData = new Array(gridHeight).fill().map(
() => new Array(gridWidth).fill(0)
);
这段代码会创建一个类似这样的数组:
[
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
]
然后可以增加一个函数,用来标记某个格子已经走过了,走过之后直接把 0 改为 1:
function markPointAsTaken(point, value = 1) {
gridData[point.y][point.x] = value;
}
例如,假设已经在迷宫中绘制了一条路径并标记了占用的格子,数组就会变成类似于这样:
[
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 1, 1, 1],
[0, 0, 1, 1, 1, 0, 1, 1, 0, 0],
[0, 1, 1, 0, 1, 0, 1, 1, 1, 0],
[0, 1, 1, 1, 1, 1, 1, 1, 1, 0],
[1, 1, 0, 1, 1, 1, 1, 1, 1, 0],
[1, 1, 1, 1, 1, 1, 1, 1, 1, 0],
[1, 0, 0, 1, 1, 1, 0, 1, 1, 0],
[0, 0, 0, 1, 1, 1, 0, 1, 1, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
]
接下来再增加一个辅助函数,检查某个格子是否为空且在规定区域内,修改之前写的 findNextPoint
函数,让它只返回有效的空格坐标:
function isCellEmpty(point) {
// 检查格子是否在网格外
if (
point.x < 0 ||
point.x >= gridWidth ||
point.y < 0 ||
point.y >= gridHeight
) {
return false;
}
// 检查格子是否为空
return gridData[point.y][point.x] === 0;
}
到目前为止已经有了寻找路径所需的所有函数,但还有一个问题:如果运行中途被"卡住"了该怎么办?(如果所有相邻的格子都已占用,应该走哪里呢?)
最简单的解决办法就是:如果卡住了就直接重置数组!重新开始,点击看看效果
这个办法还算有效,但如果尝试用它来求解更大的迷宫,可能会需要很长时间才能找到有效路径,另一个解决办法就是,可以尝试"后退"几步并从另一个位置继续寻找,从而使逻辑更加智能 点击在线体验
// 导入随机工具库,提供随机整数、随机选择数组元素和随机概率的功能
import { randomInt, randomItemInArray, randomChance } from 'https://unpkg.com/randomness-helpers@0.0.1/dist/index.js';
// 获取页面中的 SVG 元素和相关的 DOM 元素
const svgEl = document.querySelector('svg'); // 用于显示迷宫路径的SVG元素
const patternEl = document.querySelector('.pattern'); // 用于渲染迷宫路径的容器
const gridEl = document.querySelector('.grid'); // 用于渲染网格的容器
// 定义迷宫的基本配置
const gridWidth = 20; // 网格的宽度(列数)
const gridHeight = 20; // 网格的高度(行数)
const scale = 50; // 每个网格单元的缩放比例(用于计算位置)
const splittingChance = 0.1; // 额外路径的生成概率
const animationSpeed = 20; // 路径绘制的动画速度(毫秒)
const retryLimit = 30; // 最大重试次数,如果路径无法继续,重新开始
// 初始化变量
let interval; // 动画定时器
let failedCount = 0; // 失败计数器,记录路径生成失败的次数
let mainPathPoints = []; // 主路径的点集合
let otherPaths = []; // 额外路径的集合
// 创建初始的网格数据
let gridData = buildFreshGrid();
// 绘制网格的函数,返回 SVG 格式的网格线条
function drawGrid() {
let gridMarkup = ''; // 用于存储网格的SVG markup
// 绘制横向的网格线
for (let y = 0; y <= gridHeight; y++) {
gridMarkup += `
<line
class="grid-line"
x1="0"
x2="${gridWidth * scale}"
y1="${y * scale}"
y2="${y * scale}"
/>
`;
}
// 绘制纵向的网格线
for (let x = 0; x <= gridWidth; x++) {
gridMarkup += `
<line
class="grid-line"
y1="0"
y2="${gridHeight * scale}"
x1="${x * scale}"
x2="${x * scale}"
/>
`;
}
return gridMarkup; // 返回生成的网格线的HTML字符串
}
// 创建新的空网格,初始化所有单元格为0(未占用)
function buildFreshGrid() {
return new Array(gridHeight).fill().map(
() => new Array(gridWidth).fill(0)
);
}
// 调整迷宫路径点的位置,转换为适应 SVG 坐标系
function adjustMazePoint(point) {
return {
x: scale / 2 + point.x * scale,
y: scale / 2 + point.y * scale
};
}
// 将路径点数组转换为 SVG 路径数据
function buildPathData(points) {
points = points.map(adjustMazePoint); // 调整每个路径点位置
const firstPoint = points.shift(); // 移除第一个点作为起始点
// SVG路径的M命令表示移动到起始点
let commands = [`M ${firstPoint.x}, ${firstPoint.y}`];
// 遍历路径点,使用L命令连接每个点
points.forEach(point => {
commands.push(`L ${point.x}, ${point.y}`);
});
return commands.join(' '); // 返回完整的SVG路径字符串
}
// 绘制路径的函数
function drawLine(points, className = '') {
return `
<path
class="maze-path ${className}"
d="${buildPathData(points)}"
/>
`;
}
// 生成主路径的起始点,随机选择一个起始位置
function mainPathStartPoints() {
const yStart = randomInt(0, gridHeight - 1); // 随机选择一个Y轴上的起始位置
return [
{ x: -1, y: yStart }, // 主路径的起始点,X为-1表示从网格外开始
{ x: 0, y: yStart } // 第二个起始点,X为0表示网格内的起点
];
}
// 标记网格中的一个点为已占用,值为1表示占用
function markPointAsTaken(point, value = 1) {
gridData[point.y][point.x] = value;
}
// 寻找当前点的相邻未占用的点,返回一个随机的相邻点
function findNextPoint(point) {
const potentialPoints = [];
// 判断上、下、左、右四个方向的相邻点是否未被占用
if (gridData[point.y - 1]?.[point.x] === 0) {
potentialPoints.push({ y: point.y - 1, x: point.x });
}
if (gridData[point.y + 1]?.[point.x] === 0) {
potentialPoints.push({ y: point.y + 1, x: point.x });
}
if (gridData[point.y]?.[point.x - 1] === 0) {
potentialPoints.push({ y: point.y, x: point.x - 1 });
}
if (gridData[point.y]?.[point.x + 1] === 0) {
potentialPoints.push({ y: point.y, x: point.x + 1 });
}
// 如果有可用的相邻点,随机返回其中一个,否则返回undefined
return potentialPoints.length === 0 ? undefined : randomItemInArray(potentialPoints);
}
// 重置迷宫的状态,包括路径、网格数据和失败计数器
function refreshState() {
mainPathPoints = mainPathStartPoints(); // 重置主路径的起始点
gridData = buildFreshGrid(); // 重置网格数据
markPointAsTaken(mainPathPoints.at(-1)); // 标记起始点为占用
otherPaths = []; // 清空额外路径
failedCount = 0; // 重置失败计数器
}
// 绘制所有的路径(包括主路径和额外路径)
function drawLines() {
let markup = '';
// 绘制额外路径
markup += otherPaths.map(drawLine).join('');
// 绘制主路径
markup += drawLine(mainPathPoints, 'main');
patternEl.innerHTML = markup; // 更新SVG中的内容
}
// 主路径生成的函数,使用随机算法生成迷宫的主路径
function buildMainPath() {
refreshState(); // 重置迷宫状态
drawLines(); // 绘制当前的路径
interval = setInterval(() => {
const nextPoint = findNextPoint(mainPathPoints.at(-1)); // 寻找主路径的下一个点
// 如果没有可用的下一个点,说明当前路径无法继续
if (!nextPoint) {
if (failedCount > retryLimit) { // 如果重试次数超过最大值,重新生成路径
refreshState();
} else {
failedCount++;
for (let i = 0; i < failedCount; i++) {
markPointAsTaken(mainPathPoints.pop(), 0); // 退回路径并清除占用
}
}
} else {
mainPathPoints.push(nextPoint); // 添加下一个路径点
markPointAsTaken(nextPoint); // 标记该点为占用
// 如果到达迷宫的右侧边界,停止路径生成
if (nextPoint.x === gridWidth - 1) {
mainPathPoints.push({ x: nextPoint.x + 1, y: nextPoint.y }); // 添加右边界外的点
clearInterval(interval); // 停止定时器
}
}
drawLines(); // 更新路径显示
}, animationSpeed);
}
// 随机生成更多的额外路径
function addMorePaths() {
gridData.forEach((row, y) => {
row.forEach((cell, x) => {
// 如果该点已经被占用,并且符合生成额外路径的概率
if (cell && randomChance(splittingChance)) {
otherPaths.push([{ y, x }]); // 创建新的路径
}
});
});
}
// 判断迷宫是否已经完全生成(所有点都被占用)
function mazeComplete() {
return gridData.flat().every(cell => cell === 1); // 检查所有单元格是否都已被占用
}
// 生成额外路径的函数,使用定时器逐步绘制这些路径
function buildOtherPaths() {
interval = setInterval(() => {
addMorePaths(); // 添加更多路径
// 遍历所有额外路径,尝试继续生成
otherPaths.forEach((path) => {
const nextPoint = findNextPoint(path.at(-1)); // 寻找下一个点
if (nextPoint) {
path.push(nextPoint); // 如果找到了点,加入路径
markPointAsTaken(nextPoint); // 标记该点为占用
}
});
drawLines(); // 更新显示路径
if (mazeComplete()) { // 如果迷宫已完成,停止定时器
clearInterval(interval);
console.log('maze done');
}
}, animationSpeed);
}
// 初始绘制函数
function draw() {
gridEl.innerHTML = drawGrid(); // 绘制网格
const mazeWidth = gridWidth * scale; // 计算迷宫宽度
const mazeHeight = gridHeight * scale; // 计算迷宫高度
// 设置 SVG 的视口大小和尺寸
svgEl.setAttribute('viewBox', `0 0 ${mazeWidth} ${mazeHeight}`);
svgEl.setAttribute('width', mazeWidth);
svgEl.setAttribute('height', mazeHeight);
patternEl.innerHTML = ''; // 清空当前的路径
clearInterval(interval); // 清除现有的定时器
buildMainPath(); // 开始生成主路径
}
// 初始绘制网格
gridEl.innerHTML = drawGrid();
// 绑定点击事件,用于重新生成迷宫
document.querySelector('.randomize').addEventListener('click', draw);
步骤 3:从主路径分支,填充剩余的网格
已经有了主路径,接下来要做的就是填充剩余的网格。首先创建一个辅助函数,检查每个单元格是否已被占用:
function mazeComplete() {
// 将二维数组展平成一维数组,然后检查是否所有单元格都被设置为1
return gridData.flat().every(cell => cell === 1);
}
接下来开始填充迷宫。每次循环时将执行以下操作:
-
遍历每条路径,尽可能为其添加新格子。
-
遍历所有路径上的格子,在某些格子上随机添加新路径。
-
一直循环,直到网格所有格子完全占用
import { randomChance } from 'https://unpkg.com/randomness-helpers@0.0.1/dist/index.js';
const otherPaths = [];
function buildOtherPaths() {
while(!mazeComplete()) {
// 添加一些新路径
addMorePaths();// 遍历所有路径 otherPaths.forEach((path) => { // 尝试在每条路径上添加一个点 const nextPoint = findNextPoint(path.at(-1)); if(nextPoint) { path.push(nextPoint); markPointAsTaken(nextPoint); } });
}
}function addMorePaths() {
// 遍历网格中的所有单元格
gridData.forEach((row, y) => {
row.forEach((cell, x) => {
// 如果该单元格已被占用,则以10%的几率从该单元格开始新路径
if(cell && randomChance(0.1)) {
otherPaths.push([{
y,
x,
}]);
}
})
})
}
这样就完成了迷宫的生成!可以点击"随机生成" 来生成新的迷宫。