1. 程式人生 > >Unity Procedural Level Generator 基礎總結與功能優化

Unity Procedural Level Generator 基礎總結與功能優化

Procedural Level Generator是在Unity應用商店中釋出的一款免費的輕量級關卡生成器:

可以直接搜尋關鍵字在應用商店中查詢並下載。

和我之前生成關卡的想法不同,這個外掛生成地圖的方式類似於拼積木,它將每一個地圖分為一個一個的部分,無論是房間還是通道,都叫做Section,只是用不同的標籤來規定和約束這些部分,並逐一的將這些部分在空間中連線起來,每一個部分需要自己手動定義它的預製體,形狀,碰撞盒子以及出口列表,通過出口列表來判斷下一個部分的連線位置和方向,用碰撞盒子的Bounds.Intersects(Bounds bounds);方法來判斷一個部分的生成是否會是一個無效的連線:

 1         public bool IsSectionValid(Bounds newSection, Bounds sectionToIgnore) =>
 2             !RegisteredColliders.Except(sectionToIgnore.Colliders).Any(c => c.bounds.Intersects(newSection.Colliders.First().bounds));
 3 
 4         //
 5         // 摘要:
 6         //     Does another bounding box intersect with this bounding box?
 7         //
 8         // 引數:
 9         //   bounds:
10         public bool Intersects(Bounds bounds);

 

利用提前製作Section預製體的方式來連線生成整個關卡的方式,確實避免了很多讓人頭疼的演算法設計,但可能外掛本身也只是為了提供一個基本思路,因此有些地方值得優化。

 1.缺少門的概念

很多時候,進入一個地圖的房間,我們需要門的解鎖和開關來對探索進行限制,也有可能進入一個滿是怪物的房間,這個房間的所有門會自動關閉,給玩家一種身陷敵營是時候浴血奮戰的錯覺。故而考慮在Section中給每個類增加一個自帶Door的列表,該列表可以沒有任何元素,例如很多通道之間是不需要門來進行連線的,但房間與通道之間,房間與房間之間,可以同時建立門來執行必要的約束限制。

 

定義門的類,注意保持在外掛的名稱空間之下:

 1 using UnityEngine;
 2 using System.Collections.Generic;
 3 
 4 namespace LevelGenerator.Scripts
 5 {
 6     public class Door : MonoBehaviour
 7     {
 8         public List<string> Tag1s = new List<string>();
 9         public List<string> Tag2s = new List<string>();
10 
11         public Transform ExitTransdorm { get; set; }
12         public void Initialize(LevelGenerator levelGenerator)
13         {
14             transform.SetParent(levelGenerator.Container);
15         }
16     }
17 }

這裡只定義了最基礎的一些屬性和方法,主要是門連線的兩個Section的標籤列表,用於更為準確的判定該門的所屬。

在Section類中新增放置門的方法:

 1         /// <summary>
 2         /// initialize door datas
 3         /// </summary>
 4         /// <param name="exit">place transform</param>
 5         /// <param name="next">next section</param>
 6         public void PlaceDoor(Transform exit, Section next)
 7         {
 8             var t = Instantiate(LevelGenerator.Doors.PickOne(), exit);
 9             t.Initialize(LevelGenerator);
10             Doors.Add(t.gameObject);
11 
12             var d = t.GetComponent<Door>();
13             d.Tag1s.AddRange(Tags);
14             d.Tag2s.AddRange(next.Tags);
15             d.ExitTransdorm = exit;
16 
17             //send door initialize event
18             if (Idx > 0 || next.Idx > 0)
19                 EventManager.QueueEvent(new DoorInitEvent(t.transform, Idx, next.Idx));
20         }

並且在每一個門建立後及時記錄在Section的Doors列表中,傳送建立完成的事件,這裡使用的事件系統可以詳見:

https://www.cnblogs.com/koshio0219/p/11209191.html

呼叫就是在成功生成每一個Section之後:

 1         protected void GenerateSection(Transform exit)
 2         {
 3             var candidate = IsAdvancedExit(exit)
 4                 ? BuildSectionFromExit(exit.GetComponent<AdvancedExit>())
 5                 : BuildSectionFromExit(exit);
 6 
 7             if (LevelGenerator.IsSectionValid(candidate.Bounds, Bounds))
 8             {               
 9                 candidate.LastSections.Add(this);
10                 NextSections.Add(candidate);
11 
12                 if (LevelGenerator.SpaceTags.Contains(candidate.Tags.First()) && LevelGenerator.CheckSpaceTags(candidate))
13                 {
14                     Destroy(candidate.gameObject);
NextSection.Remove(candidate); 15 GenerateSection(exit); 16 return; 17 } 18 19 candidate.Initialize(LevelGenerator, order); 20 candidate.LastExits.Add(exit); 21 22 PlaceDoor(exit, candidate); 23 } 24 else 25 { 26 Destroy(candidate.gameObject); 27 PlaceDeadEnd(exit); 28 } 29 }

由於通道與通道之間不需要放門,因此在所有Section生成完畢之後將一部分門刪除:(此方法位於關卡生成器這個控制類中)

 1         /// <summary>
 2         /// Clear the corridor doors
 3         /// </summary>
 4         protected void CheckDeleteDoors()
 5         {
 6             foreach (var s in registeredSections)
 7             {
 8                 if (s != null)
 9                 {
10                     var temp = new List<GameObject>();
11                     foreach (var d in s.Doors)
12                     {
13                         var ds = d.GetComponent<Door>();
14                         if (ds.Tag1s.Contains("corridor") && ds.Tag2s.Contains("corridor"))
15                         {
16                             temp.Add(d);
17                             Destroy(d);
18                         }
19                     }
20 
21                     foreach(var t in temp)
22                     {
23                         s.Doors.Remove(t);
24                     }
25                 }
26             }
27         }

這裡注意一點,遍歷列表的時候不能直接對列表的元素進行移除,所以先建立了一個臨時需要移除的列表作為替代,遍歷臨時列表以移除元素,當然了,你用通用方式for迴圈倒著遍歷也是可行的,個人不太喜歡用for迴圈而已。

說句題外話,可能有人會有疑惑,為什麼不直接在建立門的時候做條件限制,非要等到最後統一再來遍歷刪除呢,其實最主要的原因是為了儘量少的變動原始的程式碼邏輯和結構,而更傾向於新增新的方法來對外掛進行附加功能的完善,這樣可以很大的程度上減少bug觸發的概率,畢竟別人寫的外掛你很可能總有漏想的地方,隨意的改動和刪除對方已經寫過的內容並非良策,最好是隻新增程式碼而不對原始程式碼進行任何的改動或刪除,僅以這樣的方式來達到完善功能的目的。除錯的時候也只用關注自己新增的部分即可。

 

2.路徑的末尾很可能是通道

關於這一點,可能會根據遊戲的不同而異,因為這個外掛在生成地圖的過程中,無論是房間還是通道,都是同一個類Section,這樣沒辦法保證路徑末尾是一個房間,還是通道。可以新增一個功能用於檢查和刪除端點是通道的部分。

 

在Section中新增以下屬性方便遍歷刪除:

 1         [HideInInspector]
 2         public List<GameObject> DeadEnds = new List<GameObject>();
 3         [HideInInspector]
 4         public List<Transform> LastExits = new List<Transform>();
 5         [HideInInspector]
 6         public List<Section> LastSections = new List<Section>();
 7         [HideInInspector]
 8         public List<Section> NextSections = new List<Section>();
 9         [HideInInspector]
10         public List<GameObject> Doors = new List<GameObject>();

分別代表每一個Section的死亡端點列表,上一個Section的列表,下一個Section的列表(類似於雙向連結串列),與上一個Section連線的位置列表,門的列表,有了這些資料結構,無論怎麼遍歷,修改和獲取資料都是會變得非常容易。新增的地方自然是生成Section的方法中,放置端點的方法中,及放置門的方法中。

 

開始檢查並刪除末尾的通道:(根據實際需求是否呼叫)

 1         /// <summary>
 2         /// clear end sections and update datas
 3         /// </summary>
 4         protected void DeleteEndSections()
 5         {
 6             var temp = new List<Section>();
 7             foreach (var s in registeredSections)
 8             {
 9                 temp.Add(s);
10                 DeleteEndSection(s);
11             }
12 
13             foreach(var t in temp)
14             {
15                 foreach (var c in t.Bounds.Colliders)
16                 {
17                     DeadEndColliders.Remove(c);
18                 }
19                 registeredSections.Remove(t);
20             }
21         }
22 
23         /// <summary>
24         /// clear the end corridors and doors , place deadend prafabs' instances
25         /// </summary>
26         /// <param name="s">the check section</param>
27         protected void DeleteEndSection(Section s)
28         {
29             if (s.Tags.Contains("corridor"))
30             {
31                 if (s.DeadEnds.Count == s.ExitsCount)
32                 {
33                     //刪除通道以及通道的端點方塊
34                     Destroy(s.gameObject);
35                     foreach (var e in s.DeadEnds)
36                     {
37                         Destroy(e);
38                     }
39 
40                     foreach (var ls in s.LastSections)
41                     {
42                         //刪除末端通道後需要在上一個節點的退出點放置端點方塊(不然牆壁上就會有洞)
43                         foreach (var le in s.LastExits)
44                         {
45                             ls.PlaceDeadEnd(le);
46                         }
47 
48                         //同樣的,懸空的門應該刪除
49                         var temp = new List<GameObject>();
50                         foreach (var d in ls.Doors)
51                         {
52                             var ds = d.GetComponent<Door>();
53                             if (s.LastExits.Contains(ds.ExitTransdorm))
54                             {
55                                 temp.Add(d);
56                                 Destroy(d);
57                             }
58                         }
59 
60                         foreach (var t in temp)
61                         {
62                             ls.Doors.Remove(t);
63                         }
64 
65                         //遞迴遍歷,因為端點的通道可能很長,要直到遍歷到非通道為止
66                         DeleteEndSection(ls);
67                     }
68                 }
69             }
70         }

 

3.沒有間隔隨機的規則系統

在實際生成隨機地圖的過程中,很容易發現一個嚴重的問題,在隨機的過程中,同類型的房間接連出現,例如,玩家剛剛進入了一個商店型別的房間,後面又馬上可能再進入一個商店型別的房間,這樣顯然很不好,而為了避免這種情況發生,就要考慮給隨機系統新增額外的隨機規則。

 

在生成器的控制類中新增需要間隔隨機的標籤列表:

1         /// <summary>
2         /// The tags that need space
3         /// </summary>
4         public string[] SpaceTags;

在生成具體Section的過程中要對下一個生成的Section進行標籤檢查:

 1                 candidate.LastSections.Add(this);
 2                 NextSections.Add(candidate);
 3 
 4                 //對間隔標籤進行檢查
 5                 if (LevelGenerator.SpaceTags.Contains(candidate.Tags.First()) && LevelGenerator.CheckSpaceTags(candidate))
 6                 {
 7                     Destroy(candidate.gameObject);
 8                     NextSections.Remove(candidate);
 9                     GenerateSection(exit);
10                     return;
11                 }
12 
13                 candidate.Initialize(LevelGenerator, order);
14                 candidate.LastExits.Add(exit);
15 
16                 PlaceDoor(exit, candidate);

只有通過檢查才能繼續初始化和生成其他資料,不然就重新隨機。具體的檢查演算法如下:

 1         private bool bSpace;
 2 
 3         /// <summary>
 4         /// check the space tags
 5         /// </summary>
 6         /// <param name="section">next creat scetion</param>
 7         /// <returns>is successive tag</returns>
 8         public bool CheckSpaceTags(Section section)
 9         {
10             foreach (var ls in section.LastSections)
11             {
12                 if (ls.Tags.Contains("corridor"))
13                 {
14                     //包含通道時別忘了遍歷該通道的其他分支
15                     if (OtherNextCheck(ls, section))
16                         return bSpace = true;
17 
18                     bSpace = false;
19                     CheckSpaceTags(ls);
20                 }
21                 else
22                 {
23                     if (SpaceTags.Contains(ls.Tags.First()))
24                     {
25                         return bSpace = true;
26                     }
27                     else
28                     {
29                         //即使上一個房間未包含間隔標籤,但該房間的其他分支也需要考慮
30                         if (OtherNextCheck(ls, section))
31                             return bSpace = true;
32                     }
33                 }
34             }
35 
36             return bSpace;
37         }
38 
39         bool result;
40         bool OtherNextCheck(Section section,Section check)
41         {
42             foreach(var ns in section.NextSections)
43             {
44                 //如果是之前的Section分支則跳過此次遍歷
45                 if (ns == check)
46                     continue;
47 
48                 if (ns.Tags.Contains("corridor"))
49                 {
50                     result = false;
51                     OtherNextCheck(ns, check);
52                 }
53                 else
54                 {
55                     if (SpaceTags.Contains(ns.Tags.First()))
56                     {
57                         return result = true;
58                     }
59                 }
60             }
61 
62             return result;
63         }

總共有三種情況不符合要求:

1.包含間隔標籤房間的上一個房間也包含間隔標籤。(最直接的一種情況,直接Pass)

2.雖然包含間隔標籤的房間的上一個房間不包含間隔標籤,但連線它們通道的某一其他分支中的第一個房間包含間隔標籤。

3.雖然包含間隔標籤的房間的上一個房間不包含間隔標籤,且連線它們通道的任何一個其他分支中的第一個房間也不包含間隔標籤,但上一個房間的其他分支中的第一個房間包含間隔標籤。

上面三種情況都會造成一次戰鬥結束後可能同時又多個商店房間的情況。

 

隨機生成關卡的效果展示:(圖中選中的部分為門,間隔標籤房間即是其中有內容物的小房間)

 

 

我將改動之後的外掛重新進行了打包,以供下載參考:(其中有些發事件的程式碼比較懶沒有刪除,遇到問題直接對應行即可)

https://files.cnblogs.com/files/koshio0219/LevelGenerator.zip

 

更多有關隨機地圖關卡的隨筆可見:

https://www.cnblogs.com/koshio0219/p/12739913.html

&n