Godot可视化编程实战:基于GraphEdit构建节点式整数加法计算器 1. 项目概述与核心思路最近在捣鼓Godot引擎想做一个可视化编程或者节点式逻辑编辑的小工具。很多朋友可能用过UE的蓝图或者Unity的Bolt但在Godot里官方提供了一个非常强大的原生组件GraphEdit和GraphNode。直接用它们来搭一个简单的整数加法计算器听起来好像有点“杀鸡用牛刀”但这恰恰是理解这套系统底层逻辑和灵活性的绝佳切入点。这个项目不是为了做一个计算器而是通过“整数加法”这个最简单的功能彻底搞明白如何在Godot里创建、连接、管理自定义的节点图并让它们真正“跑”起来。无论你是想给自己的游戏做技能树编辑器、对话树系统还是更复杂的可视化脚本工具这个基础都至关重要。简单来说我们要做的是在GraphEdit画布上放置两种自定义的GraphNode。一种叫“整数常量节点”用来输入数字另一种叫“加法运算节点”用来执行计算。然后用连接线把“常量节点”的输出端口连接到“加法节点”的输入端口。最后当连接关系建立后加法节点能实时计算出结果并显示出来。整个过程我们会深入到信号处理、数据流、节点自定义UI和动态端口管理这些核心环节。下面我就把从零搭建这个系统的每一步拆开揉碎了讲给你听。2. 环境准备与项目初始化2.1 创建Godot项目与主场景启动Godot 4.x建议使用稳定版本如4.2.1创建一个新项目渲染器根据自己喜好选择Forward或Mobile均可对这个demo没影响。项目创建好后我们首先需要建立一个主场景。创建主场景在场景面板中点击“其他节点”按钮搜索并添加一个Control节点作为根节点重命名为Main。选择Control节点是因为GraphEdit本身是Control的子类便于我们进行UI布局。添加GraphEdit在Main节点下添加一个GraphEdit节点。将其锚点设置为“全范围”这样它会铺满整个窗口。暂时不需要修改其他属性。保存场景将场景保存为main.tscn。现在你的场景树应该看起来像这样Main (Control) └── GraphEdit注意GraphEdit是画布容器负责管理节点的拖拽、框选、缩放和连接线的绘制。它本身不包含业务逻辑逻辑都在我们自定义的GraphNode里。2.2 理解GraphEdit的基础配置虽然我们现在不需要复杂配置但了解几个关键属性对后续调试有帮助Zoom画布的缩放级别。你可以通过鼠标滚轮调整也可以在代码中控制。Show Grid和Snapping是否显示网格和启用吸附。对于需要对齐的编辑器开启它们会很方便。Right Disconnects默认为false。如果设置为true那么通过右键点击连接线可以断开连接这是一个很实用的功能我们稍后会用到。我们的核心工作不是配置GraphEdit而是创建两种能被它识别和管理的自定义节点。3. 自定义GraphNode整数常量节点GraphNode是可任意定制内容的容器。我们的第一个节点是“整数常量节点”它需要有一个显示数字的标签一个可以调整数字的微调器SpinBox以及一个输出端口。3.1 创建场景与基础结构在场景面板中新建一个场景选择“其他节点”搜索并添加GraphNode作为根节点重命名为IntegerConstantNode。选中IntegerConstantNode在检查器面板中将Title属性修改为“整数常量”。这个标题会显示在节点的顶部栏。为了有好的视觉效果我们可以设置一下节点的最小尺寸。在检查器里找到Custom Minimum Size将其设置为Vector2(150, 80)。这样节点看起来不会太局促。3.2 设计节点内部UI现在我们需要在GraphNode内部添加控件。在IntegerConstantNode下添加一个VBoxContainer垂直盒子容器重命名为Contents。它将帮助我们垂直排列控件。在Contents下添加一个Label节点重命名为ValueLabel。我们用它来显示“值”的文本。在Contents下添加一个SpinBox节点重命名为ValueSpinBox。SpinBox是Godot中用于输入整数的完美控件。配置SpinBox选中ValueSpinBox在检查器中将Min Value设为-99999Max Value设为99999Step设为1。这定义了输入范围。将Value设为0作为默认值。勾选Editable确保用户可以输入。你可以调整Custom Minimum Size的x分量让输入框宽一些比如Vector2(120, 20)。你的节点场景树现在应该是IntegerConstantNode (GraphNode) └── Contents (VBoxContainer) ├── ValueLabel (Label) └── ValueSpinBox (SpinBox)3.3 添加输出端口并编写脚本GraphNode的连接端口是其灵魂。端口有方向输入/输出和类型。添加输出端口选中根节点IntegerConstantNode在检查器面板中找到Slot属性。点击“添加元素”。将Slot的第一个元素索引0的Enable Left Port设为false我们不需要左侧输入。将Enable Right Port设为true我们需要右侧输出。Type Left和Type Right是端口的类型标识用于连接兼容性检查。我们可以自定义类型。这里将Type Right设为1代表“整数类型”。你可以用任何正整数但同一类型的端口才能相连。附加脚本选中IntegerConstantNode点击检查器上方的“附加脚本”按钮创建一个新脚本命名为integer_constant_node.gd。编写脚本逻辑这个脚本需要做几件事获取UI控件的引用。当SpinBox的值改变时更新节点内部存储的值并可能发出一个信号通知其他节点“我的值变了”。提供一个方法让其他节点如加法节点能来“获取”当前的值。extends GraphNode # 导出变量方便在编辑器中链接节点 export var spin_box: SpinBox export var value_label: Label # 节点当前持有的整数值 var current_value: int 0 func _ready(): # 初始化标签显示 value_label.text 值%d % current_value # 连接SpinBox值改变信号 if spin_box: spin_box.value_changed.connect(_on_spin_box_value_changed) # 当SpinBox值改变时的处理函数 func _on_spin_box_value_changed(new_value: float): # SpinBox的value是float但我们存为int current_value int(new_value) value_label.text 值%d % current_value # 发出一个自定义信号通知外部值已更新可选用于实时计算 # 这里我们先注释等加法节点时再讨论 # value_updated.emit(current_value) # 一个公共方法供其他节点获取本节点的值 func get_output_value() - int: return current_value脚本与场景绑定保存脚本后回到场景视图。选中IntegerConstantNode你应该能在检查器中看到Spin Box和Value Label两个导出属性。将场景中的ValueSpinBox节点拖到Spin Box属性上将ValueLabel节点拖到Value Label属性上。完成绑定。至此一个能输出整数的常量节点就做好了。保存这个场景为integer_constant_node.tscn。4. 自定义GraphNode加法运算节点加法节点更复杂一些。它需要两个输入端口一个输出端口。它要监听输入端口连接的变化并从连接的常量节点获取值进行计算。4.1 创建场景与UI新建场景根节点为GraphNode重命名为AddNode标题设为“加法”。设置Custom Minimum Size为Vector2(180, 120)。添加一个VBoxContainer重命名为Contents。在Contents下添加一个HBoxContainer水平盒子重命名为InputA。在InputA下添加一个Label文本设为“A:”。在InputA下添加一个LineEdit重命名为InputValueA将其Editable设为false。这个LineEdit不是用来输入的而是用来显示从上游节点获取的值。复制InputA这个HBoxContainer包括其子项粘贴到Contents下重命名为InputB。将其中的Label文本改为“B:”LineEdit重命名为InputValueB。在Contents下添加一个Label节点重命名为ResultLabel文本设为“结果--”。场景树如下AddNode (GraphNode) └── Contents (VBoxContainer) ├── InputA (HBoxContainer) │ ├── Label (文本“A:”) │ └── InputValueA (LineEdit) ├── InputB (HBoxContainer) │ ├── Label (文本“B:”) │ └── InputValueB (LineEdit) └── ResultLabel (Label)4.2 配置输入输出端口加法节点有两个输入一个输出。选中AddNode在检查器Slot属性中添加三个元素点击三次“添加元素”。配置第一个槽位索引0Enable Left Port:trueType Left:1(与常量节点的输出类型匹配)Enable Right Port:false这个槽位对应输入A。配置第二个槽位索引1Enable Left Port:trueType Left:1Enable Right Port:false这个槽位对应输入B。配置第三个槽位索引2Enable Left Port:falseEnable Right Port:trueType Right:1(输出也是整数类型)这个槽位对应输出结果。重要GraphNode的Slot数组索引决定了端口的从上到下的顺序。索引0是顶部的端口索引1是第二个以此类推。这个顺序必须和你UI布局A在上B在下以及后续代码中的逻辑对应起来。4.3 编写加法节点脚本这是整个项目的核心逻辑所在。脚本需要处理端口的连接、断开事件并从中获取数据。为AddNode附加新脚本命名为add_node.gd。首先定义我们需要用到的信号和变量。extends GraphNode # 导出UI控件引用 export var input_value_a: LineEdit export var input_value_b: LineEdit export var result_label: Label # 存储两个输入值 var input_a: int 0 var input_b: int 0 # 存储连接到输入端口的“源节点”引用 # 键是端口索引0对应A1对应B值是对应的源节点 var connected_nodes: Dictionary {} func _ready(): # 初始化显示 update_display()重写关键虚函数GraphNode有几个重要的虚函数用于响应GraphEdit的连接事件。# 当有连接线连接到本节点时触发 func _on_connection_from_empty(to_port: int, release_position: Vector2): # 这个函数通常用于“从空处开始拖拽连接线”的场景我们这里用不到但可以留空或打印日志 print(Connection started from empty to port: , to_port) # 当有连接线连接到本节点的某个端口时触发连接建立 func _on_connection_request(from_node: StringName, from_port: int, to_port: int): # 这个函数非常重要当其他节点试图连接到我时会触发。 # from_node: 发起连接的源节点名称 # from_port: 源节点的输出端口索引 # to_port: 目标端口即我的输入端口索引 print(Connection request to me (, name, ) at port , to_port, from , from_node, :, from_port) # 我们需要告诉GraphEdit允许这个连接。 # 通过get_parent()获取GraphEdit然后调用其connect_node函数。 var graph_edit get_parent() if graph_edit and graph_edit is GraphEdit: # 第四个参数是“允许连接”的标志设为true graph_edit.connect_node(from_node, from_port, name, to_port) # 当连接线准备断开时触发断开前 func _on_disconnection_request(from_node: StringName, from_port: int, to_port: int): # 当其他节点试图断开与我的连接时触发。 print(Disconnection request from me (, name, ) at port , to_port, from , from_node, :, from_port) var graph_edit get_parent() if graph_edit and graph_edit is GraphEdit: # 告诉GraphEdit断开连接 graph_edit.disconnect_node(from_node, from_port, name, to_port)处理连接建立与断开后的逻辑仅仅允许物理连接还不够我们需要在连接真正建立或断开后更新我们的数据。这通常通过GraphEdit的connection相关信号来完成但更清晰的做法是在节点内自己监听。我们可以利用_enter_tree和_exit_tree的变体或者使用一个自定义的更新函数在连接变化后被调用。这里采用一个更直接的方法在_process或使用定时器去检查连接状态。但更优雅的是由主控GraphEdit在连接变化时通知所有节点更新。为了简化我们修改思路当连接建立后加法节点主动去“拉取”一次数据并提供一个公共方法让上游节点在值改变时“推送”数据过来或者让外部如一个“计算”按钮触发所有节点重新计算。我们先实现“拉取”逻辑。修改add_node.gd# 新增一个函数用于从指定端口的连接中获取值 func update_input_from_port(port_index: int): var graph_edit get_parent() if not graph_edit or not graph_edit is GraphEdit: return # 获取连接到本节点指定输入端口的连接信息 var connections graph_edit.get_connection_list() for conn in connections: if conn[to] name and conn[to_port] port_index: # 找到了连接到我的端口的线 var from_node_name: StringName conn[from] var from_node_port: int conn[from_port] var from_node graph_edit.get_node_or_null(NodePath(from_node_name)) if from_node and from_node.has_method(get_output_value): # 假设源节点有get_output_value方法 var value from_node.get_output_value() if port_index 0: input_a value if input_value_a: input_value_a.text str(value) elif port_index 1: input_b value if input_value_b: input_value_b.text str(value) # 获取到值后重新计算 calculate_and_update_output() break # 每个输入端口只应有一个连接 # 计算并更新输出显示 func calculate_and_update_output(): var result input_a input_b if result_label: result_label.text 结果%d % result # 这里可以添加逻辑将结果提供给下游节点如果下游节点有类似update_input的方法 # 例如emit_signal(output_updated, result) # 更新所有输入的显示和计算 func update_all_inputs(): update_input_from_port(0) # 更新端口A update_input_from_port(1) # 更新端口B func update_display(): if input_value_a: input_value_a.text str(input_a) if input_value_b: input_value_b.text str(input_b) calculate_and_update_output()修改连接请求函数在连接建立后立即触发一次数据更新。func _on_connection_request(from_node: StringName, from_port: int, to_port: int): var graph_edit get_parent() if graph_edit and graph_edit is GraphEdit: graph_edit.connect_node(from_node, from_port, name, to_port) # 连接成功后更新该端口对应的输入值 update_input_from_port(to_port)处理断开连接当连接断开时需要清空对应的输入值和显示。func _on_disconnection_request(from_node: StringName, from_port: int, to_port: int): var graph_edit get_parent() if graph_edit and graph_edit is GraphEdit: graph_edit.disconnect_node(from_node, from_port, name, to_port) # 断开后清空该端口的输入 if to_port 0: input_a 0 if input_value_a: input_value_a.text elif to_port 1: input_b 0 if input_value_b: input_value_b.text calculate_and_update_output()绑定UI控件保存脚本回到AddNode场景将InputValueA、InputValueB和ResultLabel节点分别拖到脚本对应的导出属性上。保存这个场景为add_node.tscn。5. 整合到主场景并实现动态交互现在我们有两种节点了需要把它们放到GraphEdit中并让整个系统联动起来。5.1 修改主场景脚本打开之前创建的main.tscn选中根节点Main为其附加一个新脚本命名为main.gd。extends Control # 导出对GraphEdit的引用并预加载我们的自定义节点场景 export var graph_edit: GraphEdit onready var constant_node_scene preload(res://integer_constant_node.tscn) onready var add_node_scene preload(res://add_node.tscn) func _ready(): if not graph_edit: graph_edit $GraphEdit # 可选设置一些GraphEdit的属性 graph_edit.right_disconnects true # 允许右键断开连接 # 连接GraphEdit的信号用于响应连接变化这是实现全局联动的关键 graph_edit.connection_request.connect(_on_graph_edit_connection_request) graph_edit.disconnection_request.connect(_on_graph_edit_disconnection_request) graph_edit.connection_to_empty.connect(_on_graph_edit_connection_to_empty) graph_edit.connection_from_empty.connect(_on_graph_edit_connection_from_empty) # 初始时创建几个示例节点 spawn_initial_nodes() func spawn_initial_nodes(): # 创建两个常量节点 var const_node1 constant_node_scene.instantiate() const_node1.name Const1 const_node1.position_offset Vector2(100, 100) graph_edit.add_child(const_node1) var const_node2 constant_node_scene.instantiate() const_node2.name Const2 const_node2.position_offset Vector2(100, 250) graph_edit.add_child(const_node2) # 创建一个加法节点 var add_node1 add_node_scene.instantiate() add_node1.name Add1 add_node1.position_offset Vector2(400, 180) graph_edit.add_child(add_node1) # 处理连接请求从某个节点到另一个节点 func _on_graph_edit_connection_request(from_node: StringName, from_port: int, to_node: StringName, to_port: int): # 这里可以进行连接有效性检查例如类型匹配、防止循环连接等 print(Connection request from %s:%d to %s:%d % [from_node, from_port, to_node, to_port]) # 检查类型是否匹配简单示例 var from_node_obj graph_edit.get_node_or_null(NodePath(from_node)) var to_node_obj graph_edit.get_node_or_null(NodePath(to_node)) # 这里可以添加更复杂的类型检查逻辑... # 如果检查通过建立连接 graph_edit.connect_node(from_node, from_port, to_node, to_port) # 连接建立后强制目标节点更新一次输入 if to_node_obj and to_node_obj.has_method(update_input_from_port): to_node_obj.update_input_from_port(to_port) # 处理断开请求 func _on_graph_edit_disconnection_request(from_node: StringName, from_port: int, to_node: StringName, to_port: int): print(Disconnection request from %s:%d to %s:%d % [from_node, from_port, to_node, to_port]) graph_edit.disconnect_node(from_node, from_port, to_node, to_port) # 断开后也通知目标节点更新 var to_node_obj graph_edit.get_node_or_null(NodePath(to_node)) if to_node_obj and to_node_obj.has_method(update_input_from_port): # 清空显示的逻辑已经在AddNode的_disconnection_request里了这里可以不用重复 # 或者我们调用一个通用的“刷新”方法 pass # 以下两个函数可以用于实现“从空白处拖拽创建新连接”的高级功能本例暂不实现 func _on_graph_edit_connection_to_empty(to_node: StringName, to_port: int, release_position: Vector2): print(Connection to empty from %s:%d % [to_node, to_port]) func _on_graph_edit_connection_from_empty(from_node: StringName, from_port: int, release_position: Vector2): print(Connection from empty from %s:%d % [from_node, from_port])5.2 完善常量节点以支持“推送”更新为了让加法节点在常量节点的值改变时能自动更新我们需要修改常量节点使其在值变化时通知所有连接到它的下游节点。修改integer_constant_node.gdextends GraphNode # ... 之前已有的导出变量和current_value ... # 自定义信号当值更新时发出 signal value_updated(new_value: int) func _ready(): value_label.text 值%d % current_value if spin_box: spin_box.value_changed.connect(_on_spin_box_value_changed) func _on_spin_box_value_changed(new_value: float): current_value int(new_value) value_label.text 值%d % current_value # 发出值更新信号 value_updated.emit(current_value) # 尝试通知下游节点 notify_downstream_nodes() # 新增通知所有连接到本节点输出端口的下游节点 func notify_downstream_nodes(): var graph_edit get_parent() if not graph_edit or not graph_edit is GraphEdit: return var connections graph_edit.get_connection_list() for conn in connections: if conn[from] name and conn[from_port] 0: # 我们的输出端口是0 var to_node_name: StringName conn[to] var to_node_port: int conn[to_port] var to_node graph_edit.get_node_or_null(NodePath(to_node_name)) if to_node and to_node.has_method(update_input_from_port): # 调用下游节点的更新方法告诉它从哪个端口更新 to_node.update_input_from_port(to_node_port)现在当常量节点的数值改变时它会遍历GraphEdit中的所有连接找到从自己输出端口端口0出发的所有连接并调用下游节点的update_input_from_port方法。下游的加法节点收到调用后会重新从该端口拉取数据从而实现了联动的实时计算。5.3 绑定与测试回到main.tscn选中Main节点在检查器中将Graph Edit属性指向场景中的GraphEdit子节点。运行项目。你应该能看到画布上自动生成了两个常量节点和一个加法节点。连接节点鼠标左键点击一个常量节点右侧的输出端口那个小圆点拖拽到加法节点左侧的输入端口A或B松开鼠标。连接线应该会出现。观察计算连接成功后加法节点对应的LineEdit会显示常量节点的当前值默认为0并且结果标签会显示两数之和000。改变输入点击常量节点的SpinBox修改数值。你会看到加法节点对应的输入显示和结果标签实时更新。断开连接右键点击连接线前提是right_disconnects true选择“断开连接”。加法节点对应的输入显示会被清空结果变为另一个有效输入与0的和或0。测试两个输入将另一个常量节点连接到加法节点的另一个输入端口修改两个常量的值观察结果是否正确变化。6. 核心机制深度解析与优化6.1 数据流模型拉取 vs 推送我们这个简单的例子实际上实现了一个混合模型。拉取 (Pull): 加法节点在连接建立或收到通知时主动调用上游节点的get_output_value()方法来获取数据。这是由下游节点发起的。推送 (Push): 常量节点在自身值改变时主动遍历连接并调用下游节点的更新方法。这是由上游节点发起的。在更复杂的系统中你需要选择一种主要模型纯拉取适合计算不频繁、依赖关系明确的场景。由最终需要结果的节点或一个调度器触发整个图的“计算”每个节点按依赖关系向上游拉取数据。优点是逻辑清晰避免不必要的计算。纯推送适合需要实时响应、数据驱动变化的场景。任何节点的变化都像多米诺骨牌一样向下游传播。优点是响应快但可能导致大量级联计算需要小心处理循环依赖。我们的混合模型是一个不错的折中上游变化时推送通知下游收到通知后拉取具体数据。这避免了上游节点需要知道下游节点具体需要什么数据。6.2 端口类型与连接验证我们只用了类型1来表示整数。在真正的编辑器中你会有多种类型整数、浮点数、布尔值、字符串、对象引用等。GraphEdit的端口连接机制内置了类型检查只有Type Left和Type Right相等的端口才能连接。你可以定义一套自己的类型枚举enum DataType { INTEGER 1, FLOAT 2, BOOLEAN 3, STRING 4, VECTOR2 5 }然后在设置端口Type时使用这些枚举值。在_on_graph_edit_connection_request函数中你可以加入更严格的逻辑比如禁止整数连接到布尔值端口。6.3 节点序列化与保存一个可视化编辑器必须能保存和加载。GraphEdit本身不负责保存节点图和连接数据这需要你自己实现。保存逻辑遍历GraphEdit的所有子节点过滤出GraphNode。对于每个节点保存其name、type可通过场景文件名或自定义属性识别、position_offset以及节点特有的数据如常量节点的current_value。获取GraphEdit的所有连接graph_edit.get_connection_list()保存为一个数组。将节点数组和连接数组组合成一个字典用JSON.stringify()保存到文件。加载逻辑从文件读取JSON解析出节点数据和连接数据。根据节点type实例化对应的场景。设置节点的name、position_offset和内部数据。将节点添加到GraphEdit。遍历连接数据调用graph_edit.connect_node()恢复所有连接。这是一个非常重要的功能能让你的编辑器真正实用化。6.4 性能与用户体验优化批量更新如果一张图有很多节点一个上游节点的变化可能导致下游很长链路的重复计算。可以考虑引入“脏标记”机制在一次更新周期如_process结束时统一重新计算所有标记为“脏”的节点。撤销/重做Godot的EditorUndoRedoManager在编辑器插件中可用但在独立运行时你需要自己实现一个简单的命令模式来记录节点移动、连接、属性修改等操作。节点布局算法对于复杂的图手动布局很麻烦。可以尝试实现自动布局算法如力导向图或者提供对齐、分布等排版工具。自定义连接线样式通过继承GraphEdit并重写_draw()方法或者使用主题覆盖可以自定义连接线的颜色、粗细、样式例如用不同颜色代表不同的数据类型。7. 常见问题与排查技巧实录在实际实现过程中你几乎一定会遇到下面这些问题。这里是我踩过坑后的总结。7.1 连接线无法拖动或连接检查端口是否启用确认GraphNode的Slot属性中对应索引的Enable Left Port或Enable Right Port已经勾选。检查类型匹配确保连接两端的端口TypeType Left或Type Right是相同的非零值。类型为0通常意味着“无类型”或“任意类型”但明确设置类型有助于管理。检查信号连接GraphEdit的connection_request等信号是否已经正确连接到主脚本的函数上。没有这些信号处理GraphEdit不知道如何处理你的连接操作。节点是否在GraphEdit内确保你的自定义GraphNode是GraphEdit节点的直接子节点。如果嵌套在其他容器里GraphEdit可能无法正确识别和管理它们。7.2 连接建立了但数据不更新检查更新触发路径确认数据流的触发链路是完整的。是“推送”还是“拉取”在我们的例子中需要常量节点值改变 - 发出value_updated信号并调用notify_downstream_nodes()。notify_downstream_nodes()需要能正确找到下游节点。打印graph_edit.get_connection_list()看看连接信息是否正确。下游加法节点的update_input_from_port方法被调用。在update_input_from_port中能通过连接信息找到上游节点并成功调用其get_output_value()。打印调试在关键函数如notify_downstream_nodes、update_input_from_port的开始添加print()语句输出函数名和关键参数观察执行流程。检查方法名确保调用的方法名完全一致Godot是区分大小写的。get_output_value和get_Output_Value是不同的方法。7.3 节点位置或状态保存后错乱使用position_offset而非positionGraphNode在GraphEdit中的位置应通过position_offset属性来设置和获取。position是相对于其父节点的全局变换可能包含缩放等你不希望保存的信息。序列化时包含足够信息保存节点时除了位置还要保存节点的类型标识符。加载时根据这个标识符来实例化正确的场景。不要只存节点名因为实例化后的节点名可能重复。在_ready之后恢复数据加载节点、设置位置和恢复内部数据如常量节点的值的操作最好在节点的_ready()回调完成之后进行。可以主控脚本在add_child()并等待一帧用await get_tree().process_frame后再设置数据。7.4 如何处理多个输出端口或动态端口我们的例子是固定端口。有些节点可能需要动态端口比如一个“创建数组”节点输出端口数量由输入参数决定。动态修改Slot属性你可以在运行时通过代码修改GraphNode的slot数组。增加或删除数组元素然后调用update()或set_slot()在Godot 3中常用Godot 4的API略有不同主要是直接操作属性来刷新节点外观。端口索引管理动态端口的索引会变化在保存连接和查找连接时需要格外小心。最好为每个端口定义一个唯一的标识符如字符串ID而不仅仅依赖整数索引。7.5 实现更复杂的节点分支、循环整数加法只是最基础的运算。要实现条件分支if、循环for等逻辑你需要设计更复杂的节点内部状态和数据流。执行流端口除了数据端口可以增加一种特殊的“执行流”端口用另一种类型表示比如类型100。连接线表示程序执行的顺序。节点内部状态机一个“分支”节点可能需要根据输入的条件布尔值决定执行流从哪个输出端口流出。这需要在节点内部维护一个小状态机并在收到“执行”信号时进行判断和转发。上下文数据对于需要跨节点传递的数据比如循环索引可以设计一个“上下文”或“黑板”对象随着执行流在节点间传递。从简单的数据流图升级到可执行的视觉脚本系统是一个巨大的跨越但核心——基于GraphEdit和GraphNode的节点连接与管理——是完全相同的。把这个加法器的例子吃透你就拿到了构建更复杂可视化工具的万能钥匙。