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

news2024/12/24 3:36:40

我们做游戏的时候经常会有流程控制,流程控制的方法有很多,行为决策树,状态机等。本质差别都不大,就是把每一段执行逻辑做成一个一个的节点,根据条件执行某个节点,切换到某个节点。今天给大家分享一下基于状态机来做游戏流程的控制。

对啦!这里有个游戏开发交流小组里面聚集了一帮热爱学习游戏的零基础小白,也有一些正在从事游 戏开发的技术大佬,欢迎你来交流学习。

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 状态机”相关源码与实现。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/150546.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

如何在VSCode中添加Python解释器并安装Python库

如何在VSCode中添加Python解释器并安装Python库作者介绍一&#xff0e; 安装VScode编辑器二&#xff0e; 安装Python解释器三&#xff0e; 在VScode中添加Python解释器四&#xff0e; 创建项目并在VScode中打开&#xff1b;五、在VScode中安装Python库作者介绍 孟莉苹&#xf…

【TypeScript入门】TypeScript入门篇——对象

对象其实就是一种封装的概念&#xff0c;它把事物封装成一个类&#xff0c;然后提供类的接口&#xff0c;而具体的过程人们是看不到的。 一、对象实例 二、TypeScript 类型模板 三、鸭子类型(Duck Typing) 对象是包含一组键值对的实例。 值可以是标量、函数、数组、对象等&am…

Linux常用命令——lsof命令

在线Linux命令查询工具(http://www.lzltool.com/LinuxCommand) lsof 显示Linux系统当前已打开的所有文件列表lsof -p pid 补充说明 lsof命令用于查看你进程打开的文件&#xff0c;打开文件的进程&#xff0c;进程打开的端口(TCP、UDP)。找回/恢复删除的文件。是十分方便的系…

十四、Node.js 中 session验证登录

在前一篇内容中讲到这个cookie实现验证登录&#xff0c;cookie是存储在客户端的&#xff0c;而session是存储在服务器的&#xff0c;相比较session的安全性会更高&#xff0c;session对象存储特定用户会话所需要的属性以及配置信息&#xff0c;服务通过session对象将用户的信息…

CPU使用率过高的原因及解决方法

常见的CPU使用率过高可不是职场CPU哦&#xff0c;而是电脑的中央处理器&#xff0c;CPU作为计算机系统重要的运算和控制核心&#xff0c;可谓是“很忙”的存在。在我们日常使用电脑设备时&#xff0c;由于打开的软件、游戏、网页等程序容易导致电脑运作卡顿&#xff0c;这时候就…

《图机器学习》-Traditional Methods for Machine Learning in Graphs

Traditional Methods for Machine Learning in Graphs前言一、Node-Level Tasks and Features二、Link-Level Tasks and Features三、Graph-Level Tasks and Features前言 图机器学习任务可以分为三种&#xff1a; Node-level prediction&#xff1a;节点级的预测 如对节点进…

最优控制学习笔记3----无约束条件的泛函极值问题

无约束条件的最优控制问题 设函数 x(t)x(t)x(t) 在 [t0,tf][t_0, t_f][t0​,tf​] 区间上连续可到&#xff0c;考虑 Lagrange型性能指标函数 J[x(t)]∫t0tfL[x(t),x˙(t),t]dtJ[x(t)]\displaystyle\int_{t_0}^{t_f}L[x(t), \dot{x}(t), t]dtJ[x(t)]∫t0​tf​​L[x(t),x˙(t),…

Linux---常见指令

目录 01. ls 指令 02. pwd命令 03. cd 指令 04. touch指令 05.mkdir指令 06.rmdir指令 && rm 指令 07.man指令 08.cp指令 09.mv指令 10. cat指令 11.more指令 12.less指令 13.head指令 13.tail指令 简述重定向和管道概念&#xff1a; 14.时间相关的指令 15.Ca…

Git Fork操作与配置

我理解的git fork&#xff1a;将别人&#xff08;张三&#xff09;仓库包括文件&#xff0c;提交历史&#xff0c;issues等复制一份到自己的github账号下。我们在可以通过修改本地项目的代码&#xff0c;然后&#xff0c;给&#xff08;张三&#xff09;发送一个Merge Request&…

C. Yet Another Tournament(贪心)

Problem - C - Codeforces 通用领域 医学 计算机 金融经济 你正在参加另一场比赛。有n1个参与者:你和其他n个对手&#xff0c;编号从1到n。 每两名参与者将相互竞争一次。如果对手i和对手j比赛&#xff0c;他获胜当且仅当i>j。 当我的对手和你对弈时&#xff0c;一切都变…

rabbitmq+netcore6 【4】Routing:路由

文章目录1&#xff09;前言2&#xff09;Direct exchange 直接类型的交换机3&#xff09;Multiple bindings 多绑定4&#xff09;Emitting logs 发送日志5&#xff09;Subscribing 订阅6&#xff09;综合以上代码准备工作生产者消费者1消费者2消费者3运行结果官网参考链接&…

USB大容量存储设备浅析

一 USB 设备类 SB 引入了设备类的概念&#xff0c;根据每一类驱动程序的功能将USB设备分为几大类&#xff0c;标准的几大类包括&#xff1a; 大容量存储类 网络类 集线器类 串行转换器 音频类 视频类 图像类 调制解调器 打印机 HID(Human Interface Device 人机接口设备)每一…

我记不住的那些编程语言的语法(数组)-1

背景&#xff1a;我记不住各种语言的语法&#xff0c;例如C、Java、Go、Python、JavaScript&#xff0c;大概就是常用的这几种语言&#xff0c;每种语言有其自己的语法规范&#xff0c;有的时候会记混了&#xff0c;所以想记录一下细节。这个系列会不定期的更新&#xff0c;本期…

一路坎坷,入局到突破【2022年度总结】

秃秃 1> 来到CSDN&#xff1a; 2019年5月时决定只身一人去外省实习&#xff0c;顺便在CSDN这个“资源库”注册了一下账号。直到我20年在公司做技术分享时&#xff0c;才真正开始在CSDN上的创作&#xff1b; 21年的时候也只是把CSDN当做笔记&#xff0c;就自己写一写&…

行业洞察|猴子可以打字,动物走进元宇宙还有多远?

很多学者和专家认为&#xff0c;人类与动物的区别在于语言的使用。人类可以使用语言&#xff0c;但是动物不会。其实也许是我们人类听不懂动物的语言&#xff0c;并不是他们不会使用。本质在于沟通的媒介不同&#xff0c;导致我们无法相互交流。但是&#xff0c;埃隆马斯克&…

App原型设计规范

一、界面尺寸 1.ios分辨率 2.android界面尺寸 ① 安卓分辨率 ②常见安卓手机分辨率及尺寸 一般情况下大家在设计app端原型的时候&#xff0c;由于现在ios和安卓慢慢在趋向一致&#xff0c;所以基本上都只会设计一套原型&#xff0c;尺寸方面一般都是按照iphone6的750*1344(2倍…

Django 数据备份dumpdata 踩的坑

项目背景&#xff1a; 项目使用的是sqlite数据库&#xff0c;要求备份除了网络表之外的所有数据 实施方案&#xff1a; python3 manage.py dumpdata --exclude network.TRoute --indent 2 --format json > aq3.json 方案操作结果是&#xff1a; 查看aq3.json如下&#xff…

一文详解GCC7、CUDA 11.2、CUDNN部署

在部署之前&#xff0c;需要了解下python-tensorflow-cuDNN-CUDA版本对应关系,以便能够完全兼容下文以此版本为例部署gcc-7.3.1gpu driver-460.106.00cuda-11.2cudnn-8.1.1一.gcc部署1.安装[rootgpu ~]# yum -y install centos-release-scl [rootgpu ~]# yum install devtoolse…

《收获,不止Oracle》索引细化

1.索引知识图框 2.索引探秘 2.1 BTREE索引 索引是建在表的具体列上的&#xff0c;其存在的目的是让表的查询变得更快&#xff0c;效率更高。表记录丢失关乎生死&#xff0c;而索引丢失只需重建即可。 索引却是数据库学习中最实用的技术之一。谁能深刻地理解和掌握索引的知识&…

Spring gateway websocket自定义负载均衡

业务需求 公司IM服务主要基于netty实现websocket&#xff0c;为保证在线用户channel通道畅通故一直使用单机运行。现由于公司业务增加需要增加IM集群&#xff0c;由于channel通道不能缓存&#xff0c;故急需一套可以完整兼容之前功能的方案。 技术选型 1、采用spring websocke…