【USparkle专栏】如果你深怀绝技,爱“搞点研究”,乐于分享也博采众长,我们期待你的加入,让智慧的火花碰撞交织,让知识的传递生生不息!
写这篇文章的背景来自于去年做的一款《星穹铁道》的战斗模拟器,可在B站搜索【耗时3个月,自制星铁战斗系统!就为作出最强攻略!】查看 。
后来结合自身经验又将此战斗系统扩展复用到了多种类型的游戏Demo中。在此来简单总结分享下其中组成,也不期待能帮助到大家什么,仅当在如今游戏行业的氛围中聊以慰藉吧。
做这个模拟器的背景在视频里其实有交代的蛮清楚的,感兴趣的会看不感兴趣的也不用多介绍了。来说一下实现了什么样的功能:
为什么要从卡牌讲起?
概览下战斗系统里包含什么?
这是写给我自己做的独立游戏的,并不适用于所有项目/大厂,正式的战斗系统会更为复杂严谨,各位资深道友看见了觉得写的草率了就图一乐吧。
下面的内容组织思路?
从《星穹铁道》战斗系统的整体构建来总览回顾一步步还原从0制作一款卡牌战斗雏形的过程。
根据游戏类型(卡牌),做了下出手执行顺序的草图,这是游戏运转前的基础验证,后续的所有战斗逻辑皆以此为基础扩展。
可以看到《星穹铁道》的核心出手机制定义为行动值,其实行动值就是速度,每个角色在程序中的运行速度的差异决定了普攻出手的先后。然后辅以每个角色特殊的战技出手条件&能量出手条件,形成了一个角色全部的行动规则。
下面提供角色更新伪代码(为常规模拟模式稍微做了些2D表现,所以使用了协程wait.):
// 通过协程分帧处理表现public IEnumerator ExcuteInner(){ // 前置需要添加Boss | 角色 | 日志系统 | 开局事件触发等 var maxRunCount = 全局配置表["行动值上限"]; while (curRunCount++ < maxRunCount) { // 更新人物 foreach (var actor in actorList) { actor.Update(); yield return new WaitForSeconds(0.5f); } // 更新怪物 or 对手等.. yield return new WaitForSeconds(0.01f); }}
角色出手逻辑伪代码:
public void Update(){ // 判断死亡,死亡不会执行后续 if (IsDie()) return; // 更新角色携带的宠物 m_pet?.UpdatePet(); // 更新人物路程 ChangeWay(speed); // 检查大招释放规则(能量更新在角色出手外,可以做到差帧更新: 即大招能量条满了不会同帧抢技能执行) CheckPlayEnergySkill(); // 判断是否可以出手(当前行动路程大于预设) if (m_curWay >= GlobalConfig.S_SumWay) { // 回合开始事件等派发 EventCenter.Instance().Notify(EVENT_NAME.ROUND_BEGIN, this, arg); // 路程归零(这里可以加速度余量,没加大概是强度使然) SetWay(0); // 更新持续伤害 UpdateLastDamageBuffs(); // 判断是否存在冻结等行动停滞效果 if (IsFrozen()) { // 冻结之后路程变成5000后面且不会出手,但是可以触发buff } else { ActorFight(); } // 检查大招释放规则 CheckPlayEnergySkill(); // 更新Buff(挂在角色身上的正面负面都算) UpdateBuffs(); // 更新被动技能 UpdatePassiveSkills(); // 清理当前轮次的攻击对象等回合相关信息 // ClearRoundData(); } // 移除延迟buff列表(有些buff不会在当前更新即刻移除,要放在DelayRemoveBuff列表中延迟移除) // 移除被动(同理) // 角色更新完成}
补充ActorFight判断是否是战技出手即完成完整角色出手流程:
public void ActorFight(){ // 敌人出手前更新韧性 // 出手前判断buff列表是否有回合开始时需要执行的buff for (int i = 0; i < m_buffList.Count; i++) { buffExcuteDic[m_buffList[i].m_buffType].RoundBeginExcute(m_buffList[i]); } // 判断技能出手条件是否满足 决定此刻是普攻/战技出手 if (!CanSkill()) { PlayAttack(); } else { PlaySkill(); }}
角色属性是伤害公式调用的基础,伤害公式就是根据配置动态修改各种属性的业务算法,而各种属性又决定了玩家的游戏策略,所以这个时候可以优先选择构建属性系统。
以《星穹铁道》早期的人物属性举例,可以拆解为:
速度、阵营、角色类型、攻击力、增伤、伤害加成、暴击率、无视防御、防御、暴击加成、血量、等级、穿透、抗性、韧性、嘲讽值、最大能量值、攻击属性、弱点、抗性弱点。
下面这个配置表也可以参考一二,为什么用的是拼音啊?Up是我室友他学俄语的啊!!!要蚌埠住了,实在没有勇气用俄语来做表头和代码注释,索性在部分属性上折个中用拼音了。
这里有3个点:
Q:为什么是会出现5000这种数字?
A:为了保证精度,用万分比保留2位小数部分。
Q:为什么是会出现大面积的0?
A:0代表了角色身上的初始属性确实没有,但是可以支持后续带有特定属性的角色更新。
Q:为什么逻辑上又出现中文?
A:不用怕,针对有强迫症的程序来讲,是准备了一张中文转换表的,可以将中文适配成对应的枚举,但是给“策划”展示依然是中文方便阅读配置(不然你填个2谁知道是什么)。
至此,关于属性的基础创建就完成了,选择了对应的角色就可以直接使用属性系统了。
本来不想扩散的,不过上一章节配置表中有很多的0,看不习惯,那就增加一个章节讲一下属性系统的实际数值是如何填充的(其中一种方式)- “装备系统的构建”。这一节很短,快的离谱。
Q:装备很多、属性很杂、还有装备各种特殊效果、套装加成等,关于加成的配置和装备的代码一定很麻烦吧?
A:核心代码几十行代码就搞定,只需要“通用的属性加成”与“被动添加移除机制”,下面提供伪代码:
// 获取装备属性public int GetEquipAdd(PropertyType _property){ // 自行判空 异常处理 return m_configData[propertyType];}// 添加被动public void AddPassiveSkill(){ // 自行判空 异常处理 var passiveID = m_configData["PassiveID"]; foreach (var p in passives) { if (p > 0) { // 参数分别代表: 1.给谁上被动 2.谁上的被动 3.被动的id UTils.AddPassive(m_srcActor, m_srcActor, p); } }}// 移除被动public void Remove(){ // 自行判空 异常处理 var passiveID = m_configData["PassiveID"]; foreach (var p in passives) { if (p <= 0) { continue; } if (m_srcActor.ContainsPassive(p)) { m_srcActor.RemovePassive(p); } }}
至此,已经讲完属性系统与装备填充的部分,接下来关注比较核心的伤害公式部分(每个游戏此部分应不尽相同,特别是成长类型游戏,是数值策划保证游戏生命周期的必修课)。
来拆解一下伤害公式(早期的部分伤害公式中文描述,后续我们是有微调迭代的,每一种伤害公式我们后期都和原版游戏运行进行了大量对比验证)。
关于伤害公式的选择有非常多种,这里因为复刻的《星穹铁道》就以《星穹铁道》的公式为引子介绍,其余公式可以根据情况自行推导。
《星穹铁道》的伤害公式是乘法公式DMG=aATKF(targetDEF),这种公式可以形容为“折损伤害”,比较适合ATK与DEF的成长空间无限大(无限成长扩展那种),通过构建F(DEF),可以得到边界递减的收益效果:
简单知道了公式原理后,我们来拆解一下《星穹铁道》存在哪些伤害公式(有十多种,不一一截全了,早期Demo期间推导的战斗公式需求表,内容在后续实战测试验证后有调整)。
这里大家可以看到,正式游戏中的伤害公式不止一种,为了增加游戏的复杂多变性,往往就需要增加各种打破常规的计算方式来增加伤害的数值乐趣。这点如果是做独立游戏的,可以根据平衡性自己设计,或者就完全拆解已经被验证过数值曲线的数值公式。
接下来来看战斗机制部分。
核心为技能、Buff和子弹的组织循环,再加入“被动”、“印记”、“法球”等效果,基本可以实现“所有”想象的到的战斗技能效果。
如何实现高扩展性?
这套体系的优势就在高扩展性,将逻辑原子化提供节点给“策划”调用,扩展参数以达到强大的复用效果。
在B站的评论区经常有人会问:米哈游再出一个英雄,你是不是要重新全部实现一次英雄的技能呢?如果是这样,累也怕是累死了,《星穹铁道》战斗团队十数人一个版本的内容我完全手敲代码复刻要死的。
如何做到的?两点:
技能配置文件
这部分既是指技能编辑器产出的技能组成,也同样指我们技能表中的配置。那为什么会存在两份结构不一样的配置表呢?一个比较直观的解释:技能配置表更像是数据库,你可以随时读取数据。技能配置文件则是组织技能出手后的复杂执行逻辑&效果的配置文件。他们的核心都为“数据驱动”,所以也可以说,只要做到了足够丰富抽象的节点设计,使用“数据驱动”就可以让策划自己编排所有后续的战斗需求了。
技能配置表
技能配置表单拿出来是因为大家基本都会使用的到,一般也会转成二进制数据而存在。这里我想为小团队/独立游戏制作者推荐一个个人制作的C#转表工具。
实现的原理很简单就不讲了,有需要的我可以把工具贴到Git上。
使用结果上可以将配置表的数据转成C#类中清晰可见的数据结构并且有相应数据填充,实现了C#类的配置表信息存储。适用于小团队是因为其足够的方便,导出后可以在业务侧直接使用对应配置表类的数据。并且点击类名可以跳转到对应类中查看所有有效数据,减少查看原始Excel的繁琐操作,更改临时数据更是可以做到1s搞定。
下面是C#配置表的示例(原始数据为Excel表,为了避免不必要的麻烦使用的是自己的Demo游戏配置数据):
public partial class NCONFIG_CSHAP{ public static Dictionary<int, CfgBuffData> CfgBuff = new Dictionary<int, CfgBuffData> { [100101] = new CfgBuffData { ID = 100101, Name = "通用伤害", Desc = "测试", SkillSrc = "XX技能", LastTime = 1f, BuffType = "通用伤害", Param1 = 0, Param2 = 0, Param3 = 0, PerCentParam = 10000, target = "对方单体", BuffTypeAddBuff = 0, LastBuff = false, DieJia = "", MaxCount = 1, DelayBuff = false, TriggerCD = 0, EndAddBuffs = new int[] { }, }, [100102] = new CfgBuffData { ID = 100102, Name = "普通攻击伤害", Desc = "测试", SkillSrc = "XX技能", LastTime = 1f, BuffType = "通用伤害", Param1 = 0, Param2 = 0, Param3 = 0, PerCentParam = 30000, target = "对方单体", BuffTypeAddBuff = 0, LastBuff = false, DieJia = "", MaxCount = 1, DelayBuff = false, TriggerCD = 0, EndAddBuffs = new int[] { }, }, }}
抽象逻辑节点
当建立了一定体量的逻辑功能节点后,“策划”就可以通过简单改变配置来组合达到不同的技能效果了。
在讲之前,可以先分析以下两个技能的区别:
这两个技能看起来风马牛不相及,实际上同属【伤害】这个概念中。我们可以从技能描述中抽离出“随机/选中敌人”、“火焰/冰霜”、“单回合/多回合”、“1段/5段伤害”、“暴击”这些差异点。
那我们只需要构建一个通用的伤害Buff,在执行的过程中根据以上五种差异元素做配置判断即可,根据配置:
用来简化的表达如下:技能产生Buff,Buff通过配置表参数传递特殊数据,而具体的执行逻辑节点是注册机制,使用Map索引即可。
具体逻辑节点要做的事情千变万化,也是高扩展性的核心竞争力 。这里就不展开讲了,注意配置的扩展就好。
这是侑虎科技第1621篇文章,感谢作者Jamin供稿。欢迎转发分享,未经作者授权请勿转载。如果您有任何独到的见解或者发现也欢迎联系我们,一起探讨。(QQ群:793972859)
作者主页:
https://www.zhihu.com/people/liang-zhi-ming-70
再次感谢Jamin的分享,如果您有任何独到的见解或者发现也欢迎联系我们,一起探讨。(QQ群:793972859)