使用javaScript生成随机迷宫

效果预览

我制作了一个 CodePen,以动画形式展示随机迷宫的创建过程,以便更加直观的观察算法的工作原理。(点击即可访问生成新迷宫)

基本思路

使用javaScript生成随机迷宫的核心思想是使用一个"深度优先搜索"(DFS)算法。该算法可以从一个起点开始,探索未访问过的区域,并通过回溯找到所有可通行的路径。通过这种方式,我们可以在一个预定的网格中随机生成迷宫。

定义约束条件

迷宫有很多不同的类型和生成方法。在开始编写代码之前,首先思考希望得到一个什么样子的迷宫,并考虑如何避免过度复杂化,最后设定了一些基本的约束条件:

  • 迷宫是矩形的。虽然六边形和圆形迷宫更美观,但它们的实现相对也 更复杂。
  • 迷宫中只有一条路径。
  • 这条路径从迷宫的左边缘到右边缘。
  • 迷宫中的每个方格都应该是有可能经过的。

有了这些约束条件,就可以准备开始进行具体的规划。

迷宫游戏规划

把生成迷宫的过程分为三个步骤:

  1. 创建一个矩形网格。
  2. 找到从左侧到右侧的路径。
  3. 从主路径上分支,填充剩余的网格。

在开始编写代码之前,可以通过一些草图来理解这些步骤:

  • 画一个正方形网格。
  • 在这个正方形中画一条锯齿形的路径。
  • 用不同的颜色标出从主路径上分出的其他路径,填充整个方格。

步骤 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);
}

接下来开始填充迷宫。每次循环时将执行以下操作:

  1. 遍历每条路径,尽可能为其添加新格子。

  2. 遍历所有路径上的格子,在某些格子上随机添加新路径。

  3. 一直循环,直到网格所有格子完全占用

    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,
    }]);
    }
    })
    })
    }

这样就完成了迷宫的生成!可以点击"随机生成" 来生成新的迷宫。

完整代码示例

相关推荐
码字哥5 分钟前
EasyExcel设置表头上面的那种大标题(前端传递来的大标题)
java·服务器·前端
GIS好难学2 小时前
《Vue进阶教程》第六课:computed()函数详解(上)
前端·javascript·vue.js
nyf_unknown2 小时前
(css)element中el-select下拉框整体样式修改
前端·css
m0_548514772 小时前
前端打印功能(vue +springboot)
前端·vue.js·spring boot
执键行天涯2 小时前
element-plus中的resetFields()方法
前端·javascript·vue.js
Days20502 小时前
uniapp小程序增加加载功能
开发语言·前端·javascript
喵喵酱仔__2 小时前
vue 给div增加title属性
前端·javascript·vue.js
dazhong20122 小时前
HTML前端开发-- Iconfont 矢量图库使用简介
前端·html·svg·矢量图·iconfont
界面开发小八哥3 小时前
LightningChart JS助力德国医疗设备商打造高精度肺功能诊断软件
javascript·信息可视化·数据可视化·lightningchart·图表工具
m0_748248773 小时前
前端vue使用onlyoffice控件实现word在线编辑、预览(仅列出前端部分需要做的工作,不包含后端部分)
前端·vue.js·word