5. 敌人生成与波次
2026/4/13大约 10 分钟
敌人生成与波次
大量敌人 = 大量乐趣
割草游戏的爽感,很大程度上来自"满屏都是敌人"的视觉冲击。当你看到几百个敌人从四面八方涌来,而你的武器像割草机一样将它们一波波消灭——那种感觉,就像夏天用高压水枪冲地面上的蚂蚁群一样解压。
但生成大量敌人不是简单地"每秒创建一个"。我们需要考虑:在屏幕外生成(不能凭空出现在玩家眼前)、逐渐增加数量(随着时间推移越来越多)、不同类型的敌人、以及性能优化(几百个敌人不能卡顿)。
敌人生成的核心问题
敌人从哪里来?
想象你站在一片空地上,周围有僵尸朝你走来。如果你看到僵尸"凭空出现",会觉得很假。正确的做法是:让僵尸从你看不到的地方(屏幕外)走进来。
| 生成位置 | 效果 | 是否推荐 |
|---|---|---|
| 屏幕中心(玩家身边) | 玩家会吓一跳,很假 | 不推荐 |
| 屏幕边缘 | 玩家能看到敌人走进来 | 一般 |
| 屏幕外(稍远一点) | 敌人从远处走来,自然 | 推荐 |
| 固定点(地图边缘) | 像一个漏斗,太规律 | 不推荐 |
生成距离
建议在摄像机可视范围外 3~5 个单位处生成敌人。这样玩家看不到"刷新"的瞬间,但敌人很快就会进入视野。
敌人生成器
生成器的工作流程
每帧检查:
→ 当前存活敌人数 < 最大敌人数?
→ 是:距离上次生成超过间隔时间?
→ 是:在屏幕外随机位置生成一个敌人
→ 否:等待
→ 否:不生成C
// EnemySpawner.cs
// 敌人生成器,负责在屏幕外持续生成敌人
using Godot;
public partial class EnemySpawner : Node3D
{
// === 生成参数 ===
// 生成间隔(秒)
[Export] public float BaseSpawnInterval = 1.0f;
// 每次生成几个敌人
[Export] public int BaseSpawnCount = 3;
// 最大同时存在敌人数
[Export] public int MaxEnemies = 200;
// 生成距离(距离玩家多远生成)
[Export] public float SpawnDistance = 20f;
// 生成距离的随机浮动范围
[Export] public float SpawnDistanceVariance = 5f;
// === 敌人场景 ===
[Export] public PackedScene BasicEnemyScene;
[Export] public PackedScene FastEnemyScene;
[Export] public PackedScene EliteEnemyScene;
[Export] public PackedScene BossEnemyScene;
// === 运行时状态 ===
private float _spawnTimer = 0f;
private float _gameTime = 0f;
// 引用
private Node3D _enemyContainer;
private Node3D _player;
private Camera3D _camera;
// 难度倍率(由外部设置,随时间增加)
public float DifficultyMultiplier { get; set; } = 1.0f;
public override void _Ready()
{
_enemyContainer = GetParent().GetNode<Node3D>("Enemies");
_player = GetParent().GetNode<Node3D>("Player");
_camera = GetParent().GetNode<Camera3D>("Camera3D");
}
public override void _Process(double delta)
{
_gameTime += (float)delta;
// 当前存活敌人数
int currentCount = _enemyContainer.GetChildCount();
// 如果敌人数已达上限,不生成
if (currentCount >= MaxEnemies) return;
// 更新生成计时器
float interval = GetSpawnInterval();
_spawnTimer -= (float)delta;
if (_spawnTimer <= 0)
{
// 一次生成多个敌人
int count = GetSpawnCount();
for (int i = 0; i < count; i++)
{
SpawnEnemy();
}
_spawnTimer = interval;
}
}
/// <summary>
/// 根据游戏时间计算当前生成间隔
/// </summary>
private float GetSpawnInterval()
{
// 随时间缩短间隔,但有下限
float interval = BaseSpawnInterval / DifficultyMultiplier;
return Mathf.Max(interval, 0.1f); // 最快0.1秒生成一次
}
/// <summary>
/// 根据游戏时间计算每次生成几个
/// </summary>
private int GetSpawnCount()
{
int count = Mathf.RoundToInt(
BaseSpawnCount * DifficultyMultiplier);
return Mathf.Min(count, 10); // 每次最多生成10个
}
/// <summary>
/// 在屏幕外的随机位置生成一个敌人
/// </summary>
private void SpawnEnemy()
{
if (_player == null) return;
// 决定生成什么类型的敌人
PackedScene scene = ChooseEnemyType();
if (scene == null) return;
// 计算生成位置(在玩家周围的圆形区域边缘)
float angle = GD.Randf() * Mathf.Tau; // 随机角度
float distance = SpawnDistance
+ GD.Randf() * SpawnDistanceVariance;
Vector3 spawnPos = _player.GlobalPosition + new Vector3(
Mathf.Cos(angle) * distance,
0,
Mathf.Sin(angle) * distance
);
// 生成敌人
var enemy = scene.Instantiate<Node3D>();
enemy.GlobalPosition = spawnPos;
_enemyContainer.AddChild(enemy);
// 如果敌人有初始化方法,调用它
if (enemy.HasMethod("Initialize"))
{
enemy.Call("Initialize", DifficultyMultiplier);
}
}
/// <summary>
/// 根据游戏时间随机选择敌人类型
/// </summary>
private PackedScene ChooseEnemyType()
{
float roll = GD.Randf();
// 前3分钟只有普通敌人
if (_gameTime < 180)
{
return BasicEnemyScene;
}
// 3-7分钟出现快速敌人
if (_gameTime < 420)
{
if (roll < 0.7f) return BasicEnemyScene;
return FastEnemyScene;
}
// 7分钟以后出现精英敌人
if (roll < 0.5f) return BasicEnemyScene;
if (roll < 0.8f) return FastEnemyScene;
return EliteEnemyScene;
}
}GDScript
# enemy_spawner.gd
# 敌人生成器,负责在屏幕外持续生成敌人
extends Node3D
# === 生成参数 ===
# 生成间隔(秒)
@export var base_spawn_interval: float = 1.0
# 每次生成几个敌人
@export var base_spawn_count: int = 3
# 最大同时存在敌人数
@export var max_enemies: int = 200
# 生成距离(距离玩家多远生成)
@export var spawn_distance: float = 20.0
# 生成距离的随机浮动范围
@export var spawn_distance_variance: float = 5.0
# === 敌人场景 ===
@export var basic_enemy_scene: PackedScene
@export var fast_enemy_scene: PackedScene
@export var elite_enemy_scene: PackedScene
@export var boss_enemy_scene: PackedScene
# === 运行时状态 ===
var _spawn_timer: float = 0.0
var _game_time: float = 0.0
# 引用
var _enemy_container: Node3D
var _player: Node3D
var _camera: Camera3D
# 难度倍率(由外部设置,随时间增加)
var difficulty_multiplier: float = 1.0
func _ready():
_enemy_container = get_parent().get_node("Enemies")
_player = get_parent().get_node("Player")
_camera = get_parent().get_node("Camera3D")
func _process(delta):
_game_time += delta
# 当前存活敌人数
var current_count = _enemy_container.get_child_count()
# 如果敌人数已达上限,不生成
if current_count >= max_enemies:
return
# 更新生成计时器
var interval = get_spawn_interval()
_spawn_timer -= delta
if _spawn_timer <= 0:
# 一次生成多个敌人
var count = get_spawn_count()
for i in range(count):
spawn_enemy()
_spawn_timer = interval
## 根据游戏时间计算当前生成间隔
func get_spawn_interval() -> float:
# 随时间缩短间隔,但有下限
var interval = base_spawn_interval / difficulty_multiplier
return maxf(interval, 0.1) # 最快0.1秒生成一次
## 根据游戏时间计算每次生成几个
func get_spawn_count() -> int:
var count = roundi(base_spawn_count * difficulty_multiplier)
return mini(count, 10) # 每次最多生成10个
## 在屏幕外的随机位置生成一个敌人
func spawn_enemy():
if _player == null:
return
# 决定生成什么类型的敌人
var scene = choose_enemy_type()
if scene == null:
return
# 计算生成位置(在玩家周围的圆形区域边缘)
var angle = randf() * TAU # 随机角度
var distance = spawn_distance + randf() * spawn_distance_variance
var spawn_pos = _player.global_position + Vector3(
cos(angle) * distance,
0,
sin(angle) * distance
)
# 生成敌人
var enemy = scene.instantiate()
enemy.global_position = spawn_pos
_enemy_container.add_child(enemy)
# 如果敌人有初始化方法,调用它
if enemy.has_method("initialize"):
enemy.initialize(difficulty_multiplier)
## 根据游戏时间随机选择敌人类型
func choose_enemy_type() -> PackedScene:
var roll = randf()
# 前3分钟只有普通敌人
if _game_time < 180:
return basic_enemy_scene
# 3-7分钟出现快速敌人
if _game_time < 420:
if roll < 0.7:
return basic_enemy_scene
return fast_enemy_scene
# 7分钟以后出现精英敌人
if roll < 0.5:
return basic_enemy_scene
if roll < 0.8:
return fast_enemy_scene
return elite_enemy_scene敌人基类
所有敌人共享一些基本行为:朝玩家移动、碰到玩家扣血、被打死了掉经验宝石。
敌人属性一览
| 敌人类型 | 血量 | 速度 | 伤害 | 特殊能力 | 出现时间 |
|---|---|---|---|---|---|
| 普通 | 10 | 2.0 | 5 | 无 | 开局 |
| 快速 | 5 | 4.0 | 3 | 速度快但脆皮 | 3分钟后 |
| 精英 | 50 | 1.5 | 15 | 血厚,伤害高 | 7分钟后 |
| Boss | 500 | 1.0 | 30 | 超大,各种攻击 | 特定事件 |
C
// EnemyBase.cs
// 所有敌人的基类
using Godot;
public partial class EnemyBase : CharacterBody3D
{
// === 敌人基本属性 ===
[Export] public string EnemyName = "普通敌人";
[Export] public float MaxHealth = 10f;
[Export] public float MoveSpeed = 2.0f;
[Export] public float Damage = 5f;
[Export] public float ExperienceValue = 1f;
[Export] public float ContactDamageInterval = 1.0f; // 接触伤害间隔
// === 运行时状态 ===
public float CurrentHealth { get; private set; }
private float _contactDamageTimer = 0f;
private bool _isDead = false;
// 引用
private Node3D _player;
private PackedScene _experienceGemScene;
// 受击无敌时间
[Export] public float InvincibleTime = 0.2f;
private float _invincibleTimer = 0f;
public void Initialize(float difficultyMultiplier)
{
// 根据难度倍率调整属性
MaxHealth *= difficultyMultiplier;
Damage *= Mathf.Sqrt(difficultyMultiplier);
CurrentHealth = MaxHealth;
_player = GetTree().CurrentScene
.GetNodeOrNull<Node3D>("Player");
// 加载经验宝石场景
_experienceGemScene = GD.Load<PackedScene>(
"res://scenes/items/experience_gem.tscn");
}
public override void _PhysicsProcess(double delta)
{
if (_isDead || _player == null) return;
// 更新无敌计时器
if (_invincibleTimer > 0)
_invincibleTimer -= (float)delta;
// 朝玩家移动
MoveTowardPlayer();
// 接触伤害
HandleContactDamage((float)delta);
}
/// <summary>
/// 朝玩家方向移动
/// </summary>
private void MoveTowardPlayer()
{
Vector3 direction = (_player.GlobalPosition
- GlobalPosition);
// 只在XZ平面上移动
direction.Y = 0;
if (direction.LengthSquared() > 0.01f)
{
direction = direction.Normalized();
Velocity = direction * MoveSpeed;
// 面向移动方向
float angle = Mathf.Atan2(direction.X, direction.Z);
Rotation = new Vector3(0, angle, 0);
}
else
{
Velocity = Vector3.Zero;
}
MoveAndSlide();
}
/// <summary>
/// 处理接触伤害(敌人碰到玩家时扣血)
/// </summary>
private void HandleContactDamage(float delta)
{
_contactDamageTimer -= delta;
if (_contactDamageTimer > 0) return;
// 检测是否碰到玩家
if (_player is CharacterBody3D playerBody)
{
float distance = GlobalPosition.DistanceTo(
_player.GlobalPosition);
if (distance < 0.8f) // 碰撞距离
{
// 对玩家造成伤害
if (_player.HasMethod("TakeDamage"))
{
_player.Call("TakeDamage", Damage);
}
// 重置接触伤害计时器
_contactDamageTimer = ContactDamageInterval;
}
}
}
/// <summary>
/// 受到伤害(被武器调用)
/// </summary>
public void TakeDamage(float damage)
{
if (_isDead) return;
if (_invincibleTimer > 0) return;
CurrentHealth -= damage;
_invincibleTimer = InvincibleTime;
// 受击视觉反馈(闪红)
FlashRed();
if (CurrentHealth <= 0)
{
Die();
}
}
/// <summary>
/// 死亡处理
/// </summary>
private void Die()
{
_isDead = true;
// 掉落经验宝石
DropExperience();
// 死亡动画(缩小消失)
var tween = CreateTween();
tween.TweenProperty(this, "scale",
Vector3.Zero, 0.3f);
tween.TweenCallback(QueueFree);
// 通知生成器减少计数
EmitSignal(SignalName.TreeExiting);
}
/// <summary>
/// 掉落经验宝石
/// </summary>
private void DropExperience()
{
if (_experienceGemScene == null) return;
var gem = _experienceGemScene.Instantiate<Node3D>();
gem.GlobalPosition = GlobalPosition;
// 设置经验值
if (gem.HasMethod("SetValue"))
{
gem.Call("SetValue", ExperienceValue);
}
var dropsContainer = GetTree().CurrentScene
.GetNodeOrNull<Node3D>("Drops");
dropsContainer?.AddChild(gem);
}
/// <summary>
/// 受击闪红效果
/// </summary>
private void FlashRed()
{
// 找到模型节点,临时改成红色
var model = GetNodeOrNull<Node3D>("Model");
if (model == null) return;
foreach (var child in model.GetChildren())
{
if (child is MeshInstance3D mesh)
{
var mat = mesh.Mesh.SurfaceGetMaterial(0)
as StandardMaterial3D;
if (mat != null)
{
// 保存原色,临时变红
var originalColor = mat.AlbedoColor;
mat.AlbedoColor = Colors.Red;
// 0.1秒后恢复
var tween = CreateTween();
tween.TweenInterval(0.1);
tween.TweenCallback(
Callable.From(() =>
mat.AlbedoColor = originalColor));
}
}
}
}
}GDScript
# enemy_base.gd
# 所有敌人的基类
extends CharacterBody3D
# === 敌人基本属性 ===
@export var enemy_name: String = "普通敌人"
@export var max_health: float = 10.0
@export var move_speed: float = 2.0
@export var damage: float = 5.0
@export var experience_value: float = 1.0
@export var contact_damage_interval: float = 1.0 # 接触伤害间隔
# === 运行时状态 ===
var current_health: float
var _contact_damage_timer: float = 0.0
var _is_dead: bool = false
# 引用
var _player: Node3D
var _experience_gem_scene: PackedScene
# 受击无敌时间
@export var invincible_time: float = 0.2
var _invincible_timer: float = 0.0
func initialize(difficulty_multiplier: float):
# 根据难度倍率调整属性
max_health *= difficulty_multiplier
damage *= sqrt(difficulty_multiplier)
current_health = max_health
_player = get_tree().current_scene.get_node_or_null("Player")
# 加载经验宝石场景
_experience_gem_scene = load(
"res://scenes/items/experience_gem.tscn")
func _physics_process(delta):
if _is_dead or _player == null:
return
# 更新无敌计时器
if _invincible_timer > 0:
_invincible_timer -= delta
# 朝玩家移动
move_toward_player()
# 接触伤害
handle_contact_damage(delta)
## 朝玩家方向移动
func move_toward_player():
var direction = _player.global_position - global_position
direction.y = 0 # 只在XZ平面上移动
if direction.length_squared() > 0.01:
direction = direction.normalized()
velocity = direction * move_speed
# 面向移动方向
var angle = atan2(direction.x, direction.z)
rotation = Vector3(0, angle, 0)
else:
velocity = Vector3.ZERO
move_and_slide()
## 处理接触伤害(敌人碰到玩家时扣血)
func handle_contact_damage(delta):
_contact_damage_timer -= delta
if _contact_damage_timer > 0:
return
# 检测是否碰到玩家
var distance = global_position.distance_to(
_player.global_position)
if distance < 0.8: # 碰撞距离
# 对玩家造成伤害
if _player.has_method("take_damage"):
_player.take_damage(damage)
# 重置接触伤害计时器
_contact_damage_timer = contact_damage_interval
## 受到伤害(被武器调用)
func take_damage(dmg: float):
if _is_dead:
return
if _invincible_timer > 0:
return
current_health -= dmg
_invincible_timer = invincible_time
# 受击视觉反馈(闪红)
flash_red()
if current_health <= 0:
die()
## 死亡处理
func die():
_is_dead = true
# 掉落经验宝石
drop_experience()
# 死亡动画(缩小消失)
var tween = create_tween()
tween.tween_property(self, "scale", Vector3.ZERO, 0.3)
tween.tween_callback(queue_free)
## 掉落经验宝石
func drop_experience():
if _experience_gem_scene == null:
return
var gem = _experience_gem_scene.instantiate()
gem.global_position = global_position
if gem.has_method("set_value"):
gem.set_value(experience_value)
var drops_container = get_tree().current_scene \
.get_node_or_null("Drops")
if drops_container:
drops_container.add_child(gem)
## 受击闪红效果
func flash_red():
var model = get_node_or_null("Model")
if model == null:
return
for child in model.get_children():
if child is MeshInstance3D:
var mat = child.mesh.surface_get_material(0) \
as StandardMaterial3D
if mat:
var original_color = mat.albedo_color
mat.albedo_color = Color.RED
var tween = create_tween()
tween.tween_interval(0.1)
tween.tween_callback(
func(): mat.albedo_color = original_color
)性能优化:对象池
当敌人数超过 200 个时,反复创建和销毁敌人节点会导致卡顿。解决方案是对象池——提前创建好一批敌人,用的时候"激活",不用的时候"回收"。
对象池原理
生活化比喻:对象池就像一个"工具箱"。你不需要每次用螺丝刀都去买一把新的,而是用完了放回工具箱,下次需要时再拿出来。
| 方式 | 每次操作 | 缺点 |
|---|---|---|
| 直接创建/销毁 | Instantiate() + QueueFree() | 频繁操作导致内存碎片和GC卡顿 |
| 对象池 | Get() + Return() | 需要预先分配内存,但运行时零开销 |
性能红线
如果敌人数超过 300 且帧率低于 30 FPS,需要考虑以下优化:
- 使用对象池
- 距离剔除(远处敌人不更新AI)
- 降低远处敌人的物理更新频率
敌人配置数据表
为了方便调整敌人的数值平衡,建议使用资源文件(Resource)来存储配置:
| 配置项 | 普通僵尸 | 蝙蝠 | 骷髅战士 | 死神Boss |
|---|---|---|---|---|
| 场景文件 | basic_enemy | fast_enemy | elite_enemy | boss_enemy |
| 血量 | 10 | 5 | 50 | 500 |
| 速度 | 2.0 | 4.0 | 1.5 | 1.0 |
| 伤害 | 5 | 3 | 15 | 30 |
| 经验值 | 1 | 1 | 5 | 50 |
| 接触间隔 | 1.0秒 | 0.5秒 | 0.8秒 | 0.5秒 |
| 出现时间 | 0分钟 | 3分钟 | 7分钟 | Boss事件 |
总结
本章我们实现了:
- 敌人生成器 — 在屏幕外持续生成敌人
- 敌人基类 — 朝玩家移动、接触伤害、死亡掉落
- 敌人类型 — 普通/快速/精英/Boss四种
- 难度递增 — 随时间增加敌人数量和种类
- 性能考虑 — 对象池和距离剔除
现在满屏都是敌人了!下一章,我们要实现升级系统,让玩家在战斗中不断变强。
