D5/UI/[文档]UI开发框架说明.md

236 lines
23 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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()` 方法也不会被调用。**