目录
上面的例子介绍了框架的简单使用,接着介绍四个类型节点的运行逻辑。
前言:
在自己做的比赛项目中,在初期的游戏系统部分完成之后,剩下的内容就是搭建游戏逻辑了。特别是在剧情解密游戏中,搭建过程就是跟着策划流程文档拼图游戏,其中工作量还蛮重复性的。之前有些过几篇关于设计模式和框架的文章,其实目标也是将一些可复用的逻辑抽象出来使用,优化制作管线和提高效率。因此便想到了做一个可视化的节点界面工具出来,方便在长期项目中策划同学也能参与进来编写游戏逻辑,减轻程序方面的负担。
在设计的过程中有很多的考虑点,最初自己对比了一些成熟的可视化节点插件插件bolt,playerMaker等,一开始是想在这些插件上做节点拓展,封装一些自己写的逻辑上去,但发现这些插件中内容很多,自己想要的东西比较轻量级,并且这些插件对于节点和Inspector窗口也不是很方便拓展(自定义属性和odin对接)
在Unity 2021默认的版本内置中,其实已经有了一个节点绘制系统GraphView,其中Visual Scripting和Shader Graph两个节点窗口的就是它的运用了,所以最终自己决定使用了GraphView配合UIbuilder实现了一个事件节点编辑器,将以前的事件系统包装了进来,部分游戏逻辑变成了可视化节点操作。
在设计时也有各种权衡,例如在Bolt中,一个节点前后端口不仅代表流向,也带有这个节点运行时需要的数据,在自己实现的系统中,简化了节点构成,将数据全部放入Inspector中,节点的前后端口只有流向的意义(实际上GraphView是Editor部分在游戏运行时是不生效的)
最终项目运用效果展示:
整个系统还是花了蛮多时间的,中间要寻找各种教程参考(国内教程是真的少),自己思考总结结合以前的框架等。最终写完在实际游戏比赛中运用,确实极大的提高了程序间的构成成本的搭建游戏逻辑效率。系统中涉及的点也很多,因此后续会慢慢写出几个章节分别介绍系统各个模块的实现(开坑,希望不咕咕咕)
系统各部分组成(后续章节计划):
Runtime 事件节点实时运行部分
该章节会讲述游戏实际运行时,这个行为树的事件系统是怎么执行的,其中的节点的数据结构,实际上和自己之前一篇文章内容思想很类似。[Unity] 状态机事件流程框架 (一)(C#事件系统,Trigger与Action)_Sugarzo的博客-CSDN博客_c# 事件系统
当然后续也做了一些优化,框架中一个涉及四个类型的节点:触发器节点、事件节点、条件节点、序列节点,会介绍一下其中脚本的逻辑。
上面的展示图中其实可以看出来,其实所有的节点都是继承自MonoBehaviour,作为一个component附加在游戏物品上,这自然不是理论上的最高效率。当然这里设计有几个权衡,一是该框架的数据都是由Unity自带的Inspector绘制,没有提供节点的数据端口,因此需要节点的数据信息在UnityEngine.Object的派生类里。
UnityEngine.Object其实还可以选择使用ScriptableObject,效率自然更高,但对比Monobaheviour少了个生命周期函数。最终权衡之下还是选择了用component作为节点数据。
Editor 编辑器部分 (UIbuilder)
该章节会讲解怎么使用uiBuilder做出自己想要的节点界面,以及做一些简单的编辑器窗口,在项目根据模板新建脚本资源。
GraphView unity的节点绘制系统
该章节会讲解GraphView使用,如何构建节点图。实际上节点图只用来组织上面Runtime部分的数据流向(一个NodeView关联一个Runtime的Component),游戏运行时时不构建节点图,GraphView负责Runtime部分的数据编辑(修改流向),以及如何根据各节点流向反过来构建编辑器下的节点图(写这里的时候仿佛在写数据结构作业,什么图论、邻接表都用上了),然后是一些删除节点,复制节点操作的实现。
项目下载Github
链接:还在优化,等上面的章节计划写完再放出来吧
框架使用手册:
样例场景
在框架中的Scene/Logic中,可以看到一个样例场景,可以参考里面的逻辑。
如何搭建逻辑
很多节点逻辑依附于MainObject预制体实现功能,在使用时一般场景内已经装好了,如果是新建场景需要在资源中将该预制体添加到场景中,才能保证逻辑的正常运行。
在Unity右键新建物品菜单中,可以看到一个FlowChart的选项,点击新建,场景便会多一个FlowChart游戏物品。
选中FlowChart,点击上面挂载的脚本“Open”,就可以打开节点面板了!随后的后续编辑也只需要通过这个按钮进入即可
Note文本框是用来写注释的,不影响实际运行效果。
点击Open后会打开一个面板,目前上面什么都没有。编辑面板分为两部分:左边的Inspector面板用于显示和编辑点击节点的属性,右边的图用来显示节点(目前是空的)
在右边的节点图区域右键,点击Create Node,可以看到可以创建的节点列表:
节点主要分为四大类:触发器节点、行为(事件)节点、分支节点、序列节点。
其中,触发器和行为节点是主要的节点:触发器节点决定什么时候触发这个事件(例如玩家按下某个按键,游戏中某个状态改变到指定值,游戏内某个事件发生都可以算是一个触发时机),行为节点决定了进入该节点后程序需要执行什么逻辑。具体主要节点的功能见文档末尾的节点附录。
例如,我想执行一段逻辑:当进入游戏场景的时候开始播放游戏音乐BGM,此时新建一个StatusTrigger和AudioAction,在创建节点列表中找到对应节点,点击对应节点可以在左边的Inspector面板中设置对应属性值,连接逻辑如下:
运行游戏,可以看到逻辑被正常运行。
接着,我想加上当玩家按下ESC键时,游戏退出,这其中的逻辑如下(可以接着在原来的FlowChart中继续搭建逻辑)
理论上一张图里只要不是几百个节点都不会卡(应该),不过最好还是根据需求分开模块,一个游戏物品只能搭载一个FlowChart对应一张图,当逻辑多时就多搭建几张图,比如点击事件放一张图,状态事件放一张图等。
通过简单的Trigger和Action的连接,就可以搭建游戏逻辑了!
节点类型介绍
上面的例子介绍了框架的简单使用,接着介绍四个类型节点的运行逻辑。
触发器节点:
触发器节点是决定事件什么时候触发的关键,当条件满足时,该触发器节点连接的下一节点逻辑会被触发,此时该触发器State也会进入【执行中】的状态。
触发器只有一个输出端口,输出端口只能单连接。
触发器节点共有属性:
State:表示该触发器的状态,当触发器被触发时,State进入【执行中】,直到触发器触发的逻辑流向的最后一个节点执行完成后,State才会进入【执行完毕】。当事件节点在后面形成环形逻辑时,则会造成永远不会进入【执行完毕】的状态,请注意这点。
生命周期执行:与Unity的MonoBehaviour执行顺序一致。
CanExecuteOnRunning:当触发器状态位于【执行中】时,该触发器是否能被再次触发,默认值为false
(对于状态触发器StatusTrigger建议为false,背后的原理是当游戏状态被修改时所有StatusTrigger都会检查自己条件,如果满足就执行,如果勾选了这个选项就会每次修改状态都会重复触发,如果是其他Trigger不做限制,根据具体需求实现)
RunOnlyOnce:该触发器是否只能执行一次,当该选项被勾中时,触发器执行完成后便会摧毁自己(当勾选该选项时,CanExecuteOnRunning需要为false)注意这里Trigger物品的状态并不会存档,意味着当场景被重载时,该Trigger依然会被重新构建。
事件节点:
事件节点:决定该事件执行的逻辑内容
触发器拥有一个输入端口,一个输出端口,输入端口可以多连接,输出端口只能单连接。
事件节点共有属性:
Wait1Frame:执行时等待一帧,将逻辑在时间执行顺序上拆分一下,不然当流程拉长时逻辑挤在一帧执行就会卡顿。
运行特性:
当事件节点为最后一个时(即它的output端口没有连接任何其他端口),事件结束时,就会将触发该事件的触发器节点State设置成【执行完毕】,如果当输入入口被多连接时,该事件节点正在运行时又恰好其他触发器节点也执行了这个事件节点,则前一个触发器的State会被立即切换为设置成【执行完毕】,同时两个事件逻辑将会一起执行,完整时间取决于其中较快的那一个,因此不推荐将非实时完成的事件节点作为最后一个节点并输入端口多连接。
条件节点:
一种特殊的事件节点,有两个输出端口,都只能单连接。当条件满足时流向true,不满足时流向false
序列节点:
一种特殊的事件节点,有一个支持多连接的输出端口(也是目前框架里唯一一个支持输出端口多连接的节点),可以整合多个流向的事件。
该节点的执行逻辑有些不同,只有在所有流向的逻辑都执行完成时,才会返回【执行完成】给对应的触发器,当自己为执行完成所有流向的逻辑时,即使向该节点Input,该操作也不会被运行(并立即将该操作的触发器状态设置成【执行完成】),使用时需要注意。
节点扩展
以下是程序篇部分,可以通过脚本新增新的节点,在顶部窗口打开【项目框架设置】
设置好文件名和保存脚本路径,点击Create即可,注意不要重名了。
所有节点都是state : MonoBehaviour的基类,所以享有Unity GameObject的生命周期,可以被destroy和setActive。
public enum EState
{
[LabelText("未执行")]
None,
[LabelText("正在进入")]
Enter,
[LabelText("正在执行")]
Running,
[LabelText("正在退出")]
Exit,
[LabelText("执行完成")]
Finish,
}
public interface IStateEvent
{
void Execute();
void OnEnter();
void OnRunning();
void OnExit();
}
//所有节点的基类
public abstract class NodeState : MonoBehaviour
{
#if UNITY_EDITOR
[HideInInspector]
public Vector2 nodePos;
#endif
//流向下一节点的流
[HideInInspector]
public MonoState nextFlow;
}
public abstract class MonoState : NodeState, IStateEvent
{
}
NodeState : MonoBehaviour
MonoState : NodeState, IStateEvent
BaseTrigger:MonoState
BaseAction:MonoState
BaseBranch:BaseAction
BaseSeqence:BaseAction
触发器节点:
命名空间为SugarFrame.Node,模板中有两个内置注册事件和注销事件的函数,分别会在触Enable和DisEnable中执行,请保证最好注册注销事件一一对应。
using UnityEngine;
namespace SugarFrame.Node
{
public class #TTT# : BaseTrigger
{
//Called on Enable
public override void RegisterSaveTypeEvent()
{
//EventManager.StartListening("",Execute);
}
//Called on DisEnable
public override void DeleteSaveTypeEvent()
{
//EventManager.StopListening("",Execute);
}
}
}
Trigger的核心在于何时调用Execute函数,当Execute执行时,代表Trigger触发。
例如ButtonTrigger的写法如下,当按钮被按下时触发事件:
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
namespace SugarFrame.Node
{
public class ButtonTrigger : BaseTrigger
{
public List<Button> buttons;
//Called on Enable
public override void RegisterSaveTypeEvent()
{
foreach (var btn in buttons)
btn.onClick.AddListener(Execute);
}
//Called on DisEnable
public override void DeleteSaveTypeEvent()
{
foreach (var btn in buttons)
btn.onClick.RemoveListener(Execute);
}
}
}
事件节点:
命名空间为SugarFrame.Node,只需要重写RunningLogic(),在逻辑执行完成时调用RunOver()即可
请保证RunOver一定要被执行且一次逻辑中只被执行一次,才能正确设置好逻辑的退出
using UnityEngine;
namespace SugarFrame.Node
{
public class #TTT# : BaseAction
{
[Header("#TTT#")]
public string content;
public override void RunningLogic()
{
//Write Logic
RunOver();
}
}
}
如果是非实时逻辑(比如异步加载,需要等待),可以将RunOver传入对应委托,或者用协程挂起即可。下面是IntervalAction的参考写法:
using System;
using System.Collections;
using UnityEngine;
namespace SugarFrame.Node
{
public class IntervalAction : BaseAction
{
[Header("等待x秒后执行下一个")]
public float timer = 1f;
public override void RunningLogic()
{
StartCoroutine(WaitTime(RunOver));
}
IEnumerator WaitTime(Action _event)
{
if(timer <= 0)
{
_event?.Invoke();
yield break;
}
yield return new WaitForSeconds(timer);
_event?.Invoke();
}
}
}
条件节点:
命名空间为SugarFrame.Node,只需要重写bool IfResult()的函数即可判断流向
using UnityEngine;
namespace SugarFrame.Node
{
public class #TTT# : BaseBranch
{
[Header("#TTT#")]
public string content;
public override bool IfResult()
{
return true;
}
}
}