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