简介:这是Unity运行时节点编辑器互动电影案例的工程源码,面向有Unity开发经验、想研究节点编辑与互动叙事机制的中高级开发者。项目尚未完工,运行界面仍需策划和美术打磨,但已能完成节点的编辑、保存与加载,可视为探索互动电影节点化驱动思路的半成品框架。压缩包共205个文件、约8.56MB,主要包含工程配置资产、C#逻辑脚本、着色器、预制体,以及着色器包含文件、JSON数据、字体和说明文档等,基本覆盖了编辑器面板、数据存储与渲染表现等关键模块。已有231人学习下载。虽然作者自述代码因思路多次调整而有些凌乱,但恰恰能看出运行时节点编辑器从想法到落地的完整路径,对于想搭建类似框架的开发者,这份源码仍具有不错的拆解与参考价值。
1. 运行时节点编辑器:互动电影的剧情Flow不该写死在代码里
互动电影这类 Unity 项目,做到一半往往会栽在同一个地方:玩家的选择一多,剧情分支就开始在代码里横向生长。今天加一个选项,明天加一个结局,后天发现某个分支要重跑一段镜头,整个流程就变成没人敢动的黑匣子。
运行时节点编辑器做的事情,是把一整段剧情——对话、选项、事件触发、结局判定——抽象成一张节点图数据。编辑态拖连线,运行态由执行器驱动,策划改剧情不碰代码。这套方案适合做互动叙事、悬疑解谜、多结局AVG的团队和个人开发者。下面给出一套能跑的骨架,从数据模型讲到运行时编辑面板,再列五个我实际踩过的高频翻车点。
2. 把节点图立住:纯数据模型与通用执行器
2.1 编辑态与运行态分开设计,别把节点图当代码跑
“节点编辑器”这四个字拆开,其实是两个独立系统:编辑态的画布编辑器,和运行态的图执行器。很多项目先画了一个漂亮的编辑器,导出一张图,运行时却用一坨 switch 去猜下一个节点,结果图只是张没用的摆设。
我一般会把执行器当成第一优先级。互动电影的典型节奏是:播一段剧情 → 停在选项点 → 玩家选择 → 进入另一段剧情。这个流程本质是图遍历,不是线性播放。Unity 的 Timeline 擅长处理线性的镜头排布,但分支决策、条件判断、变量记录仍然需要一个能串起它们的骨架,这就是运行时节点图存在的意义。它把“剧情内容”和“剧情逻辑”分开:内容改了不用重新出包,逻辑就集中在执行器一个类里。
还有一个常见误解:以为运行时就是要玩家看到编辑器。实际上“运行时”指图在玩家包里实时执行、实时读取、可被存档恢复。编辑器放在哪一层是另一件事,后面第四章单独讲。
2.2 节点图序列化模型:GUID、端口名与 configJson
节点图要能被保存、传输、回放,第一步就是把它变成纯数据。我常用的最小模型是三个类:节点、连线、整张图。
[Serializable] public class NodeData { public string id; // 全局唯一标识,用 GUID 字符串,不要用自增整数 public string type; // entry / dialogue / branch / event / ending public string displayName; // 策划在画布上看到的标题 public Vector2 position; // 画布坐标,运行时也保留,方便回放和复盘 public string configJson; // 每个类型自己的配置,统一序列化成 JSON 字符串 } [Serializable] public class EdgeData { public string fromNode; // 起始节点 id public string fromPort; // 起始端口名,例如 opt_0、out public string toNode; // 目标节点 id public string toPort; // 目标端口名 } [Serializable] public class GraphData { public string schemaVersion; // 图结构版本,存档迁移靠它 public List<NodeData> nodes = new List<NodeData>(); public List<EdgeData> edges = new List<EdgeData>(); }这里有三个设计点要解释清楚。
第一,id 用 GUID 字符串。自增整数在编辑器会话里没问题,一旦策划删了中间某个节点、重新保存,后面所有节点的编号都会变化,老存档里的 id 会对不上。GUID 在节点创建时生成一次,永不复用,这是存档兼容的地基。
第二,连线不存数组下标,存端口名。分支节点有多个出口,端口名比下标稳定得多。策划在画布上拖动重排连线,连线的数组顺序会变,但端口名不会。
第三,configJson 用字符串而不是嵌套强类型。Unity 的序列化对多态支持很差,如果 NodeData 里直接放 BranchConfig、EventConfig 这些子类,编辑器面板会很难写。统一存 JSON 字符串,读取时再用 JsonUtility 反序列化成对应配置,开闭原则最好。
保存和加载就两行:
var json = JsonUtility.ToJson(graphData, true); File.WriteAllText(Application.persistentDataPath + "/plot_v1.json", json, new System.Text.UTF8Encoding(false));注意:File.WriteAllText 的编码参数一定显式传 UTF8,否则在部分本地化系统上会落成系统默认编码,中文剧情文本后面必出乱码,详细见第五章。
2.3 最小执行器:从 entry 到 ending 的完整走查代码
执行器的工作只有一件事:进入一个节点,等它处理完,再根据端口选择下一个节点。下面是能直接跑通的最小版本。
using System.Collections; using System.Collections.Generic; using System.Linq; using UnityEngine; public class PlotRunner : MonoBehaviour { public TextAsset plotAsset; // 策划导出的剧情图 JSON private GraphData _graph; private Dictionary<string, NodeData> _nodeMap; private Dictionary<string, List<EdgeData>> _outEdges; private string _currentNodeId; private int _stepCount; // 步数计数,用于循环保护 public void LoadGraph(TextAsset asset) { _graph = JsonUtility.FromJson<GraphData>(asset.text); _nodeMap = _graph.nodes.ToDictionary(n => n.id); // 把所有连线按起始节点分组,得到“每个节点有哪些出口” _outEdges = _graph.edges .GroupBy(e => e.fromNode) .ToDictionary(g => g.Key, g => g.ToList()); } public void StartPlot() { var entry = _graph.nodes.First(n => n.type == "entry"); EnterNode(entry.id); } public void EnterNode(string nodeId) { _currentNodeId = nodeId; _stepCount++; // 全图最多 500 步,超出直接报错,防止环路把游戏拖死 if (_stepCount > 500) { Debug.LogError("剧情图疑似含环,步数超限:" + nodeId); return; } var node = _nodeMap[nodeId]; switch (node.type) { case "dialogue": ShowDialogue(node); break; case "branch": ShowBranch(node); break; case "event": StartCoroutine(FireEvent(node)); break; case "ending": HandleEnding(node); break; default: Debug.LogError("未知节点类型:" + node.type); break; } } // 按端口名取下一跳,而不是按连线数组下标 public string GetNextNode(string fromNodeId, string portName) { if (!_outEdges.TryGetValue(fromNodeId, out var edges)) return null; foreach (var edge in edges) { if (edge.fromPort == portName) return edge.toNode; } return null; } }逻辑说明:执行器本身不关心“下一个节点是谁”,只问“当前节点处理完了,从哪个端口出去”。每个节点用一个 switch 分支分发到不同处理方法,这些方法负责和 UI、动画、音效打交道。_stepCount 是保护性质的计数器,防止策划在画布上画出一个环导致死循环。
参数说明:LoadGraph 的输入是 TextAsset,因为编辑器里最常见的落地方式是直接把 JSON 存成 .asset 或 .txt 资源。如果你要支持运行时热改,要把加载入口改成从 persistentDataPath 读文件;plotAsset 只是编辑器开发期的便捷入口。500 这个阈值不是拍脑袋:正常互动电影单条通关路径一般不超过 100 个节点,500 足够宽松又能挡住环路。
3. 互动电影节点图:对话、分支、事件三类节点怎么设计和驱动
3.1 节点类型清单:数据节点与执行节点拆开的好处
互动电影常见的节点类型其实就五种,一张表说清楚:
| 节点类型 | 职责 | 运行时行为 | 典型配置 |
|---|---|---|---|
| entry | 剧情入口 | 读取后直接走出口 | 初始变量 |
| dialogue | 展示对话 | 显示文本,等玩家点击继续 | 说话人、文本、立绘 |
| branch | 玩家决策 | 弹出选项,选中后走对应出口 | 选项列表、条件、默认兜底 |
| event | 触发表现 | 调动画/镜头/音效,可选阻塞等待 | 事件名、参数、等待时长 |
| ending | 结局收束 | 记录结局、解锁成就、回标题 | 结局编号、是否计入收集 |
数据节点和执行节点要拆开:dialogue、event 这类本身没有图结构信息,不要往里面塞“下一个节点”字段,出口统一叫 out,由连线决定走向。branch 是唯一有多出口的节点,出口名用 opt_0、opt_1 这种带下标的端口名,对应选项下标。
这样的好处是编辑器的连线逻辑统一:所有节点都只有“端口”,没有“硬编码的下一跳”。策划想临时把一个 dialogue 的走向改到另一段,只需要拖一条新连线,不用打开配置面板改字段。这个交互细节对内容策划的日常效率影响非常大。
3.2 分支节点:选项文本、条件表达式与兜底出口
分支节点是互动电影的命根子。选项不仅要显示文本,还要在显示前决定哪些选项可用。比如“主角没有钥匙,这个门就不能打开”,这个判断发生在选项弹出之前。
[Serializable] public class BranchOptionConfig { public string text; // 选项在 UI 上显示的文字 public string condition; // 形如 "钥匙 == 1" 或 "信任度 >= 60" public string nextNode; // 选中后去往的节点 id(可留空,走连线) } [Serializable] public class BranchConfig { public List<BranchOptionConfig> options = new List<BranchOptionConfig>(); public string fallbackNode; // 所有条件都不满足时去的兜底节点 }分支的执行逻辑:
private void ShowBranch(NodeData node) { var cfg = JsonUtility.FromJson<BranchConfig>(node.configJson); // 先过滤出当前可显示的选项 var available = cfg.options .Where(o => string.IsNullOrEmpty(o.condition) || EvalCondition(o.condition)) .ToList(); DialogUI.Instance.ShowOptions(available, index => { // 优先走配置里写死的 nextNode,否则走端口 opt_{index} 的连线 var next = cfg.options[index].nextNode; if (string.IsNullOrEmpty(next)) next = GetNextNode(node.id, "opt_" + index); if (string.IsNullOrEmpty(next)) next = cfg.fallbackNode; EnterNode(next); }); }逻辑说明:先做条件过滤,再弹 UI,回调里按“配置 nextNode → 端口连线 → fallbackNode”的顺序找目标。这个优先级很重要:策划可以直接在选项里指定目的地,也可以靠画布连线,两种习惯都能兼容,不会因为换了一种编辑风格就改代码。
条件表达式的解析不需要引入重型库。整段剧情图里变量就那么几十个,我一般写一个极小的解析器,只支持变量 == 值、变量 >= 值、变量 != 值三种比较:
public bool EvalCondition(string condition) { // 只支持 "变量 操作符 值" 三段式,操作符限 ==、!=、>=、<= var parts = condition.Split(' '); if (parts.Length != 3) return false; var left = _storyState.GetVar(parts[0]); var op = parts[1]; var right = parts[2]; switch (op) { case "==": return left == right; case "!=": return left != right; case ">=": return float.Parse(left) >= float.Parse(right); case "<=": return float.Parse(left) <= float.Parse(right); default: return false; } }参数说明:condition 字符串里强制用空格分隔,解析简单但写起来不直观。如果你的策划团队量大,建议上表达式求值库或者用可视化的“变量比较”配置面板代替手写字符串。小团队用手写字符串很快,人一多就必然出现钥匙==1vs钥匙 == 1这种空格不一致的问题,排查时能把人逼疯。
3.3 事件节点:用事件总线让剧情驱动镜头和动画
事件节点是把节点图和表现层解耦的关键。剧情节点不该直接引用某个镜头脚本或 Timeline 资源,否则图一旦复用,引用关系就全乱了。我惯用的做法是事件总线:
public static class PlotEventBus { public static event System.Action<string, string> OnRaise; public static void Raise(string eventName, string param) { OnRaise?.Invoke(eventName, param); } } [Serializable] public class EventConfig { public string eventName; // 例如 "PlayTimeline:地下室开门" public string param; // 参数,例如镜头名称或动画状态名 public float waitSeconds; // 大于 0 表示阻塞等待,等完再走下一个节点 }执行端:
private IEnumerator FireEvent(NodeData node) { var cfg = JsonUtility.FromJson<EventConfig>(node.configJson); PlotEventBus.Raise(cfg.eventName, cfg.param); // 如果事件需要播完再继续,就阻塞等待 if (cfg.waitSeconds > 0f) yield return new WaitForSeconds(cfg.waitSeconds); var next = GetNextNode(node.id, "out"); if (!string.IsNullOrEmpty(next)) EnterNode(next); }逻辑说明:图只管发消息,具体怎么播交给订阅方。比如某个镜头脚本订阅了PlayTimeline:地下室开门,收到后播放对应 Timeline。节点图本身不持有任何场景对象的引用,换场景、换角色、换资源都不影响图的逻辑。
参数说明:waitSeconds 是一把双刃剑。设了它,事件节点会阻塞执行器,正好用来等一段镜头播完;但如果你依赖一个精确的动画时长,硬编码秒数非常脆。动画剪辑改了时长,这里就得跟着改。更稳的方案是事件订阅方在播完后主动调用 runner 的 Continue,但那样执行器要多暴露一个接口。我的建议是小项目先用 waitSeconds,等到出现对不齐的情况再换回调驱动。
4. 运行时编辑面板:GraphView 的边界与 UI Toolkit 方案
4.1 先查程序集:GraphView 能不能在玩家包里跑
这是很多人第一步就踩的坑。Unity 的 GraphView 类位于UnityEditor.Experimental.GraphView命名空间,它挂在编辑器程序集里,打出来的玩家包根本不包含这部分代码。你在编辑器里using UnityEditor.Experimental.GraphView写得再爽,出包那一刻编译直接失败。
所以“运行时节点编辑器”要先想清楚:编辑器是给策划在开发期用,还是给玩家在游戏里用?
常见做法是分两条路线。路线一:策划在开发期用 GraphView 搭的编辑器导 JSON,运行时只跑执行器。路线二:如果你真的需要在运行时开放节点编辑(比如玩家自制剧情关卡),就要用 UI Toolkit 的 VisualElement 自绘一套轻量节点面板,包含方块、连线、拖拽,工作量不小但可行。
我一般推荐先走路线一,把编辑器的编辑体验做到位,运行时保持轻量。互动电影的核心诉求是玩家看剧情、做选择,不是让玩家画图。除非你的产品定位就是玩家创作,否则别在运行时编辑器上投入过高的开发成本。
4.2 节点视图与图数据:双向绑定要守住单一数据源
不管用哪种方式画节点,最需要注意的是数据流向。我给运行时节点视图定的规矩是:图数据是唯一数据源,视图只负责展示和写回,不持有自己的副本。
using UnityEngine.UIElements; public class RuntimeNodeView : VisualElement { public NodeData Target { get; private set; } public RuntimeNodeView(NodeData data) { Target = data; var typeLabel = new Label(data.type); typeLabel.AddToClassList("node-type-label"); Add(typeLabel); var nameField = new TextField("标题") { value = data.displayName, isDelayed = true // 失焦才触发回调,避免每个按键都刷新画布 }; nameField.RegisterValueChangedCallback(evt => { Target.displayName = evt.newValue; // 单向写回数据,不重建视图 }); Add(nameField); } public void Refresh() { // 外部修改数据后调用,统一刷新所有子控件 } }逻辑说明:每次用户输入,只更新 Target 这张数据上的字段,不重建整个 VisualElement。如果每敲一个字符就重新生成节点视图,画布会疯狂闪烁、输入框还会丢失焦点,这是自绘节点编辑器最常见的体验灾难。
参数说明:isDelayed = true是 TextField 的一个关键参数,开启后值变更回调在失焦或回车时才触发。这样策划在节点上改名字,不会把每个键盘事件都扩散到整张图的重绘流程里。连线拖拽同理,建议松手落点后才写回 EdgeData,拖动过程中只在画布层画一条临时线。
4.3 运行中改图:存档快照与后悔药
运行时编辑器一旦允许改图,就必须考虑“改到一半退出”和“玩家在旧版本图上玩到一半存档”的情况。我在互动电影案例里做了三种存档:剧情快照、变量快照、图版本号,三个合在一起才是完整的一局存档。
[Serializable] public class PlotSnapshot { public string graphVersion; // 记录这张图是哪个版本导出的 public string currentNodeId; // 玩家当前停在哪个节点 public string stateJson; // 所有剧情变量序列化 public int nodeStepCount; // 已走步数,恢复后接着计数 } public void SaveSnapshot(int slot) { var snap = new PlotSnapshot { graphVersion = _graph.schemaVersion, currentNodeId = _currentNodeId, stateJson = JsonUtility.ToJson(_storyState), nodeStepCount = _stepCount }; var path = System.IO.Path.Combine(Application.persistentDataPath, $"plot_slot_{slot}.json"); System.IO.File.WriteAllText(path, JsonUtility.ToJson(snap), new System.Text.UTF8Encoding(false)); }读档逻辑反过来:先读 snapshot,比对 graphVersion 和当前图的 schemaVersion,不一致就看版本迁移脚本能不能把旧节点 id 映射到新图。映射不出来就回到最近的存档点,或者直接回 entry 重置。
我的习惯是在每个 branch 节点弹出选项前自动存一个快照,这是玩家的“后悔药”。互动电影玩家一定会想回头看看另一个选项是什么结局,快照系统把这种倒回去的体验的成本降到一个按钮。
5. 互动电影节点编辑器踩坑清单:五个高频翻车现场
5.1 节点 ID 用自增整数,老存档全线错乱
现象:策划在编辑器里删掉中间某个节点后重新导出,玩家读取旧存档,发现主角跳到了一段完全不相关的剧情,好感度变量也全对不上。
原因:自增整数 ID 在节点删除后会被重建,后插入的节点顶掉了旧编号。存档里记录的是“节点 3”,重新加载后节点 3 已经是另一个剧情节点了。
解决:节点创建时生成 GUID 字符串,存在 NodeData.id 里,删除后不再复用。这一步没有技巧,就是一开始别偷懒。已经在老项目里用了整数的,写一个迁移脚本,发布前把旧存档全部作废或强制重置到 entry。
5.2 事件节点阻塞式等待,执行器整段冻住
现象:走到某个事件节点后,角色动画播完,剧情就卡死了,点屏幕没有任何反应,日志里也没有报错。
原因:事件节点里用了while (animator.GetCurrentAnimatorStateInfo(0).normalizedTime < 1f)这类阻塞循环,把主线程卡死在协程外面,Unity 的 UI 事件根本来不及处理。
解决:任何“等待”都必须用协程的yield return或回调,不要用 while 自旋。事件节点统一走StartCoroutine(FireEvent(node)),内部用 WaitForSeconds 或 WaitUntil。如果依赖动画结束,用 AnimationEvent 或状态机的 OnStateExit 回调去调执行器的 Continue 接口,比轮询 normalizedTime 稳定得多。
5.3 编辑器热重载,运行时图状态被清空
现象:在编辑器模式下,暂停游戏、改节点参数、恢复播放,剧情从头开始,剧情变量全部归零。
原因:Unity 编辑器在修改脚本或资源后触发域重载,运行时挂在场景里的 MonoBehaviour 被销毁重建,内存里的图状态自然没了。这不是图的问题,是 MonoBehaviours 的固有行为。
解决:开发期把图数据放到 ScriptableObject 资源里而不是场景对象上,变量状态存成 [SerializeField] 字段,域名重载后至少图还在。真正常见的做法是,需要热调时走“读档恢复”而不是“重跑从头”:把玩家当前节点和变量存快照,重载后自动 LoadSnapshot。这也是上面做了存档系统的额外收益。
5.4 中文 JSON 编码的玄学问题
现象:剧情文本里有中文,编辑器里看 JSON 没问题,打进包里后某些文本变成“锟斤拷”或者读取出来缺字。
原因:String 本身没丢,丢在文件读写环节。File.WriteAllText 不指定编码时,在中文系统上默认落成 GBK;而 TextAsset 在 Unity 资源管线里按 UTF-8 导入,两边编码对不上,读出来就是乱码。
解决:所有写文件统一File.WriteAllText(path, json, new System.Text.UTF8Encoding(false)),读文件统一File.ReadAllText(path, System.Text.Encoding.UTF8)。不要在中间环节做 Encoding 转换,全程忽略系统默认编码。这样“玄学”就变成了确定性行为。
5.5 剧情图画成环,自动测试与逃生门
现象:玩家在某个选项选了特定分支后,剧情在两个 dialogue 节点之间反复横跳,直到玩家强退游戏。
原因:策划在画布上不小心把 A 连回 B,又让 B 连回 A,构成一个环。执行器本身不做环检测,就无限走下去。
解决:执行器里加步数上限(第 2.3 节代码里的 _stepCount > 500 就是干这个的),超限时报错并跳回 entry 或最近存档点。编辑端再加校验:保存时对图做一次有上限的 DFS,从 entry 出发统计能到达的节点集合,凡是单项不可达的节点和所有环,在画布上用红色高亮标出来,从源头堵住环。
6. 让图飞一会儿:自动化遍历验证与图的版本管理习惯
节点图改起来太方便,反而更容易坏。我给互动电影项目定了一条规矩:每次保存图,都必须跑一遍自动化遍历,确认所有可达路径都能走到至少一个 ending 节点。
写一个不依赖场景的测试执行器,遍历时只走图数据结构,不触发任何 UI 和动画:
using System.Collections.Generic; using UnityEngine; public static class PlotGraphValidator { public static List<string> FindUnreachableEndings(GraphData graph) { var reachable = new HashSet<string>(); var stack = new Stack<string>(); var entryId = graph.nodes.Find(n => n.type == "entry").id; stack.Push(entryId); int steps = 0; while (stack.Count > 0 && steps < 200) { steps++; var id = stack.Pop(); if (!reachable.Add(id)) continue; // 收集所有出口连线的目标节点 foreach (var edge in graph.edges) { if (edge.fromNode == id) stack.Push(edge.toNode); } } var endings = graph.nodes.FindAll(n => n.type == "ending"); return endings.FindAll(e => !reachable.Contains(e.id)) .ConvertAll(e => e.displayName); } }我把这个验证器接到了保存按钮上:如果检测到有 ending 不可达,不允许导出 JSON,并在编辑器里弹出不可达节点列表。这个门槛看着简单,实际上把大量“结局根本触发不了”的线上事故消灭在了开发期。
最后补一个血泪经验:图也是一种代码,要版本管理。我要求每次修改 schemaVersion +1,并随手写一句变更说明,存图和存代码走同一条提交流程。有一次我没做版本迁移,一个结局节点的 id 变了,玩家已经打出来的存档全部失效,那批投诉让我长记性长到今天。节点编辑器越是好用,越要用工程纪律管住它,希望帮到你。
本文还有配套的精品资源,点击获取