房間和迷宮:一個地牢生成演算法
轉自:https://indienova.com/indie-game-development/rooms-and-mazes-a-procedural-dungeon-generator/
文章來源
微博上的 @大城小胖(indienova 個人資料)向我們推薦了這篇文章,在此表示感謝。
本文的作者是:Bob Nystroms,本文的轉載和翻譯已經獲得了他的授權。
原文地址在:Rooms and Mazes: A Procedural Dungeon Generator,有興趣的同學可以前往閱讀原文。閱讀原文是一種良好的習慣,一方面回訪原作者增加他的訪問量,同時也增加他寫文章的動力。另一方面,原文下有些討論的內容會比文章本身還有價值,非常值得常回去看看。
前言
好,今天我終於有時間再思考一下我的 Roguelike 了,那麼,忘掉遊戲主迴圈,讓我們來研究一下 Roguelike 遊戲最有趣也是最有挑戰的部分:生成地牢!
點選下面的這個小方塊看看最後是什麼效果。
再次點選可以重新開始看起來很整齊吧?如果你不想深究,那麼程式碼在這裡。
我關於計算機最早的記憶之一,就是看到我家 Apple IIe 電腦上執行的一個迷宮生成程式。它先將螢幕用綠色的小方塊格子填滿,然後不斷的在牆上挖洞。最後,所有網格上的方塊都被連線起來,螢幕上展現出一個完美、完整的迷宮。
我小小的家用電腦能夠創造這麼神奇的東西--每個方塊都可以同其它的連線--儘管看起來很混亂--它向隨機的方向雕刻,所以每個迷宮都不一樣。這給我十歲的大腦留下了深刻的印象。直到今天也還會有這種感覺。
什麼是地牢?
程式生成(Procedural generation)--讓程式隨機生成取代手工創作--當它工作正常的時候是非常有趣的。由於每一次都不同,你會獲得成倍的重玩樂趣。哪怕作為一個開發者,你也無法預計自己能否通過自己用程式寫出來的關卡,甚至會出現你意想不到的驚喜。
有時候,使用程式生成會讓人覺得簡單。手工製作顯然需要投入大量的精力和工作。如果你想要一個遊戲有 100 的關卡,那麼你將不得不製作 100 個關卡。但是如果你使用了一個隨機生成關卡的生成器,那麼你將會免費得到 100 關,1000 關,或者 1 百萬關!
哈,當然沒那麼簡單。設計這樣一個程式比你用手工來設計關卡要難得多。你必須要努力動腦,想清楚怎麼做,並且想法子將它轉換成程式碼。你其實是要親自編寫一個模擬體系。
它必須要在技術和審美上取得平衡。對我來說,我將注意集中在:
- 它需要有合理的效能。生成器只需要在進入關卡前執行,所以它不需要非常快。但我們也不希望要讓玩家浪費生命中的好幾秒等待著生成。
- 地牢需要被連線起來。就像在我綠色螢幕的 Apple 上的迷宮那樣。這意味著在地牢中的任意一點,都有一條道路--哪怕是迂迴的--通往另外一點。
這是非常重要的。如果玩家領取了一個任務,卻無法到達那裡,會是很殘酷的事情。同時,生成玩家到不了的地方也完全是在浪費時間。
- 還有,地牢的迷宮應該是不完美的。“完美”的迷宮意味著兩點之間只有唯一的一條通路。所有的走廊分佈得就像一棵樹,它有樹叉,但是中間沒有交集。而“不完美”的迷宮則有著可迴圈的通路--從 A 到 B 有多個可選通路。
“不完美”的迷宮是遊戲機制的需要,而不是技術上的需求。你可以造一個基於“完美”的迷宮的 Roguelike,而且確實有不少 Roguelike 是按照這個方法來做的,因為它的實現比較簡單。
但是我發現它們玩起來缺少樂趣。當玩家遇到一個死衚衕的時候,必須要回溯回到之前的路線去,然後尋找新的可探索的地方。同時,你也無法繞著敵人轉圈兒,或者走一條小路繞過敵人--針對這些情況,如果能實現的話其實都不賴。因為從根本上來說:遊戲本來就是一個決定和做出不同選擇的過程。所以,“完美”的地牢只給玩家一條路徑並不太合適。
- 我需要有開放的房間。我可以創造出沒有房間,完全由狹窄的走廊和過道組成的迷宮。但是這樣玩家就無法真對敵人做出合理的躲避,也無從採取策略來對付敵人,這會喪失很多遊戲樂趣。
大的、開放的空間可以讓玩家有空間釋放法術,或者進行大型戰鬥。同時,房間也可以通過不同的裝飾風格來增強遊戲場景的表現力。寶箱、陷阱、深淵、藏寶室等等,這些都需要有房間來表現。所以,房間在遊戲中起著至關重要的作用。
- 我也需要走廊。同時,我也不希望這個地牢完全由房間組成。有些遊戲會將房間連著房間生成。它玩兒起來並沒有什麼問題,但是會有一些乏味。我希望玩家在遊戲過程中有不同的感受,走廊會讓他們感到封閉感,同時,在面對怪獸的時候,將它們引入到狹窄的走廊裡面一個一個幹掉也是一種策略,遊戲體驗會更加豐富。
- 所有這些應該是可調的。很多 Roguelike 會生成大量的難度逐漸提高的多層地牢,但是除此之外就沒有其它的了。我的遊戲則不是這樣。它有多種不同的區域。每一個區域都有自己的風格和感覺。有些可能很小,感覺很侷促,其它的則可能很寬敞而又井井有條。
我採用了多種不同的地牢生成演算法來實現它。戶外的區域採用完全不同的生成策略(我可能需要針對這個再寫一篇教程。瞧~又一個雄心勃勃的承諾!)但是,從頭開始編寫一個新的地牢生成器會浪費掉大量的時間。所以,理想的做法是將生成器的一些引數設定成可調,這樣我就可以通過同一套程式碼生成不同風格和感覺的地牢。
看得見風景的房間
我幾乎一直在開發這個遊戲(還使用了四種不同的語言!),而且我也嘗試了多種不同的地牢生成器。我主要的靈感來源是 Angband。我研究這款遊戲所花的時間要超過我開發自己遊戲所用的時間。
Angband 已經非常老了。在那個時代的電腦上,很難實現快速的地牢生成演算法,所以它採用的方法非常簡單:
- 隨機生成一批互不覆蓋的房間;
- 用隨機的通道將它們連線起來。
為了確保房間們不覆蓋,我們在每次生成一個新房間的時候,如果發現它跟其它房間有重合,那就丟棄掉。不過這樣可能會造成無限迴圈,所以我限制了生成房間的總次數。當房間越來越多的時候,生成失敗房間的機率就會越來越高--最後你會發現只能放這麼多房間啦--但是通過調整這個總次數還是會讓你對房間的密度有所控制,比如:
嘗試次數 |
60 |
一個黑暗扭曲的走廊
我寫的大部分地牢生成器都是從這兒開始的。但是最難的地方在於:如何將它們連線起來。這也是我這篇教程的主要目的--一個優雅完美的解決方案。
Angband 的解決方案粗暴但是卻令人驚訝的有效。它選擇一對兒房間--完全不管它們距離有多遠--然後從一個房間開始一條隨機的線路連線到另外一個房間(希望能)。它聰明的使用了不少方法來避免過多的交錯和疊加,不過也允許這些線路有機會同房間、走道以及死衚衕交錯。
我花了不少時間試著實現它,但是從未得到理想的結果(應該是我自己的問題)。我生成的走道總是要麼太直,要麼就是交錯得很難看。
多年以前,我記得他曾經為紙上 D&D 寫過的一個真的地牢生成器。跟他之前的大多數迷宮不同,這一個有真正的房間,而且結果看上去很棒。
但是,在那時候,我不知道它是怎麼工作的。它是怎樣基於迷宮生成了走廊和房間?我把這個疑問放在腦子的某個角落並且很快忘了它。
FastAsUcan 的帖子提供瞭解答,它大概是這樣工作的:
- 建立一個完美的迷宮。有很多演算法,都相當直接;
- 將迷宮簡化。找到死衚衕並將它們重新用石頭填充;
- 選擇一些剩餘死衚衕,然後在毗鄰的牆上打洞,讓它們連線起來。這樣迷宮就不完美了。(記住,這是好事情!)
- 建立房間,並且尋找合適的放置處。“合適的”意味著不會覆蓋迷宮,但是要接近迷宮,這樣才好給它加上門並且連線到走廊。
這裡最神奇的部分,也是我沒想到的部分,就是迷宮的簡化。一個正常的迷宮會把所有區域都覆蓋,不會給你留下任何可以放置房間的空間。Jamis 和 FastAsUcan 所做的則是:先雕刻出迷宮,然後再將死衚衕“反雕刻”回去。
這做起來其實相當容易。死衚衕就是三面都是牆的那個。當你找到一個死衚衕的時候,你將它重置回石塊就可以了。這樣做的結果就是:之前跟它相連的那個走廊塊兒就變成了死衚衕。這樣持續做下去,直到再也找不到死衚衕,你會發現就有了大量可以放置房間的空間。
當然,如果你從一個完美的迷宮開始這麼做,你到最後會將整個迷宮都“簡化”掉!完美的迷宮沒有迴圈,只要你持續這麼做下去,所有的走廊都會變成死衚衕。Jamis 的解決方案是:不去掉所有的死衚衕,只去掉一些。它執行一會兒就停下來,就像這樣:
保留的過道: |
1000 |
一旦你這麼做了之後,就有機會可以放置房間了。Jamis 採用的方法很有趣,他生成某種尺寸的房間然後試著將它放到迷宮中的每一個位置去,一旦有重合衝突的房間或者走廊,那麼這個位置就被丟棄。剩餘下來的位置會進行一個“排序”,排序的依據是距離走廊越近的排名越高。最後,根據排序結果選擇最好的位置將房間放到那裡。然後再在房間邊上放上門來連線走廊。
不斷的重複這個過程,最後你就得到一個地牢。
房間,然後是迷宮
我立即按照這個方法開始編寫程式碼。結果看上去還不錯。但是我發現最後放置房間的步驟顯得非常緩慢。對小面積的紙上游戲來說當然效率還不錯,但是針對基於計算機的 Roguelike 顯然有些吃力。
於是,我做了些思考和修改。在這上面我的貢獻其實很小,但我覺得值得將它記下來。(說實話,我覺得看著地牢動態的生成充滿樂趣)
Buck 和 Karcero 都是先從迷宮開始,然後新增房間。我的則是反其道而行。首先,它會放置一堆房間。然後遍歷整個地牢,當它發現一個完整的開闊空間的時候,從這裡開始生成迷宮。
迷宮生成器持續的雕刻路徑,並且避免同現有的開放的空間交錯。這樣你就可以保證迷宮只有一個解法。如果你讓它雕刻進已有的走廊,那你就會得到迴圈。
讓你的迷宮填滿房間周圍奇形怪狀的空隙是很方便的。換句話說,迷宮的生成是一個隨機的洪水填充(flood fill) 演算法。通過在不連線的空間內進行迷宮的填充,我們就會得到一個地牢:充滿了互相不連線的房間和走廊。
每個顏色代表一個不同的已連線區域尋找一個連線
現在剩下來的任務就是將所有這些搞成一個連通的地牢。幸運的是,這很容易做到。因為通過前面的房間和迷宮生成工作,我們可以確定:每一個尚未連線的區域跟它的鄰居之間只相隔一個圖塊(Tile)。
我們可以輕易的找到可能的連線點:
- 石塊;
- 毗鄰的兩個不同顏色的區域。
高亮顯示的連線點:
我們使用它們來將不同的區域連線起來。通常,我們會將整個地牢看做一個圖(Graph),然後每一個圖塊(Tile)是一個點(Vertex)。但是我們可以將它抽象到更高的層級。現在我們將每個區域看作一個點,而將連線點們當作它們之間的邊。如果我們利用所有的連線點,那麼這個地牢的連通狀態就會變得非常複雜。這並不是我們想要的,所以,我們只需要雕刻那些連線兩個區域的連線點一次。換個漂亮而又專業的說法就是:我們在尋找一個 spanning tree(生成樹)。
這個過程相當直接:
- 隨機選取一個房間,將它作為主區域。
- 隨機選取一個主區域邊上的連線點並將其開啟。在演示中,我們將其變成一個門,但你也可以將其變成一個開放的走廊、上鎖的門或者一個魔法衣櫥。看你怎麼想了,可以充分發揮你的創造力。
記住,我們並不知道確定的門或者走廊,它們統一表現為“區域”。所以我們有時可能會直接將兩個房間連線起來。當然你可以避免發生這種情況,但是相信我,這會使得生成的地牢更有趣。 - 連線好的區域現在跟主區域是一個整體了,合併成為新的主區域。在演示中,我用了 flood fill 演算法來將新的主區域填充上顏色,因為這看起來比較醒目。在實際應用中,不需要有這個填充的步驟。只需要建立一個簡單的資料結構來表示“區域 X 已經被合併了”。
- 移除掉無關的連線點。在兩個區域合併後,有很大的可能還存在一些兩個區域的連線點。因為不再需要通過它們來連線這兩個區域了,而且我們需要的是 spanning tree。所以將它們移除掉。
- 如果還有剩餘的連線點,那麼再次進行 #2 步的操作。因為剩餘的連線點表明還有至少一個尚未連線的區域。我們需要迴圈來將它們合併成最後一個完整的主區域。
前面我說過,我們不需要一個完美的地牢,因為那會玩起來很無趣。但是,既然我們建立了一個 spanning tree,我們反而得到了一個完美的地牢。我們只允許使用一個連線點來連線兩個區域,於是它變成了一個樹,這意味著地牢中的任意兩點之間只有一條通路。
修正這個問題也很簡單,在 #3 步,當我們準備移除不需要的連線點的時候,我們可以給這些連線點一個很小的機率來變成開放的,就像這樣:
if(rng.oneIn(50)) _carve(pos, CELL_DOOR);
這樣我們就會偶爾的在兩個區域之間多開放一些連線點。這樣我們就會得到有迴圈、不完美但是更加有趣的地牢。而且這也很容易進行調整,如果我們增加開放額外連線點的機率,那麼我們就會得到連通狀態更加複雜的地牢。
反雕刻
如果我們現在就停下,那麼我們會得到一堆包含了迷宮和走廊的地牢,裡面有很多死衚衕。這看起來很令人抓狂,不過這並不是我們想要的。我們需要簡化它。
我們現在已經將所有房間都互相連線起來了,我們可以移除掉迷宮中的那些死衚衕。我們這樣做的話,只有那些用來連線房間的走廊會被保留。這樣,每一段走廊都會引領玩家去到一個有趣的地方,而不是死衚衕。
最後我們得到什麼
總結一下:
- 放置一堆隨機的互相不會覆蓋的房間;
- 把房間之外的空地用迷宮填滿;
- 將所有相鄰的迷宮和房間連線起來,同時也增加少量的連線;
- 移除掉所有的死衚衕。
我很高興我們走到這一步。儘管它並不完美。它致力於在房間之間創造討厭、曲折的走廊。你可以調整自己的地牢演算法,但是如果走廊不夠曲折它們就會貼著地牢的邊緣,看起來很奇怪。
房間和迷宮沿著邊界對齊讓事情變得簡單,填充貼合得也比較完美,但是這樣的地牢看起來反而有些人工的痕跡。不過相比我之前的工作它無疑已經有了很大的進步,而且生成的地牢看上去玩起來應該很有趣。
如果你想要自己看看,那麼你可以在瀏覽器中直接玩這個遊戲。演示程式碼在這裡,但是相對比較粗糙。我為了演示而將它們製作成動畫,這增加了程式碼的複雜性。所以,這裡是我在遊戲中使用的乾淨的版本。
作為走到這裡的獎賞,這裡有一個非常巨大的地牢。我覺得非常迷人: