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

最後的紳士發表於2021-06-23

前言

成品預覽: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/迷宮生成/

相關文章