独立游戏-战斗系统开发构建

独立游戏-战斗系统开发构建

【USparkle专栏】如果你深怀绝技,爱“搞点研究”,乐于分享也博采众长,我们期待你的加入,让智慧的火花碰撞交织,让知识的传递生生不息!


背景

写这篇文章的背景来自于去年做的一款《星穹铁道》的战斗模拟器,可在B站搜索【耗时3个月,自制星铁战斗系统!就为作出最强攻略!】查看 。

后来结合自身经验又将此战斗系统扩展复用到了多种类型的游戏Demo中。在此来简单总结分享下其中组成,也不期待能帮助到大家什么,仅当在如今游戏行业的氛围中聊以慰藉吧。

做这个模拟器的背景在视频里其实有交代的蛮清楚的,感兴趣的会看不感兴趣的也不用多介绍了。来说一下实现了什么样的功能:

  1. 完全模拟了《星穹铁道》(卡牌向)的所有核心战斗机制,技能体系、数值体系、能量、装备系统、行动出手逻辑等。

  2. 截止xx前所有角色战斗逻辑(和官方技能描述一致)。

  3. 实现了随机/伪随机的可重入操作(也就是视频里经常提到的万次模拟)。

  4. 伤害公式数值公式、甚至Buff被动等时序以及打出来的伤害和官方角色实际体验保持一致

  5. 截止xx前完全拆解并实现了当前版本的所有特殊角色的特殊逻辑。

  6. 加了点前端界面,有战斗过程出手演示行动演示数据LOG演示及调试等界面。

为什么要从卡牌讲起?

  • 因为这是我个人经历中比较少见的给“自己”“商用”的战斗体系搭建,是从0开始搭建成功落地验证了扩展性的结构。因为平时就热爱游戏开发,这套战斗体系我同样移植给了自己做的“音游”、“肉鸽”、“休闲游戏”、“割草游戏”等多个Demo中。所以其实可以看到这并不局限于游戏类型,只要维护核心观念的扩展性即可(配置驱动)。

概览下战斗系统里包含什么?

  • 战斗核心机制是“技能”、“Buff”、“子弹”体系的三者联动, 辅以“被动”、“装备”、“法球”等机制用来配合实现特定逻辑。

  • 逻辑与表现分离:这是避免不同步的基础,也是后续万次重入结论一致的基础,在设计之初就应该考虑的。

  • C#配置表工具:在工作中有做了一个把Excel表导出成C#数据表的工具,生成C#之后可以让代码里直接调用对应的Excel类的属性,所见即所得可以说非常好用了。

  • 出手逻辑:也就是程序如何循环的。这里不同类型的游戏会定制化一些。

  • 伤害机制:伤害公式确认好之后,重点就是用Buff和属性、被动等共同组织起各种加成与时序算法。

  • 人物属性:单独拿出来说是因为人物属性需要做到动静分离,实时变更。影响因素较多(如装备、被动、Buff等),做到高效组织是需要点功力在的。

  • 触发机制:其实大部分就是被动带来的,注册+触发执行。

  • 日志系统:包含及其丰富细致的日志系统,让所有数据有迹可循。

  • 战斗表现:Demo做的粗糙,真正商业化游戏配合着技能编辑器来,根据时间轴做好战斗前摇、出手、后摇阶段的排版。

  • 操作手感:这是我最擅长的部分,单独拿出来作为一个文章不为过,解决过王者上千个战斗Bug,走A、寻敌、位移、指示器等等非常多,也感慨优化了大部分的英雄。

  • 打击感:抛砖引玉:技能缓存、飘字、震动、血条表现、顿帧、硬直等等。

  • 音频:不是我关注的重点,但是玩家体验的关键一环。

  • 网络:帧同步|状态同步。

  • Debug:快捷的调试工具也是节约开发时间的“利器”。

  • 渲染表现:风火雷电冰、雨雪阴晴、草水等等,不作为本文重点。

  • AI:本身AI&怪物AI。

  • UI:战斗UI,可能讲一下有趣的地方,比如3DUI、资源后处理Event生成等。

  • 资源加载:和战斗关联比较大,顺便提一下。

战斗系统雏形构建思路(《星穹铁道》为例)

这是写给我自己做的独立游戏的,并不适用于所有项目/大厂,正式的战斗系统会更为复杂严谨,各位资深道友看见了觉得写的草率了就图一乐吧。

下面的内容组织思路?
从《星穹铁道》战斗系统的整体构建来总览回顾一步步还原从0制作一款卡牌战斗雏形的过程。

  1. 首先构建初始时序
  2. 讲一下个人风格战斗体系的组成,重点是如何实现高扩展性
  3. 讲一下战斗相关系统扩展,比如战斗表现|日志系统|资源加载等。
  4. 将战斗系统扩展应用到多类型游戏中,验证扩展性和鲁棒性。

构建初始时序

根据游戏类型(卡牌),做了下出手执行顺序的草图,这是游戏运转前的基础验证,后续的所有战斗逻辑皆以此为基础扩展。


出手执行顺序草图

可以看到《星穹铁道》的核心出手机制定义为行动值,其实行动值就是速度,每个角色在程序中的运行速度的差异决定了普攻出手的先后。然后辅以每个角色特殊的战技出手条件&能量出手条件,形成了一个角色全部的行动规则

下面提供角色更新伪代码(为常规模拟模式稍微做了些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站的评论区经常有人会问:米哈游再出一个英雄,你是不是要重新全部实现一次英雄的技能呢?如果是这样,累也怕是累死了,《星穹铁道》战斗团队十数人一个版本的内容我完全手敲代码复刻要死的。

如何做到的?两点:

  1. 技能配置文件
  2. 优秀的抽象逻辑节点

技能配置文件
这部分既是指技能编辑器产出的技能组成,也同样指我们技能表中的配置。那为什么会存在两份结构不一样的配置表呢?一个比较直观的解释:技能配置表更像是数据库,你可以随时读取数据。技能配置文件则是组织技能出手后的复杂执行逻辑&效果的配置文件。他们的核心都为“数据驱动”,所以也可以说,只要做到了足够丰富抽象的节点设计,使用“数据驱动”就可以让策划自己编排所有后续的战斗需求了。

技能配置表
技能配置表单拿出来是因为大家基本都会使用的到,一般也会转成二进制数据而存在。这里我想为小团队/独立游戏制作者推荐一个个人制作的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. 释放技能,对随机敌人造成x点伤害,并叠加一层火焰伤害,每回合每层火焰伤害造成x点伤害,最高上限5层。

  2. 释放技能,对选中敌人造成五段冰霜伤害,最后一段伤害必定造成暴击。

这两个技能看起来风马牛不相及,实际上同属【伤害】这个概念中。我们可以从技能描述中抽离出“随机/选中敌人”、“火焰/冰霜”、“单回合/多回合”、“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)