尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Unity可视化脚本实战:基于XNode与Odin构建可运行流程图系统

Unity可视化脚本实战:基于XNode与Odin构建可运行流程图系统 1. 项目概述与核心价值最近在做一个中型规模的Unity项目策划和程序之间关于玩法逻辑的沟通成本越来越高。策划画了一堆流程图程序对着流程图写代码但一旦逻辑需要调整两边就得重新对齐费时费力。为了解决这个问题我决定在项目里引入一个流程可视化系统让策划能直接在Unity编辑器里通过拖拽节点、连线的方式搭建和调试游戏逻辑。最终我选择了Unity XNode Odin这套技术栈来实现它不仅实现了基础的流程图编辑还特别定制了UI提示框节点并且让整个流程可以真正运行起来实现了“所见即所得”的编辑与调试体验。简单来说这个系统就是一个运行在Unity编辑器内的可视化脚本工具。它允许非程序员比如策划、美术通过连接预定义的、功能明确的节点例如“显示对话框”、“播放动画”、“等待2秒”、“分支判断”来构建复杂的游戏行为序列。XNode提供了稳定可靠的节点图底层框架Odin则极大地美化和增强了节点的编辑器界面让操作体验丝滑流畅。而“可运行”意味着你不需要写一行代码点击播放在Game视图里就能看到这个节点流程被一步步执行的效果这对于快速原型验证和逻辑调试来说价值巨大。2. 技术选型与架构设计思路2.1 为什么是XNode Odin市面上Unity的节点图方案不少有NodeCanvas、PlayMaker这种功能强大的资产也有Unity官方的Visual Scripting原名Bolt。我选择XNode首要原因是它的轻量、开源和高度可定制性。NodeCanvas和PlayMaker是黑盒虽然功能全但定制特定节点、修改底层执行逻辑非常困难。而XNode的代码结构清晰它只负责最核心的图数据结构、节点连接和端口管理将节点的具体功能、外观和运行时行为完全交给开发者定义这给了我们最大的灵活性。Odin Inspector则是对XNode的“神级”补充。XNode自带的节点编辑器比较简陋而Odin能让我们用极少的代码为节点添加漂亮的抽屉、列表、按钮和颜色标记。例如一个“显示对话”节点我们可以用Odin的[BoxGroup]、[LabelText]、[TextArea]等Attribute将角色名、对话内容、头像等字段组织得井井有条策划一看就懂。Odin解决了“易用性”的问题让非技术人员也能愉快地使用我们制作的工具。2.2 系统核心架构分层整个系统我设计为三层结构确保职责清晰便于维护和扩展。第一层数据层XNode Graph Nodes这是系统的基石。我们创建一个继承自XNode.NodeGraph的类比如GameFlowGraph它就是一个流程图的容器。所有的功能节点都继承自XNode.Node。在这一层我们只定义节点的数据和接口。例如一个节点有哪些输入/输出端口[Input]、[Output]Attribute它有哪些可配置的参数公共字段。关键点在于数据层不包含任何具体的游戏逻辑或Unity引擎API调用它只描述流程的结构和参数。第二层视图层Odin Custom Editor这一层负责将数据层“漂亮地”展示出来。我们为GameFlowGraph和各个Node类型编写自定义的Editor脚本。利用Odin的DrawDefaultInspector和自定义绘制逻辑我们可以根据节点类型改变节点颜色。在节点标题栏显示关键信息如对话节点的前几个字。为端口添加图标和提示文字。在Graph编辑器窗口添加工具栏按钮如“运行”、“暂停”。第三层运行时层Graph Runner Node Logic这是让流程图“活”起来的关键。我们创建一个GraphRunner单例或MonoBehaviour组件。它的核心职责是遍历并执行一个NodeGraph。它需要理解节点的执行顺序通常从某个标记为“开始”的节点出发沿着连线执行并调用每个节点对应的运行时逻辑方法。这里就需要将数据层的节点定义与具体的游戏功能绑定。我采用的方式是为每个Node类型定义一个对应的“处理器”Handler类GraphRunner找到节点后调用对应处理器的执行方法并传入节点数据。3. 核心模块实现细节3.1 基础节点图框架搭建首先导入XNode和Odin确保Odin已注册。创建一个ScriptableObject作为流程图的资源文件。// GameFlowGraph.cs using XNode; using UnityEngine; [CreateAssetMenu(fileName New Flow Graph, menuName Game Flow/Flow Graph)] public class GameFlowGraph : NodeGraph { // 可以在这里添加一些Graph级别的数据或方法比如查找起始节点 public StartNode GetStartNode() { foreach (var node in nodes) { if (node is StartNode startNode) return startNode; } return null; } }然后创建最基础的节点类型StartNode开始和BaseNode所有功能节点的基类。// StartNode.cs using XNode; using UnityEngine; [NodeWidth(150)] public class StartNode : Node { [Output(connectionType ConnectionType.Override)] public NodePort exit; // 这个节点没有逻辑仅作为流程入口点 }// BaseNode.cs using XNode; using Sirenix.OdinInspector; // 引入Odin public abstract class BaseNode : Node { // 所有节点共有的方法获取下一个节点 public BaseNode GetNextNode(string portName exit) { NodePort port GetOutputPort(portName); if (port ! null port.IsConnected) { return port.Connection.node as BaseNode; } return null; } // 抽象方法由子类实现具体的运行时逻辑 public abstract void Execute(GraphRunner runner); }注意NodeWidthAttribute来自XNode用于控制节点在编辑器中的默认宽度。[Output]定义了一个输出端口。ConnectionType.Override表示这个端口只能有一条连线这对于控制流节点是合适的。3.2 UI提示框节点的设计与实现这是策划最常用的节点之一。目标配置角色名、对话内容、头像运行时在游戏UI中显示一个对话气泡。首先定义节点数据// DialogueNode.cs using XNode; using Sirenix.OdinInspector; using UnityEngine; [NodeTint(#4CAF50)] // 给节点一个绿色色调 public class DialogueNode : BaseNode { [Input(connectionType ConnectionType.Override)] public NodePort entry; [Output(connectionType ConnectionType.Override)] public NodePort exit; [BoxGroup(对话配置), LabelText(角色名)] public string characterName NPC; [BoxGroup(对话配置), LabelText(对话内容), TextArea(3, 5)] public string dialogueText 你好冒险者; [BoxGroup(对话配置), LabelText(头像), PreviewField(50, ObjectFieldAlignment.Left)] public Sprite avatar; [BoxGroup(高级选项), LabelText(自动继续延时秒), MinValue(0)] public float autoContinueDelay 0f; public override void Execute(GraphRunner runner) { // 运行时逻辑交给Handler处理这里只是数据载体 runner.ExecuteNode(this); } }实操心得使用Odin的[BoxGroup]将相关字段分组[LabelText]自定义显示名称[TextArea]让多行文本输入更友好[PreviewField]能预览头像图片这些细节能极大提升策划的使用体验。[NodeTint]让节点在图中一目了然。接下来实现运行时处理器。我们需要一个管理UI的DialogueUI单例。// DialogueNodeHandler.cs using UnityEngine; public static class DialogueNodeHandler { public static void Execute(DialogueNode node, GraphRunner runner) { // 1. 获取UI管理器 var uiManager DialogueUIManager.Instance; if (uiManager null) { Debug.LogError(DialogueUIManager 未找到); runner.ContinueFlow(node); // 出错时尝试继续流程 return; } // 2. 显示UI uiManager.ShowDialogue(node.characterName, node.dialogueText, node.avatar, () { // 3. 这是回调函数当玩家点击“继续”或自动延时结束后调用 OnDialogueFinished(runner, node); }, node.autoContinueDelay); } private static void OnDialogueFinished(GraphRunner runner, DialogueNode node) { // 4. 对话结束继续执行下一个节点 runner.ContinueFlow(node); } }GraphRunner的ExecuteNode方法会根据节点类型路由到对应的Handler// GraphRunner.cs (部分) public void ExecuteNode(BaseNode node) { if (node is DialogueNode dialogueNode) { DialogueNodeHandler.Execute(dialogueNode, this); } // ... 其他节点类型的判断 } public void ContinueFlow(BaseNode currentNode) { var nextNode currentNode.GetNextNode(exit); if (nextNode ! null) { ExecuteNode(nextNode); } else { Debug.Log(流程执行完毕。); // 可以触发Graph结束事件 } }注意事项UI操作如显示、隐藏必须在主线程进行。我们的Handler通过回调机制将流程控制权交还给GraphRunner确保了节点执行的序列化。autoContinueDelay参数实现了自动播放功能对于展示剧情非常有用。3.3 让流程图真正“运行”起来一个静态的图不是我们的目标。我们需要在Unity编辑器的Play模式下也能驱动这个图执行并最好能看到可视化的执行反馈比如当前正在执行的节点高亮。第一步创建Graph Runner MonoBehaviour这个组件负责在游戏运行时持有并执行一个GameFlowGraph资产。// RuntimeGraphRunner.cs using UnityEngine; using Sirenix.OdinInspector; public class RuntimeGraphRunner : MonoBehaviour { [SerializeField, Required, BoxGroup(配置)] private GameFlowGraph flowGraph; [SerializeField, BoxGroup(配置)] private bool playOnStart true; private GraphRunner _graphRunner; void Start() { if (flowGraph null) { Debug.LogError(未分配 Flow Graph, this); return; } _graphRunner new GraphRunner(flowGraph); if (playOnStart) { StartGraph(); } } [Button(开始运行流程), GUIColor(0, 1, 0)] public void StartGraph() { if (_graphRunner ! null !_graphRunner.IsRunning) { _graphRunner.Start(); } } void OnDestroy() { _graphRunner?.Stop(); } }第二步增强GraphRunner支持编辑器可视化我们需要修改GraphRunner让它不仅能执行逻辑还能记录当前节点并触发事件。// GraphRunner.cs using System; using UnityEngine; public class GraphRunner { public GameFlowGraph Graph { get; private set; } public BaseNode CurrentNode { get; private set; } public bool IsRunning { get; private set; } // 事件当节点开始执行时 public event ActionBaseNode OnNodeEnter; // 事件当节点执行完毕时 public event ActionBaseNode OnNodeExit; public GraphRunner(GameFlowGraph graph) { Graph graph; } public void Start() { if (Graph null) throw new ArgumentNullException(nameof(Graph)); var startNode Graph.GetStartNode(); if (startNode null) { Debug.LogError(流程图中没有找到开始节点); return; } IsRunning true; ExecuteNode(startNode as BaseNode); } public void ExecuteNode(BaseNode node) { if (node null) return; CurrentNode node; OnNodeEnter?.Invoke(node); // 执行节点逻辑。注意某些节点如等待节点可能是异步的。 node.Execute(this); // 注意OnNodeExit 不一定在这里调用。 // 对于同步节点可以在Execute末尾调用。 // 对于异步节点如对话、等待需要在异步回调中调用。 } // 提供一个通用的“继续”方法供节点异步逻辑完成后调用 public void ContinueFlow(BaseNode fromNode) { OnNodeExit?.Invoke(fromNode); var nextNode fromNode.GetNextNode(); if (nextNode ! null) { ExecuteNode(nextNode); } else { Stop(); } } public void Stop() { IsRunning false; CurrentNode null; Debug.Log(GraphRunner 已停止。); } }第三步编辑器窗口高亮当前节点我们需要创建一个自定义的Editor Window它监听GraphRunner的事件并刷新节点图的显示。// FlowGraphEditorWindow.cs using UnityEditor; using UnityEngine; using XNodeEditor; using Sirenix.OdinInspector.Editor; [CustomEditor(typeof(RuntimeGraphRunner))] public class RuntimeGraphRunnerEditor : OdinEditor { public override void OnInspectorGUI() { base.OnInspectorGUI(); RuntimeGraphRunner runner (RuntimeGraphRunner)target; if (runner ! null Application.isPlaying) { EditorGUILayout.Space(); if (GUILayout.Button(在节点图编辑器中打开并跟踪)) { FlowGraphWindow.OpenAndTrack(runner); } } } } public class FlowGraphWindow : XNodeEditor.NodeEditorWindow { private RuntimeGraphRunner _trackedRunner; private BaseNode _lastHighlightedNode; public static void OpenAndTrack(RuntimeGraphRunner runner) { var window GetWindowFlowGraphWindow(流程跟踪器); window._trackedRunner runner; if (runner ! null) { // 订阅事件 var gr runner.GetGraphRunner(); // 假设有一个方法获取内部的GraphRunner实例 gr.OnNodeEnter window.OnNodeEntered; gr.OnNodeExit window.OnNodeExited; } window.Repaint(); } private void OnNodeEntered(BaseNode node) { _lastHighlightedNode node; Repaint(); // 触发重绘高亮节点 } private void OnNodeExited(BaseNode node) { // 可以取消高亮 Repaint(); } public override void OnGUI() { base.OnGUI(); // 我们可以在这里绘制一些额外的运行时信息 if (_trackedRunner ! null _trackedRunner.IsRunning) { EditorGUILayout.LabelField($状态: 运行中, EditorStyles.boldLabel); if (_lastHighlightedNode ! null) { EditorGUILayout.LabelField($当前节点: {_lastHighlightedNode.name}); } } } // 重写XNode的绘制方法为当前节点添加高亮 public override void DrawNode(XNode.Node node) { base.DrawNode(node); // 如果这个节点是当前正在执行的节点绘制一个高亮框 if (node _lastHighlightedNode as XNode.Node) { Rect rect node.GetRect(); rect.position new Vector2(-3, -3); rect.size new Vector2(6, 6); GUI.color Color.green; GUI.Box(rect, , (GUIStyle)flow node 0 on); GUI.color Color.white; } } void OnDestroy() { // 清理事件订阅 if (_trackedRunner ! null) { // ... 取消订阅 } } }这样在Play模式下策划点击运行然后在专门的“流程跟踪器”窗口就能看到节点图并且当前执行到的节点会有一个绿色的高亮框实现了真正的可视化调试。4. 高级功能与扩展实践4.1 实现分支与循环逻辑节点一个完整的流程系统离不开条件分支和循环。我们来实现一个BranchNode分支节点。// BranchNode.cs using XNode; using Sirenix.OdinInspector; [NodeTint(#FF9800)] // 橙色 public class BranchNode : BaseNode { public enum ConditionType { Bool, IntComparison, HasItem } [Input] public NodePort entry; [Output(dynamicPortList true)] public NodePort[] exits; // 动态端口列表用于“是/否”或多个分支 [BoxGroup(条件)] public ConditionType conditionType ConditionType.Bool; [BoxGroup(条件), ShowIf(conditionType, ConditionType.Bool), LabelText(条件值)] public bool boolCondition true; [BoxGroup(条件), ShowIf(conditionType, ConditionType.IntComparison)] public int leftValue; [BoxGroup(条件), ShowIf(conditionType, ConditionType.IntComparison)] public ComparisonOperator comparison; [BoxGroup(条件), ShowIf(conditionType, ConditionType.IntComparison)] public int rightValue; public enum ComparisonOperator { Equal, NotEqual, Greater, Less, GreaterOrEqual, LessOrEqual } public override void Execute(GraphRunner runner) { int selectedExitIndex EvaluateCondition(); if (selectedExitIndex 0 selectedExitIndex exits.Length) { // 连接到对应出口端口的节点就是下一个要执行的节点 var nextNode GetOutputPort($exits {selectedExitIndex}).Connection?.node as BaseNode; if (nextNode ! null) { runner.ContinueFlowTo(nextNode); } else { runner.ContinueFlow(this); // 如果没有连接尝试从默认出口走 } } else { runner.ContinueFlow(this); } } private int EvaluateCondition() { switch (conditionType) { case ConditionType.Bool: return boolCondition ? 0 : 1; // 索引0对应“真”分支索引1对应“假”分支 case ConditionType.IntComparison: bool result false; switch (comparison) { case ComparisonOperator.Equal: result leftValue rightValue; break; // ... 其他比较运算符 } return result ? 0 : 1; default: return 0; } } }关键点[Output(dynamicPortList true)]允许我们在编辑器里动态添加或删除输出端口。ShowIf是Odin的Attribute可以根据conditionType的值动态显示或隐藏相关的配置字段这使节点界面非常清晰。EvaluateCondition方法根据配置计算出应该走哪个分支返回对应的端口索引。4.2 与游戏数据驱动集成流程节点经常需要读取游戏状态如玩家等级、任务进度、背包物品来决定行为。我们可以创建一个Blackboard黑板系统作为中间层。// GameBlackboard.cs using System.Collections.Generic; using UnityEngine; public class GameBlackboard : MonoBehaviour { public static GameBlackboard Instance { get; private set; } private Dictionarystring, int _ints new Dictionarystring, int(); private Dictionarystring, bool _bools new Dictionarystring, bool(); private Dictionarystring, string _strings new Dictionarystring, string(); void Awake() { Instance this; } public void SetInt(string key, int value) _ints[key] value; public int GetInt(string key, int defaultValue 0) _ints.TryGetValue(key, out int val) ? val : defaultValue; // ... 其他类型的Get/Set方法 // 在BranchNode的EvaluateCondition中可以这样使用 // if (conditionType ConditionType.IntComparison) { // leftValue GameBlackboard.Instance.GetInt(someKey); // } }然后我们可以创建GetBlackboardValueNode和SetBlackboardValueNode让策划直接在流程图中读写游戏数据使得流程逻辑与游戏状态深度绑定实现高度动态的剧情或玩法。4.3 性能优化与批量处理当节点图变得非常庞大时可能会遇到性能问题。主要有两个优化点1. 端口连接检查优化XNode在获取连接时如果节点端口很多频繁的GetPort()和GetConnection()调用可能成为瓶颈。我们可以缓存端口引用。public abstract class BaseNode : Node { private Dictionarystring, NodePort _cachedOutputPorts; public BaseNode GetNextNodeCached(string portName exit) { if (_cachedOutputPorts null) _cachedOutputPorts new Dictionarystring, NodePort(); if (!_cachedOutputPorts.TryGetValue(portName, out NodePort port)) { port GetOutputPort(portName); _cachedOutputPorts[portName] port; } if (port ! null port.IsConnected) { return port.Connection.node as BaseNode; } return null; } }2. 避免每帧遍历所有节点GraphRunner的执行本质上是事件驱动的链式调用只有当前激活的节点在工作这本身就是高效的。但要避免在Update中轮询检查节点状态。对于“等待”类节点应使用协程Coroutine或Unity的Invoke而不是在Execute方法里阻塞主线程。// WaitNode.cs public class WaitNode : BaseNode { public float duration 1.0f; public override void Execute(GraphRunner runner) { runner.StartCoroutine(WaitAndContinue(runner)); } System.Collections.IEnumerator WaitAndContinue(GraphRunner runner) { yield return new WaitForSeconds(duration); runner.ContinueFlow(this); } }踩坑记录最初我在WaitNode.Execute里用了Thread.Sleep直接导致编辑器卡死。切记所有涉及延时的操作在Unity里必须用协程或异步方法。5. 常见问题与调试技巧实录在实际开发和团队推广使用中我遇到了不少典型问题这里总结一下排查思路和解决方案。5.1 节点端口连接不上或连线错误问题现象在编辑器里拖动端口无法连线或者连线后逻辑执行不对。排查步骤检查端口定义确认端口字段的[Input]或[Output]Attribute是否正确。connectionType设置是否合理Multiple允许多个连接Override只允许一个。检查端口类型XNode默认要求相连的端口类型即字段类型必须一致。如果你定义了一个public MyClass output那么输入端口也必须是MyClass类型。如果希望连接任何类型可以使用[NonSerialized]或object类型但这会失去类型安全。查看控制台错误有时端口连接逻辑错误会在后台抛出异常查看Unity Console是否有相关错误信息。重启编辑器XNode的编辑器脚本有时会有缓存问题重启Unity Editor往往能解决一些诡异的连线问题。5.2 流程执行到某个节点后停止问题现象点击运行流程执行了几步后卡住不再继续。排查技巧检查节点连线首先在编辑器中确认当前节点的输出端口是否连接到了下一个节点。经常有策划忘了连线。检查ContinueFlow调用在疑似卡住的节点类型对应的Handler中添加详细的Debug.Log确保runner.ContinueFlow(...)被正确调用。对于异步节点如等待、对话要确保回调函数被触发。使用GraphRunner的调试视图在自定义的FlowGraphWindow中增加一个节点执行历史列表打印出每一步执行的节点名可以清晰看到流程停在了哪里。检查条件分支如果是BranchNode仔细检查条件配置和黑板数据。可能条件永远不满足导致没有有效的出口。5.3 Odin Attribute不生效或显示异常问题现象在节点脚本中写了Odin的Attribute如[BoxGroup]但在节点编辑器里看不到效果。解决方案确认Odin已启用在Unity顶部菜单栏点击Odin Inspector - Preferences - Enable Odin Inspector确保其为勾选状态。检查编辑器基类确保你的自定义节点编辑器类继承了正确的Odin编辑器基类。对于XNode节点通常需要创建一个NodeEditor并在其中使用DrawDefaultInspectorOdin一个Odin提供的扩展方法来绘制。[CustomNodeEditor(typeof(DialogueNode))] public class DialogueNodeEditor : NodeEditor { public override void OnBodyGUI() { // 获取节点对象 var node target as DialogueNode; // 使用Odin绘制默认Inspector node.DrawDefaultInspectorOdin(); } }字段序列化确保使用了Odin Attribute的字段是public或带有[SerializeField]的private字段并且没有被[NonSerialized]或[HideInInspector]覆盖。5.4 构建Build后节点图功能失效问题现象在Editor下运行正常打包成exe或移动端后流程不执行或出错。深度排查ScriptableObject引用丢失检查RuntimeGraphRunner上引用的GameFlowGraph资产是否被打包进构建。确保该资产在Resources文件夹内或者通过地址ables/AssetBundle正确加载。反射与AOT编译如果使用了大量动态类型或反射比如通过节点类名动态创建处理器在IL2CPP构建时可能会遇到问题。建议使用明确的注册表或字典来映射节点类型和处理器。public class NodeHandlerRegistry { private static DictionaryType, ActionBaseNode, GraphRunner _handlers new DictionaryType, ActionBaseNode, GraphRunner() { { typeof(DialogueNode), (node, runner) DialogueNodeHandler.Execute((DialogueNode)node, runner) }, { typeof(WaitNode), (node, runner) WaitNodeHandler.Execute((WaitNode)node, runner) }, // ... }; public static void ExecuteNode(BaseNode node, GraphRunner runner) { if (_handlers.TryGetValue(node.GetType(), out var handler)) { handler(node, runner); } } }UI依赖检查DialogueUIManager.Instance等在运行时初始化的单例在构建后是否能够正确获取。确保UI系统在场景加载时就已经初始化完毕。5.5 节点图版本管理与团队协作问题策划和程序同时修改同一个.asset节点图文件导致版本冲突。建议工作流粒度拆分不要将所有流程做到一个巨大的图里。按照功能模块如“新手引导第一章”、“商店购买流程”、“Boss战斗阶段”拆分成多个小的Graph文件。预制节点将常用的、稳定的节点逻辑做成预制节点Prefab Node的概念较弱主要是保存为可复用的.asset模板比较困难。一种实践是创建“子图”节点SubGraph Node它引用另一个GameFlowGraph这样可以实现节点的模块化和复用。自定义属性与元数据为GameFlowGraph添加版本号、作者、最后修改时间等字段便于追踪。这套基于Unity、XNode和Odin的流程可视化系统从零搭建到在项目中稳定运行花了我们大约两周的核心开发时间。它带来的最大改变不是技术上的而是工作流上的。策划现在可以独立搭建和调试大部分游戏逻辑程序只需要负责提供新的、功能更底层的节点“积木”。沟通效率提升了迭代速度也快了很多。如果你也在为游戏逻辑的复杂度和协作效率发愁强烈建议尝试引入类似的工具链它的投入产出比会非常高。
返回列表