从0开始做个Brotato(3):理解Unity的EC
在前两篇中,我们讲解了策划拆解和重新设计一个游戏的技巧,在这篇,我们就要正式的踏入实现的领域,开始动手把设计变成实现。当然这并不意味着到这里,“游戏策划”的工作就已经完成了,毫无关系了,因为在这个阶段,我们虽然已经不设计游戏玩法的东西了,但是顺着游戏开发,我们还需要设计很多很多的东西,包括且不限于游戏中的数据表要怎么设计——不仅仅是表头,也包括策划配置数据表的IDE设计等(通常我们都把Excel当做唯一的填表工具,这是不对的)。同时,当你更进一步理解一些实现的原理的时候,你才可能更深的去“看破”一些别人游戏的设计,以及在自己设计游戏过程中运用到这些技巧。这不仅仅是一个“和程序员沟通更方便”的问题,而是一个设计思维更逻辑化的事情。所以无论对于程序员还是策划,尤其是有志于做独立、好玩的游戏的人来说,这些知识和技术,都是非常珍贵的。
01 什么是Unity的EC
在开始这个问题之前,我们先要做个类比,来说清楚引擎是什么——我们做游戏就好比盖一幢大楼,游戏策划设计了大楼的图纸之后,我们要开始建筑,建造大楼之前我们需要打下地基,而引擎,更像是外包给某个工程队来专门负责打地基,所以Unity也好、UE也好、Godot以及其他主流引擎也好,他们的作用,更多的是地基。地基的作用不容小视,当然对于我们来说,最重要的还是,地基将限制和约束我们的一些开发方式,因为对于程序来说,这就是一个基础框架,因此我们很多设计和制作,不得不在框架的基础上展开,这也是我们对于引擎要做一个选择的原因,不同的引擎会带来不同的开发习惯、不同问题解决的方案,有各自的优势也有各自的缺陷。
因此,当我们选择了Unity,那么我们就应该最先去了解一下Unity提供的框架。Unity的框架的核心思想,是一种GameObject和Component的架构方式,因为几年前暴雪在GDC讲解了Entity Component System技术之后,许多菜鸟错误的把ECS的特征套用在Unity上,甚至联想到了“Unity也是ECS,只是没有把逻辑代码单独从Component提出来”的谬论,所以在这里为了更符合“市场”的错误理解,我们把这套GameObject Component的框架,称之为“Entity Component”的框架(简称EC),来进一步讲解。
在本篇中,不仅会带大家从更加游戏开发角度来进一步理解Unity的EC,也会为大家解开EC和ECS之间存在的天壤之别。
NO.1 那什么是EC呢?
简单地说,就是Unity认为,游戏的世界是由场景(Scene)所构成的,在场景中有许许多多的运行的单位GameObject,而每个GameObject是什么,是由组成这个GameObject的Components组成的。
NO.2 EC的优缺点
EC本质上是一种数据驱动(data driven)模式,因此在抽象一些事物上,我们可以有更多的“碎片化”或者标准说法叫“组件化”的思维。在(我们接受教育所学到的)OOP思维中,抽象事物是找到其共同点提炼父类的思维:
早期的OOP思想由于只能派生自一个父类,所以当且仅当我们十分明确了全部的、所有的需求之后,才可能动手去把它们抽象好。而实际开发工程中,无论是否是游戏领域(当然,尤其是需求多变的游戏开发领域)都会遇到需求变化和追加问题,以至于我们发现,其实有时候一个类如果能派生自两个甚至更多个父类的时候,会更加合适。
就比如在上图中,我们现在需要一种加上军用武器的卡车类,叫做军用卡车类:
这时候我们发现,我们需要提炼一个“军用武器类”,因为武装直升机和军用卡车都有共有的性质,为了保持OOP的“可维护性”,最佳方案是:
● 提炼一个军用武器类
● 武装直升机派生自直升飞机类和军用武器类
● 军用卡车派生自卡车类和军用武器类
由此,就有了多继承(multiple inheritance)这个思维补丁。尽管如此,当我们的游戏版本不断迭代,然后需求越来越多,变化越来越多的时候,OOP带来的重构压力也就会越来越大。
于是,Unity采用了一种组件式的思路——即把每个功能模块变成一种组件,每一个组件是一个数据,当我们把这个数据加入到一个对象上的时候,对象就拥有了这个组件的功能,组件对外暴露出数据,而组件本身功能和逻辑都由组件自己来控制执行——这是一种基于Data Driven的思路设计出来的组件模式:
使用组件式开发,我们就可以更灵活的通过组件,来组合出各种不同的对象来,一个组件可以用于很多完全不同的情景,这和OOP的宗旨相同,却有了更高的灵活性,因为打破了OOP的继承树状结构。
但也是这样一个模式存在一个缺陷——我们的游戏并不是由一个场景和若干个自我运作的GameObject组成就可以了的。虽然这已经足够让它看起来是个游戏了,但是游戏最终就是“一个系统去修改一堆数据的过程”,因此我们需要有这么一个系统,去整体把控这个数据。比如每个敌人都是一个GameObject,而我们游戏至少得有一个规则,比如“击败多少个敌人获胜”,那么被击败的敌人,这个GameObject光是移除掉是不够的,还得有个统计的地方,我总得有个地方去管理一下我击败了多少敌人,不是敌人挂了通知他去“加分”,就是由他来“统计”挂了多少敌人。这样的数据通信需求,就是EC结构的一个“漏网之鱼”。
于是Unity给出了一个GameManager的补丁,其实我们本可以利用Unity的这套EC的性质,在任何一个GameObject下绑定一个关注其他GameObject的Component,这个Component来管理游戏的数据,但是因为“单例”这个不那么正确的需求(很多人认为游戏同一时间只有一局,所以应该是单例的,但是这个理解显然是错误的,只是不在本文进一步说明了),Unity才对GameObject做了一个特殊处理——它一定是单例的,并且为了让用户知道他们努力做了这个特殊处理,还特地做了自动把GameManager.cs的图标变成小齿轮的功能。
NO.3 UNITY的EC与ECS的根本区别
当我们说到组件式的时候,就很容易被混淆成ECS(Entity Component System),但事实上EC和ECS是有着天壤之别的,并不是简单的“逻辑是否写在Component里面”的问题,很显然在这个问题上,Unity的开发人员对于ECS也并不是那么理解,他们也认为自己和ECS的区别只是在内存分配的性能上,所以推出了DOTS,但是DOTS并不符合ECS的模式。这里我们就会简单的说明一下EC和ECS的区别,以便于在后续的开发中,大家可以安心的丢掉ECS思想,专注的使用Unity的这套框架(Unity无论是否是DOTS都是EC模式的,与ECS根本不沾边)。
EC的核心思路是“我是什么”,这其实本质上还是一个OOP的思维——更重视的是事物本身是什么,比如我们用EC的思维去看游戏中的一辆汽车,他可以有这些组件:
● Transform:这个就是汽车在Scene中的坐标等信息,现在transform在Unity的GameObject中已经是一个必要的组件了,毕竟GameObject一定是放在Scene上的东西,所以必定需要一个transform。
● Renderer:汽车的外观,如果汽车需要渲染出来(这是自然需要的),就需要至少有一个类型的Renderer,比如2D游戏的话,就得有个SpriteRenderer。
● 汽车组件:这是一个“证明这是汽车”的组件,或者说是使用Unity开发的一种方法,这个组件等于是汽车这个东西的一个中枢系统,在后文会详细提到。
● 移动组件:控制Transform中的Position和Rotation的组件,这个组件不仅仅会被用于汽车,确切地说,它会用于游戏中所有需要移动的东西。他的工作是负责收集并且筛选来自各个系统、各个其他组件的移动请求,最后整合出一个本帧移动,并由这个本帧移动的值来改变GameObject.transform的值。
● 动力组件:这个组件是专门用来提供给移动组件一部分移动信息的,与他功能相似但作用不同的是“被推动”组件,假如我们的游戏中允许部分汽车被其他东西推动,那么这个推动的移动就不应该在动力组件中,因为并不是每一台汽车都会被推动的,但是每一台汽车的都会有发动机驱动的规则。
到此,我们可以看出,尽管我们做到了每个组件都有自己的逻辑和功能,加上每个组件之后,就形成了汽车,并且添加删除组件可以改变这个东西,以至于他不再是汽车。但是我们做这个事情的过程,都是围绕着“我们要一辆汽车,所以我们需要Pick这些组件”而展开的,最终我们做的事情都是明确了“我是谁”(“我是一辆汽车”)。
但是在ECS的抽象中则截然不同,在ECS的世界里面,没有具体的汽车这个东西,只有事情。还是用汽车来举例:
在我们人类的心目中,有一个Entity代表了汽车。但是这里请注意,这个entity对于计算机来说,它仅仅只是一个“数据捆绑包”,他的作用是证明哪些Component是一组的,别无他意,所以“这是一辆汽车”的概念,仅存在于人心,并且是一个完全可以丢弃掉的概念——他是不是汽车根本不重要,并且此时他可能只有一个RenderComponent和一个TransformComponent,因为有一个RenderSystem,这个RenderSystem关心的是RenderComponent和TransformComponent,他根据这两个Component在屏幕上对应位置绘制对应物品,因此他绘制出来的是一架飞机还是一个蛋糕或者是一辆汽车,这都是有可能的,因此你没法断定这个被绘制的entity对我们人类来说是什么。
此时,当我们要让这两“汽车”或者“蛋糕”或者“飞机”移动的话,我们需要给他添加一个MoveComponent,这个MoveComponent的作用是提供出Transform变化的信息,而有一个MoveSystem,他关心的是MoveComponent和TransformComponent,他的工作是将MoveComponent中的移动信息作为依据,为TransformComponent改写数据。当Transform中的数据变化的时候,RenderSystem中绘制的位置也就发生了变化,于是产生了我们肉眼看到的“移动”这个现象,但实际上即使没有Render,他也移动了。
重点了来了!当我们需要汽车停下的时候,并不是说暂停掉这个MoveComponent,或者确保他提供的是一个“不移动”的信息给MoveSystem,而是直接干掉这个MoveComponent,因为没有了MoveComponent,因此MoveSystem不在会捕捉到这个entity,也就不会导致这个Entity下的TransformComponent数据因此发生变化,这就产生了“不再移动”,或者说“从来不会移动”的效果。
因此,ECS中,根据实际变化拆卸Component,让Entity“临时”被某些系统关注,才是核心,而这个核心背后的逻辑是——“我有什么”,他不是“因为我是人,所以我有眼睛,所以我能看到的东西”,而是“因为我要看到东西了,所以我装上了眼睛,不需要了就丢掉眼睛,我从来不是任何东西,所以我是不是一个有眼睛的东西,这根本不重要”——因果完全不同。要做一个危险的类比就是:当我要移动的时候,我才有了“腿”才会“走路”,当我没不需要移动的时候,我可以“砍断我的腿”,而不是暂停腿的工作——是腿彻底不要了(remove add),不是留着不用(disable enable)。因为“我有了这个”所以“我这时候是这个”,有点佛学的“缘来是我”的意思,但是ECS就是这样的逻辑思维方式——每个System关心的是一件事情,而不是说它起到什么功能,因此他只关心这件事情需要的数据,你这个entity恰好有这个数据,才会被他操作,但是他操作的也不是entity本身,而是因为entity打包了他关心的数据ABC,他才知道哪个A的信息是为哪个B和C提供的,至于entity是什么,是飞机、还是汽车或者是蛋糕,这都无所谓,她不需要知道是什么。
到此,我们已经很明显的看到了——在ECS中,相比EC,他的System并不是简单的“只是新开一个文件把代码转移进去”,而是从根基上抽象的不同。也正是因为这样的不同,所以我们在抽象EC的时候,是跟抽象ECS截然不同的。
02 EC使用中如何对事物进行抽象
NO.1 构思过程
在一个游戏中会有许许多多的元素,比如角色、子弹等,当我们分析出有什么元素之后,就要对它进行进一步的分析设计,来把它用Unity的EC“表达”出来,这个思路的过程可以是这样的:
首先是确定“我是什么”
这是典型的OOP思考方式的第一步,也是EC模式的思考方式的第一步,就是确定游戏中有些什么,每个东西具体是什么,比如“角色”、“子弹”等等。在这个过程中,我们可能第一时间思考的东西需要进一步的思考,才能够得出一个相对更加准确地理解。
明确了“我是什么”之后,可以建立一个“中枢Component”,比如CharacterComponent,加上这个Component的GameObject就是一个角色了。比如绝大多数游戏中的“敌人”,那么“敌人”和“玩家角色”到底有什么异同点呢?仅仅只是“玩家角色”接受来自玩家的操作输入的指令,而“敌人”接受的是来自各自AI的操作。所以“敌人”和“玩家角色”在“我是什么”这个问题上,并没有什么区别,“我”都是一个角色对象,只是某些数据不同,这些数据可能是一个简单的数值属性,也可能是一些Component有所不同,但是“中枢”是一样的。
那我们为什么需要这样一个“中枢Component”呢?这不仅是一个东西的“身份证”,而且还是一个管理其他Component的Component。之所以我们需要这样一个“管理其他Component的Component”,是因为:
首先这是一个“中枢”,代表了“我”是什么,所以他可以是最“小弟”的那个Component——它可以依赖于其他所有的Component,这种依赖未必是需要RequireComponent的,因为某些Component在逻辑中也是允许拿不到的(引用值为null)。我们通过这个Component来调用其他Component提供的接口,然后同时自己也暴露出接口给GameManager等系统去调用,真正的达到了“中枢”的目的。
其次是尽可能逃避了GetComponent的操作,因为我们在实际开发中,比如GameManager要对一个GameObject进行处理,我们很可能会需要同时处理一个GameObject下的某个Component,就会用到GetComponent,而GetComponent开销又不低。因此我们利用EC的性质(必须明确“我是什么”的这条性质),干脆暴露出这么一个中枢Component来,中枢Component中记录了所有引用(这些引用还可以是private的),并且对他们进行操作。
然后是判断“我”需要的“零件”
归纳中枢Component的核心技巧在于理清拆解“我”的构成和方法,然后就是判断“我”还需要一些什么来辅助“我”的工作。
这里第一判断的就是我需要一些什么功能,这些功能是否应该是独立的, 比如移动这个功能,它的作用是整合所有的移动请求,然后筛选出本帧的移动,最后改写transform中的值。这个功能就是一个“最简单”“最基本”的功能,并且因为是这样抽象的,所以它即可以被用于角色、也可以被用于子弹,只要能移动的都可以用上它。
推理出一个功能是否是“最简单”的,最重要的一点就是如何把一件事情说清楚了,首先,使用人类的自然语言,完整阐述功能流程,然后去掉其中的概括词,把每个概括词背后的东西说明白了,比如“让角色走起来”,什么是“走起来”,本质就是“播放走路动画”+“选定一个移动目标”+“坐标变化”,而“选定一个移动目标”就是确定了一种移动的方式,比如“每帧向前移动xxx米”,然后“前”是什么?就是“transform.rotation”,这样一来,就是“把话说清楚了”,就能得到一个“最简单”的“零件”了。
当这个“零件”被抽象出来以后,我们就要思考这个零件的工作具体内容。首先是这个零件,是否是一个“自运行的”——即它在FixedUpdate或者Update中需要做一些什么?并不是每一个Component都真的需要有FixedUpdate或者Update的。
比如我们上面提到的移动Component,它的工作室“获得一个本帧的移动目标,然后改变Transform的值”,因为这个移动是逻辑层的移动,游戏规则级别的,而非画面表现级别,画面表现依赖于逻辑数据的变化,所以这个Component中的是FixedUpdate而不是Update,FixedUpdate就是“每帧”,干的事情就是根据一个移动请求列表(因为同时会有很多很多“源”发出移动请求),筛选出最后的移动结果,然后改写Transform。而他要暴露出来的接口,就是“发出移动请求”,因为他需要这个数据来支持他的工作,而这个数据是别人传递给他的。
在我们设计每一个“零件”的时候,要注意一点,就是“零件”之间要尽可能避免依赖,尤其是互相依赖的耦合现象。比如上述的移动Component,就会需要依赖Transform,但是幸好Transform不依赖于移动。如果我们一个Component要做的事情依赖的数据可能来自于另外一个Component,那也请把它暴露出一个接口来,让中枢Component来“协调”。
最后我们要分析这个“零件”真的是始终需要的吗。比如我们要给角色一个死亡特效,在角色死亡后对角色进行一些表现处理用(比如逐渐雾化后消失等),这个效果可以写一个Component,但是可以并不第一时间加上给角色, 而是角色挂了之后AddComponent。尽管EC的精神上并不提倡动态AddComponent和Destroy,但是少量情况下依然还是允许这么用的,比如这个死亡特效。
最后就是数据源问题
这里最重要的一点就是GameObject中的Components的数据都是怎么来的,我们将其分为“运行时数据”和“填表数据”,“运行时数据”顾名思义就是完全由游戏运行时动态运算出来的数据,而“填表数据”未必不会在运行时改变,只是他的初始化的时候需要人为赋值,通常项目里面我们会把它做一个Excel表然后转json读进来,但实际上我们也可以利用Unity这个编辑器本身的特性,让策划在Inspector面板上编辑。
因为我们的大多GameObject都会做成Prefab,比如角色、子弹等,最后由程序逻辑在运行时实例化出来,因此在Prefab里修改Component中的值,就相当于在Excel表中编辑(只是个人习惯可能不同)。而要这么使用,我们也得首先抽象好一个数据到底是不是“填表数据”,对于“填表数据”,我们在代码里给的是直接的public,而运行时数据中那些公开数据,则应该使用getter setter来使其不出现在Inspector面板。
NO.2 以BROTATO的敌人为例演算一次
我们现在就以自定义的一个Brotato的怪物为例,做一次设计。首先我们假设他是一个Brotato中最初级怪物的“换皮”,这也是为了说的简单明确点,这个怪物在Brotato是这样的:
而在我们这里,他是这样的:
值得注意的是:我们这个怪物的Prefab里面有2层GameObject,第一层是这个怪物本身,而第二层才是他的图片。这是一个技巧,这样做的好处包括且不限于:可以轻松的修改怪物视觉上的锚点,可以随着游戏进行时随意改变它画面上的位置(比如某个动作要带有root motion等)而不那么容易影响逻辑坐标的运算(逻辑坐标是外层GameObject的坐标)。
有了GameObject之后,我们就要看有哪些Component了:
角色(中枢)
证明这是一个角色,怪物本身也是一个角色。中枢Component中暴露的接口全都取决于怪物能干什么,他依赖于所有其他的Component,并且提供出对应接口给GameManager等调用。
ANIMATOR与动画筛选控制器
通常我们总觉得有Animator这个unity提供的控件就足够了,但是Animator仅仅只是一个“动画切换状态机”,他的功能是你可以给Aniator设置状态(比如调用他的SetTrigger等接口),然后他就根据现在的状态,跳转到那个状态需要播放的动作,他的核心贡献仅仅只是做了动画融合以及跳转数据的归档。
而我们真正在游戏中要控制角色做什么动作,单纯的依靠Animator是不够的,除非做的是一个纯粹的“电影表演模式”——比如纯回合制游戏中,我们选好指令之后,发生的事情都在一瞬间算好了,得出一个动画序列,然后根据这个序列完全不会“出错”的去播放动画,这时候仅用Animator凑合着还能跑。而实际上因为每一帧我们都可能有无数个系统要求某个角色改变动画,比如玩家要求角色做走路动作、受伤系统又要求角色做受伤动作这两个动作我究竟该做谁呢?也许有人说在animator中连线练得好就解决了,但是有没有这样一种可能——在某个状态下走路动作优先级会高于受伤,而不在这个状态下则是受伤高于走路?尽管这样复杂的逻辑依然可以用animator去做,但是为了人为能更好的设计和填写,最聪明的做法就是先有一个动画筛选器,他根据我们设定的逻辑来筛选出当前应该播放的动画(或者说谁是要给Animator设置的值),然后去设置Animator。
所以这里除了Animator(交给美术制作纯粹的动画之间的关联)之外,我们还需要一个动画筛选器(交给策划程序去安排逻辑)。
移动控制器与AI行为
移动控制器本文上述已经说明了很多了,而AI行为是指什么呢?在Brotato中,我们可以发现AI的行为相对是固定的,比如我们选择的怪,他的AI就是跟着玩家走,直到对其玩家,因此这个AI行为的FixedUpdate只做一件事情,就是判断玩家在“我”的什么方位,然后告诉MoveComponent移动过去,MoveComponent则计算出最终落点,去改变Transform。
攻击碰撞、受击碰撞、角色属性
因为这个角色本身可以像子弹一样攻击敌人(也就是玩家角色),所以他跟子弹一样需要一个AttackHitBox,这就是攻击碰撞组件,有这个组件的单位就可以设置以这个角色的Transform为锚点的一系列范围作为攻击碰撞范围,那么在Brotato中,其实只需要一个半径就可以了,因为我们都把它们做成圆形碰撞。除了碰撞范围还需要一些类似伤害力、攻击间隔、最大碰撞次数的信息。
受击碰撞则是一个角色可以受击的范围,如果没有这个组件,这个角色就无法被碰撞到,比如Brotato中玩家造出来的塔就是没有受击碰撞组件的,但是作为一个敌人,可以被子弹碰撞,那么他一定就需要一个受击碰撞组件。
角色属性并不是一个Component,他也不会直接出现在中枢里。我们不难发现,有攻击碰撞才有攻击这个事情,所以攻击力相关的属性都可以在攻击碰撞组件中,这个组件中的攻击力数据来源可以是多样化的,在实例化的时候根据算法产生的,要体现每一个怪攻击力不同的关键还是他们的攻击碰撞这个组件的某些暴露的值。同理HP等数据也应该在受击碰撞的组件中,而移动力等等则分配到对应的移动组件中去。
掉落
如果怪物会有掉落,比如掉落金币、掉落宝箱,就可以交给这个Component来作,这个Component负责游戏中所有掉落物的设计。在Brotato中,只有回血物品掉落(几率)、宝箱掉落(几率)和金币掉落(个数)的设计。
属性生成
既然我们上面说到了属性都分配到了各个组件了,是不是我们要为每个怪在各个组件去填写数据呢?其实不必那么麻烦,我们可以提供一个属性生成组件,将所有属性填写在这个组件,而这个组件的工作,就可以是去为其他组件设置值,尽管他会依赖于很多其他组件,有一点“中枢”的意思,但是这也不是一个不好的设计(当然,我们完全可以暴露接口让中枢去调用得出数据,然后负责赋值就可以逃避这个依赖关系了,这也是好的做法)。外加上Brotato这样的Roguelike也需要根据关卡数等参数去调整数值(从“填表数值”到“运行时数值”之间会有个公式转换),就更需要有这样一个Component去做事儿了。
03 接着就开始动手
当我们完成了对游戏的每一个元素的构思,这部分工作就完成了,接下来就是精确到每一个Component去实现对应的内容,以及去开发一些Manager(比如GameManager)的功能,下一篇我们将会进一步讲解一些Brotato的元素具体的设计制作。
独立小游戏Brotato制作系列
从0开始做个Brotato(1):拆解游戏篇
从0开始做个Brotato(2):避免开发坑点