4. 射击系统
2026/4/13大约 11 分钟
射击系统
射击系统概述
射击系统是什么?
射击系统就是"玩家按下开枪键,子弹从枪口飞出去"的整套机制。它不只是"发射一个东西"这么简单——还需要考虑子弹往哪个方向飞、多久能开下一枪、子弹碰到敌人怎么办、子弹飞出屏幕怎么办等一大堆问题。
射击系统包含以下几个核心模块:
| 模块 | 功能 | 复杂度 |
|---|---|---|
| 子弹场景 | 子弹的外观、碰撞、移动 | 低 |
| 射击方向 | 8方向射击控制 | 中 |
| 射击冷却 | 控制开枪频率 | 低 |
| 对象池 | 管理大量子弹的创建和回收 | 高 |
| 武器系统 | 不同武器有不同的子弹行为 | 高 |
创建子弹场景
子弹场景结构
Bullet (Area3D) # 子弹根节点
├── MeshInstance3D # 子弹外观(小长方体或球体)
├── CollisionShape3D # 碰撞体
├── VisibleOnScreenNotifier3D # 检测子弹是否在屏幕内
└── Bullet.cs / Bullet.gd # 子弹脚本为什么用 Area3D 而不是 RigidBody3D?
子弹不需要物理效果(不需要重力、不需要弹跳、不需要被撞飞),它只需要"飞出去"和"碰到东西"。Area3D 比 RigidBody3D 轻量得多,性能更好。当屏幕上有几百颗子弹时,这个区别就很明显了。
子弹参数
| 参数 | 普通枪 | 散弹枪 | 激光枪 |
|---|---|---|---|
| 速度 | 20 | 15 | 40 |
| 伤害 | 1 | 1(每颗) | 2 |
| 存活时间 | 2秒 | 0.5秒 | 3秒 |
| 方向 | 单发 | 5方向散射 | 单发穿透 |
子弹脚本
C#
using Godot;
/// <summary>
/// 子弹 - 从枪口飞出,碰到敌人或飞出屏幕后消失
/// </summary>
public partial class Bullet : Area3D
{
// ===== 参数 =====
[Export] public float Speed = 20.0f; // 飞行速度
[Export] public float Damage = 1.0f; // 伤害值
[Export] public float Lifetime = 2.0f; // 存活时间(秒)
[Export] public bool CanPierce = false; // 是否穿透敌人
// ===== 内部状态 =====
private Vector3 _direction = Vector3.Right; // 飞行方向
private float _lifetimeTimer = 0.0f; // 存活计时器
private bool _isActive = false; // 是否激活
/// <summary>
/// 初始化子弹(设置飞行方向)
/// </summary>
public void Initialize(Vector3 direction, float speed = -1f)
{
_direction = direction.Normalized();
if (speed > 0)
Speed = speed;
_isActive = true;
_lifetimeTimer = 0.0f;
// 旋转子弹,让它面向飞行方向
LookAt(Position + _direction, Vector3.Up);
// 连接碰撞信号
BodyEntered += OnBodyEntered;
}
public override void _PhysicsProcess(double delta)
{
if (!_isActive) return;
float dt = (float)delta;
// 1. 移动子弹
Position += _direction * Speed * dt;
// 2. 更新存活时间
_lifetimeTimer += dt;
if (_lifetimeTimer >= Lifetime)
{
Deactivate();
}
}
/// <summary>
/// 碰到其他物体时触发
/// </summary>
private void OnBodyEntered(Node3D body)
{
// 碰到敌人:造成伤害
if (body.HasMethod("TakeDamage"))
{
body.Call("TakeDamage", Damage);
}
// 如果不能穿透,碰到任何东西就消失
if (!CanPierce)
{
Deactivate();
}
}
/// <summary>
/// 飞出屏幕时触发
/// </summary>
private void OnScreenExited()
{
Deactivate();
}
/// <summary>
/// 激活子弹(从对象池取出时调用)
/// </summary>
public void Activate()
{
Show();
SetProcess(true);
SetPhysicsProcess(true);
Monitoring = true; // 开启碰撞检测
_isActive = true;
}
/// <summary>
/// 停用子弹(回到对象池时调用)
/// </summary>
public void Deactivate()
{
Hide();
SetProcess(false);
SetPhysicsProcess(false);
Monitoring = false; // 关闭碰撞检测
_isActive = false;
}
}GDScript
extends Area3D
## 子弹 - 从枪口飞出,碰到敌人或飞出屏幕后消失
# ===== 参数 =====
@export var speed: float = 20.0 # 飞行速度
@export var damage: float = 1.0 # 伤害值
@export var lifetime: float = 2.0 # 存活时间(秒)
@export var can_pierce: bool = false # 是否穿透敌人
# ===== 内部状态 =====
var _direction: Vector3 = Vector3.RIGHT # 飞行方向
var _lifetime_timer: float = 0.0 # 存活计时器
var _is_active: bool = false # 是否激活
## 初始化子弹(设置飞行方向)
func initialize(direction: Vector3, spd: float = -1.0):
_direction = direction.normalized()
if spd > 0:
speed = spd
_is_active = true
_lifetime_timer = 0.0
# 旋转子弹,让它面向飞行方向
look_at(position + _direction, Vector3.UP)
# 连接碰撞信号
body_entered.connect(_on_body_entered)
func _physics_process(delta):
if not _is_active:
return
# 1. 移动子弹
position += _direction * speed * delta
# 2. 更新存活时间
_lifetime_timer += delta
if _lifetime_timer >= lifetime:
deactivate()
## 碰到其他物体时触发
func _on_body_entered(body: Node3D):
# 碰到敌人:造成伤害
if body.has_method("TakeDamage"):
body.TakeDamage(damage)
# 如果不能穿透,碰到任何东西就消失
if not can_pierce:
deactivate()
## 飞出屏幕时触发
func _on_screen_exited():
deactivate()
## 激活子弹(从对象池取出时调用)
func activate():
show()
set_process(true)
set_physics_process(true)
monitoring = true # 开启碰撞检测
_is_active = true
## 停用子弹(回到对象池时调用)
func deactivate():
hide()
set_process(false)
set_physics_process(false)
monitoring = false # 关闭碰撞检测
_is_active = false8方向射击
魂斗罗的经典操作之一就是可以朝8个方向射击。我们需要根据玩家的输入来决定子弹飞行的方向。
8方向示意图
↑(上)
╱ ╲
↖(左上) (右上)↗
←(左) (右)→
↙(左下) (右下)↘
╲ ╱
↓(下)方向计算逻辑
C#
/// <summary>
/// 根据玩家输入计算射击方向
/// </summary>
private Vector3 GetShootDirection()
{
float inputX = 0;
float inputY = 0;
// 读取方向输入
if (Input.IsActionPressed("move_right")) inputX += 1;
if (Input.IsActionPressed("move_left")) inputX -= 1;
if (Input.IsActionPressed("move_up") || Input.IsActionPressed("jump")) inputY += 1;
if (Input.IsActionPressed("duck")) inputY -= 1;
// 如果没有方向输入,默认朝面朝方向射击
if (inputX == 0 && inputY == 0)
{
inputX = FacingDirection;
}
// 归一化(让斜向的长度也是1)
Vector3 direction = new Vector3(inputX, inputY, 0).Normalized();
return direction;
}GDScript
## 根据玩家输入计算射击方向
func _get_shoot_direction() -> Vector3:
var input_x: float = 0
var input_y: float = 0
# 读取方向输入
if Input.is_action_pressed("move_right"):
input_x += 1
if Input.is_action_pressed("move_left"):
input_x -= 1
if Input.is_action_pressed("move_up") or Input.is_action_pressed("jump"):
input_y += 1
if Input.is_action_pressed("duck"):
input_y -= 1
# 如果没有方向输入,默认朝面朝方向射击
if input_x == 0 and input_y == 0:
input_x = facing_direction
# 归一化(让斜向的长度也是1)
var direction = Vector3(input_x, input_y, 0).normalized()
return direction射击冷却
什么是射击冷却?
射击冷却就是"两枪之间的最小间隔时间"。如果没有冷却,玩家按住射击键,每帧都会发射一颗子弹——以60帧计算,每秒发射60颗子弹,这太多了。加了冷却后,比如设为0.15秒,每秒最多只能发射约6.6颗子弹。
C#
// 射击冷却相关
[Export] public float FireRate = 0.15f; // 射击间隔(秒)
private float _fireTimer = 0.0f; // 射击计时器
private void HandleShooting(float dt)
{
// 更新射击计时器
if (_fireTimer > 0)
_fireTimer -= dt;
// 按下射击键 且 冷却完毕
if (Input.IsActionPressed("shoot") && _fireTimer <= 0)
{
Shoot();
_fireTimer = FireRate; // 重置冷却
}
}
private void Shoot()
{
Vector3 direction = GetShootDirection();
_shootingManager.SpawnBullet(Position, direction);
}GDScript
# 射击冷却相关
@export var fire_rate: float = 0.15 # 射击间隔(秒)
var _fire_timer: float = 0.0 # 射击计时器
func _handle_shooting(dt: float):
# 更新射击计时器
if _fire_timer > 0:
_fire_timer -= dt
# 按下射击键 且 冷却完毕
if Input.is_action_pressed("shoot") and _fire_timer <= 0:
_shoot()
_fire_timer = fire_rate # 重置冷却
func _shoot():
var direction = _get_shoot_direction()
_shooting_manager.spawn_bullet(position, direction)子弹对象池
为什么需要对象池?
想象你在玩射击游戏,每秒发射10颗子弹,每颗子弹存活2秒。那屏幕上同时最多有20颗子弹。如果你每次都"新建一颗子弹、用完就销毁",就会反复创建和销毁对象——这会导致内存碎片和性能问题。
对象池的思路是:提前创建一批子弹放好,需要用时从池子里"借"一颗,用完了"还"回去。这样就不会反复创建和销毁了。
对象池工作原理
初始化:创建20颗子弹,全部设为"未激活"状态
┌───┬───┬───┬───┬───┐
│ ○ │ ○ │ ○ │ ○ │ ○ │ ○ = 未激活
└───┴───┴───┴───┴───┘
发射子弹:从池中取出一颗,设为"激活"
┌───┬───┬───┬───┬───┐
│ ● │ ○ │ ○ │ ○ │ ○ │ ● = 已激活
└───┴───┴───┴───┴───┘
连续发射:再取出三颗
┌───┬───┬───┬───┬───┐
│ ● │ ● │ ● │ ● │ ○ │
└───┴───┴───┴───┴───┘
子弹消失:回到池中
┌───┬───┬───┬───┬───┐
│ ○ │ ● │ ● │ ● │ ○ │
└───┴───┴───┴───┴───┘对象池实现
C#
using Godot;
using System.Collections.Generic;
/// <summary>
/// 子弹对象池 - 管理子弹的创建和回收
/// </summary>
public partial class BulletPool : Node3D
{
[Export] public PackedScene BulletScene; // 子弹场景
[Export] public int PoolSize = 50; // 池子大小
private readonly List<Bullet> _pool = new();
public override void _Ready()
{
// 预先创建所有子弹
for (int i = 0; i < PoolSize; i++)
{
var bullet = BulletScene.Instantiate<Bullet>();
bullet.Deactivate(); // 初始为未激活状态
AddChild(bullet);
_pool.Add(bullet);
}
}
/// <summary>
/// 从池中获取一颗子弹
/// </summary>
public Bullet GetBullet()
{
// 遍历池子,找到第一颗未激活的子弹
foreach (var bullet in _pool)
{
if (!bullet.IsActive)
{
bullet.Activate();
return bullet;
}
}
// 如果池子满了,所有子弹都在用
GD.PrintWarn("子弹池已满!无法创建新子弹。");
return null;
}
/// <summary>
/// 释放子弹(回到池中)
/// </summary>
public void ReleaseBullet(Bullet bullet)
{
bullet.Deactivate();
}
}GDScript
extends Node3D
## 子弹对象池 - 管理子弹的创建和回收
@export var bullet_scene: PackedScene # 子弹场景
@export var pool_size: int = 50 # 池子大小
var _pool: Array[Bullet] = []
func _ready():
# 预先创建所有子弹
for i in range(pool_size):
var bullet = bullet_scene.instantiate()
bullet.deactivate() # 初始为未激活状态
add_child(bullet)
_pool.append(bullet)
## 从池中获取一颗子弹
func get_bullet() -> Bullet:
# 遍历池子,找到第一颗未激活的子弹
for bullet in _pool:
if not bullet._is_active:
bullet.activate()
return bullet
# 如果池子满了,所有子弹都在用
push_warning("子弹池已满!无法创建新子弹。")
return null
## 释放子弹(回到池中)
func release_bullet(bullet: Bullet):
bullet.deactivate()武器系统
武器类型定义
| 武器 | 子弹数 | 射速 | 特殊效果 |
|---|---|---|---|
| 普通枪 | 1发 | 中等 | 无 |
| 机枪 | 1发 | 很快 | 射速翻倍 |
| 散弹枪 | 5发 | 慢 | 扇形散射 |
| 激光枪 | 1发 | 快 | 穿透敌人 |
武器数据结构
C#
/// <summary>
/// 武器数据 - 描述一种武器的属性
/// </summary>
public class WeaponData
{
public string Name { get; set; } // 武器名称
public float FireRate { get; set; } // 射击间隔
public float BulletSpeed { get; set; } // 子弹速度
public float BulletDamage { get; set; } // 子弹伤害
public int BulletCount { get; set; } // 每次发射子弹数
public float SpreadAngle { get; set; } // 散射角度(度)
public bool CanPierce { get; set; } // 是否穿透
public int Level { get; set; } // 武器等级
// 预设武器
public static WeaponData Normal => new()
{
Name = "普通枪", FireRate = 0.2f, BulletSpeed = 20f,
BulletDamage = 1f, BulletCount = 1, SpreadAngle = 0f,
CanPierce = false, Level = 1
};
public static WeaponData MachineGun => new()
{
Name = "机枪", FireRate = 0.1f, BulletSpeed = 22f,
BulletDamage = 1f, BulletCount = 1, SpreadAngle = 0f,
CanPierce = false, Level = 2
};
public static WeaponData SpreadGun => new()
{
Name = "散弹枪", FireRate = 0.35f, BulletSpeed = 18f,
BulletDamage = 1f, BulletCount = 5, SpreadAngle = 30f,
CanPierce = false, Level = 3
};
public static WeaponData LaserGun => new()
{
Name = "激光枪", FireRate = 0.15f, BulletSpeed = 40f,
BulletDamage = 2f, BulletCount = 1, SpreadAngle = 0f,
CanPierce = true, Level = 4
};
}GDScript
## 武器数据 - 描述一种武器的属性
class_name WeaponData
var name: String # 武器名称
var fire_rate: float # 射击间隔
var bullet_speed: float # 子弹速度
var bullet_damage: float # 子弹伤害
var bullet_count: int # 每次发射子弹数
var spread_angle: float # 散射角度(度)
var can_pierce: bool # 是否穿透
var level: int # 武器等级
# 预设武器
static func normal() -> WeaponData:
var data = WeaponData.new()
data.name = "普通枪"
data.fire_rate = 0.2
data.bullet_speed = 20.0
data.bullet_damage = 1.0
data.bullet_count = 1
data.spread_angle = 0.0
data.can_pierce = false
data.level = 1
return data
static func machine_gun() -> WeaponData:
var data = WeaponData.new()
data.name = "机枪"
data.fire_rate = 0.1
data.bullet_speed = 22.0
data.bullet_damage = 1.0
data.bullet_count = 1
data.spread_angle = 0.0
data.can_pierce = false
data.level = 2
return data
static func spread_gun() -> WeaponData:
var data = WeaponData.new()
data.name = "散弹枪"
data.fire_rate = 0.35
data.bullet_speed = 18.0
data.bullet_damage = 1.0
data.bullet_count = 5
data.spread_angle = 30.0
data.can_pierce = false
data.level = 3
return data
static func laser_gun() -> WeaponData:
var data = WeaponData.new()
data.name = "激光枪"
data.fire_rate = 0.15
data.bullet_speed = 40.0
data.bullet_damage = 2.0
data.bullet_count = 1
data.spread_angle = 0.0
data.can_pierce = true
data.level = 4
return data射击管理器
射击管理器是连接玩家和子弹的桥梁。它负责:从对象池取子弹、设置子弹方向和参数、处理武器切换。
C#
using Godot;
/// <summary>
/// 射击管理器 - 管理子弹发射和武器切换
/// </summary>
public partial class ShootingManager : Node3D
{
[Export] public BulletPool Pool; // 子弹对象池
[Export] public PackedScene MuzzleFlash; // 枪口火焰特效
private WeaponData _currentWeapon;
public override void _Ready()
{
// 默认武器是普通枪
_currentWeapon = WeaponData.Normal;
}
/// <summary>
/// 发射子弹
/// </summary>
public void SpawnBullet(Vector3 origin, Vector3 direction)
{
// 根据子弹数量发射(散弹枪发射多颗)
for (int i = 0; i < _currentWeapon.BulletCount; i++)
{
Bullet bullet = Pool.GetBullet();
if (bullet == null) continue;
// 计算散射方向
Vector3 finalDir = direction;
if (_currentWeapon.BulletCount > 1)
{
float angleSpread = Mathf.DegToRad(_currentWeapon.SpreadAngle);
float startAngle = -angleSpread / 2;
float step = _currentWeapon.BulletCount > 1
? angleSpread / (_currentWeapon.BulletCount - 1) : 0;
float angle = startAngle + step * i;
// 旋转方向向量
finalDir = direction.Rotated(Vector3.Forward, angle);
}
// 设置子弹位置和方向
bullet.GlobalPosition = origin;
bullet.Speed = _currentWeapon.BulletSpeed;
bullet.Damage = _currentWeapon.BulletDamage;
bullet.CanPierce = _currentWeapon.CanPierce;
bullet.Initialize(finalDir);
}
}
/// <summary>
/// 切换武器
/// </summary>
public void SwitchWeapon(WeaponData newWeapon)
{
_currentWeapon = newWeapon;
GD.Print($"切换武器: {_currentWeapon.Name}");
}
/// <summary>
/// 当前武器的射击间隔
/// </summary>
public float CurrentFireRate => _currentWeapon.FireRate;
}GDScript
extends Node3D
## 射击管理器 - 管理子弹发射和武器切换
@export var pool: BulletPool # 子弹对象池
@export var muzzle_flash: PackedScene # 枪口火焰特效
var _current_weapon: WeaponData
func _ready():
# 默认武器是普通枪
_current_weapon = WeaponData.normal()
## 发射子弹
func spawn_bullet(origin: Vector3, direction: Vector3):
# 根据子弹数量发射(散弹枪发射多颗)
for i in range(_current_weapon.bullet_count):
var bullet = pool.get_bullet()
if bullet == null:
continue
# 计算散射方向
var final_dir = direction
if _current_weapon.bullet_count > 1:
var angle_spread = deg_to_rad(_current_weapon.spread_angle)
var start_angle = -angle_spread / 2
var step = angle_spread / (_current_weapon.bullet_count - 1) \
if _current_weapon.bullet_count > 1 else 0
var angle = start_angle + step * i
# 旋转方向向量
final_dir = direction.rotated(Vector3.FORWARD, angle)
# 设置子弹位置和方向
bullet.global_position = origin
bullet.speed = _current_weapon.bullet_speed
bullet.damage = _current_weapon.bullet_damage
bullet.can_pierce = _current_weapon.can_pierce
bullet.initialize(final_dir)
## 切换武器
func switch_weapon(new_weapon: WeaponData):
_current_weapon = new_weapon
print("切换武器: %s" % _current_weapon.name)
## 当前武器的射击间隔
var current_fire_rate: float:
get:
return _current_weapon.fire_rate本章小结
本章我们实现了完整的射击系统:
- 子弹场景——Area3D + 碰撞 + 自动消失
- 8方向射击——根据玩家输入计算射击方向
- 射击冷却——控制开枪频率,防止子弹太多
- 子弹对象池——预创建子弹,反复利用,提升性能
- 武器系统——支持普通枪、机枪、散弹枪、激光枪
- 射击管理器——统一管理子弹发射和武器切换
性能提醒
对象池是射击游戏中最关键的性能优化之一。如果没有对象池,每次发射子弹都要 Instantiate 和 Free,在子弹密集时会导致明显的卡顿。务必使用对象池。
