🚀 春季促销开始! 🚀 不要错过以7折优惠购买我所有系统的机会! 

点击购买

状态树队列系统

avatar`
Yuewu(罗传月武)
Updated: Apr 16, 2025

概述

制作AI是一个复杂且繁琐的过程,尽管虚幻引擎提供了行为树和状态树可用于AI决策和行为,但随着游戏AI的复杂度不断攀升,诸多问题会慢慢涌现:

  1. 复杂庞大的行为树/状态树,开发变得难以迭代。
  2. 由于行为树和状态树的灵活性,用户经常将行为和决策混合在一起。
  3. 重复的逻辑和计算在不同的地方反复出现且难以抽象。
  4. 关注点分散,颗粒度大小划分不清晰。

用户依然需要遵循一些更高级的设计模式和准则,才能更好的组织和管理游戏中的AI。

而命令系统作为一个辅助手段,帮助你更好地解决此类问题,它主要遵循如下几个设计要点:

  1. 决策和行为进行分离,在GAIS中,所有的个体AI行为,都可以用命令的形式来完成,个体AI只需要围绕给定的命令数据进行内部决策以完成该命令。(如果是攻击命令,那么AI就不应该在这个命令中给自己回血或者寻找补给。)
  2. 决策仅根据游戏内状态,决定该下达什么命令给个体AI。(自主AI可以自己给自己下达命令,非自主AI可以由玩家下达具体命令,又或者,导演AI协调多个其他AI,并下达合适的命令。)
  3. 命令是关注点清晰,职责范围明确,且可复用的。一次只做好一件事情。(不再需要庞大的行为树/状态树)
  4. 命令是可以被打断、可以排队、也可以插队。

下文是较为详细的系统设计文档,如果你不喜欢它,可以直接去看案例。

准备工作

命令系统只适用于游戏中的由AIController控制的Pawn及其子类。

要使用命令系统,你需要对UE5的StateTree有一个基础的了解。

  1. 添加CommandSystemComponent到Pawn上,这样Pawn才能执行命令,并确保为其选择一个Stop命令。
  2. 添加CommandStateTreeComponent到AIController上,这样AI才能够执行具备状态树的命令,只需要挂载,并不需要操作。
  3. 添加BlackboardComponent到AIController上,这样AI才能够通过黑板获取当前命令相关的数据。当然,黑板是可选的。

命令定义

命令定义是一个蓝图,用于定义一个命令相关的所有静态数据

比如:

命令在UI上要显示的内容,名称,描述,图标等。

命令是否可以被打断、它的成功/失败条件。

命令需要执行的AI行为,可以是行为树,也可以是状态树。

它也提供一些可覆写的函数,比如命令的目标是否有效,该Actor是否可以服从该命令等等。

你通过继承已存在的命令定义来创建新的定义,并按需修改配置,或者覆写逻辑。

命令类型

GAIS自带多种类型的命令,而你还可以通过蓝图或者C++拓展不同的命令类型。

最常用的是带状态树(WithStateTree)的命令,这类命令可以配置命令的执行者会跑什么状态树,这个状态树称之为命令状态树。

其底层原理是CommandComponent收到一个“带状态树命令”后,让CommandStreeTreeComponent跑了一个新的状态树。而这个状态树的Context就是命令接收者(Pawn)

GAIS中也会自带一些现成的命令供你使用,比如UseAbility命令,下图是一个典型命令的配置,及其状态树内部逻辑。

UI呈现

一个命令呈现在玩家眼中,需要包含如下信息。这些信息可以以游戏UI的形式呈现出来。

比如命令的名字,描述,它的正常、悬停、按下、禁用状态的图标。

假设玩家接管一个队伍、可以对队伍下达N多个命令,那么无论是以游戏圆盘的形式、还是列表的形式,都需要有如上的基本信息才能进行呈现。

这些信息是需要在每一种命令上去配置的。

执行策略

一个命令有多种执行类型,有的需要持续一段时间,且不能被取消,有些需要持续一段时间但是能取消;还有的命令只是一瞬间的。

瞬间:这种类型的命令,不会打断当前任何的命令,一瞬间就结束,比如“给一个Buff”。

可取消:这种类型的命令一般会有一个周期,比如移动命令,这类命令会在有其他命令的时候被取消。

不可取消:这种类型的命令一般会有一个周期,比如冲锋命令,但这类命令不能够被其他命令打断,需要命令自行结束。

目标类型

一个命令需要目标数据,但目标数据里会包含目标Actor,位置等信息,我们如何知道到底用哪些数据呢?这时候就需要一个命令目标类型,告诉我这个目标是基于方向(可由位置来表示,因为本质是一个向量)的,还是基于目标Actor的,还是基于一个位置的,然后用这个命令目标类型来使用合适的目标数据。

命令目标类型如下:

  • 无:无明确目标,
  • Actor
  • 位置
  • 方向

在程序中的实现是EGAIS_CommandTargetType枚举。

命令系统组件

游戏中的任意Actor(无论它是一个人类,还是怪物,还是一条狗),只要它挂载了CommandSystemComponent,都会接受各种各样的命令。无任何命令的时候,会有一个默认(Stop)命令。

命令系统组件维护一组命令队列,以及当前命令。

不同类型的命令可以采用不同的命令实现来完成,

比如有些命令会运行一套AI逻辑,有些命令会使用一套技能,有些命令则是执行一些纯粹的逻辑后马上结束,有些命令则会一直执行。

案例:

队伍平时都是“自由AI”命令,跑一套进攻AI逻辑。当队伍被接管后(被玩家下达接管命令),就会一直跟随玩家。当给接管中的队伍下达一个技能命令后,会在队伍当前命令(接管)前插入新的技能命令,技能结束后继续执行接管命令(如果这时候玩家已经退出接管)则回到“自由AI”命令。

由上可见,队伍会做3各命令,自由AI命令需要跑一套AI,接管命令队伍只需要跟着玩家(可能根本不算AI逻辑),技能命令可以是任意技能。

同时,针对每个命令、执行命令的单位,都有可能选择不执行,比如队伍在释放一个无法打断的技能

本部分的内容,阐述一个命令本身是什么,应该具备什么样的内容。命令如何发送、一个单位具体如何执行某种命令,由单独的设计去阐述。

一个命令在游戏引擎中的呈现就是一个命令资产,里面能配置命令的方方面面。

命令前置需求

一个命令能够正确下达需要满足一些前置情况,比如喊某个队伍去进攻一个敌方队伍,那么首先被命令的队伍应该没有崩溃、且被攻击的队伍没有死亡对吧;又比如红色警戒当中一个正在建造的单位,你也是不能下达某些命令的。所以被命令的Actor、或者命令所指向的目标Actor,都会有需要一些前置情况被满足后,才能正常下达命令。

所以一般一个命令的前置需求如下:

被命令的Actor需要满足的状态,如果都满足,命令才能下达。

比如命令一个士兵去攻击另外一个士兵,但是受命令的士兵前提是得活着才能执行。

被命令的Actor不能拥有的任意状态,如果有任意,命令无法下达。

比如命令一个士兵去攻击另外一个士兵,但是受命令的士兵处于眩晕状态,所以无法执行。

命令的目标Actor需要满足的状态,如果都满足,名才能下达。

比如命令一个士兵去斩杀另外一个士兵,那么另外一个士兵首先得濒死才能被斩杀,命令才会执行。

命令的目标Actor不能拥有某些状态,如果有任意,命令无法下达

比如命令一个士兵去攻击另外一个士兵,但另外一个士兵处于无敌状态,所以命令就无法执行。

一个命令能否成功下达,需要满足上面的所有需求。

而在程序实现中,我们使用标记(Tag)来处理Actor的状态信息。并使用FRTSOrderTagRequirements结构体来呈现。

简单来说,针对不同的命令,这种命令会根据所命令的单位或命令的目标Actor的状态变化从而产生某些时候不能用的情况。

命令的拒绝/服从

命令一般由A下达给B,比如老板给员工下命令,队长给士兵下达命令。但现实中(哪怕是游戏中),也会有B不服从A命令的情况。所以任何的命令,都有机会拒绝服从命令,并给命令发起者提供一些反馈。同样一个命令,给听话的人会被服从,给不听话的人就不会被服从。

所以每一个命令都能根据受命令的人是谁,来决定这个人会不会服从命令。

假设我们在战斗中有两个队伍,其小队长性格分别是胆小、和胆大。 那么同样一个冲锋命令,下达给胆大的队伍,队伍会立刻服从,而下达给胆小的队伍,有一定概率不会服从。 当然、无论他们胆大或者胆小,假如士气都非常低迷,可能都不会服从,但这样状态性的东西,可以通过上面的前置需求来满足。

简单来说,针对不同的命令,这种命令的拒绝和服从逻辑是一开始就定义好的,且游戏中的逻辑是不会变的(数据会)。

这种设计可以给我们留出空间、比如性格、或者忠诚度之类的东西都能在这个阶段体现出来。

命令的实现和拓展性

如概述中提到的,不同类型的命令可以采用不同的实现来完成,比如有些命令会运行一套AI逻辑,有些命令会使用一套技能,有些命令则是执行一些纯粹的逻辑后马上结束。

我们默认提供如下几类命令。

纯命令:专门用来进行继承并拓展的命令,包括下面几个预制命令都是由它拓展而来。而下面几个预制命令也是能够继续被拓展。

带行为树的命令:当受到命令的Actor成功服从这个命令后,就会跑一个新的行为树,当行为树结束,这个命令就结束。保留方案

带状态树的命令:当受到命令的Actor成功服从这个命令后,就会跑一个新的状态树,当状态树结束,这个命令就结束。

命令的接收器

上面提到了命令的实现,但是不是任何人都具备接受命令的能力,比如你不能喊一个残疾人去跑步。因为残疾人根本不具备跑步的能力,换到我们游戏里面就是,你不能喊一个StaticMeshActor去跑带AI逻辑的命令,因为它连AI控制器都没有。

再举例就是:

你想让一个单位接受一个带状态树的命令,但你都没有任何东西可以跑状态树。

你想让一个单位接受一个带行为树的命令,但你都没有行为树组件。

而我们游戏里,主要用到带状态树的命令,而为了让单位有能力执行这种命令,我们就做了一个组件(CommandStateTreeComponent)作为这类命令的接收器,它会根据接收到的命令里配置好的状态树,让单位跑这个状态树。因为同时执行的命令只有一个,所以也只会跑一个状态树。

命令结果

一个命令一般会有几种结果,成功、取消或失败,不同的命令会产生不同的结果。

命令下达流程

命令目标数据

任何命令,都会有一个目标数据,包含执行这个命令所需的数据。

比如“叫金生起床”这个命令就需目标数据,里面包含了金生。

比如“去建设银行办卡”这个命令就需要目标数据,里面包含了建行的位置。

目标数据一般包含如下内容:

  • 目标Actor
  • 目标位置
  • 目标状态

在程序中的实现是FGAIS_CommandTargetData结构体。

命令下达的方式有很多种,最简单的一种就是直接下达命令。

相同命令:

下达命令后,如果单位正在执行完全相同(同样的命令,但目标数据不一样就不能算完全相同)的命令则什么也不会发生。

当前命令处理:

如果新命令的执行策略是非瞬间的,就会判断当前命令是否可以取消,不能取消,就会进入命令队列(下文会讲。)然后流程结束;如果当前命令能被取消,就会结束当前命令,然后继续处理新命令。

新命令的处理:

任何一个新命令,在前面的流程已经处理后,就会根据命令的设计本身,判断单位是否可以服从这个命令。然后根据命令的目标数据、类型来判断这个命令的数据到底是否有效。这些都通过后,一个单位就可以真正地跑这个命令。

命令队列

命令队列,通俗点讲就是待执行的命令。一般在当前命令无法被打断但又收到了新的命令后会产生(还有命令插队、下文会讲到。)。

1.在给某单位下达非瞬间命令时,如果单位当前正在执行的命令不可打断(执行策略=不可取消),就会进入命令队列,当当前命令执行完毕后,会运行队列中第一个命令。

案例:如一个队伍正在执行冲锋命令,冲锋是不可打断的,但是这时候我给队伍下达一个去攻击某个队伍的命令,由于冲锋不可被打断,那么在冲锋结束后,才会执行进攻某个队伍的命令。

2.无论命令队列中有多少命令,如果有新命令下达,就会清空所有命令队列,然后重复1的流程。

举例:接1的案例,队伍当前命令是冲锋、队列中有1个待执行命令是我给队伍下达了移动到某处的命令。这时候如果我再下达一个进攻某个队伍的命令,那么命令队列被清空,由于冲锋无法打断,所以重复1的流程后,就变成了当前命令是冲锋,队列中有1个命令是进攻某个队伍。

2.如果命令队列已经有多个命令,那么在无任何新命令下达的情况下,会根据1的流程,把队列中的命令挨个执行完毕。

举例:这里就是矛盾的点,前面说到,只要有新命令下达,队列就会被清空。那命令队列怎么才会存在多个命令呢?答案是:命令插队。

命令插队

命令插队也是下达命令的方式之一。它不尊重命令的执行策略。

前插队

比如一个队伍正在执行进攻某个队伍的命令,我想让队伍先冲锋、冲锋完了再接着进攻某个队伍。就可以用到前插队。

前插队的意思就是,强制让单位执行给定的命令,并把单位当前的命令放到命令队列的第1位(因为是第1位,所以叫前插队)。给定的命令执行完后,之前被插队的命令就会以命令队列的流程重新执行。

后插队

在命令队列里有讲到,无论命令队列中有多少命令,如果有新命令下达,就会清空所有命令队列。但我们如何给定一系列命令,然后让单位挨着执行呢。这时候就要用到后插件,即不是直接给单位下达命令,而是往手动往单位的命令队列尾部(因为是尾部,所以叫后插队)去追加新的命令。

后插队很适合做一些比如让队伍按照规划的A、B、C3点进行移动的功能,按照命令队列执行流程,队伍会先走到A点,然后走到B点,然后走到C点。但如果我想打断这一队列,重新直接下达一个新的命令即可。