《Unity3D高级编程之进阶主程》第十章,地图与寻路(三) 地图编辑器

对于任何游戏来说,地图与场景的是比较重要的,特别是对于中大型游戏来说,在地图和场景上花费的时间和精力占去了大部分。而对于大部分游戏类型来说,布置场景,优化场景,甚至为场景写个编辑器是必不可少的,我们通常称他们为‘地图编辑器’。

地图编辑器有哪几种实现方式,该如何实现,顺便讲一讲从哪些方面下手优化场景,我们这节就来讲讲关于地图的事。

===

地图编辑器的基本功能

什么是地图编辑器?Unity3D本身的编辑器就是属于场景编辑器,但它没有达地图编辑器的功能,只是能添加场景,添加场景里的物件,移动,旋转,缩放模型物件而已,并不能帮助我们将这些物件变成自定义的具体数据然后存储下来成为文件。

对于一张完整的地图来说,我们需要的是能生成一个包含地图中所有元素数据的文件,并且我们可以通过这个文件还原整个地图。

这个文件不只是可以在视觉上还原地图,还要还原我们已经设定好的地图中的逻辑,包括碰撞的逻辑,触发逻辑,关卡逻辑,事件逻辑,以及游戏逻辑。

说白了,为了能让地图还原成我们编辑的那个模式,并且可以继续持续的编辑,我们需要创造一个地图编辑器来完成这个功能。

下面就让我们来说说地图编辑器是怎么实现的。

地图的元素都是以坐标,旋转角度,缩放大小为基准形成的数据,大部分元素都是节点,模型,特效,因此大部分地图编辑器都需要有,元素的坐标,角度,缩放大小,至少有这三样的数据记录。其他的数据,也会有包括配置表ID,物件类型,范围大小,脚本名字等,是为了配合不同游戏系统的需要而构建的。

即通常每个元素的数据为:

    class map_unit
    {
        position, //坐标
        rotation, //旋转角度
        scale, //缩放
        type, //类型
        table_id, //配置表ID
        size, //大小
        function_name, //功能名
    }

其中position, rotation, scale 是基础的数据类型,它记录了需要被展示在场景中的位置,角度,缩放大小。而其他的数据,例如 type 可以用来表示这个物件的类型,是人,是怪,是门,是机关,还是不会动的静态场景物件。

而 talbeid 可以用来表达这个前面这个 type 类型所对应的配置表ID,用这个 tableid 可以映射到具体数据表里或者说Excel表里的某一行数据,因此各种type下的展示效果可以根据这个 talbeid 的不同而不同,例如 怪物有很多种,每个talbeid 都代表了怪物表里的一种怪物,因此怪物的形状和怪物AI都可以用这个 table_id 映射。

size 大小则可以认为是该物体所触发事件的一个范围,比如当角色进入5 * 5 这个size大小范围时将触发机关,或者触发剧情,或者触发任务,或者触发生成一堆怪物。

functionname 一般都会指向某个功能性逻辑。当某个物件size范围内被触发时,功能性逻辑就执行操作,使用它的意图是通过它指向的是某个具体的功能,因为每个物件都有可能具有不同类型的操作指令,因此指令可能是纷繁复杂的。 例如,functionname里填上makenewtask 可以定义为,触发时制造新的任务,或者 playanimation 触发时播放某个动画,或者 generatemaster 生成一堆怪物等等诸如此类。

有了数据就需要对数据保存和读取,包括地图数据文件的保存和读取,这两个是最基本的功能。

数据协议格式在编辑器中应用的选择

关于数据的存储与解析,我们在前面的数据协议章节中专门做了详细讲解,这里我们做一些应用,该怎么选择地图数据的数据格式和存储协议。各类协议在这里也能体现其不同的优势。

我们把数据都存储到文件中,存储数据就需要格式,我们选定一种协议格式来存储后,就得用相同的协议读取,假设我们用最方便的Json协议格式存储所有数据,并放入文件中,那么我们在读取数据的时候也需要从Json数据中解析出每个元素存储到内存中。

假设说我们在众多协议中选择了使用Json协议,我们选择Json协议的意义是什么呢,乍一眼看来Json占用的空间又大,解析又慢,导致很多人都摒弃它,这样的情况下我们为什么还用它。肯定是因为简单,快速,易上手,只要有一点点编程知识的人都知道Json的格式,即使不知道也只需要花几分钟就能明白其原理,这对于众多新手来说是适合的门槛线,他们能快速上手快速融入团队。这种团队执行效率,对于一些技术力量参差不齐的团队(资深只有1,2个,其他都是新人)来说是好的选择,大家能很容易达成一个比较高的共识,协作起来也没有太多障碍。

我们可不要小看达成共识的好处,我们必须认识到我们独自一个人是完不成任务的,这一点很多人虽然清楚明白,但实际中总是犯个人主义的毛病,忍不住靠自己,而不是靠团队,最后事情太多弄得自己手忙脚乱。产品要靠大家一起完成每个人都出尽所有的本事而非一个英雄搞定一切,团队在协作时没有共识或者共识比较差,将发生许许多多怪异的问题,最终都会导致团队执行效率下降,产品的质量降低。假设有一种解决方案能让团队所有成员都能达成共识,即使这种解决方案的效率并不高,在团队执行效率面前我们都需要重点考虑一下。

有很多协议比Json空间占用小,解析快,效率高,自定义格式的数据协议就是其中一种,那么我们为什么又非要抛弃Json来使用自定义格式的数据协议呢?

假设说我们使用自定义格式的协议,把每个变量都转换成byte流形式存储,这种形式的存储是最能掌控的,也是最能够节省空间的。因此用自定义协议做存储格式的人,可能非常想要掌控这个存储过程,而不想让其他第三方的插件干扰。不过,自定义协议在使用时也有很大的缺陷,当数据格式变化时,对变化协议格式的适应能力比较弱,虽然也不是没办法,但确实有点代价。

代价是什么呢?代价就是要为每个版本的数据格式各自写一个完整的数据读取和存储的程序。每增加一个版本,为了维护旧的数据,都要在原有的数据解析的程序外,增加一个新的数据解析程序。就如下面的伪代码:

    void ReadData(io_stream)
    {
        version = io_stream.read_int();
        if( version == 1 )
        {
            Read_version1(io_stream);
        }
        else if(version == 2)
        {
            Read_version2(io_stream);
        }
    }

    voi Read_version(io_stream)
    {
        id = io_stream.read_int();
        level = io_stream.read_int();
    }

    voi Read_version(io_stream)
    {
        id = io_stream.read_int();
        name = io_stream.read_str();
        gold = io_stream.read_int();
    }

代码中读取数据时考虑了多种版本的兼容,使用了不同函数应对不同版本的办法,为每个版本写一个特有的读取顺序,在读取开头,用一个int元素来代表是应该使用哪个版本来读取数据,得到数据版本号后就能对应到不同版本的读取方法。

自定义方式确实压缩了数据,提高了效率,但也同时增加了维护的复杂度,而Proto Buffer就没有这样的缺点。

再假设说,我们使用Proto Buffer协议来作为存储格式,原本自定义协议的弊端被大大的削减,即使是数据格式升级和改变,都能轻松的应对,确实是一个比较好的选择,协议数据小,解析速度快,协议升级方便。

但不要忘了,在看似优秀的协议下的同时我们也被被束缚在了Proto Buffer里,即不得不依赖于Proto Buffer。在未来假设的某一天,我们需要使用其他工具或语言来读取数据文件时,必须被要求使用Proto Buffer来得到数据格式的转换。其实,Proto Buffer在学习门槛上也提高了不少,对于新手来说理解Proto Buffer并使用,虽然我们自己并不觉得有多大的难度,但对于新人来说可是个大门槛,在他们的角度上看,Json是比Proto Buffer更适合。

上面是我们在编写和创作地图编辑器中对数据部分的了解,接下来我们来看看地图数据对整个游戏的作用。

地图编辑器所带来的加载方式的改变

我们说地图编辑器主要的作用是将场景地图的编辑功能和地图数据再利用功能结合起来,让可视化的地图编辑器更多的帮助策划设计者或者场景美术人员编辑他们觉得更好看,更舒适,更绚丽的场景地图,并且这些编辑完的场景能随时保存成游戏中需要的数据格式。

也正因为有了地图数据,我们才能在游戏中正确的还原原本编辑好的地图场景,而且在加载地图时有更明确的目标。

其中数据到场景的还原是通过加载的方式进行的,这里我们不得不说一下地图的加载形式。地图加载的形式有三种,我们可以选择一次性加载全部地图(即阻塞式的加载所有地图元素),也可以选择流式的动态加载,也可以选择按需分批加载地图中的元素模型。

一次性加载显然是最容易和方便的,只要把所有数据读取进来,针对每个元素的数据,加载它所指定的模型或效果到指定的位置,并设置旋转和缩放,再对挂上对应的脚本如果需要的话。

一次性加载这么容易和方便,我们有时可以完全不需要地图编辑器,一个prefab搞定整个场景。这样说来话为什么还需要地图编辑器?

随着功能和需要的扩大,当游戏开始时,很多物件并不需要加载到场景中,而是根据个人玩家的游戏进度来判断是否需要加载。这时如果还是按一个prefab搞定一个场景,一下子把所有物件都加载进来,内存势必浪费很多,这是我们不希望看到的。在一次性加载地图的方式中,地图编辑器就能帮助我们根据需要加载物件,帮助我们节省不必要的开销。

然后,游戏越做越大,场景不断扩大,场景中物体的种类和数量越来越多,一次性加载需要消耗的CPU和时间也越来越大。原本只要加载几个面片当做地形的prefab,发展成了带有众多山,路,草,石头,路,桥,人,房子等的一整个大场景。这时,即使是按需加载也可能会在加载整个场景的阻塞中卡住很长时间,体验越来越差。流式加载就在这时体现了更加好的体验。

根据地图编辑器的数据,进行流式的动态加载,让人能有逐步出现的视觉体验,而不是画面卡住的糟糕体验。这里不深入扩展开去说明如何使用Unity3D的API做流式加载,而是将这些内容放“资源加载的多种方式”的章节中进行讲解。

流式加载缓解了瞬间的CPU消耗,把CPU消耗按时间平摊开来了,所以画面看起来不那么硬板。但是还不够,RPG的大世界中少有切换场景的时候,大都是整个地图无缝连接,这样才能体会到真实世界无缝的行走和旅行的体验。随着地图的继续扩大,整个世界都被容纳进了地图,我们不可能把整个世界都加载进内存里,因此分块分批加载成了最迫切的需求。

分批和分块的方法其实很多,比如可以用距离来判断加载和卸载的内容,这里我们介绍一个比较常用的方法,即九宫格分块加载方式。

我们可以把一整个世界横竖切N和M刀,这样就有了分成 (N+1) * (M+1) 个块,每个块之间的地形也是被拆分开来的,因此可以说每块都是独立的,完全可以被独立加载或独立卸载。

在游戏场景中,其实我们只能看到一部分的画面,即周围的800-1200米范围内的画面,越远的地形和风景意义越来越少,因此当前在所的分割块,加上周围的八个分割块足以能展示我们需要的画面,即九块的地图内容足以成为我们展示的画面内容。即:

    [-][-][-][-][-][-][-]
    [-][-][-][-][-][-][-]
    [-][-][2][2][2][-][-]
    [-][-][2][1][2][-][-]
    [-][-][2][2][2][-][-]
    [-][-][-][-][-][-][-]
    [-][-][-][-][-][-][-]
    [-][-][-][-][-][-][-]

图中1为角色所在的地图块,2位周围8块已经被加载进来的地图内容。

我们的角色不断向前行进,穿越了我们当前的地图块的边界,进入了另一个地图块,这时九块内容发生了变化,以我们角色为中心点的地图块周围的八块与原先我们所在的八块内容发生了变化。我们需要加载周围八块内容中,没有被加载进来的那三块内容,即:

    [-][-][-][-][-][-][-]
    [-][-][-][-][-][-][-]
    [-][3][2][2][2][-][-]
    [-][3][1][1][2][-][-]
    [-][3][2][2][2][-][-]
    [-][-][-][-][-][-][-]
    [-][-][-][-][-][-][-]
    [-][-][-][-][-][-][-]

图中1为角色所在块,向前进了1块,原来所在的地块不再是角色的中心块了,转移到了新的地图块,那么周围的8块地图的也发生了变化。

我们必须加载新的地图块,来确保我们展示的内容是完整的,即标记为3的内容块,并且我们还需要卸载被废弃的三块内容,即:

    [-][-][-][-][-][-][-]
    [-][-][-][-][-][-][-]
    [-][3][3][3][2][-][-]
    [-][3][1][3][2][-][-]
    [-][3][3][3][2][-][-]
    [-][-][-][-][-][-][-]
    [-][-][-][-][-][-][-]
    [-][-][-][-][-][-][-]

图中标记为2的内容块是被废弃的内容块,是需要我们卸载的内容块,卸载不必要的内存贴图,模型等数据。

根据九宫格的加载和卸载规则,角色不断前进或后退,不同方向上的前进或者后退,不断的跨越不同区块的内容,地图模块不断的加载需要的地图内容块并卸载不需要的内容块,角色能始终看到完整的地图内容,而不需要大量的内存支持,因为我们在不断的卸载那些不需要的地图块。

当然,我们可以把它划分的更细致一些,将地图分的更细,然后再采用25宫格,49宫格等等,在玩家不断行走的同时,加载那些进入范围内的地图模型,而舍弃那些离开我们的地图方块。

我们也可以将一次性加载和流式加载结合使用,在加载地图块时,把最关键部分一次性阻塞式的加载,比如地形和碰撞体,而其他的物件则用流式加载方式,使得加载不会瞬间消耗大量CPU,平滑的过度到加载完整个场景,所带来的游戏体验将是绝佳的。

感谢您的耐心阅读

Thanks for your reading

  • 版权申明

    本文为博主原创文章,未经允许不得转载:

    《Unity3D高级编程之进阶主程》第十章,地图与寻路(三) 地图编辑器

    Copyright attention

    Please don't reprint without authorize.

  • 微信公众号