5. 敌人AI与波次系统
2026/4/13大约 10 分钟
敌人AI与波次系统
敌人类型概览
敌人是游戏的灵魂
一个好游戏的敌人设计,决定了游戏的趣味性。如果敌人太蠢,玩家觉得无聊;如果敌人太聪明,玩家觉得不公平。魂斗罗的敌人设计精髓在于:每个敌人都有固定的行为模式,玩家通过观察和学习来找到对策。
我们要实现的敌人类型
| 敌人类型 | 行为模式 | 血量 | 掉落 |
|---|---|---|---|
| 步兵 | 地面巡逻,发现玩家后射击 | 1 | 10%概率掉落道具 |
| 炮台 | 固定位置,定时射击 | 3 | 30%概率掉落道具 |
| 飞行器 | 空中飞行,投弹 | 2 | 20%概率掉落道具 |
| 狙击手 | 远距离精确射击 | 1 | 15%概率掉落道具 |
| 重装兵 | 高血量,缓慢前进 | 5 | 50%概率掉落道具 |
敌人基类
所有敌人都共享一些通用功能:受伤、死亡、掉落道具。所以我们先创建一个"敌人基类",其他敌人继承它。
什么是基类?
基类就像一个"模板"。你把所有敌人共用的功能写在基类里,然后每种具体敌人只需要写自己独特的部分就行了。这就像"所有动物都会动"是共性(写在基类),但"鸟会飞、鱼会游"是个性(写在子类)。
C#
using Godot;
/// <summary>
/// 敌人基类 - 所有敌人共享的功能
/// </summary>
public partial class EnemyBase : CharacterBody3D
{
// ===== 通用参数 =====
[ExportGroup("基础属性")]
[Export] public int MaxHealth = 1; // 最大血量
[Export] public float MoveSpeed = 3.0f; // 移动速度
[Export] public int ScoreValue = 100; // 击杀得分
[Export] public float DropChance = 0.1f; // 道具掉落概率(0~1)
[ExportGroup("检测参数")]
[Export] public float DetectRange = 15.0f; // 检测玩家距离
[Export] public float AttackRange = 10.0f; // 攻击距离
[Export] public float ForgetRange = 20.0f; // 丢失目标距离
// ===== 内部状态 =====
protected int _currentHealth;
protected Player _player;
protected bool _isDead = false;
protected enum EnemyState { Patrol, Chase, Attack, Dead }
protected EnemyState _currentState = EnemyState.Patrol;
// ===== 信号 =====
[Signal] public delegate void EnemyDiedEventHandler(EnemyBase enemy);
[Signal] public delegate void DamageTakenEventHandler(int remainingHealth);
public override void _Ready()
{
_currentHealth = MaxHealth;
_player = GetTree().GetFirstNodeInGroup("player") as Player;
}
public override void _PhysicsProcess(double delta)
{
if (_isDead) return;
float dt = (float)delta;
// 状态机更新
switch (_currentState)
{
case EnemyState.Patrol:
UpdatePatrol(dt);
break;
case EnemyState.Chase:
UpdateChase(dt);
break;
case EnemyState.Attack:
UpdateAttack(dt);
break;
case EnemyState.Dead:
break;
}
// 应用移动
Velocity = new Vector3(Velocity.X, Velocity.Y - 15f * dt, Velocity.Z);
MoveAndSlide();
}
/// <summary>
/// 受到伤害
/// </summary>
public virtual void TakeDamage(float damage)
{
if (_isDead) return;
_currentHealth -= (int)damage;
EmitSignal(SignalName.DamageTaken, _currentHealth);
GD.Print($"{Name} 受到 {damage} 伤害,剩余血量: {_currentHealth}");
if (_currentHealth <= 0)
{
Die();
}
}
/// <summary>
/// 死亡处理
/// </summary>
protected virtual void Die()
{
_isDead = true;
_currentState = EnemyState.Dead;
// 通知其他系统
EmitSignal(SignalName.EnemyDied, this);
// 随机掉落道具
if (GD.Randf() < DropChance)
{
SpawnDrop();
}
// 播放死亡动画/特效
PlayDeathEffect();
// 延迟后移除
var timer = GetTree().CreateTimer(1.0);
timer.Timeout += QueueFree;
}
/// <summary>
/// 掉落道具(由子类重写或使用默认实现)
/// </summary>
protected virtual void SpawnDrop()
{
GD.Print($"{Name} 掉落了道具!");
// 道具生成逻辑在道具系统章节实现
}
/// <summary>
/// 播放死亡特效
/// </summary>
protected virtual void PlayDeathEffect()
{
// 简单的闪烁效果
var tween = CreateTween();
for (int i = 0; i < 5; i++)
{
tween.TweenProperty(this, "visible", false, 0.05);
tween.TweenProperty(this, "visible", true, 0.05);
}
tween.TweenProperty(this, "visible", false, 0.05);
}
/// <summary>
/// 检查玩家是否在范围内
/// </summary>
protected float DistanceToPlayer()
{
if (_player == null) return float.MaxValue;
return GlobalPosition.DistanceTo(_player.GlobalPosition);
}
/// <summary>
/// 获取朝向玩家的方向
/// </summary>
protected Vector3 DirectionToPlayer()
{
if (_player == null) return Vector3.Left;
return (_player.GlobalPosition - GlobalPosition).Normalized();
}
// ===== 虚方法 - 由子类实现 =====
protected virtual void UpdatePatrol(float dt) { }
protected virtual void UpdateChase(float dt) { }
protected virtual void UpdateAttack(float dt) { }
}GDScript
extends CharacterBody3D
## 敌人基类 - 所有敌人共享的功能
# ===== 通用参数 =====
@export_group("基础属性")
@export var max_health: int = 1 # 最大血量
@export var move_speed: float = 3.0 # 移动速度
@export var score_value: int = 100 # 击杀得分
@export var drop_chance: float = 0.1 # 道具掉落概率(0~1)
@export_group("检测参数")
@export var detect_range: float = 15.0 # 检测玩家距离
@export var attack_range: float = 10.0 # 攻击距离
@export var forget_range: float = 20.0 # 丢失目标距离
# ===== 内部状态 =====
var _current_health: int
var _player: Node # Player 节点引用
var _is_dead: bool = false
enum EnemyState { PATROL, CHASE, ATTACK, DEAD }
var _current_state: EnemyState = EnemyState.PATROL
# ===== 信号 =====
signal enemy_died(enemy: Node)
signal damage_taken(remaining_health: int)
func _ready():
_current_health = max_health
_player = get_tree().get_first_node_in_group("player")
func _physics_process(delta):
if _is_dead:
return
# 状态机更新
match _current_state:
EnemyState.PATROL:
_update_patrol(delta)
EnemyState.CHASE:
_update_chase(delta)
EnemyState.ATTACK:
_update_attack(delta)
EnemyState.DEAD:
pass
# 应用移动
velocity = Vector3(velocity.x, velocity.y - 15.0 * delta, velocity.z)
move_and_slide()
## 受到伤害
func take_damage(damage: float):
if _is_dead:
return
_current_health -= int(damage)
damage_taken.emit(_current_health)
print("%s 受到 %d 伤害,剩余血量: %d" % [name, int(damage), _current_health])
if _current_health <= 0:
_die()
## 死亡处理
func _die():
_is_dead = true
_current_state = EnemyState.DEAD
# 通知其他系统
enemy_died.emit(self)
# 随机掉落道具
if randf() < drop_chance:
_spawn_drop()
# 播放死亡动画/特效
_play_death_effect()
# 延迟后移除
var timer = get_tree().create_timer(1.0)
timer.timeout.connect(queue_free)
## 掉落道具(由子类重写或使用默认实现)
func _spawn_drop():
print("%s 掉落了道具!" % name)
# 道具生成逻辑在道具系统章节实现
## 播放死亡特效
func _play_death_effect():
# 简单的闪烁效果
var tween = create_tween()
for i in range(5):
tween.tween_property(self, "visible", false, 0.05)
tween.tween_property(self, "visible", true, 0.05)
tween.tween_property(self, "visible", false, 0.05)
## 检查玩家是否在范围内
func _distance_to_player() -> float:
if not _player:
return INF
return global_position.distance_to(_player.global_position)
## 获取朝向玩家的方向
func _direction_to_player() -> Vector3:
if not _player:
return Vector3.LEFT
return (_player.global_position - global_position).normalized()
# ===== 虚方法 - 由子类实现 =====
func _update_patrol(_dt: float): pass
func _update_chase(_dt: float): pass
func _update_attack(_dt: float): pass步兵AI——巡逻与追踪
步兵是最基础的敌人。它的行为模式很简单:没看到玩家时左右巡逻,看到玩家后追上去射击。
状态机
什么是状态机?
状态机就是"敌人在不同状态下做不同的事"。就像红绿灯:红灯时停车,绿灯时通行,黄灯时减速。敌人也有状态:巡逻时慢慢走,发现玩家时追上去,追到了就攻击。
状态之间的转换:
巡逻 ──发现玩家──→ 追踪 ──到达攻击距离──→ 攻击
↑ │ │
└──玩家跑远了────────┘ │
└──玩家跑更远了─────────────────────────────┘C#
using Godot;
/// <summary>
/// 步兵敌人 - 巡逻、追踪、射击
/// </summary>
public partial class SoldierEnemy : EnemyBase
{
// 巡逻参数
[Export] public float PatrolDistance = 5.0f; // 巡逻范围
[Export] public float ShootCooldown = 1.5f; // 射击间隔
private Vector3 _patrolStart; // 巡逻起点
private float _patrolDirection = 1; // 巡逻方向
private float _shootTimer = 0.0f;
public override void _Ready()
{
base._Ready();
_patrolStart = GlobalPosition;
MaxHealth = 1;
MoveSpeed = 3.0f;
DropChance = 0.1f;
}
protected override void UpdatePatrol(float dt)
{
// 检查是否发现玩家
if (DistanceToPlayer() < DetectRange)
{
_currentState = EnemyState.Chase;
return;
}
// 左右巡逻
float patrolX = _patrolStart.X + Mathf.Sin(
_patrolDirection * MoveSpeed * dt
) * PatrolDistance;
Velocity = new Vector3(
_patrolDirection * MoveSpeed,
Velocity.Y,
0
);
// 到达巡逻边界时转向
if (GlobalPosition.X > _patrolStart.X + PatrolDistance)
_patrolDirection = -1;
if (GlobalPosition.X < _patrolStart.X - PatrolDistance)
_patrolDirection = 1;
}
protected override void UpdateChase(float dt)
{
// 检查是否丢失玩家
if (DistanceToPlayer() > ForgetRange)
{
_currentState = EnemyState.Patrol;
return;
}
// 到达攻击距离,切换到攻击状态
if (DistanceToPlayer() < AttackRange)
{
_currentState = EnemyState.Attack;
return;
}
// 追踪玩家
Vector3 dir = DirectionToPlayer();
Velocity = new Vector3(dir.X * MoveSpeed, Velocity.Y, 0);
}
protected override void UpdateAttack(float dt)
{
// 玩家跑远了,继续追
if (DistanceToPlayer() > AttackRange * 1.5f)
{
_currentState = EnemyState.Chase;
return;
}
// 停下来射击
Velocity = new Vector3(0, Velocity.Y, 0);
_shootTimer -= dt;
if (_shootTimer <= 0)
{
ShootAtPlayer();
_shootTimer = ShootCooldown;
}
}
/// <summary>
/// 朝玩家射击
/// </summary>
private void ShootAtPlayer()
{
Vector3 dir = DirectionToPlayer();
GD.Print($"步兵向玩家射击!方向: {dir}");
// 子弹生成逻辑(复用射击管理器)
var manager = GetTree().GetFirstNodeInGroup("shooting_manager");
if (manager != null && manager.HasMethod("SpawnEnemyBullet"))
{
manager.Call("SpawnEnemyBullet", GlobalPosition, dir);
}
}
}GDScript
extends "res://Scripts/Enemies/EnemyBase.gd"
## 步兵敌人 - 巡逻、追踪、射击
# 巡逻参数
@export var patrol_distance: float = 5.0 # 巡逻范围
@export var shoot_cooldown: float = 1.5 # 射击间隔
var _patrol_start: Vector3 # 巡逻起点
var _patrol_direction: float = 1 # 巡逻方向
var _shoot_timer: float = 0.0
func _ready():
super._ready()
_patrol_start = global_position
max_health = 1
move_speed = 3.0
drop_chance = 0.1
func _update_patrol(dt: float):
# 检查是否发现玩家
if _distance_to_player() < detect_range:
_current_state = EnemyState.CHASE
return
# 左右巡逻
velocity = Vector3(
_patrol_direction * move_speed,
velocity.y,
0
)
# 到达巡逻边界时转向
if global_position.x > _patrol_start.x + patrol_distance:
_patrol_direction = -1
if global_position.x < _patrol_start.x - patrol_distance:
_patrol_direction = 1
func _update_chase(dt: float):
# 检查是否丢失玩家
if _distance_to_player() > forget_range:
_current_state = EnemyState.PATROL
return
# 到达攻击距离,切换到攻击状态
if _distance_to_player() < attack_range:
_current_state = EnemyState.ATTACK
return
# 追踪玩家
var dir = _direction_to_player()
velocity = Vector3(dir.x * move_speed, velocity.y, 0)
func _update_attack(dt: float):
# 玩家跑远了,继续追
if _distance_to_player() > attack_range * 1.5:
_current_state = EnemyState.CHASE
return
# 停下来射击
velocity = Vector3(0, velocity.y, 0)
_shoot_timer -= dt
if _shoot_timer <= 0:
_shoot_at_player()
_shoot_timer = shoot_cooldown
## 朝玩家射击
func _shoot_at_player():
var dir = _direction_to_player()
print("步兵向玩家射击!方向: %s" % str(dir))
# 子弹生成逻辑(复用射击管理器)
var manager = get_tree().get_first_node_in_group("shooting_manager")
if manager and manager.has_method("SpawnEnemyBullet"):
manager.SpawnEnemyBullet(global_position, dir)炮台敌人
炮台不移动,但会定时射击。它比步兵更简单,但血量更高。
| 特点 | 说明 |
|---|---|
| 固定位置 | 不移动 |
| 定时射击 | 每隔固定时间发射一颗子弹 |
| 高血量 | 需要3发子弹才能消灭 |
| 高掉落率 | 击破后有30%概率掉落道具 |
波次系统
什么是波次系统?
波次系统就是"按批次生成敌人"。不是一次性把所有敌人都放出来,而是分几波:第一波3个步兵,打完之后第二波5个步兵+1个炮台,以此类推。这样玩家不会觉得太难,也不会觉得太简单。
波次数据结构
| 波次 | 敌人配置 | 说明 |
|---|---|---|
| 第1波 | 3个步兵 | 热身 |
| 第2波 | 5个步兵 + 1个炮台 | 稍微难一点 |
| 第3波 | 3个步兵 + 2个炮台 + 1个飞行器 | 引入飞行敌人 |
| 第4波 | 5个步兵 + 2个炮台 + 2个飞行器 | 密集弹幕 |
| 第5波 | 1个重装兵 + 2个步兵 | Boss前的最后一波 |
波次管理器
C#
using Godot;
using System.Collections.Generic;
/// <summary>
/// 波次管理器 - 控制敌人的生成节奏
/// </summary>
public partial class WaveManager : Node3D
{
[Export] public PackedScene SoldierScene;
[Export] public PackedScene TurretScene;
[Export] public PackedScene FlyerScene;
// 波次配置
private readonly List<WaveData> _waves = new()
{
new() { Soldiers = 3, Turrets = 0, Flyers = 0 },
new() { Soldiers = 5, Turrets = 1, Flyers = 0 },
new() { Soldiers = 3, Turrets = 2, Flyers = 1 },
new() { Soldiers = 5, Turrets = 2, Flyers = 2 },
new() { Soldiers = 2, Turrets = 0, Flyers = 0, Heavy = 1 },
};
private int _currentWave = 0;
private int _enemiesAlive = 0;
private bool _waveActive = false;
public override void _Ready()
{
// 监听敌人死亡信号
GetTree().Connect("node_removed", Callable.From((Node node) =>
{
if (node is EnemyBase)
{
_enemiesAlive--;
CheckWaveComplete();
}
}));
}
/// <summary>
/// 开始下一波
/// </summary>
public void StartNextWave()
{
if (_currentWave >= _waves.Count) return;
var wave = _waves[_currentWave];
GD.Print($"=== 第 {_currentWave + 1} 波开始 ===");
// 生成步兵
for (int i = 0; i < wave.Soldiers; i++)
{
SpawnEnemy(SoldierScene, new Vector3(20 + i * 3, 1, 0));
}
// 生成炮台
for (int i = 0; i < wave.Turrets; i++)
{
SpawnEnemy(TurretScene, new Vector3(25 + i * 5, 1, 0));
}
// 生成飞行器
for (int i = 0; i < wave.Flyers; i++)
{
SpawnEnemy(FlyerScene, new Vector3(15 + i * 4, 5, 0));
}
_waveActive = true;
_currentWave++;
}
private void SpawnEnemy(PackedScene scene, Vector3 position)
{
var enemy = scene.Instantiate<Node3D>();
enemy.GlobalPosition = position;
GetParent().AddChild(enemy);
_enemiesAlive++;
}
private void CheckWaveComplete()
{
if (_enemiesAlive <= 0 && _waveActive)
{
_waveActive = false;
GD.Print($"=== 第 {_currentWave} 波完成 ===");
// 2秒后开始下一波
var timer = GetTree().CreateTimer(2.0);
timer.Timeout += StartNextWave;
}
}
}
/// <summary>
/// 波次数据
/// </summary>
public class WaveData
{
public int Soldiers { get; set; }
public int Turrets { get; set; }
public int Flyers { get; set; }
public int Heavy { get; set; } = 0;
}GDScript
extends Node3D
## 波次管理器 - 控制敌人的生成节奏
@export var soldier_scene: PackedScene
@export var turret_scene: PackedScene
@export var flyer_scene: PackedScene
# 波次配置
var _waves: Array[Dictionary] = [
{ "soldiers": 3, "turrets": 0, "flyers": 0 },
{ "soldiers": 5, "turrets": 1, "flyers": 0 },
{ "soldiers": 3, "turrets": 2, "flyers": 1 },
{ "soldiers": 5, "turrets": 2, "flyers": 2 },
{ "soldiers": 2, "turrets": 0, "flyers": 0, "heavy": 1 },
]
var _current_wave: int = 0
var _enemies_alive: int = 0
var _wave_active: bool = false
func _ready():
# 监听敌人死亡信号
tree_connect("node_removed", _on_node_removed)
func _on_node_removed(node: Node):
if node is EnemyBase:
_enemies_alive -= 1
_check_wave_complete()
## 开始下一波
func start_next_wave():
if _current_wave >= _waves.size():
return
var wave = _waves[_current_wave]
print("=== 第 %d 波开始 ===" % (_current_wave + 1))
# 生成步兵
for i in range(wave.get("soldiers", 0)):
_spawn_enemy(soldier_scene, Vector3(20 + i * 3, 1, 0))
# 生成炮台
for i in range(wave.get("turrets", 0)):
_spawn_enemy(turret_scene, Vector3(25 + i * 5, 1, 0))
# 生成飞行器
for i in range(wave.get("flyers", 0)):
_spawn_enemy(flyer_scene, Vector3(15 + i * 4, 5, 0))
_wave_active = true
_current_wave += 1
func _spawn_enemy(scene: PackedScene, pos: Vector3):
var enemy = scene.instantiate()
enemy.global_position = pos
get_parent().add_child(enemy)
_enemies_alive += 1
func _check_wave_complete():
if _enemies_alive <= 0 and _wave_active:
_wave_active = false
print("=== 第 %d 波完成 ===" % _current_wave)
# 2秒后开始下一波
var timer = get_tree().create_timer(2.0)
timer.timeout.connect(start_next_wave)敌人从屏幕外进入
魂斗罗的敌人不会凭空出现在屏幕中,而是从屏幕外面走进来。这给了玩家反应时间。
屏幕外生成
敌人在摄像机视野范围之外的右侧生成,然后自己走进屏幕。玩家的正交摄像机大约能看到 X=0 到 X=18 的范围(取决于 Size 设置),所以敌人在 X=20 左右生成是合理的。
本章小结
本章我们实现了完整的敌人系统:
- 敌人基类——所有敌人共享的受伤、死亡、掉落功能
- 步兵AI——巡逻/追踪/攻击三种状态
- 炮台敌人——固定位置定时射击
- 波次系统——按批次生成敌人,难度递进
- 屏幕外生成——敌人从画面外进入,给玩家反应时间
下一章预告
下一章我们将实现关卡设计,包括视差滚动背景、地形设计和关卡触发器。这些元素将把"一片空地上的战斗"变成"有节奏的关卡体验"。
