4. 武器系统与射击
2026/4/14大约 13 分钟
武器系统与射击
如果角色控制器是 CS 的"地基",那武器系统就是 CS 的"灵魂"。这一章我们要实现:多武器切换、射线命中检测、后坐力模式、弹道散布、换弹机制、伤害计算——这些组合在一起就构成了 CS 独一无二的"枪感"。
武器系统架构
在写代码之前,先搞清楚武器系统的整体结构:
WeaponManager (管理器) ← 管理所有武器槽位和切换
├── Slot 0: 主武器(步枪/冲锋枪) ← 按 1 切换
├── Slot 1: 副武器(手枪) ← 按 2 切换
└── Slot 2: 近战(刀具) ← 按 3 切换
WeaponBase (武器基类) ← 所有武器的共同逻辑
├── HitscanWeapon (射线武器) ← 所有枪械
│ ├── 射击(发射射线检测命中)
│ ├── 后坐力模式
│ ├── 弹道散布
│ └── 换弹
└── MeleeWeapon (近战武器) ← 刀
└── 近战攻击(近距离区域检测)什么是"射线武器"(Hitscan)?
游戏里的枪械有两种主流实现方式:
- 射线检测(Hitscan):开枪瞬间从枪口发出一条射线,立刻判定命中。CS 的所有枪都是这种。优点是响应极快、手感精准。
- 弹道模拟(Projectile):真的创建一个子弹物体,模拟它的飞行轨迹。适合狙击游戏(如《狙击精英》)。
CS 选择射线检测是因为它能让射击反馈"即时"——扣下鼠标的一瞬间就知道打没打中。
武器基类
所有武器共享的逻辑抽到一个基类里:切换动画、武器状态管理。
C#
// Scripts/Weapons/WeaponBase.cs
using Godot;
public enum WeaponState
{
Idle, // 待机,可以射击
Firing, // 正在射击
Reloading, // 正在换弹
Switching, // 正在切换武器
Drawing // 刚拿出武器的拔枪动画
}
/// <summary>
/// 武器基类。定义了所有武器的通用行为。
/// 具体的射击逻辑(射线/近战)由子类实现。
/// </summary>
public partial class WeaponBase : Node3D
{
[Export] public WeaponData Data { get; set; }
[Export] public float DrawTime { get; set; } = 0.6f;
[Export] public float SwitchTime { get; set; } = 0.3f;
protected WeaponState _state = WeaponState.Drawing;
protected int _currentAmmo;
protected int _reserveAmmo;
protected float _fireTimer;
protected float _stateTimer;
public WeaponState State => _state;
public int CurrentAmmo => _currentAmmo;
public int ReserveAmmo => _reserveAmmo;
public bool CanFire => _state == WeaponState.Idle && _currentAmmo > 0;
public override void _Ready()
{
if (Data != null)
{
_currentAmmo = Data.MagazineSize;
_reserveAmmo = Data.ReserveAmmo;
}
}
public override void _Process(double delta)
{
var dt = (float)delta;
switch (_state)
{
case WeaponState.Drawing:
_stateTimer += dt;
if (_stateTimer >= DrawTime)
_state = WeaponState.Idle;
break;
case WeaponState.Switching:
_stateTimer += dt;
if (_stateTimer >= SwitchTime)
_state = WeaponState.Drawing;
break;
case WeaponState.Firing:
_fireTimer -= dt;
if (_fireTimer <= 0)
_state = WeaponState.Idle;
break;
case WeaponState.Reloading:
_stateTimer += dt;
if (_stateTimer >= Data.ReloadTime)
FinishReload();
break;
}
}
/// <summary>
/// 尝试射击。由子类实现具体命中检测。
/// </summary>
public virtual bool TryFire(Vector3 origin, Vector3 direction)
{
if (!CanFire) return false;
_currentAmmo--;
_fireTimer = Data.FireRate;
_state = WeaponState.Firing;
return true;
}
/// <summary>
/// 开始换弹
/// </summary>
public virtual void Reload()
{
if (_state != WeaponState.Idle) return;
if (_currentAmmo >= Data.MagazineSize) return;
if (_reserveAmmo <= 0) return;
_state = WeaponState.Reloading;
_stateTimer = 0;
}
private void FinishReload()
{
int needed = Data.MagazineSize - _currentAmmo;
int toLoad = Mathf.Min(needed, _reserveAmmo);
_currentAmmo += toLoad;
_reserveAmmo -= toLoad;
_state = WeaponState.Idle;
}
/// <summary>
/// 切换武器时调用
/// </summary>
public void StartSwitchOut()
{
_state = WeaponState.Switching;
_stateTimer = 0;
}
public void StartSwitchIn()
{
_state = WeaponState.Drawing;
_stateTimer = 0;
}
/// <summary>
/// 补充弹药(买枪时调用)
/// </summary>
public void RefillAmmo()
{
_currentAmmo = Data.MagazineSize;
_reserveAmmo = Data.ReserveAmmo;
}
}GDScript
# Scripts/Weapons/WeaponBase.gd
extends Node3D
class_name WeaponBase
enum WeaponState {
IDLE, # 待机,可以射击
FIRING, # 正在射击
RELOADING, # 正在换弹
SWITCHING, # 正在切换武器
DRAWING # 刚拿出武器的拔枪动画
}
@export var data: WeaponData
@export var draw_time: float = 0.6
@export var switch_time: float = 0.3
var _state: WeaponState = WeaponState.DRAWING
var _current_ammo: int
var _reserve_ammo: int
var _fire_timer: float = 0.0
var _state_timer: float = 0.0
var state: WeaponState:
get: return _state
var current_ammo: int:
get: return _current_ammo
var reserve_ammo: int:
get: return _reserve_ammo
var can_fire: bool:
get: return _state == WeaponState.IDLE and _current_ammo > 0
func _ready():
if data:
_current_ammo = data.magazine_size
_reserve_ammo = data.reserve_ammo
func _process(delta: float):
match _state:
WeaponState.DRAWING:
_state_timer += delta
if _state_timer >= draw_time:
_state = WeaponState.IDLE
WeaponState.SWITCHING:
_state_timer += delta
if _state_timer >= switch_time:
_state = WeaponState.DRAWING
WeaponState.FIRING:
_fire_timer -= delta
if _fire_timer <= 0:
_state = WeaponState.IDLE
WeaponState.RELOADING:
_state_timer += delta
if _state_timer >= data.reload_time:
_finish_reload()
## 尝试射击。由子类实现具体命中检测。
func try_fire(origin: Vector3, direction: Vector3) -> bool:
if not can_fire:
return false
_current_ammo -= 1
_fire_timer = data.fire_rate
_state = WeaponState.FIRING
return true
## 开始换弹
func reload():
if _state != WeaponState.IDLE:
return
if _current_ammo >= data.magazine_size:
return
if _reserve_ammo <= 0:
return
_state = WeaponState.RELOADING
_state_timer = 0.0
func _finish_reload():
var needed = data.magazine_size - _current_ammo
var to_load = mini(needed, _reserve_ammo)
_current_ammo += to_load
_reserve_ammo -= to_load
_state = WeaponState.IDLE
## 切换武器时调用
func start_switch_out():
_state = WeaponState.SWITCHING
_state_timer = 0.0
func start_switch_in():
_state = WeaponState.DRAWING
_state_timer = 0.0
## 补充弹药(买枪时调用)
func refill_ammo():
_current_ammo = data.magazine_size
_reserve_ammo = data.reserve_ammo射线武器(所有枪械)
这是最核心的部分——射击、后坐力、弹道散布都在这里实现。
C#
// Scripts/Weapons/HitscanWeapon.cs
using Godot;
/// <summary>
/// 射线武器。所有枪械(手枪、步枪、狙击枪)都用这个类。
///
/// 射击流程:
/// 1. 检查能否射击(弹药、状态)
/// 2. 从摄像机位置发射射线
/// 3. 根据后坐力和散布偏移射线方向
/// 4. 检测命中 → 计算伤害 → 应用后坐力
/// </summary>
public partial class HitscanWeapon : WeaponBase
{
[Export] public float MaxRayDistance { get; set; } = 200f;
// 后坐力累积
private float _recoilAccumulator;
private int _consecutiveShots;
private float _recoilRecoveryTimer;
// 散布
private float _currentSpread;
// 换弹声音
[Export] public AudioStream ShootSound { get; set; }
private AudioStreamPlayer3D _audioPlayer;
public override void _Ready()
{
base._Ready();
_audioPlayer = new AudioStreamPlayer3D();
AddChild(_audioPlayer);
}
public override bool TryFire(Vector3 origin, Vector3 direction)
{
if (!base.TryFire(origin, direction))
return false;
// 计算当前散布(移动越大散布越大)
_currentSpread = CalculateSpread();
// 应用散布偏移
var spreadOffset = CalculateSpreadOffset(_currentSpread);
var finalDirection = (direction + spreadOffset).Normalized();
// 发射射线检测命中
var hitResult = PerformRaycast(origin, finalDirection);
if (hitResult != null)
{
HandleHit(hitResult);
}
// 应用后坐力
ApplyRecoil();
// 播放射击音效
PlayShootSound();
// 更新连续射击计数
_consecutiveShots++;
return true;
}
/// <summary>
/// 计算当前弹道散布值。
/// 散布受三个因素影响:移动速度、蹲下、连续射击。
/// </summary>
private float CalculateSpread()
{
var player = GetParent()?.GetParent() as PlayerController;
if (player == null) return Data.StandingSpread;
float baseSpread = Data.StandingSpread;
// 蹲下时散布减小
if (player.IsCrouching)
baseSpread = Data.CrouchingSpread;
// 移动时散布增大(根据速度线性增加)
float speedRatio = player.SpeedFactor;
if (speedRatio > 0.1f)
{
// 静步时散布增加较小
if (player.IsSprinting)
baseSpread = Mathf.Lerp(baseSpread, Data.WalkSpeed, speedRatio);
else
baseSpread = Mathf.Lerp(baseSpread, Data.MovingSpread, speedRatio);
}
// 连续射击会增加散布
float spreadPenalty = _consecutiveShots * 0.005f;
baseSpread += spreadPenalty;
return baseSpread;
}
/// <summary>
/// 生成随机散布偏移。
/// 在一个圆锥体内随机取一个方向偏移。
/// </summary>
private Vector3 CalculateSpreadOffset(float spread)
{
// 在单位圆内随机取一个点
float angle = (float)GD.RandRange(0, Mathf.Tau);
float radius = (float)GD.RandRange(0, spread);
return new Vector3(
Mathf.Cos(angle) * radius,
Mathf.Sin(angle) * radius,
0
);
}
/// <summary>
/// 执行射线检测。从摄像机位置向指定方向发射一条射线。
/// </summary>
private Godot.Collections.Dictionary PerformRaycast(Vector3 origin, Vector3 direction)
{
var spaceState = GetWorld3D().DirectSpaceState;
var endPoint = origin + direction * MaxRayDistance;
var query = PhysicsRayQueryParameters3D.Create(origin, endPoint);
// 排除自身碰撞体(避免打中自己)
var player = GetParent()?.GetParent() as CharacterBody3D;
if (player != null)
{
query.Exclude = new Godot.Collections.Rid[] { player.GetRid() };
}
return spaceState.IntersectRay(query);
}
/// <summary>
/// 处理命中。根据击中部位计算伤害。
/// </summary>
private void HandleHit(Godot.Collections.Dictionary hitResult)
{
var collider = (Node3D)hitResult["collider"];
var hitPosition = (Vector3)hitResult["position"];
// 检查是否击中了角色
// 通过碰撞体所属组判断命中部位
float damage = Data.BaseDamage;
string hitZone = "body";
if (collider.IsInGroup("head_hitbox"))
{
damage *= Data.HeadshotMultiplier;
hitZone = "head";
}
else if (collider.IsInGroup("stomach_hitbox"))
{
damage *= Data.StomachMultiplier;
hitZone = "stomach";
}
else if (collider.IsInGroup("leg_hitbox"))
{
damage *= Data.LegMultiplier;
hitZone = "leg";
}
else
{
damage *= Data.ChestMultiplier;
}
// 发射命中信号
var gameEvents = GetNode<GameEvents>("/root/GameEvents");
gameEvents.EmitSignal(GameEvents.SignalName.PlayerHit, collider, damage, hitZone);
// 创建命中特效(弹孔/火花)
CreateHitEffect(hitPosition, hitResult["normal"] as Vector3? ?? Vector3.Up);
}
/// <summary>
/// 应用后坐力。每把枪的后坐力模式不同。
///
/// 后坐力分两部分:
/// - 垂直后坐力:枪口上抬(所有枪都有)
/// - 水平后坐力:左右偏移(步枪比较明显)
/// </summary>
private void ApplyRecoil()
{
if (Data == null) return;
float verticalRecoil;
float horizontalRecoil;
if (Data.RecoilPattern != null)
{
// 使用预定义的后坐力曲线
float t = Mathf.Min((float)_consecutiveShots / 30f, 1.0f);
verticalRecoil = Data.RecoilPattern.SampleBaked(t) * Data.RecoilMagnitude;
}
else
{
// 默认后坐力:逐渐增大
verticalRecoil = Data.RecoilMagnitude * (1f + _consecutiveShots * 0.1f);
}
// 水平后坐力:随机左右偏移
horizontalRecoil = (float)GD.RandRange(
-Data.RecoilMagnitude * 0.3f,
Data.RecoilMagnitude * 0.3f
);
// 通知摄像机添加后坐力
var player = GetParent()?.GetParent() as CharacterBody3D;
if (player != null)
{
var camera = player.GetNode<FPSCamera>("Head");
camera.AddRecoil(verticalRecoil, horizontalRecoil);
}
}
public override void _Process(double delta)
{
base._Process(delta);
// 后坐力恢复(停止射击后准星慢慢回到原位)
if (_state != WeaponState.Firing)
{
_recoilRecoveryTimer += (float)delta;
if (_recoilRecoveryTimer > 0.1f) // 停止射击 0.1 秒后开始恢复
{
_consecutiveShots = 0;
_recoilAccumulator = 0;
}
}
else
{
_recoilRecoveryTimer = 0;
}
}
private void PlayShootSound()
{
if (ShootSound != null)
{
_audioPlayer.Stream = ShootSound;
_audioPlayer.PitchScale = (float)GD.RandRange(0.95, 1.05);
_audioPlayer.Play();
}
}
private void CreateHitEffect(Vector3 position, Vector3 normal)
{
// 创建弹孔特效(简化版,用粒子表示)
// 实际项目中会从对象池取出预制好的特效实例
GD.Print($"命中位置: {position}, 爆头特效等");
}
}GDScript
# Scripts/Weapons/HitscanWeapon.gd
extends WeaponBase
class_name HitscanWeapon
@export var max_ray_distance: float = 200.0
## 后坐力累积
var _recoil_accumulator: float = 0.0
var _consecutive_shots: int = 0
var _recoil_recovery_timer: float = 0.0
## 散布
var _current_spread: float = 0.0
## 射击声音
@export var shoot_sound: AudioStream
@onready var _audio_player: AudioStreamPlayer3D = AudioStreamPlayer3D.new()
func _ready():
super._ready()
add_child(_audio_player)
func try_fire(origin: Vector3, direction: Vector3) -> bool:
if not super.try_fire(origin, direction):
return false
# 计算当前散布(移动越大散布越大)
_current_spread = _calculate_spread()
# 应用散布偏移
var spread_offset = _calculate_spread_offset(_current_spread)
var final_direction = (direction + spread_offset).normalized()
# 发射射线检测命中
var hit_result = _perform_raycast(origin, final_direction)
if hit_result.size() > 0:
_handle_hit(hit_result)
# 应用后坐力
_apply_recoil()
# 播放射击音效
_play_shoot_sound()
# 更新连续射击计数
_consecutive_shots += 1
return true
## 计算当前弹道散布值。
## 散布受三个因素影响:移动速度、蹲下、连续射击。
func _calculate_spread() -> float:
var player = get_parent()?.get_parent() as PlayerController
if not player:
return data.standing_spread
var base_spread: float = data.standing_spread
# 蹲下时散布减小
if player.is_crouching:
base_spread = data.crouching_spread
# 移动时散布增大(根据速度线性增加)
var speed_ratio: float = player.speed_factor
if speed_ratio > 0.1:
if player.is_sprinting:
base_spread = lerpf(base_spread, data.walk_speed, speed_ratio)
else:
base_spread = lerpf(base_spread, data.moving_spread, speed_ratio)
# 连续射击会增加散布
var spread_penalty: float = _consecutive_shots * 0.005
base_spread += spread_penalty
return base_spread
## 生成随机散布偏移。
## 在一个圆锥体内随机取一个方向偏移。
func _calculate_spread_offset(spread: float) -> Vector3:
# 在单位圆内随机取一个点
var angle: float = randf_range(0, TAU)
var radius: float = randf_range(0, spread)
return Vector3(cos(angle) * radius, sin(angle) * radius, 0)
## 执行射线检测。从摄像机位置向指定方向发射一条射线。
func _perform_raycast(origin: Vector3, direction: Vector3) -> Dictionary:
var space_state = get_world_3d().direct_space_state
var end_point = origin + direction * max_ray_distance
var query = PhysicsRayQueryParameters3D.create(origin, end_point)
# 排除自身碰撞体(避免打中自己)
var player = get_parent()?.get_parent() as CharacterBody3D
if player:
query.exclude = [player.get_rid()]
return space_state.intersect_ray(query)
## 处理命中。根据击中部位计算伤害。
func _handle_hit(hit_result: Dictionary):
var collider = hit_result["collider"] as Node3D
var hit_position = hit_result["position"] as Vector3
# 通过碰撞体所属组判断命中部位
var damage: float = data.base_damage
var hit_zone: String = "body"
if collider.is_in_group("head_hitbox"):
damage *= data.headshot_multiplier
hit_zone = "head"
elif collider.is_in_group("stomach_hitbox"):
damage *= data.stomach_multiplier
hit_zone = "stomach"
elif collider.is_in_group("leg_hitbox"):
damage *= data.leg_multiplier
hit_zone = "leg"
else:
damage *= data.chest_multiplier
# 发射命中信号
var game_events = get_node("/root/GameEvents") as GameEvents
game_events.player_hit.emit(collider, damage, hit_zone)
# 创建命中特效
_create_hit_effect(hit_position, hit_result.get("normal", Vector3.UP))
## 应用后坐力。
## 后坐力分两部分:
## - 垂直后坐力:枪口上抬(所有枪都有)
## - 水平后坐力:左右偏移(步枪比较明显)
func _apply_recoil():
if not data:
return
var vertical_recoil: float
var horizontal_recoil: float
if data.recoil_pattern:
# 使用预定义的后坐力曲线
var t: float = minf(float(_consecutive_shots) / 30.0, 1.0)
vertical_recoil = data.recoil_pattern.sample_baked(t) * data.recoil_magnitude
else:
# 默认后坐力:逐渐增大
vertical_recoil = data.recoil_magnitude * (1.0 + _consecutive_shots * 0.1)
# 水平后坐力:随机左右偏移
horizontal_recoil = randf_range(
-data.recoil_magnitude * 0.3,
data.recoil_magnitude * 0.3
)
# 通知摄像机添加后坐力
var player = get_parent()?.get_parent() as CharacterBody3D
if player:
var camera = player.get_node("Head") as FPSCamera
camera.add_recoil(vertical_recoil, horizontal_recoil)
func _process(delta: float):
super._process(delta)
# 后坐力恢复(停止射击后准星慢慢回到原位)
if _state != WeaponState.FIRING:
_recoil_recovery_timer += delta
if _recoil_recovery_timer > 0.1: # 停止射击 0.1 秒后开始恢复
_consecutive_shots = 0
_recoil_accumulator = 0.0
else:
_recoil_recovery_timer = 0.0
func _play_shoot_sound():
if shoot_sound:
_audio_player.stream = shoot_sound
_audio_player.pitch_scale = randf_range(0.95, 1.05)
_audio_player.play()
func _create_hit_effect(position: Vector3, normal: Vector3):
# 创建弹孔特效(简化版)
print("命中位置: ", position)武器管理器
武器管理器负责管理三个武器槽位和切换逻辑。
C#
// Scripts/Weapons/WeaponManager.cs
using Godot;
using System.Collections.Generic;
/// <summary>
/// 武器管理器。管理主武器、副武器、近战三个槽位。
/// </summary>
public partial class WeaponManager : Node3D
{
private WeaponBase[] _weapons = new WeaponBase[3]; // 0=主武器, 1=副武器, 2=近战
private int _currentSlot;
private PlayerController _player;
private FPSCamera _camera;
public WeaponBase CurrentWeapon => _weapons[_currentSlot];
public override void _Ready()
{
_player = GetParent<PlayerController>();
_camera = _player.GetNode<FPSCamera>("Head");
// 默认装备:刀具
// 实际项目中这里会加载场景
}
public override void _Process(double delta)
{
HandleWeaponInput();
}
private void HandleWeaponInput()
{
// 武器切换
if (Input.IsActionJustPressed("weapon_1")) SwitchToSlot(0);
else if (Input.IsActionJustPressed("weapon_2")) SwitchToSlot(1);
else if (Input.IsActionJustPressed("weapon_2")) SwitchToSlot(2);
// 射击
if (Input.IsActionPressed("shoot"))
{
if (CurrentWeapon is HitscanWeapon gun)
{
var origin = _camera.GetNode<Camera3D>("Camera3D").GlobalPosition;
var direction = _camera.GetForwardDirection();
gun.TryFire(origin, direction);
}
}
// 换弹
if (Input.IsActionJustPressed("reload"))
{
CurrentWeapon?.Reload();
}
}
private void SwitchToSlot(int slot)
{
if (slot == _currentSlot) return;
if (_weapons[slot] == null) return;
// 收起当前武器
CurrentWeapon?.StartSwitchOut();
_currentSlot = slot;
// 显示新武器
_weapons[slot].StartSwitchIn();
// 更新移动速度倍率
if (_weapons[slot].Data != null)
{
_player.SetWeaponSpeedMultiplier(_weapons[slot].Data.MovementSpeedMultiplier);
}
}
/// <summary>
/// 购买武器(买枪菜单调用)
/// </summary>
public bool PurchaseWeapon(int slot, PackedScene weaponScene)
{
var weapon = weaponScene.Instantiate<WeaponBase>();
// 替换当前槽位武器
if (_weapons[slot] != null)
{
RemoveChild(_weapons[slot]);
_weapons[slot].QueueFree();
}
_weapons[slot] = weapon;
AddChild(weapon);
return true;
}
}GDScript
# Scripts/Weapons/WeaponManager.gd
extends Node3D
## 武器管理器。管理主武器、副武器、近战三个槽位。
var _weapons: Array[WeaponBase] = [null, null, null] # 0=主武器, 1=副武器, 2=近战
var _current_slot: int = 2 # 默认手持刀具
var _player: PlayerController
var _camera: FPSCamera
var current_weapon: WeaponBase:
get: return _weapons[_current_slot]
func _ready():
_player = get_parent() as PlayerController
_camera = _player.get_node("Head") as FPSCamera
func _process(_delta: float):
_handle_weapon_input()
func _handle_weapon_input():
# 武器切换
if Input.is_action_just_pressed("weapon_1"):
_switch_to_slot(0)
elif Input.is_action_just_pressed("weapon_2"):
_switch_to_slot(1)
elif Input.is_action_just_pressed("weapon_3"):
_switch_to_slot(2)
# 射击
if Input.is_action_pressed("shoot"):
var gun = current_weapon as HitscanWeapon
if gun:
var origin = _camera.get_node("Camera3D").global_position as Vector3
var direction = _camera.get_forward_direction()
gun.try_fire(origin, direction)
# 换弹
if Input.is_action_just_pressed("reload"):
if current_weapon:
current_weapon.reload()
func _switch_to_slot(slot: int):
if slot == _current_slot:
return
if _weapons[slot] == null:
return
# 收起当前武器
if current_weapon:
current_weapon.start_switch_out()
_current_slot = slot
# 显示新武器
_weapons[slot].start_switch_in()
# 更新移动速度倍率
if _weapons[slot].data:
_player.set_weapon_speed_multiplier(_weapons[slot].data.movement_speed_multiplier)
## 购买武器(买枪菜单调用)
func purchase_weapon(slot: int, weapon_scene: PackedScene) -> bool:
var weapon = weapon_scene.instantiate() as WeaponBase
# 替换当前槽位武器
if _weapons[slot]:
remove_child(_weapons[slot])
_weapons[slot].queue_free()
_weapons[slot] = weapon
add_child(weapon)
return true伤害系统
命中检测之后,需要一个独立的系统来处理伤害、死亡和击杀奖励。
C#
// Scripts/Systems/DamageSystem.cs
using Godot;
public partial class DamageSystem : Node
{
public override void _Ready()
{
var gameEvents = GetNode<GameEvents>("/root/GameEvents");
gameEvents.PlayerHit += OnPlayerHit;
}
private void OnPlayerHit(Node3D target, float damage, string hitZone)
{
// 查找目标角色的 HealthComponent
var health = target.GetParent()?.GetNodeOrNull<HealthComponent>("HealthComponent");
if (health == null) return;
// 应用伤害
health.TakeDamage(damage);
if (health.IsDead)
{
var killer = GetViewport().GetCamera3D().GetParent().GetParent() as Node3D;
var gameEvents = GetNode<GameEvents>("/root/GameEvents");
gameEvents.EmitSignal(GameEvents.SignalName.PlayerDied, target, killer);
}
}
}
/// <summary>
/// 血量组件。挂在每个角色上。
/// </summary>
public partial class HealthComponent : Node
{
[Export] public float MaxHealth { get; set; } = 100f;
[Export] public bool HasArmor { get; set; } = false;
[Export] public float ArmorValue { get; set; } = 100f;
private float _currentHealth;
public float CurrentHealth => _currentHealth;
public bool IsDead => _currentHealth <= 0;
public override void _Ready()
{
_currentHealth = MaxHealth;
}
public void TakeDamage(float damage)
{
if (IsDead) return;
// 护甲吸收一半伤害
if (HasArmor && ArmorValue > 0)
{
float absorbed = damage * 0.5f;
ArmorValue -= absorbed;
damage -= absorbed;
if (ArmorValue <= 0)
{
HasArmor = false;
ArmorValue = 0;
}
}
_currentHealth -= damage;
_currentHealth = Mathf.Max(_currentHealth, 0);
}
public void Reset()
{
_currentHealth = MaxHealth;
}
}GDScript
# Scripts/Systems/DamageSystem.gd
extends Node
func _ready():
var game_events = get_node("/root/GameEvents") as GameEvents
game_events.player_hit.connect(_on_player_hit)
func _on_player_hit(target: Node3D, damage: float, hit_zone: String):
# 查找目标角色的 HealthComponent
var health = target.get_parent()?.get_node_or_null("HealthComponent") as HealthComponent
if not health:
return
# 应用伤害
health.take_damage(damage)
if health.is_dead:
var killer = get_viewport().get_camera_3d().get_parent().get_parent() as Node3D
var game_events = get_node("/root/GameEvents") as GameEvents
game_events.player_died.emit(target, killer)
## 血量组件。挂在每个角色上。GDScript
# Scripts/Systems/HealthComponent.gd
extends Node
class_name HealthComponent
@export var max_health: float = 100.0
@export var has_armor: bool = false
@export var armor_value: float = 100.0
var _current_health: float
var current_health: float:
get: return _current_health
var is_dead: bool:
get: return _current_health <= 0
func _ready():
_current_health = max_health
func take_damage(damage: float):
if is_dead:
return
# 护甲吸收一半伤害
if has_armor and armor_value > 0:
var absorbed: float = damage * 0.5
armor_value -= absorbed
damage -= absorbed
if armor_value <= 0:
has_armor = false
armor_value = 0.0
_current_health -= damage
_current_health = maxf(_current_health, 0)
func reset():
_current_health = max_health小结
这一章实现了武器系统的核心部分:
- 武器基类:管理武器状态(待机/射击/换弹/切换)、弹药计数
- 射线武器:射线检测命中、后坐力模式、弹道散布
- 武器管理器:三个武器槽位、切换逻辑
- 伤害系统:部位伤害、护甲减伤、击杀判定
关键设计决策:
- 用
PhysicsRayQueryParameters3D实现即时命中检测 - 后坐力通过
Curve资源定义模式,不同枪有不同的曲线 - 散布根据移动速度和连续射击动态计算
- 伤害分四个部位(头/胸/腹/腿),各有不同倍率
下一章我们实现地图设计——如何搭建一个有战术深度的射击地图。
