Files
ichni_Creator_Studio/Assets/Scripts/Editor Tools/NodeScript/Node重构大纲.md
2026-05-23 21:05:16 +08:00

17 KiB
Raw Blame History

NodeScript 重构大纲

一、重构目标

将节点系统从推送式push-based求值改为Manager 集中控制的拉取式pull-based生命周期循环。不改 UI 层,聚焦于 NodeManager 对节点的调度逻辑和节点自身的行为表现。


二、核心架构变更

2.1 节点生命周期状态

NodeBase 新增状态枚举:

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<T>.Notify() / Output<T>.SetValue() 推送链 不再需要推送机制
Input<T> 改为存储对上游 Output<T> 的引用,通过 Pull() 取值 拉取模式
NodeBase 新增 NodeStatus Status 属性 生命周期状态
NodeBase 新增抽象方法 Loop() 替代 Evaluate(),返回 (List<NodeBase> triggers, bool removeFromRuntime)
NodeBase 新增 int L 属性 到 Start 节点的最短距离
NodeBase 新增 List<NodeBase> GetPrecedingNodes() 获取所有前置依赖节点
保留 IInput / IOutput 接口 UI 层依赖不变
保留 Signal 结构体 仅用于触发操作
新增 InputAny 概念(见 3.4 动态类型支持

3.2 NodeManager.cs — 生命周期调度

变更项 说明
新增 HashSet<NodeBase> triggerTable 触发表
新增 HashSet<NodeBase> 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 类型列表

// 系统支持的全部可连线类型
static readonly HashSet<Type> SupportedTypes = new()
{
    typeof(float), typeof(int), typeof(bool), typeof(string),
    typeof(Vector2), typeof(Vector3), typeof(Color),
    typeof(GameElement), typeof(List<GameElement>),
    typeof(Signal),
};

3.4.3 InputAny / OutputAny 设计

/// <summary>未锁定的输入端口,连线后类型自动锁定</summary>
public class InputAny : IInput
{
    object _sourceOutput;       // 连到的 Output可能是 Output<T> 或 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;
    
    /// <summary>直接取已连接的上游值(泛型方式)</summary>
    public T GetValue<T>() { ... }
    
    /// <summary>取值为 object</summary>
    public object GetValue() { ... }
    
    /// <summary>连线时由 Manager 调用,锁定端口类型</summary>
    internal void LockType(Type t) { DataType = t; }
}

/// <summary>未锁定的输出端口,类型由同节点的 InputAny 传播决定</summary>
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<T>() { ... }
    public void SetValue<T>(T v) { _value = v; }
    
    /// <summary>由节点的某个 InputAny 锁定后传播过来</summary>
    internal void LockType(Type t) { DataType = t; }
}

3.4.4 类型传播规则

一个节点上存在多个 InputAny / OutputAny 时,类型按以下优先级传播:

规则 1外部优先: 任一 InputAny 被连线 → 锁定该端口类型 → 传播到同节点所有未锁定的 InputAny / OutputAny
规则 2冲突检测: 两个已连线的 InputAny 类型不一致 → Manager 阻止连线,报 "Type mismatch"
规则 3OutputAny: 总是跟随同节点的首个已锁定 InputAny 的类型
规则 4无输入节点: 常量类节点无 InputAnyOutputAny 类型由节点内置字段/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<object, object, object>> 查表分派。

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<T> 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 上):

/// <summary>当某个 InputAny 或 OutputAny 锁定了类型后,通知节点刷新其他端口</summary>
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。新增标记

public class InputAny : IInput
{
    public bool IsFixedType { get; init; }   // true = 不被传播覆盖,始终保持初始类型
}

NodeLerp.tnew InputAny { IsFixedType = true, Name = "t" }(已指定 float 意图时 LockType(float)


3.7 特殊节点:变量节点

class NodeVariable<T> : NodeBase
{
    Input<Signal> signal;   // 通常不连,仅在循环内使用
    Input<T> set;
    Output<T> get;
}
  • Loop() 立即返回当前值,不存在 Hang 状态
  • 有 Signal 输入时(循环体内),等待 Signal 触发才更新
  • 变量节点不参与 InputAny 类型传播——类型由泛型参数 T 固定

3.8 循环节点 & 子函数节点(新 UIRect 容器)

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 合并为 NodeConst3.5.1 四个节点合一,旧版全部删除
NodeSplitV2/V3 合并为 NodeSplit3.5.3 两个节点合一
NodeCombineV2/V3 合并为 NodeCombine3.5.4 两个节点合一
NodeForLoop 大改Rect 容器 + 子控制器 count 改用 InputAny
NodeForEach<T> 类似 ForLoop 改造
NodeBranch 迁移到 Loop()condition 改用 InputAny 支持 float/int 条件
NodeGameElement / NodeSetTransform / NodeClone 生命周期适配Signal 等待逻辑不变
NodeVariable<T> 特殊处理:立即 Complete不经过 Hang 保持泛型不变
NodeDebugLog / NodeLog 迁移InputAny 支持任意显示类型
NodeList<T> / NodeListAdd<T> / NodeListGet<T> 保持泛型Loop() 适配
NodePositionStepper 迁移,参数改用 InputAny
新增 NodeLerp 全新
新增 NodeCompare 全新
新增 NodeSelect 全新
新增 NodeSet 全新(需要反向写入能力)

四、实施步骤建议

  1. Phase 1: 核心类型层 — 修改 NodeCore.cs

    • 新增 NodeStatus 枚举
    • NodeBase 新增 Loop()LStatus
    • Input<T> / Output<T> 改为拉取模式
    • 实现 InputAnyOutputAnyOnTypePropagatedIsFixedType
  2. Phase 2: Manager 调度层 — 修改 NodeManager.cs

    • 实现触发表/运行时表
    • 实现 RunCycle() 循环调度
    • 实现 L 值计算 + 连线验证
    • 实现 TryConnect 中的 InputAny 类型锁定传播流程
  3. Phase 3: 统一节点实现 — 删除旧重复节点,实现新版

    • 先实现 NodeConstNodeMath(验证 InputAny 机制可用)
    • 再实现 NodeSplitNodeCombineNodeLerp
    • NodeCompareNodeSelectNodeSet(反向写入)
    • 迁移保留的节点(NodeStartNodeBranchNodeVariable<T> 等)
  4. Phase 4: 循环/子函数 — Rect 容器 + 子控制器

    • NodeUIBuilder 增加 Rect 容器支持
    • 循环节点子控制器实现
    • 子函数定义/执行节点 + Manager 注册扫描
  5. Phase 5: 测试 & 清理

    • 验证保存/加载兼容(含新旧类型映射)
    • 删除旧版备份文件 NodeBase.cs.bak
    • 删除旧节点类(NodeConstFloat 等)
    • 验证所有节点类型覆盖无遗漏

五、风险点 & 注意事项

  • UI 不变原则NodeObjectConnectorSlotNodeUIBuilder 的接口保持稳定,节点层改动不应破坏 UI 渲染ConnectorSlot 仅新增 RefreshAppearance()
  • InputAny 类型冲突:同一节点两个已连线的 InputAny 类型不一致时Manager 拒绝并报错
  • 反向写入NodeSet 的 targetRef 需要能修改上游 Variable 内部值,这是 InputAny 设计的关键难点
  • 向后兼容:旧 JSON 中的 NodeConstFloat 等类型名加载时需映射到新的 NodeConst
  • 循环嵌套:子控制器设计需考虑循环内嵌套循环的递归情况
  • Performance:大图时每帧遍历运行时表的开销,类型检查用 Type 引用比较(非字符串)
  • Signal 类型:保留但不参与 InputAny 的类型传播