第三十二章:UI系统基础
用户界面(UI)是游戏与玩家交互的重要桥梁,包括菜单、HUD、对话框、设置界面等。Godot 4提供了功能强大的UI系统,基于Control节点构建,支持灵活的布局、丰富的控件和完整的主题系统。本章将全面介绍Godot UI系统的基础知识。
32.1 Control节点基础
32.1.1 Control节点概述
# control_basics.gd
# Control节点基础
extends Control
## Control节点体系
##
## Control是所有UI节点的基类,提供:
## - 锚点和边距系统
## - 大小标志(size flags)
## - 焦点系统
## - 鼠标过滤
## - 主题支持
func _ready():
_explore_control_properties()
func _explore_control_properties():
print("=== Control属性 ===")
print("位置: ", position)
print("大小: ", size)
print("最小大小: ", custom_minimum_size)
print("旋转: ", rotation)
print("缩放: ", scale)
print("枢轴偏移: ", pivot_offset)
print("\n=== 锚点 ===")
print("锚点左: ", anchor_left)
print("锚点上: ", anchor_top)
print("锚点右: ", anchor_right)
print("锚点下: ", anchor_bottom)
print("\n=== 偏移 ===")
print("偏移左: ", offset_left)
print("偏移上: ", offset_top)
print("偏移右: ", offset_right)
print("偏移下: ", offset_bottom)
## 设置Control位置和大小
func set_rect(pos: Vector2, rect_size: Vector2):
position = pos
size = rect_size
## 居中显示
func center_in_parent():
var parent_size = get_parent_control().size if get_parent_control() else get_viewport_rect().size
position = (parent_size - size) / 2
## 全屏填充
func fill_parent():
anchor_left = 0
anchor_top = 0
anchor_right = 1
anchor_bottom = 1
offset_left = 0
offset_top = 0
offset_right = 0
offset_bottom = 0
32.1.2 锚点与边距系统
# anchors_margins.gd
# 锚点与边距详解
extends Control
## 锚点预设
enum AnchorPreset {
TOP_LEFT,
TOP_RIGHT,
BOTTOM_LEFT,
BOTTOM_RIGHT,
CENTER_LEFT,
CENTER_RIGHT,
CENTER_TOP,
CENTER_BOTTOM,
CENTER,
LEFT_WIDE,
TOP_WIDE,
RIGHT_WIDE,
BOTTOM_WIDE,
VCENTER_WIDE,
HCENTER_WIDE,
FULL_RECT
}
## 应用锚点预设
func apply_anchor_preset(preset: AnchorPreset):
match preset:
AnchorPreset.TOP_LEFT:
set_anchors_preset(Control.PRESET_TOP_LEFT)
AnchorPreset.TOP_RIGHT:
set_anchors_preset(Control.PRESET_TOP_RIGHT)
AnchorPreset.BOTTOM_LEFT:
set_anchors_preset(Control.PRESET_BOTTOM_LEFT)
AnchorPreset.BOTTOM_RIGHT:
set_anchors_preset(Control.PRESET_BOTTOM_RIGHT)
AnchorPreset.CENTER:
set_anchors_preset(Control.PRESET_CENTER)
AnchorPreset.LEFT_WIDE:
set_anchors_preset(Control.PRESET_LEFT_WIDE)
AnchorPreset.TOP_WIDE:
set_anchors_preset(Control.PRESET_TOP_WIDE)
AnchorPreset.RIGHT_WIDE:
set_anchors_preset(Control.PRESET_RIGHT_WIDE)
AnchorPreset.BOTTOM_WIDE:
set_anchors_preset(Control.PRESET_BOTTOM_WIDE)
AnchorPreset.FULL_RECT:
set_anchors_preset(Control.PRESET_FULL_RECT)
## 自定义锚点设置
func set_custom_anchors(left: float, top: float, right: float, bottom: float):
anchor_left = left
anchor_top = top
anchor_right = right
anchor_bottom = bottom
## 设置边距(偏移)
func set_margins(left: float, top: float, right: float, bottom: float):
offset_left = left
offset_top = top
offset_right = right
offset_bottom = bottom
## 响应式布局示例
func setup_responsive_layout():
# 根据屏幕方向调整布局
var viewport_size = get_viewport_rect().size
if viewport_size.x > viewport_size.y:
# 横屏布局
_apply_landscape_layout()
else:
# 竖屏布局
_apply_portrait_layout()
func _apply_landscape_layout():
# 左侧面板
anchor_left = 0
anchor_right = 0.3
anchor_top = 0
anchor_bottom = 1
func _apply_portrait_layout():
# 顶部面板
anchor_left = 0
anchor_right = 1
anchor_top = 0
anchor_bottom = 0.4
## 监听视口大小变化
func _ready():
get_viewport().size_changed.connect(_on_viewport_resized)
func _on_viewport_resized():
setup_responsive_layout()
32.1.3 大小标志
# size_flags.gd
# 大小标志详解
extends Control
## 大小标志说明
##
## SIZE_FILL: 填充可用空间
## SIZE_EXPAND: 扩展以占用额外空间
## SIZE_SHRINK_BEGIN: 收缩到容器开始位置
## SIZE_SHRINK_CENTER: 收缩到容器中心
## SIZE_SHRINK_END: 收缩到容器结束位置
## 设置大小标志
func setup_size_flags():
# 水平方向填充并扩展
size_flags_horizontal = Control.SIZE_FILL | Control.SIZE_EXPAND
# 垂直方向仅填充
size_flags_vertical = Control.SIZE_FILL
# 拉伸比例(用于容器中分配空间)
size_flags_stretch_ratio = 1.0
## 常用大小标志组合
func apply_common_flags(flag_type: String):
match flag_type:
"fill_expand":
size_flags_horizontal = Control.SIZE_EXPAND_FILL
size_flags_vertical = Control.SIZE_EXPAND_FILL
"shrink_center":
size_flags_horizontal = Control.SIZE_SHRINK_CENTER
size_flags_vertical = Control.SIZE_SHRINK_CENTER
"fill_only":
size_flags_horizontal = Control.SIZE_FILL
size_flags_vertical = Control.SIZE_FILL
"expand_only":
size_flags_horizontal = Control.SIZE_EXPAND
size_flags_vertical = Control.SIZE_EXPAND
## 设置拉伸比例
func set_stretch_ratio(ratio: float):
size_flags_stretch_ratio = ratio
32.2 鼠标与焦点
32.2.1 鼠标过滤
# mouse_handling.gd
# 鼠标处理
extends Control
## 鼠标过滤模式
## MOUSE_FILTER_STOP: 处理鼠标事件,阻止传递
## MOUSE_FILTER_PASS: 处理鼠标事件,继续传递
## MOUSE_FILTER_IGNORE: 忽略鼠标事件
func _ready():
_setup_mouse_handling()
_connect_mouse_signals()
func _setup_mouse_handling():
# 设置鼠标过滤
mouse_filter = Control.MOUSE_FILTER_STOP
# 设置默认光标
mouse_default_cursor_shape = Control.CURSOR_ARROW
func _connect_mouse_signals():
mouse_entered.connect(_on_mouse_entered)
mouse_exited.connect(_on_mouse_exited)
func _on_mouse_entered():
# 鼠标进入时改变光标
mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
modulate = Color(1.2, 1.2, 1.2)
func _on_mouse_exited():
mouse_default_cursor_shape = Control.CURSOR_ARROW
modulate = Color.WHITE
## 处理GUI输入事件
func _gui_input(event):
if event is InputEventMouseButton:
if event.button_index == MOUSE_BUTTON_LEFT and event.pressed:
_on_clicked()
accept_event() # 阻止事件继续传递
elif event is InputEventMouseMotion:
_on_mouse_motion(event.position)
func _on_clicked():
print("Control被点击")
func _on_mouse_motion(local_pos: Vector2):
pass
## 自定义光标
func set_custom_cursor(texture: Texture2D, hotspot: Vector2 = Vector2.ZERO):
Input.set_custom_mouse_cursor(texture, Input.CURSOR_ARROW, hotspot)
func reset_cursor():
Input.set_custom_mouse_cursor(null)
32.2.2 焦点系统
# focus_system.gd
# 焦点系统
extends Control
## 焦点模式
## FOCUS_NONE: 不接受焦点
## FOCUS_CLICK: 点击获得焦点
## FOCUS_ALL: 点击和Tab键都可获得焦点
@export var focus_mode_setting: FocusMode = FocusMode.FOCUS_ALL
## 焦点邻居(用于键盘导航)
@export var focus_neighbor_left_path: NodePath
@export var focus_neighbor_right_path: NodePath
@export var focus_neighbor_top_path: NodePath
@export var focus_neighbor_bottom_path: NodePath
func _ready():
focus_mode = focus_mode_setting
_setup_focus_neighbors()
_connect_focus_signals()
func _setup_focus_neighbors():
if focus_neighbor_left_path:
focus_neighbor_left = focus_neighbor_left_path
if focus_neighbor_right_path:
focus_neighbor_right = focus_neighbor_right_path
if focus_neighbor_top_path:
focus_neighbor_top = focus_neighbor_top_path
if focus_neighbor_bottom_path:
focus_neighbor_bottom = focus_neighbor_bottom_path
func _connect_focus_signals():
focus_entered.connect(_on_focus_entered)
focus_exited.connect(_on_focus_exited)
func _on_focus_entered():
# 获得焦点时的视觉反馈
modulate = Color(1.0, 1.0, 0.8)
# 可以播放声音
# AudioManager.play_sfx("focus")
func _on_focus_exited():
modulate = Color.WHITE
## 编程式焦点控制
func focus():
grab_focus()
func unfocus():
release_focus()
func has_current_focus() -> bool:
return has_focus()
## 焦点导航
func focus_next():
var next = find_next_valid_focus()
if next:
next.grab_focus()
func focus_previous():
var prev = find_prev_valid_focus()
if prev:
prev.grab_focus()
32.2.3 焦点管理器
# focus_manager.gd
# 焦点管理器
extends Node
## 焦点组
var focus_groups: Dictionary = {}
var current_group: String = ""
var focus_stack: Array[Control] = []
## 注册焦点控件到组
func register_focusable(control: Control, group: String):
if not focus_groups.has(group):
focus_groups[group] = []
focus_groups[group].append(control)
## 注销焦点控件
func unregister_focusable(control: Control, group: String):
if focus_groups.has(group):
focus_groups[group].erase(control)
## 切换焦点组
func switch_focus_group(group: String):
if not focus_groups.has(group):
return
current_group = group
var controls = focus_groups[group]
if controls.size() > 0:
controls[0].grab_focus()
## 获取当前焦点组的下一个控件
func focus_next_in_group():
if current_group.is_empty():
return
var controls = focus_groups[current_group]
var current_focus = get_viewport().gui_get_focus_owner()
if not current_focus:
if controls.size() > 0:
controls[0].grab_focus()
return
var index = controls.find(current_focus)
if index >= 0:
var next_index = (index + 1) % controls.size()
controls[next_index].grab_focus()
## 焦点栈操作
func push_focus(control: Control):
var current = get_viewport().gui_get_focus_owner()
if current:
focus_stack.push_back(current)
control.grab_focus()
func pop_focus():
if focus_stack.size() > 0:
var previous = focus_stack.pop_back()
if is_instance_valid(previous):
previous.grab_focus()
## 清除焦点
func clear_focus():
var current = get_viewport().gui_get_focus_owner()
if current:
current.release_focus()
## 处理UI导航输入
func _input(event):
if event.is_action_pressed("ui_focus_next"):
focus_next_in_group()
get_viewport().set_input_as_handled()
32.3 基础UI控件
32.3.1 Label标签
# label_usage.gd
# Label控件使用
extends Control
@onready var label: Label = $Label
@onready var rich_label: RichTextLabel = $RichTextLabel
func _ready():
_setup_label()
_setup_rich_label()
func _setup_label():
# 基础文本
label.text = "Hello, Godot!"
# 水平对齐
label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
# 垂直对齐
label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
# 自动换行
label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
# 文本溢出处理
label.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
# 大写转换
label.uppercase = false
# 可见字符(用于打字机效果)
label.visible_ratio = 1.0
func _setup_rich_label():
# BBCode支持
rich_label.bbcode_enabled = true
# 设置富文本
rich_label.text = """[center][color=red]红色文字[/color][/center]
[b]粗体[/b] [i]斜体[/i] [u]下划线[/u]
[url=https://godotengine.org]链接[/url]
[img]res://icon.png[/img]
[code]代码块[/code]"""
# 链接点击处理
rich_label.meta_clicked.connect(_on_meta_clicked)
func _on_meta_clicked(meta):
print("点击了链接: ", meta)
OS.shell_open(str(meta))
## 打字机效果
func typewriter_effect(target_label: Label, text: String, speed: float = 0.05):
target_label.text = text
target_label.visible_ratio = 0.0
var tween = create_tween()
tween.tween_property(target_label, "visible_ratio", 1.0, text.length() * speed)
return tween
## 动态更新文本
func update_score(score: int):
label.text = "分数: %d" % score
func update_timer(seconds: float):
var minutes = int(seconds / 60)
var secs = int(seconds) % 60
label.text = "%02d:%02d" % [minutes, secs]
32.3.2 Button按钮
# button_usage.gd
# Button控件使用
extends Control
@onready var button: Button = $Button
@onready var texture_button: TextureButton = $TextureButton
@onready var check_button: CheckButton = $CheckButton
@onready var check_box: CheckBox = $CheckBox
func _ready():
_setup_button()
_setup_texture_button()
_setup_toggle_buttons()
func _setup_button():
button.text = "点击我"
button.icon = preload("res://icon.png")
button.icon_alignment = HORIZONTAL_ALIGNMENT_LEFT
button.expand_icon = true
# 连接信号
button.pressed.connect(_on_button_pressed)
button.button_down.connect(_on_button_down)
button.button_up.connect(_on_button_up)
button.toggled.connect(_on_button_toggled)
func _setup_texture_button():
# 设置不同状态的纹理
texture_button.texture_normal = preload("res://button_normal.png")
texture_button.texture_pressed = preload("res://button_pressed.png")
texture_button.texture_hover = preload("res://button_hover.png")
texture_button.texture_disabled = preload("res://button_disabled.png")
texture_button.texture_focused = preload("res://button_focused.png")
# 忽略纹理大小
texture_button.ignore_texture_size = true
texture_button.stretch_mode = TextureButton.STRETCH_KEEP_ASPECT_CENTERED
texture_button.pressed.connect(_on_texture_button_pressed)
func _setup_toggle_buttons():
# CheckButton(开关样式)
check_button.text = "启用音效"
check_button.button_pressed = true
check_button.toggled.connect(_on_sound_toggled)
# CheckBox(复选框样式)
check_box.text = "显示FPS"
check_box.toggled.connect(_on_fps_toggled)
## 信号处理
func _on_button_pressed():
print("按钮被按下")
func _on_button_down():
print("按钮按下中")
func _on_button_up():
print("按钮释放")
func _on_button_toggled(toggled_on: bool):
print("按钮切换: ", toggled_on)
func _on_texture_button_pressed():
print("纹理按钮被按下")
func _on_sound_toggled(enabled: bool):
# AudioManager.set_sound_enabled(enabled)
print("音效: ", "开启" if enabled else "关闭")
func _on_fps_toggled(enabled: bool):
# 显示/隐藏FPS
print("FPS显示: ", enabled)
## 按钮组(单选)
var button_group: ButtonGroup
func setup_radio_buttons(buttons: Array[BaseButton]):
button_group = ButtonGroup.new()
for btn in buttons:
btn.button_group = button_group
btn.toggle_mode = true
button_group.pressed.connect(_on_radio_selected)
func _on_radio_selected(button: BaseButton):
print("选中: ", button.name)
32.3.3 输入控件
# input_controls.gd
# 输入控件
extends Control
@onready var line_edit: LineEdit = $LineEdit
@onready var text_edit: TextEdit = $TextEdit
@onready var spin_box: SpinBox = $SpinBox
@onready var slider: HSlider = $HSlider
func _ready():
_setup_line_edit()
_setup_text_edit()
_setup_spin_box()
_setup_slider()
func _setup_line_edit():
# 占位符文本
line_edit.placeholder_text = "请输入用户名..."
# 最大长度
line_edit.max_length = 20
# 输入过滤
line_edit.text_changed.connect(_on_line_edit_changed)
line_edit.text_submitted.connect(_on_line_edit_submitted)
# 密码模式
# line_edit.secret = true
# line_edit.secret_character = "*"
# 可编辑
line_edit.editable = true
# 选择
line_edit.selecting_enabled = true
# 右键菜单
line_edit.context_menu_enabled = true
# 虚拟键盘(移动端)
line_edit.virtual_keyboard_enabled = true
line_edit.virtual_keyboard_type = LineEdit.KEYBOARD_TYPE_DEFAULT
func _setup_text_edit():
# 多行文本
text_edit.placeholder_text = "请输入内容..."
# 自动换行
text_edit.wrap_mode = TextEdit.LINE_WRAPPING_BOUNDARY
# 语法高亮(需要设置SyntaxHighlighter)
# text_edit.syntax_highlighter = CodeHighlighter.new()
# 信号
text_edit.text_changed.connect(_on_text_edit_changed)
func _setup_spin_box():
# 数值范围
spin_box.min_value = 0
spin_box.max_value = 100
spin_box.step = 1
spin_box.value = 50
# 前后缀
spin_box.prefix = "¥"
spin_box.suffix = " 元"
# 允许更大/更小的值
spin_box.allow_greater = false
spin_box.allow_lesser = false
# 可编辑
spin_box.editable = true
spin_box.value_changed.connect(_on_spin_box_changed)
func _setup_slider():
slider.min_value = 0
slider.max_value = 100
slider.step = 1
slider.value = 50
# 刻度
slider.tick_count = 11
slider.ticks_on_borders = true
# 可编辑
slider.editable = true
slider.value_changed.connect(_on_slider_changed)
## 信号处理
func _on_line_edit_changed(new_text: String):
print("文本改变: ", new_text)
# 输入验证示例
var valid = _validate_username(new_text)
line_edit.modulate = Color.WHITE if valid else Color.RED
func _on_line_edit_submitted(text: String):
print("提交文本: ", text)
func _on_text_edit_changed():
print("多行文本改变")
print("字数: ", text_edit.text.length())
func _on_spin_box_changed(value: float):
print("数值改变: ", value)
func _on_slider_changed(value: float):
print("滑块值: ", value)
func _validate_username(username: String) -> bool:
# 用户名验证:3-20字符,仅字母数字下划线
var regex = RegEx.new()
regex.compile("^[a-zA-Z0-9_]{3,20}$")
return regex.search(username) != null
32.4 容器节点
32.4.1 基础容器
# containers.gd
# 容器节点
extends Control
## 容器类型概述
##
## Container: 所有容器的基类
## BoxContainer: 线性排列(HBox/VBox)
## GridContainer: 网格排列
## FlowContainer: 流式排列
## CenterContainer: 居中容器
## MarginContainer: 边距容器
## PanelContainer: 面板容器
## ScrollContainer: 滚动容器
## SplitContainer: 分割容器
## TabContainer: 标签页容器
func _ready():
_setup_examples()
func _setup_examples():
_create_vbox_example()
_create_grid_example()
_create_scroll_example()
## VBoxContainer示例
func _create_vbox_example():
var vbox = VBoxContainer.new()
vbox.name = "VBoxExample"
# 分隔设置
vbox.add_theme_constant_override("separation", 10)
# 对齐方式
vbox.alignment = BoxContainer.ALIGNMENT_BEGIN
# 添加子控件
for i in range(5):
var button = Button.new()
button.text = "按钮 %d" % i
button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
vbox.add_child(button)
add_child(vbox)
## GridContainer示例
func _create_grid_example():
var grid = GridContainer.new()
grid.name = "GridExample"
grid.columns = 3
# 分隔设置
grid.add_theme_constant_override("h_separation", 5)
grid.add_theme_constant_override("v_separation", 5)
# 添加子控件
for i in range(9):
var button = Button.new()
button.text = "%d" % (i + 1)
button.custom_minimum_size = Vector2(50, 50)
grid.add_child(button)
add_child(grid)
## ScrollContainer示例
func _create_scroll_example():
var scroll = ScrollContainer.new()
scroll.name = "ScrollExample"
scroll.custom_minimum_size = Vector2(200, 150)
# 滚动设置
scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_AUTO
scroll.vertical_scroll_mode = ScrollContainer.SCROLL_MODE_AUTO
# 内容
var vbox = VBoxContainer.new()
for i in range(20):
var label = Label.new()
label.text = "项目 %d" % i
vbox.add_child(label)
scroll.add_child(vbox)
add_child(scroll)
32.4.2 高级容器
# advanced_containers.gd
# 高级容器
extends Control
## 标签页容器
func create_tab_container() -> TabContainer:
var tabs = TabContainer.new()
tabs.custom_minimum_size = Vector2(300, 200)
# 标签对齐
tabs.tab_alignment = TabBar.ALIGNMENT_CENTER
# 添加页面
var page1 = Control.new()
page1.name = "常规"
tabs.add_child(page1)
var page2 = Control.new()
page2.name = "高级"
tabs.add_child(page2)
var page3 = Control.new()
page3.name = "关于"
tabs.add_child(page3)
# 设置图标
tabs.set_tab_icon(0, preload("res://icon.png"))
# 禁用某个标签
tabs.set_tab_disabled(2, false)
# 信号
tabs.tab_changed.connect(func(tab): print("切换到标签: ", tab))
return tabs
## 分割容器
func create_split_container() -> HSplitContainer:
var split = HSplitContainer.new()
split.custom_minimum_size = Vector2(400, 200)
# 分割偏移
split.split_offset = 150
# 是否可拖动
split.dragger_visibility = SplitContainer.DRAGGER_VISIBLE
# 左侧面板
var left_panel = PanelContainer.new()
left_panel.custom_minimum_size = Vector2(100, 0)
split.add_child(left_panel)
# 右侧面板
var right_panel = PanelContainer.new()
split.add_child(right_panel)
# 信号
split.dragged.connect(func(offset): print("分割线拖动: ", offset))
return split
## 边距容器
func create_margin_container() -> MarginContainer:
var margin = MarginContainer.new()
# 设置边距
margin.add_theme_constant_override("margin_left", 20)
margin.add_theme_constant_override("margin_right", 20)
margin.add_theme_constant_override("margin_top", 10)
margin.add_theme_constant_override("margin_bottom", 10)
# 内容
var content = Label.new()
content.text = "带边距的内容"
margin.add_child(content)
return margin
## 面板容器
func create_panel_container() -> PanelContainer:
var panel = PanelContainer.new()
# 自定义样式
var style = StyleBoxFlat.new()
style.bg_color = Color(0.2, 0.2, 0.2)
style.corner_radius_top_left = 10
style.corner_radius_top_right = 10
style.corner_radius_bottom_left = 10
style.corner_radius_bottom_right = 10
style.border_width_left = 2
style.border_width_right = 2
style.border_width_top = 2
style.border_width_bottom = 2
style.border_color = Color(0.5, 0.5, 0.5)
panel.add_theme_stylebox_override("panel", style)
return panel
32.5 弹窗与对话框
32.5.1 Popup系统
# popup_system.gd
# 弹窗系统
extends Control
## 创建基础弹窗
func create_popup() -> Popup:
var popup = Popup.new()
popup.size = Vector2(200, 100)
# 弹窗设置
popup.popup_window = true
popup.transient = true # 临时窗口
popup.exclusive = true # 独占焦点
add_child(popup)
return popup
## 创建面板弹窗
func create_popup_panel() -> PopupPanel:
var popup = PopupPanel.new()
popup.size = Vector2(250, 150)
# 添加内容
var vbox = VBoxContainer.new()
var label = Label.new()
label.text = "这是一个弹窗面板"
vbox.add_child(label)
var button = Button.new()
button.text = "关闭"
button.pressed.connect(popup.hide)
vbox.add_child(button)
popup.add_child(vbox)
add_child(popup)
return popup
## 创建菜单弹窗
func create_popup_menu() -> PopupMenu:
var menu = PopupMenu.new()
# 添加菜单项
menu.add_item("新建", 0)
menu.add_item("打开", 1)
menu.add_separator()
menu.add_item("保存", 2)
menu.add_item("另存为...", 3)
menu.add_separator()
# 添加子菜单
var recent_menu = PopupMenu.new()
recent_menu.name = "RecentMenu"
recent_menu.add_item("文件1.txt", 0)
recent_menu.add_item("文件2.txt", 1)
menu.add_child(recent_menu)
menu.add_submenu_item("最近打开", "RecentMenu")
menu.add_separator()
menu.add_item("退出", 4)
# 快捷键
menu.set_item_shortcut(0, _create_shortcut(KEY_N, true)) # Ctrl+N
menu.set_item_shortcut(2, _create_shortcut(KEY_S, true)) # Ctrl+S
# 图标
menu.set_item_icon(0, preload("res://icon.png"))
# 禁用项
menu.set_item_disabled(3, true)
# 信号
menu.id_pressed.connect(_on_menu_item_pressed)
add_child(menu)
return menu
func _create_shortcut(key: Key, ctrl: bool = false) -> Shortcut:
var shortcut = Shortcut.new()
var event = InputEventKey.new()
event.keycode = key
event.ctrl_pressed = ctrl
shortcut.events = [event]
return shortcut
func _on_menu_item_pressed(id: int):
match id:
0: print("新建")
1: print("打开")
2: print("保存")
3: print("另存为")
4: get_tree().quit()
## 显示弹窗
func show_popup(popup: Popup, position: Vector2 = Vector2.ZERO):
if position == Vector2.ZERO:
popup.popup_centered()
else:
popup.popup(Rect2(position, popup.size))
## 右键菜单示例
func _gui_input(event):
if event is InputEventMouseButton:
if event.button_index == MOUSE_BUTTON_RIGHT and event.pressed:
var menu = create_popup_menu()
menu.position = get_global_mouse_position()
menu.popup()
32.5.2 对话框
# dialogs.gd
# 对话框
extends Control
## 确认对话框
func show_confirmation_dialog(
title: String,
message: String,
on_confirm: Callable,
on_cancel: Callable = Callable()
):
var dialog = ConfirmationDialog.new()
dialog.title = title
dialog.dialog_text = message
dialog.ok_button_text = "确定"
dialog.cancel_button_text = "取消"
dialog.confirmed.connect(func():
on_confirm.call()
dialog.queue_free()
)
dialog.canceled.connect(func():
if on_cancel.is_valid():
on_cancel.call()
dialog.queue_free()
)
add_child(dialog)
dialog.popup_centered()
## 接受对话框(只有确定按钮)
func show_alert_dialog(title: String, message: String):
var dialog = AcceptDialog.new()
dialog.title = title
dialog.dialog_text = message
dialog.ok_button_text = "确定"
dialog.confirmed.connect(dialog.queue_free)
add_child(dialog)
dialog.popup_centered()
## 文件对话框
func show_file_dialog(
mode: FileDialog.FileMode,
filters: PackedStringArray,
callback: Callable
):
var dialog = FileDialog.new()
dialog.file_mode = mode
dialog.access = FileDialog.ACCESS_FILESYSTEM
dialog.filters = filters
# 窗口设置
dialog.size = Vector2(600, 400)
dialog.title = _get_file_dialog_title(mode)
dialog.file_selected.connect(func(path):
callback.call(path)
dialog.queue_free()
)
dialog.files_selected.connect(func(paths):
callback.call(paths)
dialog.queue_free()
)
dialog.dir_selected.connect(func(dir):
callback.call(dir)
dialog.queue_free()
)
dialog.canceled.connect(dialog.queue_free)
add_child(dialog)
dialog.popup_centered()
func _get_file_dialog_title(mode: FileDialog.FileMode) -> String:
match mode:
FileDialog.FILE_MODE_OPEN_FILE: return "打开文件"
FileDialog.FILE_MODE_OPEN_FILES: return "打开文件(多选)"
FileDialog.FILE_MODE_OPEN_DIR: return "选择目录"
FileDialog.FILE_MODE_OPEN_ANY: return "打开"
FileDialog.FILE_MODE_SAVE_FILE: return "保存文件"
return "文件对话框"
## 颜色选择器
func show_color_picker(
initial_color: Color,
callback: Callable
):
var dialog = ColorPickerButton.new()
dialog.color = initial_color
dialog.edit_alpha = true
dialog.color_changed.connect(callback)
# 或者使用弹窗形式
var popup = PopupPanel.new()
var picker = ColorPicker.new()
picker.color = initial_color
picker.color_changed.connect(callback)
popup.add_child(picker)
add_child(popup)
popup.popup_centered()
## 自定义输入对话框
func show_input_dialog(
title: String,
message: String,
default_value: String,
callback: Callable
):
var dialog = ConfirmationDialog.new()
dialog.title = title
var vbox = VBoxContainer.new()
var label = Label.new()
label.text = message
vbox.add_child(label)
var line_edit = LineEdit.new()
line_edit.text = default_value
line_edit.custom_minimum_size = Vector2(200, 0)
vbox.add_child(line_edit)
dialog.add_child(vbox)
dialog.confirmed.connect(func():
callback.call(line_edit.text)
dialog.queue_free()
)
dialog.canceled.connect(dialog.queue_free)
add_child(dialog)
dialog.popup_centered()
# 自动聚焦输入框
line_edit.grab_focus()
32.6 实际案例:主菜单系统
# main_menu.gd
# 完整的主菜单系统
extends Control
## 菜单状态
enum MenuState {
MAIN,
OPTIONS,
CREDITS,
QUIT_CONFIRM
}
var current_state: MenuState = MenuState.MAIN
## UI引用
@onready var main_panel: Control = $MainPanel
@onready var options_panel: Control = $OptionsPanel
@onready var credits_panel: Control = $CreditsPanel
## 按钮引用
@onready var new_game_btn: Button = $MainPanel/VBox/NewGameButton
@onready var continue_btn: Button = $MainPanel/VBox/ContinueButton
@onready var options_btn: Button = $MainPanel/VBox/OptionsButton
@onready var credits_btn: Button = $MainPanel/VBox/CreditsButton
@onready var quit_btn: Button = $MainPanel/VBox/QuitButton
## 选项控件
@onready var master_volume: HSlider = $OptionsPanel/VBox/MasterVolume
@onready var music_volume: HSlider = $OptionsPanel/VBox/MusicVolume
@onready var sfx_volume: HSlider = $OptionsPanel/VBox/SFXVolume
@onready var fullscreen_check: CheckButton = $OptionsPanel/VBox/Fullscreen
@onready var vsync_check: CheckButton = $OptionsPanel/VBox/VSync
@onready var resolution_option: OptionButton = $OptionsPanel/VBox/Resolution
signal game_started
signal game_continued
func _ready():
_connect_signals()
_load_settings()
_setup_resolution_options()
_update_continue_button()
show_main_menu()
func _connect_signals():
# 主菜单按钮
new_game_btn.pressed.connect(_on_new_game)
continue_btn.pressed.connect(_on_continue)
options_btn.pressed.connect(_on_options)
credits_btn.pressed.connect(_on_credits)
quit_btn.pressed.connect(_on_quit)
# 选项控件
master_volume.value_changed.connect(_on_master_volume_changed)
music_volume.value_changed.connect(_on_music_volume_changed)
sfx_volume.value_changed.connect(_on_sfx_volume_changed)
fullscreen_check.toggled.connect(_on_fullscreen_toggled)
vsync_check.toggled.connect(_on_vsync_toggled)
resolution_option.item_selected.connect(_on_resolution_selected)
func _setup_resolution_options():
var resolutions = [
Vector2i(1280, 720),
Vector2i(1920, 1080),
Vector2i(2560, 1440),
Vector2i(3840, 2160)
]
for res in resolutions:
resolution_option.add_item("%dx%d" % [res.x, res.y])
func _load_settings():
# 从配置文件加载设置
var config = ConfigFile.new()
if config.load("user://settings.cfg") == OK:
master_volume.value = config.get_value("audio", "master", 100)
music_volume.value = config.get_value("audio", "music", 100)
sfx_volume.value = config.get_value("audio", "sfx", 100)
fullscreen_check.button_pressed = config.get_value("video", "fullscreen", false)
vsync_check.button_pressed = config.get_value("video", "vsync", true)
func _save_settings():
var config = ConfigFile.new()
config.set_value("audio", "master", master_volume.value)
config.set_value("audio", "music", music_volume.value)
config.set_value("audio", "sfx", sfx_volume.value)
config.set_value("video", "fullscreen", fullscreen_check.button_pressed)
config.set_value("video", "vsync", vsync_check.button_pressed)
config.save("user://settings.cfg")
func _update_continue_button():
# 检查是否有存档
continue_btn.disabled = not FileAccess.file_exists("user://savegame.dat")
## 状态切换
func show_main_menu():
current_state = MenuState.MAIN
_show_panel(main_panel)
func show_options():
current_state = MenuState.OPTIONS
_show_panel(options_panel)
func show_credits():
current_state = MenuState.CREDITS
_show_panel(credits_panel)
func _show_panel(panel: Control):
main_panel.visible = panel == main_panel
options_panel.visible = panel == options_panel
credits_panel.visible = panel == credits_panel
# 动画过渡
if panel.visible:
panel.modulate.a = 0
var tween = create_tween()
tween.tween_property(panel, "modulate:a", 1.0, 0.3)
## 按钮回调
func _on_new_game():
emit_signal("game_started")
# 可以添加过渡动画
_fade_out_and_start()
func _on_continue():
emit_signal("game_continued")
_fade_out_and_start()
func _on_options():
show_options()
func _on_credits():
show_credits()
func _on_quit():
# 显示确认对话框
var dialog = ConfirmationDialog.new()
dialog.dialog_text = "确定要退出游戏吗?"
dialog.confirmed.connect(func(): get_tree().quit())
add_child(dialog)
dialog.popup_centered()
func _on_back_to_main():
_save_settings()
show_main_menu()
## 设置回调
func _on_master_volume_changed(value: float):
AudioServer.set_bus_volume_db(0, linear_to_db(value / 100.0))
func _on_music_volume_changed(value: float):
var bus_idx = AudioServer.get_bus_index("Music")
if bus_idx >= 0:
AudioServer.set_bus_volume_db(bus_idx, linear_to_db(value / 100.0))
func _on_sfx_volume_changed(value: float):
var bus_idx = AudioServer.get_bus_index("SFX")
if bus_idx >= 0:
AudioServer.set_bus_volume_db(bus_idx, linear_to_db(value / 100.0))
func _on_fullscreen_toggled(enabled: bool):
if enabled:
DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_FULLSCREEN)
else:
DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_WINDOWED)
func _on_vsync_toggled(enabled: bool):
DisplayServer.window_set_vsync_mode(
DisplayServer.VSYNC_ENABLED if enabled else DisplayServer.VSYNC_DISABLED
)
func _on_resolution_selected(index: int):
var resolutions = [
Vector2i(1280, 720),
Vector2i(1920, 1080),
Vector2i(2560, 1440),
Vector2i(3840, 2160)
]
if index < resolutions.size():
DisplayServer.window_set_size(resolutions[index])
## 过渡动画
func _fade_out_and_start():
var tween = create_tween()
tween.tween_property(self, "modulate:a", 0.0, 0.5)
tween.tween_callback(func():
queue_free()
)
## 输入处理
func _input(event):
if event.is_action_pressed("ui_cancel"):
match current_state:
MenuState.OPTIONS, MenuState.CREDITS:
_on_back_to_main()
32.7 本章小结
本章全面介绍了Godot 4 UI系统的基础知识:
- Control节点基础:位置、大小、锚点、边距、大小标志
- 鼠标与焦点:鼠标过滤、焦点系统、焦点管理器
- 基础UI控件:Label、Button、输入控件
- 容器节点:BoxContainer、GridContainer、ScrollContainer等
- 弹窗与对话框:Popup、PopupMenu、文件对话框
- 实际案例:完整的主菜单系统
UI系统是游戏开发中不可或缺的部分,良好的UI设计能够显著提升用户体验。下一章我们将深入学习更多UI控件的详细用法。