尧图网络科技YAOTU DIGITAL 获取报价
获取报价
首页 / 资讯中心 / 文章详情

Unity运行时节点编辑器:互动电影剧情分支可视化编辑与热重载实战

发布时间:2026/9/26 12:28:15

资讯中心
01
ARTICLE

Unity运行时节点编辑器:互动电影剧情分支可视化编辑与热重载实战

Unity运行时节点编辑器:互动电影剧情分支可视化编辑与热重载实战
简介这是一份基于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 ListPortData inputs; // 输入端口 public ListPortData 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.GetComponentImage(); 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 GetComponentInParentNodeView(); tempLine new GameObject(TempLine); tempLine.AddComponentMeshFilter(); tempLine.AddComponentMeshRenderer(); } public void OnDrag(PointerEventData eventData) { // 每帧更新临时曲线终点为鼠标位置 Vector2 mousePos eventData.position; // 实际项目里这里要转成 Canvas 局部坐标再生成 Mesh } public void OnEndDrag(PointerEventData eventData) { Destroy(tempLine); // 射线检测鼠标下方是否有输入端口 var results new ListRaycastResult(); EventSystem.current.RaycastAll(eventData, results); foreach (var r in results) { var targetPort r.gameObject.GetComponentInputPort(); 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 ListNodeData nodes new ListNodeData(); 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.FromJsonGraphSaveData然后根据nodes列表重建所有 NodeView。注意读档后要重新计算连线 Mesh因为节点位置变了旧 Mesh 的顶点坐标已经不对。4. 互动电影案例把节点编辑器接进剧情流程4.1 剧情节点的执行器设计节点编辑器只是编辑工具真正跑剧情需要一个执行器。执行器从「开始节点」出发按连线顺序执行每个节点。剧情节点播放视频或显示文本条件节点判断变量跳转节点直接跳到目标节点。执行器用协程实现每个节点执行完调用MoveNext()走到下一个节点。public class StoryExecutor : MonoBehaviour { public NodeData startNode; private Dictionarystring, 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在初始化时从存档数据构建用Dictionarystring, NodeData加速查找。PlayStory是一个返回IEnumerator的方法内部可以用yield return new WaitForSeconds或等待视频播放完毕。条件节点的Evaluate方法需要自己写一个简单的表达式解析器支持flag true这种基本语法即可不要上完整的 C# 表达式树太重。4.2 变量系统与条件判断的最小实现互动电影里常见的条件有「看过某段剧情」「选了某个选项」「好感度大于 X」。变量系统用一个Dictionarystring, 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里嵌套了PortDataPortData也必须标记。解决检查所有参与序列化的类是否都加了[System.Serializable]字段是否都是 public。另一个常见原因是ListPortData初始化在构造函数里而JsonUtility反序列化时不调用构造函数导致 List 为 null。解决在字段声明时直接初始化 new ListPortData()。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 Liststring ValidateGraph(ListNodeData nodes) { var errors new Liststring(); var idSet new HashSetstring(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策划误删一个节点直接崩溃血泪经验。希望帮到你。本文还有配套的精品资源点击获取
02
RELATED NEWS

相关资讯

更多网站建设与数字化升级内容

03
WHY YAOTU

想打造同款高转化官网?

懂行业、懂生意,从建站到增长一站式陪跑

◈

场景化定制

不做模板站,围绕你的业务场景量身设计,小众不撞款。

◐

营销型架构

以转化目标组织内容与路径,让官网真正带来询盘。

▲

全周期服务

设计、开发、运营、运维一体,上线只是开始。

免费获取你的建站方案

留下需求,专属顾问 24 小时内为你输出方案建议。