4. 射击与瞄准系统
2026/4/13大约 10 分钟
射击与瞄准系统
吉普车能开了,接下来给它装上武器。赤色要塞有两种主要武器:机关枪(朝移动方向射击)和手雷/炸弹(抛物线范围伤害)。这一章我们来实现完整的射击系统。
射击系统设计
武器类型
| 武器 | 射击方式 | 伤害 | 射速 | 特点 |
|---|---|---|---|---|
| 机关枪 | 直线子弹 | 低 | 快 | 对付步兵 |
| 手雷 | 抛物线轨迹 | 高 | 慢 | 对付建筑和坦克 |
| 导弹 | 直线飞行 | 很高 | 很慢 | 升级后解锁 |
射击方向方案
在原版赤色要塞中,射击方向是独立于移动方向的——你可以向右开,但朝上开枪。这给了玩家很大的灵活性。
但在简化版本中,我们也可以让射击方向跟随移动方向(车头朝哪就往哪打),降低操作难度。
| 方案 | 操作难度 | 操控复杂度 | 推荐程度 |
|---|---|---|---|
| 射击方向 = 移动方向 | 简单 | 只需方向键+射击键 | 新手推荐 |
| 射击方向独立(双摇杆) | 较难 | 需要鼠标或双摇杆手柄 | 进阶推荐 |
| 朝最近敌人自动瞄准 | 最简单 | 只需按射击键 | 休闲模式 |
推荐方案
本教程默认实现"射击方向 = 移动方向",并在代码中预留"独立瞄准"的接口。你可以在基础版本完成后,再尝试加入独立瞄准。
子弹场景
子弹是游戏中出现频率最高的物体,设计时要考虑性能。
子弹场景结构
创建 scenes/bullets/Bullet.tscn:
Bullet (Area3D) ← 使用 Area3D 做碰撞检测(轻量级)
├── MeshInstance3D ← 子弹外观(小球或小方块)
├── CollisionShape3D ← 碰撞形状(SphereShape3D)
└── Timer ← 生命周期计时器(子弹飞太久自动销毁)为什么用 Area3D 而不是 RigidBody3D?
| 对比 | Area3D | RigidBody3D |
|---|---|---|
| 性能消耗 | 低 | 较高 |
| 适用场景 | 不需要物理效果,只需要碰撞检测 | 需要弹跳、旋转等物理效果 |
| 我们的子弹 | 直线飞行,碰到就消失 → Area3D 足够 | 不需要 |
子弹脚本
C
using Godot;
/// <summary>
/// 子弹 - 直线飞行,碰到敌人造成伤害
/// </summary>
public partial class Bullet : Area3D
{
[Export] public float Speed { get; set; } = 25.0f; // 子弹飞行速度
[Export] public int Damage { get; set; } = 1; // 伤害值
[Export] public float Lifetime { get; set; } = 3.0f; // 最长存活时间(秒)
private Vector3 _direction = Vector3.Forward; // 飞行方向
private Timer _lifetimeTimer;
public override void _Ready()
{
// 设置生命周期计时器
_lifetimeTimer = GetNode<Timer>("Timer");
_lifetimeTimer.WaitTime = Lifetime;
_lifetimeTimer.Timeout += OnLifetimeExpired;
_lifetimeTimer.Start();
// 连接碰撞信号
BodyEntered += OnBodyEntered;
}
public override void _PhysicsProcess(double delta)
{
// 沿着方向直线飞行
Position += _direction * Speed * (float)delta;
}
/// <summary>
/// 设置子弹飞行方向(由发射者调用)
/// </summary>
public void SetDirection(Vector3 direction)
{
_direction = direction.Normalized();
// 让子弹模型朝向飞行方向
LookAt(Position + _direction, Vector3.Up);
}
/// <summary>
/// 碰到物体时的处理
/// </summary>
private void OnBodyEntered(Node3D body)
{
// 检查是否碰到敌人
if (body.HasMethod("TakeDamage"))
{
body.Call("TakeDamage", Damage);
}
// 子弹碰到任何东西就销毁
QueueFree();
}
/// <summary>
/// 超时自动销毁
/// </summary>
private void OnLifetimeExpired()
{
QueueFree();
}
}GDScript
# 子弹 - 直线飞行,碰到敌人造成伤害
extends Area3D
@export var speed: float = 25.0 # 子弹飞行速度
@export var damage: int = 1 # 伤害值
@export var lifetime: float = 3.0 # 最长存活时间(秒)
var _direction: Vector3 = Vector3.FORWARD # 飞行方向
func _ready():
# 设置生命周期计时器
$Timer.wait_time = lifetime
$Timer.timeout.connect(_on_lifetime_expired)
$Timer.start()
# 连接碰撞信号
body_entered.connect(_on_body_entered)
func _physics_process(delta):
# 沿着方向直线飞行
position += _direction * speed * delta
## 设置子弹飞行方向(由发射者调用)
func set_direction(direction: Vector3):
_direction = direction.normalized()
# 让子弹模型朝向飞行方向
look_at(position + _direction, Vector3.UP)
## 碰到物体时的处理
func _on_body_entered(body: Node3D):
# 检查是否碰到敌人
if body.has_method("take_damage"):
body.take_damage(damage)
# 子弹碰到任何东西就销毁
queue_free()
## 超时自动销毁
func _on_lifetime_expired():
queue_free()射击管理器
把射击逻辑从吉普车中分离出来,做成独立的武器管理器:
C
using Godot;
/// <summary>
/// 武器管理器 - 处理射击逻辑和弹药管理
/// 挂在吉普车的 GunMount 节点上
/// </summary>
public partial class WeaponManager : Node3D
{
[Export] public PackedScene BulletScene { get; set; } // 子弹场景引用
[Export] public float FireRate { get; set; } = 0.15f; // 射击间隔(秒)
[Export] public PackedScene GrenadeScene { get; set; } // 手雷场景引用
[Export] public float GrenadeCooldown { get; set; } = 1.5f; // 手雷冷却时间
private float _fireCooldown; // 当前射击冷却
private float _grenadeCooldown; // 当前手雷冷却
public override void _PhysicsProcess(double delta)
{
// 更新冷却计时
if (_fireCooldown > 0)
_fireCooldown -= (float)delta;
if (_grenadeCooldown > 0)
_grenadeCooldown -= (float)delta;
// 射击(机关枪)
if (Input.IsActionPressed("shoot") && _fireCooldown <= 0)
{
Fire();
_fireCooldown = FireRate;
}
// 特殊攻击(手雷)
if (Input.IsActionJustPressed("special") && _grenadeCooldown <= 0)
{
ThrowGrenade();
_grenadeCooldown = GrenadeCooldown;
}
}
/// <summary>
/// 发射子弹
/// </summary>
private void Fire()
{
if (BulletScene == null) return;
// 获取车头朝向作为射击方向
Node3D jeep = GetParent().GetParent<Node3D>(); // GunMount → Jeep
Vector3 shootDir = -jeep.GlobalTransform.Basis.Z; // 车头前方
// 在枪口位置创建子弹
Bullet bullet = BulletScene.Instantiate<Bullet>();
GetTree().CurrentScene.AddChild(bullet);
bullet.GlobalPosition = GlobalPosition;
bullet.SetDirection(shootDir);
}
/// <summary>
/// 投掷手雷
/// </summary>
private void ThrowGrenade()
{
if (GrenadeScene == null) return;
Node3D jeep = GetParent().GetParent<Node3D>();
Vector3 throwDir = -jeep.GlobalTransform.Basis.Z;
Grenade grenade = GrenadeScene.Instantiate<Grenade>();
GetTree().CurrentScene.AddChild(grenade);
grenade.GlobalPosition = GlobalPosition;
grenade.Throw(throwDir);
}
/// <summary>
/// 获取当前手雷冷却进度(0~1,用于UI显示)
/// </summary>
public float GetGrenadeCooldownProgress()
{
return 1.0f - (_grenadeCooldown / GrenadeCooldown);
}
}GDScript
# 武器管理器 - 处理射击逻辑和弹药管理
# 挂在吉普车的 GunMount 节点上
extends Node3D
@export var bullet_scene: PackedScene # 子弹场景引用
@export var fire_rate: float = 0.15 # 射击间隔(秒)
@export var grenade_scene: PackedScene # 手雷场景引用
@export var grenade_cooldown: float = 1.5 # 手雷冷却时间
var _fire_cooldown: float = 0.0 # 当前射击冷却
var _grenade_cooldown: float = 0.0 # 当前手雷冷却
func _physics_process(delta):
# 更新冷却计时
if _fire_cooldown > 0:
_fire_cooldown -= delta
if _grenade_cooldown > 0:
_grenade_cooldown -= delta
# 射击(机关枪)
if Input.is_action_pressed("shoot") and _fire_cooldown <= 0:
fire()
_fire_cooldown = fire_rate
# 特殊攻击(手雷)
if Input.is_action_just_pressed("special") and _grenade_cooldown <= 0:
throw_grenade()
_grenade_cooldown = grenade_cooldown
## 发射子弹
func fire():
if bullet_scene == null:
return
# 获取车头朝向作为射击方向
var jeep = get_parent().get_parent() as Node3D # GunMount -> Jeep
var shoot_dir = -jeep.global_transform.basis.z # 车头前方
# 在枪口位置创建子弹
var bullet = bullet_scene.instantiate()
get_tree().current_scene.add_child(bullet)
bullet.global_position = global_position
bullet.set_direction(shoot_dir)
## 投掷手雷
func throw_grenade():
if grenade_scene == null:
return
var jeep = get_parent().get_parent() as Node3D
var throw_dir = -jeep.global_transform.basis.z
var grenade = grenade_scene.instantiate()
get_tree().current_scene.add_child(grenade)
grenade.global_position = global_position
grenade.throw(throw_dir)
## 获取当前手雷冷却进度(0~1,用于UI显示)
func get_grenade_cooldown_progress() -> float:
return 1.0 - (_grenade_cooldown / grenade_cooldown)手雷系统
手雷和子弹不同——它是抛物线轨迹飞行,落地后产生范围伤害。
手雷场景结构
Grenade (RigidBody3D) ← 使用刚体实现抛物线运动
├── MeshInstance3D ← 手雷外观(小球)
├── CollisionShape3D ← 碰撞形状
└── ExplosionArea (Area3D) ← 爆炸范围检测
└── CollisionShape3D ← 爆炸范围(SphereShape3D,初始缩放为0)手雷脚本
C
using Godot;
/// <summary>
/// 手雷 - 抛物线飞行,落地后范围爆炸
/// </summary>
public partial class Grenade : RigidBody3D
{
[Export] public float ThrowForce { get; set; } = 15.0f; // 投掷力度
[Export] public float ThrowUpForce { get; set; } = 8.0f; // 向上的力度(产生抛物线)
[Export] public float ExplosionRadius { get; set; } = 5.0f; // 爆炸半径
[Export] public int ExplosionDamage { get; set; } = 3; // 爆炸伤害
[Export] public float FuseTime { get; set; } = 1.5f; // 引信时间(秒)
private bool _hasExploded = false;
/// <summary>
/// 投掷手雷(由武器管理器调用)
/// </summary>
public void Throw(Vector3 direction)
{
// 给手雷一个初速度:前方力度 + 向上力度
ApplyCentralImpulse(
new Vector3(
direction.X * ThrowForce,
ThrowUpForce,
direction.Z * ThrowForce
)
);
// 设置引信倒计时
var timer = new Timer();
timer.WaitTime = FuseTime;
timer.OneShot = true;
timer.Timeout += Explode;
AddChild(timer);
timer.Start();
}
public override void _PhysicsProcess(double delta)
{
// 手雷在空中旋转(视觉效果)
if (!_hasExploded)
{
RotateX((float)delta * 5.0f);
RotateZ((float)delta * 3.0f);
}
}
/// <summary>
/// 爆炸!
/// </summary>
private void Explode()
{
if (_hasExploded) return;
_hasExploded = true;
// 获取爆炸范围内的所有物体
var spaceState = GetWorld3D().DirectSpaceState;
var shape = new SphereShape3D();
shape.Radius = ExplosionRadius;
var query = new PhysicsShapeQueryParameters3D();
query.Shape = shape;
query.Transform = GlobalTransform;
query.CollisionMask = 2; // 敌人层
var results = spaceState.IntersectShape(query);
foreach (var result in results)
{
var collider = result["collider"].As<Node3D>();
if (collider.HasMethod("TakeDamage"))
{
// 根据距离计算伤害(越远伤害越低)
float distance = GlobalPosition.DistanceTo(collider.GlobalPosition);
float damageRatio = 1.0f - (distance / ExplosionRadius);
int finalDamage = (int)(ExplosionDamage * damageRatio);
collider.Call("TakeDamage", Mathf.Max(finalDamage, 1));
}
}
// TODO: 播放爆炸特效(第9章实现)
// 销毁手雷
QueueFree();
}
}GDScript
# 手雷 - 抛物线飞行,落地后范围爆炸
extends RigidBody3D
@export var throw_force: float = 15.0 # 投掷力度
@export var throw_up_force: float = 8.0 # 向上的力度(产生抛物线)
@export var explosion_radius: float = 5.0 # 爆炸半径
@export var explosion_damage: int = 3 # 爆炸伤害
@export var fuse_time: float = 1.5 # 引信时间(秒)
var _has_exploded: bool = false
## 投掷手雷(由武器管理器调用)
func throw(direction: Vector3):
# 给手雷一个初速度:前方力度 + 向上力度
apply_central_impulse(
Vector3(
direction.x * throw_force,
throw_up_force,
direction.z * throw_force
)
)
# 设置引信倒计时
var timer = Timer.new()
timer.wait_time = fuse_time
timer.one_shot = true
timer.timeout.connect(explode)
add_child(timer)
timer.start()
func _physics_process(delta):
# 手雷在空中旋转(视觉效果)
if not _has_exploded:
rotate_x(delta * 5.0)
rotate_z(delta * 3.0)
## 爆炸!
func explode():
if _has_exploded:
return
_has_exploded = true
# 获取爆炸范围内的所有物体
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 = global_transform
query.collision_mask = 2 # 敌人层
var results = space_state.intersect_shape(query)
for result in results:
var collider = result["collider"] as Node3D
if collider and collider.has_method("take_damage"):
# 根据距离计算伤害(越远伤害越低)
var distance = global_position.distance_to(collider.global_position)
var damage_ratio = 1.0 - (distance / explosion_radius)
var final_damage = int(explosion_damage * damage_ratio)
collider.take_damage(maxi(final_damage, 1))
# TODO: 播放爆炸特效(第9章实现)
# 销毁手雷
queue_free()手雷的抛物线轨迹
手雷之所以能飞出弧线,是因为我们给了它两个方向的力:
🎯 落点
╱
╱ ← 向上的力让手雷飞高
╱
●───→ ← 向前的力让手雷飞远
起点- 前方力度(ThrowForce):决定手雷飞多远
- 向上力度(ThrowUpForce):决定手雷飞多高、在空中停留多久
RigidBody3D 会自动受重力影响,所以手雷在上升后自然会下落,形成抛物线。
射击冷却时间设计
冷却时间(Cooldown)决定了武器的"节奏感":
| 武器 | 冷却时间 | 每秒射击次数 | 节奏感 |
|---|---|---|---|
| 机关枪 | 0.12~0.18秒 | 约6~8次 | 哒哒哒哒,密集输出 |
| 重机枪 | 0.08秒 | 约12次 | 更密集,压制力强 |
| 手雷 | 1.0~2.0秒 | - | 轰!等一下...轰! |
| 导弹 | 2.0~3.0秒 | - | 蓄力...发射! |
射击手感
好的射击手感不仅取决于冷却时间,还需要:
- 视觉反馈:发射时有闪光、弹壳飞出
- 音效反馈:清脆的枪声
- 屏幕震动:轻微的摄像机抖动
- 击中反馈:敌人被击中时闪白或弹数字
这些我们会在第9章(音效与视觉特效)中实现。
碰撞检测流程
子弹从发射到命中的完整流程:
1. 玩家按射击键
↓
2. WeaponManager 创建 Bullet 实例
↓
3. Bullet 设置飞行方向 = 车头朝向
↓
4. Bullet 每帧沿方向移动
↓
5. Area3D 检测到碰撞(body_entered 信号)
↓
6. 检查碰撞对象是否有 TakeDamage 方法
↓
7. 调用 TakeDamage 造成伤害
↓
8. Bullet 自毁(QueueFree)对象池优化(进阶)
如果你的游戏有大量子弹同时飞行,频繁创建和销毁节点会影响性能。解决方案是使用对象池(Object Pool):
C
using Godot;
using System.Collections.Generic;
/// <summary>
/// 简单的对象池 - 复用子弹,避免频繁创建和销毁
/// </summary>
public partial class BulletPool : Node
{
private PackedScene _bulletScene;
private Queue<Bullet> _pool = new();
private int _maxSize = 50;
public void Initialize(PackedScene bulletScene, int maxSize = 50)
{
_bulletScene = bulletScene;
_maxSize = maxSize;
}
/// <summary>
/// 获取一个子弹(从池中取或新建)
/// </summary>
public Bullet GetBullet()
{
Bullet bullet;
if (_pool.Count > 0)
{
// 池中有空闲子弹,取出来复用
bullet = _pool.Dequeue();
bullet.Visible = true;
bullet.SetDeferred("monitoring", true);
}
else
{
// 池空了,创建新子弹
bullet = _bulletScene.Instantiate<Bullet>();
GetTree().CurrentScene.AddChild(bullet);
bullet.TreeExiting += () => ReturnBullet(bullet);
}
return bullet;
}
/// <summary>
/// 归还子弹到池中(而不是销毁)
/// </summary>
public void ReturnBullet(Bullet bullet)
{
if (_pool.Count < _maxSize)
{
bullet.Visible = false;
bullet.SetDeferred("monitoring", false);
_pool.Enqueue(bullet);
}
else
{
bullet.QueueFree();
}
}
}GDScript
# 简单的对象池 - 复用子弹,避免频繁创建和销毁
extends Node
var _bullet_scene: PackedScene
var _pool: Array[Area3D] = []
var _max_size: int = 50
func initialize(bullet_scene: PackedScene, max_size: int = 50):
_bullet_scene = bullet_scene
_max_size = max_size
## 获取一个子弹(从池中取或新建)
func get_bullet() -> Area3D:
var bullet: Area3D
if _pool.size() > 0:
# 池中有空闲子弹,取出来复用
bullet = _pool.pop_front()
bullet.visible = true
bullet.set_deferred("monitoring", true)
else:
# 池空了,创建新子弹
bullet = _bullet_scene.instantiate()
get_tree().current_scene.add_child(bullet)
bullet.tree_exiting.connect(_on_bullet_returning.bind(bullet))
return bullet
## 归还子弹到池中(而不是销毁)
func return_bullet(bullet: Area3D):
if _pool.size() < _max_size:
bullet.visible = false
bullet.set_deferred("monitoring", false)
_pool.append(bullet)
else:
bullet.queue_free()
func _on_bullet_returning(bullet: Area3D):
return_bullet(bullet)对象池什么时候用?
在游戏开发初期(敌人不多、子弹不密集时),不需要对象池。直接用 QueueFree() 销毁就好。当你发现游戏在大量射击时出现卡顿,再用对象池优化。过早优化是万恶之源。
本章检查清单
- [x 子弹能直线飞行并自动销毁
下一章
有枪有弹,下一步来制作可探索的地图。
