0%

基于NodeCanvas的AI框架

一、引言

之前项目中重构过AI的框架,基于NodeCanvas实现,不过现在项目已经无了,所以想总结下之前重构的思路,做个备忘。

二、设计目的

  • 实现行为树与客户端逻辑的解耦:作为Plugin,保证行为树框架层面没有客户端相关的逻辑和数据,即行为树只负责根据数据做决策,不知晓上层具体的游戏逻辑,可以实现行为树框架在不同游戏之间的移植。
  • 对现有的行为树Task做扩展:使其能够满足策划的行为树配置需求,兼顾配置过程的灵活性和易用性。应对变化的需求需满足开闭原则。
  • 同时支持控制游戏玩法:行为树不光需要控制游戏角色,还需要支持控制某一特定玩法。在某一玩法中,实现行为树的分层,即不光有每个游戏个体的行为树,还有控制整体玩法流程的行为树。

三、实现方案

整体结构

黑板

基本介绍

NodeCanvas中的黑板细分可分为三种:全局黑板、Obj黑板,Asset黑板,区别如下:

  • 全局黑板

全局黑板挂载在游戏物体上,可以同时存在多个全局黑板,通过唯一的Identifier区分。客户端可以通过获得游戏物体上的GlobalBlackboard组件读写黑板上的变量。任何行为树可以在变量选择的下拉菜单中选择场景中存在的全局黑板变量。

  • Asset黑板

Asset黑板存在于行为树中,和行为树一起保存在Asset文件中,客户端可以通过行为树获取到其中的blackboard。Asset黑板无法储存场景中的引用。

  • Obj黑板

在编辑器中,给物体挂上BehaviorTreeOwner时,会自动添加Obj黑板,该黑板会与游戏物体绑定,可以储存场景中的引用。和全局黑板一样也可以通过获取物体上的组件来获取它。但因为游戏中的行为树脚本都需要动态加载,所以使用的都是Asset黑板,没有用到Obj黑板。

框架中黑板的使用

在该AI框架中,客户端和行为树之间传递数据大多依赖黑板。

  • 对于AI物体自身的数据(如怪物的血量、状态等)都通过自身行为树上的黑板储存交换。游戏层通过IAIUnit管理此黑板,对外提供交换数据的接口。
  • 对于全局的数据(如全局游戏状态,场景数据、任务数据等)通过全局黑板储存交换,游戏层通过单例AIManager管理多个全局黑板,对外提供交换数据的接口。
  • 客户端和行为树的数据交换有两种模式:
    • 推模式:客户端主动将数据写入黑板,需要提前定义好所需的数据,并保证在数据更新时都能通知到黑板。
    • 拉模式:在行为树需要数据时通过命令告诉客户端,客户端将数据写入对应黑板的变量,需要提前定义好命令和相应的变量。

命令接收器

为了将具体的逻辑代码从行为树中移到客户端,添加了客户端和行为树之间命令收发的机制。命令接收器是除了黑板外第二个重要的客户端和行为树交换数据的桥梁,起到转移命令的作用。

ICammandReceiver

在行为树层定义接口ICammandReceiver,客户端的CommandManager实现具体方法。需要接收命令的模块持有一个cmdReceiver,将需要相应的命令接收器写到行为树黑板上,行为树就可以选择对应的命令接收器触发命令了。

1
2
3
4
5
6
7
8
9
10
public interface ICommandReceiver
{
void ExecuteCmd(CommandEnum name, Action<bool> callback = null);
void ExecuteCmd<T>(CommandEnum name, T param, Action<bool> callback = null);
void ExecuteCmd<T1, T2>(CommandEnum name, T1 param1, T2 param2,
Action<bool> callback = null);
bool ConditionCheck(CommandEnum name);
bool ConditionCheck<T>(CommandEnum name, T param);
bool ConditionCheck<T1, T2>(CommandEnum name, T1 param1, T2 param2);
}
AI单位:IAIUnit

IAIUnit为游戏中的AI单位接口,其中包含行为树、黑板和命令接收器,并需要实现初始化、创建启停AI、读写变量的方法。任何需要受到行为树控制的模块需要实现IAIUnit,如角色单位AIUnit和玩法AIGamePlay。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public interface IAIUnit
{
BehaviourTreeOwner BtOwner { get; set; }
IBlackboard MyBlackboard { get; set; }
ICommandReceiver CmdReceiver { get; set; }
void Init();
void InitCmd();
void StartAI();
void StopAI();
void DestroyAI();
void ChangeAI(BehaviourTree bt);
void SetVariable<T>(string name, T value);
T GetVariable<T>(string name);
void CreateAI(GameObject go, BehaviourTree bt);
}

全局AI管理器:AIManager

AIMamager为客户端的AI管理器。

  • 维护所有AI单位,对外提供创建删除和启停AI的接口,创建时完成游戏物体和AI的绑定,所有角色的AI以ID为索引储存到AIManager中,当玩法的AI创建后也会储存在AIManager中。若行为树之间需要数据的传递和通信就可以通过AIManager进行。
  • 维护全局黑板,包括全局黑板的创建、提供读写变量的接口。
  • 维护全局命令接收器。当行为树发出全局相关的指令时就由AIManager执行。

四、遇到的问题和🕳

  • Task对泛型的扩展

    由于NodeCavas只支持一个泛型参数的Task,而要想将发送命令Task做的通用,满足带任意类型、任意多个参数还能定义任意类型的返回值就遇到了较大的限制。目前对源码进行了扩展,使其能够支持两个参数泛型类的创建。但随着参数个数增加,逻辑复杂度和性能会呈指数级上升,原因在于底层会在编辑时打开泛型菜单时就把所有类创建完毕,如果Unity中的类型有100种,那么一个泛型只需要创建100+100(List)+100(Dictionary)种,两个泛型就有300*300种,当个数达到三个时复杂度就已经超出承受范围了。目前两个参数已经能满足大部分的需求,后续在这块上再考虑如何优化突破限制。

  • 打包后类型丢失

    当行为树中有增删类型或是增加新的泛型Task时,在打包前需要世界之树(Giant Tree)/类型管理(Types Editor)中重新生成AOTClasses.cs和link.xml文件,以防止类型被裁减。

  • 性能相关问题
    • 在开启行为树时,会根据行为树资源Clone一个新的实例,这块涉及到Json的反序列化,性能开销较大。因此在GraphOwner中使用字典private Dictionary<Graph, Graph> instances = new Dictionary<Graph, Graph>();对实例进行了缓存,key为graph源文件,value为实例,只要GraphOwner不被销毁,反复开启行为树不会有额外的开销。
    • 同时,在外部(加载Asset的地方)还需要对行为树Asset文件做对象池,在反复刷怪时可以避免反复加载资源文件。
    • 在Variable类中禁止类型自动转换,可以避免类型转换带来的拆装箱。