8. 游戏界面
2026/4/14大约 8 分钟
8. 俄罗斯方块——游戏界面
8.1 游戏界面是什么?
想象你走进一家餐厅:菜单告诉你有什么菜(主菜单),服务员告诉你今天的特色(HUD),如果需要等位会给你一个号码牌(暂停),吃完后服务员递上账单(游戏结束画面)。
游戏界面(UI)就是玩家和游戏之间的"沟通桥梁"——告诉玩家当前状态、提供操作选项、展示游戏结果。
8.2 界面整体布局
俄罗斯方块的标准界面布局如下:
┌──────────────────────────────────────────┐
│ 俄罗斯方块 │
├──────────────────────────────────────────┤
│ ┌──────┐ ┌──────────┐ ┌──────┐ │
│ │ HOLD │ │ │ │ NEXT │ │
│ │ │ │ 10 x 20 │ │ ■ │ │
│ │ │ │ 游戏区域 │ │ ■■ │ │
│ ├──────┤ │ │ │ ■ │ │
│ │ 分数 │ │ │ ├──────┤ │
│ │ 1200 │ │ │ │ 等级 │ │
│ ├──────┤ │ │ │ 3 │ │
│ │ 等级 │ │ │ ├──────┤ │
│ │ 3 │ │ │ │ 消行 │ │
│ ├──────┤ │ │ │ 25 │ │
│ │ 消行 │ │ │ └──────┘ │
│ │ 25 │ │ │ │
│ └──────┘ └──────────┘ │
│ [暂停] [操作说明] │
└──────────────────────────────────────────┘8.3 HUD(游戏内信息显示)
HUD(Heads-Up Display)是游戏进行时始终显示在屏幕上的信息,包括分数、等级、消行数等。
C
using Godot;
/// <summary>
/// HUD——游戏进行时的信息显示
/// </summary>
public partial class HUD : CanvasLayer
{
// 分数相关
private Label _scoreLabel;
private Label _highScoreLabel;
private int _displayedScore = 0;
// 等级相关
private Label _levelLabel;
// 消行相关
private Label _linesLabel;
// Combo显示
private Label _comboLabel;
private float _comboHideTimer = 0f;
public override void _Ready()
{
_scoreLabel = GetNode<Label>("ScorePanel/ScoreValue");
_highScoreLabel = GetNode<Label>("ScorePanel/HighScoreValue");
_levelLabel = GetNode<Label>("LevelPanel/LevelValue");
_linesLabel = GetNode<Label>("LinesPanel/LinesValue");
_comboLabel = GetNode<Label>("ComboDisplay");
// 连接信号
GameManager.Instance.ScoreChanged += OnScoreChanged;
GameManager.Instance.LevelChanged += OnLevelChanged;
GameManager.Instance.LinesCleared += OnLinesCleared;
// 加载最高分
int highScore = SaveSystem.LoadHighScore();
_highScoreLabel.Text = highScore.ToString();
}
public override void _Process(double delta)
{
// 分数滚动动画(数字平滑递增)
if (_displayedScore < GameManager.Instance.Score)
{
int diff = GameManager.Instance.Score - _displayedScore;
int step = Mathf.Max(1, diff / 10);
_displayedScore = Mathf.Min(_displayedScore + step, GameManager.Instance.Score);
_scoreLabel.Text = _displayedScore.ToString("N0");
}
// Combo显示计时
if (_comboHideTimer > 0)
{
_comboHideTimer -= (float)delta;
if (_comboHideTimer <= 0)
{
_comboLabel.Visible = false;
}
}
}
private void OnScoreChanged(int newScore)
{
// 触发滚动动画(在_Process中执行)
}
private void OnLevelChanged(int newLevel)
{
_levelLabel.Text = newLevel.ToString();
// 等级变化的缩放动画
_AnimateLabel(_levelLabel);
}
private void OnLinesCleared(int lines)
{
_linesLabel.Text = GameManager.Instance.TotalLinesCleared.ToString();
}
/// <summary>
/// 显示Combo信息
/// </summary>
public void ShowCombo(int comboCount)
{
if (comboCount <= 1) return;
_comboLabel.Text = $"{comboCount} COMBO!";
_comboLabel.Visible = true;
_comboHideTimer = 2.0f;
// 缩放动画
var tween = CreateTween();
tween.TweenProperty(_comboLabel, "scale",
new Vector2(1.5f, 1.5f), 0.1f);
tween.TweenProperty(_comboLabel, "scale",
Vector2.One, 0.2f);
}
private void _AnimateLabel(Control label)
{
var tween = CreateTween();
tween.TweenProperty(label, "scale",
new Vector2(1.3f, 1.3f), 0.1f);
tween.TweenProperty(label, "scale",
Vector2.One, 0.2f);
}
}GDScript
extends CanvasLayer
## HUD——游戏进行时的信息显示
# 分数相关
@onready var _score_label = $ScorePanel/ScoreValue
@onready var _high_score_label = $ScorePanel/HighScoreValue
var _displayed_score: int = 0
# 等级相关
@onready var _level_label = $LevelPanel/LevelValue
# 消行相关
@onready var _lines_label = $LinesPanel/LinesValue
# Combo显示
@onready var _combo_label = $ComboDisplay
var _combo_hide_timer: float = 0.0
func _ready() -> void:
# 连接信号
GameManager.score_changed.connect(_on_score_changed)
GameManager.level_changed.connect(_on_level_changed)
GameManager.lines_cleared.connect(_on_lines_cleared)
# 加载最高分
var high_score = SaveSystem.load_high_score()
_high_score_label.text = str(high_score)
func _process(delta: float) -> void:
# 分数滚动动画(数字平滑递增)
if _displayed_score < GameManager.score:
var diff = GameManager.score - _displayed_score
var step = maxi(1, diff / 10)
_displayed_score = mini(_displayed_score + step, GameManager.score)
_score_label.text = "%d" % _displayed_score
# Combo显示计时
if _combo_hide_timer > 0:
_combo_hide_timer -= delta
if _combo_hide_timer <= 0:
_combo_label.visible = false
func _on_score_changed(new_score: int) -> void:
pass # 触发滚动动画(在_process中执行)
func _on_level_changed(new_level: int) -> void:
_level_label.text = str(new_level)
_animate_label(_level_label)
func _on_lines_cleared(lines_count: int) -> void:
_lines_label.text = str(GameManager.total_lines_cleared)
## 显示Combo信息
func show_combo(combo_count: int) -> void:
if combo_count <= 1:
return
_combo_label.text = "%d COMBO!" % combo_count
_combo_label.visible = true
_combo_hide_timer = 2.0
# 缩放动画
var tween = create_tween()
tween.tween_property(_combo_label, "scale",
Vector2(1.5, 1.5), 0.1)
tween.tween_property(_combo_label, "scale",
Vector2.ONE, 0.2)
func _animate_label(label: Control) -> void:
var tween = create_tween()
tween.tween_property(label, "scale",
Vector2(1.3, 1.3), 0.1)
tween.tween_property(label, "scale",
Vector2.ONE, 0.2)8.4 暂停菜单
暂停菜单在玩家按ESC或P键时弹出,提供"继续游戏"和"返回主菜单"选项。
C
using Godot;
/// <summary>
/// 暂停菜单
/// </summary>
public partial class PauseMenu : CanvasLayer
{
private PanelContainer _panel;
private Button _resumeButton;
private Button _restartButton;
private Button _quitButton;
private Label _titleLabel;
public override void _Ready()
{
_panel = GetNode<PanelContainer>("Panel");
_titleLabel = GetNode<Label>("Panel/VBox/Title");
_resumeButton = GetNode<Button>("Panel/VBox/ResumeButton");
_restartButton = GetNode<Button>("Panel/VBox/RestartButton");
_quitButton = GetNode<Button>("Panel/VBox/QuitButton");
// 连接按钮信号
_resumeButton.Pressed += OnResumePressed;
_restartButton.Pressed += OnRestartPressed;
_quitButton.Pressed += OnQuitPressed;
// 默认隐藏
Visible = false;
// 监听游戏状态变化
GameManager.Instance.StateChanged += OnStateChanged;
}
private void OnStateChanged(int newState)
{
var state = (GameManager.GameState)newState;
Visible = (state == GameManager.GameState.Paused);
if (Visible)
{
_resumeButton.GrabFocus();
}
}
private void OnResumePressed()
{
GameManager.Instance.TogglePause();
}
private void OnRestartPressed()
{
GameManager.Instance.TogglePause();
GameManager.Instance.StartGame();
GetTree().ReloadCurrentScene();
}
private void OnQuitPressed()
{
GameManager.Instance.ReturnToMenu();
GetTree().ChangeSceneToFile("res://scenes/ui/main_menu.tscn");
}
}GDScript
extends CanvasLayer
## 暂停菜单
@onready var _panel = $Panel
@onready var _title_label = $Panel/VBox/Title
@onready var _resume_button = $Panel/VBox/ResumeButton
@onready var _restart_button = $Panel/VBox/RestartButton
@onready var _quit_button = $Panel/VBox/QuitButton
func _ready() -> void:
# 连接按钮信号
_resume_button.pressed.connect(_on_resume_pressed)
_restart_button.pressed.connect(_on_restart_pressed)
_quit_button.pressed.connect(_on_quit_pressed)
# 默认隐藏
visible = false
# 监听游戏状态变化
GameManager.state_changed.connect(_on_state_changed)
func _on_state_changed(new_state: int) -> void:
var state = new_state
visible = (state == GameManager.GameState.PAUSED)
if visible:
_resume_button.grab_focus()
func _on_resume_pressed() -> void:
GameManager.toggle_pause()
func _on_restart_pressed() -> void:
GameManager.toggle_pause()
GameManager.start_game()
get_tree().reload_current_scene()
func _on_quit_pressed() -> void:
GameManager.return_to_menu()
get_tree().change_scene_to_file("res://scenes/ui/main_menu.tscn")8.5 游戏结束画面
游戏结束时显示最终成绩、最高分和操作选项。
C
using Godot;
/// <summary>
/// 游戏结束画面
/// </summary>
public partial class GameOverScreen : CanvasLayer
{
private PanelContainer _panel;
private Label _titleLabel;
private Label _finalScoreLabel;
private Label _highScoreLabel;
private Label _newRecordLabel;
private Button _retryButton;
private Button _menuButton;
private bool _isNewRecord = false;
public override void _Ready()
{
_panel = GetNode<PanelContainer>("Panel");
_titleLabel = GetNode<Label>("Panel/VBox/Title");
_finalScoreLabel = GetNode<Label>("Panel/VBox/ScoreValue");
_highScoreLabel = GetNode<Label>("Panel/VBox/HighScoreValue");
_newRecordLabel = GetNode<Label>("Panel/VBox/NewRecord");
_retryButton = GetNode<Button>("Panel/VBox/RetryButton");
_menuButton = GetNode<Button>("Panel/VBox/MenuButton");
_retryButton.Pressed += OnRetryPressed;
_menuButton.Pressed += OnMenuPressed;
Visible = false;
_newRecordLabel.Visible = false;
GameManager.Instance.StateChanged += OnStateChanged;
}
private void OnStateChanged(int newState)
{
var state = (GameManager.GameState)newState;
if (state == GameManager.GameState.GameOver)
{
ShowGameOver();
}
else
{
Visible = false;
}
}
/// <summary>
/// 显示游戏结束画面
/// </summary>
private void ShowGameOver()
{
Visible = true;
int score = GameManager.Instance.Score;
// 显示最终分数
_finalScoreLabel.Text = score.ToString("N0");
// 检查是否刷新最高分
int highScore = SaveSystem.LoadHighScore();
_isNewRecord = score > highScore;
if (_isNewRecord)
{
SaveSystem.SaveHighScore(score);
highScore = score;
_newRecordLabel.Visible = true;
_titleLabel.Text = "新纪录!";
// 新纪录闪烁动画
var tween = CreateTween();
tween.SetLoops();
tween.TweenProperty(_newRecordLabel, "modulate:a", 0.3f, 0.5f);
tween.TweenProperty(_newRecordLabel, "modulate:a", 1.0f, 0.5f);
}
else
{
_newRecordLabel.Visible = false;
_titleLabel.Text = "游戏结束";
}
_highScoreLabel.Text = highScore.ToString("N0");
// 入场动画
_panel.Scale = new Vector2(0.8f, 0.8f);
_panel.Modulate = new Color(1, 1, 1, 0);
var animTween = CreateTween();
animTween.TweenProperty(_panel, "scale",
Vector2.One, 0.3f).SetTrans(Tween.TransitionType.Back);
animTween.Parallel().TweenProperty(_panel, "modulate:a",
1.0f, 0.3f);
_retryButton.GrabFocus();
}
private void OnRetryPressed()
{
GameManager.Instance.StartGame();
GetTree().ReloadCurrentScene();
}
private void OnMenuPressed()
{
GameManager.Instance.ReturnToMenu();
GetTree().ChangeSceneToFile("res://scenes/ui/main_menu.tscn");
}
}GDScript
extends CanvasLayer
## 游戏结束画面
@onready var _panel = $Panel
@onready var _title_label = $Panel/VBox/Title
@onready var _final_score_label = $Panel/VBox/ScoreValue
@onready var _high_score_label = $Panel/VBox/HighScoreValue
@onready var _new_record_label = $Panel/VBox/NewRecord
@onready var _retry_button = $Panel/VBox/RetryButton
@onready var _menu_button = $Panel/VBox/MenuButton
var _is_new_record: bool = false
func _ready() -> void:
_retry_button.pressed.connect(_on_retry_pressed)
_menu_button.pressed.connect(_on_menu_pressed)
visible = false
_new_record_label.visible = false
GameManager.state_changed.connect(_on_state_changed)
func _on_state_changed(new_state: int) -> void:
if new_state == GameManager.GameState.GAME_OVER:
_show_game_over()
else:
visible = false
## 显示游戏结束画面
func _show_game_over() -> void:
visible = true
var score = GameManager.score
# 显示最终分数
_final_score_label.text = "%d" % score
# 检查是否刷新最高分
var high_score = SaveSystem.load_high_score()
_is_new_record = score > high_score
if _is_new_record:
SaveSystem.save_high_score(score)
high_score = score
_new_record_label.visible = true
_title_label.text = "新纪录!"
# 新纪录闪烁动画
var tween = create_tween()
tween.set_loops()
tween.tween_property(_new_record_label, "modulate:a", 0.3, 0.5)
tween.tween_property(_new_record_label, "modulate:a", 1.0, 0.5)
else:
_new_record_label.visible = false
_title_label.text = "游戏结束"
_high_score_label.text = "%d" % high_score
# 入场动画
_panel.scale = Vector2(0.8, 0.8)
_panel.modulate = Color(1, 1, 1, 0)
var anim_tween = create_tween()
anim_tween.tween_property(_panel, "scale",
Vector2.ONE, 0.3).set_trans(Tween.TransitionType.BACK)
anim_tween.parallel().tween_property(_panel, "modulate:a",
1.0, 0.3)
_retry_button.grab_focus()
func _on_retry_pressed() -> void:
GameManager.start_game()
get_tree().reload_current_scene()
func _on_menu_pressed() -> void:
GameManager.return_to_menu()
get_tree().change_scene_to_file("res://scenes/ui/main_menu.tscn")8.6 主菜单
主菜单是游戏的"门面"——玩家启动游戏后第一个看到的画面。
C
using Godot;
/// <summary>
/// 主菜单
/// </summary>
public partial class MainMenu : CanvasLayer
{
private Button _startButton;
private Button _settingsButton;
private Button _quitButton;
private Label _titleLabel;
private Label _versionLabel;
public override void _Ready()
{
_startButton = GetNode<Button>("Panel/VBox/StartButton");
_settingsButton = GetNode<Button>("Panel/VBox/SettingsButton");
_quitButton = GetNode<Button>("Panel/VBox/QuitButton");
_titleLabel = GetNode<Label>("Panel/VBox/Title");
_versionLabel = GetNode<Label>("Panel/Version");
_startButton.Pressed += OnStartPressed;
_settingsButton.Pressed += OnSettingsPressed;
_quitButton.Pressed += OnQuitPressed;
_versionLabel.Text = "v1.0.0";
// 默认焦点在"开始游戏"按钮上
_startButton.GrabFocus();
// 标题呼吸动画
var tween = CreateTween().SetLoops();
tween.TweenProperty(_titleLabel, "modulate:a", 0.7f, 2.0f);
tween.TweenProperty(_titleLabel, "modulate:a", 1.0f, 2.0f);
}
private void OnStartPressed()
{
GameManager.Instance.StartGame();
GetTree().ChangeSceneToFile("res://scenes/main.tscn");
}
private void OnSettingsPressed()
{
// 打开设置界面(后续章节实现)
GD.Print("打开设置");
}
private void OnQuitPressed()
{
GetTree().Quit();
}
}GDScript
extends CanvasLayer
## 主菜单
@onready var _start_button = $Panel/VBox/StartButton
@onready var _settings_button = $Panel/VBox/SettingsButton
@onready var _quit_button = $Panel/VBox/QuitButton
@onready var _title_label = $Panel/VBox/Title
@onready var _version_label = $Panel/Version
func _ready() -> void:
_start_button.pressed.connect(_on_start_pressed)
_settings_button.pressed.connect(_on_settings_pressed)
_quit_button.pressed.connect(_on_quit_pressed)
_version_label.text = "v1.0.0"
# 默认焦点在"开始游戏"按钮上
_start_button.grab_focus()
# 标题呼吸动画
var tween = create_tween().set_loops()
tween.tween_property(_title_label, "modulate:a", 0.7, 2.0)
tween.tween_property(_title_label, "modulate:a", 1.0, 2.0)
func _on_start_pressed() -> void:
GameManager.start_game()
get_tree().change_scene_to_file("res://scenes/main.tscn")
func _on_settings_pressed() -> void:
print("打开设置")
func _on_quit_pressed() -> void:
get_tree().quit()8.7 UI主题与样式
为了让游戏界面看起来更专业,我们使用Godot的Theme(主题)系统来统一样式。
C
// 使用代码创建简单的主题资源
// (实际项目中建议在编辑器中创建.tres主题文件)
public partial class GameTheme : Node
{
/// <summary>
/// 创建并返回游戏UI主题
/// </summary>
public static Theme CreateTheme()
{
var theme = new Theme();
// 默认字体
var font = new FontFile(); // 加载字体文件
// font.Load("res://assets/fonts/main_font.ttf");
// 标题样式
var titleStyle = new StyleBoxFlat();
titleStyle.BgColor = new Color(0.1f, 0.1f, 0.2f, 0.9f);
titleStyle.BorderColor = new Color(0.3f, 0.3f, 0.5f);
titleStyle.BorderWidthTop = 2;
titleStyle.BorderWidthBottom = 2;
titleStyle.SetCornerRadiusAll(8);
// 按钮样式
var buttonNormal = new StyleBoxFlat();
buttonNormal.BgColor = new Color(0.2f, 0.3f, 0.5f, 0.9f);
buttonNormal.SetCornerRadiusAll(4);
var buttonHover = new StyleBoxFlat();
buttonHover.BgColor = new Color(0.3f, 0.4f, 0.6f, 0.9f);
buttonHover.SetCornerRadiusAll(4);
var buttonPressed = new StyleBoxFlat();
buttonPressed.BgColor = new Color(0.15f, 0.25f, 0.45f, 0.9f);
buttonPressed.SetCornerRadiusAll(4);
// 设置主题
theme.SetStylebox("panel", "PanelContainer", titleStyle);
theme.SetStylebox("normal", "Button", buttonNormal);
theme.SetStylebox("hover", "Button", buttonHover);
theme.SetStylebox("pressed", "Button", buttonPressed);
return theme;
}
}GDScript
extends Node
## UI主题管理器
## 创建并返回游戏UI主题
static func create_theme() -> Theme:
var theme = Theme.new()
# 面板样式
var panel_style = StyleBoxFlat.new()
panel_style.bg_color = Color(0.1, 0.1, 0.2, 0.9)
panel_style.border_color = Color(0.3, 0.3, 0.5)
panel_style.border_width_top = 2
panel_style.border_width_bottom = 2
panel_style.set_corner_radius_all(8)
# 按钮样式
var button_normal = StyleBoxFlat.new()
button_normal.bg_color = Color(0.2, 0.3, 0.5, 0.9)
button_normal.set_corner_radius_all(4)
var button_hover = StyleBoxFlat.new()
button_hover.bg_color = Color(0.3, 0.4, 0.6, 0.9)
button_hover.set_corner_radius_all(4)
var button_pressed = StyleBoxFlat.new()
button_pressed.bg_color = Color(0.15, 0.25, 0.45, 0.9)
button_pressed.set_corner_radius_all(4)
# 设置主题
theme.set_stylebox("panel", "PanelContainer", panel_style)
theme.set_stylebox("normal", "Button", button_normal)
theme.set_stylebox("hover", "Button", button_hover)
theme.set_stylebox("pressed", "Button", button_pressed)
return theme8.8 操作说明界面
一个好的游戏应该让玩家随时能查看操作说明。
| 按键 | 功能 |
|---|---|
| ← → | 左右移动 |
| ↑ / X | 顺时针旋转 |
| Z | 逆时针旋转 |
| ↓ | 软降(加速下落) |
| 空格 | 硬降(直接落到底) |
| C | 暂存方块 |
| P / ESC | 暂停游戏 |
8.9 本章小结
| 界面组件 | 说明 |
|---|---|
| HUD | 显示分数、等级、消行数,带滚动和缩放动画 |
| 暂停菜单 | 继续游戏、重新开始、返回菜单 |
| 游戏结束画面 | 最终分数、最高分、新纪录提示 |
| 主菜单 | 开始游戏、设置、退出 |
| UI主题 | 统一的视觉风格 |
| 操作说明 | 按键映射说明 |
关键设计点:
- 分数变化时有滚动动画,让玩家感受到分数在涨
- 等级变化时有缩放动画,吸引注意力
- 新纪录时有闪烁效果,增强成就感
- 所有菜单按钮支持键盘焦点导航
下一章我们将添加音效和特效,让游戏更有感觉。
