教程一 · 第 7 章:UI 与游戏流程
Godot 的 UI 系统(Control 节点体系)功能极强,也有自己的一套规则。本章做完整的游戏外壳:主菜单 → 关卡 → 暂停 → 胜利结算 → 设置(音量)。
7.1 Control 体系心智模型
所有 UI 节点都继承 Control,它和 Node2D 最大的不同:拥有锚点(Anchor)、容器(Container)、焦点(Focus)三大布局机制。
常用节点:
| 节点 | 用途 |
|---|---|
| Label | 文字 |
| Button | 按钮(子类 CheckButton、TextureButton) |
| LineEdit / TextEdit | 单行输入 / 多行文本 |
| ProgressBar / TextureProgressBar | 血条、进度条 |
| ColorRect / TextureRect | 色块 / 图片 |
| Panel / PanelContainer | 背景板 |
| HBoxContainer / VBoxContainer | 横向/纵向自动排列子节点 |
| GridContainer | 网格排列(背包、关卡选择) |
| MarginContainer / CenterContainer | 留白 / 居中 |
| Control | 纯容器/自定义绘制基类 |
第一规则:UI 放在 CanvasLayer 下。 UI 是屏幕空间的东西,不该跟着游戏世界和摄像机移动。HUD、菜单各放一个 CanvasLayer(Layer 值越大越靠上,暂停菜单可以盖过 HUD)。
7.2 布局:锚点与容器
锚点(Anchor):选中 Control,检查器 Layout 下拉有现成预设:Top Wide(顶部横条)、Center、Full Rect(铺满)等。锚点决定”父节点尺寸变化时我怎么贴”。
容器(Container):子 Control 的位置和尺寸由容器自动管理,你手动拖会被覆盖。
VBoxContainer:子节点从上到下排,加 Separation 控制间距HBoxContainer:从左到右MarginContainer:四周留白(Theme Override Constants 调边距)CenterContainer:唯一子节点居中
经典菜单结构(布局稳、自适应分辨率):
MainMenu(Control,Layout = Full Rect)└── CenterContainer(Full Rect) └── VBoxContainer ├── Label "我的游戏"(大字号) ├── Button "开始游戏" ├── Button "设置" └── Button "退出"按钮宽度统一:给 VBox 下的 Button 设置 Custom Minimum Size,或在 VBoxContainer 里它们默认等宽拉伸。
7.3 主菜单脚本
菜单场景 scenes/ui/main_menu.tscn,根 Control 挂脚本:
using Godot;
public partial class MainMenu : Control{ public override void _Ready() { GetNode<Button>("CenterContainer/VBox/StartButton").Pressed += OnStart; GetNode<Button>("CenterContainer/VBox/SettingsButton").Pressed += OnSettings; GetNode<Button>("CenterContainer/VBox/QuitButton").Pressed += OnQuit; }
private void OnStart() { GetTree().ChangeSceneToFile("res://scenes/level1.tscn"); }
private void OnSettings() { GetNode<Control>("SettingsPanel").Visible = true; }
private void OnQuit() { GetTree().Quit(); // 注意:编辑器里运行时 Quit 不会关编辑器窗口,这是正常的(发布版才退出游戏) }}让主菜单成为项目主场景(项目设置 → 应用 → 运行 → 主场景)。游戏从菜单启动,而不是直接冲进关卡。
7.4 HUD:血条与分数
HUD(CanvasLayer)└── MarginContainer(Top Wide,四周 16px 边距) └── HBoxContainer ├── Label "HP" ├── TextureProgressBar(或 ProgressBar)命名 HpBar,Min=0 Max=100 └── Label 命名 CoinLabel订阅玩家信号(第 5 章模式):
public partial class HUD : CanvasLayer{ private ProgressBar _hpBar = null!; private Label _coinLabel = null!;
public override void _Ready() { _hpBar = GetNode<ProgressBar>("MarginContainer/HBox/HpBar"); _coinLabel = GetNode<Label>("MarginContainer/HBox/CoinLabel");
var player = GetTree().GetFirstNodeInGroup("player"); if (player is Player p) { p.HealthChanged += hp => _hpBar.Value = hp; }
if (GameManager.Instance != null) GameManager.Instance.CoinsChanged += c => _coinLabel.Text = $"金币 {c}"; }}字符串插值 $"金币 {c}" 是 C# 6 语法,教程二第 3 章细讲,UI 文本几乎全用它。
血条想更花哨:TextureProgressBar 三张图(底、填充、高光);或者用 Tween 让数字滚动:
CreateTween() .TweenProperty(_hpBar, "value", (double)hp, 0.25f) .SetTrans(Tween.TransitionType.Quad) .SetEase(Tween.EaseType.Out);7.5 Tween:让 UI 动起来
Tween(补间)= “在 X 秒内把属性从当前值平滑变到目标值”,UI 动画 90% 靠它,不用碰 AnimationPlayer:
// 面板淡入(需要面板 Modulate 初始 alpha=0)panel.Modulate = panel.Modulate with { A = 0f };panel.Show();CreateTween().TweenProperty(panel, "modulate:a", 1f, 0.2f);
// 伤害数字飘起淡出var label = ...;var tween = CreateTween();tween.SetParallel(true);tween.TweenProperty(label, "position:y", label.Position.Y - 60f, 0.8f);tween.TweenProperty(label, "modulate:a", 0f, 0.8f);tween.Chain().TweenCallback(Callable.From(label.QueueFree));SetParallel(true)后续动画同时播放;Chain()切回顺序modulate:a这种”属性:子属性”路径可以只动画透明度TweenCallback在动画里插函数调用- 结束节点释放:
tween.TweenCallback(Callable.From(() => QueueFree()))
7.6 暂停菜单
暂停的本质是停掉 SceneTree 的处理,但菜单自己还能动:
public partial class PauseMenu : CanvasLayer{ public override void _Ready() => Visible = false;
public override void _UnhandledInput(InputEvent @event) { if (Input.IsActionJustPressed("pause")) // 自己加个输入动作绑 Esc Toggle(); }
private void Toggle() { bool paused = !GetTree().Paused; GetTree().Paused = paused; Visible = paused; }}关键配置:PauseMenu 根节点(CanvasLayer/Control)检查器 Process → Mode 设为 Always,否则树暂停后它自己也收不到输入、按钮点不动。游戏世界节点保持默认 Inherit/Pausable,暂停时它们全部冻结。
按钮:
- 继续:
GetTree().Paused = false; Visible = false; - 回主菜单:先
GetTree().Paused = false;再 ChangeScene(暂停状态切场景会让新场景也冻结,踩过的人都记得) - 退出:Quit()
焦点与手柄:暂停菜单打开时设一个默认按钮焦点:
GetNode<Button>("Panel/ResumeButton").GrabFocus();用方向键/手柄在按钮间移动是 Control 内置能力,零代码,但要检查 Theme 里有 Focus 样式否则看不出选中了谁。
7.7 游戏流程状态机
主菜单、游玩、暂停、胜利、死亡,流程切换最好集中管理。建 Autoload SceneManager:
public partial class SceneManager : Node{ [Signal] public delegate void GameStateChangedEventHandler(StringName state);
public StringName State { get; private set; } = "boot";
public void SetState(StringName newState) { State = newState; EmitSignal(SignalName.GameStateChanged, newState); GetTree().Paused = newState is "paused" or "gameover"; }
public async void ChangeScene(string path) { SetState("loading"); // 这里可以弹一个过场画面 await ToSignal(GetTree(), SceneTree.SignalName.ProcessFrame); GetTree().Paused = false; GetTree().ChangeSceneToFile(path); }}is "paused" or "gameover" 是 C# 9 的模式匹配(教程二第 2 章)。
为什么要显式状态:否则”玩家死了但暂停菜单开着""胜利结算动画还没完就重开”这类组合 bug 层出不穷。状态集中一处,各系统订阅状态信号自行反应。
简单项目不用搞这么正式——菜单和关卡各一个场景、一个暂停层就够。知道有这个台阶,需要时再上。
7.8 设置界面:音量、全屏、按键
音频总线(Audio Bus)
Godot 音量不是直接调播放器,而是调总线(底部面板 Audio:Master、SFX、Music…):
// 线性滑块 0..1 转分贝private float LinearToDb(float v) => v <= 0.001f ? -80f : Mathf.LinearToDb(v);
public void OnMasterSliderChanged(float v) => AudioServer.SetBusVolumeDb(AudioServer.GetBusIndex("Master"), LinearToDb(v));Slider 的 ValueChanged 信号连过来。设置值存存档(第 6 章 SaveData 加 MasterVolume 字段),启动时读回设置总线。
全屏/分辨率
DisplayServer.WindowSetMode( fullscreen ? DisplayServer.WindowMode.Fullscreen : DisplayServer.WindowMode.Windowed);项目设置里把窗口 Stretch Mode 设为 canvas_items + Aspect expand,做自适应最省心;UI 用锚点+容器铺满,16:9 和 21:9 都不会崩。
按键重绑定
// 监听下一个按键输入并替换动作绑定public override async void _UnhandledInput(InputEvent @event){ if (@event is InputEventKey key && key.Pressed && !key.Echo) { InputMap.ActionEraseEvents("jump"); InputMap.ActionAddEvent("jump", new InputEventKey { PhysicalKeycode = key.PhysicalKeycode }); // 存存档时遍历 InputMap 保存 }}7.9 场景过渡(Loading/淡入淡出)
做一个 Autoload TransitionLayer(CanvasLayer,ProcessMode=Always):
public partial class TransitionLayer : CanvasLayer{ private ColorRect _fade = null!;
public override void _Ready() { _fade = new ColorRect { Color = new Color(0,0,0,0), MouseFilter = Control.MouseFilterEnum.Ignore }; _fade.SetAnchorsPreset(Control.LayoutPreset.FullRect); AddChild(_fade); }
public async Task FadeToScene(string path, float time = 0.3f) { _fade.MouseFilter = Control.MouseFilterEnum.Stop; // 挡住误操作 await CreateTween().TweenProperty(_fade, "color:a", 1f, time).ToSignal(Tween.SignalName.Finished); GetTree().ChangeSceneToFile(path); await ToSignal(GetTree(), SceneTree.SignalName.ProcessFrame); await CreateTween().TweenProperty(_fade, "color:a", 0f, time).ToSignal(Tween.SignalName.Finished); _fade.MouseFilter = Control.MouseFilterEnum.Ignore; }}调用:await TransitionLayer.Instance.FadeToScene("res://scenes/level2.tscn");(普通 async 方法用 Task,await 它的节点方法可以正常等待)。
大关卡加载卡顿明显时,用 ResourceInteractiveLoader 分帧加载显示进度条,等项目真有需求再查。
7.10 本章检查清单
做完游戏外壳,逐项确认:
- 启动进主菜单,而不是直接进关卡
- 菜单按钮全部有响应,退出在发布版可用
- HUD 用信号更新,不在
_Process里刷文本 - Esc 暂停时世界冻结、菜单可操作、再按恢复
- 暂停中切场景不会冻住新场景
- 音量设置存盘,重启后保留
- 分辨率变化时 UI 不错位(拖窗口测试)
- 键盘能操作菜单(GrabFocus,为以后手柄铺路)
第 8 章讲”果汁感”:音效、粒子、屏幕震动,以及性能分析与调试;第 9 章导出发布。
《Godot C# 零基础》系列导航
⬅️ 上一篇:Godot C# 零基础教程 06:Resource 数据驱动、动态实例化与存档 📑 返回系列总目录(第 01 篇) ➡️ 下一篇:Godot C# 零基础教程 08:音效、粒子、屏幕震动与调试