一、牢骚(可忽略)
写博客还真是挺难坚持的,从上次写到今天又过了1年多。在这段时间里虽然没有写博客,但是确实学了很多知识,自己也有所提高。比起自己前面几年的迷茫和荒废,终于算是有了一个清晰的目标。最最重要的还是养成了一个比较好的工作和生活习惯。今天主要是想总结一下,看看自己这9年多主要都做了什么。
二、战斗相关功能(偏MMOARPG)
(一)战斗包含哪些模块
1、玩家控制部分
这是玩家最直接交互的部分,也是所谓的好手感的关键部分。控制有以下几个方面。
(1)移动控制: 最基础的部分,有两点要注意。第一,输入获取逻辑最好放在Unity生命周期Update的最前面,这样可以使这次输入产生的结果能在当前帧生效。第二,从获取输入,到将输入转化为具体逻辑,再到表现的开始,一定要在同一帧完成。举个实际的例子,某天策划总是反馈说操作不灵,尤其是低帧率的情况下。后来调试发现,从输入到最终的移动经历了几个模块(UI-按键解析-自动移动-状态切换),有的模块处理不那么严谨,再下一帧才往外输出结果,然后就造成了3帧之后才对玩家输入有响应。
(2)技能控制: 这里一般涉及多种释放方式,包括常规的单UI技能、单按键的连续技能、类似农药的技能释放规则(增加了技能的前置逻辑:按下时进行范围选择、抬起释放技能)。这里要注意是,UI数据和技能数据的解耦,不然换UI的时候麻烦就大了。
(3)摄像机控制: Unity有个不错的虚拟相机插件,需求不太复杂可以直接用。如果特殊需求多,还是自己写可控性更强。除了玩家直接控制之外,自己最好做一层管理,方便在多个相机切换。
(4)技能压栈处理: 这个需求是为了增加玩家技能释放感觉而加的。如果你玩过97或者98格斗系列应该会有深刻的感受。但就我个人来讲,觉得在仅仅是带动作元素的游戏里做这个功能没太大必要,反而增加了代码的复杂度,收益并不明显。
2、战斗表现部分:
(1)动画管理
a. 动画打组: Unity提供了AnimatorController静态打组,当然也可以自己管理动画组。但AnimatorController提供了IK和上下半身的特性,所以如果需要这些额外特性,不想自己从写代码,使用AniamtorController是比较方便的。为了策划方便使用,写了一个AnimatorController的自动生成工具。
b. 动画组合: 解释一下这个词表示什么意思。有时候我们会遇到一个情况,策划要求有一个技能释放2秒,需要有起手+循环+结束动画。如果动画层只支持单个动画的播放,那么我们就需要在技能的表现逻辑里去控制动画的依次播放。而更好的方式肯定还是放在动画层比较好,技能逻辑只管总时间就好。于是就把三个动画组合到一个动画ID里,其他系统通过调用ID,和传入总时间进行动画播放。
c. 动画替换: 这个功能主要是方便有些剧情或者特殊逻辑需要改变玩家的动画。比如把原本的跑步动画改为捂着肚子跑。目前是通过替换动画ID组的方式实现。AnimatorController本身有层的概念,也可以实现这个功能,但是目前项目里用层级做了上下半身动画的需求,所以只能在逻辑上做替换功能。
d.Playable: 当需要在游戏运行过程中动态的给模型添加动画,并且已经使用了AnimatorController作为动画播放媒介的时候,只能采用Playable内含多个AnimatorContoller的方式进行扩展。目前只是使用Playable实现了功能,还没有对它有更深入的了解(鄙视自己!!!)。
e. 动画RootMotion: Unity的Animator里可以直接应用Root Motion,这种应用更适合单机游戏。如果是网游涉及到同步的,最好是不直接使用。通过导出动画的位移和旋转数据,在游戏运行时根据具体情况对位移数据进行缩放,才可以达到同步的目的,并且这种方式不会损失动画的节奏感。如果服务器效率允许,还可以将这些数据移植到服务器使用。
(2)与动画相关的表现效果
动画过程中产生粒子特效、声音、镜头特效等,使整个表现更丰满。 这里用到了两个编辑器,一个是与动画直接相关的编辑器,目的是在动画的某个时刻产生事件,执行对应的逻辑。另一个是和动画无关的效果逻辑编辑器,用于除动画外的场景,比如子弹爆炸。分成两个编辑器是因为和动画相关的表现,美术使用的更多。而与动画无关的表现更多的用在逻辑触发上,策划使用的更多。而策划和美术对svn解决冲突的操作非常不熟练,极易造成错误,所以才分成两个编辑器两个文件。
(3)提升打击感杂项
一般带动作元素的游戏,总是要提到打击感这个词。既然是感觉那么每个人的评价就会非常的主观。我个人认为影响打击感的因素按重要程度排序为,音效、动画、特效。作为程序能对打击感做出贡献的有几个方面,技能动作定帧、受击定帧、受击特效方向,受击动画方向,相机震动,相机模糊等。有一个尽量优化的点是保证攻击和受击动画的匹配度,但这点对网游来说确实没有特别完美的处理方式。
3、客户端底层通用部分
(1)资源异步问题
资源加载基本都采用异步方式,一旦处理不好经常会导致上层逻辑混乱。先举一个简单的例子,比如人物模型和武器模型共同组成了一个角色,有几种方式处理这种情况。第一种方式:这两个模型同时申请,那么谁先返回上层是不确定的,那么就要写一些额外的代码来处理武器先返回的情况。第二种方式:申请的时候就保证顺序,先申请人物模型,返回后再申请武器模型。这么写勉强算是逻辑正确并且也有不错的可读性,但只适用于这种简单情况。比如出现了一个复杂点的情况,当人物模型已返回且正在申请武器模型的时候,由于玩家的某个操作,导致人物模型换了一个。这个时候武器模型和第二个人物模型谁先返回又不确定了。所以要从根本上解决异步的问题,还得从结构和设计上下手。
我们都知道代理模式,用代理可以将资源封装为不同类型的上层逻辑对象(人物、武器、粒子特效等等),将异步加载的操作封装在代理内部,外部的逻辑可以用同步的方式去写,大大减少了出错的概率。有了代理确实稳了很多,但是有一个问题还需要考虑清楚,何时将逻辑数据更新到资源上去呢?首先想到的肯定是Unity的Update和LateUpdate,在Update里更新逻辑数据,在LateUpdate将数据更新到资源。这样做其实问题不算很大,但如果吹毛求疵一点,我们会发现,动画的更新在LateUpdate之前,也就是说,如果我们在LateUpdate里播放动画,那么在下一帧才能看到效果。所以,我觉得最好的办法,还是在Update里自己分几个有先后次序的Tick,然后在自己的Tick里分情况处理数据。
(2)实例的资源缓存
为了避免频繁的资源加载和内存申请而造成卡顿,很多项目都会采用缓存的方式。与美术资源相关的一般分三层来做,AB缓存、Assets缓存和GameObject实例缓存。与程序相关的自定义类实例缓存。
就我个人而言,我只负责了GameObject实例缓存这块儿。在我实际开发的时候,对这层又细分了两层,偏底层的是通用的GameObject管理层,偏上层的是具体管理器层。
通用的GameObject管理层目的是想同一个GameObject能被不同的上层系统共用。这层目前主要控制的三个事情。第一个,是每帧最多能实例化的GameObject个数。第二个是控制实例化的优先级,同时也会将这个优先级传递给Assets层,控制资源加载的优先级。第三个如果一段时间内某个实例一直没有被使用则通知Assets层,可以卸载该资源。
具体管理器层的目的主要是在拿到GameObject后,为其添加必要的组件。以特效管理器为例,当特效管理器拿到GameObject后,会调用其上已经有的必要组件的接口,如果这个特效是个声源,那么还要额外挂上SphereCollider和AkGameObj组件。当归还此特效的时候,就需要在管理器层先将SphereCollider和AkGameObj组件去掉,再还给通用缓存池。
(3)寻路网格和场景碰撞相关内容
考虑到服务器效率问题,寻路采用的传统的A*寻路算法,客户端也使用了相同的数据,并且客户端还部分的使用了Unity自带的物理碰撞效果。
Unity的物理和碰撞检测的消耗还是很大的,所以最好不要所有角色都使用,但也没必要全去掉,毕竟Unity自身的碰撞模拟要比自己写表现上会更符合物理规律。常规游戏对本地角色和大体型Boss的碰撞比较关心,这两种角色可以开启。其他角色建议都不使用物理和碰撞,改用射线检测来修正位置。
对于本地玩家角色来讲,网格数据可以限制可到达区域,场景本身也可以限制可达到区域。这就需要一个规则来处理这两种情况。目前采用的方式是,XZ坐标谁先产生碰撞信息,就使用谁的数据。Y坐标在一定误差范围内优先使用场景高度。XZ坐标如果是先碰到场景物体,那么Unity会进行物理运算,玩家角色会沿着移动方向滑动。但是如果先到达了网格的碰撞区域,那么这里就需要自己写算法来模拟物理滑动了。
我想了一个效果还可接受的算法。大概想法是这样的:首先在游戏最开始需要记录一个玩家的有效位置,然后每帧的最后逻辑阶段(渲染前)对当前位置和上一个有效位置的Delta值进行检测和修正。网格数据XZ方向是跟坐标轴XZ重合的,那么只需要判断delta值在X和Z分量上是否可行走即可。如果不可行走,就去掉对应分量的数值。这样在另一个分量上如果还有数值,就可以在该分量上保留滑动的效果。最后将玩家角色的Y分量设置为场景地面高度(必须在误差范围内),并将有效位置的XZ更新为新值,Y更新为网格高度。
4、特色功能及系统:载具、射击IK、上下半身动画。
(1)载具
刚听策划提到这个系统的时候,还以为要做个常规的MMORPG的坐骑。后来看了文档才发现这个载具能战斗,能DIY技能,后期还可能做多人战斗,顿时感觉档次不一样了。由于载具承载了很多战斗和交互功能,所以在服务器必然是一个场景内对象。结构设计上并没有多难,使用OO的思想把载具拆成小对象组装起来就可以了。
这里还能拿出来说的是兴趣列表(AOI)的同步。AOI就是玩家能够在客户端看到的角色的列表,当时采用的是传统的9宫格方式,找到离自己最近的玩家和NPC。AOI的目的是减少玩家的聚集对服务器和客户端性能造成的压力。AOI会因为载具的出现而产生一些问题。比如,在服务器一个玩家的AOI包括10个其他玩家+10个NPC,也就是说客户端最多能看到除自己外的10个玩家+10个NPC。载具在服务器是按NPC算的,做在载具上的玩家还是按玩家算,如果不改变AOI的规则,那么这10个玩家和NPC都是离自己最近的。此时会出现一种情况,玩家看到了其他10个玩家,同时看到了稍远一点的两个载具,但这两个载具上面没有玩家。这是因为最近的10其他个玩家已经填满了AOI,稍远一点的载具是NPC所以能看到。这种表现很奇怪,因此要想办法避免。当时采用的解决方式是,由客户端主动发现,如果载具上没有玩家就向服务器申请对应玩家的数据,保证客户端只要能看到载具就一定能看到上面的玩家。服务器则在广播数据包的时候判断如果发现是载具,则同时广播给上面的所有玩家。现在想想实际上这个解决方式并不是很好。这种方式无形的增加了可见列表的数量,本应该限制为20个角色,现在肯定要超了,效率问题再次出现。我觉得比较好的方式是控制总数量,按优先级排序,如果有一个玩家在载具上,则将载具和其上的所有玩家都加入列表。如果超出20则停止刷新兴趣列表。
(2)射击IK
其实这里想重点说的并不是射击IK的实现方式。因为我做的是偏向MMORPG的游戏,所以只是有很弱化的射击IK。我重点想说的是2019年Unity一直在推的ECS。我使用IAnimationJob配合PlayableGraph实现对骨骼的控制,来近似模拟射击IK。关于ECS的介绍网上很多,由于项目刚开始没有按着这个框架搭,所以这里只是在中期对新技术的一次尝试。(再次鄙视一下自己,没有深入的看ECS!!!)
(3)上下半身动画
这里踩的坑可以用“桃花潭水深千尺”来形容。我使用的上下半身方案其实就是Animator的动画层+Avatar遮罩。想法很简单,感觉做起来应该也很简单,但事与愿违。unity骨架分为两种,一种是Humanoid,一种是Generic。常规只要是人形的我们都会使用Humanoid,但是问题来了。Humanoid的蒙皮骨架是要注册在Avatar信息里的,不在里面的骨骼是不更新蒙皮信息的,只更新位置和朝向。当我使用Animator的Avatar遮罩时,基础层动画和上层动画的分割点是pelvis骨骼,也就是说,这根骨骼的旋转和位移信息直接影响上层动画中的Pelvis所有子骨骼。这里就会有一个制作规范,pelvis骨骼必须是稳定的,并且两层的动画都基于这根骨骼做动画才能在拼接好的时候看起来协调。而美术在制作的时候,pelvis骨是有蒙皮信息的,一旦固定了这根骨骼会使角色的腰部看起来很僵硬。经过这次采坑后我觉得如果需求特别复杂,还是用Generic骨架更好。
(二)战斗难点和核心
1、纯3D游戏的状态机同步及数据校验
从设计上讲并不难,使用状态模式实现大体结构,但是细节确实很多。状态的同步主要依靠进入状态的参数和过程中的参数两种。进入参数不用说肯定要同步,而过程中的参数同步策略需要根据状态类型区分。状态有有限的持续时间,过程中的逻辑可以用进入状态的参数演算出来的,就不用同步过程参数。状态持续时间不确定,且状态内有数据变化的需要同步过程参数。目前来讲移动是需要同步过程参数的,其他状态都不需要。比如击退,击飞这些都可以用参数描述曲线轨迹,CS端各自执行就可以。但为了避免误差的累计,需要利用每次进入状态的数据来同步终点位置。
关于数据校验,因为有些状态是有位移的,所以这些位移是否合法需要场景校验。当时我做的游戏是纯3D的,并且客户端使用的是引擎自带的碰撞库,所以应用在服务器会有效率问题。如果服务器单独走另一套碰撞库会出现CS两端不统一的情况,可能会有隐患。于是就取了个巧,从客户端随机选择一个同场景的玩家进行碰撞校验。这样做的弊端也比较明显,就是客户端作弊。但是实际上线运营后发现还可以,作弊人数并不多,而且随机选取也可以规避一部分作弊。如果真的很多人作弊,还可以开发信誉系统作为保障。关于信誉系统只是想过,没有写成代码。
2、客户端先行的技能释放流程,及连续释放技能的处理
常规游戏技能的释放基本都是由服务器决定。而当时我们采取的是客户端技能先行的方式,也就是客户端判断技能是否可以释放,如果可以就直接进行技能表现。这种方式的一个优势的反馈非常快,没有网络延迟,可以提升手感。
缺点也不少,但都是可接受的。主要以下几点:
(1)客户端释放成功而服务器校验未通过时感受会变差。而实际情况是,玩家真的打起来的时候并注意不到这点,而且这种情况发生的概率很低。
(2)对其他客户端的表现有一些牺牲,因为另一个客户端看到的技能经过了两个网络延迟。不过如果游戏重的是PVE那这个缺点也就不那么重要了。
(3)这种情况比较特殊,只有在技能和伤害列表都由客户端提供的时候才会出现。比如,当连续释放技能的时候,可能由于网络延迟造成两个技能包同时到达服务器,此时会出现公共cd判断或者连续技能的时间判断将第二个技能数据丢弃的情况。这里我们采用了一种误差累积的方式来综合判断这两个技能是否应该被释放。比如,第一个两个技能一起过来的时候,第二个技能默认可以释放,但积累了一个时间值,相当于这个技能吞噬掉了第一个技能多少时间。当这个值大于一定值后,我们才认为客户端可能作弊了。正常情况下由网络不稳定造成的连续技能包,在一段时间内的平均时间是均匀的。
3、伤害列表计算的优化
当在服务器计算技能命中的时候,通常会遍历周围的目标,并进行各种图形的碰撞检测(球形、矩形、扇形、环形),循环比较次数比较多,计算量也不小。优化的一个思路是让客户端计算命中列表,服务器根据这个列表计算命中,可以大大减少服务器的循环次数(当时我们服务器的兴趣列表是64+64=128个角色)。
4、流量优化
(1)减小数据包体
这个优化有一个前提,不能是太大的场景。场景大小小于4096*4096就可以使用这种方式优化。思路就是将位置信息的float形参数变为uint16,用高4位存整数部分,用最低的一位存一位小数。同理,朝向信息的float形也使用类似的方式压缩。由于移动数据包非常的频繁,并且占总数据包流量大概50%,所以,这个压缩带来的收益还是很可观的。
(2)移动数据包通过距离控制发包间隔。
这个是个我想要尝试的优化点,但是没有真正的写进项目内测试。大概思路是,每次兴趣列表更新的时候,通知状态机系统更新兴趣列表内的角色与玩家距离数据。这个距离数据不用每帧更新,因为短时间内距离突变的可能性很低。依据距离将数据包广播频率划分多个档次,越远频率越低。这个优化对远程射击类游戏是不可取的,但是对MMORPG类型的游戏则没问题。毕竟远距离的角色移动对玩家来讲也不太关注。
5、战斗数值
战斗数值可以分两部分来说,属性计算的数值和战斗计算的数值。
(1)属性数值
属性数值指的是玩家本身、装备、宝石、buff等等其他系统携带的数值。属性分散在各个系统,这些属性需要按一定的规则合并在一起,称为属性合并。合并完之后需要计算出一个结果值展示给玩家,且在战斗过程中直接参与计算。
游戏中常规的属性加成方式一般是绝对值加成和百分比加成。百分比加成有时候会影响一部分属性,有时候会影响整个属性。比如攻击力属性,有的游戏里某个装备的描述是“增加基础攻击力的20%”,有的装备描述则是“增加攻击力的20%”。根据这个需求可以考虑将攻击力分为两个类型(基础攻击力和额外攻击力)。另外因为展示给玩家的时候肯定需要显示攻击力总和,所以这两个类型还要保持在一个数集里方便计算。那么可以考虑将这两种攻击力放在两个层上,0层是基础层,1层是额外层。每层分别包含属性对应的绝对值和百分比值。在运算时,0层的百分比会对0层的绝对值进行修正,那么0层的结果=绝对值
×
\times
×(1+0层百分比)。1层的百分比会对0层的运算结果+1层的绝对值进行修正,那么1层的结果=(0层结果+1层绝对值)
×
\times
×(1+1层百分比)。那么,“增加基础攻击力的20%”等价于0层的百分比值增加0.2,“增加攻击力的20%”等价于1层百分比值增加0.2。这种结构可以扩展到更多层,比如一般游戏中的大部分buff都是对结果值进行增减,比如buff描述“降低攻击力的20%”等价于2层的百分比值减少0.2。
有的游戏中在属性上还划分1、2级属性,1级属性会按着一定的规则转化为2级属性。虽然我经历过的项目没有使用1级属性和2级属性。但关于如何实现我想了一下,并在项目代码里做了小的测试。这次试验是在没有改变属性集数据结构,而是通过增加流程的方式做的。方法是先将1级属性合并计算出结果值,然后用公式将结果值转换为2级属性值并构造一个新的属性集合合并到原有的属性集中,最后再对所有属性集计算结果值,就可以得到最终所有1、2级属性结果值。
(2)战斗数值
属性数值计算的结果值即玩家的属性是一种战斗数值,另一种战斗数值是技能数值。90%以上的技能数值都是以攻击力为基数的,在此基础上加上乘以一个百分比,再加上一个绝对值形成攻击力终值,并以攻击力终值为起点开始效果的结算流程。还有不到10%的技能可能是以一些其他属性为基数算出攻击力终值,这个扩展可以考虑放在Lua里。计算流程中的很多节点都有可能对某个数值进行修正,也有可能对流程中的某个公式进行改变,所以这些节点也都可以考虑用Lua扩展。
三、为什么自己没有做成一款游戏
(一)关于团队重要性的思考
在大公司里做一个大型游戏,往往耗时几年,团队人数上百,一个人在这样的环境中所受的影响可想而知。我觉得团队的高度直接影响着每个成员的高度,所以选择一个好团队非常重要。什么是一个团队最重要的?可能很多人会说是团队文化,也就是团队价值观。这点我不否认非常重要,但是我觉得价值观更多的是影响一个团队的稳定性,而不是方向性。价值观不同的人很难长期合作。以我个人的经历推断团队最重要的应该是“明确的目标”。明确的目标可以想象成一个3D空间中的立方体,它代表着一个大的规则。哪些要做,哪些不要做,都是以是否符合目标为依据的,都要尽量框在这个立方体内。明确的目标不能太宏观,它要可以细化为具体的小目标,否则就相当于没有目标。具体执行的时候,应该以小目标为依据。
我相信大部分人肯定都知道目标的重要性,但说起来容易做起来难,很少有人能真正的做到大部分选择都落在立方体内。这里我想强调的其实只是希望团队能时刻保持一致的方向。
其他还有很多因素都对团队影响很大。比如团队价值观、各部门的信任程度及合作氛围、做事的风格是否依据四象限法则、团队的积累是否通过文档或者某种方式保留下来等。现实中没有完美的团队,只要团队的规则和原则能够保持就是好团队。
(二)自己的性格适合在团队里担任什么角色
自己的性格是偏向闷骚型的,曾经也尝试过提高领导力,但是尝试过后还是感觉不合适。我觉得一个团队最好是有尽量多的性格类型互相弥补,能够使事情办的更稳。而我能在里面承担的角色,个人认为更像是MOBA游戏里的5号位,偶尔可以承担一下三号位。
(三)平台和市场
其实我并不太懂平台和市场,只是觉得他们挺影响游戏是否能成的。个人觉得我在做第一款游戏的时候,平台和市场都是非常好的,但是由于游戏耗时过长,再加上运营经验不足,导致游戏没有成。最近在bilibili上又回顾了一下自己做的游戏(蛮荒搜神记),现在看看都觉得挺棒的,不知道当时为什么没有成。
现在我已经改变了看法,想要做成一款游戏影响因素实在太多了,这种事情不能强求。于是改变了最初的想法。我还是做一款自己觉得好玩的游戏吧,至于能不能成,交给运气。
四、技术栈
(一)按基础知识分
C++、C#、Unity、网络、Opengl。(这也太少了!!!强烈鄙视自己!!!)
(二)按游戏功能分
输入管理、相机管理、动画管理、战斗表现管理、角色状态机、客户端实例的资源管理、服务器的属性管理。(还是太少了!!!)
五、总结
总结下来,发现自己真没有几两重。9年多还是一个普普通通的程序员,没有多骄人的战绩,没有多深的技术积累,一直在一个公司里做一颗螺丝钉。导致这个结果的,是自己的不善总结,没有好的习惯,以及职业规划的不明确。2018年才认识到了问题,已经努力了一段时间,希望自己能坚持下去。虽然笨鸟也飞晚了,但只要从现在开始飞,也算是我能做到的极限了。