mam520 发表于 2024-9-13 09:33

Unity基于状态机的架构与设计

我们做游戏的时候经常会有流程控制,流程控制的方式有很多,行为决策树,状态机等。本质分歧都不大,就是把每一段执行逻辑做成一个一个的节点,按照条件执行某个节点,切换到某个节点。今天给大师分享一下基于状态机来做游戏流程的控制。
对啦!这里有个游戏开发交流小组里面堆积了一帮热爱学习游戏的零基础小白,也有一些正在从事游戏开发的技术大佬,欢迎你来交流学习。
1 一个简单的状态机案例
我们先来拆解一个使用案例,通过这个案例让大师对状态机的流程控制有一个基本的了解。首先我们来构建一些状态节点,放入到状态机中。编写伪代码如下:


初始化逻辑节点NodeInit,用来做初始化的逻辑控制, NodeLogin,用来做登录场景的逻辑控制, NodeTown节点用来做游戏战斗场景的逻辑控制。


每个状态机节点,都有几个统一的固定的入口,这些入口如何设计与行业相关,比如我们的游戏行业,设计状态机节点接口一般如下:
public interface IFsmNode
        {
                /// <summary>
                /// 节点名称
                /// </summary>
                string Name { get; }

                void OnEnter();
                void OnUpdate();
                void OnFixedUpdate();
                void OnExit();
                void OnHandleMessage(object msg);
        }Name: 状态机节点的名字;
OnEnter: 状态机进入到这个状态节点时执行,一般用于初始化;
OnExit: 状态机来开这个状态节点时执行,一般用户结束时候的一些销毁资源与释放等;
OnUpdate: 每一帧城市调用状态机节点的update, 很多每帧措置的事务可以放OnUpdate;
OnFixedUpdate: 每个FixedUpdate 城市调用状态机的OnFixedUpdate函数,一些固定迭代次数的更新可以放此接口。
OnHandleMessage(object msg): 给状态机节点触发事件动静的时候调用这个接口,来作为状态机节点措置事件动静的控制入口。
每个状态机节点,都实现IFsmNode所对应的接口,放入到状态机中统一打点。案例中我们在游戏开始时先执行NodeInit状态节点,完成游戏的初始化。
public void StartGame()
        {
                _fsm.Run(nameof(NodeInit));
        }先来看NodeInit节点措置的逻辑,NodeInit只在OnEnter里面实现了初始化的相关逻辑,其它接口,没有任何逻辑措置。代码如下
void IFsmNode.OnEnter()
        {
                AudioPlayerSetting.InitAudioSetting();

                // 使用协程初始化
                this.StartCoroutine(Init());
        }

        private IEnumerator Init()
        {
                // 加载UIRoot
                var uiRoot = WindowManager.Instance.CreateUIRoot<CanvasRoot>(”UIPanel/UIRoot”);
                yield return uiRoot;

                // 加载常驻面板
                yield return GameObjectPoolManager.Instance.CreatePool(”UIPanel/UILoading”, true);

                // 进入到登录流程
                FsmManager.Instance.Change(nameof(NodeLogin));
        }如上面的代码所示, 当状态机执行NodeInit节点状态的时候,会初始化时调用OnEnter接口, NodeInit的OnEnter接口中,调用了Init函数来做初始化,首先会创建一个UIRoot, 然后把资源加载界面显示出来,完成资源加载后,进入到登录逻辑节点场景,注意这里,状态机就由本来的NodeInit切换到NodeLogin状态机节点。当进入NodeLogin节点的时候,就会执行它的OnEnter接口,接下来我们看下登录节点的逻辑措置如下:
void IFsmNode.OnEnter()
        {
                var uiwindow = UITools.OpenWindow<UILogin>();
                uiwindow.Completed += Uiwindow_Completed;

                string sceneName = ”Scene/Login”;
                SceneManager.Instance.ChangeMainScene(sceneName, null);
        }显示一个登录的UI界面,同时切换场景到登录场景,这样我们的状态机控制逻辑就切换到登录场景了,如图所示:


接下来输入用户名+暗码,点击”Run Game”按钮,看下RunGame按钮的措置:
private void OnClickLogin()
        {
                // 替换按钮图片
                if (_loginSprite.SpriteName == ”Button_Rectangular_Large_Green_Background”)
                        _loginSprite.SpriteName = ”Button_Rectangular_Large_Red_Background”;
                else
                        _loginSprite.SpriteName = ”Button_Rectangular_Large_Green_Background”;

                // 发送登录事件
                var message = new LoginEvent.ConnectServer
                {
                        Account = _account.text,
                        Password = _password.text
                };
                EventManager.Instance.SendMessage(message);
        }给状态机的节点发送一个登录事件动静, 这样就可以调用到状态机节点的事件措置函数,
private void OnHandleEvent(IEventMessage msg)
        {
                if(msg is LoginEvent.ConnectServer)
                {
                        FsmManager.Instance.Change(nameof(NodeTown));
                }
        }在事件措置函数中,调用状态机切换到NodeTown状态机节点运行。最后我们来看下NodeTown游戏战斗场景中的节点措置,初始化OnEnter接口如下:
void IFsmNode.OnEnter()
        {
                string sceneName = ”Scene/Town”;
                SceneManager.Instance.ChangeMainScene(sceneName, OnSceneLoad);
                UITools.OpenWindow<UILoading>(sceneName);
                UITools.OpenWindow<UIMain>();
                AudioManager.Instance.PlayMusic(”Audio/Music/town”, true);
        }切换到游戏战斗场景,显示战斗的主UI, 播放游戏的布景音乐。在看下其它接口,OnUpdate迭代游戏世界变化,OnExit, 删除掉游戏世界释放掉资源,代码如下:
void IFsmNode.OnExit()
        {
                _gameWorld.Destroy();
                UITools.CloseWindow<UIMain>();
        }如图所示:


通过这个案例的分析,我们确定了游戏状态机的设计,总结如下:
Step1: 设计一些游戏状态节点,节点中实现具体的一些逻辑措置接口;
Step2: 将游戏状态节点插手到游戏状态机中;
Step3: 给状态机编写好”切换节点”的接口,进入节点之前,先调用上一个节点的分开OnExit接口,然后调用新节点的OnEnter接口, 按照游戏的需求,每次Update, FixedUpdate, 迭代状态机节点的OnUpdate与OnFixedUpdate接口。
2基于状态机控制的具体实现与设计
有了上面的分析,我们对状态机就了解的很清楚了,自然设计一个状态机用来控制游戏的跳转控制逻辑就长短常简单的事情了,我们把游戏中的基于状态机的控制分成“与项目无关”与“与游戏项目相关”的两个部门来设计与措置。先来看下”与项目无关”的状态机部门设计: 两个代码: IFsmNode.cs与FiniteStateMachine.cs, IFsmNode.cs代码负责定义状态机节点的接口,上文中的代码已经给出了游戏开发中状态机节点常用接口。开发者在实现具体业务逻辑的时候,只要担任这个接口并实现即可。
FiniteStateMachine.cs, 主要实现了对状态机节点的打点,主要数据成员与接口如下:
privatereadonly List<IFsmNode> _nodes = new List<IFsmNode>(); 定义一个数据成员保留所有的状态机节点。
private IFsmNode _curNode;
private IFsmNode _preNode;
定义两个数据成员 curNode与prevNode来保留当前正在运行的状态节点与上一个状态节点;
publicvoid AddNode(IFsmNode node) 定义一个接口,将新的状态节点插手到状态机中;
publicvoid Run(string entryNode) 定义一个接口,作为执行第一个状态节点的接口;
publicvoid Transition(string nodeName)定义一个接口,作为执行由当前状态切换到新的状态机节点的接口;
基于Update,来调用当前执行的状态机节点的Update,FixedUpdate, HandleMessage接口。
public class FiniteStateMachine
        {
                private readonly List<IFsmNode> _nodes = new List<IFsmNode>();
                private IFsmNode _curNode;
                private IFsmNode _preNode;

                /// <summary>
                /// 节点转换关系图
                /// 注意:如果为NULL则不检测转换关系
                /// </summary>
                public FsmGraph Graph;

                /// <summary>
                /// 当前运行的节点名称
                /// </summary>
                public string CurrentNodeName
                {
                        get { return _curNode != null ? _curNode.Name : string.Empty; }
                }

                /// <summary>
                /// 之前运行的节点名称
                /// </summary>
                public string PreviousNodeName
                {
                        get { return _preNode != null ? _preNode.Name : string.Empty; }
                }


                /// <summary>
                /// 启动状态机
                /// </summary>
                /// <param name=”entryNode”>入口节点</param>
                public void Run(string entryNode)
                {
                        _curNode = GetNode(entryNode);
                        _preNode = GetNode(entryNode);

                        if (_curNode != null)
                                _curNode.OnEnter();
                        else
                                MotionLog.Error($”Not found entry node : {entryNode}”);
                }

                /// <summary>
                /// 显示帧更新
                /// </summary>
                public void Update()
                {
                        if (_curNode != null)
                                _curNode.OnUpdate();
                }

                /// <summary>
                /// 物理帧更新
                /// </summary>
                public void FixedUpdate()
                {
                        if (_curNode != null)
                                _curNode.OnFixedUpdate();
                }

                /// <summary>
                /// 插手一个节点
                /// </summary>
                public void AddNode(IFsmNode node)
                {
                        if (node == null)
                                throw new ArgumentNullException();

                        if (_nodes.Contains(node) == false)
                        {
                                _nodes.Add(node);
                        }
                        else
                        {
                                MotionLog.Warning($”Node {node.Name} already existed”);
                        }
                }

                /// <summary>
                /// 转换节点
                /// </summary>
                public void Transition(string nodeName)
                {
                        if (string.IsNullOrEmpty(nodeName))
                                throw new ArgumentNullException();

                        IFsmNode node = GetNode(nodeName);
                        if (node == null)
                        {
                                MotionLog.Error($”Can not found node {nodeName}”);
                                return;
                        }

                        // 检测转换关系
                        if (Graph != null)
                        {
                                if (Graph.CanTransition(_curNode.Name, node.Name) == false)
                                {
                                        MotionLog.Error($”Can not transition {_curNode} to {node}”);
                                        return;
                                }
                        }

                        MotionLog.Log($”FSM transition {_curNode.Name} to {node.Name}”);
                        _preNode = _curNode;
                        _curNode.OnExit();
                        _curNode = node;
                        _curNode.OnEnter();
                }

                /// <summary>
                /// 返回到之前的节点
                /// </summary>
                public void RevertToPreviousNode()
                {
                        Transition(PreviousNodeName);
                }

                /// <summary>
                /// 接收动静
                /// </summary>
                public void HandleMessage(object msg)
                {
                        if (_curNode != null)
                                _curNode.OnHandleMessage(msg);
                }

                private bool IsContains(string nodeName)
                {
                        for (int i = 0; i < _nodes.Count; i++)
                        {
                                if (_nodes.Name == nodeName)
                                        return true;
                        }
                        return false;
                }
                private IFsmNode GetNode(string nodeName)
                {
                        for (int i = 0; i < _nodes.Count; i++)
                        {
                                if (_nodes.Name == nodeName)
                                        return _nodes;
                        }
                        return null;
                }
        }这样驱动了状态机节点的相关接口的调用与执行。写好FiniteStateMachine, IFsmNode两个代码以后,状态机就已经设计完成了,接下来就是具体游戏项目中的使用。也就是与使用相关的代码了。其实非常简单,主要有3步:
Step1: 创建一个状态机对象;
Step2: 我们要添加一个状态机的逻辑节点,只要担任IFsmNode,实现相关接口,并把逻辑节点放到状态机对象中统一打点起来。
Step3: 按照业务逻辑来切换运行的状态机的节点。从而达到逻辑控制的目的。
3: 基于状态机扩展一些特殊的状态控制
状态机设计完成以后,我们还可以基于状态机来做一些特殊的状态控制,让我们的逻辑代码更清晰,维护起来更便利,比如最常见的挨次执行状态机ProcedureFsm。就是说执行完一个状态节点,顿时执行第二个状态节点。这样我们做挨次流程就非常便利了,比如热更新的挨次流程状态机:
1: 查抄版本状态节点;
2: 增量下载信息比对节点;
3: 增量下载资源节点;
4: 下载完成后进入游戏节点;
把这些状态机节点插手到ProcedureFsm中,那么它就会从第一个节点开始运行,后面每个节点依次执行。
项目中是否用状态机的方式来做为你的逻辑控制,这个可以按照具体的需求来进行分析。没有绝对的好与坏,适合即可。
今天的分享就到这里,存眷我(插手到学习小组),可以获取”Unity 状态机”相关源码与实现。
页: [1]
查看完整版本: Unity基于状态机的架构与设计