【Javascript + Vue】實現隨機生成迷宮圖片


前言

成品預覽:https://codesandbox.io/s/maze-vite-15-i7oik?file=/src/maze.js

不久前寫了一篇文章介紹了如何解迷宮:https://www.cnblogs.com/judgeou/p/14805429.html

這回來說說怎么生成迷宮。

解迷宮通常是先把原始數據(圖片)轉換為特定數據結構,然后對其執行一些算法,得出結果。而生成迷宮,理所應當的是先使用合適的算法生成數據結構,再把這個數據結構渲染出來:

  • 解迷宮:輸入 -> 數據結構 -> 算法處理
  • 生成迷宮:算法處理 -> 數據結構 -> 輸出

原初形態

這是一個 8x8 的迷宮:

image

每一個房間都無法到達其他房間,而我們要做的,就是從里面挑選一些格子,然后打掉他的某些牆壁,讓他與隔壁房間聯通。

下面來設計它的數據結構:

class Cell {
  constructor (x, y, value) {
    this.x = x
    this.y = y
    this.value = value
  }
}

class MazeGanerator {
  static 上 = 0b1000
  static 左 = 0b0100
  static 下 = 0b0010
  static 右 = 0b0001

  /**
   * 
   * @param {Number} width 
   * @param {Number} height 
   */
  constructor (width, height) {
    this.width = width
    this.height = height
    this.cellSize = 50
    this.cellBorder = 2
    this.nodes = new Array(width * height)
  }

  build () {
    let { nodes } = this
    let { length } = nodes

    for (let i = 0; i < length; i++) {
      let { x, y } = this.indexToPos(i)
      let node = nodes[i] = new Cell(x, y, 0b1111) // 4個bit代表上下左右牆壁的開閉狀態,0:開,1:閉
    }
  }

  /**
   * 
   * @param {HTMLCanvasElement} canvas 
   */
  renderCanvas (canvas) {
    const { 上, 左, 下, 右 } = MazeGanerator
    let { nodes, width, height, cellSize, cellBorder } = this
    let { length } = nodes
    
    canvas.width = width * cellSize
    canvas.height = height * cellSize
    let ctx = canvas.getContext('2d')
    ctx.fillStyle = "#FFFFFF"
    ctx.fillRect(0, 0, canvas.width, canvas.height)

    for (let i = 0; i < length; i++) {
      let node = nodes[i]
      let { x, y, value } = node
      let leftTopX = x * cellSize
      let leftTopY = y * cellSize

      // 開始畫邊框
      ctx.beginPath()
      ctx.lineWidth = cellBorder

      if ((value & 上) === 上) {
        ctx.moveTo(leftTopX, leftTopY)
        ctx.lineTo(leftTopX + cellSize,  leftTopY)
      }
      if ((value & 左) === 左) {
        ctx.moveTo(leftTopX, leftTopY)
        ctx.lineTo(leftTopX,  leftTopY + cellSize)
      }
      if ((value & 下) === 下) {
        ctx.moveTo(leftTopX, leftTopY + cellSize)
        ctx.lineTo(leftTopX + cellSize,  leftTopY + cellSize)
      }
      if ((value & 右) === 右) {
        ctx.moveTo(leftTopX + cellSize, leftTopY)
        ctx.lineTo(leftTopX + cellSize,  leftTopY + cellSize)
      }

      ctx.closePath()
      ctx.strokeStyle = '#000000'
      ctx.stroke()
    }
  }

  indexToPos (i) {
    let x = i % this.width
    let y = Math.floor(i / this.width)
    return { x, y }
  }
}

每一個格子用 Cell 來表示,x、y 是坐標,而 value 值代表了格子四面牆的開閉狀態,通過一些位運算來實現,0b1111 代表全部牆均為閉合,0b0000 代表全部牆都打開。C語言程序員通常會特別喜歡玩弄bit。

build 函數負責初始化整個迷宮,把所有格子默認設置為四面牆全部閉合。

renderCanvas 函數很長,但是作用很簡單,就是把這個迷宮渲染到一個 canvas 標簽。

然后把代碼和之前的解迷宮的代碼稍微結合一下:

https://codesandbox.io/s/maze-vite-9-1h3qh?file=/src/App.vue

image

隨機破牆

我們從 (0, 0) 出發(即左上角),隨機選擇可以破的牆,然后破牆到達下一個格子,之后再次隨機選一堵牆來破,一直持續下去,直到遇上無牆可破的情況。

部分關鍵的代碼:

class MazeGanerator {
  static 上 = 0b1000
  static 左 = 0b0100
  static 下 = 0b0010
  static 右 = 0b0001

  /**
   * 破牆循環
   * @param {Function} cb 
   */
  async breakWall (cb = async () => {}) {
    let { nodes } = this
    let current = nodes[0]

    for (;;) {
      let breakDirection = this.getRandomNext(current)
      await cb(current)
      
      if (breakDirection !== null) {
        current.value ^= breakDirection.value
        breakDirection.nextNode.value ^= breakDirection.oppositeValue

        current = breakDirection.nextNode
      } else {
        break
      }
    }
  }

  /**
   * 獲取周圍可以破的牆
   * @param {Cell} node 
   * @returns 
   */
  getNextDirections (node) {
    const { 上, 左, 下, 右 } = MazeGanerator
    let { x, y, value } = node

    return [ 上, 左, 下, 右 ]
    .filter(direction => (value & direction) === direction)
    .map(direction => {
      let nextX
      let nextY
      let oppositeValue
  
      if (direction === 上) {
        oppositeValue = 下
        nextX = x
        nextY = y - 1
      } else if (direction === 左) {
        oppositeValue = 右
        nextX = x - 1
        nextY = y
      } else if (direction === 下) {
        oppositeValue = 上
        nextX = x
        nextY = y + 1
      } else if (direction === 右) {
        oppositeValue = 左
        nextX = x + 1
        nextY = y
      }
      
      // 邊界判斷
      if (nextX >= 0 && nextY >= 0 && nextX < this.width && nextY < this.height) {
        return { x: nextX, y: nextY, value: direction, oppositeValue }
      } else {
        return null
      }
    })
    .filter(item => item !== null)
  }

  /**
   * 隨機獲取周圍可以破的牆
   * @param {Cell} node 
   * @returns 
   */
  getRandomNext (node) {
    let nextDirections = this.getNextDirections(node)

    if (nextDirections.length > 0) {
      let nextDirection = nextDirections[this.getRandomInt(0, nextDirections.length - 1)]
      let nextNode = this.nodes[this.posToIndex(nextDirection.x, nextDirection.y)]
  
      return {
        nextNode,
        value: nextDirection.value,
        oppositeValue: nextDirection.oppositeValue
      }
    } else {
      return null
    }
  }
}

完整代碼:https://codesandbox.io/s/maze-vite-10-qoq0h?file=/src/maze.js

主要邏輯其實只是 breakWall 方法,其他的都是一些繁瑣的邊界判斷之類的。破牆的時候注意要破兩面牆,一面是當前方塊的牆,一面是下一個方塊的牆,方向剛好相反。

下面是運行起來的一些結果:

image

可以看到效果不太理想,主要的問題是通行區域過於集中,以至於經常出現大塊空地。如果把迷宮規模擴大,明顯發現很多區域的牆都沒有破,處於完全封閉狀態。

隨機傳送到任意方格進行破牆,應該可以解決通行區域過於集中的問題,嘗試修改代碼:

  async breakWall (cb = async () => {}) {
    let { nodes } = this
    let current = nodes[0]

    for (;;) {
      let breakDirection = this.getRandomNext(current)
      await cb(current)
      
      if (breakDirection !== null) {
        current.value ^= breakDirection.value
        breakDirection.nextNode.value ^= breakDirection.oppositeValue
		
        // 改為隨機選取下一個方格
        current = nodes[this.getRandomInt(0, nodes.length - 1)]
      } else {
        break
      }
    }
  }

運行結果:

image

通行區域確實分散了開來,但仍然存在很多無法到達的封閉方格。仔細想想,根本原因是因為整個迭代過程結束后,依然存在從未到達過的方格,所以需要想辦法讓每一個方格都至少到達一次,至少打破一面牆。

准備一個 nodesShuffle 數組,里面的元素和 nodes 是一樣的,但是使用 洗牌算法 去打亂順序,然后在 breakWall 里面迭代這個洗牌后的數組即可:

  /**
   * 破牆循環
   * @param {Function} cb 
   */
  async breakWall (cb = async () => {}) {
    let { nodesShuffle } = this
    let { length } = nodesShuffle

    for (let i = 0; i < length; i++) {
      let current = nodesShuffle[i]
      let breakDirection = this.getRandomNext(current)

      await cb(current)

      if (breakDirection !== null) {
        current.value ^= breakDirection.value
        breakDirection.nextNode.value ^= breakDirection.oppositeValue
      }
    }
  }

完整代碼:https://codesandbox.io/s/maze-vite-11-jfcum?file=/src/App.vue

運行效果:

image

看起來算是有模有樣了,但是仔細觀察,存在互相隔絕的大區域,比如:

image

A、B 區域互相無法到達,有沒有辦法可以使得迷宮中任意兩個方格,都有且只有一條通達道路呢?答案是肯定的。關鍵點在於,每回迭代不能從所有的方格里面隨意選,而是必須要從已被破過牆的方格里面選擇,這樣就能夠徹底杜絕孤立區域。

/**
   * 破牆循環
   * @param {Function} cb 
   */
  async breakWall (cb = async () => {}) {
    let { nodes, nodesChecked } = this

    nodesChecked.push(nodes[0])
    nodes[0].checked = true

    for (; nodesChecked.length > 0;) {
      let randomIndex = this.getRandomInt(0, nodesChecked.length - 1)
      let current = nodesChecked[randomIndex]
      let breakDirection = this.getRandomNext(current)

      await cb(current)

      if (breakDirection !== null) {
        current.value ^= breakDirection.value

        let { nextNode } = breakDirection
        nextNode.value ^= breakDirection.oppositeValue
        nextNode.checked = true

        nodesChecked.push(nextNode)
      } else {
        nodesChecked.splice(randomIndex, 1)
      }
    }
  }
  
/**
   * 獲取周圍可以破的牆
   * @param {Cell} node 
   * @returns 
   */
  getNextDirections (node) {
    const { 上, 左, 下, 右 } = MazeGanerator
    let { x, y, value } = node

    return [ 上, 左, 下, 右 ]
    .filter(direction => (value & direction) === direction)
    .map(direction => {
      let nextX
      let nextY
      let oppositeValue
  
      if (direction === 上) {
        oppositeValue = 下
        nextX = x
        nextY = y - 1
      } else if (direction === 左) {
        oppositeValue = 右
        nextX = x - 1
        nextY = y
      } else if (direction === 下) {
        oppositeValue = 上
        nextX = x
        nextY = y + 1
      } else if (direction === 右) {
        oppositeValue = 左
        nextX = x + 1
        nextY = y
      }
      
      // 邊界判斷
      if (nextX >= 0 && nextY >= 0 && nextX < this.width && nextY < this.height) {
        let nextNode = this.nodes[this.posToIndex(nextX, nextY)]
        return { x: nextX, y: nextY, value: direction, oppositeValue, nextNode }
      } else {
        return null
      }
    })
    .filter(item => item !== null && item.nextNode.checked === false)
  }

把被破過牆的方格使用 checked 屬性標記起來,並且放入數組 nodesChecked,每次就從這個數組隨機取下一個方格。getNextDirections 添加一個過濾條件,就是如果某面牆對着的方格曾經被破過牆,就不能選這面牆了。如果一個方格已經無牆可破,則把他從 nodesChecked 中刪除,減少迭代次數。

完整代碼:https://codesandbox.io/s/maze-vite-12-28isc?file=/src/maze.js:9899-10297

運行效果:

image

回溯法

現在所有區域都聯通了,不再有孤立區域,但是卻存在一些非常難看的死胡同,比如:

image

這些死胡同實在太淺了,如何讓迷宮擁有良好的戰略縱深呢?答案就是結合我們的第一個方案,先不要使用隨機傳送法,而是沿路往前推進,直至遇到無牆可破的情況,再從 nodesChecked 出棧一個 node,把他當作新的起點繼續前進,直到 nodesChecked 為空即可:

  async breakWall (cb = async () => {}) {
    let { nodes, nodesChecked } = this

    nodesChecked.push(nodes[0])
    nodes[0].checked = true

    let current = nodes[0]

    for (; nodesChecked.length > 0;) {
      let breakDirection = this.getRandomNext(current)

      await cb(current)

      if (breakDirection !== null) {
        current.value ^= breakDirection.value

        let { nextNode } = breakDirection
        nextNode.value ^= breakDirection.oppositeValue
        nextNode.checked = true

        nodesChecked.push(nextNode)
        current = nextNode
      } else {
        current = nodesChecked.pop()
      }
    }
  }

image

效果很不錯,這種方法可以稱為回溯法,看起來也確實像。

這種方法的缺點也是顯而易見,隨着迷宮規模的增大,需要的迭代次數和數組空間也會增大。

最后,加入一些必要的可定義參數,最終成品:https://codesandbox.io/s/maze-vite-13-j9uqv?file=/src/maze.js:10050-10503

image

牆壁建造者

從現實的角度考慮,沒有人在建造迷宮時先把所有的牆造好,然后再把他們鑿穿。所以是否有一種算法是通過添加牆壁來實現生成迷宮的呢?答案是有的。

一開始,整個迷宮看起來是這樣的:

image

什么也沒有,所以接下來要往里面添加牆壁?是,也不是,我們要換一種思路,不是添加牆壁,而是將整個迷宮一分為二:

image

接着在分界線上砸出一個缺口:

image

然后在剩下的區域里面再做同樣的事情

image

image

不斷對區域進行切分,直到區域大小達到 1 為止。

class Area {
  constructor (x, y, width, height) {
    this.x = x
    this.y = y
    this.width = width
    this.height = height
  }
}
  async createWall (cb = async () => {}) {
    let { width, height } = this
    let areas = this.areas = [ new Area(0, 0, width, height) ]

    for (;;) {
      let index = areas.findIndex(area => area.width > 1 || area.height > 1)
      
      if (index >= 0) {
        let area = areas[index]
        let [ areaA, areaB ] = this.splitArea(area)

        areas.splice(index, 1)
        areas.push(areaA)
        areas.push(areaB)

        await cb()
      } else {
        break
      }
    }
  }

  splitArea (area) {
    let { x, y, width, height } = area
    let xA, xB, yA, yB, widthA, widthB, heightA, heightB // A、B 是兩個分裂后的區域

    if ( width > height) { // 豎切
      let splitLength = Math.floor(width / 2) // 對半分
      
      xA = x
      yA = y
      widthA = splitLength
      heightA = height

      xB = x + splitLength
      yB = y
      widthB = width - splitLength
      heightB = height

      let yRandom = this.getRandomInt(y, y + height - 1)
      let gap = { x: xB, y: yRandom, direction: 'horizontal' }
      this.gaps.push(gap)
    } else { // 橫切
      let splitLength = Math.floor(height / 2) // 對半分
      
      xA = x
      yA = y
      widthA = width
      heightA = splitLength

      xB = x
      yB = y + splitLength
      widthB = width
      heightB = height - splitLength

      let xRandom = this.getRandomInt(x, x + width - 1)
      let gap = { x: xRandom, y: yB, direction: 'vertical' }
      this.gaps.push(gap)
    }

    let areaA = new Area(xA, yA, widthA, heightA)
    let areaB = new Area(xB, yB, widthB, heightB)

    return [ areaA, areaB ]
  }

完整代碼:https://codesandbox.io/s/maze-vite-14-eggfr?file=/src/maze.js:12878-13569

canvas 的渲染代碼這里我就不貼了,這里關鍵就是把 Cell 改為了 Area,用來表示一個任意大小的矩形范圍,然后把缺口存儲到另外一個數組 gaps 中,渲染的時候先渲染 Area,再渲染 gaps 就行。

結果:

image

感覺效果不太行,嘗試不要每次都對半分,而是隨機選擇切割點,只需要改動 splitLength 的賦值語句即可:

  splitArea (area) {
    let { x, y, width, height } = area
    let xA, xB, yA, yB, widthA, widthB, heightA, heightB // A、B 是兩個分裂后的區域

    if ( width > height) { // 豎切
      let splitLength = this.getRandomInt(1, width - 1) // 隨機切割
      
      xA = x
      yA = y
      widthA = splitLength
      heightA = height

      xB = x + splitLength
      yB = y
      widthB = width - splitLength
      heightB = height

      let yRandom = this.getRandomInt(y, y + height - 1)
      let gap = { x: xB, y: yRandom, direction: 'horizontal' }
      this.gaps.push(gap)
    } else { // 橫切
      let splitLength = this.getRandomInt(1, height - 1) // 隨機切割
      
      xA = x
      yA = y
      widthA = width
      heightA = splitLength

      xB = x
      yB = y + splitLength
      widthB = width
      heightB = height - splitLength

      let xRandom = this.getRandomInt(x, x + width - 1)
      let gap = { x: xRandom, y: yB, direction: 'vertical' }
      this.gaps.push(gap)
    }

    let areaA = new Area(xA, yA, widthA, heightA)
    let areaB = new Area(xB, yB, widthB, heightB)

    return [ areaA, areaB ]
  }

效果:https://codesandbox.io/s/maze-vite-15-i7oik?file=/src/maze.js

image

稍微有所改觀,至少看起來不會是那種規規整整的“田”字型了,但無論如何,都沒法和回溯法的效果相提並論,我暫時還沒能想到更加好的方法,如果大家有有趣的想法,請務必在評論中分享。

最終的源代碼:https://gitee.com/judgeou/maze-vite/tree/迷宮生成/


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM