Unity基于状态机的架构与设计
我们做游戏的时候经常会有流程控制,流程控制的方法有很多,行为决策树,状态机等。本质差别都不大,就是把每一段执行逻辑做成一个一个的节点,根据条件执行某个节点,切换到某个节点。今天给大家分享一下基于状态机来做游戏流程的控制。
1 一个简单的状态机案例
我们先来拆解一个使用案例,通过这个案例让大家对状态机的流程控制有一个基本的了解。首先我们来构建一些状态节点,放入到状态机中。编写伪代码如下:
创建一个状态机:
FiniteStateMachine _fsm = new FiniteStateMachine()
往状态机里面加入所有控制流程状态的逻辑节点:
_fsm.AddNode(new NodeInit());
_fsm.AddNode(new NodeLogin());
_fsm.AddNode(new NodeTown());
初始化逻辑节点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[i].Name == nodeName)
return true;
}
return false;
}
private IFsmNode GetNode(string nodeName)
{
for (int i = 0; i < _nodes.Count; i++)
{
if (_nodes[i].Name == nodeName)
return _nodes[i];
}
return null;
}
}
这样驱动了状态机节点的相关接口的调用与执行。写好FiniteStateMachine, IFsmNode两个代码以后,状态机就已经设计完成了,接下来就是具体游戏项目中的使用。也就是与使用相关的代码了。其实非常简单,主要有3步:
Step1: 创建一个状态机对象;
Step2: 我们要添加一个状态机的逻辑节点,只要继承IFsmNode,实现相关接口,并把逻辑节点放到状态机对象中统一管理起来。
Step3: 根据业务逻辑来切换运行的状态机的节点。从而到达逻辑控制的目的。
3: 基于状态机扩展一些特殊的状态控制
状态机设计完成以后,我们还可以基于状态机来做一些特殊的状态控制,让我们的逻辑代码更清晰,维护起来更方便,比如最常见的顺序执行状态机ProcedureFsm。就是说执行完一个状态节点,马上执行第二个状态节点。这样我们做顺序流程就非常方便了,比如热更新的顺序流程状态机:
1: 检查版本状态节点;
2: 增量下载信息比对节点;
3: 增量下载资源节点;
4: 下载完成后进入游戏节点;
把这些状态机节点加入到ProcedureFsm中,那么它就会从第一个节点开始运行,后面每个节点依次执行。
项目中是否用状态机的方式来做为你的逻辑控制,这个可以根据具体的需求来进行分析。没有绝对的好与坏,适合即可。
今天的分享就到这里,关注我(加入到学习群),可以获取”Unity 状态机”相关源码与实现。