简介:这是一份基于Unity引擎的运行时节点编辑器项目,面向具备一定Unity开发经验、对互动电影或可视化叙事系统感兴趣的中高级开发者,用于研究节点编辑与数据持久化的实现思路。项目目前尚未完全收尾,显示类节点的运行逻辑仍待策划与美术确认,但节点编辑、保存与加载功能已可正常使用,适合作为技术验证与二次开发的起点。压缩包共205个文件,约8.56MB,包含16个C#脚本、13个Shader、8个Prefab、37个Asset资源及若干meta、json、png等配置与素材文件,覆盖核心逻辑、渲染管线与场景资源。已有231人学习关注。读者可从中获取节点图编辑器的工程结构、数据序列化方案与URP渲染配置参考,并结合作者提供的运行效果视频与配套文章,理解互动电影类项目的节点驱动思路,同时留意源码因思路迭代而存在的可优化空间。
1. 运行时节点编辑器到底解决互动电影的什么痛点
做互动电影最头疼的不是拍素材,而是剧情分支一旦超过三层,if-else就会变成一团乱麻。传统做法是把分支逻辑硬编码在 C# 脚本里,策划想改一个选项的跳转目标,程序就得重新编译打包,一来一回半小时没了。Unity 运行时节点编辑器要解决的就是这件事:让策划在游戏运行过程中直接拖拽节点、连线、改条件,逻辑即时生效,不用退出 Play 模式。它本质上是一套跑在 Unity 运行时里的可视化脚本系统,节点代表剧情片段或条件判断,连线代表流转方向,数据存在 ScriptableObject 或 JSON 里。适合做互动叙事、分支对话、点击解谜这类强剧情驱动的项目,也适合需要频繁调逻辑的策划团队。热搜里常出现的「Unity 节点编辑器」和「互动电影」组合,核心诉求就一个:把剧情逻辑从代码里解放出来,让非程序员也能改。
2. 运行时节点编辑器的底层选型:为什么不用 GraphView 硬扛
2.1 运行时和编辑器期的本质区别
Unity 自带的 GraphView 是编辑器期工具,依赖UnityEditor命名空间,打包后直接失效。很多人第一次做运行时节点编辑器,习惯性去翻 GraphView 的源码想移植,结果发现它跟 EditorWindow 绑得太死,拆出来成本极高。运行时节点编辑器的核心约束是:所有 UI 和交互必须基于UnityEngine和UnityEngine.UI(或 UI Toolkit 的运行时部分),不能碰任何 Editor 相关 API。
常见做法是分两层:数据层用纯 C# 类描述节点和连线,表现层用 UGUI 或 UI Toolkit 画节点框和贝塞尔曲线。数据层和表现层通过事件解耦,这样打包后数据层照常跑,表现层只在需要编辑时激活。我一般会把节点数据序列化成 JSON,存到Application.persistentDataPath下,运行时读写都不依赖 AssetDatabase。
2.2 节点数据结构的三个关键字段
节点数据结构设计不好,后面扩展会非常痛苦。一个最小可用的节点类至少要有三个字段:唯一 ID、节点类型、端口列表。唯一 ID 用System.Guid生成,避免用自增 int 在增删节点时出现冲突。节点类型用枚举区分「剧情节点」「条件节点」「跳转节点」。端口列表分输入端口和输出端口,每个端口记录连接的目标节点 ID 和目标端口索引。
[System.Serializable] public class NodeData { public string id; // Guid 字符串,全局唯一 public NodeType type; // 剧情/条件/跳转 public string payload; // 剧情文本或条件表达式 public List<PortData> inputs; // 输入端口 public List<PortData> outputs; // 输出端口 public Vector2 editorPos; // 编辑器里的位置,仅编辑期用 } [System.Serializable] public class PortData { public string targetNodeId; // 连向哪个节点 public int targetPortIndex; // 连向该节点的第几个输入端口 }这段代码里payload字段是刻意留成字符串的,剧情文本直接存,条件表达式也存成字符串后续用解析器处理。editorPos只在编辑时用,运行时可以忽略。参数上唯一要注意的是id必须用 Guid 而不是GetInstanceID(),后者在重新加载场景后会变,存档会错乱。
2.3 连线渲染为什么选贝塞尔而不是直线
节点之间的连线如果用直线,节点一多就会互相穿插,视觉上根本分不清哪条线连哪。贝塞尔曲线可以通过控制点把线「弯」出去,配合不同颜色区分分支,可读性高很多。Unity 里画贝塞尔曲线有两种方式:用LineRenderer或者自己生成 Mesh。运行时节点编辑器推荐自己生成 Mesh,因为 LineRenderer 的材质和排序在 UI 层级里容易出问题。
// 生成一条二次贝塞尔曲线的 Mesh public Mesh GenerateBezierMesh(Vector2 start, Vector2 end, int segments = 20) { var mesh = new Mesh(); var vertices = new Vector3[segments + 1]; var indices = new int[segments * 2]; Vector2 control = (start + end) * 0.5f + Vector2.up * 50f; // 控制点向上偏移 for (int i = 0; i <= segments; i++) { float t = i / (float)segments; // 二次贝塞尔公式 Vector2 p = (1 - t) * (1 - t) * start + 2 * (1 - t) * t * control + t * t * end; vertices[i] = new Vector3(p.x, p.y, 0); } for (int i = 0; i < segments; i++) { indices[i * 2] = i; indices[i * 2 + 1] = i + 1; } mesh.vertices = vertices; mesh.SetIndices(indices, MeshTopology.Lines, 0); return mesh; }segments控制曲线平滑度,20 段在 1080p 下已经看不出折角。控制点偏移量50f是经验值,节点间距大就调大,间距小就调小。这段代码只生成了线框,实际项目里还要给 Mesh 套一个带颜色的材质,不同分支用不同颜色,策划一眼就能区分主线支线。
3. 从零搭一个可运行的节点编辑器:场景、交互与存档
3.1 场景搭建与 UI 层级规划
场景结构建议分三层:Canvas 根节点下挂「背景层」「连线层」「节点层」「UI 层」。连线层必须在节点层下面,否则线会盖住节点框。节点层里每个节点是一个 Prefab,包含背景 Image、标题 Text、输入输出端口 Button。端口用 Button 而不是 Image,是为了直接拿到点击事件,省去自己写射线检测。
// 节点 Prefab 上的控制脚本 public class NodeView : MonoBehaviour { public NodeData data; public Button inputPort; public Button outputPort; public Text titleText; public void Refresh() { titleText.text = data.payload; // 端口颜色按节点类型区分 var img = outputPort.GetComponent<Image>(); img.color = data.type == NodeType.Condition ? Color.yellow : Color.green; } }Refresh()在数据变更后调用,保证 UI 和数据一致。端口颜色区分类型是给策划的视觉提示,条件节点黄色、剧情节点绿色,这个习惯能减少很多「连错线」的沟通成本。
3.2 拖拽、连线与断开的交互实现
拖拽节点用IDragHandler和IBeginDragHandler接口,在OnDrag里把屏幕坐标转成 Canvas 局部坐标,更新RectTransform.anchoredPosition。连线用「按下输出端口 → 拖拽 → 松开在输入端口」的流程,拖拽过程中实时更新一条临时贝塞尔曲线,松手时检测鼠标下方是否有输入端口。
public class ConnectionDragger : MonoBehaviour, IBeginDragHandler, IDragHandler, IEndDragHandler { private NodeView sourceNode; private GameObject tempLine; public void OnBeginDrag(PointerEventData eventData) { sourceNode = GetComponentInParent<NodeView>(); tempLine = new GameObject("TempLine"); tempLine.AddComponent<MeshFilter>(); tempLine.AddComponent<MeshRenderer>(); } public void OnDrag(PointerEventData eventData) { // 每帧更新临时曲线终点为鼠标位置 Vector2 mousePos = eventData.position; // 实际项目里这里要转成 Canvas 局部坐标再生成 Mesh } public void OnEndDrag(PointerEventData eventData) { Destroy(tempLine); // 射线检测鼠标下方是否有输入端口 var results = new List<RaycastResult>(); EventSystem.current.RaycastAll(eventData, results); foreach (var r in results) { var targetPort = r.gameObject.GetComponent<InputPort>(); if (targetPort != null) { // 建立连接,写入 sourceNode.data.outputs break; } } } }RaycastAll比Raycast更可靠,因为鼠标松开时可能同时命中多个 UI 元素,取第一个带InputPort组件的即可。断开连接的做法是右键点击连线,弹出菜单选「删除」,或者直接拖拽输入端口到空白处。右键菜单用ContextMenu或自己搭一个简单的 Popup,不要用系统右键,移动端不兼容。
3.3 存档与读档:JSON 序列化的两个坑
存档用JsonUtility.ToJson就够了,但有两个坑必须提前避开。第一,JsonUtility不支持Dictionary,所以节点数据里不能用字典存端口映射,只能用 List。第二,Vector2可以序列化,但Vector3在旧版本 Unity 里序列化后读出来是零,建议位置信息统一用Vector2。
[System.Serializable] public class GraphSaveData { public List<NodeData> nodes = new List<NodeData>(); public string graphName; } public void SaveGraph(string fileName) { var save = new GraphSaveData(); save.nodes = allNodes.Select(n => n.data).ToList(); save.graphName = fileName; string json = JsonUtility.ToJson(save, true); string path = Path.Combine(Application.persistentDataPath, fileName + ".json"); File.WriteAllText(path, json); Debug.Log($"存档写入: {path}"); }Application.persistentDataPath在 Windows 下是%userprofile%\AppData\LocalLow\公司名\产品名,在 Android 下是应用私有目录,不需要额外权限。读档时先File.ReadAllText再JsonUtility.FromJson<GraphSaveData>,然后根据nodes列表重建所有 NodeView。注意读档后要重新计算连线 Mesh,因为节点位置变了,旧 Mesh 的顶点坐标已经不对。
4. 互动电影案例:把节点编辑器接进剧情流程
4.1 剧情节点的执行器设计
节点编辑器只是编辑工具,真正跑剧情需要一个执行器。执行器从「开始节点」出发,按连线顺序执行每个节点。剧情节点播放视频或显示文本,条件节点判断变量,跳转节点直接跳到目标节点。执行器用协程实现,每个节点执行完调用MoveNext()走到下一个节点。
public class StoryExecutor : MonoBehaviour { public NodeData startNode; private Dictionary<string, NodeData> nodeMap; public IEnumerator Run() { var current = startNode; while (current != null) { switch (current.type) { case NodeType.Story: yield return PlayStory(current.payload); current = GetNext(current, 0); break; case NodeType.Condition: bool result = Evaluate(current.payload); current = GetNext(current, result ? 0 : 1); break; case NodeType.Jump: current = nodeMap[current.payload]; break; } } } private NodeData GetNext(NodeData node, int portIndex) { if (portIndex >= node.outputs.Count) return null; string nextId = node.outputs[portIndex].targetNodeId; return nodeMap.TryGetValue(nextId, out var next) ? next : null; } }nodeMap在初始化时从存档数据构建,用Dictionary<string, NodeData>加速查找。PlayStory是一个返回IEnumerator的方法,内部可以用yield return new WaitForSeconds或等待视频播放完毕。条件节点的Evaluate方法需要自己写一个简单的表达式解析器,支持flag == true这种基本语法即可,不要上完整的 C# 表达式树,太重。
4.2 变量系统与条件判断的最小实现
互动电影里常见的条件有「看过某段剧情」「选了某个选项」「好感度大于 X」。变量系统用一个Dictionary<string, object>存全局状态,条件表达式解析时从字典取值。表达式格式建议固定为变量名 运算符 值,用空格分隔,解析简单且策划容易写对。
public bool Evaluate(string expression) { // 格式: "affection >= 50" var parts = expression.Split(' '); if (parts.Length != 3) return false; string varName = parts[0]; string op = parts[1]; string valueStr = parts[2]; if (!variables.TryGetValue(varName, out var varValue)) return false; float left = System.Convert.ToSingle(varValue); float right = float.Parse(valueStr); switch (op) { case ">=": return left >= right; case "<=": return left <= right; case "==": return Mathf.Approximately(left, right); case ">": return left > right; case "<": return left < right; default: return false; } }Mathf.Approximately用于浮点数相等判断,避免0.1 + 0.2 != 0.3这种经典问题。变量值统一转成 float 处理,字符串类型的变量(比如角色名)单独走另一套比较逻辑。这个解析器只支持单条件,多条件用多个条件节点串联,不要在一个表达式里写&&,解析复杂度会指数上升。
4.3 运行时编辑与游戏流程的切换
运行时节点编辑器的使用场景通常是「策划在 Play 模式下暂停游戏,打开编辑器改逻辑,改完继续跑」。所以需要一个快捷键(比如 F1)切换编辑模式和游玩模式。编辑模式下时间缩放设为 0,所有剧情执行器暂停;游玩模式下编辑器 UI 隐藏,执行器恢复。
public class EditorModeSwitcher : MonoBehaviour { public GameObject editorRoot; public StoryExecutor executor; private bool isEditing = false; void Update() { if (Input.GetKeyDown(KeyCode.F1)) { isEditing = !isEditing; editorRoot.SetActive(isEditing); Time.timeScale = isEditing ? 0f : 1f; if (!isEditing) { // 退出编辑模式时重新加载剧情数据 executor.ReloadGraph(); } } } }Time.timeScale = 0会暂停所有WaitForSeconds,但不会暂停WaitForSecondsRealtime,所以剧情执行器里如果用了实时等待要单独处理。ReloadGraph方法重新读 JSON 并重建nodeMap,保证编辑后的逻辑立即生效。这个切换机制是运行时节点编辑器的核心价值所在,策划改完不用重启游戏,直接继续测。
5. 避坑与排查:运行时节点编辑器最容易翻车的五个地方
5.1 连线 Mesh 在节点移动后不更新
现象:拖动节点后,连线还停留在旧位置,看起来像「断开了」。原因:贝塞尔 Mesh 只在创建时生成了一次,节点位置变化后没有重新计算顶点。解决:在NodeView.OnDrag里触发一个OnNodeMoved事件,连线管理器监听该事件并重新生成对应 Mesh。不要每帧都重建所有连线,只重建与移动节点相关的连线。
5.2 JsonUtility 序列化后 List 变空
现象:存档写入了,读出来nodes列表是空的。原因:JsonUtility要求字段必须是public或者带[SerializeField],且类必须标记[System.Serializable]。如果NodeData里嵌套了PortData,PortData也必须标记。解决:检查所有参与序列化的类是否都加了[System.Serializable],字段是否都是 public。另一个常见原因是List<PortData>初始化在构造函数里,而JsonUtility反序列化时不调用构造函数,导致 List 为 null。解决:在字段声明时直接初始化= new List<PortData>()。
5.3 端口点击事件被节点拖拽吞掉
现象:点击输出端口想拉线,结果整个节点被拖走了。原因:端口的 Button 和节点的 DragHandler 在同一层级,事件冒泡导致拖拽优先。解决:在端口的脚本里实现IPointerDownHandler并在回调里调用eventData.Use(),阻止事件继续冒泡。或者把端口做成节点的子物体,但给端口单独加一个CanvasGroup并设置blocksRaycasts = true,节点拖拽脚本里判断eventData.pointerCurrentRaycast.gameObject是否是端口,是则跳过拖拽。
5.4 打包后节点编辑器 UI 错位
现象:在编辑器里跑得好好的,打包到 Android 后节点位置全乱了。原因:Canvas 的CanvasScaler在编辑器和真机上的参考分辨率不一致,anchoredPosition的基准变了。解决:节点位置存储时用相对坐标(相对于 Canvas 中心),不要用绝对屏幕坐标。CanvasScaler的Reference Resolution设成设计分辨率,Match设为 0.5,保证横竖屏都不跑偏。
5.5 条件表达式解析遇到空字符串直接崩
现象:策划在条件节点里没填表达式,运行到该节点时Split返回长度为 1 的数组,parts[1]越界。原因:没有做空值和格式校验。解决:Evaluate方法开头加if (string.IsNullOrWhiteSpace(expression)) return false;,并且Split后判断parts.Length == 3再继续。另外,float.Parse遇到非数字字符串会抛异常,用float.TryParse替代,解析失败返回 false 并打一条警告日志,方便策划定位问题。
6. 进阶技巧:用节点编辑器做分支热重载与数据校验
节点编辑器跑通之后,下一步值得投入的是「热重载」和「数据校验」。热重载指的是策划改完节点逻辑,不重启游戏直接生效。实现方式是在EditorModeSwitcher退出编辑模式时,不仅重新加载nodeMap,还要把当前执行到的节点 ID 保留下来,重新加载后从该节点继续执行。这里有个细节:如果策划删掉了当前正在执行的节点,需要回退到最近的父节点,否则执行器会卡死。我一般会在每个节点执行前把当前节点 ID 写到一个lastValidNodeId变量里,重载时如果找不到当前节点就跳回lastValidNodeId。
数据校验是另一个容易被忽略但极其重要的环节。策划连线时很容易出现「输出端口连到不存在的节点」「条件节点没有输入端口」「开始节点被删除」这类问题。建议在保存存档前跑一遍校验,把错误节点标红并在控制台输出具体原因。
public List<string> ValidateGraph(List<NodeData> nodes) { var errors = new List<string>(); var idSet = new HashSet<string>(nodes.Select(n => n.id)); foreach (var node in nodes) { foreach (var port in node.outputs) { if (!idSet.Contains(port.targetNodeId)) { errors.Add($"节点 {node.id} 的输出连向了不存在的节点 {port.targetNodeId}"); } } if (node.type == NodeType.Condition && node.inputs.Count == 0) { errors.Add($"条件节点 {node.id} 没有输入端口"); } } return errors; }ValidateGraph返回错误列表,调用方决定是弹窗提示还是直接阻止保存。HashSet用于 O(1) 判断节点 ID 是否存在,节点数量上千时比List.Contains快一个数量级。校验通过后再写 JSON,能避免大量「存档坏了」的售后问题。
最后说一个我踩过的坑:节点编辑器的 Undo/Redo 不要自己从头写,用命令模式把每个操作封装成ICommand接口,维护一个操作栈。我第一版没做 Undo,策划误删一个节点直接崩溃,血泪经验。希望帮到你。
本文还有配套的精品资源,点击获取