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