简介这是一套基于Unity引擎的运行时节点编辑器示例工程以互动电影叙事为目标场景适合已有一定Unity开发基础、想研究节点化编辑与数据序列化方案的中高级开发者。包内共205个文件涵盖37个asset工程配置文件输入管理、渲染管线、全局设置等、16个C#核心逻辑脚本、13个shader着色器、8个prefab可视化预制体以及json、txt、cginc等辅助资源压缩包仅8.56MB体量轻巧便于快速拆解。目前已有231人学习、浏览作者还在描述中强调项目尚未完全完工运行交互部分依赖策划与美术的进一步敲定但核心的节点编辑、保存加载链路已经可以实际跑通由于作者思路经历多次调整源码中存在一定冗余和凌乱之处反而为读者提供了理解真实项目迭代与代码重构过程的参考。对于想借鉴互动电影编辑器交互逻辑、节点连线表现或数据持久化思路的开发者这份资源可作为一个抛砖引玉的起点。1. Unity运行时节点编辑器为什么互动电影需要可视化流程做互动电影最先卡住团队的往往不是美术和音频而是分支剧情的组织方式。这套Unity运行时节点编辑器是把“剧情图”直接画在编辑器里、运行时也能保存加载的一种落地思路。它把一段剧情切成节点用连线表示跳转关系策划不需要改代码也能调整流程。工程本身还没做完展示层仍需策划美术确认但节点编辑、保存加载链路是通的。适合想研究节点编排思路的中高级开发小白直接上手容易踩雷。我对这类工程一直保持一个态度先看数据模型再看UI最后才看渲染效果。节点编辑器最值钱的部分不在界面多漂亮而在节点怎么定义、连线怎么存、加载后怎么恢复。下面按这个顺序把工程拆开讲中间会给出可以直接抄走的C#结构、JSON格式和常见坑位。2. 节点图的数据模型端口决定后续能不能扩展2.1 把剧情拆成节点而不是把剧情写到if-else里互动电影的核心矛盾是分支多。一个普通剧情片段可能只有1个出口一个选择节点会有3到5个出口按数值变量跳转则要求端口带条件。若直接在脚本里按流程写死if (playerChoice a) { PlayClip(ending_a); } else if (playerChoice b) { PlayClip(ending_b); }流程短的时候还能跑一旦中间插入集换式支线、角色好感度、多次循环场景这个脚本就会变成一座无法维护的分支高塔。新增一个选项就要重新翻代码、找跳转位置策划也没法自己调整。用节点图之后剧情被切成一个个StoryNode对话框是一类节点过场是一类节点选项又是一类节点所有跳转关系用边来描述。这个工程的做法是给所有剧情节点一个统一基类把节点ID、位置、端口信息都放在基类里// 所有剧情节点都继承这个基类 [System.Serializable] public abstract class StoryNode { public string nodeId; // 保存/加载用的唯一ID public Rect editorRect; // 节点在编辑器画布上的位置 public abstract string TypeName { get; } // 输入端口名列表例如 in public Liststring inputKeys new Liststring(); // 输出端口名列表例如 out选择节点可能有 a b c public Liststring outputKeys new Liststring(); }这里有个选型关键点节点之间不要直接保存对方实例的C#引用而是保存端口ID。原因有二。第一运行时序列化的时候对象引用很容易断除非你把整张图做成一个ScriptableObject再做整体引用否则加载回来全成null。第二端口ID允许一条边同时表达“从哪个节点的哪个口出去到哪个节点的哪个口进来”语义上更接近剧情跳转本身。我在做类似系统时习惯把editorRect也放进序列化数据里。虽然它和运行时逻辑无关但保存画布布局能让策划下次打开时维持原样否则每次重新打开图都乱成一团反馈体验非常差。2.2 端口与连线边是剧情流图的定义体有了节点还不够节点之间必须靠连线才能形成图。连线在数据结构上是一个独立对象不挂在某个节点内部这样才能支持加注释、修改路由、批量操作。工程里的连线结构大概长这样[System.Serializable] public class StoryEdge { public string edgeId; public string fromNodeId; public string fromPort; public string toNodeId; public string toPort; // 可选的通过条件例如好感度 5 public EdgeCondition condition; }连线的fromPort和toPort必须和节点上的outputKeys、inputKeys对齐。检查连线合法性时先看端口名是否存在再看端口类型是否匹配。比如剧情节点输出口是next就不能直接连到选择节点的条件输入口上否则运行时遍历图时会因为端口类型对不上而直接跳过。图遍历是这类系统的运行时核心。常见做法是从入口节点开始取它的所有输出边找出满足condition的那条边顺着边走到下一个节点然后递归处理。这里要注意循环引用。互动电影里允许循环比如重新读档回到某一段但运行时必须设置一个最大步数上限否则死循环会拖死主线程。一般我会在Graph上维护一个访问深度public StoryNode Step(StoryNode currentNode, Dictionarystring, object runtimeVars) { if (stepCount maxSteps) { Debug.LogError(剧情图超过最大步数疑似死循环请检查连线); return null; } // 找到当前节点上所有输出边中第一个条件满足的 foreach (var edge in GetOutgoingEdges(currentNode)) { if (edge.condition null || edge.condition.Check(runtimeVars)) { stepCount; return GetNodeById(edge.toNodeId); } } Debug.LogWarning(当前节点没有任何出口满足条件); return null; }这个stepCount和maxSteps是保命用的。我在第一个项目里没加这东西策划在编辑器里把两条边连成环运行直接卡了十几秒之后强制重启。从那以后我所有剧情图系统都加步数上限。2.3 为什么不用状态机节点图比它好在哪很多第一次接触节点编辑器的开发者会问Unity的Animator不也是状态机吗为什么不用Animator来搭剧情这里要区分两件事。状态机适合表达“当前状态 事件驱动跳转”它是扁平的一层每个状态负责自己的表现而互动电影的剧情更像一棵树加若干条回边节点不仅要跳转还要挂载数据对话文本、镜头动画、角色状态增减。状态机里这些信息分散在状态组件和动画事件里策划改起来要来回切换窗口。节点图的好处是信息都聚在图上这个节点是什么类型连到哪几个地方触发条件写在哪条边上一目了然。缺点也很明显没有状态机那种强制单一活跃状态的保证谁都可以连出菱形甚至环状结构所以上面提到的循环检测和条件校验必不可少。3. 编辑器交互层拖拽、连线与工程资产配置3.1 运行时编辑器的UI载体IMGUI还是UI Toolkit节点编辑器这类工具在Unity里有两条主流路线。一条是编辑器专用扩展用GraphView做可视化编辑只在Unity编辑器里用另一条是运行时节点编辑器打包之后玩家或者策划在游戏内也能打开编辑界面。这个工程属于后者它的工程包里能看到Default Style Sheet和一套SDF字体资产这基本说明作者走的是UI Toolkit路线。运行时用UI Toolkit和用IMGUI写节点编辑器差别很大。IMGUI是每一帧即时绘制适合快速验证但布局、样式、触摸支持都很原始做复杂节点卡片会非常痛苦。UI Toolkit有样式表、标准控件和事件系统节点拖拽的代码会清爽很多。工程包里提到的Default Style Sheet.asset就是UI Toolkit的默认样式源SDF字体负责的是中文和Emoji的清晰渲染。节点编辑器界面至少需要两类基础设施一块无限大的画布以及一个可拖动的节点卡片。UI Toolkit里一般用VisualElement承载节点再用一个带滚动和缩放的容器承载画布。拖拽本质上是把鼠标的屏幕坐标转成画布局部坐标void OnPointerDown(PointerDownEvent evt) { Vector2 canvasPos canvasGroup.WorldToLocal(evt.mousePosition); currentAction HitTest(canvasPos); } void OnPointerMove(PointerMoveEvent evt) { if (currentAction DragAction.MoveNode) { Vector2 delta evt.mousePosition - lastMousePos; node.style.left node.layout.x delta.x / zoomScale; node.style.top node.layout.y delta.y / zoomScale; } }这里zoomScale特别容易踩坑。画布缩放之后鼠标在屏幕上移动同样的距离节点应该移动的距离是缩放前的像素数如果你忘了除缩放系数节点拖起来会比鼠标快或慢策划第一眼就会觉得“这东西不跟手”。我为了这个调试了差不多一下午最后才发现是忘了把UI Toolkit的scale考虑进去。3.2 连线绘制与命中优先级节点图的连线在视觉上一般是贝塞尔曲线起点和终点是端口中心两个控制点根据端口方向推算。使用UI Toolkit时可以用Painter绘制曲线也可以在节点下挂一张Image来缓存重绘。我的建议是节点数量少于两百个时直接每帧重绘曲线没有问题超过两百条连线必须考虑脏标记只在节点移动或者新增连线时重绘。连线还有个比绘制更棘手的事情命中检测。策划用鼠标去点一条线想选中它然后删除这看起来很简单但实现时要注意命中优先级。我常用的顺序是先检测连线再检测节点最后才检测空白。如果反过来节点面积大会把下面的连线全部挡住永远选不中线。检测连线的办法是用鼠标位置到二次贝塞尔曲线上做距离近似把曲线采样成十六个点逐一算距离小于某个像素阈值就判定命中float MinDistanceToCurve(Vector2 mousePos, ListVector2 samplePoints) { float minDist float.MaxValue; foreach (var pt in samplePoints) { float d Vector2.Distance(mousePos, pt); if (d minDist) minDist d; } return minDist; }采样点数十六个其实是一个精度和性能的折中。互动电影图一般不会超过几百个节点连线数量有限十六个点足够精确。如果图特别大曲线跨了整个屏幕采样点数可以提高到三十二但每帧检测次数也会翻倍必须配合脏标记做缓存。3.3 工程包内资产清单每个asset是干什么的拿到工程之后很多人会被ProjectSettings.asset、QualitySettings.asset这一堆配置文件吓到以为项目坏了。实际上这些是Unity自动生成的项目配置不属于逻辑代码。这里我按工程内出现的资产做了一个用途梳理资产名用途使用建议ProjectSettings.assetUnity项目全局设置记录公司名、产品名、版本号等不用手动改迁移工程时保留原样InputManager.asset输入系统配置老版Input Manager的轴映射如果用新版Input System可以忽略QualitySettings.asset画质分级配置URP下控制像素光、阴影、材质质量打包前按目标机型压一档URP-HighFidelity.asset高保真渲染管线资产控制HDR、抗锯齿、后处理互动电影偏叙事建议打开SMAAURP-HighFidelity-Renderer.assetURP渲染器数据配置屏幕空间效果检查是否开启了不必要的体积光Default Style Sheet.assetUI Toolkit的默认样式表修改后要重启编辑器才能完全刷新LiberationSans SDF.asset / 中文字体SDF / EmojiOneTextMeshPro字体资产一个是拉丁字体SDF一个中文字体SDF一个Emoji字体如果界面不显示中文多半是字体SDF缺少对应字重关于QualitySettings和URP这两项我多说一句。这个工程选了URP-HighFidelity说明作者目标平台并不是低端移动设备。互动电影如果走移动端高保真渲染管线的开销很容易让中端机发热严重建议实测帧率后再决定要不要降级到URP-Medium。4. 保存与加载节点结构落地成一个JSON4.1 序列化方案选型JsonUtility的边界节点编辑器最核心的闭环是画图、保存、加载、接着画。工程既然明确说“目前可以进行节点编辑和保存、加载”说明这条链路是通的。具体用什么方式保存工程里没有详细标注但Unity项目最常碰到的就是JsonUtility、Newtonsoft.Json、以及ScriptableObject存档三种。JsonUtility最大的问题是它序列化多态时只输出基类字段子类字段会丢。如果你用StoryNode基类接收StoryDialogueNode实例JsonUtility序列化之后DialogueText这类子类字段直接消失加载回来就是个空壳节点。所以要处理这类节点图常见做法是绕开JsonUtility用Newtonsoft.Json配合$type字段手动指定子类类型。如果你不想引入第三方库就得自己写一个序列化包装类把子类特有字段全部摊平放进一个字典加载时再按类型重建。我一般会写一个NodeSerializer来完成这件事[System.Serializable] public class SerializableNode { public string nodeType; // 如 dialogue public string nodeId; public float posX; public float posY; // 其他子类字段以键值对形式保存避免泛型序列化问题 public ListKeyValuePairstring, string customFields new(); }用customFields的好处是新增节点字段不需要改存档结构坏处是字段名一变就丢数据只能靠版本号兜底。工程里作者说“思路变了好几次”我想八成也包含这种字段结构的返工所以在没有迁移脚本的情况下老存档打不开是非常正常的。4.2 一张节点图的JSON文件结构独立于具体序列化库节点图落盘时的推荐结构其实很固定。下面是我基于工程描述整理出的一个可落地格式{ version: 1, entryNodeId: start_node, nodes: [ { nodeType: dialogue, nodeId: start_node, position: { x: 120, y: 80 }, dialogueText: 傍晚主角推开房门, speakerName: 旁白 }, { nodeType: choice, nodeId: choice_node_01, position: { x: 420, y: 80 }, options: [走过去质问, 假装没看见] } ], edges: [ { edgeId: edge_01, fromNode: start_node, fromPort: next, toNode: choice_node_01, toPort: in, condition: null } ] }这份JSON里最重要的字段是三样version、nodeId、condition。version用来做迁移NodeJackson每次改版后旧文件按版本号走转换逻辑nodeId必须稳定不能拿节点名字当ID因为策划很容易改两个同名节点condition放在边上而不是节点上是为了让一个节点可以按不同条件走向不同分支。加载流程就是先反序列化成SerializableNode数组和StoryEdge数组再按nodeType反射或switch创建对应的节点实例最后把边的ID映射到节点实例上。这里有一个隐藏问题反序列化时边引用的节点ID可能不存在这就是坏数据。我建议加载后立即做一次完整性校验遍历所有边检查fromNode和toNode是否都能在节点列表里找到。找不到的边记录到日志里然后删除而不是直接抛异常崩溃。4.3 节点里引用场景对象怎么落盘互动电影节点经常要指向场景里的某个相机、某个灯光、某个角色。这类引用不能直接存GameObject引用因为场景重新加载后引用必断。工程的几个SDF字体资产本身不涉及这个问题但节点里的显示对象一定绕不开。通用做法是保存对象在场景层级中的路径[System.Serializable] public class SceneObjectRef { public string scenePath; // 场景名例如 Stage1 public string hierarchyPath; // 根节点/相机组/主相机 public int token; // 运行时缓存用编辑器内记录InstanceID }加载后通过GameObject.Find(hierarchyPath)找回对象。这个方案看起来土但在运行时节点编辑器里是最稳的。不要存AssetDatabase之类的编辑器专属ID打包后那套API根本不存在。如果引用的是预制体实例记得先实例化预制体再绑定节点数据加载时序应该先建场景、再实例化对象、最后执行节点图入口。我自己经常在保存时把整个层级路径打印出来看一眼一旦发现hierarchyPath带着“(Clone)”字样说明存的是运行时实例下次加载时这个路径大概率对不上。正确做法是存预制体源资产路径或者在场景加载前先完成预制体实例化。5. 避坑指南半成品的边界与三类典型问题5.1 先认清哪些能用哪些没做完作者在自己的说明里强调项目尚未完成运行界面还需要策划和美术最终确定因此与“显示问题相关”的节点运行时还没有真正做完。这句话翻译成实际操作就是节点编辑、保存、加载这条链路可用但最终给玩家看到的那一版界面不是成型的。拿这个工程做研究、理思路完全没问题但想直接当商业项目底子工作量大得超出想象。我拿到手第一步做的事情是给分类打标签。能动的新建节点、拖拽、连线、保存、再加载。不能动的别指望它直接播放一段完整互动电影。调试的时候走编辑链路不要一上来就点Play然后期待出现完整剧情界面。5.2 加载后节点位置全部重叠节点图保存后重新打开所有节点挤在画布左上角看起来就跟没保存位置一样。这是运行时节点编辑器最常碰到的现象。原因通常是保存时只保存了节点数据没有保存画布滚动量和缩放值或者序列化时节点坐标被转换到了错误空间。UI Toolkit里position系数和屏幕缩放互相干扰保存的是局部坐标加载时却用了全局坐标就会全部对齐到初始原点。解决方法是保存时统一用画布局部坐标加载时先创建节点等画布尺寸确定后再设置坐标。加载后调用一次canvas.MarkDirtyRepaint()强制刷新。如果你发现位置存对了但显示不对检查editorRect的单位。IMGUI用的是像素UI Toolkit用的事逻辑像素两者在高DPI屏幕上差一个缩放比。保存数据时统一除以CanvasScaleFactor即可。5.3 保存后的JSON文件打不开或者字段丢失现象用JsonUtility正常序列化加载却提示节点为空列表或者子类字段全部为默认值。这个问题在工程源码“思路变了”的背景下尤其容易出现。原因是JsonUtility对多态继承的支持有限子类的字段不会被序列化进去。解决方式是每一条节点记录都带一个nodeType标记加载时先反序列化到中间容器再手动创建实际类型。另一种常见情况是旧存档里节点类型名和最新代码不一致。作者中途改过几次思路节点类型从StoryNode改成别的名字也正常所以存档版本号必须保留。建议做一层加载容错解析失败时把原始文本卡片保存到单独的dump文件里不要直接覆盖原存档。这样即使新版代码读不了旧文件也还能人工翻译数据。5.4 连线明明存在运行时却不跳转编辑界面里连线正确显示节点位置也没问题但运行后剧情卡在某个节点不动。这类问题多出现在条件端口上。排查顺序是固定的一套。第一看目标节点是否在Graph上被标记为入口很多互动电影图有不止一个入口运行只认指定入口的话其他节点永远不会被自动触发。第二看边上有没有condition字段默认空条件要视为无条件通过但部分实现在判断时把空condition当成“永远不通过”这就是一个奇怪的坑。第三检查端口类型选择节点的输出口可能是optionA、optionB这样的业务端口而运行时逻辑只在找next端口时遍历逻辑不匹配就会卡死。第四看目标节点是否在加载时因为ID丢失被过滤掉了。遇到这种情况我会在编辑器里加一个调试模式面板显示当前节点ID、可用输出边数量、条件计算结果。比起对着日志猜直接看面板更快。6. 把节点图跑起来一个最简单的运行验证6.1 用三节点做冒烟测试写一个最小验证图入口节点、一个选项节点、两个结局节点。入口无条件连接到选项选项按变量值分支到两个结局。跑之前先在编辑界面确认所有连线都通过校验然后按顺序检查三步入口是否存在、每条边的condition是否明确、结局节点是不是死路。我习惯给Graph加一个自检函数打开运行时先在编辑器里跑一遍public Liststring ValidateGraph() { Liststring errors new(); foreach (var edge in edges) { if (GetNodeById(edge.fromNodeId) null) errors.Add($边 {edge.edgeId} 的起点节点缺失); if (GetNodeById(edge.toNodeId) null) errors.Add($边 {edge.edgeId} 的终点节点缺失); } if (GetNodeById(entryNodeId) null) errors.Add(没有设置入口节点); return errors; }这个函数能过滤掉七八成低级错误。它不检查条件字段的合法性但已经足够作为运行前的基础校验。冒烟测试的目标不是验证完整玩法而是验证保存加载链路是通的、图遍历不崩溃。6.2 边界检查与后续扩展方向互动电影节点编辑器真正上难度的地方在于“运行界面还没有做完”的那部分。作者把编辑器和数据模型放到了一起但最终播放层要依赖策划美术敲定UI这意味着节点数据结构一旦定下来播放层改动会比较痛苦。后续扩展时我建议把节点类型继续拆细至少分成纯数据节点设置变量、触发音效、纯表现节点播放视频、显示对话、混合节点同时改变量和表现三类。纯数据节点可以脱离UI单独测试表现节点则等界面定了再具体实现。工程源码确实存在作者说的问题结构乱、思路反复、注释可能不匹配实现。但这种事在探索型项目里太常见了你用它做研究的时候不要试图把一个文件啃完再动手。先跑起来三节点冒烟测试再对照源码看它怎么处理保存和加载比从头读代码节省时间得多。我从那次强制重启卡死之后就立了一个规矩凡是带节点图的系统第一步先写最大值保护第二步保存前先ValidateGraph第三步加载后一定check一遍所有边引用。这套流程在后来几个项目里救了我很多次希望帮到你。本文还有配套的精品资源点击获取