10. 寻路与导航
寻路与导航
你有没有注意过,游戏里的敌人不是直线朝你跑过来的?就算中间有墙、有石头、有水,它们也能绕过障碍找到你。这就是寻路(Pathfinding)的作用。
打个比方:寻路就像你用手机导航开车。你告诉导航"我要去人民广场",导航会帮你算出一条路线——遇到堵路就绕行,遇到单行道就换一条路。游戏里的敌人也一样,它们用"导航系统"找到通往玩家的最佳路线。
为什么需要寻路
如果没有寻路,游戏里的角色只能直线移动——遇到墙就傻站在那里,不知道绕过去。这样的游戏体验非常糟糕。
| 场景 | 没有寻路 | 有寻路 |
|---|---|---|
| 敌人追玩家 | 撞墙,卡住 | 绕过障碍物追上来 |
| NPC 走路 | 穿墙而过(Bug) | 沿着道路行走 |
| RTS 士兵移动 | 堆在一起走不动 | 自动列队绕行 |
| 塔防敌人 | 随便走,可能卡住 | 沿着路径前进 |
NavigationRegion2D —— 定义可行走区域
在 Godot 中,寻路的第一步是告诉游戏"哪些地方可以走,哪些地方不能走"。这就像给游戏画一张导航地图。
NavigationRegion2D 就是用来定义可行走区域的节点。你需要在它上面画一个导航网格(Navigation Mesh,简称 NavMesh),标记出所有角色可以行走的地面。
创建导航区域
- 在场景中添加一个
NavigationRegion2D节点 - 选中它,在 Inspector 面板中找到
Navigation Mesh属性 - 点击创建新的
NavigationPolygon - 在顶部工具栏切换到"导航"模式(图标是一个导航箭头)
- 在场景中画出可行走的区域(多边形)
小技巧
导航网格不需要画得非常精确。只要覆盖了角色能走到的所有区域就行。角色不会精确地踩在导航网格的边缘上,而是会沿着网格的中心区域行走。
NavigationAgent2D —— 自动寻路
有了导航区域,接下来就是让角色使用它来寻路。NavigationAgent2D 就是 Godot 提供的"GPS 导航仪"——你告诉它目标位置,它自动帮你算路线。
基本用法
给角色(CharacterBody2D)添加一个 NavigationAgent2D 子节点,然后通过代码设置目标位置,Agent 就会自动计算路径。
using Godot;
public partial class NavigatingEnemy : CharacterBody2D
{
private NavigationAgent2D _agent;
private CharacterBody2D _player;
[Export] public float Speed { get; set; } = 150f;
public override void _Ready()
{
// 获取子节点 NavigationAgent2D
_agent = GetNode<NavigationAgent2D>("NavigationAgent2D");
_player = GetTree().GetFirstNodeInGroup("player") as CharacterBody2D;
// 设置寻路参数
_agent.PathDesiredDistance = 10f; // 到目标多近算"到达"
_agent.TargetDesiredDistance = 15f; // 到玩家多近算"追上了"
_agent.PathMaxDistance = 50f; // 路径点之间最大距离
// 设置导航地图
_agent.NavigationMap = GetWorld2d().NavigationMap;
}
public override void _PhysicsProcess(double delta)
{
if (_player == null) return;
// 每帧更新目标位置为玩家当前位置
_agent.TargetPosition = _player.GlobalPosition;
// 获取 Agent 计算出的下一个移动位置
Vector2 nextPosition = _agent.GetNextPathPosition();
// 朝下一个位置移动
Vector2 direction = (nextPosition - GlobalPosition).Normalized();
Velocity = direction * Speed;
MoveAndSlide();
}
}extends CharacterBody2D
@export var speed: float = 150.0
@onready var _agent: NavigationAgent2D = $NavigationAgent2D
var _player: CharacterBody2D = null
func _ready() -> void:
_player = get_tree().get_first_node_in_group("player") as CharacterBody2D
# 设置寻路参数
_agent.path_desired_distance = 10.0 # 到目标多近算"到达"
_agent.target_desired_distance = 15.0 # 到玩家多近算"追上了"
_agent.path_max_distance = 50.0 # 路径点之间最大距离
# 设置导航地图
_agent.navigation_map = get_world_2d().navigation_map
func _physics_process(_delta: float) -> void:
if not _player:
return
# 每帧更新目标位置为玩家当前位置
_agent.target_position = _player.global_position
# 获取 Agent 计算出的下一个移动位置
var next_position := _agent.get_next_path_position()
# 朝下一个位置移动
var direction := (next_position - global_position).normalized()
velocity = direction * speed
move_and_slide()寻路参数详解
| 参数 | 含义 | 推荐值 |
|---|---|---|
PathDesiredDistance | 路径点之间的最小距离,太密会频繁更新 | 10-20 |
TargetDesiredDistance | 到目标多近算"到达",太小角色会在目标附近抖动 | 10-20 |
PathMaxDistance | 角色偏离路径多远需要重新计算 | 30-50 |
AvoidanceEnabled | 是否启用避障(角色之间不重叠) | true |
AvoidanceLayers | 避障的层级,同层会互相避让 | 根据需要设置 |
A* 算法基础——寻路的底层原理
Godot 的 NavigationAgent2D 内部使用的是 A* 算法(读作"A-Star"),这是游戏开发中最常用的寻路算法。了解它的原理有助于你理解寻路系统的行为。
A* 算法的生活化解释
想象你在一片迷宫里,想从入口走到出口。你会怎么做?
- 你会先看周围有几条路可以走
- 对于每条路,你会评估:走这条路大概还要走多远?
- 你会优先选择"已经走的距离 + 估计还要走的距离"最短的那条路
- 如果一条路走不通(死胡同),你就退回来换另一条
这就是 A* 算法的核心思路。它用两个数值来评估每条路:
| 数值 | 含义 | 公式 |
|---|---|---|
| G 值 | 从起点到当前点的实际距离 | 已经走了多少步 |
| H 值 | 从当前点到终点的估计距离(启发值) | 用直线距离估算 |
| F 值 | 总评估值 | F = G + H |
A* 每次都选择 F 值最小的方向继续探索。
一个简单的例子
起点 S,终点 E,# 是墙
S . . . .
. # # . .
. # . . .
. . . # .
. . . . E
从 S 到 E 的最短路径(用 * 标记):
S * . . .
. # # * .
. # . * .
. . . # *
. . . * EA* 算法会这样思考:
- 从 S 出发,可以向上、下、左、右四个方向走
- 向右走的 H 值最小(离终点最近),所以先探索右边
- 遇到墙壁后,绕行继续探索
- 最终找到 F 值最小的路径
为什么 A* 比"暴力搜索"快
暴力搜索会尝试所有可能的路径,然后挑最短的——这就像你走迷宫时把每条路都走一遍。A* 聪明在它会"猜"哪个方向更可能通向终点,优先探索那个方向,省去了大量无用的搜索。
动态障碍——运行时更新导航
游戏世界中不是一成不变的。门可能会关上、桥可能会断、石头可能会挡路。当这些变化发生时,导航区域也需要更新。
方法一:使用 NavigationLink2D 连接分离的区域
当两个导航区域之间有间隙(比如一座桥),可以用 NavigationLink2D 来连接它们。
方法二:运行时修改导航区域
using Godot;
public partial class DynamicNavigation : Node
{
private NavigationRegion2D _navRegion;
public override void _Ready()
{
_navRegion = GetNode<NavigationRegion2D>("NavigationRegion2D");
}
// 添加一个矩形障碍(比如一堵倒塌的墙)
public void AddObstacle(Rect2 obstacleArea)
{
var navigationMesh = _navRegion.NavigationPolygon;
// 从当前导航网格中挖掉障碍区域
// 注意:Godot 4 的 NavigationPolygon 支持直接修改
// 这里用简化方式——重新烘焙导航网格
BakeWithObstacle(obstacleArea);
}
// 移除障碍
public void RemoveObstacle()
{
// 重新烘焙,不包含障碍
BakeWithObstacle(new Rect2());
}
private void BakeWithObstacle(Rect2 obstacle)
{
// 在实际项目中,你可以使用 NavigationServer
// 直接在运行时修改导航网格
var navMap = _navRegion.NavigationMap;
// 使用 NavigationServer 查询路径
// 服务器会在下次查询时自动使用更新后的导航数据
GD.Print("导航区域已更新");
}
// 查询两点之间的路径(不移动,只获取路径)
public Vector2[] GetPath(Vector2 from, Vector2 to)
{
var map = GetWorld2d().NavigationMap;
return NavigationServer2D.MapGetPath(map, from, to, true);
}
}extends Node
var _nav_region: NavigationRegion2D
func _ready() -> void:
_nav_region = $NavigationRegion2D
# 添加障碍物到导航区域
func add_obstacle(obstacle_rect: Rect2) -> void:
# 使用 NavigationServer 的 RegionSetNavigationPolygon
# 在运行时修改导航网格
var nav_map := get_world_2d().navigation_map
var rid := _nav_region.get_region_rid()
# 方法:重新设置导航多边形(排除障碍区域)
var new_polygon := NavigationPolygon.new()
# ... 构建新的多边形,排除障碍区域 ...
# NavigationServer2D.region_set_navigation_polygon(rid, new_polygon)
# 触发烘焙
NavigationServer2D.bake_from_source_geometry_data(nav_map)
print("导航区域已更新,障碍已添加")
# 移除障碍
func remove_obstacle() -> void:
var nav_map := get_world_2d().navigation_map
NavigationServer2D.bake_from_source_geometry_data(nav_map)
print("导航区域已更新,障碍已移除")
# 查询两点之间的路径
func get_path(from: Vector2, to: Vector2) -> PackedVector2Array:
var nav_map := get_world_2d().navigation_map
return NavigationServer2D.map_get_path(nav_map, from, to, true)方法三:使用 RigidBody2D 作为动态障碍
如果障碍物本身就是一个物理节点(比如一个可以被推动的箱子),你可以直接让它和导航系统配合:
- 给箱子添加
NavigationObstacle2D子节点 - 当箱子移动时,导航系统会自动更新可行走区域
using Godot;
public partial class PushableBox : RigidBody2D
{
private NavigationObstacle2D _navObstacle;
public override void _Ready()
{
// 给物理刚体添加导航障碍
_navObstacle = new NavigationObstacle2D();
_navObstacle.AvoidanceEnabled = true;
_navObstacle.Radius = 30f; // 障碍物半径
AddChild(_navObstacle);
}
}extends RigidBody2D
var _nav_obstacle: NavigationObstacle2D
func _ready() -> void:
# 给物理刚体添加导航障碍
_nav_obstacle = NavigationObstacle2D.new()
_nav_obstacle.avoidance_enabled = true
_nav_obstacle.radius = 30.0 # 障碍物半径
add_child(_nav_obstacle)实战案例:塔防敌人路径
塔防游戏的核心玩法就是敌人沿着固定路径前进,玩家在路径两侧放置防御塔。下面我们实现一个简单的塔防敌人寻路系统。
场景设置
- 创建一个地图场景,画出路径
- 在路径上放置
NavigationRegion2D,标记可行走区域 - 创建敌人场景,添加
NavigationAgent2D - 创建路径点(起点和终点)
using Godot;
using System.Collections.Generic;
public partial class TowerDefenseEnemy : CharacterBody2D
{
private NavigationAgent2D _agent;
[Export] public float BaseSpeed { get; set; } = 100f;
[Export] public int MaxHp { get; set; } = 100;
[Export] public int GoldReward { get; set; } = 10;
public int CurrentHp { get; private set; }
public float Speed { get; set; }
// 路径点(从起点到终点)
private Vector2[] _waypoints;
private int _currentWaypointIndex = 0;
private bool _isAlive = true;
// 信号:敌人到达终点
[Signal] public delegate void ReachedEndEventHandler();
// 信号:敌人被消灭
[Signal] public delegate void KilledEventHandler(int goldReward);
public override void _Ready()
{
_agent = GetNode<NavigationAgent2D>("NavigationAgent2D");
_agent.PathDesiredDistance = 5f;
_agent.TargetDesiredDistance = 10f;
CurrentHp = MaxHp;
Speed = BaseSpeed;
// 初始化路径点
_waypoints = GetWaypoints();
if (_waypoints.Length > 0)
{
_agent.TargetPosition = _waypoints[0];
}
}
public override void _PhysicsProcess(double delta)
{
if (!_isAlive) return;
if (_agent.IsNavigationFinished())
{
// 到达当前路径点,前往下一个
_currentWaypointIndex++;
if (_currentWaypointIndex >= _waypoints.Length)
{
// 到达终点!
_isAlive = false;
EmitSignal(SignalName.ReachedEnd);
QueueFree();
return;
}
_agent.TargetPosition = _waypoints[_currentWaypointIndex];
return;
}
// 沿着寻路路径移动
Vector2 nextPos = _agent.GetNextPathPosition();
Vector2 direction = (nextPos - GlobalPosition).Normalized();
// 面朝移动方向
if (direction.X != 0)
{
Sprite2D sprite = GetNodeOrNull<Sprite2D>("Sprite2D");
if (sprite != null)
{
sprite.FlipH = direction.X < 0;
}
}
Velocity = direction * Speed;
MoveAndSlide();
}
// 被防御塔攻击
public void TakeDamage(int damage)
{
CurrentHp -= damage;
GD.Print($"敌人受到 {damage} 点伤害,剩余 HP:{CurrentHp}");
if (CurrentHp <= 0)
{
Die();
}
}
// 减速效果(冰冻塔)
public void ApplySlow(float slowFactor, float duration)
{
Speed = BaseSpeed * slowFactor;
var timer = GetTree().CreateTimer(duration);
timer.Timeout += () =>
{
Speed = BaseSpeed;
};
}
private void Die()
{
_isAlive = false;
GD.Print("敌人被消灭!");
EmitSignal(SignalName.Killed, GoldReward);
QueueFree();
}
// 获取路径点(从场景中的 Marker2D 节点获取)
private Vector2[] GetWaypoints()
{
var points = new List<Vector2>();
int index = 0;
while (true)
{
var marker = GetNodeOrNull<Marker2D>($"../Waypoints/Point{index}");
if (marker == null) break;
points.Add(marker.GlobalPosition);
index++;
}
GD.Print($"找到 {points.Count} 个路径点");
return points.ToArray();
}
}extends CharacterBody2D
signal reached_end
signal killed(gold_reward: int)
@export var base_speed: float = 100.0
@export var max_hp: int = 100
@export var gold_reward: int = 10
@onready var _agent: NavigationAgent2D = $NavigationAgent2D
var current_hp: int
var speed: float
var _waypoints: Array[Vector2] = []
var _current_waypoint_index: int = 0
var _is_alive: bool = true
func _ready() -> void:
_agent.path_desired_distance = 5.0
_agent.target_desired_distance = 10.0
current_hp = max_hp
speed = base_speed
# 初始化路径点
_waypoints = _get_waypoints()
if _waypoints.size() > 0:
_agent.target_position = _waypoints[0]
func _physics_process(_delta: float) -> void:
if not _is_alive:
return
if _agent.is_navigation_finished():
_current_waypoint_index += 1
if _current_waypoint_index >= _waypoints.size():
# 到达终点
_is_alive = false
reached_end.emit()
queue_free()
return
_agent.target_position = _waypoints[_current_waypoint_index]
return
# 沿着路径移动
var next_pos := _agent.get_next_path_position()
var direction := (next_pos - global_position).normalized()
# 面朝移动方向
if direction.x != 0:
var sprite := get_node_or_null("Sprite2D") as Sprite2D
if sprite:
sprite.flip_h = direction.x < 0
velocity = direction * speed
move_and_slide()
# 被防御塔攻击
func take_damage(damage: int) -> void:
current_hp -= damage
print("敌人受到 %d 点伤害,剩余 HP:%d" % [damage, current_hp])
if current_hp <= 0:
_die()
# 减速效果
func apply_slow(slow_factor: float, duration: float) -> void:
speed = base_speed * slow_factor
await get_tree().create_timer(duration).timeout
speed = base_speed
func _die() -> void:
_is_alive = false
print("敌人被消灭!")
killed.emit(gold_reward)
queue_free()
# 获取路径点
func _get_waypoints() -> Array[Vector2]:
var points: Array[Vector2] = []
var index := 0
while true:
var marker := get_node_or_null("../Waypoints/Point%d" % index) as Marker2D
if not marker:
break
points.append(marker.global_position)
index += 1
print("找到 %d 个路径点" % points.size())
return points寻路优化技巧
| 技巧 | 说明 |
|---|---|
| 减少寻路频率 | 不是每帧都需要重新计算路径,可以每隔 0.5 秒算一次 |
| 简化导航网格 | 导航网格越简单,寻路越快。用大块多边形代替细碎的小块 |
| 分层寻路 | 大范围用粗糙网格快速找方向,小范围用精细网格找具体路线 |
| 路径平滑 | A* 算出来的路径是折线,可以适当平滑让移动更自然 |
| 限制寻路距离 | 如果目标太远,可以分阶段寻路而不是一次算完全程 |
最终建议
- NavigationRegion2D + NavigationAgent2D 是 Godot 4 的标准寻路方案,优先使用
- 导航网格不需要太精细,够用就行
- 敌人不需要每帧都重新计算路径——玩家位置变化不大时不需要更新
- 动态障碍用
NavigationObstacle2D最简单 - 塔防类游戏可以预计算路径,不需要实时寻路
