今天抽出一点空闲时间给大家讲解一下AI的设计实现思路的常见方法,学习游戏中那些复杂的AI行为是如何实现的。
为什么需要这些设计?
使用行为树和有限状态机的意义在于它们提供了灵活且结构化的方式来控制游戏AI的行为逻辑。当然最重要的在于其传统switch case/if else对于AI行为的分层解耦性差,及其容易导致后期一个脚本中大量的代码,不利于维护和其他人阅读,修bug也非常困难。因此需要使用良好的设计模式解决这一问题。
有限状态机
(1)概念
是一种基于状态的决策系统,它通过不同的状态和状态之间的转换来管理角色的行为。每个状态定义了一组特定的行为,当满足一定条件时,角色会从一个状态转换到另一个状态。状态机的主要优势在于它的简单性和可预测性,非常适合处理明确的状态变化场景。
当然太复杂的概念看着脑壳痛,所有这段话是个什么意思呢?简单来说,状态机就是保存了一组AI可能会执行的方法,不同状态机保存不同的方法,通常情况下AI会一直执行这个方法体,直到,当某个状态(if条件)被满足后,AI就会切换状态,改变执行的方法然后继续执行新方法体中的内容。
按照图来讲就像下面那样:
默认AI是在移动这个状态中执行行为,如果发现了玩家或一定时间后就会转移到当前的状态,然后继续这个状态执行下去直到其它状态的条件被满足后。
(2)实现
以下是最基础的有限状态机的实现代码,具体状态机的实现方式是不止这一种的,我提供只不过是一个比较简单的泛用型状态机。
/// <summary>
/// 有限状态机接口,运行构造简单的有限状态机
/// </summary>
/// <typeparam name="T">泛型参数,约束MonoBehaviour</typeparam>
public interface StateNode<T> where T : MonoBehaviour
{
/// 到达条件
public Func<bool> State { get; set; }
/// 可转换状态表
public List<StateNode<T>> StateList { get; set; }
/// <summary>
/// 进入一次时执行
/// </summary>
public void EnterState(T t);
/// <summary>
/// 每帧更新执行
/// </summary>
public void UpdateState(T t);
/// <summary>
/// 退出时执行
/// </summary>
public void ExitState(T t);
}
/// <summary>
/// 有限状态机控制器
/// </summary>
/// <typeparam name="T">泛型参数,约束MonoBehaviour</typeparam>
public class StateController<T> : RootNode where T : MonoBehaviour
{
public Func<bool> Condition { get; set; }
/// 当前的状态节点
protected StateNode<T> CurState;
//保存的参数
protected readonly T t;
//构造方法
public StateController(T mono,StateNode<T> StartState, Func<bool> condition)
{
CurState = StartState;
Condition = condition ?? (() => true);
t = mono;
}
/// <summary>
/// 执行当前状态机并判断是否满足切换条件
/// </summary>
public void DoTree()
{
if (!Condition()) return;
CurState.UpdateState(t);
foreach (var v in CurState.StateList.Where(v => v.State()))
{
CurState.ExitState(t);
CurState = v;
v.EnterState(t);
break;
}
}
}
(3)解析
RootNode是一个我自己基于自己需要的模块化设计思路工具而所定义的接口,大家不需要在意,其它代码内容我会一一解析:
StateNode 状态机接口
StateController<T> 状态机控制类
我们可以使用任何一个普通的C#类(简单来讲就是这个类不需要继承自MonoBehaviour)然后T泛型参数改为你需要实现的AI的类型即可,(where是做泛型约束,让只有继承了MonoBehaviour的类才可以被封装)这样可以更好的规范状态机的使用范围。
然后,我们可以在状态机对应的方法体中利用获取的T参数做各种想要的基本行为,比如这个状态机类是对应AI的移动,那么就只需要在每帧更新的方法中写入 t.transform += (...控制移动的代码) 就可以,StateList则是封装到达状态机,用于标识可以达到的其它的状态机,最后定义其State即满足这个进入状态机的条件(比如说进行类初始化的时候)就算完成了一个简单的状态机定义。
状态机控制类则是控制这些状态之间的转换和执行的,在Update或者FixedUpdate调用控制器的DoTree()方法时,会每帧执行当前状态机CurState的方法,然后遍历判断其它状态机的条件是否被满足,当满足后,就会执行退出当前状态机的方法然后替换当前状态机位下一个状态机并执行对应的开始时执行一次的方法。(控制器的T是保存当前AI类的脚本的,这样对应的状态机进入时就可以拿到AI的脚本T)
(4)扩展
状态机虽然可以一定程度上解耦AI设计和实现上的困难,但是如果AI的行为非常的复杂就会导致出现大量的状态机节点,然后这些状态机之间的相互转化就会变得复杂,如果下面这样:
上面的转换网可能还不够直观,但是如果有多加入了几个状态(比如没有确定的其它),那么每一次扩展可能都需要对当前的状态关系转换修改很多次,导致维护起来非常麻烦。
其中一种解决方法就是使用分层的有限状态机,将一部分合并为一个子状态机,子状态机处理内部的行为,比如将上述的攻击和技能合并为一个状态机,来处理AI的攻击行为。当然还有很多其它的办法,不过我就懒得讲了(其实是没了解dog)
行为树
(1)概念
行为树是一种分层的决策树,通常用于实现复杂的行为逻辑。它将各种行为组织成节点,并通过条件来控制节点的执行顺序。行为树的主要优势在于它的灵活性和可扩展性,可以轻松地添加、修改和组合不同的行为模块。
简单来说就是把AI的行为转换为一种树状关系,利用条件来决定不断拆分AI的行为逻辑,这样说可能有点抽象,不用担心,上图:
这样的话大家就可以看出,行为树的运行逻辑,通过检查某一条件是否成立从而决定转到对应的节点下,直到找到对应的行为叶子节点,完成一次行为的执行。
(2)实现
/// <summary>
/// 基节点,行为控制器基础
/// </summary>
public interface RootNode
{
/// <summary>
/// 执行条件
/// </summary>
public Func<bool> Condition { get; set; }
/// <summary>
/// 树执行-仅限BaseNode
/// </summary>
public void DoTree();
}
/// <summary>
/// 行为树节点基类
/// </summary>
public abstract class BaseNode : RootNode
{
/// <summary>
/// 保护构造,避免示例BaseNode方法
/// </summary>
/// <param name="Condition"></param>
protected BaseNode(Func<bool> Condition)
{
this.Condition = Condition ?? (() => true);
}
/// <summary>
/// 不使用bool,因为用于动态检测条件是否成立
/// </summary>
public Func<bool> Condition { get; set; }
/// <summary>
/// 非特殊用法不推荐进行继承然后重写的函数
/// 如果是直接继承此类需要定义新的行为模式
/// </summary>
protected abstract void Excute();
/// <summary>
/// 因为Excute()需要受到保护而不能被执行,所以需要一个基类中的构造执行被保护的方法
/// </summary>
/// <param name="Node">需要执行的节点</param>
protected void Execute(BaseNode Node) => Node.Excute();
/// <summary>
/// 外部唯一可以启用行为树的方法,也能确保基类为行为节点和列表节点时余弦判断合法性
/// </summary>
public void DoTree()
{
if (Condition()) Excute();
}
}
/// <summary>
/// 行为树行为节点类
/// </summary>
public class ActionNode : BaseNode
{
protected Action action; //行为
/// <summary>
/// 构造方法,行为树行为节点对象
/// </summary>
/// <param name="action">行为方法</param>
/// <param name="Condition">可执行判断条件</param>
public ActionNode(Action action, Func<bool> Condition = null) : base(Condition) => this.action = action;
/// <summary>
/// 执行传入的函数
/// </summary>
protected sealed override void Excute() => action();
/// <summary>
/// 重设行为节点参数,action为null将不会有修改作用
/// </summary>
/// <param name="NewAction">行为方法</param>
public void ReSetAction(Action NewAction) => action = NewAction ?? action;
}
/// <summary>
/// 行为树复合节点类
/// </summary>
public class ComopositeNode : BaseNode
{
protected readonly List<BaseNode> Nodes; //节点列表
/// <summary>
/// 构造方法,构造一个复合节点,它会判断并执行第一个满足条件的节点
/// </summary>
/// <param name="Nodes">子节点列表</param>
/// <param name="Condition">判断条件,默认不写为true</param>
public ComopositeNode(List<BaseNode> Nodes, Func<bool> Condition = null) : base(Condition) =>
this.Nodes = Nodes;
/// <summary>
/// 用于遍历所有节点,然后执行找到满足条件的节点
/// </summary>
protected override void Excute()
{
foreach (var Node in Nodes.Where(Node => Node.Condition()))
{
Execute(Node);
return;
}
}
/// <summary>
/// 清空行为列表
/// </summary>
public void ClearNodes() => Nodes.Clear();
}
以上 是一个我目前自用的行为树设计,(当然这只是部分代码,还有一些基于其它用途的节点,但这里只是先展示最基本的设计思路)当然具体要什么样的行为树设计方案还是需要你自己来,我只是提供一个方案设计。
(3)解析
基本思路代码注释中已经详细解说了,而且原理思想和状态机其实差不多,只不过方法的执行从通过接口继承实现变为了new对应的类对象传入对应的Action实现方法即可。
三个基本的节点的作用如下:
BaseNode用于定义行为树的根,利用DoTree()方法,它会按照你设计的行为树结构进行执行,ActionNode负责封装这个AI的行为,而ComopositeNode则是包装一堆行为树的节点,然后根据条件执行,找到满足的节点执行后就会返回退出,所有的非抽象节点都有默认的条件判断,在没有设置的情况它们始终返回true。
public void Start()
{
AITree = new NoSequenceNode(new()
{
new ActionNode<string, bool>("Victory", true, NetAIState.AIAnim.SetBool,
() => !NetAIState.PlayerObject),
new NoSequenceNode(new()
{
new ActionNode(TrackPlayer),
new ActionNode(ToJump, NeedJump)
},() => NetAIState.AIState == NetAIState.AIEnum.Normal),
new ActionNode(ToAttack,
() => AttackTick.Expired(Runner) && NetAIState.PlayerDis <= NetAIState.AttackRange),
}, () => NetAIState.IsOnGround && NetAIState.AIState != NetAIState.AIEnum.Hurt);
}
public void FixedUpdate()
{
if (NetAIState.AIState is NetAIState.AIEnum.Die or NetAIState.AIEnum.Hurt) return;
AITree.DoTree();
}
public void TrackPlayer()
{
//运动到玩家所在位置
var PlayPos = NetAIState.PlayerObject.transform.position;
var DisX = Mathf.Abs(PlayPos.x - transform.position.x);
//持续向玩家方向运动
if (DisX > NetAIState.AttackRange * 0.5f)
NetAIState.AIRigid2D.velocity = new Vector2(NetAIState.TrackSpeed * (
transform.position.x < PlayPos.x ? 1 : -1), NetAIState.AIRigid2D.velocity.y);
else NetAIState.AIRigid2D.velocity = new Vector2(0, NetAIState.AIRigid2D.velocity.y);
//改变朝向位置
if (DisX > NetAIState.AttackRange * 0.3f)
transform.GetChild(0).rotation = Quaternion.Euler(0, transform.position.x > PlayPos.x ? 0 : 180, 0);
}
public bool NeedJump()
{
var PlayPos = NetAIState.PlayerObject.transform.position;
if (Mathf.Abs(PlayPos.y - transform.position.y) < 3) return false;
if (JumpTick.Expired(Runner) && Mathf.Abs(PlayPos.x - transform.position.x)
< NetAIState.AttackRange)
{
JumpRateTick = TickTimer.CreateFromSeconds(Runner, JumpTime);
return true;
}
if (!JumpRateTick.Expired(Runner)) return false;
JumpRateTick = TickTimer.CreateFromSeconds(Runner, JumpTime);
return Random.Range(0, JumpRate) > 50;
}
//...略
这是一段我的行为树如何使用的案例,可以当做参考【其中NoSequenceNode为无序执行(自用设计的),即会把所有满足了的条件的子节点进行执行,ActionNode<string, bool>是可以用来额外封装传入两个参数(仅针对引用类型)这些都是自用的扩展,】,利用行为树可以比较明显的看到各个关系之间的层级,而且对于复杂的行为进行扩展时只需要往对应位置中插入对应的ActionNode或者其它节点然后实现对应的方法就可以了。
** (4)扩展**
行为树的一个上位就是决策树,通过收集数据来影响AI的下一步决策,让AI行为可以比较动态的变化并变得更聪明,不过具体我也不是很了解,有兴趣的大家可以去学习一样。
优缺点
行为树的优缺点
优点:
- 灵活性:可以通过添加、删除、修改节点来轻松调整行为。
- 模块化:行为可以分离成独立的模块,便于重用和维护。
缺点:
- 复杂性:随着行为树的规模增大,树结构可能变得复杂,难以管理。
- 性能:复杂行为树可能会导致性能开销,尤其是在节点层次很多的情况下。
有限状态机的优缺点
优点:
- 简单性:每个状态都非常明确,容易实现和理解。
- 确定性:状态转变条件明确,行为逻辑清晰可控。
缺点:
可扩展性:添加新的状态和行为可能需要修改大量已有代码,导致维护困难。
灵活性:不适合处理复杂和动态变化的行为逻辑。
行为树扩展性是要强于状态机的,如果你自己用草稿设计一个AI是,往往能很容易设计出一个AI树状的行为转化关系,这对于开发有很大帮助,从我提供的图大家应该就能理解。但是为什么行为树会性能较差?因为行为树在运行时是会逐渐递归调用,过深递归是会影响性能,而另外一点在于,行为树会一一判断条件寻找到满足的那个条件,有可能达到这个行为需要经过很多层判断,其中非常多的可能并不必要,而状态机只会判断能够到达的状态。 而状态机在于难以设计其转化关系,打个比方,如果用过Unity的动画器控制器,当你有比较多的动画的时候线就会连接的密密麻麻非常难以维护(甚至有时候合理的进行设计子状态,混合树也不一定解决的了),对于简单的行为还好,但对于复杂的行为或者需要进行频繁的更改和扩展(比如商业项目就是如此)往往就会带来大量问题。 具体需要使用哪一个通过你的项目具体需要来说,另外除了状态机和行为树也有很多其它的办法,不过需要你自己去学习了。
本文转载自: https://blog.csdn.net/WEIWEI6661012/article/details/142833288
版权归原作者 什么奇怪的称昵 所有, 如有侵权,请联系我们删除。
版权归原作者 什么奇怪的称昵 所有, 如有侵权,请联系我们删除。