第三十二章:UI系统基础

第三十二章: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系统的基础知识:

  1. Control节点基础:位置、大小、锚点、边距、大小标志
  2. 鼠标与焦点:鼠标过滤、焦点系统、焦点管理器
  3. 基础UI控件:Label、Button、输入控件
  4. 容器节点:BoxContainer、GridContainer、ScrollContainer等
  5. 弹窗与对话框:Popup、PopupMenu、文件对话框
  6. 实际案例:完整的主菜单系统

UI系统是游戏开发中不可或缺的部分,良好的UI设计能够显著提升用户体验。下一章我们将深入学习更多UI控件的详细用法。

← 返回目录