Unity项目架构设计入门指南:以潜水员戴夫为例,培养架构思想
前言
我现在已经会自己开发小游戏了,但是我发现我面对复杂的需求完全没有思路。
我在开发稍微大一点的项目明显更吃力了,复杂的代码明显超出了我的能力范围。
以上或许是很多人在初学Unity时会抱怨的事情。而我可以告诉你,他们缺少的并不是高超的编程能力或者是非凡的引擎理解,他们缺少的只是良好的项目架构意识。
作为一个目前开发了十余款游戏的开发者,我每一次开发都在迭代更好的项目架构,其间总结出了一套最适合初学者学习的Unity项目架构理念。本文将涉及一定的引擎基础和C#语言基础,并且仅专注于开发角度,而非资源加载优化等方面。
不过首先,我们在学习一样新东西之前,请让我们问出:
什么是项目架构?
架构:同结构,是指在一个系统或者材料之中,互相关联元素的排列及组织。
⸺ 维基百科
就像整理你的房间一样,游戏也需要一个整洁的项目环境。整理房间的目的之一是让你能更快的找到你需要的物品,项目架构的目的也一样⸺让你能很快找到一个功能在何处实现,一些参数需要在何处修改,即使你已经很久没有打开这个项目了。同时,一个好的项目架构让你能更快更方便的在你的游戏中添加新的东西。
所以,所谓项目架构其实用大白话说就是:如何高效且有逻辑的管理每一坨代码和数据。
不过,我从来不知道什么项目架构,我的游戏也一直能正常运行。就像我的书桌,就算它永远乱糟糟的,我也一直非常清楚什么东西放在哪里,所以⸺
为什么需要项目架构?
至于这个,请允许我使用更详细具体的例子来说明⸺潜水员戴夫。我希望你曾经玩过或者了解过这款游戏,这会对接下来的内容有一些帮助。接下来假如让我们来开发一款:
山寨游戏,潜水员大卫!
我们现在正打算复刻潜水员戴夫的白天探险捕鱼环节,我们已经完成了玩家控制系统,而我们打算添加一种小鱼作为玩家的狩猎目标,这种小鱼需要实现以下几种行为:
- 在平时会成群随机游动,在玩家靠近时逃跑
- 可以被玩家攻击
- 死后掉落战利品
假设玩家(player.cs)等代码已经存在了,于是我们随便增加了一些代码:
| 功能的位置 | 函数名称 | 功能 | 相关的脚本 |
|---|---|---|---|
| player.cs | 攻击 | 判定攻击范围内是否击中了鱼,如果击中就扣除鱼的健康值,如果鱼死了就调用鱼的死亡函数 | fish1.cs中的血量变量和死亡函数 |
| fish1.cs | 游荡 | 在玩家远离的时候游荡,如果玩家靠近了,就向反方向游动 | player.cs中的位置变量 |
| fish1.cs | 死亡 | 在角色的背包中添加对应战利品 | player.cs中的背包物品变量 |
现在我们需要增加第二条鱼,这条鱼会在玩家靠近的时候攻击玩家,并且不同部位被玩家击中会有不同的增伤,我们又了这些代码:
| 功能的位置 | 函数名称 | 功能 | 相关的脚本 |
|---|---|---|---|
| player.cs | 攻击 | 判定攻击范围内是否击中了鱼1或鱼2,如果击中鱼1就扣除鱼1的健康值,如果鱼1死了就调用鱼1的死亡函数;如果击中了鱼2,判断击中的部位,计算增伤扣除鱼2的健康值,如果鱼2死了就调用鱼2的死亡函数 | fish1.cs中的血量变量和死亡函数,fish2.cs中的血量变量和死亡函数 |
| fish2.cs | 游荡 | 在玩家原理的时候游荡,如果玩家靠近了,就向玩家方向游动,足够靠近玩家触发攻击动作 | player.cs中的位置变量 |
| fish2.cs | 死亡 | 在角色的背包中添加对应鱼2的新战利品 | player.cs中的背包物品变量 |
| fish2.cs | 攻击 | 判断攻击范围内是否击中了玩家,如果击中就扣除玩家的血量,如果玩家血量小于0则调用玩家的死亡函数 | player.cs中的血量变量 |
现在试想我们需要加入第三条鱼,我们…
不,这一定是有问题的,比起开发游戏,这更像是无尽的折磨。我们一定缺少了真正的开发者应该拥有的一项必不可少的技能,而这能帮助我们更方便的去维护和拓展我们的游戏!所以⸺
到底是哪里出问题了?
我们发现如果我们继续进行下去,添加十条鱼甚至一百条鱼的时候,player.cs的攻击函数将会扩展到几千行,而我们的每一条鱼的脚本也都需要从零开始编辑。更有甚者,如果你打算更改一些东西,举个例子,增加一个类似不死图腾的道具,那么你需要在所有鱼的攻击函数里面增加这个道具的判断,或者用其他更加歪门邪道的方法来进一步毁掉你的项目。这听着就不是很健康。
我们都是程序员,至少是会写程序的,我们应该知道有一个东西叫做时间复杂度,而这样不健康的项目架构在增加或修改一个功能时的时间复杂度至少是O(n)。那么有什么能让我们在增加新的东西、维护旧的东西时的时间复杂度无限接近于O(1)呢?接下来让我们来探讨:
项目架构的终极追求
正如前文中讲的,项目架构就是整理的艺术,而整理的意义之一,就是能最快的找到东西。一个理想的架构在完成后应该:
是方便维护的:你可以很快速的定位到一个功能的实现位置,一个功能的数据位置。你需要能简单的修改你游戏的参数且无需改动代码(包括代码中的常量)。
- 图形和算法的分离:你在修改一个角色的外观的时候,即使不做额外调整也不会影响到角色的实际行为。
- 算法和数据的分离:在你需要修改一项数据的时候,确保你只需要修改数据,而不是改动代码。
- 算法和算法的分离:两个在逻辑上不相干的物体,在算法上也不应该互相引用。比如玩家在攻击的时候,不需要知道他打的是鱼还是石头,因为鱼和石头都跟玩家的攻击动作不相干。
是方便扩展的:你可以在尽量少修改原有的代码的前提下添加一个新的功能甚至新的系统。
- 弱连接和健康的公共方法:多使用事件,继承,接口,泛型;少直接引用(相对来说,具体需要辩证看待)。公共方法应该有清晰的目的,尽量少直接暴露一个变量。
- 清晰的时间顺序:编写自己的脚本执行器,除非你完全掌握Unity的脚本执行顺序,但事实上Unity的脚本执行顺序在默认情况下同级别的方法执行顺序不可控。
支持Unity:不要过度在意和Unity的解偶,你用的就是Unity。
如上是一些大纲性的概念,而接下来我们来讲讲我喜欢的具体的实现。请注意,这些并非标准答案或万金油,一切都取决于实际情况和具体项目。
图形和算法的分离
要像演木偶戏一样制作游戏。在木偶戏中,木偶不应该也不会有自己的想法,一切行动都是由表演者通过提线来进行演出;而尽管一切都由表演者进行,观众只能看见木偶而看不见表演者。
于是我们便可以创造如下的结构的游戏总架构,每一个圈圈代表一个Unity中的GameObject:
1 | ⭕️ 捕鱼场景 |
我们现在专注于玩家控制器这一部分,可以看到玩家控制器和玩家形象被分离成了两个物体。这样做有两个好处:
其一:单独处理玩家的逻辑和动画
动画经常需要对角色进行拉伸,旋转,抖动等等操作,但如果直接对于Unity中的碰撞体进行同样的操作,一般会造成额外的开销和物理模拟的不稳定性。所以如果我们将玩家的可视化形象仅仅作为一个子物体存在,这个形象的变形等就完全不会影响到其他的计算,比如抖动不会影响到玩家的实际位置,拉伸不会改变碰撞器大小等。
其二:动画对于实际逻辑的隐藏
在开发的很多时候,我们需要使用占位符来代替一些尚未产出的美术资源,而玩家的形象和玩家的具体表现在逻辑上没有任何关联(比如会二段跳的角色可以是Kid,也可以是玛德琳)。理想情况下,我们如果需要更改玩家形象或者增加一些视觉效果,我们完全不需要也不应该改动移动逻辑的代码。所以我们在编写程序的时候,不应该将角色的移动逻辑和动画紧密的编写在一起。你可能没有必要使用事件/接口等等将移动逻辑和动画系统完全分离成两个脚本,但是你至少需要将动画和移动逻辑放在两个方法中。
算法和数据的分离
在游戏中有很多数据,比如:
- 玩家的移动速度
- 玩家的武器种类
- 控制玩家移动的键位
- 鱼群的刷新逻辑
- 不同鱼的名字/图片
- …
算法是对于数据的运算方法,算法和数据密不可分,但是这不是你将所有数字作为常量写在你的代码里的理由。直接将常量写在代码中会有如下的几个劣势:
- 你的数据会被分散在代码的各个地方,从而很难集中调整
- 在你修改数字的时候,代码需要被重新编译(或者重载),这一般会花费很多时间
- 如果负责改这些数字的人不懂代码,你很可能需要返工
而以下是一些存储数据的良好方法(并不是存档,而是存储游戏运行所需要的数据)
使用场景存储数据
场景是一个比较大且笨重的数据存储系统,存储着若干游戏对象的父子结构和组件引用以及组件参数。在Unity中加载场景一般会造成不可忽视的开销,加载稍大场景可能会导致游戏在一帧中卡顿较长时间,但是可以通过异步加载缓解。
使用预制体存储数据
预制体可以理解成一个印章,修改预制体会同步修改场景中所有的相同预制体,而场景中的预制体也可以在部分参数上覆盖预制体原本的参数而不影响预制体本体和其他预制体。预制体可以理解成一个只有一个物体的小场景,并且可以被其他场景/预制体引用。
使用ScriptableObject存储数据
Unity提供的一个更加轻量化的数据结构,可以存储一切能在检查器中被显示和修改的数据,并且可以在运行时修改。
使用电子表格存储数据
需要自己实现的数据结构,一般在大型项目中使用。一般只能存储字符串,需要自行解析。非常方便于批量修改和比较,适合不会程序的人使用和修改。对于数量多,相似性高的多个物体的配置非常推荐。
总揽
| 存储方式 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 场景 | 简单直观 | 载入慢,不易复用 | 关卡,一次性数据 |
| 预制体 | 可复用 | 难批量修改,数据相对不直观 | 常见对象模板 |
| ScriptableObject | 轻量 | 难批量修改,需额外维护引用 | 全局配置,数据表 |
| 表格 | 策划友好 | 需解析,类型不安全 | 批量配置 |
算法和算法的分离
再次观察一下我们的项目结构,这次关注场景总控制器。
1 | ⭕️ 捕鱼场景 |
可以看到我们的场景有一个总控制器,而总控制器下有其他分门别类的小控制器。比如玩家管理系统负责将游戏的输入传递给玩家控制器,如果我们打开了菜单,我们可以很方便的在此处阻断键盘输入。而鱼的管理系统负责控制鱼群的生成和鱼的总量。
或许有些人会反对在游戏中使用单例,但是经过我的实测,场景级别的单例管理器无论在编码效率还是在整体的逻辑清晰度上都是相较良好的,但是需要控制在场景内使用,利用场景来管理单例的生命周期,从而避免单例模式的一些缺点。同时采用这样的管理器架构,我们可以很好的控制脚本的执行顺序。场景总控制器会被设置总是在Unity默认顺序的第一个被执行,接着子控制器的Update等生命周期函数都在场景总控制器中调用,从而确定脚本的执行顺序。这相比一个个单独在Unity的项目中设置执行优先级,会更加方便。
应用我们的项目架构
此时我们了解了传说中的Unity项目架构的方法,让我们重新设计《潜水员大卫》的捕鱼场景吧!
依然是需要实现这样的几种功能的小鱼:
- 在平时会成群随机游动,在玩家靠近时逃跑
- 可以被玩家攻击
- 死后掉落战利品
我们创建了这样的场景结构:
1 | ⭕️ 捕鱼场景 |
我们首先编写脚本Class FishBase,赋予了鱼的基础逻辑:游走,逃跑和掉落战利品。
接着我们编写了接口Interface ICanBeHit,定义了一个可以被攻击的接口,并使Class FishBase继承这个接口。并修改玩家控制器,使其能攻击带有这个接口的物品。
接着编写鱼类的管理系统Class FishManager,可以生成鱼群,并从Class PlayerManager中获取玩家位置并维护一个威胁物列表可供小鱼获取,威胁物列表包含了威胁物的位置和威胁物的类型。
接着编写Class Fish1,继承Class FishBase,配置实际的战利品种类并增加威胁物类型:玩家。
这样我们就完成了小鱼的三种行为。
如果我们需要增加捕食玩家的鱼类2,我们首先应该思考,追击玩家或者其他鱼是否是一个鱼类共有的行为。如果不是,我们只需要创建一个继承Class FishBase的Class Fish2,直接重写受到威胁的逃离行为,如果威胁物是玩家则冲上去。如果是,其他鱼类也经常会捕食一些鱼类或玩家,我们则需要为Class FishBase增加一个捕食方法,在Class FishManager中修改威胁物列表为周围物体列表,不同鱼类通过标签判断周围的物体是威胁物还是捕食物。
如果我们现在需要增加鱼类3,它被击中时会喷射墨汁,我们假设只有这一种鱼会喷射墨汁,我们只需要编写一个继承Class FishBase的Class Fish3,并重写受击方法,增加喷射墨汁的功能就行。
可以发现我们无论添加什么鱼类,我们都只需要进行小范围的修改,更不需要去改动和鱼完全不相干的玩家的代码,这就是一个好的项目架构。
更进一步的,我们现在的代码在获取玩家位置上,Class FishManager依然有对于Class PlayerManager的依赖,这是我们不希望的。所以我们可以新建一个类型Class ObjPositionManager来获取所有物品的位置,Class FishManager和Class PlayerManager都向Class ObjPositionManager汇报位置并从中获取其他物品的位置。
总结
本文讲述了什么项目架构,为什么需要项目架构,以及如何进行项目架构。以最简单的话来说,好的项目架构就是为了更快更方便的项目维护和开发。而实现好的架构就需要分离项目的各个部分,从美术到程序到数据,只有分离的够小,我们才能在获取了最少的信息的情况下用最多的精力去维护和开发。
最后还是一句话,没有最好的项目架构,只有最适合你的项目的项目架构,实践出真知,现在就去整理你的项目吧!