# NodeScript 重构大纲 ## 一、重构目标 将节点系统从**推送式(push-based)求值**改为**Manager 集中控制的拉取式(pull-based)生命周期循环**。不改 UI 层,聚焦于 NodeManager 对节点的调度逻辑和节点自身的行为表现。 --- ## 二、核心架构变更 ### 2.1 节点生命周期状态 为 `NodeBase` 新增状态枚举: ```csharp enum NodeStatus { Ready, Hang, Complete } ``` - **Ready** — 节点等待被触发执行 - **Hang** — 节点因前置节点未完成而挂起,保留在运行时表中等待下一周期 - **Complete** — 节点已完成本轮运算 变量节点的特殊规则:不存在 Hang 状态,启动后立即返回值/引用并 Complete。 ### 2.2 触发表 + 运行时表(Manager 侧) Manager 维护两张表: | 表 | 作用 | |---|---| | **触发表** `triggerTable` | 收集本周期要加入运行的节点,周期开始前并入运行时表 | | **运行时表** `runtimeTable` | 当前周期正在遍历的节点集合 | 周期流程: 1. `triggerTable` → 并入 `runtimeTable`,清空 `triggerTable` 2. 遍历 `runtimeTable`,调用每个节点的 `Loop()` 方法 3. `Loop()` 返回:下一轮要触发的节点(加入 `triggerTable`)+ 是否从 `runtimeTable` 移除自己 ### 2.3 拉取式取值 节点不再通过 Output → Input 推送数据。改为: - 每个周期节点从前置节点的 Output 中**主动拉取**值或引用 - 如果前置节点不处于 `Complete`,本节点 `Status = Hang`,将前置节点加入 `triggerTable`,自己保留在 `runtimeTable` - 取值条件满足后,执行运算,返回 `Complete` ### 2.4 最短距离 L(单向逻辑保证) 每个节点计算到 Start 节点的最短距离 `L`,用于约束连线方向: - L=0 的节点的输出可以连接到 L=7 的节点的输入 - L=7 的节点的输出**不能**连接到 L=2 的节点的输入 - 确保逻辑单向流动 --- ## 三、文件级重构计划 ### 3.1 NodeCore.cs — 核心类型重定义 | 变更项 | 说明 | |---|---| | 移除 `Input.Notify()` / `Output.SetValue()` 推送链 | 不再需要推送机制 | | `Input` 改为存储对上游 `Output` 的引用,通过 `Pull()` 取值 | 拉取模式 | | `NodeBase` 新增 `NodeStatus Status` 属性 | 生命周期状态 | | `NodeBase` 新增抽象方法 `Loop()` | 替代 `Evaluate()`,返回 `(List triggers, bool removeFromRuntime)` | | `NodeBase` 新增 `int L` 属性 | 到 Start 节点的最短距离 | | `NodeBase` 新增 `List GetPrecedingNodes()` | 获取所有前置依赖节点 | | 保留 `IInput` / `IOutput` 接口 | UI 层依赖不变 | | 保留 `Signal` 结构体 | 仅用于触发操作 | | 新增 `InputAny` 概念(见 3.4) | 动态类型支持 | ### 3.2 NodeManager.cs — 生命周期调度 | 变更项 | 说明 | |---|---| | 新增 `HashSet triggerTable` | 触发表 | | 新增 `HashSet runtimeTable` | 运行时表 | | 新增 `void RunCycle()` | 单周期执行逻辑 | | 重写 `RunGraph()` | 初始化触发表为 Start/Entry 节点,循环调用 `RunCycle()` 直到运行时表为空 | | 新增 `void ComputeLValues()` | 在连线变更后重新计算所有节点的最短距离 L | | 新增 `bool ValidateConnection(NodeBase from, NodeBase to)` | 连线前验证 L 约束 | | 新增子控制器管理 | 用于循环节点和子函数节点的内部运行监控 | | 新增 `void RegisterSubFunction(NodeBase definition)` | 扫描注册子函数定义 | | `TryConnect()` 中增加 L 约束检查 | 阻止反向连线 | | `SaveToFile()` / `LoadFromFile()` 适配新状态 | 保存/加载兼容 | | UI 部分(拖线、复制粘贴等)保持不变 | — | ### 3.3 NodeObject.cs / ConnectorSlot.cs / NodeUIBuilder.cs — UI 层 **原则上不修改**,仅可能的微调: - `NodeObject.Init()` 中调用 `nodeBase.InitConnectors()` 后触发 L 值计算 - 循环/子函数节点的 Rect 容器支持(见 3.6) ### 3.4 动态类型 — InputAny / OutputAny 机制(多类型统一节点的基石) #### 3.4.1 问题 当前同一功能、不同类型的节点大量重复: | 功能 | 现有节点 | 覆盖类型 | |---|---|---| | 常量 | `NodeConstFloat`, `NodeConstVector2`, `NodeConstVector3`, `NodeConstColor` | float, Vector2, Vector3, Color | | 拆分 | `NodeSplitV2`, `NodeSplitV3` | Vector2, Vector3 | | 合并 | `NodeCombineV2`, `NodeCombineV3` | Vector2, Vector3 | | 数学 | `NodeMath`(仅 float) | float | 这些节点的**逻辑完全一致**,仅类型不同。引入 `InputAny` / `OutputAny` 后,一个节点覆盖所有类型。 #### 3.4.2 类型列表 ```csharp // 系统支持的全部可连线类型 static readonly HashSet SupportedTypes = new() { typeof(float), typeof(int), typeof(bool), typeof(string), typeof(Vector2), typeof(Vector3), typeof(Color), typeof(GameElement), typeof(List), typeof(Signal), }; ``` #### 3.4.3 InputAny / OutputAny 设计 ```csharp /// 未锁定的输入端口,连线后类型自动锁定 public class InputAny : IInput { object _sourceOutput; // 连到的 Output(可能是 Output 或 OutputAny) public string Name { get; set; } public Type DataType { get; private set; } // null 表示未锁定,连线后锁定 public bool IsConnected => _sourceOutput != null; public bool HasReceived { get; set; } public Color ConnectorColor => DataType != null ? NodeColors.Get(DataType) : Color.grey; /// 直接取已连接的上游值(泛型方式) public T GetValue() { ... } /// 取值为 object public object GetValue() { ... } /// 连线时由 Manager 调用,锁定端口类型 internal void LockType(Type t) { DataType = t; } } /// 未锁定的输出端口,类型由同节点的 InputAny 传播决定 public class OutputAny : IOutput { object _value; public string Name { get; set; } public Type DataType { get; private set; } // null 表示未锁定 public bool IsConnected => _targets.Count > 0; public Color ConnectorColor => DataType != null ? NodeColors.Get(DataType) : Color.grey; public T GetValue() { ... } public void SetValue(T v) { _value = v; } /// 由节点的某个 InputAny 锁定后传播过来 internal void LockType(Type t) { DataType = t; } } ``` #### 3.4.4 类型传播规则 一个节点上存在多个 `InputAny` / `OutputAny` 时,类型按以下优先级传播: ``` 规则 1(外部优先): 任一 InputAny 被连线 → 锁定该端口类型 → 传播到同节点所有未锁定的 InputAny / OutputAny 规则 2(冲突检测): 两个已连线的 InputAny 类型不一致 → Manager 阻止连线,报 "Type mismatch" 规则 3(OutputAny): 总是跟随同节点的首个已锁定 InputAny 的类型 规则 4(无输入节点): 常量类节点无 InputAny,OutputAny 类型由节点内置字段/UI 选择决定 ``` #### 3.4.5 ConnectorSlot UI 适配 - 未锁定的 `InputAny` / `OutputAny` 连接点显示**灰色** - 连线锁定后,动态更新连接点的颜色以匹配锁定类型 - `ConnectorSlot` 需要监听 `DataType` 变化并刷新颜色 - Manager 在 `TryConnect` 成功后调用 `Slot.RefreshAppearance()` --- ### 3.5 多类型统一节点设计 以下节点取代现有的同功能多类型节点。 #### 3.5.1 NodeConst — 通用常量(取代 NodeConstFloat/V2/V3/Color) ``` NodeConst: [UI] Dropdown: Type (float / int / bool / Vector2 / Vector3 / Color) [UI] 根据所选类型动态显示对应输入控件 OutputAny value ← 类型由 UI 选择锁定 ``` 逻辑:无输入,Loop 中直接 Complete。输出值被下游拉取。 #### 3.5.2 NodeMath — 通用数学运算(扩展覆盖类型) ``` NodeMath: [UI] Dropdown: Op (Add / Sub / Mul / Div) InputAny a ← 连线后锁定类型 InputAny b ← 跟随 a 的类型(或相反,谁先连跟谁) OutputAny result ← 类型来源同 InputAny 的锁定类型 ``` 支持的运算映射(LUT 注册): | 类型 | Add | Sub | Mul | Div | |---|---|---|---|---| | float, int | `+` | `-` | `*` | `/` | | Vector2, Vector3 | `+` | `-` | `* float` | `/ float` | | string | 拼接 | — | — | — | | Color | `+` (叠加) | `-` | `* float` | — | > 实现:用 `Dictionary<(Type, Op), Func>` 查表分派。 #### 3.5.3 NodeSplit — 通用拆分(取代 NodeSplitV2/V3) ``` NodeSplit: InputAny input ← Vector2 → 输出 X(float), Y(float) ← Vector3 → 输出 X(float), Y(float), Z(float) OutputAny x, y, z ← z 仅在 Vector3 时激活 ``` - 默认所有 OutputAny 端口可见但灰掉,`input` 锁定类型后按需激活 - 对于 Color:输出 R, G, B, A (float) #### 3.5.4 NodeCombine — 通用合并(取代 NodeCombineV2/V3) ``` NodeCombine: [UI] Dropdown: Type (Vector2 / Vector3 / Color) InputAny x, y, z, w ← 数量按类型动态显示 OutputAny output ← 类型跟随 UI 选择 ``` #### 3.5.5 NodeLerp — 线性插值(新节点) ``` NodeLerp: InputAny a, b ← 连线锁定类型 InputAny t ← 预期 float(不受 a/b 锁定影响,标记为 fixed-type) OutputAny result ← 跟随 a/b 类型 ``` 支持 float, int, Vector2, Vector3, Color。 #### 3.5.6 NodeCompare — 比较运算(新节点) ``` NodeCompare: [UI] Dropdown: Op (==, !=, >, <, >=, <=) InputAny a, b ← 连线锁定类型(支持 float, int) OutputAny result ← 固定 bool ``` > 注意:> \ < 仅在数值类型有效,== / != 可扩展至 string。 #### 3.5.7 NodeSelect — 二选一(新节点) ``` NodeSelect: InputAny condition ← 如果未连线,用 [UI] Toggle(bool);如果连线则类型锁定 bool InputAny trueValue, falseValue ← 类型互相跟随 OutputAny result ← 跟随 trueValue/falseValue 类型 ``` #### 3.5.8 NodeSet — 万能赋值(新节点,引用语义) ``` NodeSet: InputAny targetRef ← 连接到变量节点的 get 输出(锁定为目标类型) InputAny value ← 跟随 targetRef 类型 // 无 OutputAny,纯副作用节点 ``` > 关键:targetRef 不仅是取值,还要修改其引用的 Variable 内部值。需要 InputAny 能够"反向写入"。 #### 3.5.9 节点对比总结 | 统一节点 | 取代旧节点 | 覆盖类型数 | |---|---|---| | `NodeConst` | `NodeConstFloat`, `NodeConstVector2`, `NodeConstVector3`, `NodeConstColor` | 6+ | | `NodeMath` | `NodeMath`(扩展) | 5 (float, int, Vector2, Vector3, Color) | | `NodeSplit` | `NodeSplitV2`, `NodeSplitV3` | 3 (Vector2, Vector3, Color) | | `NodeCombine` | `NodeCombineV2`, `NodeCombineV3` | 3 (Vector2, Vector3, Color) | | `NodeLerp` | (新) | 5 | | `NodeCompare` | (新) | 3 | | `NodeSelect` | (新) | 任意 | | `NodeSet` | (新) | 任意 | --- ### 3.6 InputAny 类型锁定流程(Manager 侧) ``` TryConnect(OutputAny/Output src, InputAny dst): 1. 获取 src 的实际 DataType → T 2. 如果 dst.DataType == null → dst.LockType(T) → 传播到同节点其他端口 3. 如果 dst.DataType == T → OK 4. 如果 dst.DataType != T → 拒绝,类型不匹配 5. 调用 dst.ownerNode.OnTypePropagated() 通知节点刷新 UI ``` 传播方法(在 `NodeBase` 上): ```csharp /// 当某个 InputAny 或 OutputAny 锁定了类型后,通知节点刷新其他端口 public virtual void OnTypePropagated(ConnectorSlot lockedSlot, Type lockedType) { // 默认:遍历所有未锁定端口,LockType(lockedType) foreach (var slot in GetAnySlots()) if (slot.DataType == null) slot.LockType(lockedType); } ``` #### 3.6.1 fixed-type 端口标记 某些 InputAny 不接受类型传播(如 `NodeLerp.t` 必须是 float)。新增标记: ```csharp public class InputAny : IInput { public bool IsFixedType { get; init; } // true = 不被传播覆盖,始终保持初始类型 } ``` `NodeLerp.t` → `new InputAny { IsFixedType = true, Name = "t" }`(已指定 float 意图时 LockType(float)) --- ### 3.7 特殊节点:变量节点 ```csharp class NodeVariable : NodeBase { Input signal; // 通常不连,仅在循环内使用 Input set; Output get; } ``` - `Loop()` 立即返回当前值,不存在 Hang 状态 - 有 Signal 输入时(循环体内),等待 Signal 触发才更新 - 变量节点不参与 InputAny 类型传播——类型由泛型参数 T 固定 ### 3.8 循环节点 & 子函数节点(新 UI:Rect 容器) **UI 变更(NodeUIBuilder 扩展):** - 新增一个可手动拖拽缩放大小的 Rect 区域 - 将其他节点拖入此 Rect 即表示"在节点内部" - 循环节点的 Index 和 Signal 输出点移到第一列(输入列),可拉线到 Rect 内部 **节点结构:** ``` NodeForLoop: 外部输入: exec(Signal), count(InputAny) ← count 支持 int/float 外部输出: completed(Signal) 内部 Rect 中: 子节点...(由主 Manager 调度,子控制器监控) Index 输出(int) — 在第一列,拉入 Rect 内 LoopBody 输出(Signal) — 在第一列,拉入 Rect 内 ``` **子控制器职责:** - 检查循环体内所有节点是否 Complete - 重置内部节点,开启下一步循环 - 向主 Manager 报告循环是否全部完成 **子函数定义节点:** ``` NodeSubFunctionDef: 输入: name(string) Rect 外→Rect 内的变量节点 → 代表函数输入参数(变量类型即参数类型) Rect 内的 set 节点 → Rect 外 → 代表函数输出 ``` **子函数执行节点(配合 InputAny):** ``` NodeSubFunctionCall: — 根据已注册的子函数定义,动态生成 InputAny/OutputAny 端口 — 端口类型由子函数定义中变量节点的泛型参数决定 — Manager 扫描所有文件注册子函数定义 ``` ### 3.9 现有节点迁移计划 | 旧节点 | 处理方式 | 说明 | |---|---|---| | `NodeStart` | 迁移:`Evaluate()` → `Loop()` | 启动后 Complete | | `NodeEntry` | 同上 | — | | `NodeMath` | **扩展为多类型版本**(3.5.2) | 旧版删除 | | `NodeConstFloat/V2/V3/Color` | **合并为 `NodeConst`**(3.5.1) | 四个节点合一,旧版全部删除 | | `NodeSplitV2/V3` | **合并为 `NodeSplit`**(3.5.3) | 两个节点合一 | | `NodeCombineV2/V3` | **合并为 `NodeCombine`**(3.5.4) | 两个节点合一 | | `NodeForLoop` | 大改:Rect 容器 + 子控制器 | count 改用 InputAny | | `NodeForEach` | 类似 ForLoop 改造 | — | | `NodeBranch` | 迁移到 Loop(),condition 改用 InputAny | 支持 float/int 条件 | | `NodeGameElement` / `NodeSetTransform` / `NodeClone` 等 | 生命周期适配,Signal 等待逻辑不变 | — | | `NodeVariable` | 特殊处理:立即 Complete,不经过 Hang | 保持泛型不变 | | `NodeDebugLog` / `NodeLog` | 迁移,InputAny 支持任意显示类型 | — | | `NodeList` / `NodeListAdd` / `NodeListGet` | 保持泛型,Loop() 适配 | — | | `NodePositionStepper` | 迁移,参数改用 InputAny | — | | **新增** `NodeLerp` | 全新 | — | | **新增** `NodeCompare` | 全新 | — | | **新增** `NodeSelect` | 全新 | — | | **新增** `NodeSet` | 全新(需要反向写入能力) | — | --- ## 四、实施步骤建议 1. **Phase 1: 核心类型层** — 修改 `NodeCore.cs` - 新增 `NodeStatus` 枚举 - `NodeBase` 新增 `Loop()`、`L`、`Status` - `Input` / `Output` 改为拉取模式 - 实现 `InputAny`、`OutputAny`、`OnTypePropagated`、`IsFixedType` 2. **Phase 2: Manager 调度层** — 修改 `NodeManager.cs` - 实现触发表/运行时表 - 实现 `RunCycle()` 循环调度 - 实现 L 值计算 + 连线验证 - 实现 `TryConnect` 中的 InputAny 类型锁定传播流程 3. **Phase 3: 统一节点实现** — 删除旧重复节点,实现新版 - 先实现 `NodeConst`、`NodeMath`(验证 InputAny 机制可用) - 再实现 `NodeSplit`、`NodeCombine`、`NodeLerp` - `NodeCompare`、`NodeSelect`、`NodeSet`(反向写入) - 迁移保留的节点(`NodeStart`、`NodeBranch`、`NodeVariable` 等) 4. **Phase 4: 循环/子函数** — Rect 容器 + 子控制器 - `NodeUIBuilder` 增加 Rect 容器支持 - 循环节点子控制器实现 - 子函数定义/执行节点 + Manager 注册扫描 5. **Phase 5: 测试 & 清理** - 验证保存/加载兼容(含新旧类型映射) - 删除旧版备份文件 `NodeBase.cs.bak` - 删除旧节点类(`NodeConstFloat` 等) - 验证所有节点类型覆盖无遗漏 --- ## 五、风险点 & 注意事项 - **UI 不变原则**:`NodeObject`、`ConnectorSlot`、`NodeUIBuilder` 的接口保持稳定,节点层改动不应破坏 UI 渲染(ConnectorSlot 仅新增 `RefreshAppearance()`) - **InputAny 类型冲突**:同一节点两个已连线的 InputAny 类型不一致时,Manager 拒绝并报错 - **反向写入**:`NodeSet` 的 targetRef 需要能修改上游 Variable 内部值,这是 InputAny 设计的关键难点 - **向后兼容**:旧 JSON 中的 `NodeConstFloat` 等类型名加载时需映射到新的 `NodeConst` - **循环嵌套**:子控制器设计需考虑循环内嵌套循环的递归情况 - **Performance**:大图时每帧遍历运行时表的开销,类型检查用 `Type` 引用比较(非字符串) - **Signal 类型**:保留但不参与 InputAny 的类型传播