3. 怪物与路径
2026/4/14大约 12 分钟
3. 保卫萝卜——怪物与路径
路径系统:怪物怎么知道往哪里走?
在塔防游戏中,怪物需要沿着一条固定的路线行走。你可以把这条路线想象成"人行道"——行人(怪物)不会乱跑,只会沿着人行道走。
Godot 提供了两个专门处理路径的节点:
| 节点 | 作用 | 生活比喻 |
|---|---|---|
| Path2D | 定义一条路径(像在地图上画一条线) | 规划好的"人行道" |
| PathFollow2D | 沿路径移动(像行人在人行道上走) | 在人行道上走的"行人" |
它们是怎么配合工作的?
Path2D(路径)
│
├── 定义了一条由多个"控制点"连成的曲线
│ 控制点1 ──→ 控制点2 ──→ 控制点3 ──→ 控制点4
│
└── PathFollow2D(跟随者)
│
└── 自动沿着这条曲线移动
通过 offset(偏移量)控制位置
offset = 0 → 在起点(控制点1)
offset = 100 → 走了100像素的距离创建路径
在编辑器中创建
- 在
Main场景的Map节点下,添加 Path2D 节点,命名为MonsterPath - 选中
MonsterPath,在编辑器上方的工具栏中你会看到一个"曲线"图标 - 点击它进入曲线编辑模式
- 在视图中点击添加控制点,画出怪物行走的路线
- 起点是怪物的出生点,终点是萝卜的位置
提示:你可以拖拽控制点来调整路径的形状。每个控制点旁边有两个小手柄,拖动手柄可以调整曲线的弯曲程度。
路径的属性
| 属性 | 说明 | 建议值 |
|---|---|---|
| Curve | 路径的曲线数据 | 在编辑器中手动绘制 |
| Curve.BakeInterval | 路径的采样精度(像素) | 5.0(越小越精确) |
怪物数据资源
在创建怪物之前,先定义怪物的"数据模板"——就像用模具批量生产一样,不同类型的怪物有不同的"模具"。
C
using Godot;
/// <summary>
/// 怪物数据资源 —— 怪物的"设计图纸"
/// 每种怪物都有一份这样的图纸,定义了它的各项属性
/// </summary>
[GlobalClass]
public partial class MonsterData : Resource
{
/// <summary>怪物名称(显示用)</summary>
[Export] public string MonsterName { get; set; } = "小兵";
/// <summary>怪物类型标识</summary>
[Export] public string MonsterType { get; set; } = "normal";
/// <summary>最大生命值(能挨几下打)</summary>
[Export] public int MaxHp { get; set; } = 100;
/// <summary>移动速度(每秒走多少像素)</summary>
[Export] public float MoveSpeed { get; set; } = 60.0f;
/// <summary>击杀奖励金币</summary>
[Export] public int Reward { get; set; } = 20;
/// <summary>到达终点造成的伤害</summary>
[Export] public int Damage { get; set; } = 1;
/// <summary>怪物场景(预制体)</summary>
[Export] public PackedScene MonsterScene { get; set; }
/// <summary>怪物图标(在 UI 中显示)</summary>
[Export] public Texture2D Icon { get; set; }
/// <summary>是否为 Boss</summary>
[Export] public bool IsBoss { get; set; } = false;
/// <summary>怪物描述</summary>
[Export] public string Description { get; set; } = "普通小怪";
}GDScript
class_name MonsterData
extends Resource
## 怪物数据资源 —— 怪物的"设计图纸"
## 每种怪物都有一份这样的图纸,定义了它的各项属性
## 怪物名称(显示用)
@export var monster_name: String = "小兵"
## 怪物类型标识
@export var monster_type: String = "normal"
## 最大生命值(能挨几下打)
@export var max_hp: int = 100
## 移动速度(每秒走多少像素)
@export var move_speed: float = 60.0
## 击杀奖励金币
@export var reward: int = 20
## 到达终点造成的伤害
@export var damage: int = 1
## 怪物场景(预制体)
@export var monster_scene: PackedScene
## 怪物图标(在 UI 中显示)
@export var icon: Texture2D
## 是否为 Boss
@export var is_boss: bool = false
## 怪物描述
@export var description: String = "普通小怪"怪物类型一览表
| 类型 | 生命值 | 速度 | 奖励 | 特点 |
|---|---|---|---|---|
| 普通小怪 | 100 | 60 | 20 | 中规中矩,数量最多 |
| 快速小怪 | 60 | 120 | 25 | 跑得快但血少 |
| 坦克小怪 | 300 | 40 | 40 | 血厚走得慢 |
| Boss | 2000 | 30 | 200 | 超级血厚,会扣很多生命 |
怪物脚本实现
怪物沿着路径移动的核心逻辑,就是控制 PathFollow2D 的 progress 属性不断增加。
C
using Godot;
/// <summary>
/// 怪物基类 —— 所有怪物的"通用模板"
/// 处理沿路径移动、受伤、死亡等通用逻辑
/// </summary>
public partial class Monster : PathFollow2D
{
// ===== 信号 =====
/// <summary>怪物死亡时发送(传递奖励金币数)</summary>
[Signal]
public delegate void MonsterDiedEventHandler(int reward, Vector2 position);
/// <summary>怪物到达终点时发送(传递伤害值)</summary>
[Signal]
public delegate void MonsterReachedEndEventHandler(int damage);
/// <summary>怪物血量变化时发送</summary>
[Signal]
public delegate void HpChangedEventHandler(int currentHp, int maxHp);
// ===== 怪物属性 =====
[ExportGroup("Monster Stats")]
/// <summary>最大生命值</summary>
[Export] public int MaxHp { get; set; } = 100;
/// <summary>移动速度(像素/秒)</summary>
[Export] public float MoveSpeed { get; set; } = 60.0f;
/// <summary>击杀奖励</summary>
[Export] public int Reward { get; set; } = 20;
/// <summary>到达终点伤害</summary>
[Export] public int Damage { get; set; } = 1;
// ===== 运行时状态 =====
private int _currentHp;
private bool _isDead = false;
/// <summary>当前生命值</summary>
public int CurrentHp => _currentHp;
/// <summary>是否存活</summary>
public bool IsAlive => !_isDead;
// ===== 血条相关 =====
private ProgressBar _healthBar;
private Sprite2D _sprite;
public override void _Ready()
{
// 初始化生命值
_currentHp = MaxHp;
// 获取子节点引用
_healthBar = GetNode<ProgressBar>("HealthBar");
_sprite = GetNode<Sprite2D>("Sprite2D");
// 设置 PathFollow2D 的旋转模式
// Rotating 表示跟随路径方向旋转(怪物面朝前进方向)
Loop = false; // 不循环,走到底就结束
// 初始化血条
UpdateHealthBar();
}
public override void _PhysicsProcess(double delta)
{
if (_isDead) return;
// 沿路径前进
// progress 表示怪物在路径上走了多远(单位:像素)
Progress += MoveSpeed * (float)delta;
// 检查是否到达终点
if (ProgressRatio >= 1.0f)
{
ReachEnd();
}
}
/// <summary>
/// 怪物受到伤害
/// </summary>
/// <param name="amount">伤害值</param>
/// <returns>怪物是否死亡</returns>
public bool TakeDamage(int amount)
{
if (_isDead) return false;
_currentHp -= amount;
UpdateHealthBar();
// 受伤闪烁效果
FlashRed();
EmitSignal(SignalName.HpChanged, _currentHp, MaxHp);
if (_currentHp <= 0)
{
Die();
return true;
}
return false;
}
/// <summary>
/// 怪物死亡
/// </summary>
private void Die()
{
_isDead = true;
// 播放死亡动画
PlayDeathAnimation();
// 通知外部:怪物死了,给奖励
EmitSignal(SignalName.MonsterDied, Reward, GlobalPosition);
// 延迟后移除怪物
var timer = GetTree().CreateTimer(0.5);
timer.Timeout += QueueFree;
}
/// <summary>
/// 怪物到达终点
/// </summary>
private void ReachEnd()
{
if (_isDead) return;
_isDead = true;
EmitSignal(SignalName.MonsterReachedEnd, Damage);
// 移除怪物
QueueFree();
}
/// <summary>
/// 更新血条显示
/// </summary>
private void UpdateHealthBar()
{
if (_healthBar == null) return;
float ratio = (float)_currentHp / MaxHp;
_healthBar.Value = ratio * 100.0;
// 血量低时变红
if (ratio < 0.3f)
{
_healthBar.GetNode<ColorRect>("Fill").Color = Colors.Red;
}
}
/// <summary>
/// 受伤闪烁效果
/// </summary>
private void FlashRed()
{
if (_sprite == null) return;
var tween = CreateTween();
tween.TweenProperty(_sprite, "modulate", Colors.Red, 0.1);
tween.TweenProperty(_sprite, "modulate", Colors.White, 0.1);
}
/// <summary>
/// 播放死亡动画
/// </summary>
private void PlayDeathAnimation()
{
if (_sprite == null) return;
var tween = CreateTween();
tween.TweenProperty(_sprite, "scale", Vector2.Zero, 0.3);
tween.TweenProperty(_sprite, "modulate", new Color(1, 1, 1, 0), 0.3);
tween.Parallel().TweenProperty(_healthBar, "modulate",
new Color(1, 1, 1, 0), 0.3);
}
/// <summary>
/// 减速效果
/// </summary>
/// <param name="factor">减速因子(0.5 表示速度减半)</param>
/// <param name="duration">持续秒数</param>
public void ApplySlow(float factor, float duration)
{
if (_isDead) return;
float originalSpeed = MoveSpeed;
MoveSpeed *= factor;
// duration 秒后恢复速度
var timer = GetTree().CreateTimer(duration);
timer.Timeout += () =>
{
if (!_isDead)
MoveSpeed = originalSpeed;
};
}
}GDScript
extends PathFollow2D
class_name Monster
## 怪物基类 —— 所有怪物的"通用模板"
## 处理沿路径移动、受伤、死亡等通用逻辑
# ===== 信号 =====
## 怪物死亡时发送(传递奖励金币数)
signal monster_died(reward: int, position: Vector2)
## 怪物到达终点时发送(传递伤害值)
signal monster_reached_end(damage: int)
## 怪物血量变化时发送
signal hp_changed(current_hp: int, max_hp: int)
# ===== 怪物属性 =====
## 最大生命值
@export var max_hp: int = 100:
set(value):
max_hp = value
_update_health_bar()
## 移动速度(像素/秒)
@export var move_speed: float = 60.0
## 击杀奖励
@export var reward: int = 20
## 到达终点伤害
@export var damage: int = 1
# ===== 运行时状态 =====
var _current_hp: int = 0
var _is_dead: bool = false
var _original_speed: float = 0.0
## 当前生命值
var current_hp: int:
get: return _current_hp
## 是否存活
var is_alive: bool:
get: return not _is_dead
# ===== 血条相关 =====
@onready var _health_bar: ProgressBar = $HealthBar
@onready var _sprite: Sprite2D = $Sprite2D
func _ready() -> void:
# 初始化生命值
_current_hp = max_hp
_original_speed = move_speed
# 设置 PathFollow2D 的旋转模式
loop = false # 不循环,走到底就结束
# 初始化血条
_update_health_bar()
func _physics_process(delta: float) -> void:
if _is_dead:
return
# 沿路径前进
# progress 表示怪物在路径上走了多远(单位:像素)
progress += move_speed * delta
# 检查是否到达终点
if progress_ratio >= 1.0:
_reach_end()
## 怪物受到伤害
## 返回怪物是否死亡
func take_damage(amount: int) -> bool:
if _is_dead:
return false
_current_hp -= amount
_update_health_bar()
# 受伤闪烁效果
_flash_red()
hp_changed.emit(_current_hp, max_hp)
if _current_hp <= 0:
_die()
return true
return false
## 怪物死亡
func _die() -> void:
_is_dead = true
# 播放死亡动画
_play_death_animation()
# 通知外部:怪物死了,给奖励
monster_died.emit(reward, global_position)
# 延迟后移除怪物
await get_tree().create_timer(0.5).timeout
queue_free()
## 怪物到达终点
func _reach_end() -> void:
if _is_dead:
return
_is_dead = true
monster_reached_end.emit(damage)
queue_free()
## 更新血条显示
func _update_health_bar() -> void:
if not _health_bar:
return
var ratio: float = float(_current_hp) / float(max_hp)
_health_bar.value = ratio * 100.0
# 血量低时变红
if ratio < 0.3:
var fill: ColorRect = _health_bar.get_node("Fill")
if fill:
fill.color = Color.RED
## 受伤闪烁效果
func _flash_red() -> void:
if not _sprite:
return
var tween = create_tween()
tween.tween_property(_sprite, "modulate", Color.RED, 0.1)
tween.tween_property(_sprite, "modulate", Color.WHITE, 0.1)
## 播放死亡动画
func _play_death_animation() -> void:
if not _sprite:
return
var tween = create_tween()
tween.tween_property(_sprite, "scale", Vector2.ZERO, 0.3)
tween.tween_property(_sprite, "modulate", Color(1, 1, 1, 0), 0.3)
tween.parallel().tween_property(_health_bar, "modulate", Color(1, 1, 1, 0), 0.3)
## 减速效果
func apply_slow(factor: float, duration: float) -> void:
if _is_dead:
return
move_speed = _original_speed * factor
# duration 秒后恢复速度
await get_tree().create_timer(duration).timeout
if not _is_dead:
move_speed = _original_speed创建怪物场景
场景结构
在 Godot 编辑器中创建怪物场景:
- 新建场景,根节点选择 PathFollow2D,命名为
Monster - 添加子节点:
| 节点类型 | 名称 | 说明 |
|---|---|---|
| Sprite2D | Sprite2D | 怪物的外观(贴图) |
| ProgressBar | HealthBar | 血条 |
| ColorRect | HealthBar/Fill | 血条填充颜色 |
| CollisionShape2D | Hitbox | 碰撞检测区域 |
| Area2D | HitArea | 受伤检测区域 |
怪物场景的层级
Monster (PathFollow2D) ← 挂载 monster.gd 脚本
├── Sprite2D ← 怪物贴图
├── HealthBar ← 血条容器
│ └── TextureProgressBar (或 ColorRect) ← 血条填充
├── Hitbox (CollisionShape2D) ← 碰撞区域
└── HitArea (Area2D) ← 受伤检测区域
└── CollisionShape2D生成怪物
需要一个"怪物生成器"来按照波次配置在路径起点创建怪物。
C
using Godot;
using System.Collections.Generic;
/// <summary>
/// 怪物生成器 —— 负责"制造"怪物并放到路径上
/// 就像工厂流水线一样,按照配置生产怪物
/// </summary>
public partial class MonsterSpawner : Node
{
/// <summary>怪物生成的路径</summary>
[Export] public Path2D MonsterPath { get; set; }
/// <summary>怪物存放的容器</summary>
[Export] public Node2D MonstersContainer { get; set; }
/// <summary>当前存活的怪物列表</summary>
private readonly List<Monster> _aliveMonsters = new();
/// <summary>
/// 生成一个怪物
/// </summary>
/// <param name="data">怪物数据资源</param>
/// <param name="delay">延迟生成秒数</param>
public void SpawnMonster(MonsterData data, float delay = 0.0f)
{
if (delay > 0)
{
// 延迟生成
var timer = GetTree().CreateTimer(delay);
timer.Timeout += () => DoSpawnMonster(data);
}
else
{
DoSpawnMonster(data);
}
}
/// <summary>
/// 实际生成怪物的逻辑
/// </summary>
private void DoSpawnMonster(MonsterData data)
{
if (data.MonsterScene == null)
{
GD.PrintErr($"怪物场景未设置: {data.MonsterName}");
return;
}
// 实例化怪物场景
var monster = data.MonsterScene.Instantiate<Monster>();
// 设置怪物属性(从数据资源中读取)
monster.MaxHp = data.MaxHp;
monster.MoveSpeed = data.MoveSpeed;
monster.Reward = data.Reward;
monster.Damage = data.Damage;
// 将怪物添加到路径上
MonsterPath.AddChild(monster);
// 初始位置在路径起点
monster.Progress = 0;
// 连接怪物信号
monster.MonsterDied += OnMonsterDied;
monster.MonsterReachedEnd += OnMonsterReachedEnd;
// 添加到存活列表
_aliveMonsters.Add(monster);
}
/// <summary>
/// 怪物死亡回调
/// </summary>
private void OnMonsterDied(int reward, Vector2 position)
{
// 给玩家金币
GameManager.Instance.AddGold(reward);
GameManager.Instance.AddScore(reward);
// 从存活列表移除
// 注意:需要在回调中找到对应的怪物引用
CheckWaveComplete();
}
/// <summary>
/// 怪物到达终点回调
/// </summary>
private void OnMonsterReachedEnd(int damage)
{
// 扣除生命值
GameManager.Instance.LoseLife(damage);
// 从存活列表移除
CheckWaveComplete();
}
/// <summary>
/// 检查当前波次是否完成(所有怪物都死了或到达终点)
/// </summary>
private void CheckWaveComplete()
{
// 清理已死亡的怪物引用
_aliveMonsters.RemoveAll(m => !IsInstanceValid(m) || !m.IsAlive);
if (_aliveMonsters.Count == 0)
{
GameManager.Instance.OnWaveComplete();
}
}
/// <summary>
/// 获取当前存活怪物数量
/// </summary>
public int GetAliveMonsterCount()
{
_aliveMonsters.RemoveAll(m => !IsInstanceValid(m) || !m.IsAlive);
return _aliveMonsters.Count;
}
/// <summary>
/// 清除所有怪物
/// </summary>
public void ClearAllMonsters()
{
foreach (var monster in _aliveMonsters)
{
if (IsInstanceValid(monster))
monster.QueueFree();
}
_aliveMonsters.Clear();
}
}GDScript
extends Node
class_name MonsterSpawner
## 怪物生成器 —— 负责"制造"怪物并放到路径上
## 就像工厂流水线一样,按照配置生产怪物
## 怪物生成的路径
@export var monster_path: Path2D
## 怪物存放的容器
@export var monsters_container: Node2D
## 当前存活的怪物列表
var _alive_monsters: Array[Monster] = []
## 生成一个怪物
func spawn_monster(data: MonsterData, delay: float = 0.0) -> void:
if delay > 0:
# 延迟生成
await get_tree().create_timer(delay).timeout
_do_spawn_monster(data)
else:
_do_spawn_monster(data)
## 实际生成怪物的逻辑
func _do_spawn_monster(data: MonsterData) -> void:
if not data.monster_scene:
push_error("怪物场景未设置: %s" % data.monster_name)
return
# 实例化怪物场景
var monster: Monster = data.monster_scene.instantiate()
# 设置怪物属性(从数据资源中读取)
monster.max_hp = data.max_hp
monster.move_speed = data.move_speed
monster.reward = data.reward
monster.damage = data.damage
# 将怪物添加到路径上
monster_path.add_child(monster)
# 初始位置在路径起点
monster.progress = 0
# 连接怪物信号
monster.monster_died.connect(_on_monster_died)
monster.monster_reached_end.connect(_on_monster_reached_end)
# 添加到存活列表
_alive_monsters.append(monster)
## 怪物死亡回调
func _on_monster_died(reward: int, _position: Vector2) -> void:
# 给玩家金币
GameManager.add_gold(reward)
GameManager.add_score(reward)
_check_wave_complete()
## 怪物到达终点回调
func _on_monster_reached_end(damage: int) -> void:
# 扣除生命值
GameManager.lose_life(damage)
_check_wave_complete()
## 检查当前波次是否完成
func _check_wave_complete() -> void:
# 清理已死亡的怪物引用
_alive_monsters = _alive_monsters.filter(
func(m): return is_instance_valid(m) and m.is_alive
)
if _alive_monsters.is_empty():
GameManager.on_wave_complete()
## 获取当前存活怪物数量
func get_alive_monster_count() -> int:
_alive_monsters = _alive_monsters.filter(
func(m): return is_instance_valid(m) and m.is_alive
)
return _alive_monsters.size()
## 清除所有怪物
func clear_all_monsters() -> void:
for monster in _alive_monsters:
if is_instance_valid(monster):
monster.queue_free()
_alive_monsters.clear()路径可视化调试
在开发过程中,你可能想看到路径的形状。Godot 默认在编辑器中会显示 Path2D 的曲线,但你也可以在运行时显示它来辅助调试。
C
/// <summary>
/// 路径调试工具 —— 在运行时画出路径,方便调试
/// </summary>
public partial class PathDebugger : Node2D
{
[Export] public Path2D TargetPath { get; set; }
[Export] public Color LineColor { get; set; } = Colors.Yellow;
[Export] public float LineWidth { get; set; } = 2.0f;
public override void _Draw()
{
if (TargetPath == null || TargetPath.Curve == null) return;
var curve = TargetPath.Curve;
int pointCount = curve.PointCount;
if (pointCount < 2) return;
// 画出路径曲线
for (int i = 0; i < pointCount - 1; i++)
{
var start = curve.GetPointPosition(i);
var end = curve.GetPointPosition(i + 1);
DrawLine(start, end, LineColor, (float)LineWidth);
}
}
}GDScript
extends Node2D
class_name PathDebugger
## 路径调试工具 —— 在运行时画出路径,方便调试
## 目标路径
@export var target_path: Path2D
## 线条颜色
@export var line_color: Color = Color.YELLOW
## 线条宽度
@export var line_width: float = 2.0
func _draw() -> void:
if not target_path or not target_path.curve:
return
var curve: Curve2D = target_path.curve
var point_count: int = curve.point_count
if point_count < 2:
return
# 画出路径曲线
for i in range(point_count - 1):
var start: Vector2 = curve.get_point_position(i)
var end: Vector2 = curve.get_point_position(i + 1)
draw_line(start, end, line_color, line_width)本节小结
| 概念 | 说明 |
|---|---|
| Path2D | 定义怪物行走的路线,用控制点画曲线 |
| PathFollow2D | 沿路径移动的节点,通过 progress 控制位置 |
| MonsterData | 怪物的数据资源,定义血量、速度、奖励等 |
| Monster | 怪物脚本,处理移动、受伤、死亡逻辑 |
| MonsterSpawner | 怪物生成器,按配置创建怪物实例 |
现在怪物已经能够沿着路径行走了!下一节我们将实现防御塔系统——那些用来攻击怪物的主力军。
