23 KiB
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行为:- 尝试通过元数据
previous_page.get_meta("hide_on_next")获取布尔值。 - 如果元数据不存在,并且
previous_page具有名为 "about_to_show" 的信号 (这是一个特定条件,可能用于特定页面类型),则会尝试直接访问previous_page.hide_on_next脚本变量(UIPage基类将hide_on_next定义为一个导出变量,默认为true)。如果此时hide_on_next属性未找到,会打印错误并默认隐藏。 - 如果上述条件都不满足,则使用默认行为(隐藏)。
- 尝试通过元数据
- 如果最终决定需要隐藏前一页面,则调用
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)
- 在 Godot 编辑器的文件系统中,右键点击目标文件夹,选择“新建” -> “场景”。
- 根节点类型: 选择
Control作为根节点类型。你也可以根据需要选择PanelContainer等Control的派生类。 - 命名规范: 场景文件建议使用清晰的命名,例如
staff_management_screen.tscn或project_details_dialog.tscn。 - 保存场景。
3.2. 场景节点结构与命名规范
-
页面根节点 (即场景的根 Control 节点):
- 此节点将附加一个继承自
UIPage的特定页面脚本 (见 3.3)。 - 在
Main.tscn场景中(或包含MainController的场景),此页面场景的实例将作为MainController节点的直接子节点(如果是顶级页面)或另一个UIPage实例的子节点(如果是子页面)。
- 此节点将附加一个继承自
-
内部 UI 控件:
- 在页面根节点下,根据需求添加各种 Godot UI 控件,如
Label,Button,LineEdit,TextureRect,ProgressBar,ItemList,HBoxContainer,VBoxContainer,GridContainer等。 - 命名规范:
- 为重要的、需要在脚本中引用的控件赋予清晰、一致的名称。
- 建议使用驼峰式命名或下划线命名,并能体现控件类型和功能,例如:
PlayerNameLabelConfirmButton或confirm_buttonProjectListVBoxContainerCancelChangesButton
- 布局: 强烈建议使用 Godot 的容器节点 (
HBoxContainer,VBoxContainer,GridContainer,MarginContainer,PanelContainer,ScrollContainer等) 来组织和布局内部 UI 控件,以实现响应式和易于维护的界面。
- 在页面根节点下,根据需求添加各种 Godot UI 控件,如
3.3. 创建页面脚本 (.gd)
-
在 Godot 编辑器的文件系统中,右键点击目标文件夹,选择“新建” -> “脚本”。
-
命名规范: 脚本名称应与其管理的页面场景相对应,例如
staff_management_page.gd。 -
继承
UIPage:# staff_management_page.gd class_name StaffManagementPage # 使用 class_name 方便类型提示 extends UIPage -
@onready变量获取控件引用: 在脚本顶部,使用@onready关键字获取场景中需要交互的 UI 控件的引用。@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 -
实现
_ready()方法:- 必须调用
super._ready(): 确保基类UIPage的_ready()逻辑(如查找main_controller)被执行。 - 连接页面内部控件的信号: 将按钮的
pressed信号、LineEdit的text_changed信号等连接到此脚本中的处理函数。
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) # ... 其他信号连接 - 必须调用
-
覆写
_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状态等。
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 - 建议调用
-
覆写
_on_page_deactivated()方法 (可选): 此方法在页面每次通过hide_page()即将变为非活动状态(隐藏)之前调用。- 建议调用
super._on_page_deactivated(): 基类实现会打印日志 (UIPage deactivated: [PageName])。 - 用于执行页面隐藏前的清理工作,例如保存未提交的临时输入到
shared_workflow_data,或者重置页面状态以便下次激活时重新初始化。
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) - 建议调用
-
实现自定义方法: 编写处理页面特定交互逻辑的函数(例如上面
_ready中连接的_on_hire_button_pressed)。- 这些方法可能会读取页面输入、执行计算、调用
set_main_data(key, value)来更新MainController中的共享数据。
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) - 这些方法可能会读取页面输入、执行计算、调用
-
调用导航方法: 在适当的时候(例如,用户点击确认按钮后),调用从
UIPage继承的导航方法:go_next(): 前往流程中的下一个页面或确认结束工作流。go_back(): 返回上一个页面、父页面或取消结束工作流。go_to_child_page(child_node): 打开当前页面的一个子页面。
-
附加脚本: 将创建好的页面脚本附加到对应 UI 场景的根节点上。
3.4. 配置页面导航 (Inspector)
- 打开包含
MainController和 UI 页面实例的场景(例如Main.tscn)。 - 确保你的新 UI 页面场景已经被实例化并添加为
MainController节点的子节点(或相应父页面的子节点)。 - 选中新页面实例。
- 在 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的逻辑)。
- Next Ui Node Path: 拖拽流程中的下一个页面节点到此属性。如果这是流程的最后一步(确认并结束),则留空 (使其
4. 将新页面集成到工作流
-
4.1. 作为顶级页面 (例如 A, B, C):
- 将新创建的 UI 页面场景实例化,并使其成为
MainController节点的直接子节点。 MainController的_ready()函数会自动查找并处理其场景树下两层内的 UI 页面(即MainController的直接子UIPage,以及这些UIPage的直接子UIPage)。它会隐藏这些页面并连接它们的信号。确保你的新页面是UIPage类型(即附加了继承自UIPage的脚本)。- 根据工作流逻辑,配置
MainController的Initial Page Path指向此新页面(如果它是起始页),或者修改其他页面的Next Ui Node Path或Back Ui Node Path以包含此新页面。
- 将新创建的 UI 页面场景实例化,并使其成为
-
4.2. 作为子页面 (例如 A_1 是 A 的子页面):
- 将新创建的 UI 页面场景实例化,并使其成为其父
UIPage节点的子节点。例如,PageA1.tscn的实例是PageA节点的子节点。 - 在父页面 (例如
PageA.gd) 的脚本中,你需要:- 添加一个按钮或交互元素来触发打开此子页面。
- 在该按钮的响应函数中,获取子页面节点的引用,并调用
go_to_child_page(get_node("Path/To/YourChildPageNode"))。
- 配置新子页面的
Parent Page Node Path(在 Inspector 中) 指向其父UIPage节点。 MainController的_ready()中的信号连接和初始隐藏逻辑会处理MainController的直接子UIPage以及这些UIPage的直接子UIPage。如果你的子页面嵌套更深(例如,一个UIPage的孙子节点也是UIPage,即相对于MainController三层或更深),则需要调整main_controller.gd中的_ready()循环以遍历更深层级,或者手动为更深层级的UIPage调用hide_page()并使用_connect_page_signals()连接其信号到MainController的相应处理方法。
- 将新创建的 UI 页面场景实例化,并使其成为其父
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()方法也不会被调用。
- 当使用