418 lines
20 KiB
Markdown
418 lines
20 KiB
Markdown
[独立游戏-万字解析游戏战斗系统开发 - 知乎](https://zhuanlan.zhihu.com/p/694146117)
|
||
## 背景
|
||
|
||
写这篇文章的背景来自于去年做的一款**[星穹铁道](https://zhida.zhihu.com/search?content_id=242384834&content_type=Article&match_order=1&q=%E6%98%9F%E7%A9%B9%E9%93%81%E9%81%93&zhida_source=entity)**的**[战斗模拟器](https://zhida.zhihu.com/search?content_id=242384834&content_type=Article&match_order=1&q=%E6%88%98%E6%96%97%E6%A8%A1%E6%8B%9F%E5%99%A8&zhida_source=entity)**: 【耗时3个月,自制星铁战斗系统!就为作出最强攻略!】 [耗时3个月,自制星铁战斗系统!就为作出最强攻略!_手机游戏热门视频](https://link.zhihu.com/?target=https%3A//www.bilibili.com/video/BV1Er4y1R7y4/%3Fshare_source%3Dcopy_web%26vd_source%3Da00e3a4a1a7552a159a18dddb0a14fb0)。
|
||
|
||
后来结合自身经验又将此战斗系统**扩展复用**到了**多种类型**的游戏Demo中。在此来简单总结分享下其中组成,也不期待能帮助到大家什么,仅当在如今游戏行业的氛围中聊以慰藉吧。
|
||
|
||
做这个模拟器的背景在视频里其实有交代的蛮清楚的,感兴趣的会看不感兴趣的也不用多介绍了~。来说一下实现了什么样的功能:
|
||
|
||
1. 完全模拟了**星穹铁道**([卡牌向](https://zhida.zhihu.com/search?content_id=242384834&content_type=Article&match_order=1&q=%E5%8D%A1%E7%89%8C%E5%90%91&zhida_source=entity))的所有核心战斗机制,技能体系、数值体系、能量、装备系统、行动出手逻辑等。
|
||
2. 截止xx前**所有角色**的**战斗逻辑**(和官方技能描述一致)。
|
||
3. 实现了随机/伪随机的**可重入**操作(也就是视频里经常提到的**万次模拟**)。
|
||
4. **伤害公式,数值公式**,甚至**buff被动**等时序以及打出来的伤害和官方角色实际体验**保持一致**。
|
||
5. 截止xx前完全拆解并实现了当前版本的所有特殊角色的特殊逻辑。
|
||
6. 加了点前端界面,有**战斗过程,出手演示,行动演示,数据LOG演示,调试**等界面。
|
||
|
||
**为什么要从卡牌讲起?**
|
||
|
||
- 因为这是我个人经历中比较少见的给"**自己**""**商用**"的战斗体系搭建,是从**0开始搭建**并**成功落地**验证了**扩展性**的结构。因为平时就热爱游戏开发,这套战斗体系我同样移植给了自己做的**"音游""肉鸽""休闲游戏""割草游戏"**等多个Demo中。所以其实可以看到这并**不局限于游戏类型**,只要维护核心观念的扩展性即可(**配置驱动**)。
|
||
|
||
**概览下战斗系统里包含什么?**
|
||
|
||
- **战斗核心机制**是 "**技能**" "**Buff**" "**子弹**" 体系的三者联动, 辅以"被动""装备""法球"等机制用来配合实现特定逻辑。
|
||
- **逻辑与表现分离**: 这是避免不同步的基础,也是后续万次重入结论一致的基础,在设计之初就应该考虑的。
|
||
- **[C#配置表工具](https://zhida.zhihu.com/search?content_id=242384834&content_type=Article&match_order=1&q=C%23%E9%85%8D%E7%BD%AE%E8%A1%A8%E5%B7%A5%E5%85%B7&zhida_source=entity):** 在工作中有做了一个把**excel**表导出成**c#数据表**的工具,生成c#之后可以让代码里直接调用对应的excel类的属性,所见即所得可以说非常好用了。
|
||
- **出手逻辑:** 也就是程序如何循环的。这里不同类型的游戏会定制化一些。
|
||
- **伤害机制:** 伤害公式确认好之后,重点就是用buff和属性、被动等共同组织起各种加成与时序算法。
|
||
- **人物属性:** 单独拿出来说是因为人物属性需要做到动静分离,实时变更。影响因素较多(装备、被动、buff等),做到高效组织是需要点功力在的。
|
||
- **触发机制:** 其实大部分就是**被动**带来的, **注册+触发**执行。
|
||
- **日志系统:** 包含及其丰富细致的日志系统,让所有数据有迹可循。
|
||
- **战斗表现:** demo做的粗糙,真正商业化游戏配合着**技能编辑器**来,根据时间轴做好战斗前摇、出手、后摇阶段的排版。
|
||
- **操作手感:** 呦,抽象了喔。这是我最擅长的部分,单独拿出来作为一个文章不为过,解决过王者上千个战斗bug,走A,寻敌,位移,指示器等等非常多,也感慨优化了大部分的英雄。后面得空在好好讲讲这里吧。
|
||
- **打击感:** 抛砖引玉: 技能缓存、飘字、震动、血条表现、顿帧、硬直等等。
|
||
- **音频:** 不是我关注的重点.但是玩家体验的关键一环。
|
||
- **网络:** 帧同步|状态同步。
|
||
- **Debug:** 快捷的调试工具也是节约开发时间的"利器"。
|
||
- **渲染表现:** 风火雷电冰,雨雪阴晴,草水等等. 不作为本文重点。
|
||
- **AI:** 本身AI&怪物AI
|
||
- **UI:** 战斗UI,可能讲一下有趣的地方~ 比如3DUI, 资源后处理Event生成等
|
||
- **资源加载:** 和战斗关联比较大,顺便提一下
|
||
|
||
## 战斗系统雏形构建思路(星铁为例)
|
||
|
||
> 这是写给我自己做的独立游戏的,并不适用于所有项目/大厂,正式的战斗系统会更为复杂严谨,各位资深道友看见了觉得写的草率了就图一乐吧~。
|
||
|
||
**下面的内容组织思路?**
|
||
|
||
从星铁战斗系统的**整体构建**来总览回顾一步步还原**从0制作**一款卡牌**战斗雏形**的过程。
|
||
|
||
1. 首先**构建初始时序**
|
||
2. 讲一下**个人风格**的**战斗体系的组成**,重点是如何实现**高扩展性**。
|
||
3. 讲一下**战斗相关**的**系统扩展**,比如**战斗表现|日志系统|资源加载**等。
|
||
4. 将战斗系统**扩展**应用到**多类型**游戏中,验证扩展性和鲁棒性。
|
||
|
||
## 构建初始时序
|
||
|
||
> 根据**游戏类型**(卡牌),做了下**出手执行顺序**的草图,这是游戏运转前的基础验证,后续的所有战斗逻辑皆以此为基础扩展。
|
||
|
||

|
||
|
||
出手执行顺序草图
|
||
|
||
可以看到星铁的核心出手机制定义为**行动值**,其实行动值就是速度,每个角色在程序中的运行速度的差异决定了**普攻出手**的先后。然后辅以每个角色特殊的**战技出手**条件&**能量出手**条件,形成了一个角色**全部的行动规则。**
|
||
|
||
下面提供**角色更新**伪代码(为**常规模拟模式**稍微做了些2D表现,所以使用了**协程wait.**):
|
||
|
||
```csharp
|
||
// 通过协程分帧处理表现
|
||
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);
|
||
}
|
||
}
|
||
```
|
||
|
||
**角色出手逻辑伪代码:**
|
||
|
||
```text
|
||
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**判断是否是战技出手即完成完整**角色出手**流程:
|
||
|
||
```text
|
||
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: 为什么是会出现5000这种数字?A: 为了保证精度,用万分比保留2位小数部分
|
||
|
||
- Q: 为什么是会出现大面积的0?
|
||
|
||
- A: 0代表了角色身上的初始属性确实没有,但是可以支持后续带有特定属性的角色更新
|
||
|
||
- Q: 为什么逻辑上又出现中文?
|
||
|
||
- A: 不用怕,针对有强迫症的程序来讲,是准备了一张**中文转换表**的,可以将中文**适配成对应的枚举**,但是给"策划"展示依然是中文方便阅读配置(不然你填个2谁知道是啥啊喂)。
|
||
|
||
至此,关于属性的基础创建就完成了,选择了对应的角色就可以直接使用属性系统了。
|
||
|
||
## 构建装备系统
|
||
|
||
> 本来不想扩散的,不过上一章节配置表中有很多的0,看不习惯,那就增加一个章节讲一下属性系统的实际数值是如何填充的(其中一种方式)-**"装备系统的构建".** 这一节很短,快的离谱
|
||
|
||
Q: 装备很多,属性很杂,还有装备各种特殊效果,套装加成等,关于加成的配置和装备的代码一定很麻烦吧?
|
||
|
||
A: 核心代码几十行代码就搞定.只需要 "**通用的属性加成**" 与 "**被动添加移除机制**",下面提供伪代码
|
||
|
||
```text
|
||
// 获取装备属性
|
||
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);
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||

|
||
|
||
装备的配置加成属性其实可玩项【非常丰富】
|
||
|
||
ok,至此已经讲完属性系统与装备填充的部分,接下来来关注比较核心的伤害公式部分(每个游戏此部分应不尽相同,特别是成长类型游戏,是数值策划保证游戏生命周期的必修课)
|
||
|
||
## 根据伤害公式构建伤害系统
|
||
|
||
> 来拆解一下伤害公式(早期的部分伤害公式中文描述,后续我们是有微调迭代的~,每一种伤害公式我们后期都和原版游戏运行进行了大量对比验证):
|
||
|
||
关于**伤害公式**的选择有非常多种,这里因为复刻的星铁就以**星铁的公式为引子**介绍,其余公式可以根据情况自行推导.
|
||
|
||
星铁的伤害公式是**乘法公式** DMG=a*ATK*F(targetDEF) , 这种公式可以形容为“**折损伤害**”,比较适合ATK与DEF的成长空间无限大(无限成长扩展那种),通过构建F(DEF),可以得到边界递减的收益效果:
|
||
|
||

|
||
|
||
示意
|
||
|
||
简单知道了公式原理后,我们来拆解一下星铁存在哪些伤害公式(十多种,不截全了**,早期Demo期间推导**的战斗公式需求表,内容在后续实战测试验证后有调整)
|
||
|
||

|
||
|
||
星铁伤害公式在早期模拟系统Demo期间的拆解示意图
|
||
|
||
这里大家可以看到,正式游戏中的伤害公式不止一种,为了增加游戏的复杂多变性,往往就需要增加各种打破常规的计算方式来增加伤害的数值乐趣。这点如果做独立游戏的话根据平衡性自己设计吧,或者就完全拆解已经被验证过数值曲线的数值公式。
|
||
|
||
接下来来看战斗机制部分.
|
||
|
||
## 战斗体系的核心组成
|
||
|
||
> 核心为 技能 buff 子弹的组织循环, 再加入"被动""印记""法球"等效果, 基本可以实现"所有"想象的到的战斗技能效果.
|
||
|
||

|
||
|
||
技能系统简易示意图
|
||
|
||
### 如何实现**高扩展性?**
|
||
|
||
> 这套体系的优势就在高扩展性,将逻辑原子化提供节点给"策划"调用,扩展参数以达到强大的复用效果。
|
||
|
||
在B站的评论区经常有人会问: **米哈游再出一个英雄,**你是不是要**重新全部实现**一次英雄的技能呢? 如果是这样,累也怕是累死了,星铁战斗团队十数人一个版本的内容我**完全手敲代码复刻要死**的。
|
||
|
||
如何做到的? 两点:
|
||
|
||
1. [技能配置文件](https://zhida.zhihu.com/search?content_id=242384834&content_type=Article&match_order=1&q=%E6%8A%80%E8%83%BD%E9%85%8D%E7%BD%AE%E6%96%87%E4%BB%B6&zhida_source=entity)
|
||
2. 优秀的抽象逻辑节点
|
||
|
||
### 技能配置文件
|
||
|
||
这部分既是指技能编辑器产出的技能组成,也同样指我们技能表中的配置。那为什么会存在两份结构不一样的配置表呢? 一个比较直观的解释: **技能配置表**更像是数据库.你可以随时**读取数据**. 技能配置文件则是组织技能出手后的复杂执行逻辑&效果的配置文件。他们的核心都为**"[数据驱动](https://zhida.zhihu.com/search?content_id=242384834&content_type=Article&match_order=1&q=%E6%95%B0%E6%8D%AE%E9%A9%B1%E5%8A%A8&zhida_source=entity)",**所以也可以说: 只要做到了足够丰富抽象的节点设计,使用**"数据驱动"**就可以让策划自己编排所有后续的战斗需求了。
|
||
|
||

|
||
|
||
### 技能配置表
|
||
|
||
> 技能配置表单拿出来是因为大家基本都会使用的到,一般也会转成二进制数据而存在.这里我想为小团队/独立游戏制作者推荐一个个人制作的c#转表工具。
|
||
> 实现的原理很简单就不讲了,有需要的我可以把工具贴到Git上。
|
||
|
||
使用结果上可以将配置表的数据转成**c#类**中清晰可见的**数据结构**并且有相应**数据填充**,实现了c#类的配置表信息存储。适用于小团队是因为其**足够的方便**,导出后可以在业务侧直接使用对应配置表类的数据。并且点击类名可以**跳转**到对应类中**查看所有有效数据**,**减少查看原始Excel**的繁琐操作,更改**临时数据**更是可以做到**1s搞定**。
|
||
|
||
下面是c#配置表的示例(原始数据为excel表,为了避免不必要的麻烦使用的是自己的Demo游戏配置数据):
|
||
|
||

|
||
|
||
技能配置表相关数据
|
||
|
||
```text
|
||
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等方式都可以
|
||
|
||
- 将暴击**逻辑**抽象出来: 配置可以无极调整的暴击率参数就Ok.
|
||
|
||
用来简化的表达如下, 技能产生buff, Buff通过配置表参数传递特殊数据,而具体的执行逻辑节点是注册机制,使用Map索引即可。
|
||
|
||

|
||
|
||
Buff执行节点示意
|
||
|
||

|
||
|
||
通用Buff逻辑执行节点注册示意
|
||
|
||
具体逻辑节点要做的事情千变万化了,也是高扩展性的核心竞争力 。这里就不展开讲了,注意配置的扩展就好,接下来来聊一下战斗相关的系统扩展。
|
||
|
||
## **战斗相关**的**系统扩展**
|
||
|
||
> **战斗相关**的**系统扩展**,涉及到星铁的比如**战斗表现|日志系统**等, 涉及到常规游戏的,如音频管理,资源加载等,不作为重点,**后续更新.**
|
||
|
||
### 战斗表现
|
||
|
||
> 战斗表现因为是卡牌又是Demo,实际上没什么好讲的,这个章节会比较短,后面会补上图或者视频.(其实分享链接里面也有,就是个演示界面。
|
||
|
||
从设计开始, 使用xx做原型设计
|
||
|
||
### 日志系统
|
||
|
||
> 重点是日志的全面打点&显示统计和输出,后续更新 |