Unity ECS实例:制作俯视角射击游戏!

(本文作者 @对马骑马使用炎拳 )
大家好,我是炎拳。
这次我们来使用Unity ECS系统制作一个俯视角度的射击游戏。虽然现在网上有不少ECS的资料和项目,但是制作时又和实际游戏需求有较大差距。在制作这个小游戏的过程中我遇到了很多ECS特有的问题,也给出了还可以的解决方案,相信能通过实例让大家了解到ECS的优缺点是什么。
(文章不会再解释Unity DOTS的一些基本概念,感兴趣的朋友可以查阅文档了解)。
本游戏具体玩法如下:
1:完全使用键盘控制,WASD键控制角色方向移动,j 键控制射击。(这样做主要为了简化游戏输入逻辑)
2:玩家有手枪和霰弹枪两种武器形态,按Q切换。
3:当敌人低于一定量,会在玩家一定距离周围生成敌人。敌人会朝玩家移动并射击玩家。
4:玩家和敌人都有生命值,中弹后生命减少,减为0的时候死亡
这里放下Unity和相关Package版本,以免误导后来者:
Unity 版本:Unity2020.3.3f1,Universal Render Pipeline
Hybrid Renderer: Version 0.11.0-preview.44
Unity Physics: Version 0.6.0-preview.3
Jobs: Version 0.8.0-preview.23
Entities: 0.17.0-preview.41
1:准备工作
这里不过多赘述,可以移步我的上一篇文章,前四步都是一样的:

2.创建主角
先简单搭建场景,再创建主角。
首先建一个平面,扔上贴图,再建个圆圆胖胖的主角,添加物理组件Physics Shape 和Physics Body:

Physic Shape的碰撞框同样可以在场景中进行编辑,你也可以点击Fit to Enabled Meshs来直接适配:

以及实体转换组件,Convert To Entity:

我们需要其中主角能被敌人的子弹打中并获取碰撞事件,所以点击Collision Response,选择Raise Trigger Event ( 开启触发器事件),并点击PhysicBody的Motion Type,选择Kinematic :

3:主角移动和摄像机跟随
首先为主角创建一个Component,包含初始速度:
将组件挂到主角身上,speed设为10,再单独创建一个System,控制主角移动:
相机不支持转换为Entity,所以我们还是用老办法做一个跟随脚本,通过查找包含CharacterComponent的Entity,获取其Translation,得到主角位置,进行跟随,代码如下:
最后给主角手里整把枪,OK,现在主角已经能跑了:

4:实现敌人角色
敌人造型和玩家基本一致,由于玩家需要随时找到并攻击玩家角色,所以需要在定义它的Componnet 中存一个玩家Entity的引用:
首先我们需要在主角身旁一定范围外生成这些这些敌人,方便起见,我们可以在场景中创建一个管理类,存一个已经转换成实体的的敌人预制体,每次生成的时候直接按照这个模版生成即可,代码如下:
EnemySystem负责控制敌人追踪主角,并在敌人数量少于一定量时生成新的敌人:
点击运行,敌人也生成出来并开始工作了:

5:子弹,死亡,机器人
接下来我们要定义武器和子弹。虽然Convert to Entity会把面板的物体的子物体也转换为Entity,并在Entity Debugger中可以看到,但目前GameObject 方便的父子关系还不能在Unity ECS中使用,所以我们需要先记录枪口的位置。
首先定义武器:
接着定义子弹组件,制作子弹预制体的流程和上文一样,这里就不赘述了:
再定义一个作删除标签功能的组件:DeleteTag,为了尽量避免频繁的结构性变化(增删组件等),我们需要在可以被删除的物体的预制件上添加这个组件,并将其lifeTime设置为1 :
这样的话,我们就可以定下规则,当物体身上DeleteTag组件的lifeTime<=0时,系统会将其删除:
子弹的生命会不断减少,所以BulletSystem中需要自行对lifeTime 做减法:
WeaponSystem,不同枪械的子弹生命周期也不同,手枪子弹为1s,霰弹枪0.5f:
在主角和敌人身上分别挂上Weapon组件,主角便可以使用两种武器了,敌人也能自动发射子弹了:

接下来就要用到ECS中新版的物理组件了,我们先在组件中设置子弹和敌人的碰撞层级,保证同类物体不会触发碰撞事件,只有子弹和敌人碰撞会触发事件:

这里搜索资料后发现比较简单的做法是去定义一个Job继承ITriggerEventsJob接口,去接收事件,但由于Job中是并行处理数据,遇到了新的问题,由于代码比较长,上部分伪代码来说明:
图中代码的意思大概是这样:当接收到世界中发生的碰撞事件后,首先Job会判断碰撞物属于哪个ComponentGroup,如果Enemy,扣一滴血;包含Bullet,则直接销毁子弹实体,但实际上写完运行确遇到了这样的问题:

可以很明显的看到,第二次子弹打中物体时,触发了两次碰撞事件。造成这个原因是因为:在Unity ECS系统中,删除子弹实体的操作并不是立即执行的,对于使用EntityCommandBuffer执行删除操作的时序问题,有疑惑的小伙伴可以看这篇文章:
https://zhuanlan.zhihu.com/p/328218005
删除子弹实体的操作并非立即执行,同时删除子弹实体的操作和TriggerJob也是并行的(不在同一线程,两者先后顺序不确定),所以可能会出现图中的状况(箭头长度代表时间长度):

为了解决这个问题,我首先的思路是为子弹增加一个bool值记录它的状态,如果接触到敌人,再次触发碰撞事件时会直接返回,代码如下:
结果连续触发碰撞事件时,直接报错The entity does not exist,bullet Group 中并不包含这个引发碰撞的子弹:

造成这个的原因也比较好猜,当我们执行删除子弹实体的代码时,子弹实体并不会立即删除,而是要等到EntityCommandBufferSystem回放命令时统一调度,所以已经子弹可能已经被系统标记为空,自然不在BulletGroup中了,自然也找不到该实体。
解决问题思路还有很多,我们当然可以在代码中修改Collision Filter,或是关闭子弹的碰撞事件来达成效果。。但实际上这两种操作都非常麻烦,目前Dots还没有这么的自由。
在尝试过上述做法后,我所想到的一个简单的思路:在发生碰撞时,将子弹挪到一个看不见位置去,这样就不会造成多次触发碰撞事件;
同时每个子弹都有自己的生命周期,所以也可能发生子弹生命到了,被标记删除,但又刚好触发碰撞的情况。为了避免这样的冲突,我们需要在每个Group中都对子弹进行HasComponent判定,子弹删除代码如下:
最后再做个敌人被击退的效果,给敌人添加BeatBack组件,每次被子弹击中时,敌人都会获得一个持续衰减的速度,被连续击中时,获得的加速度也会逐渐衰减:
BeatBackSystem :

完整TriggerEventSystem代码如下:
6:粒子与音效
目前Particle System 也能正常的转换为Entity ,但和physic shape等组件一样,它们还并没有那么方便使用,所以这里采用了和子弹组件一样的策略,写了一个粒子生命周期的组件,在单独的系统去处理,也不过多赘述了。
至于声音,没必要转换为实体,正常使用就好了~
最后来个全家福,1000个大汉围攻主角(最初版本,除了物理碰撞基本没跑多线程,但还是不卡,就是玩):

工程地址:
https://github.com/ydwj/Unity-ECS-FpsGame
ps:工程里面下的商店的免费素材有点大~
欢迎加入游戏开发群欢乐搅基:1082025059
对游戏开发感兴趣的童鞋可戳这里进一步了解:http://www.levelpp.com/
我们的公众号:“皮皮关"
B站:“皮皮关做游戏”