# UI 框架开发指南 **1. 简介** 本文档旨在为使用本 UI 框架的开发人员提供指导,帮助大家快速、规范地创建和管理游戏中的 UI 页面及工作流。本框架的核心是通过一个中央控制器 (`MainController`) 来管理各个 UI 页面 (`UIPage` 的派生类) 的显示、隐藏、导航以及数据共享。 核心组件: * **`main_controller.gd` (`MainController`)**: 全局 UI 工作流管理器。负责页面切换、维护工作流共享数据、处理工作流的开始与结束。 * **`ui_base.gd` (`UIPage`)**: 所有具体 UI 页面的基类。提供了基础的导航逻辑、与 `MainController` 的通信接口以及页面生命周期管理。 * **具体页面脚本 **: 继承自 `UIPage`,实现特定页面的内容展示和交互逻辑。 **2. 核心概念** * **MainController (`main_controller.gd`)** * **职责**: * 管理当前激活的 UI 页面。 * 根据 `UIPage` 发出的请求进行页面切换,并处理页面间的过渡逻辑(如隐藏旧页面、显示新页面及其父页面)。 * 持有一个 `shared_workflow_data` 字典,用于在工作流中的不同页面间共享数据。此数据可在启动时从全局状态管理(例如 `GameState`)初始化。 * 定义工作流的起点 (`initial_page_path`)。 * 发出工作流结束信号 (`workflow_confirmed`, `workflow_cancelled`)。 * **导出变量**: * `initial_page_path: NodePath`: 指向工作流初始页面的节点路径。 * `gamestate_data_key: String`: 用于从全局状态管理(如 `GameState`)中读取/初始化 `shared_workflow_data` 的键名。 * **启动与显示工作流**: * `_ready()`: 控制器准备就绪时,会尝试从 `GameState` 使用 `gamestate_data_key` 加载共享数据 (如果 `gamestate_data_key` 已配置)。然后,它会遍历其直接子节点:如果是 `UIPage` 类型,则隐藏它们并连接必要的信号。接着,对于每个作为 `UIPage` 的直接子节点,它还会遍历该 `UIPage` 的直接子节点(即 `MainController` 的孙子节点):如果这些孙子节点也是 `UIPage` 类型,则同样会隐藏它们并连接信号。**注意**:`show_up()` 方法需要被外部调用以启动工作流,它不会在 `_ready()` 中自动调用。 * `show_up()`: 此方法用于显示和启动 UI 工作流。它首先会调用 `GameState.pause_game()` 来暂停游戏,然后重新从 `GameState` 使用 `gamestate_data_key` 获取最新的共享数据,并根据 `initial_page_path` 找到初始页面,然后调用 `start_new_workflow` 并将获取的数据传递给新工作流。如果初始页面路径无效或未找到对应节点,则控制器自身将保持不可见。 * `start_new_workflow(start_page: UIPage, initial_data: Dictionary = {})` 方法启动一个新的 UI 工作流。它会复制 `initial_data` (通常是从 `GameState` 或 `show_up()` 传递的) 到 `shared_workflow_data`,隐藏所有非起始的顶级页面及其子 `UIPage`(具体来说,它遍历 `MainController` 的直接子节点,如果子节点是 `UIPage` 且不是 `start_page`,则隐藏它;然后遍历该子节点的子节点,如果是 `UIPage`,也隐藏它),然后调用 `_switch_to_page` 切换到指定的 `start_page` 并使控制器可见。 * **共享数据**: * `get_shared_data(key: String, default = null)`: 从共享数据字典中获取值。 * `set_shared_data(key: String, value)`: 设置共享数据字典中的值。 * **页面切换逻辑 (`_switch_to_page`)**: * **处理前一页面**: * 如果存在前一个活动页面 (`previous_page`),控制器会决定是否隐藏它。默认行为是隐藏 (`should_hide_previous_page = true`)。控制器会按以下顺序检查 `previous_page` 的 `hide_on_next` 行为: 1. 尝试通过元数据 `previous_page.get_meta("hide_on_next")` 获取布尔值。 2. 如果元数据不存在,并且 `previous_page` 具有名为 "about_to_show" 的信号 (这是一个特定条件,可能用于特定页面类型),则会尝试直接访问 `previous_page.hide_on_next` 脚本变量(`UIPage` 基类将 `hide_on_next` 定义为一个导出变量,默认为 `true`)。如果此时 `hide_on_next` 属性未找到,会打印错误并默认隐藏。 3. 如果上述条件都不满足,则使用默认行为(隐藏)。 * 如果最终决定需要隐藏前一页面,则调用 `previous_page.hide_page()`。 * **父页面清理**: 如果前一页面 (`previous_page`) 是一个子页面(即其 `parent_page_node_path` 已配置),并且导航的目标页面 (`target_page`) 满足以下两个条件:1) `target_page` 不是 `previous_page` 的逻辑父页面 (`old_page_parent_node`),并且 2) `target_page` 的场景树父节点不是 `old_page_parent_node`(即 `target_page` 不是 `old_page_parent_node` 的直接子节点,意味着它不是 `previous_page` 在该逻辑父页面下的兄弟页面),则前一子页面的逻辑父页面 (`old_page_parent_node`) 将被隐藏,以清理不再相关的上下文。 * **激活新页面**: 将 `current_active_page` 更新为目标页面。调用 `current_active_page.show_page()`(此方法在 `UIPage` 中仅负责设置 `visible = true`),然后关键地,`MainController` 直接调用 `current_active_page._on_page_activated()` 来确保页面的激活逻辑被执行。 * **确保新子页面的父页面可见并已激活**: 如果新的当前活动页面 (`current_active_page`) 是一个子页面(配置了 `parent_page_node_path`),控制器会确保其在 `parent_page_node_path` 中指定的父 `UIPage` 节点 (`new_page_parent_node`) 也是可见且(如果需要)已激活的。 * 如果该父页面 (`new_page_parent_node`) 之前不可见,则会调用其 `show_page()` 方法(使其可见),然后调用其 `_on_page_activated()` 方法(激活它)。 * 如果父页面已经可见,则仍会调用其 `show_page()` 方法(尽管它可能仅确认可见性)。 * **信号处理器**: * `_on_page_navigate_requested(target_page_node: Control)`: 当 `UIPage` 发出 `navigate_requested` 信号时调用。如果 `target_page_node` 是 `UIPage` 的实例,则负责执行到目标页面的切换(通过 `_switch_to_page`)。否则会打印错误。 * `_on_page_workflow_end_requested(is_confirm_and_trigger_action: bool)`: 当 `UIPage` 发出 `workflow_end_requested` 信号时调用。 * 如果 `is_confirm_and_trigger_action` 为 `true`,则发出 `workflow_confirmed` 信号并携带最终的 `shared_workflow_data`。 * 否则,发出 `workflow_cancelled` 信号。 * 之后,如果存在当前活动页面 (`current_active_page`),则调用其 `hide_page()`。如果当前活动页面配置了 `parent_page_node_path`,其对应的父页面也会被调用 `hide_page()`。 * `current_active_page` 被设为 `null`。 * 当前的 `shared_workflow_data` 会使用 `GameState.set_value(gamestate_data_key, shared_workflow_data)` 保存到全局状态。注意:`MainController` 内部的 `shared_workflow_data` 不会被清空。随后调用 `GameState.resume_game()` 来恢复游戏。 * 最后,`MainController` 自身设置为不可见。 * **UIPage (`ui_base.gd`)** * **页面基类**: 所有具体的 UI 页面场景的根节点所附加的脚本都应继承自此类。 * **初始化 (`_ready`)**: * `UIPage` 在其 `_ready` 方法中会尝试获取 `MainController` 的引用。它会检查其 `owner` 节点是否为 `MainController`。这是通过判断 `owner` 是否存在并且是否拥有一个名为 `get_shared_data` 的方法来实现的。如果条件满足,则将 `owner` 赋值给 `main_controller` 变量。 * **输入处理 (`_input`)**: * 如果页面可见,它会监听鼠标右键点击事件 (`MOUSE_BUTTON_RIGHT`)。当右键被按下时,会尝试执行 `go_back()` 操作,并调用 `get_viewport().set_input_as_handled()` 来消费该输入事件。 * **导航功能**: * `go_next()`: 尝试导航到 `next_ui_node_path` 配置的页面。若路径有效且目标节点是 `Control` 类型,则发出 `navigate_requested` 信号。若路径为空或目标节点无效(或不是 `Control`),则打印错误并发出 `workflow_end_requested` 信号并传递 `true` (表示确认结束)。 * `go_back()`: 优先尝试导航到 `back_ui_node_path` 配置的页面。如果 `back_ui_node_path` 为空,则尝试导航到 `parent_page_node_path` 配置的父页面。若选中的路径有效且目标节点是 `Control` 类型,则发出 `navigate_requested` 信号。若两个路径均为空或目标节点无效(或不是 `Control`),则打印错误并发出 `workflow_end_requested` 信号并传递 `false` (表示取消结束)。 * `go_to_child_page(child_page_node: Control)`: 导航到指定的子页面。此方法会进行检查: * 确保 `child_page_node` 不为 null。 * 确保 `child_page_node` 不是当前页面的祖先节点 (使用 `_is_ancestor_of` 辅助函数判断,以防止循环导航到父级)。 * 确保 `child_page_node` 是一个 `Control` 节点。 如果检查通过,则发出 `navigate_requested` 信号。否则打印错误。 * **Inspector 配置项**: * `next_ui_node_path: NodePath`: “下一个”页面节点的路径。 * `back_ui_node_path: NodePath`: “上一个”页面节点的路径。 * `parent_page_node_path: NodePath`: (主要用于子页面)父页面节点的路径,当 `back_ui_node_path` 为空时,子页面会尝试通过 `go_back()` 返回此父页面。 * `hide_on_next: bool`: (默认值: `true`) 如果此属性为 `true`,当通过 `go_next()` 方法成功导航到 `next_ui_node_path` 所配置的页面时,`MainController` 在处理前一页面(即当前页面)时,会倾向于隐藏当前页面(具体逻辑见 `MainController._switch_to_page` 中对 `hide_on_next` 的处理)。如果设置为 `false`,当前页面在导航后有更大可能性保持可见。 * **生命周期方法 (可覆写)**: * `show_page()`: 公开方法,用于显示页面。它**仅**负责将节点的 `visible` 属性设为 `true` (如果之前是 `false`)。**此方法本身不调用 `_on_page_activated()`**。 * `hide_page()`: 公开方法,用于隐藏页面。**仅当页面之前是可见状态时**,此方法会先调用 `_on_page_deactivated()`,然后再将节点的 `visible` 属性设为 `false`。 * `_on_page_activated()`: 当页面被 `MainController` 激活时调用(通常在 `_switch_to_page` 期间)。用于加载数据、更新UI、启动动画等。基类实现会打印一条日志,如 `UIPage activated: [PageName]`。 * `_on_page_deactivated()`: 当页面通过 `hide_page()` **即将变为不可见** (即其 `visible` 属性从 `true` 变为 `false`) 之前调用。用于保存临时状态、清理资源或停止动画等。如果页面已不可见,再次调用 `hide_page()` 不会重复触发此方法。基类实现会打印一条日志,如 `UIPage deactivated: [PageName]`。 * **与 MainController 通信**: * 信号 `navigate_requested(target_page_node: Control)`: 请求 `MainController` 切换到目标页面。 * 信号 `workflow_end_requested(is_confirm_and_trigger_action: bool)`: 请求 `MainController` 结束当前工作流。 * 方法 `get_main_data(key: String, default = null)` 和 `set_main_data(key: String, value)`: 分别用于从 `main_controller` 获取和设置共享数据。如果 `main_controller` 未被正确引用,会发出警告并返回 `default` 值 (对于 `get_main_data`) 或不设置数据 (对于 `set_main_data`)。 **3. 创建新的 UI 页面** 以下步骤详细说明了如何创建一个新的 UI 页面并将其集成到框架中。 **3.1. 创建场景文件 (.tscn)** 1. 在 Godot 编辑器的文件系统中,右键点击目标文件夹,选择“新建” -> “场景”。 2. **根节点类型**: 选择 `Control` 作为根节点类型。你也可以根据需要选择 `PanelContainer` 等 `Control` 的派生类。 3. **命名规范**: 场景文件建议使用清晰的命名,例如 `staff_management_screen.tscn` 或 `project_details_dialog.tscn`。 4. 保存场景。 **3.2. 场景节点结构与命名规范** * **页面根节点 (即场景的根 Control 节点)**: * 此节点将附加一个继承自 `UIPage` 的特定页面脚本 (见 3.3)。 * 在 `Main.tscn` 场景中(或包含 `MainController` 的场景),此页面场景的实例将作为 `MainController` 节点的直接子节点(如果是顶级页面)或另一个 `UIPage` 实例的子节点(如果是子页面)。 * **内部 UI 控件**: * 在页面根节点下,根据需求添加各种 Godot UI 控件,如 `Label`, `Button`, `LineEdit`, `TextureRect`, `ProgressBar`, `ItemList`, `HBoxContainer`, `VBoxContainer`, `GridContainer` 等。 * **命名规范**: * 为重要的、需要在脚本中引用的控件赋予清晰、一致的名称。 * 建议使用驼峰式命名或下划线命名,并能体现控件类型和功能,例如: * `PlayerNameLabel` * `ConfirmButton` 或 `confirm_button` * `ProjectListVBoxContainer` * `CancelChangesButton` * **布局**: 强烈建议使用 Godot 的容器节点 (`HBoxContainer`, `VBoxContainer`, `GridContainer`, `MarginContainer`, `PanelContainer`, `ScrollContainer` 等) 来组织和布局内部 UI 控件,以实现响应式和易于维护的界面。 **3.3. 创建页面脚本 (.gd)** 1. 在 Godot 编辑器的文件系统中,右键点击目标文件夹,选择“新建” -> “脚本”。 2. **命名规范**: 脚本名称应与其管理的页面场景相对应,例如 `staff_management_page.gd`。 3. **继承 `UIPage`**: ```gdscript # staff_management_page.gd class_name StaffManagementPage # 使用 class_name 方便类型提示 extends UIPage ``` 4. **`@onready` 变量获取控件引用**: 在脚本顶部,使用 `@onready` 关键字获取场景中需要交互的 UI 控件的引用。 ```gdscript @onready var employee_list: ItemList = $ScrollContainer/VBoxContainer/EmployeeList @onready var hire_button: Button = $ControlsHBox/HireButton @onready var details_panel: PanelContainer = $DetailsPanel/ContentPanel @onready var employee_name_label: Label = $DetailsPanel/ContentPanel/NameLabel ``` 5. **实现 `_ready()` 方法**: * **必须调用 `super._ready()`**: 确保基类 `UIPage` 的 `_ready()` 逻辑(如查找 `main_controller`)被执行。 * **连接页面内部控件的信号**: 将按钮的 `pressed` 信号、`LineEdit` 的 `text_changed` 信号等连接到此脚本中的处理函数。 ```gdscript func _ready(): super._ready() # !! 非常重要 !!确保 main_controller 被初始化 if hire_button: # 良好的习惯是检查节点是否存在 hire_button.pressed.connect(_on_hire_button_pressed) if employee_list: employee_list.item_selected.connect(_on_employee_list_item_selected) # ... 其他信号连接 ``` 6. **覆写 `_on_page_activated()` 方法**: 此方法在页面被 `MainController` 激活时调用。 * **建议调用 `super._on_page_activated()`**: 基类实现会打印日志 (`UIPage activated: [PageName]`), 方便调试。 * **加载数据**: 使用 `get_main_data("data_key", default_value)` 从 `MainController` 的共享数据中获取当前工作流所需的数据。 * **更新 UI**: 根据获取的数据,更新页面上的 `Label.text`, `LineEdit.text`, `ProgressBar.value`,填充列表,设置按钮的 `disabled` 状态等。 ```gdscript func _on_page_activated(): super._on_page_activated() # 基类打印日志 var current_staff = get_main_data("company_staff", []) _populate_employee_list(current_staff) var can_hire = get_main_data("can_afford_new_hire", true) if hire_button: hire_button.disabled = not can_hire ``` 7. **覆写 `_on_page_deactivated()` 方法 (可选)**: 此方法在页面每次通过 `hide_page()` 即将变为非活动状态(隐藏)之前调用。 * **建议调用 `super._on_page_deactivated()`**: 基类实现会打印日志 (`UIPage deactivated: [PageName]`)。 * 用于执行页面隐藏前的清理工作,例如保存未提交的临时输入到 `shared_workflow_data`,或者重置页面状态以便下次激活时重新初始化。 ```gdscript func _on_page_deactivated(): super._on_page_deactivated() # 基类打印日志 # 如果有临时输入未保存,可以在这里处理 # if name_input_field.text != get_main_data("current_editing_name"): # set_main_data("temp_unsaved_name", name_input_field.text) ``` 8. **实现自定义方法**: 编写处理页面特定交互逻辑的函数(例如上面 `_ready` 中连接的 `_on_hire_button_pressed`)。 * 这些方法可能会读取页面输入、执行计算、调用 `set_main_data(key, value)` 来更新 `MainController` 中的共享数据。 ```gdscript func _on_hire_button_pressed(): print("Hire button pressed on " + name) # 可能需要更新一些共享数据,表明要进入雇佣流程 set_main_data("entering_hire_mode", true) # 然后导航到下一个页面(例如一个详细的雇佣信息填写页面) go_next() # go_next() 是从 UIPage 继承的方法 func _populate_employee_list(staff_data: Array): if employee_list: employee_list.clear() for employee_info in staff_data: employee_list.add_item(employee_info.get("name", "Unknown")) # 可以存储更复杂的数据 # employee_list.set_item_metadata(employee_list.get_item_count() - 1, employee_info) ``` 9. **调用导航方法**: 在适当的时候(例如,用户点击确认按钮后),调用从 `UIPage` 继承的导航方法: * `go_next()`: 前往流程中的下一个页面或确认结束工作流。 * `go_back()`: 返回上一个页面、父页面或取消结束工作流。 * `go_to_child_page(child_node)`: 打开当前页面的一个子页面。 10. **附加脚本**: 将创建好的页面脚本附加到对应 UI 场景的根节点上。 **3.4. 配置页面导航 (Inspector)** 1. 打开包含 `MainController` 和 UI 页面实例的场景(例如 `Main.tscn`)。 2. 确保你的新 UI 页面场景已经被实例化并添加为 `MainController` 节点的子节点(或相应父页面的子节点)。 3. 选中新页面实例。 4. 在 Godot 编辑器的 **Inspector** 面板中,找到并配置从 `UIPage` 继承来的导出变量: * **Next Ui Node Path**: 拖拽流程中的下一个页面节点到此属性。如果这是流程的最后一步(确认并结束),则留空 (使其 `NodePath` 为空)。 * **Back Ui Node Path**: 拖拽流程中的上一个页面节点到此属性。如果这是流程的起点或返回即取消工作流,则留空。 * **Parent Page Node Path**: **仅当此页面是作为另一个页面的子页面时配置**。拖拽其直接的父 `UIPage` 节点到此属性。这用于子页面在没有特定 `Back Ui Node Path` 时,通过 `go_back()` 返回其父级。 * **Hide On Next**: (布尔值,默认为 `true`) 勾选此项(或设置为 `true`)表示当通过 `go_next()` 导航到下一个页面时,`MainController` 更倾向于隐藏此页面。取消勾选(或设置为 `false`)则此页面在导航后有更大可能性保持可见(具体行为取决于 `MainController` 中 `_switch_to_page` 的逻辑)。 **4. 将新页面集成到工作流** * **4.1. 作为顶级页面 (例如 A, B, C)**: 1. 将新创建的 UI 页面场景实例化,并使其成为 `MainController` 节点的直接子节点。 2. `MainController` 的 `_ready()` 函数会自动查找并处理其场景树下两层内的 UI 页面(即 `MainController` 的直接子 `UIPage`,以及这些 `UIPage` 的直接子 `UIPage`)。它会隐藏这些页面并连接它们的信号。确保你的新页面是 `UIPage` 类型(即附加了继承自 `UIPage` 的脚本)。 3. 根据工作流逻辑,配置 `MainController` 的 `Initial Page Path` 指向此新页面(如果它是起始页),或者修改其他页面的 `Next Ui Node Path` 或 `Back Ui Node Path` 以包含此新页面。 * **4.2. 作为子页面 (例如 A_1 是 A 的子页面)**: 1. 将新创建的 UI 页面场景实例化,并使其成为其父 `UIPage` 节点的子节点。例如,`PageA1.tscn` 的实例是 `PageA` 节点的子节点。 2. 在父页面 (例如 `PageA.gd`) 的脚本中,你需要: * 添加一个按钮或交互元素来触发打开此子页面。 * 在该按钮的响应函数中,获取子页面节点的引用,并调用 `go_to_child_page(get_node("Path/To/YourChildPageNode"))`。 3. 配置新子页面的 `Parent Page Node Path` (在 Inspector 中) 指向其父 `UIPage` 节点。 4. `MainController` 的 `_ready()` 中的信号连接和初始隐藏逻辑会处理 `MainController` 的直接子 `UIPage` 以及这些 `UIPage` 的直接子 `UIPage`。如果你的子页面嵌套更深(例如,一个 `UIPage` 的孙子节点也是 `UIPage`,即相对于 `MainController` 三层或更深),则需要调整 `main_controller.gd` 中的 `_ready()` 循环以遍历更深层级,或者手动为更深层级的 `UIPage` 调用 `hide_page()` 并使用 `_connect_page_signals()` 连接其信号到 `MainController` 的相应处理方法。 **5. 注意事项与最佳实践** * **单一职责**: 保持 `UIPage` 基类脚本的通用性。特定于某个页面的复杂逻辑应该放在该页面的派生脚本中。 * **命名一致性**: 对场景、节点和脚本使用清晰、一致的命名约定。 * **响应式布局**: 优先使用 Godot 的容器节点进行布局,以适应不同的屏幕尺寸和分辨率。 * **数据流**: 所有跨页面的工作流数据都应通过 `MainController` 的 `shared_workflow_data` 进行管理。避免页面脚本之间直接相互引用以获取或修改状态。 * **`_ready()` vs `_on_page_activated()`**: * `_ready()`: 用于一次性的初始化设置,如信号连接、节点引用获取、`main_controller` 的查找。它在节点进入场景树时调用一次。 * `_on_page_activated()`: 由 `MainController` 在页面成为当前活动页面时调用。用于执行每次页面激活时需要执行的逻辑,如从 `MainController` 拉取最新数据并刷新 UI 显示。 * **错误处理与健壮性**: 在获取节点引用 (`get_node_or_null`) 或处理路径时,考虑添加空检查,以避免运行时错误。`UIPage` 和 `MainController` 中的导航方法已经包含了一些基本的错误打印。 * **测试**: 彻底测试所有导航路径,包括前进、后退、进入/退出子页面以及各种工作流结束条件(确认或取消)。 * **处理多页面同时可见的情况**: * 当使用 `hide_on_next = false` 使得多个页面同时显示时,需要特别注意UI布局,确保它们不会相互不当重叠或干扰用户操作。 * 考虑输入焦点管理。Godot的事件系统通常会将输入传递给最上层(渲染顺序)且可见的控件。如果多个可见页面都有可交互元素,请仔细规划和测试以确保行为符合预期。 * 请记住,`MainController` 中的 `current_active_page` 始终指向最新导航到的(即“最活动的”)页面。其他因 `hide_on_next = false` (或类似逻辑)而保持可见的页面虽然显示在屏幕上,但它们不是 `MainController` 当前主要管理的页面。**重要:当从一个设置了 `hide_on_next = false` 的页面导航离开时,如果 `MainController` 决定不隐藏该页面,那么该页面的 `hide_page()` 方法将不会被调用,因此其 `_on_page_deactivated()` 方法也不会被调用。**