8. 游戏界面
2026/4/14大约 8 分钟
8. 坦克大战——游戏界面
简介
游戏界面(UI,User Interface)就是你在玩游戏时看到的所有非游戏内容——顶部的分数显示、右边的敌人剩余数量、暂停菜单、关卡选择界面等。
你可以把UI想象成电视机的遥控器——电视机播放的是游戏画面,遥控器上的按钮和显示屏就是UI。UI不参与游戏逻辑,但它让玩家知道"现在发生了什么"。
界面布局
坦克大战的游戏界面分为几个区域:
┌─────────────────────────────────────┐
│ 分数: 12500 关卡: 1 生命: ♥♥♥ │ ← HUD(抬头显示)
├──────────────────────────┬──────────┤
│ │ 敌人剩余 │
│ │ ■■■■ │
│ 游戏画面区域 │ ■■■■ │
│ (13x13 地图) │ ■■■■ │
│ │ ■■■■ │
│ │ ■■■■ │
│ │ │
│ │ │
└──────────────────────────┴──────────┘| 区域 | 内容 | 说明 |
|---|---|---|
| HUD(顶部) | 分数、关卡、生命数 | 实时更新的游戏数据 |
| 游戏画面(中间) | 地图、坦克、子弹 | 主要的游戏区域 |
| 敌人列表(右侧) | 小坦克图标 | 显示剩余敌人数量 |
HUD(抬头显示)
HUD就像汽车仪表盘——开车时你需要看速度、油量、里程数,打游戏时你需要看分数、生命、关卡信息。
HUD 场景结构
HUD (CanvasLayer) # CanvasLayer 保证UI始终在画面最上层
├── TopBar (HBoxContainer) # 顶部信息栏
│ ├── ScorePanel (VBoxContainer) # 分数区域
│ │ ├── ScoreLabel (Label) # "SCORE"
│ │ └── ScoreValue (Label) # "12500"
│ ├── StagePanel (VBoxContainer) # 关卡区域
│ │ ├── StageLabel (Label) # "STAGE"
│ │ └── StageValue (Label) # "1"
│ └── LivesPanel (VBoxContainer) # 生命区域
│ ├── LivesLabel (Label) # "LIVES"
│ └── LivesValue (HBoxContainer) # 心形图标 x N
│
└── PauseMenu (Control) # 暂停菜单(默认隐藏)
├── PausePanel (PanelContainer)
│ ├── TitleLabel (Label) # "暂停"
│ ├── ResumeButton (Button) # "继续游戏"
│ ├── RestartButton (Button) # "重新开始"
│ └── QuitButton (Button) # "退出"HUD 脚本
// HUD.cs - 游戏抬头显示
using Godot;
public partial class HUD : CanvasLayer
{
// ========== 节点引用 ==========
[Export] public Label ScoreValue { get; set; }
[Export] public Label StageValue { get; set; }
[Export] public HBoxContainer LivesContainer { get; set; }
[Export] public Control PauseMenu { get; set; }
// ========== 生命图标 ==========
private PackedScene _heartIcon;
public override void _Ready()
{
// 加载心形图标
_heartIcon = GD.Load<PackedScene>("res://scenes/ui/heart_icon.tscn");
// 连接 GameManager 信号
GameManager.Instance.ScoreChanged += OnScoreChanged;
GameManager.Instance.LivesChanged += OnLivesChanged;
GameManager.Instance.StageChanged += OnStageChanged;
GameManager.Instance.StateChanged += OnStateChanged;
// 初始化显示
UpdateScore(GameManager.Instance.CurrentScore);
UpdateLives(GameManager.Instance.PlayerLives);
UpdateStage(GameManager.Instance.CurrentStage);
// 初始隐藏暂停菜单
PauseMenu.Visible = false;
}
// ========== 分数更新 ==========
private void OnScoreChanged(int newScore)
{
UpdateScore(newScore);
}
private void UpdateScore(int score)
{
// 用0填充到6位,比如 500 → "000500"
ScoreValue.Text = score.ToString("D6");
}
// ========== 生命更新 ==========
private void OnLivesChanged(int newLives)
{
UpdateLives(newLives);
}
private void UpdateLives(int lives)
{
// 清除旧的心形图标
foreach (var child in LivesContainer.GetChildren())
{
child.QueueFree();
}
// 根据生命数添加心形图标
for (int i = 0; i < lives; i++)
{
var heart = _heartIcon.Instantiate<TextureRect>();
LivesContainer.AddChild(heart);
}
}
// ========== 关卡更新 ==========
private void OnStageChanged(int newStage)
{
UpdateStage(newStage);
}
private void UpdateStage(int stage)
{
StageValue.Text = stage.ToString();
}
// ========== 暂停菜单 ==========
private void OnStateChanged(int newState)
{
var state = (GameManager.GameState)newState;
// 暂停时显示暂停菜单
if (state == GameManager.GameState.Paused)
{
PauseMenu.Visible = true;
}
else
{
PauseMenu.Visible = false;
}
}
// ========== 按钮回调 ==========
// 继续游戏
private void OnResumeButtonPressed()
{
GameManager.Instance.TogglePause();
}
// 重新开始
private void OnRestartButtonPressed()
{
GameManager.Instance.TogglePause();
GameManager.Instance.StartNewGame();
}
// 退出到主菜单
private void OnQuitButtonPressed()
{
GameManager.Instance.TogglePause();
GameManager.Instance.CurrentState = GameManager.GameState.MainMenu;
}
// ESC键暂停
public override void _UnhandledInput(InputEvent @event)
{
if (@event is InputEventKey key && key.Pressed && key.Keycode == Key.Escape)
{
if (GameManager.Instance.CurrentState == GameManager.GameState.Playing ||
GameManager.Instance.CurrentState == GameManager.GameState.Paused)
{
GameManager.Instance.TogglePause();
}
}
}
}# hud.gd - 游戏抬头显示
extends CanvasLayer
# ========== 节点引用 ==========
@export var score_value: Label
@export var stage_value: Label
@export var lives_container: HBoxContainer
@export var pause_menu: Control
# ========== 生命图标 ==========
var heart_icon: PackedScene
func _ready() -> void:
# 加载心形图标
heart_icon = load("res://scenes/ui/heart_icon.tscn")
# 连接 GameManager 信号
GameManager.score_changed.connect(on_score_changed)
GameManager.lives_changed.connect(on_lives_changed)
GameManager.stage_changed.connect(on_stage_changed)
GameManager.state_changed.connect(on_state_changed)
# 初始化显示
update_score(GameManager.current_score)
update_lives(GameManager.player_lives)
update_stage(GameManager.current_stage)
# 初始隐藏暂停菜单
pause_menu.visible = false
# ========== 分数更新 ==========
func on_score_changed(new_score: int) -> void:
update_score(new_score)
func update_score(score: int) -> void:
# 用0填充到6位,比如 500 → "000500"
score_value.text = "%06d" % score
# ========== 生命更新 ==========
func on_lives_changed(new_lives: int) -> void:
update_lives(new_lives)
func update_lives(lives: int) -> void:
# 清除旧的心形图标
for child in lives_container.get_children():
child.queue_free()
# 根据生命数添加心形图标
for i in range(lives):
var heart = heart_icon.instantiate()
lives_container.add_child(heart)
# ========== 关卡更新 ==========
func on_stage_changed(new_stage: int) -> void:
update_stage(new_stage)
func update_stage(stage: int) -> void:
stage_value.text = str(stage)
# ========== 暂停菜单 ==========
func on_state_changed(new_state: int) -> void:
# 暂停时显示暂停菜单
if new_state == GameManager.GameState.PAUSED:
pause_menu.visible = true
else:
pause_menu.visible = false
# ========== 按钮回调 ==========
## 继续游戏
func _on_resume_button_pressed() -> void:
GameManager.toggle_pause()
## 重新开始
func _on_restart_button_pressed() -> void:
GameManager.toggle_pause()
GameManager.start_new_game()
## 退出到主菜单
func _on_quit_button_pressed() -> void:
GameManager.toggle_pause()
GameManager.current_state = GameManager.GameState.MAIN_MENU
## ESC键暂停
func _unhandled_input(event: InputEvent) -> void:
if event is InputEventKey and event.pressed and event.keycode == KEY_ESCAPE:
if GameManager.current_state == GameManager.GameState.PLAYING or \
GameManager.current_state == GameManager.GameState.PAUSED:
GameManager.toggle_pause()敌人剩余数量显示
屏幕右侧用小坦克图标显示剩余敌人数量,就像一个"消灭清单":
// EnemyIndicator.cs - 敌人剩余数量显示
using Godot;
public partial class EnemyIndicator : CanvasLayer
{
[Export] public HBoxContainer IndicatorContainer { get; set; }
[Export] public TextureRect EnemyIconTemplate { get; set; }
// 当前显示的图标数量
private int _displayedCount = 0;
public override void _Ready()
{
// 连接信号
GameManager.Instance.StateChanged += OnStateChanged;
}
// 游戏开始时初始化显示
private void OnStateChanged(int newState)
{
if (newState == (int)GameManager.GameState.Playing)
{
// 显示本关的总敌人数
int totalEnemies = GameManager.Instance.TotalEnemies;
UpdateDisplay(totalEnemies);
}
}
// 更新显示
public void UpdateDisplay(int remainingEnemies)
{
// 只在数量变化时更新
if (remainingEnemies == _displayedCount) return;
// 清除旧的图标
foreach (var child in IndicatorContainer.GetChildren())
{
child.QueueFree();
}
// 添加新图标
// 用2列5行的网格显示,最多20个
var grid = new GridContainer
{
Columns = 2,
ThemeOverrideConstants = { { "h_separation", 2 }, { "v_separation", 2 } }
};
for (int i = 0; i < remainingEnemies; i++)
{
var icon = EnemyIconTemplate.Duplicate() as TextureRect;
icon.Visible = true;
grid.AddChild(icon);
}
IndicatorContainer.AddChild(grid);
_displayedCount = remainingEnemies;
}
// 敌人被消灭时调用
public void OnEnemyKilled()
{
_displayedCount--;
if (_displayedCount < 0) _displayedCount = 0;
UpdateDisplay(_displayedCount);
}
}# enemy_indicator.gd - 敌人剩余数量显示
extends CanvasLayer
@export var indicator_container: HBoxContainer
@export var enemy_icon_template: TextureRect
# 当前显示的图标数量
var displayed_count: int = 0
func _ready() -> void:
# 连接信号
GameManager.state_changed.connect(on_state_changed)
## 游戏开始时初始化显示
func on_state_changed(new_state: int) -> void:
if new_state == GameManager.GameState.PLAYING:
# 显示本关的总敌人数
var total_enemies = GameManager.TOTAL_ENEMIES
update_display(total_enemies)
## 更新显示
func update_display(remaining_enemies: int) -> void:
# 只在数量变化时更新
if remaining_enemies == displayed_count:
return
# 清除旧的图标
for child in indicator_container.get_children():
child.queue_free()
# 添加新图标(2列5行的网格显示)
var grid = GridContainer.new()
grid.columns = 2
grid.add_theme_constant_override("h_separation", 2)
grid.add_theme_constant_override("v_separation", 2)
for i in range(remaining_enemies):
var icon = enemy_icon_template.duplicate() as TextureRect
icon.visible = true
grid.add_child(icon)
indicator_container.add_child(grid)
displayed_count = remaining_enemies
## 敌人被消灭时调用
func on_enemy_killed() -> void:
displayed_count -= 1
if displayed_count < 0:
displayed_count = 0
update_display(displayed_count)关卡选择界面
关卡选择界面让玩家选择要玩的关卡。分为两部分:预设关卡和自定义关卡。
┌─────────────────────────────────────┐
│ 选择关卡 │
│ │
│ ┌─────────────────────────────┐ │
│ │ 预设关卡 │ │
│ │ [第1关] [第2关] [第3关] │ │
│ │ [第4关] [第5关] │ │
│ └─────────────────────────────┘ │
│ │
│ ┌─────────────────────────────┐ │
│ │ 自定义关卡 │ │
│ │ [我的关卡1] [我的关卡2] │ │
│ │ [创建新关卡] │ │
│ └─────────────────────────────┘ │
│ │
│ [返回主菜单] │
└─────────────────────────────────────┘// StageSelectUI.cs - 关卡选择界面
using Godot;
public partial class StageSelectUI : Control
{
// ========== 节点引用 ==========
[Export] public GridContainer PresetGrid { get; set; }
[Export] public GridContainer CustomGrid { get; set; }
[Export] public Button CreateMapButton { get; set; }
// ========== 初始化 ==========
public override void _Ready()
{
// 创建预设关卡按钮
CreatePresetButtons();
// 加载自定义关卡列表
RefreshCustomLevels();
// 创建新关卡按钮
CreateMapButton.Pressed += OnCreateMapPressed;
}
// 创建预设关卡按钮
private void CreatePresetButtons()
{
int totalStages = GameManager.Instance.TotalStages;
var stageNames = new[] { "新手村", "钢铁要塞", "水上战场", "冰上迷踪", "最终防线" };
for (int i = 0; i < totalStages; i++)
{
int stageNum = i + 1;
var button = new Button
{
Text = $"第{stageNum}关\n{stageNames[i]}",
CustomMinimumSize = new Vector2(120, 60)
};
button.Pressed += () => OnPresetStageSelected(stageNum);
PresetGrid.AddChild(button);
}
}
// 刷新自定义关卡列表
private void RefreshCustomLevels()
{
// 清除旧的按钮
foreach (var child in CustomGrid.GetChildren())
{
child.QueueFree();
}
// 获取自定义关卡列表
var customLevels = MapFileManager.GetCustomLevelList();
if (customLevels.Length == 0)
{
var label = new Label { Text = "暂无自定义关卡" };
CustomGrid.AddChild(label);
return;
}
// 为每个自定义关卡创建按钮
foreach (var levelName in customLevels)
{
var button = new Button
{
Text = levelName,
CustomMinimumSize = new Vector2(120, 50)
};
button.Pressed += () => OnCustomStageSelected(levelName);
CustomGrid.AddChild(button);
}
}
// ========== 按钮回调 ==========
// 选择预设关卡
private void OnPresetStageSelected(int stageNum)
{
GameManager.Instance.StartStage(stageNum);
}
// 选择自定义关卡
private void OnCustomStageSelected(string levelName)
{
var mapData = MapFileManager.LoadMap($"user://custom_levels/{levelName}.json");
if (mapData != null)
{
GameManager.Instance.CustomMapData = mapData;
GameManager.Instance.CurrentState = GameManager.GameState.Playing;
}
}
// 创建新关卡(打开地图编辑器)
private void OnCreateMapPressed()
{
// 切换到地图编辑器场景
GetTree().ChangeSceneToFile("res://scenes/menus/map_editor_menu.tscn");
}
}# stage_select_ui.gd - 关卡选择界面
extends Control
# ========== 节点引用 ==========
@export var preset_grid: GridContainer
@export var custom_grid: GridContainer
@export var create_map_button: Button
# ========== 初始化 ==========
func _ready() -> void:
# 创建预设关卡按钮
create_preset_buttons()
# 加载自定义关卡列表
refresh_custom_levels()
# 创建新关卡按钮
create_map_button.pressed.connect(on_create_map_pressed)
## 创建预设关卡按钮
func create_preset_buttons() -> void:
var total_stages = GameManager.total_stages
var stage_names = ["新手村", "钢铁要塞", "水上战场", "冰上迷踪", "最终防线"]
for i in range(total_stages):
var stage_num = i + 1
var button = Button.new()
button.text = "第%d关\n%s" % [stage_num, stage_names[i]]
button.custom_minimum_size = Vector2(120, 60)
button.pressed.connect(func(): on_preset_stage_selected(stage_num))
preset_grid.add_child(button)
## 刷新自定义关卡列表
func refresh_custom_levels() -> void:
# 清除旧的按钮
for child in custom_grid.get_children():
child.queue_free()
# 获取自定义关卡列表
var custom_levels = MapFileManager.get_custom_level_list()
if custom_levels.is_empty():
var label = Label.new()
label.text = "暂无自定义关卡"
custom_grid.add_child(label)
return
# 为每个自定义关卡创建按钮
for level_name in custom_levels:
var button = Button.new()
button.text = level_name
button.custom_minimum_size = Vector2(120, 50)
button.pressed.connect(func(): on_custom_stage_selected(level_name))
custom_grid.add_child(button)
# ========== 按钮回调 ==========
## 选择预设关卡
func on_preset_stage_selected(stage_num: int) -> void:
GameManager.start_stage(stage_num)
## 选择自定义关卡
func on_custom_stage_selected(level_name: String) -> void:
var map_data = MapFileManager.load_map("user://custom_levels/%s.json" % level_name)
if map_data != null:
GameManager.custom_map_data = map_data
GameManager.current_state = GameManager.GameState.PLAYING
## 创建新关卡(打开地图编辑器)
func on_create_map_pressed() -> void:
# 切换到地图编辑器场景
get_tree().change_scene_to_file("res://scenes/menus/map_editor_menu.tscn")关卡过渡界面
当一关打完后,显示"关卡通过"的过渡界面:
// StageClearUI.cs - 关卡通过界面
using Godot;
public partial class StageClearUI : Control
{
[Export] public Label StageLabel { get; set; }
[Export] public Label ScoreLabel { get; set; }
[Export] public Button NextButton { get; set; }
[Export] public Button MenuButton { get; set; }
public override void _Ready()
{
Visible = false;
// 连接信号
GameManager.Instance.StateChanged += OnStateChanged;
NextButton.Pressed += OnNextButtonPressed;
MenuButton.Pressed += OnMenuButtonPressed;
}
private void OnStateChanged(int newState)
{
if (newState == (int)GameManager.GameState.StageClear)
{
// 显示关卡通过界面
StageLabel.Text = $"第 {GameManager.Instance.CurrentStage} 关 通过!";
ScoreLabel.Text = $"得分: {GameManager.Instance.CurrentScore}";
Visible = true;
}
else
{
Visible = false;
}
}
private void OnNextButtonPressed()
{
GameManager.Instance.NextStage();
}
private void OnMenuButtonPressed()
{
GameManager.Instance.CurrentState = GameManager.GameState.MainMenu;
}
}# stage_clear_ui.gd - 关卡通过界面
extends Control
@export var stage_label: Label
@export var score_label: Label
@export var next_button: Button
@export var menu_button: Button
func _ready() -> void:
visible = false
# 连接信号
GameManager.state_changed.connect(on_state_changed)
next_button.pressed.connect(on_next_button_pressed)
menu_button.pressed.connect(on_menu_button_pressed)
func on_state_changed(new_state: int) -> void:
if new_state == GameManager.GameState.STAGE_CLEAR:
# 显示关卡通过界面
stage_label.text = "第 %d 关 通过!" % GameManager.current_stage
score_label.text = "得分: %d" % GameManager.current_score
visible = true
else:
visible = false
func on_next_button_pressed() -> void:
GameManager.next_stage()
func on_menu_button_pressed() -> void:
GameManager.current_state = GameManager.GameState.MAIN_MENU下一章预告
游戏界面完成了!玩家可以看到分数、生命、关卡信息,可以暂停、选择关卡。下一章我们将添加音效和视觉特效——射击声、爆炸声、坦克出生动画,让游戏变得更有"感觉"。
