前言
成品預覽:https://codesandbox.io/s/maze-vite-15-i7oik?file=/src/maze.js
不久前寫了一篇文章介紹瞭如何解迷宮:https://www.cnblogs.com/judgeou/p/14805429.html
這回來說說怎麼生成迷宮。
解迷宮通常是先把原始資料(圖片)轉換為特定資料結構,然後對其執行一些演算法,得出結果。而生成迷宮,理所應當的是先使用合適的演算法生成資料結構,再把這個資料結構渲染出來:
- 解迷宮:輸入 -> 資料結構 -> 演算法處理
- 生成迷宮:演算法處理 -> 資料結構 -> 輸出
原初形態
這是一個 8x8 的迷宮:
每一個房間都無法到達其他房間,而我們要做的,就是從裡面挑選一些格子,然後打掉他的某些牆壁,讓他與隔壁房間聯通。
下面來設計它的資料結構:
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
隨機破牆
我們從 (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 方法,其他的都是一些繁瑣的邊界判斷之類的。破牆的時候注意要破兩面牆,一面是當前方塊的牆,一面是下一個方塊的牆,方向剛好相反。
下面是執行起來的一些結果:
可以看到效果不太理想,主要的問題是通行區域過於集中,以至於經常出現大塊空地。如果把迷宮規模擴大,明顯發現很多區域的牆都沒有破,處於完全封閉狀態。
隨機傳送到任意方格進行破牆,應該可以解決通行區域過於集中的問題,嘗試修改程式碼:
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
}
}
}
執行結果:
通行區域確實分散了開來,但仍然存在很多無法到達的封閉方格。仔細想想,根本原因是因為整個迭代過程結束後,依然存在從未到達過的方格,所以需要想辦法讓每一個方格都至少到達一次,至少打破一面牆。
準備一個 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
執行效果:
看起來算是有模有樣了,但是仔細觀察,存在互相隔絕的大區域,比如:
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
執行效果:
回溯法
現在所有區域都聯通了,不再有孤立區域,但是卻存在一些非常難看的死衚衕,比如:
這些死衚衕實在太淺了,如何讓迷宮擁有良好的戰略縱深呢?答案就是結合我們的第一個方案,先不要使用隨機傳送法,而是沿路往前推進,直至遇到無牆可破的情況,再從 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()
}
}
}
效果很不錯,這種方法可以稱為回溯法,看起來也確實像。
這種方法的缺點也是顯而易見,隨著迷宮規模的增大,需要的迭代次數和陣列空間也會增大。
最後,加入一些必要的可定義引數,最終成品:https://codesandbox.io/s/maze-vite-13-j9uqv?file=/src/maze.js:10050-10503
牆壁建造者
從現實的角度考慮,沒有人在建造迷宮時先把所有的牆造好,然後再把他們鑿穿。所以是否有一種演算法是通過新增牆壁來實現生成迷宮的呢?答案是有的。
一開始,整個迷宮看起來是這樣的:
什麼也沒有,所以接下來要往裡面新增牆壁?是,也不是,我們要換一種思路,不是新增牆壁,而是將整個迷宮一分為二:
接著在分界線上砸出一個缺口:
然後在剩下的區域裡面再做同樣的事情
不斷對區域進行切分,直到區域大小達到 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 就行。
結果:
感覺效果不太行,嘗試不要每次都對半分,而是隨機選擇切割點,只需要改動 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
稍微有所改觀,至少看起來不會是那種規規整整的“田”字型了,但無論如何,都沒法和回溯法的效果相提並論,我暫時還沒能想到更加好的方法,如果大家有有趣的想法,請務必在評論中分享。