7. 游戏UI
2026/4/14大约 7 分钟
游戏UI
竞速游戏的UI有一个核心原则:玩家不能花超过0.5秒去看UI。在高速行驶中,玩家的注意力95%都在前方的路上,UI必须在余光中就能读懂。本章我们来设计一套简洁、直观的游戏界面。
本章你将学到
- 速度表/转速表的UI设计
- 赛段排名显示
- 迷你地图GPS导航
- 倒计时和结果画面
UI布局总览
HUD节点结构
在 Godot 中,UI用 CanvasLayer 包裹,这样UI不会随3D场景移动:
HUD (CanvasLayer)
├── MarginContainer ← 控制边距
│ ├── TopBar (HBoxContainer) ← 顶部栏
│ │ ├── RankLabel ← 排名
│ │ ├── TimerLabel ← 计时
│ │ └── StageLabel ← 赛段信息
│ ├── SpeedPanel (Panel) ← 速度表面板
│ │ ├── SpeedLabel ← 速度数字
│ │ └── SpeedUnit ← "km/h" 单位
│ ├── Minimap (TextureRect) ← 迷你地图
│ └── GearLabel ← 档位显示速度表
速度表是竞速游戏最重要的UI元素。玩家需要随时知道自己开多快。
设计选择
有两种风格可选:
- 数字式:直接显示数字,如 "178 km/h"——简洁明了
- 仪表盘式:圆形表盘带指针——更有代入感
我们选择数字式,因为在高速行驶中数字比指针更容易读。
C#
// 速度表UI控制器
public partial class SpeedometerUI : Control
{
[Export] public NodePath MotorcyclePath;
[Export] public Label SpeedLabel;
[Export] public Label UnitLabel;
private RigidBody3D _motorcycle;
public override void _Ready()
{
_motorcycle = GetNode<RigidBody3D>(MotorcyclePath);
}
public override void _Process(double delta)
{
if (_motorcycle == null) return;
// 获取速度(m/s),转换为 km/h
float speedMs = _motorcycle.LinearVelocity.Length();
int speedKmh = (int)(speedMs * 3.6f);
// 更新显示
SpeedLabel.Text = speedKmh.ToString();
// 速度越高颜色越红(增加紧张感)
if (speedKmh > 150)
SpeedLabel.Modulate = new Color(1f, 0.3f, 0.3f); // 红色
else if (speedKmh > 100)
SpeedLabel.Modulate = new Color(1f, 0.8f, 0.3f); // 橙色
else
SpeedLabel.Modulate = new Color(1f, 1f, 1f); // 白色
}
}GDScript
# 速度表UI控制器
extends Control
@export var motorcycle_path: NodePath
@export var speed_label: Label
@export var unit_label: Label
var _motorcycle: RigidBody3D
func _ready():
_motorcycle = get_node(motorcycle_path)
func _process(delta):
if _motorcycle == null:
return
# 获取速度(m/s),转换为 km/h
var speed_ms = _motorcycle.linear_velocity.length()
var speed_kmh = int(speed_ms * 3.6)
# 更新显示
speed_label.text = str(speed_kmh)
# 速度越高颜色越红(增加紧张感)
if speed_kmh > 150:
speed_label.modulate = Color(1.0, 0.3, 0.3) # 红色
elif speed_kmh > 100:
speed_label.modulate = Color(1.0, 0.8, 0.3) # 橙色
else:
speed_label.modulate = Color(1.0, 1.0, 1.0) # 白色赛段计时和排名显示
计时器显示在屏幕上方居中位置,用大号字体方便读取:
C#
// 赛段计时UI
public partial class TimerUI : Control
{
[Export] public Label TimeLabel;
[Export] public Label RankLabel;
[Export] public Label StageLabel;
private StageTimer _stageTimer;
private CheckpointManager _checkpointMgr;
public override void _Ready()
{
_stageTimer = GetNode<StageTimer>("/root/Main/StageTimer");
_checkpointMgr = GetNode<CheckpointManager>("/root/Main/CheckpointManager");
}
public override void _Process(double delta)
{
// 更新计时
TimeLabel.Text = _stageTimer.GetFormattedTime();
// 更新排名(简单版:显示经过了几个检查点)
float progress = _checkpointMgr.GetProgress();
int rank = 1; // 实际项目中需要比较所有骑手的进度
RankLabel.Text = $"P{rank}";
// 赛段信息
StageLabel.Text = $"SS{_stageTimer.CurrentStage} / {_stageTimer.TotalStages}";
}
}GDScript
# 赛段计时UI
extends Control
@export var time_label: Label
@export var rank_label: Label
@export var stage_label: Label
var _stage_timer: Node
var _checkpoint_mgr: Node
func _ready():
_stage_timer = get_node("/root/Main/StageTimer")
_checkpoint_mgr = get_node("/root/Main/CheckpointManager")
func _process(delta):
# 更新计时
time_label.text = _stage_timer.get_formatted_time()
# 更新排名
var rank = 1
rank_label.text = "P%d" % rank
# 赛段信息
stage_label.text = "SS%d / %d" % [_stage_timer.current_stage, _stage_timer.total_stages]迷你地图GPS
迷你地图显示在屏幕左下角,帮助玩家了解自己在赛道上的位置和前方路况。
实现思路
把赛道的路径"拍扁"到一张2D地图上,然后用一个亮点表示玩家当前位置。
C#
// 迷你地图 - 用 Control 手动绘制
public partial class MinimapUI : Control
{
[Export] public Path3D TrackPath;
[Export] public NodePath MotorcyclePath;
[Export] public float MapScale = 0.005f; // 地图缩放比例
[Export] public Color TrackColor = new(1f, 1f, 1f, 0.5f);
[Export] public Color PlayerColor = new(1f, 0.2f, 0.2f);
[Export] public Color CheckpointColor = new(0.2f, 1f, 0.2f);
private Vector2 _mapCenter;
private Vector2[] _trackPoints;
private RigidBody3D _motorcycle;
public override void _Ready()
{
_motorcycle = GetNode<RigidBody3D>(MotorcyclePath);
_mapCenter = Size / 2f;
BakeTrackPoints();
}
// 预计算赛道在地图上的点
private void BakeTrackPoints()
{
var curve = TrackPath.Curve;
_trackPoints = new Vector2[curve.PointCount];
for (int i = 0; i < curve.PointCount; i++)
{
Vector3 pos = curve.GetPointPosition(i);
_trackPoints[i] = new Vector2(pos.X, pos.Z) * MapScale + _mapCenter;
}
}
public override void _Draw()
{
// 画赛道线
if (_trackPoints != null && _trackPoints.Length > 1)
{
DrawPolyline(_trackPoints, TrackColor, 2f, true);
}
// 画玩家位置
if (_motorcycle != null)
{
Vector2 playerMapPos = new(
_motorcycle.GlobalPosition.X * MapScale + _mapCenter.X,
_motorcycle.GlobalPosition.Z * MapScale + _mapCenter.Y
);
DrawCircle(playerMapPos, 5f, PlayerColor);
// 画方向箭头
Vector3 forward = _motorcycle.GlobalTransform.Basis.Z;
Vector2 dir = new(forward.X, forward.Z).Normalized();
DrawLine(playerMapPos, playerMapPos + dir * 15f, PlayerColor, 2f);
}
}
public override void _Process(double delta)
{
QueueRedraw(); // 每帧重绘
}
}GDScript
# 迷你地图 - 用 Control 手动绘制
extends Control
@export var track_path: Path3D
@export var motorcycle_path: NodePath
@export var map_scale: float = 0.005 # 地图缩放比例
@export var track_color: Color = Color(1, 1, 1, 0.5)
@export var player_color: Color = Color(1, 0.2, 0.2)
var _map_center: Vector2
var _track_points: PackedVector2Array
var _motorcycle: RigidBody3D
func _ready():
_motorcycle = get_node(motorcycle_path)
_map_center = size / 2.0
_bake_track_points()
# 预计算赛道在地图上的点
func _bake_track_points():
var curve = track_path.curve
_track_points = PackedVector2Array()
for i in range(curve.point_count):
var pos = curve.get_point_position(i)
_track_points.append(Vector2(pos.x, pos.z) * map_scale + _map_center)
func _draw():
# 画赛道线
if _track_points.size() > 1:
draw_polyline(_track_points, track_color, 2.0, true)
# 画玩家位置
if _motorcycle:
var player_map_pos = Vector2(
_motorcycle.global_position.x * map_scale + _map_center.x,
_motorcycle.global_position.z * map_scale + _map_center.y
)
draw_circle(player_map_pos, 5.0, player_color)
# 画方向箭头
var forward = _motorcycle.global_transform.basis.z
var dir = Vector2(forward.x, forward.z).normalized()
draw_line(player_map_pos, player_map_pos + dir * 15.0, player_color, 2.0)
func _process(delta):
queue_redraw() # 每帧重绘倒计时画面
赛段开始前的倒计时(3-2-1-GO!)是一个经典的竞速游戏元素:
C#
// 倒计时控制器
public partial class CountdownUI : Control
{
[Export] public Label CountdownLabel;
[Export] public float CountdownDuration = 3f; // 3秒倒计时
[Signal] public delegate void CountdownFinishedEventHandler();
private float _timer;
private bool _isCountingDown;
public void StartCountdown()
{
_timer = CountdownDuration;
_isCountingDown = true;
CountdownLabel.Visible = true;
}
public override void _Process(double delta)
{
if (!_isCountingDown) return;
_timer -= (float)delta;
if (_timer <= 0f)
{
CountdownLabel.Text = "GO!";
CountdownLabel.Modulate = new Color(0f, 1f, 0f); // 绿色
_isCountingDown = false;
EmitSignal(SignalName.CountdownFinished);
// 1秒后隐藏
GetTree().CreateTimer(1.0).Timeout += () =>
CountdownLabel.Visible = false;
}
else
{
int seconds = Mathf.CeilToInt(_timer);
CountdownLabel.Text = seconds.ToString();
// 倒计时用黄色
CountdownLabel.Modulate = new Color(1f, 0.9f, 0.2f);
// 数字变大再缩小的动画效果
float scale = 1f + (_timer % 1f) * 0.5f;
CountdownLabel.Scale = new Vector2(scale, scale);
}
}
}GDScript
# 倒计时控制器
extends Control
@export var countdown_label: Label
@export var countdown_duration: float = 3.0 # 3秒倒计时
signal countdown_finished()
var _timer: float
var _is_counting_down: bool = false
func start_countdown():
_timer = countdown_duration
_is_counting_down = true
countdown_label.visible = true
func _process(delta):
if not _is_counting_down:
return
_timer -= delta
if _timer <= 0.0:
countdown_label.text = "GO!"
countdown_label.modulate = Color(0, 1, 0) # 绿色
_is_counting_down = false
countdown_finished.emit()
# 1秒后隐藏
get_tree().create_timer(1.0).timeout.connect(
func(): countdown_label.visible = false
)
else:
var seconds = ceili(_timer)
countdown_label.text = str(seconds)
# 倒计时用黄色
countdown_label.modulate = Color(1, 0.9, 0.2)
# 数字变大再缩小的动画效果
var scale = 1.0 + fmod(_timer, 1.0) * 0.5
countdown_label.scale = Vector2(scale, scale)结果画面
赛段完成后显示成绩。设计要点:信息清晰,一目了然。
┌─────────────────────────────────────┐
│ 赛段 SS3 完成! │
│ │
│ 排名 骑手 用时 │
│ 1st 你 02:34.567 │
│ 2nd AI-张三 02:36.123 │
│ 3rd AI-李四 02:38.891 │
│ │
│ 你的最佳分段时间: │
│ CP1: 00:42.3 CP2: 01:23.5 │
│ CP3: 02:01.2 终点: 02:34.5 │
│ │
│ [继续] [重赛] [退出] │
└─────────────────────────────────────┘UI设计原则总结
| 原则 | 说明 |
|---|---|
| 不遮挡视线 | UI放在屏幕边缘,中间留给游戏画面 |
| 字体要大 | 高速行驶中没时间看小字 |
| 颜色有含义 | 红色=危险/高速,绿色=安全/正常 |
| 动画要快 | UI变化不能有延迟感 |
| 可自定义 | 提供开关让玩家选择显示哪些UI |
常见问题
Q:UI在暗色场景中看不清怎么办?
给UI元素加上半透明的黑色背景或者外发光效果。比如速度数字外面加一圈暗色底板,这样在任何背景下都能看清。
Q:迷你地图怎么做才好看?
用一张手绘风格的赛道缩略图作为底图,比实时计算路径更美观。可以用外部工具(如 Photoshop)画好导入 Godot 作为 TextureRect。
Q:要不要显示地图和转速表?
对于摩托车游戏,转速表可以省略(摩托车很少换挡的概念),但速度表必须要有。如果后续加入手动挡系统,再添加转速表。
下一步
UI完成后,开始 音频与特效。
