妖尾历经几年开发,终于在今年6月底顺利上线,笔者从2017年初参与开发,主要负责妖尾战斗系统开发。战斗做为游戏的核心玩法系统,涉及不少技术点,但愿能借几篇文字,系统性总结MMORPG战斗系统的开发经验。
本文主要从宏观层面总结回合制游戏战斗的美术资源规范,系统框架设计和主要技术点,好比断线重连,技能表演等。html
系列博文传送门:
记录战斗记录你,详解妖尾战斗录像系统缓存
模型分为低模(1500-2000面)、高模(6000-10000面)两种规格,战场单位统一使用低模,但在合体技等镜头动画表演使用高模。主角模型是由头、上衣、武器、下装4部分组成的,游戏中经过网格、贴图合并成1个完整模型进行展现,这样能够实现部件换装。非主角模型比较简单,直接加载完整模型。网络
高低模都有头、脚、血量、受击、左右手、左右脚等挂点,高模相比低模额外多了表情挂点(下文解释挂点做用)。架构
高模跟低模使用不同的材质球。低模全身只用了一种材质球,而高模用了两种材质球,脸部和身体分别是不一样材质球,脸部材质球实现了uv动画用于作表情变化。高低模的身体材质球都实现了描边,高模额外开启了自阴影。高模贴图为256x256大小png图片,低模为128x128大小png图片,贴图都是宽高相等的POT尺寸,这样Android/IOS能够分别使用ECT2/PVRTC压缩格式。框架
人物表情是经过shader uv动画实现的,索引0-3从分别对应下面贴图的4个表情。因为shader是项目TA编写输出的,要让动做美术可以控制表情变化,咱们定了个表情挂点位置映射索引的规则,表情挂点x轴数值除以100向下取整即为索引,动做美术在动画时间轴里只须要编辑表情挂点的位置,经过程序转换设置shader参数,就能控制表情变化。异步
战斗单位的动画状态机具备很是多的状态,有多达60+多个动画,但经常使用动画只有其中几个,因此战斗单位不会在进入战场时一次性加载全部动画,默认只加载站立、受击、奔跑、死亡等4种动画。其余动画则每回合按需加载,咱们会按角色预先存储动做和对应资源路径的配置表,须要用到的动做查表获取路径加载资源,做为AnimationClip加载到RuntimeAnimatorController上。另外,像受击浮空等动画还须要处理好依赖,相关的过渡动画也要一并加载。编辑器
技能是使用Flux编辑器制做出来的,经过时间轴上建立多个Sequance轨道来组成一段技能表演,每一个Sequance脚本负责1种表现,如角色移动、播放特效等,Sequence脚本共同做用就能表现出一段技能。1个技能最终生成动做、音频共2个Prefab。1个战斗单位拥有的技能也很是多,不会在进入游戏时一次性加载,也是每回合按需加载要用到的技能。ide
Buff相比技能表现要简单,由于最多只有添加、持续、触发、移除等4个阶段须要作表现,每一个buff prefab挂相应的4个脚本,配置特效资源,人物动做,替换材质便可。工具
主角模型与非主角模型的资源提交规范稍有不一样,但制做流水线基本是同样的。美术提交包括模型fbx、动做fbx、动画机,材质球、贴图等资源,经过工具脚本进行资源检查、预处理,生成预制件到指定目录。性能
一套战斗框架其实包括了不少内容,一篇文章难以讲清全部细节。不过笔者尝试画图总结了战斗按功能划分的各个模块,但愿尽可能讲清基本模块的内容,模块之间的关系,从而在宏观层面了解战斗系统。
架构图紫色部分为PlayMaker脚本集合,若是将战斗框架理解为人,那PlayMaker状态机就是人的骨架,它串联了整个战斗流程。妖尾战斗采用了PlayMaker插件可视化编辑整个战斗流程,这样易于编辑,追踪整个战斗流程,直观地将战斗分红始化、表演、指令选择三大战斗状态。各战斗状态基本为线性流程,战斗状态之间则经过全局事件进行转移。
另一点是,咱们但愿尽可能用lua实现战斗逻辑,PlayMaker插件原生只支持C#,为了支持Lua,咱们实现了继承C#状态机行为基类(FsmStateAction)的子类,该类负责驱动Lua脚本,Lua脚本实现跟FsmStateAction类一样的接口和行为,这样就能够用Lua编写状态机逻辑了,代码基本实现以下:
namespace HutongGames.PlayMaker.Actions { public class LuaFsmStateAction : FsmStateAction { public string luaFileName = ""; private LuaTable _luaTable; private LuaFunction luaOnEnter = null; private LuaFunction luaOnExit = null; private LuaFunction luaOnUpdate = null; public override void OnEnter() { if (_luaTable == null && !string.IsNullOrEmpty(luaFileName)) { LuaSupport.DoFile(luaFileName); LuaFunction luaFunction = LuaSupport.lua.GetFunction(luaFileName + ".create"); if (luaFunction != null) { _luaTable = luaFunction.Invoke<LuaFsmStateAction, LuaTable>(this); luaFunction.Dispose(); } if (_luaTable != null && _luaTable.IsAlive) { luaOnEnter = _luaTable.GetLuaFunction("OnEnter"); luaOnExit = _luaTable.GetLuaFunction("OnExit"); luaOnUpdate = _luaTable.GetLuaFunction("OnUpdate"); } else { Debug.LogError("Cannot find lua class " + luaFileName); Finish(); return; } } SafeCall(luaOnEnter); } public override void OnExit() { SafeCall(luaOnExit); } public override void OnUpdate() { SafeCall(luaOnUpdate); } private void SafeCall(LuaFunction func) { if (func != null && func.IsAlive) { func.Call(); } } } }
战斗的核心管理器就是架构图底下蓝色部分的战斗控制器,它是战斗系统的大脑。战斗控制器负责接收协议数据,驱动战斗逻辑。
战斗控制器有两种方式接收数据输入。对于一般的联网战斗,底层网络层接收后台协议数据,再传输给战斗控制器。妖尾还在新帐号进入游戏时,设计了一场战斗用于展现关键剧情,这场战斗则是离线模拟战斗。咱们单独实现了模拟战斗控制器,它负责根据策划配表生成模拟协议数据,传输给战斗控制器驱动战斗逻辑。
整个战斗流程的协议设计以下图所示,能够分为战场初始化,等待加入战场,战前表演,回合选招,回合表演,战斗结束等6个阶段。战斗控制器收到不一样的协议包切换PlayMaker状态,进而改变战斗流程。
一场战斗是由一组连续的协议数据组成的。若是因为客户端卡顿,切出后台等缘由,出现前一个协议包还未处理表现完,下一个协议包已经到了,忽略协议包不处理,或者粗暴切断当前逻辑,直接处理下一个协议包都是不可取的,可能致使战斗表现异常。所以战斗控制器设计了协议缓存队列,用于缓存顺序处理协议数据,然而缓存队列并非简单地顺序处理数据就能万事大吉了,若是不加以考虑处理断线重连的状况,就会碰到像战斗进度严重延迟,甚至卡死等状况。
战斗控制器的一大要务就是处理好战斗中的断线重连,恢复并修正战斗流程。简单来看,战斗中断线重连有两大类状况:断线重连后战斗已结束;断线重连后战斗未结束。
第一种状况比较简单,断线重连的登录包带有玩家是否处于战斗中的标志位,若是当前不处于战斗中,前台却仍处于战斗场景中,则清掉全部战斗协议缓存,执行退出战斗的逻辑。
第二种状况则要细分多种状况讨论。通常断线重连后,战斗协议缓存队列可能存有多个战斗协议,须要确认协议数据是否仍为原来那场战斗的。简单判断原则就是,若是队列中收到初始战场包,且其战场ID与以前协议不一样,能够认为断线重连回来后已开始了另外一场新战斗,旧战斗数据已失效,直接清出缓存,开始处理表现新战斗。
接着考虑断线回来后还在原战斗的状况,战斗设计上断线重连必然会收到初始战场包,战场包带有当前战斗阶段的标志位,根据标志位便可还原战场状态:标志位为战前表演,回合表演阶段,该客户端立刻发送表演结束Req,等待服务端通知下回合开始,避免拖慢战斗进度;标志位为回合选招阶段,客户端切为选招界面,并根据阶段开始时间戳修正剩余选招时间。
战斗资源理所固然就是战斗系统的肉身了。管理资源的难点在于合理加载卸载,如人有四肢五官,协调越好,运动性能越强,越节省体力。
资源 | 加载策略 | 缓存策略 |
---|---|---|
战斗场景 | 登陆预加载 | 常驻内存 |
全屏背景图 | 根据场景切换 | 常驻一张图 |
战斗HUD | 高配登陆预加载; 低配入场预加载 |
高配常驻内存; 低配战后卸载 |
功能模块UI | 战中即时加载 | 出战斗卸载 |
通用特效 | 高配登陆预加载; 低配入场预加载 |
常驻内存 |
己方模型 | 进战斗预加载 | 高配缓存到下一场战斗,无命中则战后卸载; 低配战后卸载 |
敌方模型 | 进战斗预加载 | 战后卸载 |
骨骼动画 | 入场加载基本动画, 其他动画按需回合加载 |
战后卸载 |
技能 | 回合按需加载 | 缓存一回合,不命中则淘汰 |
Buff | 回合按需加载 | 战后卸载 |
上图简述了战斗系统涉及的主要资源及加载缓存策略,一言蔽之,就是既要体面,又要节约。
咱们但愿游戏体验尽可能流畅,在社区场景遭遇战斗时能秒切进入战斗,因此:
另外,一场战斗表现少说也会涉及数十个资源的异步加载,若是每处表演逻辑都要异步等待资源加载回调,很容易致使回调地狱。所以战斗状态机特地将资源加载,资源使用划分红两个阶段。每回合等待表演所需资源所有异步加载完毕,才能进入到表演阶段,表演逻辑按同步方式使用资源便可。因为资源加载粒度细分到以回合为单位来加载,实测资源加载等待并不会影响战斗表演的流畅体验。
讲到资源管理,ab打包是个绕不开的话题。ab打包粒度越细,包数量越多,IO压力大;ab打包粒度越粗,资源越冗余,包体,热更新资源量都会变大,说究竟是平衡的艺术。
资源 | 打包策略 |
---|---|
战斗场景 | 单独打包 |
全屏背景图 | 每张图单独打包 |
战斗HUD | HUD集合打包,HUD上的动态小图标按类别集合打包 |
功能模块UI | 按模块集合打包 |
通用特效 | 全部通用特效集合打包 |
主角模型 | 每一个主角各个模型部件单独打包,各个骨骼动做单独打包 |
非主角模型 | 每一个角色为单位打包 |
技能 | 每一个技能单独打包,技能引用资源分普通技能,合体技两类,再按角色为单位打包 |
Buff | 全部Buff集合打包 |
简单罗列了战斗相关资源的ab打包策略,原则上是尽可能按资源使用耦合程度划分打包,可能一块儿使用的资源,打包到一块儿,若是资源过多,就要进一步拆分ab包。再者,作好提早设计,确保打包策略在将来资源量堆起来后仍能适用。好比,主角模型的模型部件,骨骼动做很是多且在将来颇有可能新增,能够每一个资源单独打包;非主角模型模型,骨骼动做数量相对固定,就能以角色单位打包。规划好ab打包策略后,跟美术约定好规则来提交资源目录及资源,就能编写工具根据配表,不一样目录执行不一样的ab打包策略。
战斗表演大致分为技能和Buff两类表演。技能是有开始结束的一段表现,小到普通攻击,大到多人合体技,都是技能表演;Buff则是附在单位上的持续性状态表现,如人物的中毒,封印状态表现。
正如前面的框架图里提到妖尾战斗有不少表演脚本,可综合对角色,UI,场景,镜头,节奏作全方位的调度控制,从而表现一段技能。伽吉鲁和蕾比两个角色的合体技是很是有表明性的一段技能表演,涵盖了对不少技能脚本的应用,简单举例讲解这个合体技的实现,就能够了解技能是怎么编辑,表演的。下图是合体技的游戏表现:
整体来看,这个合体技由镜头动画+技能打击两部分构成,这两部分都是在同一条时间轴经过脚本组合运用编辑出来的,最后生成一个合体技预制件。
图中红色部分是镜头动画实现脚本:
不难看出镜头动画的主要逻辑是由动画挂靠脚本实现的,主要镜头,角色走位调度由美术实现Animator进行控制。视镜头动画的复杂效果,可能会堆多一些特写特效脚本同步播放,丰富画面效果。
战斗系统运用了几个透视相机,按相机深度由低到高分别是:
- 战斗背景图相机
- 战斗单位名字相机
- 战斗UI相机
- 战斗主相机
- 战斗镜头动画相机
- 战斗镜头动画UI相机
镜头动画播放完,紧接着就是绿色部分脚本,配合完成技能释放:
技能释放须要由更多的脚本组合完成,通常不须要美术产出不少资源,利用一些简单攻击特效,配置角色走位,动做,受击,镜头控制就能作出漂亮打击感的技能。
Buff表演相比技能表演更简单,容易编辑,实现。每种Buff均可以分为Buff添加,Buff持续,Buff触发,Buff移除4个阶段,视需求自由决定每一个阶段是否有具体表现,Buff编辑器只需配置每一个阶段的特效,人物动做,替换材质便可。下图是反击Buff的游戏表现,4个阶段都有特效表现。固然,也存在一些Buff是设计成彻底无表现的。
至此本文就结束了,主要仍是就美术资源,资源管理,协议交互,战斗表演作了些介绍,内容并无涵盖整个战斗系统,不过已经是战斗系统核心设计内容,特此记录,也但愿能提供一些经验借鉴。