【USparkle专栏】如果你深怀绝技,爱“搞点研究”,乐于分享也博采众长,我们期待你的加入,让智慧的火花碰撞交织,让知识的传递生生不息!
背景
写这篇文章的背景来自于去年做的一款《星穹铁道》的战斗模拟器,可在B站搜索【耗时3个月,自制星铁战斗系统!就为作出最强攻略!】查看 。
后来结合自身经验又将此战斗系统扩展复用到了多种类型的游戏Demo中。在此来简单总结分享下其中组成,也不期待能帮助到大家什么,仅当在如今游戏行业的氛围中聊以慰藉吧。
做这个模拟器的背景在视频里其实有交代的蛮清楚的,感兴趣的会看不感兴趣的也不用多介绍了。来说一下实现了什么样的功能:
- 完全模拟了《星穹铁道》(卡牌向)的所有核心战斗机制,技能体系、数值体系、能量、装备系统、行动出手逻辑等。
- 截止xx前所有角色的战斗逻辑(和官方技能描述一致)。
- 实现了随机/伪随机的可重入操作(也就是视频里经常提到的万次模拟)。
- 伤害公式、数值公式、甚至Buff被动等时序以及打出来的伤害和官方角色实际体验保持一致。
- 截止xx前完全拆解并实现了当前版本的所有特殊角色的特殊逻辑。
- 加了点前端界面,有战斗过程、出手演示、行动演示、数据LOG演示及调试等界面。
为什么要从卡牌讲起?
- 因为这是我个人经历中比较少见的给“自己”“商用”的战斗体系搭建,是从0开始搭建并成功落地验证了扩展性的结构。因为平时就热爱游戏开发,这套战斗体系我同样移植给了自己做的“音游”、“肉鸽”、“休闲游戏”、“割草游戏”等多个Demo中。所以其实可以看到这并不局限于游戏类型,只要维护核心观念的扩展性即可(配置驱动)。
概览下战斗系统里包含什么?
- 战斗核心机制是“技能”、“Buff”、“子弹”体系的三者联动, 辅以“被动”、“装备”、“法球”等机制用来配合实现特定逻辑。
- 逻辑与表现分离:这是避免不同步的基础,也是后续万次重入结论一致的基础,在设计之初就应该考虑的。
- C#配置表工具:在工作中有做了一个把Excel表导出成C#数据表的工具,生成C#之后可以让代码里直接调用对应的Excel类的属性,所见即所得可以说非常好用了。
- 出手逻辑:也就是程序如何循环的。这里不同类型的游戏会定制化一些。
- 伤害机制:伤害公式确认好之后,重点就是用Buff和属性、被动等共同组织起各种加成与时序算法。
- 人物属性:单独拿出来说是因为人物属性需要做到动静分离,实时变更。影响因素较多(如装备、被动、Buff等),做到高效组织是需要点功力在的。
- 触发机制:其实大部分就是被动带来的,注册+触发执行。
- 日志系统:包含及其丰富细致的日志系统,让所有数据有迹可循。
- 战斗表现:Demo做的粗糙,真正商业化游戏配合着技能编辑器来,根据时间轴做好战斗前摇、出手、后摇阶段的排版。
- 操作手感:这是我最擅长的部分,单独拿出来作为一个文章不为过,解决过王者上千个战斗Bug,走A、寻敌、位移、指示器等等非常多,也感慨优化了大部分的英雄。
- 打击感:抛砖引玉:技能缓存、飘字、震动、血条表现、顿帧、硬直等等。
- 音频:不是我关注的重点,但是玩家体验的关键一环。
- 网络:帧同步|状态同步。
- Debug:快捷的调试工具也是节约开发时间的“利器”。
- 渲染表现:风火雷电冰、雨雪阴晴、草水等等,不作为本文重点。
- AI:本身AI&怪物AI。
- UI:战斗UI,可能讲一下有趣的地方,比如3DUI、资源后处理Event生成等。
- 资源加载:和战斗关联比较大,顺便提一下。
战斗系统雏形构建思路(《星穹铁道》为例)
这是写给我自己做的独立游戏的,并不适用于所有项目/大厂,正式的战斗系统会更为复杂严谨,各位资深道友看见了觉得写的草率了就图一乐吧。
下面的内容组织思路?
从《星穹铁道》战斗系统的整体构建来总览回顾一步步还原从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期间推导的战斗公式需求表,内容在后续实战测试验证后有调整)。
《星穹铁道》伤害公式在早期模拟系统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[] { },
},
}
}
抽象逻辑节点
当建立了一定体量的逻辑功能节点后,“策划”就可以通过简单改变配置来组合达到不同的技能效果了。
在讲之前,可以先分析以下两个技能的区别:
- 释放技能,对随机敌人造成x点伤害,并叠加一层火焰伤害,每回合每层火焰伤害造成x点伤害,最高上限5层。
- 释放技能,对选中敌人造成五段冰霜伤害,最后一段伤害必定造成暴击。
这两个技能看起来风马牛不相及,实际上同属【伤害】这个概念中。我们可以从技能描述中抽离出“随机/选中敌人”、“火焰/冰霜”、“单回合/多回合”、“1段/5段伤害”、“暴击”这些差异点。
那我们只需要构建一个通用的伤害Buff,在执行的过程中根据以上五种差异元素做配置判断即可,根据配置:
- 将选敌逻辑抽象出来:可以选择单个、多个、随机的敌人。可以选择血量最低、伤害最高、距离最近的友军。
- 将元素逻辑抽象出来:可以配置冰、火、暗、光属性等,并单独结算属性伤害&韧性计算(关于破韧等逻辑使用条件触发即可)。
- 将回合逻辑抽象出来:控制生命周期,根据配置单回合生效即销毁还是执行多回合。
将叠加逻辑抽象出来:分段伤害可以分为几种:
- 如果是纯伤害叠加可以使用Buff叠层;
- 如果要分开结算(比如暴击率独立判定),则配置多个伤害Buff,或者Buff执行衔接Buff等方式都可以。
- 将暴击逻辑抽象出来:配置可以无极调整的暴击率参数就可以。
用来简化的表达如下:技能产生Buff,Buff通过配置表参数传递特殊数据,而具体的执行逻辑节点是注册机制,使用Map索引即可。
Buff执行节点示意:
通用Buff逻辑执行节点注册示意:
具体逻辑节点要做的事情千变万化,也是高扩展性的核心竞争力 。这里就不展开讲了,注意配置的扩展就好。
这是侑虎科技第1621篇文章,感谢作者Jamin供稿。欢迎转发分享,未经作者授权请勿转载。如果您有任何独到的见解或者发现也欢迎联系我们,一起探讨。(QQ群:793972859)
作者主页:https://www.zhihu.com/people/liang-zhi-ming-70
再次感谢Jamin的分享,如果您有任何独到的见解或者发现也欢迎联系我们,一起探讨。(QQ群:793972859)
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。