Files
obsidian-notes/InBox/战斗/写代码的诗人/战斗系统:框架设计—战斗视角的GameObject.md
2025-06-11 18:37:23 +08:00

19 KiB
Raw Blame History

 战斗系统,在大型游戏中,基本就是最复杂的系统;在项目中,通常由专门的战斗策划和战斗程序负责。相信读者中也有维护过战斗系统的,我想问几个问题:

  1. 有站在技能和Buff的更高层审视过你们的战斗系统吗

  2. 项目的战斗系统有明确的架构吗?

  3. 项目的战斗系统有缺陷吗?知道问题的成因吗?

  4. 能实现你玩过的游戏中的所有技能和Buff效果吗

    我不知道大家的回答如何但如果让刚离开第一个项目时候的我来回答这些问题我几乎需要全部答NO...

    我后来发现,其实不仅我是这样,有大量的开发者都对战斗系统缺少系统性的认知和思考,即使当前项目的战斗框架是他实现的。为什么呢?因为他不是最初的设计者,而只是搬运工,他并不真正了解他手头的工具。

    其实,对战斗系统有系统性的认知非常重要,有许多设计问题,在我们只盯着部分模块的时候是发现不了的 —— 你只会觉得很难受,但不知道为什么。

    本篇就带着大家看一下常见的战斗框架设计,以及我的心血之作,相信你定有所收获。


1.独立抽象(反面教材)

图片

    我的第一和第二个项目都将主动技能、被动技能、Buff进行了独立的抽象。

    第一个项目的战斗系统是由我Leader搭建的一开始仅包含主动技能和Buff两个模块后来在我接手战斗系统的维护工作后策划提出了被动技能的需求我便增加了被动技能模块。

    第二个项目的战斗系统是谁搭建的我并不知道但由我的同事旸仔维护我记得一开始项目里也只有技能和Buff模块后来旸仔在处理被动技能需求时也增加了被动技能模块 —— 果然是同龄人。。。

项目共性

    如果看细节的话,这两个项目的代码差异是非常大的;但从整体(框架)上看,两个项目的内核其实是相同的。

    首先从整体上看角色都可以分为5大块

  1. 属性:属性即角色身上的数据,包括:攻防血、坐标朝向、模型、等级、名字...

  2. 主状态机:用于管理角色的的主要状态切换,通常与角色的模型控制相关

  3. 主动技能通常由玩家操作施展且通常会控制角色的模型动作Action

  4. 被动技能:通过养成系统或装备获得的效果,获得后立即生效,无时间限制

  5. Buff角色身上的增益或减益状态通常是临时性的

    其次主动技能都是基于FSM + Action的框架实现的FSM管理技能的主流程Action实现具体的效果至于Buff和被动技能也都是采用类似Action的方式实现的只是接口抽象上稍有不同。

// 技能执行上下文

Q为什么坐标朝向和模型被标粗体

A坐标、朝向、模型Model及其动作Action与受击包围盒和攻击包围盒的计算相关非常重要。

主状态机

    其实这两个项目的服务端都没有显式定义角色主状态机而是根据角色的属性移动状态、死亡标识、Buff等来处理的互斥我觉得是一个失误。

    为什么应该定义主状态机?

    从逻辑层的角度讲,将行走、施法、死亡、冰冻等纳入同一个状态机,可以让角色的主要状态及其互斥逻辑更为清晰。

    从表现层的角度讲,角色模型一次只能播放一个动作,因此也需要一套状态机来管理;而要想表现层的状态机更容易编写,逻辑层涉及到模型控制相关的逻辑最好也在同一套状态机中。

图片

主要缺陷(重复感)

    在我实现需求和维护的过程中,我感受到的最大缺陷是:重复感。

    被动技能和Buff效果存在交集被动和Buff的Action代码存在一定的重复。这种重复感一直困扰着我我分不清这是真实的重复还是表面的重复 —— 既想合并它们,又觉得它们不该合并。

    此外我明确的感知到项目的战斗框架无法支持DNF那般强大的战斗系统 —— 距离还很远。

PS其实主动技能的效果和Buff、被动也会有一些重复但重复率不高而且由于主动技能流程的特殊性我们总是认为主动技能就应该是独立的。

核心问题是什么?

    开发者站在了用户的角度来设计架构。

    我们在玩游戏的过程中建立了主动技能、被动技能和buff概念在实现需求时便不假思索地设计了对应的抽象。可我们所谓的主动技能、被动技能、Buff之间的差异真的存在吗它是表面的还是真实的

    这样去做战斗系统的人 —— 比如我,大概率是没有思考过这个问题的。


2.被动是特殊的Buff

图片

    虽然被动和Buff具有较大的差异但仔细思考可以发现被动技能可以是Buff的子集被动技能的那些特性Buff也是可以拥有的而且用Buff支持被动技能并不需要太大的改动量 —— 主要处理对站街面板的加成等。

    将被动技能实现为Buff后不仅消除了被动技能和Buff之间的重复感也扩展了Buff系统。

    该架构比较符合大家认知采用率较高。不过采用该架构的项目并不全是从前一种架构演进来的也有一开始就用Buff实现被动技能的 —— 这是明智的;如果是从前一种架构演进来的,想必已有一点体会:过于明确的抽象,对战斗系统是有害的。

    PS关于抽象带来的扩展性障碍阅读《角色框架GameObject类型实现》可能会有所帮助。


3.被动技能后台施展

图片

    被动技能和Buff合并的最大障碍是数据依赖不同被动技能效果依赖的是SkillData而Buff效果依赖的是BuffData。

    既然被动技能也依赖SkillData那么是不是可以让被动技能也走主动技能的流程呢即被动技能挂载后按主动技能流程自动施展但不占用角色主状态机。

public class SkillComponent {

PS当年在第一个项目的时候我就没想到这么做 —— 还是能力不足。

    将被动和主动一视同仁,意味着支持多技能同时施展,有以下优点:

  1. 支持被动技能转主动技能施展,被动技能在满足条件的情况下可以手动施展

  2. 支持主动技能间断性施法eg技能A第一击技能B第一击技能A第二击...

后台技能 VS Buff

    将被动技能看做主动技能,更符合代码层抽象 —— 程序员们会更容易理解。

    但将被动技能看做Buff更符合业务层的抽象 —— 策划们会更容易理解此外用Buff实现被动还避免了效果交叠带来的重复代码。

思考题

    被动技能和主动技能有共同点又和Buff有共同点那是不是说主动技能和Buff有共同点


4.一切皆Buff

![图片](data:image/svg+xml,%3C%3Fxml version='1.0' encoding='UTF-8'%3F%3E%3Csvg width='1px' height='1px' viewBox='0 0 1 1' version='1.1' xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink'%3E%3Ctitle%3E%3C/title%3E%3Cg stroke='none' stroke-width='1' fill='none' fill-rule='evenodd' fill-opacity='0'%3E%3Cg transform='translate(-249.000000, -126.000000)' fill='%23FFFFFF'%3E%3Crect x='249' y='126' width='1' height='1'%3E%3C/rect%3E%3C/g%3E%3C/g%3E%3C/svg%3E)

    在市面上还存在一种架构一切皆Buff。

    从字面上看我的理解是主动技能在施展的时候也是给自己添加一个Buff然后通过Buff来实现所有的功能。

    我相信肯定有读者在看见这句话的时候心生抵触被动技能可以说是Buff但主动技能怎么能是Buff呢

    是啊不仅仅是读者觉得主动技能和Buff不一样俺也一样甚至那些说着“一切皆Buff”的团队也认为不一样

    所以,他们最终的实现并不是我理解的那样。根据职责转移的粒度,分两种情况:

  1. 技能流程由Buff构成

  2. 技能效果皆是Buff


4.1 技能流程由Buff构成

图片

    在技能设计中技能被分为多个阶段每个阶段由一组SkillAction构成SkillAction中包含了打击点、触发间隔、攻击包围盒等信息而在Buff中同样存在这些配置。

    因此可以将技能的SkillAction看做Buff这样技能便可以看做是Buff的集合在特定的阶段挂载特定的Buff即可。

// 技能执行上下文

主要缺陷(交互)

    并非所有的技能都是简单的时间线施法因此技能框架仍然需要是FSM式的而由于SkillAction的消失Skill成为空壳因此Buff需要负责技能的阶段切换所以Buff系统和技能系统存在复杂的交互逻辑。

    此外将SkillAction委托给Buff概念上接受起来还是有难度。

4.2 技能效果皆是Buff

    技能加Buff是很常见的效果那么造成伤害、治疗这些瞬间的行为为什么不能通过Buff绕一下实现呢

    所以这里又有另一种实现技能管理流程Buff执行效果即技能执行到打击点时总是通过给目标挂载Buff来实现效果。

public class SkillAction {

    不过由于从技能转移过来的效果大多是瞬时Buff为了避免不必要的同步BuffMgr需要屏蔽这些Buff的同步。

主要缺陷Buff量

    用Buff来执行技能效果其实是将Buff作为了适配器适配是有代价的。

  1. 造成伤害、治疗等效果的体量非常大全部配置在Buff表会导致非常大的Buff量。

  2. 技能效果的上下文变得更复杂,会增加效果的实现难度

  3. Buff的增删频率极大Buff需要池化

    可惜将主动技能真正实现为的Buff是更正确的方案 —— 终究是我们先入为主的观念阻碍了我们。


5.返璞归真一切皆状态State

![图片](data:image/svg+xml,%3C%3Fxml version='1.0' encoding='UTF-8'%3F%3E%3Csvg width='1px' height='1px' viewBox='0 0 1 1' version='1.1' xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink'%3E%3Ctitle%3E%3C/title%3E%3Cg stroke='none' stroke-width='1' fill='none' fill-rule='evenodd' fill-opacity='0'%3E%3Cg transform='translate(-249.000000, -126.000000)' fill='%23FFFFFF'%3E%3Crect x='249' y='126' width='1' height='1'%3E%3C/rect%3E%3C/g%3E%3C/g%3E%3C/svg%3E)

    在第三个项目,在经过较长时间的探索后,我成功将第一个项目的战斗架构演进为这套“一切皆状态”的架构。

    它何以称得上返璞归真?继续阅读,你便知晓。

状态组件

public class StateComponent {

    状态组件可以和传统架构的Buff组件类比它存储着角色身上的所有状态和状态槽。

状态

public class State {

什么是状态?

    状态在业务层面的含义,读者可以自行理解。我想告诉大家的是:状态即脚本!只有将状态看做脚本,你才能理解这套设计的强大。

    现在不论是主动技能、被动技能还是Buff都变得很简单就是给GameObject添加一个状态也就是给GameObject挂一个脚本视野是不是一下就打开了

    这像不像我们在Unity下通过MonoBehavior扩展GameObject的行为所以在业务层面State就是GameObject的行为组件。

状态脚本的实现

    状态的最终逻辑由行为树(任务树)承担,可阅读《行为树:事件驱动版实现》《行为树返璞归真TaskTree》。

    行为树并不是唯一选择,但是是很好的选择;如果不选择行为树,只需要提供一个类似的脚本抽象即可,核心抽象方法就两个:

public abstract class Task<Blackboard> {

为什么是“状态”而不是“Buff”或其它

    回答几个问题即可:

  1. 跳跃是不是Buff死亡是不是Buff施展技能是不是Buff

  2. 跳跃是不是状态?死亡是不是状态?施展技能是不是状态?流血是不是状态?冰冻是不是状态?一切持续性的效果是不是都可以定义为状态?

  3. Buff是不是状态的子集Buff是角色获得的增益或减益状态

状态带来的改变

    前面的框架有一个公共问题主状态机的状态和技能、Buff的互斥不容易处理。

    在前面的框架下处理死亡等逻辑时通常都较为麻烦通常需要编写特殊的代码来处理。这其实是因为主状态、技能和Buff不属于同一概念不同概念下的事物无法进行比较也就无法简单处理互斥而将一切都纳入状态后我们便可以通过统一的互斥规则代替这些特殊的互斥代码。

主动技能真的也可以吗?

    被动技能和Buff的处理想必大家不会有太多疑问但当前施展的主动技能需要支持外部查询该怎么处理呢

    需要“状态发布”,即状态在启动时将自己发布到关联的组件。至于状态发布的方式,我们在后面的篇章详细讨论。

public class SkillComponent {

状态槽

public class StateSlot {

静态槽和动态槽

    静态槽表示预设的状态槽在创建GameObject时即分配动态槽则在运行的时候根据需要创建。

什么是状态槽?

    我们可以通过电脑的进程和网络端口来辅助理解,先看图:

图片

    状态必须先绑定到状态槽才可以执行,好比网络应用需要先绑定网络端口;当多个状态需要绑定在同一个状态槽时,将发生状态槽冲突,将挤掉既有的状态。

    一些状态只能挂载在指定槽上跳跃、死亡、冰冻等只能挂载在1号槽静态槽也就是说我们的1号槽充当了前面架构中的主状态机。因此我们也可以通过1号槽查询角色的主状态。

    所以状态槽就是个资源句柄,有两个作用:

  1. 实现状态的互斥

  2. 提供额外的查询


演进过程

    虽然我在上面举了Unity的MonoBehavior例子但该框架并不是直接通过组件模式演进来的而是慢慢演进为组件模式的 -- 过程曲折。我在重新实现战斗框架的时候,着重关注三件事:

  1. 将行为树引入技能和Buff系统见《行为树:序

  2. 如何消除主动技能、Buff、被动技能效果交叠带来的重复感

  3. 如何实现技能修改器

    将行为树引入技能和Buff系统由于行为树的实现错误这件事花了很长时间才完成在这之前我为了消除重复感尝试过一个方案为主动技能、被动技能、Buff提取一个公共的效果Effect抽象。

图片

    为了尽可能保证接口的通用性,接口参数仅一个黑板,如下:

public interface IEffect {

    但在实现具体效果的过程中发现由于技能执行上下文SkillContext和Buff是不同的抽象类型导致Effect还是需要独立实现 -- 需要从不同的类型上读取数据。

    这个问题困扰了我好久由于我坚持认为技能和Buff是不同的东西因而无法合并它们的抽象但随着行为树和黑板在项目中的应用越来越多范围越来越广我增加了些体悟

  1. 要想提升系统的灵活性,应尽量使用黑板代替明确的数据结构定义 —— 关注业务而不是Class和Interface

  2. 在使用黑板的时候应尽可能直接将数据set到黑板 —— 即依赖注入,避免反向依赖

    在这一系列思想的影响下我发现技能和Buff也不是不能合并了在偶然的灵感之下我决定使用状态State来表达合并后的抽象。

    在确定使用状态这个概念后我便联想到了跳跃和死亡等主状态机上的状态我发现它们也可以纳入到新的状态管理中。为方便管理主状态的互斥以及查询于是增加了状态槽StateSlot

    在获得最终的结构后,我发现了两点不同:

  1. 我能证明设计的正确性!不论是拿计算机下的进程来举例,还是拿组件模式来举例,都能证明它的正确性;而在以前的项目中,我都没有这个感觉,只是觉着应该是这样的。

  2. 我看待GameObject的视角被拉高了。在之前我设计技能和Buff时就只是盯着技能和Buff模块看而现在是盯着整个GameObject在设计 —— 所以结果必然更正确。

    “证明设计的正确性”是我在该项目获得的重要习惯,这为后面的技能修改器设计起到了极大的作用。


结语

    “一切皆状态”这套架构有极强的扩展性和灵活性,在我看来,基本不会有更好的抽象了。

    读者要想掌握它,关键在于打破通过玩游戏获得的先入为主的观念 —— 不要从玩家的角度去设计战斗系统。