3D沙盒游戏开发日志11——决策层设计(行为树与优先级)

日志

本篇来详细讨论上篇中所简单介绍的决策层(一定要先看上一篇)

为了方便观看,也把上篇的框架图贴过来
请添加图片描述
之前已经说过在决策层上玩家和其它生物是由区别的,玩家使用的是简单的优先级决策EventController,其他生物使用的是复杂的行为树系统,玩家添加决策层只是为了结构上的对应,所以我将主要分析的是决策树部分。

决策树

也称行为树,实际上也不一定要用在决策层,用在动画层也是可以的,可以理解为加强版的状态机,它就是为了解决复杂的AI使用状态机会造成非常复杂的状态图而设计出来的,与以往一样我不会讲概念上的东西,我会主要从实际应用上可能的优化和遇到的困难出发来分析,如果需要看概念的话可以去看这几篇文章
1
2

  1. 首先决策树没有绝对的标准,举个例子有些人就把决策树的条件判断直接放在每一个节点内,有些人就设计单独的条件节点,有些人把节点状态作为更新的返回值,有些人返回void,这都是可行的。
  2. 你可以自己设计需要的常用节点表达,除了传统的复合节点、装饰节点、条件节点、动作节点外其实更常用的是一些通俗的表达,比如IFNode和WhileNode,含义显而易见,只不过是对传统节点进行了封装,使用这些节点能让代码看起来更清晰。
public class IFNode : Sequence
{
    public IFNode(Conditional.ConditionFn _fn, params ActionNode[] actions)
    : base(new List<BehaviourTreeNode>{new Conditional(_fn)})
    {
        foreach(var node in actions)
        {
            children.Add(node);
        }
    }
}
public class WhileNode : Parallel
{
    public WhileNode(Conditional.ConditionFn _fn, ActionNode action)
    : base(new List<BehaviourTreeNode>{new Conditional(_fn), action})
    {}
}
public class LoopNode : Sequence
{
    private int maxloop;
    private int looptime;
    public LoopNode(int _maxloop, List<BehaviourTreeNode> children) : base(children)
    {
        maxloop = _maxloop;
        looptime = 0;
    }
    public override void Tick()
    {
        int curr = runningChild;
        while(curr < children.Count)
        {
            children[curr].Tick();
            if(children[curr].status == NodeState.Running)
            {
                runningChild = curr;
                status = NodeState.Running;
                return;
            }
            else if(children[curr].status == NodeState.Failure)
            {
                runningChild = 0;
                status = NodeState.Failure;
                return;
            }
            ++curr;
        }
        looptime++;
        if(looptime < maxloop) status = NodeState.Running;
        else status = NodeState.Success;
        runningChild = 0;
    }
    public override void Reset()
    {
        base.Reset();
        looptime = 0;
    }
}
  1. 关于Tick,也有人叫做visit,指得就是更新节点,一般来说行为树会属于决策层,而决策层的更新不是和update一样的,决策层的更新要慢很多,因为没有必要每帧都做一次“思考”,Tick的间隔一般为0.25s或者0.5s,如果你想要它看起来更“迟钝”可以增长这个时间,如果你希望某个行动被立刻执行的话可以使用ForceUpdate来强制更新决策树(比如EventNode)而没有必要让生物在wander时飞快的思考造成无意义的性能消耗。

行为树和节点代码

public class BehaviourTree
{
    public BehaviourTreeNode root;
    public GameObject gameObject;
    public float interval;
    private float sleepTime = 0;
    private Dictionary<string, object> blackboard;
    //debug
    private string runningNode;
    public BehaviourTree(GameObject _go, BehaviourTreeNode _root, float _interval)
    {
        root = _root;
        root.parent = null;
        gameObject = _go;
        interval = _interval;
        blackboard = new Dictionary<string, object>();
    }
    public object TryGetValue(string key)
    {
        if(blackboard.ContainsKey(key)) return blackboard[key];
        return null;
    }
    public void SetValue(string key, object value)
    {
        blackboard[key] = value;
    }
    public void RemoveValue(string key)
    {
        if(blackboard.ContainsKey(key)) blackboard.Remove(key);
    }
    public void Tick()
    {
        if(root == null) return;
        if(sleepTime > 0)
        {
            sleepTime -= Time.deltaTime;
            return;
        }
        root.Tick();
        root.Step();
    }
    public void ForceTick()
    {
        root.Tick();
        root.Step();
    }
    public void Sleep(float time)
    {
        root.Reset();
        sleepTime = time;
    }
}
public abstract class BehaviourTreeNode
{
    public enum NodeState
    {
        Ready, Failure, Success, Running
    }
    public NodeState status;
    public BehaviourTreeNode parent;
    
    public abstract void Tick();
    public virtual void Reset()
    {
        status = NodeState.Ready;
    }
    public virtual void Step()
    {
        if(status != NodeState.Running) Reset();
    }
}
  1. 关于Blackboard,黑板这个概念一般会伴随行为树一起讨论,主要原因是行为树节点之间以及和外界需要有所联系,比如条件节点总要获取些参数才能做出判断,而行为树又是与游戏隔绝的(不派生自MonoBehaviour),所以黑板就成为了一个信息库。但我的项目并没有使用黑板(虽然保留了黑板的代码),因为之前说过我的数据全部存储在GameComponent中,所以只要每个节点能够访问到Gameobject,就能够访问到所有挂载的Component中的数据了。这也正是我想说的,黑板这一抽象概念并不一定适合直接放在游戏中,游戏的数据如此大量,你为每个数据在黑板中创建拷贝并关心它们的同步问题是相当糟糕的!

每个动作节点都保留有自己所操控的gameobject

//ActionNode的Reset中应当关注于自己Node内变量的Reset
//不需要关心Component的Reset,Component的Reset由ActionController的Interrupt负责
public abstract class ActionNode : BehaviourTreeNode
{
    public delegate bool BehaviourFn();
    public GameObject gameObject;
    public string name;
    public ActionNode(GameObject _gameObject, string _name)
    {
        gameObject = _gameObject;
        name = _name;
        gameObject.GetComponent<Brain>().AddLeafs(this);
    }
}
  1. 关于Reset,如上面代码的注释所说,ActionNode一般都会重载Reset,但切忌在reset中操作动画层和Component,这个reset是专门用于重置ActionNode内部声明的变量状态的,至于动画层和Component等到新的节点运行时调用新的DoAction自然就会取代,发生Action的Interrupted,简单点说就是每一层是每一层的Reset(比如动画层就是Interrupt),决策层的Reset不要操作别的层的事。
  2. 关于EventNode事件节点,专门说它是因为虽然常见的行为树概念中没有它,但实际上它至关重要。行为树需要负责两种决策,一种是没事时候的行为(wander等),另一种是在受到外界信号(event)时做出应对,EventNode就是处理EventHandler中引发事件的节点,它的各种行为就像动作节点一样,保留有gameobject,并且真一般会创建常用的EventNode(比如GetHit)。这里也就能看出为什么复杂的事件处理放在EventNode而普通的直接在Prefab中注册处理函数。EventNode是Update的,持续更新能执行更复杂的操作,并且它能调用动画层(动画层的调用必须是由决策层发出
public class EventNode : BehaviourTreeNode
{
    public BehaviourTreeNode child;
    public bool triggered;
    public GameObject gameObject;
    private BaseEvent e;
    public EventNode(GameObject _gameObject, BaseEvent _event, BehaviourTreeNode _child)
    {
        child = _child;
        child.parent = this;
        gameObject = _gameObject;
        e = _event;
        gameObject.GetComponent<EventHandler>().ListenEvent(e.name, OnEvent);
    }
    public void OnEvent(params object[] args)
    {
        if(status == NodeState.Running)
        {
            child.Reset();
        }
        triggered = true;
        e.fn(gameObject, args);
        gameObject.GetComponent<Brain>().ForceUpdate();
    }
    public override void Tick()
    {
        if(status == NodeState.Ready && triggered) status = NodeState.Running;
        if(status == NodeState.Running)
        {
            child.Tick();
            status = child.status;
        }
        else status = NodeState.Failure;
    }
    public override void Step()
    {
        triggered = false;
        if(status != NodeState.Running) Reset();
        else child.Step();
    }
    public override void Reset()
    {
        triggered = false;
        base.Reset();
        child.Reset();
    }
}

Brain

决策树就像PathFinder一样,是一个工具,不应该让它直接挂载在物体上,就像不会有Component直接访问PathFinder一样(总是通过MapManager),所以需要一个类来封装BehaviourTree并且提供一些简单的操作方法,它就是Brain。

public abstract class Brain : MonoBehaviour
{
    public BehaviourTree bt;
    public bool debug;
    private List<ActionNode> leafs = new List<ActionNode>();
    private bool inWork = false;
    private float lastTick = 0;
    public abstract void InitBehaviourTree();
    public virtual void InitBlackBoard(){}

    void Awake()
    {
        InitBehaviourTree();
        InitBlackBoard();
    }
    void FixedUpdate()
    {
        if(inWork && lastTick + bt.interval <= Time.time)
        {
            bt.Tick();
            lastTick = Time.time;
            if(debug) ShowRunningNode();
        } 
    }
    public void Begin()
    {
        inWork = true;
    }
    public void ForceUpdate()
    {
        bt.ForceTick();
    }
    public void AddLeafs(ActionNode node)
    {
        leafs.Add(node);
    }
    public void Stop()
    {
        inWork = false;
    }
    private void ShowRunningNode()
    {
        string s = this.GetType().ToString() + "\n";
        foreach (var item in leafs)
        {
            if(item.status == BehaviourTreeNode.NodeState.Running) s += item.name + " ";
        }
        Debug.Log(s);
    }
}

所有生物(除了人物)的大脑都派生自Brain基类,调用InitBehaviourTree来构造自己的行为树,Brain也提供了debug(保存了所有叶节点即行为节点)查看正在运行的节点等信息。

EventController

首先要说的是人物的决策要比生物要简单的多,原因很简单,当我在移动时按下攻击就代表我大脑已经决定要放弃移动去攻击了,我已经做好了决定而不需要代码再做什么事了。事实确实如此,但这并不意味着可以总是单纯的用新事件替代旧事件,如果人物正受到攻击,显然不能让他自由移动。仔细观察不难发现,这种情况只发生在外来事件(也就是EventNode处理的那种),换句话说受攻击、被冰冻等生物间的event总是要比玩家主观按下某个键产生的event有更高的优先级,既然如此不妨为PlayerEvent设置一个特有的priority系统,由EventController来根据优先级确定是否需要用新的event替代正在进行的event。

public abstract class PlayerEvent : BaseEvent
{
    public virtual int Level
    {
        get => 1;
    }
}

所有人物事件均派生自PlayerEvent,比其它事件多一个priority属性,是事件自己设定的

public class EventController : MonoBehaviour
{
    private EventHandler eventHandler;
    private ActionController actionController;
    private PlayerEvent currentEvent;

    void Awake()
    {
        eventHandler = GetComponent<EventHandler>();
        actionController = GetComponent<ActionController>();
    }
    void Start()
    {
        AddNormalEvents();
        actionController.onActionFinish += ResetEvent;
    }
    void AddNormalEvents()
    {
        AddEvent<PlayerEvents.Move>();
        AddEvent<PlayerEvents.AutoCraft>();
        AddEvent<PlayerEvents.Work>();
        AddEvent<PlayerEvents.Pick>();
        AddEvent<PlayerEvents.AutoAttack>();
        AddEvent<PlayerEvents.Attack>();
        AddEvent<PlayerEvents.GetHit>();
        AddEvent<PlayerEvents.Drop>();
        AddEvent<PlayerEvents.UnEquip>();
    }
    void AddEvent<T>() where T : PlayerEvent, new()
    {
        T e = new T();
        eventHandler.ListenEvent(e.name, (object[] args) => OnEvent(e, args));
    }
    void OnEvent(PlayerEvent e, params object[] args)
    {
        if(currentEvent != null && e.Level < currentEvent.Level) return;
        e.fn(gameObject, args);
        currentEvent = e;
    }
    public void ResetEvent(bool finish)
    {
        if(finish) currentEvent = null;
    }
}

所有人物事件的要先经过OnEvent的筛选,低优先级无法打断高优先级事件。至于EventController如何知道事件结束呢,它监听ActionController的ActionFinish事件,如果一个Action顺利完成就说明当前事件结束了。
你可能对此抱有疑问,一个事件就一定对应一个Action吗?确实如此,这是一个规定即一个人物事件必须对应一个Action。在上一篇的总结中已经提到人物在决策层和动画层更倾向于把所有的代码都堆在动画层(而其它生物相反),这主要有两个原因

  1. 人物的Action比较少,其它生物每个生物都有自己的action因此尽可能把代码放在决策层共用ActionNode,而人物只有一套action,人物也不存在和其它生物共用behaviour的必要 。
  2. EventController的功能相比BehaviourTree要简陋的多,它甚至没有update(虽然可以提供),所以不适合将大量代码放在决策层

当然如果不涉及到动画的事件可以考虑直接在prefab中注册处理,因为不涉及动画实际上就不存在冲突

总结(不足)

试想一下在设计具体的ActionNode派生类时,比如可以设计一个Wander,因为绝大多数生物都需要这个行为,但假如某个行为只有个别生物具有,你该怎么处理?而且这样的情况也不在少数,几乎每个生物总有一个特殊的行为。而且对于大部分生物,我们在决策层中写大量调用action和component代码是否违背了“决策”的含义。这一切都是因为它做了太多具体的事,所以像饥荒中决策层下还有一层状态机,有时决策层只调用一个PushEvent,具体的处理放到了每个生物的SG(stategraph)中,好处就是即便boss级别的生物它的行为树也只有十几行;弊端就是要额外设计状态机,状态机比actioncontroller要更复杂因为它包含转换关系(actioncontroller总是简单的执行新动作),这也是为什么它能把代码下放到状态机而我不能简单的把代码下放到action中。

例子

其实本来是想分析几个例子的,但至此文章已经有点过长了,所以在这里贴点代码,至于分析我等到讲完Component后再一起分析吧。
石头人的brain

public class GolemEarthBrain : Brain
{
    public override void InitBehaviourTree()
    {
        BehaviourTreeNode root = new PrioritySelector
        (
            new List<BehaviourTreeNode>{
                new GetHit<CommonAction.GetHit, CommonAction.Die>(gameObject, new CommonEvents.GetHit()),
                new IFNode(ShouldBackToHome, new GoHome<CommonAction.Move>(gameObject, "backtohome", GetComponent<RememberLocation>().locations["spawnpoint"])),
                new IFNode(ShouldSpinAttack, new ChaseAndAttack<ACGolemEarth.Dash, ACGolemEarth.SpinAttack>(gameObject, "spinattack", 5f, "spinattack")),
                new ChaseAndAttack<ACGolemEarth.Dash, CommonAction.Attack>(gameObject, "attack", 5f, "default"),
                new Sequence
                (
                    new List<BehaviourTreeNode>
                    {
                        new Wander<CommonAction.Move>(gameObject, "wander", GetComponent<RememberLocation>().locations["spawnpoint"], 8),
                        new StandStill(gameObject, "wanderbreak", 2.5f)
                    }
                )
            }
        );
        bt = new BehaviourTree(gameObject, root, 0.25f);
        
    }
    private bool ShouldBackToHome()
    {
        Vector3 spawnpoint = GetComponent<RememberLocation>().locations["spawnpoint"];
        return transform.position.PlanerDistance(spawnpoint) > Constants.golem_backhome_dist;
    }
    private bool ShouldSpinAttack()
    {
        return GetComponent<Combat>().IsReady("spinattack") && !GetComponent<ActionController>().isDoing<CommonAction.Attack>();
    }
    private bool IsDead()
    {
        return GetComponent<Health>().health == 0 && !GetComponent<ActionController>().isDoing<CommonAction.Die>();
    }
}

两个playerevent

public class Move : PlayerEvent
{
    public override string name
    {
        get => "Move";
    }
    //params:
    //
    //target
    public override void fn(GameObject gameObject, params object[] args)
    {
        ActionController actionController = gameObject.GetComponent<ActionController>();
        if(args.Length == 0 && !actionController.isDoing<LocomotionController>()) actionController.DoAction<LocomotionController>(args);
        else if(args.Length == 1) actionController.DoAction<LocomotionController>(args);
    }
}
public class GetHit : PlayerEvent
{
    public override int Level
    {
        get => 2;
    }
    public override string name
    {
        get => "GetHit";
    }
    //params:
    //attacker damage
    public override void fn(GameObject gameObject, params object[] args)
    {
        ActionController actionController = gameObject.GetComponent<ActionController>();
        Combat combat = gameObject.GetComponent<Combat>();
        combat.GetHit(args[0] as GameObject, (float)args[1]);
        Health health = gameObject.GetComponent<Health>();
        if(health.health > 0)
        {
            actionController.DoAction<GetHitController>();
        } 
        else gameObject.GetComponent<EventHandler>().RaiseEvent("Die");
    }
}
  • 20
    点赞
  • 9
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值