8. HUD 与炸弹系统
2026/4/14大约 9 分钟
HUD 与炸弹系统
HUD(Head-Up Display,抬头显示)是玩家在游戏中看到的界面信息——血量、弹药、金钱、准星、计时器等。炸弹系统是 CS 的核心目标机制——T 安放、CT 拆除。这两个系统让游戏"看得见、玩得着"。
HUD 设计
CS 的 HUD 简洁而信息密集,所有关键信息一目了然:
┌─────────────────────────────────────────────────────────────┐
│ │
│ [回合信息] │
│ CT 7 : 5 T │
│ 01:35 │
│ │
│ │
│ + │ ← 准星
│ │
│ │
│ │
│ [炸弹图标] [击杀提示] │
│ ☢ 0:28 你击杀了 Bot01 │
│ │
│ │
│ HP ████████ 100 30/90 AK-47 $4750 │
│ ───────────── ────── ───── ────── │
│ 血量条 弹药 武器名 金钱 │
└─────────────────────────────────────────────────────────────┘HUD 场景结构
HUD (CanvasLayer) ← 固定在屏幕上的 UI 层
├── TopBar (HBoxContainer) ← 顶部信息栏
│ ├── CTScore (Label) ← CT 比分
│ ├── Timer (Label) ← 回合计时器
│ └── TScore (Label) ← T 比分
│
├── Crosshair (CenterContainer) ← 准星(屏幕中央)
│ └── CrosshairLines (Control) ← 十字准星绘制
│
├── BottomBar (HBoxContainer) ← 底部信息栏
│ ├── HealthSection (VBoxContainer)
│ │ ├── HealthBar (ProgressBar) ← 血量条
│ │ └── ArmorIcon (TextureRect) ← 护甲图标
│ ├── AmmoSection (VBoxContainer)
│ │ ├── CurrentAmmo (Label) ← 当前弹匣弹药
│ │ └── ReserveAmmo (Label) ← 备用弹药
│ ├── WeaponName (Label) ← 武器名称
│ └── MoneySection (VBoxContainer)
│ └── MoneyLabel (Label) ← 金钱显示
│
├── BombIndicator (HBoxContainer) ← 炸弹指示器
│ ├── BombIcon (TextureRect)
│ └── BombTimer (Label)
│
├── KillFeed (VBoxContainer) ← 击杀信息流
│ └── KillNotification (Label) ← 单条击杀提示
│
└── RoundInfo (Panel) ← 回合提示(回合开始/结束)
└── RoundMessage (Label)HUD 控制器
C#
// Scripts/UI/HUDController.cs
using Godot;
/// <summary>
/// HUD 控制器。监听游戏事件,更新界面显示。
/// </summary>
public partial class HUDController : CanvasLayer
{
private Label _ctScoreLabel;
private Label _tScoreLabel;
private Label _timerLabel;
private ProgressBar _healthBar;
private Label _currentAmmoLabel;
private Label _reserveAmmoLabel;
private Label _weaponNameLabel;
private Label _moneyLabel;
private Label _bombTimerLabel;
private VBoxContainer _killFeed;
private Label _roundMessage;
private Control _crosshair;
public override void _Ready()
{
// 获取 UI 节点引用
_ctScoreLabel = GetNode<Label>("TopBar/CTScore");
_tScoreLabel = GetNode<Label>("TopBar/TScore");
_timerLabel = GetNode<Label>("TopBar/Timer");
_healthBar = GetNode<ProgressBar>("BottomBar/HealthSection/HealthBar");
_currentAmmoLabel = GetNode<Label>("BottomBar/AmmoSection/CurrentAmmo");
_reserveAmmoLabel = GetNode<Label>("BottomBar/AmmoSection/ReserveAmmo");
_weaponNameLabel = GetNode<Label>("BottomBar/WeaponName");
_moneyLabel = GetNode<Label>("BottomBar/MoneySection/MoneyLabel");
_bombTimerLabel = GetNode<Label>("BombIndicator/BombTimer");
_killFeed = GetNode<VBoxContainer>("KillFeed");
_roundMessage = GetNode<Label>("RoundInfo/RoundMessage");
_crosshair = GetNode<Control>("Crosshair/CrosshairLines");
// 连接游戏事件
var gameEvents = GetNode<GameEvents>("/root/GameEvents");
gameEvents.RoundStarted += OnRoundStarted;
gameEvents.RoundEnded += OnRoundEnded;
gameEvents.PlayerDied += OnPlayerDied;
gameEvents.MoneyChanged += OnMoneyChanged;
}
public override void _Process(double delta)
{
UpdateTimer();
UpdateAmmoDisplay();
UpdateHealthDisplay();
UpdateCrosshair();
}
private void UpdateTimer()
{
var roundManager = GetNode<RoundManager>("../RoundManager");
var timeRemaining = roundManager.GetPhaseTimeRemaining();
_timerLabel.Text = FormatTime(timeRemaining);
}
private void UpdateAmmoDisplay()
{
var weaponManager = GetNode<WeaponManager>("../Players/Player/WeaponHolder/WeaponManager");
if (weaponManager?.CurrentWeapon != null)
{
_currentAmmoLabel.Text = weaponManager.CurrentWeapon.CurrentAmmo.ToString();
_reserveAmmoLabel.Text = weaponManager.CurrentWeapon.ReserveAmmo.ToString();
_weaponNameLabel.Text = weaponManager.CurrentWeapon.Data?.WeaponName ?? "";
}
}
private void UpdateHealthDisplay()
{
var player = GetNode<CharacterBody3D>("../Players/Player");
var health = player?.GetNode<HealthComponent>("HealthComponent");
if (health != null)
{
_healthBar.Value = health.CurrentHealth;
}
}
/// <summary>
/// 根据移动状态动态调整准星大小。
/// 移动时准星变大,静止时缩小。
/// </summary>
private void UpdateCrosshair()
{
var player = GetNode<PlayerController>("../Players/Player");
if (player == null) return;
float spread = 4f; // 基础准星大小
// 移动时准星变大
spread += player.SpeedFactor * 20f;
// 蹲下时准星变小
if (player.IsCrouching)
spread *= 0.7f;
// 应用准星大小(通过调整线条间距)
_crosshair.Set("spread", spread);
}
private void OnRoundStarted(int roundNumber)
{
_roundMessage.Text = $"第 {roundNumber} 回合开始!";
_roundMessage.Visible = true;
// 3秒后隐藏提示
GetTree().CreateTimer(3.0).Timeout += () => _roundMessage.Visible = false;
}
private void OnRoundEnded(string winner)
{
var message = winner == "CT" ? "反恐精英 获胜!" : "恐怖分子 获胜!";
_roundMessage.Text = message;
_roundMessage.Visible = true;
// 更新比分
var roundManager = GetNode<RoundManager>("../RoundManager");
var (ct, t) = roundManager.GetScore();
_ctScoreLabel.Text = ct.ToString();
_tScoreLabel.Text = t.ToString();
}
private void OnPlayerDied(Node3D player, Node3D killer)
{
var killText = $"{killer.Name} 击杀了 {player.Name}";
AddKillNotification(killText);
}
private void OnMoneyChanged(int playerId, int newAmount)
{
_moneyLabel.Text = $"${newAmount}";
}
private void AddKillNotification(string text)
{
var label = new Label();
label.Text = text;
_killFeed.AddChild(label);
// 5秒后自动消失
GetTree().CreateTimer(5.0).Timeout += () => label.QueueFree();
}
private string FormatTime(float seconds)
{
var mins = (int)seconds / 60;
var secs = (int)seconds % 60;
return $"{mins:D2}:{secs:D2}";
}
}GDScript
# Scripts/UI/HUDController.gd
extends CanvasLayer
## HUD 控制器。监听游戏事件,更新界面显示。
@onready var _ct_score_label: Label = $TopBar/CTScore
@onready var _t_score_label: Label = $TopBar/TScore
@onready var _timer_label: Label = $TopBar/Timer
@onready var _health_bar: ProgressBar = $BottomBar/HealthSection/HealthBar
@onready var _current_ammo_label: Label = $BottomBar/AmmoSection/CurrentAmmo
@onready var _reserve_ammo_label: Label = $BottomBar/AmmoSection/ReserveAmmo
@onready var _weapon_name_label: Label = $BottomBar/WeaponName
@onready var _money_label: Label = $BottomBar/MoneySection/MoneyLabel
@onready var _bomb_timer_label: Label = $BombIndicator/BombTimer
@onready var _kill_feed: VBoxContainer = $KillFeed
@onready var _round_message: Label = $RoundInfo/RoundMessage
@onready var _crosshair: Control = $Crosshair/CrosshairLines
func _ready():
# 连接游戏事件
var game_events = get_node("/root/GameEvents") as GameEvents
game_events.round_started.connect(_on_round_started)
game_events.round_ended.connect(_on_round_ended)
game_events.player_died.connect(_on_player_died)
game_events.money_changed.connect(_on_money_changed)
func _process(_delta: float):
_update_timer()
_update_ammo_display()
_update_health_display()
_update_crosshair()
func _update_timer():
var round_manager = get_node("../RoundManager") as RoundManager
var time_remaining = round_manager.get_phase_time_remaining()
_timer_label.text = _format_time(time_remaining)
func _update_ammo_display():
var weapon_manager = get_node_or_null("../Players/Player/WeaponHolder/WeaponManager") as WeaponManager
if weapon_manager and weapon_manager.current_weapon:
_current_ammo_label.text = str(weapon_manager.current_weapon.current_ammo)
_reserve_ammo_label.text = str(weapon_manager.current_weapon.reserve_ammo)
_weapon_name_label.text = weapon_manager.current_weapon.data.weapon_name if weapon_manager.current_weapon.data else ""
func _update_health_display():
var player = get_node_or_null("../Players/Player") as CharacterBody3D
if player:
var health = player.get_node_or_null("HealthComponent") as HealthComponent
if health:
_health_bar.value = health.current_health
## 根据移动状态动态调整准星大小。
func _update_crosshair():
var player = get_node_or_null("../Players/Player") as PlayerController
if not player:
return
var spread: float = 4.0 # 基础准星大小
# 移动时准星变大
spread += player.speed_factor * 20.0
# 蹲下时准星变小
if player.is_crouching:
spread *= 0.7
_crosshair.set("spread", spread)
func _on_round_started(round_number: int):
_round_message.text = "第 %d 回合开始!" % round_number
_round_message.visible = true
# 3秒后隐藏提示
get_tree().create_timer(3.0).timeout.connect(func(): _round_message.visible = false)
func _on_round_ended(winner: String):
var message = "反恐精英 获胜!" if winner == "CT" else "恐怖分子 获胜!"
_round_message.text = message
_round_message.visible = true
# 更新比分
var round_manager = get_node("../RoundManager") as RoundManager
var score = round_manager.get_score()
_ct_score_label.text = str(score.ct)
_t_score_label.text = str(score.t)
func _on_player_died(player: Node3D, killer: Node3D):
var kill_text = "%s 击杀了 %s" % [killer.name, player.name]
_add_kill_notification(kill_text)
func _on_money_changed(_player_id: int, new_amount: int):
_money_label.text = "$%d" % new_amount
func _add_kill_notification(text: String):
var label = Label.new()
label.text = text
_kill_feed.add_child(label)
# 5秒后自动消失
get_tree().create_timer(5.0).timeout.connect(label.queue_free)
func _format_time(seconds: float) -> String:
var mins: int = int(seconds) / 60
var secs: int = int(seconds) % 60
return "%02d:%02d" % [mins, secs]炸弹系统
C4 炸弹是 CS 炸弹拆除模式的核心目标。
炸弹状态
炸弹状态流程:
没有安放 ──(T在炸弹点安放)──► 安放中(3.2秒)
│
安放完成
│
倒计时 40 秒
│ │
(CT拆除) (时间到)
│ │
拆除成功 爆炸!
CT 赢 T 赢炸弹系统实现
C#
// Scripts/Systems/BombSystem.cs
using Godot;
public enum BombState
{
None, // 没有炸弹
Carried, // 被 T 玩家携带
Planting, // 正在安放
Planted, // 已安放,倒计时中
Defusing, // 正在拆除
Exploded, // 已爆炸
Defused // 已拆除
}
/// <summary>
/// 炸弹系统。管理 C4 炸弹的完整生命周期。
///
/// 炸弹的"一生":
/// 1. 回合开始时分配给一个 T 方玩家(Carried)
/// 2. T 到达炸弹点后按 E 安放(Planting → Planted)
/// 3. 安放后开始 40 秒倒计时
/// 4. CT 可以按 E 拆除(Defusing → Defused)
/// 5. 倒计时结束则爆炸(Exploded)
/// </summary>
public partial class BombSystem : Node3D
{
[Export] public float PlantTime { get; set; } = 3.2f;
[Export] public float DetonateTime { get; set; } = 40f;
[Export] public float DefuseTime { get; set; } = 10f;
[Export] public float DefuseTimeWithKit { get; set; } = 5f;
[Export] public float ExplosionRadius { get; set; } = 17f;
[Export] public float ExplosionDamage { get; set; } = 500f;
private BombState _state = BombState.None;
private Node3D _carrier; // 携带炸弹的 T
private Vector3 _plantedPosition; // 安放位置
private float _timer;
private bool _hasDefuseKit;
private MeshInstance3D _bombModel;
private AudioStreamPlayer3D _beepSound;
private AudioStreamPlayer3D _explosionSound;
private PointLight3D _bombLight;
public BombState State => _state;
public float TimeRemaining => _timer;
public override void _Ready()
{
_bombModel = GetNode<MeshInstance3D>("BombModel");
_beepSound = GetNode<AudioStreamPlayer3D>("BeepSound");
_explosionSound = GetNode<AudioStreamPlayer3D>("ExplosionSound");
_bombLight = GetNode<PointLight3D>("BombLight");
_bombModel.Visible = false;
_bombLight.Visible = false;
}
public override void _Process(double delta)
{
switch (_state)
{
case BombState.Planting:
_timer -= (float)delta;
if (_timer <= 0)
{
PlantComplete();
}
break;
case BombState.Planted:
_timer -= (float)delta;
UpdateBeepInterval();
if (_timer <= 0)
{
Explode();
}
break;
case BombState.Defusing:
_timer -= (float)delta;
if (_timer <= 0)
{
DefuseComplete();
}
break;
}
}
/// <summary>
/// 回合开始时将炸弹分配给一个 T 方玩家。
/// </summary>
public void AssignToPlayer(Node3D player)
{
_state = BombState.Carried;
_carrier = player;
_bombModel.Visible = false;
// 加入携带者的组
player.AddToGroup("bomb_carrier");
}
/// <summary>
/// 开始安放炸弹(T 方玩家按 E 键)。
/// </summary>
public bool TryPlant(Node3D planter)
{
if (_state != BombState.Carried) return false;
if (_carrier != planter) return false;
// 检查是否在炸弹点内
if (!IsInBombSite(planter)) return false;
_state = BombState.Planting;
_timer = PlantTime;
_plantedPosition = planter.GlobalPosition;
return true;
}
private void PlantComplete()
{
_state = BombState.Planted;
_timer = DetonateTime;
// 显示炸弹模型
_bombModel.Visible = true;
_bombModel.GlobalPosition = _plantedPosition;
_bombLight.Visible = true;
// 从携带者移除标记
if (_carrier != null)
_carrier.RemoveFromGroup("bomb_carrier");
// 添加到"已安放炸弹"组(AI 会查找这个组)
AddToGroup("planted_bomb");
var gameEvents = GetNode<GameEvents>("/root/GameEvents");
gameEvents.EmitSignal(GameEvents.SignalName.BombPlanted, _plantedPosition);
}
/// <summary>
/// 开始拆除炸弹(CT 方玩家按 E 键)。
/// </summary>
public bool TryDefuse(Node3D defuser, bool hasDefuseKit)
{
if (_state != BombState.Planted) return false;
// 检查距离
var dist = defuser.GlobalPosition.DistanceTo(_plantedPosition);
if (dist > 3f) return false;
_state = BombState.Defusing;
_hasDefuseKit = hasDefuseKit;
_timer = hasDefuseKit ? DefuseTimeWithKit : DefuseTime;
return true;
}
private void DefuseComplete()
{
_state = BombState.Defused;
_bombModel.Visible = false;
_bombLight.Visible = false;
RemoveFromGroup("planted_bomb");
var gameEvents = GetNode<GameEvents>("/root/GameEvents");
gameEvents.EmitSignal(GameEvents.SignalName.BombDefused);
}
private void Explode()
{
_state = BombState.Exploded;
// 播放爆炸音效
_explosionSound.Play();
// 爆炸范围内所有人受到伤害
ApplyExplosionDamage();
// 隐藏模型
_bombModel.Visible = false;
_bombLight.Visible = false;
RemoveFromGroup("planted_bomb");
var gameEvents = GetNode<GameEvents>("/root/GameEvents");
gameEvents.EmitSignal(GameEvents.SignalName.BombExploded);
}
private void ApplyExplosionDamage()
{
// 获取爆炸范围内所有物体
var spaceState = GetWorld3D().DirectSpaceState;
var shape = new SphereShape3D();
shape.Radius = ExplosionRadius;
var query = new PhysicsShapeQueryParameters3D();
query.Shape = shape;
query.Transform = new Transform3D(Basis.Identity, _plantedPosition);
var results = spaceState.IntersectShape(query);
foreach (var result in results)
{
var collider = (Node3D)result["collider"];
var health = collider.GetNodeOrNull<HealthComponent>("HealthComponent");
if (health != null)
{
// 距离越远伤害越低
var dist = collider.GlobalPosition.DistanceTo(_plantedPosition);
var damageFactor = 1f - (dist / ExplosionRadius);
health.TakeDamage(ExplosionDamage * damageFactor);
}
}
}
/// <summary>
/// 随着倒计时推进,炸弹的"滴滴"声越来越快。
/// 这是 CS 的经典紧张感设计。
/// </summary>
private void UpdateBeepInterval()
{
if (_state != BombState.Planted) return;
// 最后 10 秒开始加快滴滴声
if (_timer <= 10f)
{
var interval = _timer / 10f * 0.5f;
if (!_beepSound.Playing)
{
_beepSound.Play();
}
}
// 炸弹灯闪烁
_bombLight.Visible = ((int)(_timer * 4) % 2) == 0;
}
private bool IsInBombSite(Node3D player)
{
foreach (var node in GetTree().GetNodesInGroup("bomb_site"))
{
if (node is Area3D site)
{
if (site.GetOverlappingBodies().Contains(player))
return true;
}
}
return false;
}
/// <summary>
/// 重置炸弹状态(新回合开始时调用)。
/// </summary>
public void Reset()
{
_state = BombState.None;
_carrier = null;
_timer = 0;
_bombModel.Visible = false;
_bombLight.Visible = false;
RemoveFromGroup("planted_bomb");
}
}GDScript
# Scripts/Systems/BombSystem.gd
extends Node3D
enum BombState {
NONE, # 没有炸弹
CARRIED, # 被 T 玩家携带
PLANTING, # 正在安放
PLANTED, # 已安放,倒计时中
DEFUSING, # 正在拆除
EXPLODED, # 已爆炸
DEFUSED # 已拆除
}
@export var plant_time: float = 3.2
@export var detonate_time: float = 40.0
@export var defuse_time: float = 10.0
@export var defuse_time_with_kit: float = 5.0
@export var explosion_radius: float = 17.0
@export var explosion_damage: float = 500.0
var _state: BombState = BombState.NONE
var _carrier: Node3D
var _planted_position: Vector3
var _timer: float = 0.0
var _has_defuse_kit: bool = false
@onready var _bomb_model: MeshInstance3D = $BombModel
@onready var _beep_sound: AudioStreamPlayer3D = $BeepSound
@onready var _explosion_sound: AudioStreamPlayer3D = $ExplosionSound
@onready var _bomb_light: PointLight3D = $BombLight
var state: BombState:
get: return _state
var time_remaining: float:
get: return _timer
func _ready():
_bomb_model.visible = false
_bomb_light.visible = false
func _process(delta: float):
match _state:
BombState.PLANTING:
_timer -= delta
if _timer <= 0:
_plant_complete()
BombState.PLANTED:
_timer -= delta
_update_beep_interval()
if _timer <= 0:
_explode()
BombState.DEFUSING:
_timer -= delta
if _timer <= 0:
_defuse_complete()
## 回合开始时将炸弹分配给一个 T 方玩家。
func assign_to_player(player: Node3D):
_state = BombState.CARRIED
_carrier = player
_bomb_model.visible = false
player.add_to_group("bomb_carrier")
## 开始安放炸弹(T 方玩家按 E 键)。
func try_plant(planter: Node3D) -> bool:
if _state != BombState.CARRIED:
return false
if _carrier != planter:
return false
# 检查是否在炸弹点内
if not _is_in_bomb_site(planter):
return false
_state = BombState.PLANTING
_timer = plant_time
_planted_position = planter.global_position
return true
func _plant_complete():
_state = BombState.PLANTED
_timer = detonate_time
_bomb_model.visible = true
_bomb_model.global_position = _planted_position
_bomb_light.visible = true
if _carrier:
_carrier.remove_from_group("bomb_carrier")
add_to_group("planted_bomb")
var game_events = get_node("/root/GameEvents") as GameEvents
game_events.bomb_planted.emit(_planted_position)
## 开始拆除炸弹(CT 方玩家按 E 键)。
func try_defuse(defuser: Node3D, has_defuse_kit: bool) -> bool:
if _state != BombState.PLANTED:
return false
var dist = defuser.global_position.distance_to(_planted_position)
if dist > 3.0:
return false
_state = BombState.DEFUSING
_has_defuse_kit = has_defuse_kit
_timer = defuse_time_with_kit if has_defuse_kit else defuse_time
return true
func _defuse_complete():
_state = BombState.DEFUSED
_bomb_model.visible = false
_bomb_light.visible = false
remove_from_group("planted_bomb")
var game_events = get_node("/root/GameEvents") as GameEvents
game_events.bomb_defused.emit()
func _explode():
_state = BombState.EXPLODED
_explosion_sound.play()
_apply_explosion_damage()
_bomb_model.visible = false
_bomb_light.visible = false
remove_from_group("planted_bomb")
var game_events = get_node("/root/GameEvents") as GameEvents
game_events.bomb_exploded.emit()
func _apply_explosion_damage():
var space_state = get_world_3d().direct_space_state
var shape = SphereShape3D.new()
shape.radius = explosion_radius
var query = PhysicsShapeQueryParameters3D.new()
query.shape = shape
query.transform = Transform3D(Basis.IDENTITY, _planted_position)
var results = space_state.intersect_shape(query)
for result in results:
var collider = result["collider"] as Node3D
var health = collider.get_node_or_null("HealthComponent") as HealthComponent
if health:
var dist = collider.global_position.distance_to(_planted_position)
var damage_factor = 1.0 - (dist / explosion_radius)
health.take_damage(explosion_damage * damage_factor)
## 随着倒计时推进,炸弹的"滴滴"声越来越快。
func _update_beep_interval():
if _state != BombState.PLANTED:
return
# 最后 10 秒开始加快滴滴声
if _timer <= 10.0:
if not _beep_sound.playing:
_beep_sound.play()
# 炸弹灯闪烁
_bomb_light.visible = int(_timer * 4) % 2 == 0
func _is_in_bomb_site(player: Node3D) -> bool:
for node in get_tree().get_nodes_in_group("bomb_site"):
var site = node as Area3D
if site:
if site.get_overlapping_bodies().has(player):
return true
return false
## 重置炸弹状态(新回合开始时调用)。
func reset():
_state = BombState.NONE
_carrier = null
_timer = 0.0
_bomb_model.visible = false
_bomb_light.visible = false
remove_from_group("planted_bomb")小结
这一章实现了 HUD 和炸弹两个系统:
- HUD:血量条、弹药显示、金钱、计时器、准星、击杀信息、回合提示
- 准星动态:根据移动状态调整准星大小
- 炸弹系统:携带 → 安放 → 倒计时 → 爆炸/拆除的完整生命周期
- 爆炸伤害:距离衰减的区域伤害
- 紧张感设计:炸弹倒计时越少,滴滴声越快
下一章我们给游戏加上音效和视觉效果,然后做最后的打磨。
