Procedural Level Generator是在Unity应用商店中发布的一款免费的轻量级关卡生成器:

可以直接搜索关键字在应用商店中查找并下载。

和我之前生成关卡的想法不同,这个插件生成地图的方式类似于拼积木,它将每一个地图分为一个一个的部分,无论是房间还是通道,都叫做Section,只是用不同的标签来规定和约束这些部分,并逐一的将这些部分在空间中连接起来,每一个部分需要自己手动定义它的预制体,形状,碰撞盒子以及出口列表,通过出口列表来判断下一个部分的连接位置和方向,用碰撞盒子的Bounds.Intersects(Bounds bounds);方法来判断一个部分的生成是否会是一个无效的连接:

         public bool IsSectionValid(Bounds newSection, Bounds sectionToIgnore) =>
!RegisteredColliders.Except(sectionToIgnore.Colliders).Any(c => c.bounds.Intersects(newSection.Colliders.First().bounds)); //
// 摘要:
// Does another bounding box intersect with this bounding box?
//
// 参数:
// bounds:
public bool Intersects(Bounds bounds);

利用提前制作Section预制体的方式来连接生成整个关卡的方式,确实避免了很多让人头疼的算法设计,但可能插件本身也只是为了提供一个基本思路,因此有些地方值得优化。

1.缺少门的概念

很多时候,进入一个地图的房间,我们需要门的解锁和开关来对探索进行限制,也有可能进入一个满是怪物的房间,这个房间的所有门会自动关闭,给玩家一种身陷敌营是时候浴血奋战的错觉。故而考虑在Section中给每个类增加一个自带Door的列表,该列表可以没有任何元素,例如很多通道之间是不需要门来进行连接的,但房间与通道之间,房间与房间之间,可以同时创建门来执行必要的约束限制。

定义门的类,注意保持在插件的命名空间之下:

 using UnityEngine;
using System.Collections.Generic; namespace LevelGenerator.Scripts
{
public class Door : MonoBehaviour
{
public List<string> Tag1s = new List<string>();
public List<string> Tag2s = new List<string>(); public Transform ExitTransdorm { get; set; }
public void Initialize(LevelGenerator levelGenerator)
{
transform.SetParent(levelGenerator.Container);
}
}
}

这里只定义了最基础的一些属性和方法,主要是门连接的两个Section的标签列表,用于更为准确的判定该门的所属。

在Section类中添加放置门的方法:

         /// <summary>
/// initialize door datas
/// </summary>
/// <param name="exit">place transform</param>
/// <param name="next">next section</param>
public void PlaceDoor(Transform exit, Section next)
{
var t = Instantiate(LevelGenerator.Doors.PickOne(), exit);
t.Initialize(LevelGenerator);
Doors.Add(t.gameObject); var d = t.GetComponent<Door>();
d.Tag1s.AddRange(Tags);
d.Tag2s.AddRange(next.Tags);
d.ExitTransdorm = exit; //send door initialize event
if (Idx > || next.Idx > )
EventManager.QueueEvent(new DoorInitEvent(t.transform, Idx, next.Idx));
}

并且在每一个门创建后及时记录在Section的Doors列表中,发送创建完成的事件,这里使用的事件系统可以详见:

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

调用就是在成功生成每一个Section之后:

         protected void GenerateSection(Transform exit)
{
var candidate = IsAdvancedExit(exit)
? BuildSectionFromExit(exit.GetComponent<AdvancedExit>())
: BuildSectionFromExit(exit); if (LevelGenerator.IsSectionValid(candidate.Bounds, Bounds))
{
candidate.LastSections.Add(this);
NextSections.Add(candidate); if (LevelGenerator.SpaceTags.Contains(candidate.Tags.First()) && LevelGenerator.CheckSpaceTags(candidate))
{
Destroy(candidate.gameObject);
NextSection.Remove(candidate);
GenerateSection(exit);
return;
} candidate.Initialize(LevelGenerator, order);
candidate.LastExits.Add(exit); PlaceDoor(exit, candidate);
}
else
{
Destroy(candidate.gameObject);
PlaceDeadEnd(exit);
}
}

由于通道与通道之间不需要放门,因此在所有Section生成完毕之后将一部分门删除:(此方法位于关卡生成器这个控制类中)

         /// <summary>
/// Clear the corridor doors
/// </summary>
protected void CheckDeleteDoors()
{
foreach (var s in registeredSections)
{
if (s != null)
{
var temp = new List<GameObject>();
foreach (var d in s.Doors)
{
var ds = d.GetComponent<Door>();
if (ds.Tag1s.Contains("corridor") && ds.Tag2s.Contains("corridor"))
{
temp.Add(d);
Destroy(d);
}
} foreach(var t in temp)
{
s.Doors.Remove(t);
}
}
}
}

这里注意一点,遍历列表的时候不能直接对列表的元素进行移除,所以先建立了一个临时需要移除的列表作为替代,遍历临时列表以移除元素,当然了,你用通用方式for循环倒着遍历也是可行的,个人不太喜欢用for循环而已。

说句题外话,可能有人会有疑惑,为什么不直接在创建门的时候做条件限制,非要等到最后统一再来遍历删除呢,其实最主要的原因是为了尽量少的变动原始的代码逻辑和结构,而更倾向于添加新的方法来对插件进行附加功能的完善,这样可以很大的程度上减少bug触发的概率,毕竟别人写的插件你很可能总有漏想的地方,随意的改动和删除对方已经写过的内容并非良策,最好是只添加代码而不对原始代码进行任何的改动或删除,仅以这样的方式来达到完善功能的目的。调试的时候也只用关注自己添加的部分即可。

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

关于这一点,可能会根据游戏的不同而异,因为这个插件在生成地图的过程中,无论是房间还是通道,都是同一个类Section,这样没办法保证路径末尾是一个房间,还是通道。可以添加一个功能用于检查和删除端点是通道的部分。

在Section中添加以下属性方便遍历删除:

         [HideInInspector]
public List<GameObject> DeadEnds = new List<GameObject>();
[HideInInspector]
public List<Transform> LastExits = new List<Transform>();
[HideInInspector]
public List<Section> LastSections = new List<Section>();
[HideInInspector]
public List<Section> NextSections = new List<Section>();
[HideInInspector]
public List<GameObject> Doors = new List<GameObject>();

分别代表每一个Section的死亡端点列表,上一个Section的列表,下一个Section的列表(类似于双向链表),与上一个Section连接的位置列表,门的列表,有了这些数据结构,无论怎么遍历,修改和获取数据都是会变得非常容易。添加的地方自然是生成Section的方法中,放置端点的方法中,及放置门的方法中。

开始检查并删除末尾的通道:(根据实际需求是否调用)

         /// <summary>
/// clear end sections and update datas
/// </summary>
protected void DeleteEndSections()
{
var temp = new List<Section>();
foreach (var s in registeredSections)
{
temp.Add(s);
DeleteEndSection(s);
} foreach(var t in temp)
{
foreach (var c in t.Bounds.Colliders)
{
DeadEndColliders.Remove(c);
}
registeredSections.Remove(t);
}
} /// <summary>
/// clear the end corridors and doors , place deadend prafabs' instances
/// </summary>
/// <param name="s">the check section</param>
protected void DeleteEndSection(Section s)
{
if (s.Tags.Contains("corridor"))
{
if (s.DeadEnds.Count == s.ExitsCount)
{
//删除通道以及通道的端点方块
Destroy(s.gameObject);
foreach (var e in s.DeadEnds)
{
Destroy(e);
} foreach (var ls in s.LastSections)
{
//删除末端通道后需要在上一个节点的退出点放置端点方块(不然墙壁上就会有洞)
foreach (var le in s.LastExits)
{
ls.PlaceDeadEnd(le);
} //同样的,悬空的门应该删除
var temp = new List<GameObject>();
foreach (var d in ls.Doors)
{
var ds = d.GetComponent<Door>();
if (s.LastExits.Contains(ds.ExitTransdorm))
{
temp.Add(d);
Destroy(d);
}
} foreach (var t in temp)
{
ls.Doors.Remove(t);
} //递归遍历,因为端点的通道可能很长,要直到遍历到非通道为止
DeleteEndSection(ls);
}
}
}
}

3.没有间隔随机的规则系统

在实际生成随机地图的过程中,很容易发现一个严重的问题,在随机的过程中,同类型的房间接连出现,例如,玩家刚刚进入了一个商店类型的房间,后面又马上可能再进入一个商店类型的房间,这样显然很不好,而为了避免这种情况发生,就要考虑给随机系统添加额外的随机规则。

在生成器的控制类中添加需要间隔随机的标签列表:

         /// <summary>
/// The tags that need space
/// </summary>
public string[] SpaceTags;

在生成具体Section的过程中要对下一个生成的Section进行标签检查:

                 candidate.LastSections.Add(this);
NextSections.Add(candidate); //对间隔标签进行检查
if (LevelGenerator.SpaceTags.Contains(candidate.Tags.First()) && LevelGenerator.CheckSpaceTags(candidate))
{
Destroy(candidate.gameObject);
NextSections.Remove(candidate);
GenerateSection(exit);
return;
} candidate.Initialize(LevelGenerator, order);
candidate.LastExits.Add(exit); PlaceDoor(exit, candidate);

只有通过检查才能继续初始化和生成其他数据,不然就重新随机。具体的检查算法如下:

         private bool bSpace;

         /// <summary>
/// check the space tags
/// </summary>
/// <param name="section">next creat scetion</param>
/// <returns>is successive tag</returns>
public bool CheckSpaceTags(Section section)
{
foreach (var ls in section.LastSections)
{
if (ls.Tags.Contains("corridor"))
{
//包含通道时别忘了遍历该通道的其他分支
if (OtherNextCheck(ls, section))
return bSpace = true; bSpace = false;
CheckSpaceTags(ls);
}
else
{
if (SpaceTags.Contains(ls.Tags.First()))
{
return bSpace = true;
}
else
{
//即使上一个房间未包含间隔标签,但该房间的其他分支也需要考虑
if (OtherNextCheck(ls, section))
return bSpace = true;
}
}
} return bSpace;
} bool result;
bool OtherNextCheck(Section section,Section check)
{
foreach(var ns in section.NextSections)
{
//如果是之前的Section分支则跳过此次遍历
if (ns == check)
continue; if (ns.Tags.Contains("corridor"))
{
result = false;
OtherNextCheck(ns, check);
}
else
{
if (SpaceTags.Contains(ns.Tags.First()))
{
return result = true;
}
}
} return result;
}

总共有三种情况不符合要求:

1.包含间隔标签房间的上一个房间也包含间隔标签。(最直接的一种情况,直接Pass)

2.虽然包含间隔标签的房间的上一个房间不包含间隔标签,但连接它们通道的某一其他分支中的第一个房间包含间隔标签。

3.虽然包含间隔标签的房间的上一个房间不包含间隔标签,且连接它们通道的任何一个其他分支中的第一个房间也不包含间隔标签,但上一个房间的其他分支中的第一个房间包含间隔标签。

上面三种情况都会造成一次战斗结束后可能同时又多个商店房间的情况。

随机生成关卡的效果展示:(图中选中的部分为门,间隔标签房间即是其中有内容物的小房间)

我将改动之后的插件重新进行了打包,以供下载参考:

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

更多有关随机地图关卡的随笔可见:

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

Unity Procedural Level Generator 基础总结与功能优化的更多相关文章

  1. 【转载】利用Unity自带的合图切割功能将合图切割成子图

    虽然目前网上具有切割合图功能的工具不少,但大部分都是自动切割或者根据plist之类的合图文件切割的, 这种切割往往不可自己微调或者很难维调,导致效果不理想. 今天逛贴吧发现了一位网友写的切割合图插件很 ...

  2. 《Unity 3D游戏客户端基础框架》概述

    框架概述: 做了那么久的业务开发,也做了一年多的核心战斗开发,最近想着自己倒腾一套游戏框架,当然暂不涉及核心玩法类型和战斗框架,核心战斗的设计要根据具体的游戏类型而定制,这里只是一些通用的基础系统的框 ...

  3. C# Unity依赖注入利用Attribute实现AOP功能

    使用场景? 很多时候, 我们定义一个功能, 当我们要对这个功能进行扩展的时候, 按照常规的思路, 我们一般都是利用OOP的思想, 在原有的功能上进行扩展. 那么有没有一种东西, 可以实现当我们需要扩展 ...

  4. [译]Vulkan教程(14)图形管道基础之固定功能

    [译]Vulkan教程(14)图形管道基础之固定功能 Fixed functions 固定功能 The older graphics APIs provided default state for m ...

  5. HTML&CSS基础-html注释功能

    HTML&CSS基础-html注释功能 作者:尹正杰 版权声明:原创作品,谢绝转载!否则将追究法律责任.  一.什么是HTML(Hypertext Markup Language) 超文本标记 ...

  6. MySQL慢日志线上问题分析及功能优化

    本文来源于数据库内核专栏. MySQL慢日志(slow log)是MySQL DBA及其他开发.运维人员需经常关注的一类信息.使用慢日志可找出执行时间较长或未走索引等SQL语句,为进行系统调优提供依据 ...

  7. WeTest功能优化第3期:业内首创,有声音的云真机

    第3期功能优化目录 [云真机远程调试]音频同步传输实现测试有声 [兼容性测试报告]新增视频助力动态定位问题 [云真机远程调试]菜单栏优化助力机型选择 本期介绍的新功能,秉承创造用户需求的理念,在云真机 ...

  8. WeTest功能优化第2期:云真机智能投屏,调试告别鼠标

    第2期功能优化目录 [云真机视频映射]云真机画面本地映射[兼容性测试报告]新增问题机型聚类功能[新增Android9.0]同步上线最新安卓系统 本期介绍的云测产品功能优化,既有重磅级技术突破,也有报告 ...

  9. WeTest功能优化第1期:截图960px,云真机映射功能了解

    第1期功能优化目录 [全线产品测试截图优化]安卓机型测试截图分辨率上升至960px [云真机新增Android 9]最新安卓系统,等你pick [云真机新增键盘映射功能]电脑键盘码字,云真机同步显示  ...

随机推荐

  1. C++中的字符串切片操作

    string str = "hello"; str.substr(0,2); //输出"he", 表示[0,2)

  2. 1011 World Cup Betting (20 分)

    With the 2010 FIFA World Cup running, football fans the world over were becoming increasingly excite ...

  3. Git应用详解第三讲:本地分支的重要操作

    前言 前情提要:Git应用详解第二讲:Git删除.修改.撤销操作 分支是git最核心的操作之一,了解分支的基本操作能够大大提高项目开发的效率.这一讲就来介绍一些分支的常见操作及其基本原理. 一.分支概 ...

  4. MTK Android Camera新增差值

    一. 计算需要的插值 如果原有的插值列表没有我们需要的插值的时候,要通过计算算出符合需求的插值,比如2700W的插值. 具体计算方法如下: 假设像素的长宽分别为X,Y,则插值为XY.由于MTK规定各参 ...

  5. 我对KMP算法的理解

    KMP算法的核心在于失配回溯表——pnext,相比于通过逐个比较来匹配字符串的朴素算法,KMP通过对模式串的分析,可以做到比较指针在主串上不回溯,一直向前. 1. KMP如何实现不回溯? 对于主串 t ...

  6. 一口气说出 4种 LBS “附近的人” 实现方式,面试官笑了

    引言 昨天一位公众号粉丝和我讨论了一道面试题,个人觉得比较有意义,这里整理了一下分享给大家,愿小伙伴们面试路上少踩坑.面试题目比较简单:"让你实现一个附近的人功能,你有什么方案?" ...

  7. 31.2 try finally使用

    package day31_exception; import java.io.FileWriter; import java.io.IOException; import java.lang.Exc ...

  8. <E> 泛型

    /* * 使用集合存储自定义对象并遍历 * 由于集合可以存储任意类型的对象,当我们存储了不同类型的对象,就有可能在转换的时候出现类型转换异常, * 所以java为了解决这个问题,给我们提供了一种机制, ...

  9. Hadoop(二) 单节点案例grep和wordcount|4

    前提步骤安装Hadoop,安装步骤: https://www.jianshu.com/p/2ce9775aeb6e 单节点案例官方文档地址:http://hadoop.apache.org/docs/ ...

  10. 《深入理解 Java 虚拟机》笔记整理

    正文 一.Java 内存区域与内存溢出异常 1.运行时数据区域 程序计数器:当前线程所执行的字节码的行号指示器.线程私有. Java 虚拟机栈:Java 方法执行的内存模型.线程私有. 本地方法栈:N ...