
1. 项目概述为什么RTS游戏的寻路是块硬骨头如果你做过即时战略游戏或者哪怕只是玩过《星际争霸》、《帝国时代》这类经典作品你肯定对“单位卡住”、“寻路乱撞”深恶痛绝。几十上百个单位在地图上移动遇到障碍物、友军、敌军如何让它们高效、平滑、不“脑残”地到达目的地是RTS游戏开发中最核心、也最棘手的问题之一。很多独立开发者初期都会用引擎自带的NavigationServer2D但一旦单位数量上来或者需要更精细的控制比如阵型、动态障碍就会立刻捉襟见肘。这就是为什么我们需要深入引擎底层自己动手实现一套基于AStar2D的寻路系统。Godot 4.2的AStar2D类是一个功能强大且高效的图搜索算法实现它不直接处理渲染和移动只专注于一件事给你从A点到B点的最优路径点列表。这意味着我们将拥有完全的掌控权。今天要分享的就是我在一个RTS原型项目中从零开始构建这套智能寻路系统的完整思路、踩过的坑以及可以直接“抄作业”的代码。我们的目标不仅是“能用”更是要达到“好用”和“敢用”的水平解决路径抖动、性能瓶颈并适配RTS游戏的高并发寻路需求。2. 核心思路与架构设计从网格到平滑路径在动手写代码之前我们必须把顶层设计想清楚。一个鲁棒的RTS寻路系统绝不是简单调用AStar2D.find_path()就完事了。它需要分层处理我将整个系统分解为四个核心层。2.1 数据层网格化世界的抽象寻路算法需要一个数据结构来理解世界。对于2D RTS最常用且高效的就是网格。我们将游戏世界划分为一个由正方形或六边形组成的网格每个格子就是一个“点”。为什么选择网格简单直观坐标转换容易判断单位占用、障碍物位置非常方便。性能可控AStar2D算法的性能与图的节点数直接相关。网格的节点数是固定的地图尺寸/格子大小便于我们进行性能预估和优化。兼容性好易于与常见的RTS地图编辑器如Tiled导出的数据结合。在Godot中我们不会为每个网格创建一个AStar2D节点那样开销太大。正确的做法是在游戏主场景或一个专用的寻路管理器中实例化一个AStar2D对象并动态地向其中添加和连接节点。网格参数设计cell_size格子大小。这是最重要的参数需要在寻路精度和性能之间权衡。值越小寻路精度越高单位可以走更精细的路线但节点数呈平方增长计算量剧增。值越大节点数少性能好但寻路会显得“粗糙”单位可能无法通过狭窄的通道。经验值对于典型RTS单位尺寸约32x32像素cell_size设为16或32是个不错的起点。这意味着一个单位大约占据1-4个格子。grid_width/grid_height根据你的地图大小和cell_size计算得出。2.2 算法层AStar2D的配置与优化AStar2D类提供了算法核心。我们需要关注几个关键方法add_point(id, position, weight_scale1.0)添加一个节点。id是唯一标识通常可以用格子的索引x y * grid_width。position是节点的世界坐标通常是格子中心。weight_scale可以用于标记“难走”的区域如沼泽、浅滩让算法优先选择其他路径。connect_points(id, to_id, bidirectionaltrue)连接两个节点表示单位可以从此格走到彼格。are_points_connected(id, to_id)检查连接性。get_point_path(from_id, to_id)核心方法返回从起点ID到终点ID的路径点Vector2数组。一个关键的优化点连接策略。默认的八方向连接上、下、左、右、四个对角线会让路径更短但会带来一个问题单位在移动时如果严格按照路径点走在拐角处会“切着”障碍物的边角移动可能导致视觉上的穿模或逻辑上的碰撞。对于RTS我强烈建议在初始化网格时只进行四方向连接上、下、左、右。这虽然会让路径在微观上看起来不是绝对最短但能保证路径始终在格子中心线上远离障碍物边缘为后续的路径平滑和单位碰撞留出空间。对角线移动可以通过路径平滑算法来实现观感更自然。2.3 逻辑层寻路请求与结果处理当玩家框选一群单位并点击地面时我们会收到一个寻路请求。这里不能简单地为每个单位单独计算一次路径那会造成巨大的性能浪费和路径冲突。批量寻路与路径分配策略计算集体目标点首先我们需要为这群单位计算一个“集体目标区域”。简单的方法是以玩家点击的点为中心根据单位数量生成一个偏移位置列表例如圆形或矩形分布。主路径查询为其中一个“种子”单位或虚拟中心点使用AStar2D计算一条从起点到集体目标区域边缘的主路径。这条路径是避开所有静态障碍物的。路径分流与分配将这条主路径共享给所有单位。每个单位的最终目标是在主路径终点附近的那个偏移位置上。它们可以沿着相同的主路径移动大部分距离只在最后阶段分流。这极大地减少了AStar算法的调用次数。动态障碍处理对于移动的友军或敌军等动态障碍不应直接写入AStar2D的网格因为频繁更新网格成本高。而是在逻辑层通过局部避障Local Avoidance或“移动意愿”向量场来处理这涉及到另一个算法如RVO2本文聚焦于全局寻路暂不深入。2.4 表现层路径平滑与移动控制AStar2D返回的路径是一系列格子中心的点。如果让单位直接从一个点直线走向下一个点移动轨迹将是生硬的折线看起来非常机械。路径平滑Path Smoothing我们需要对原始路径进行平滑处理。一个常用且有效的方法是拐点提取。从起点开始检查当前路径点current到后续某个点future例如current3的连线。如果这条连线上没有与任何障碍物相交需要进行射线检测那么中间的所有点都可以被忽略单位可以直接从current走向future。将future设为新的current重复此过程。 这样我们得到了一条由关键拐点组成的、更平滑的路径。单位移动时使用Godot的Tween或自己在_process中插值在关键拐点之间进行平滑的曲线移动如贝塞尔曲线视觉体验会大幅提升。3. 完整代码实现与分步解析下面我将结合代码详细讲解如何实现上述架构。我们创建一个名为RTSAStarManager的Node2D单例来管理整个寻路系统。3.1 第一步初始化寻路网格extends Node2D class_name RTSAStarManager # 单例访问 static var instance: RTSAStarManager # 寻路核心 var astar: AStar2D # 网格参数 var cell_size: Vector2 Vector2(32, 32) var grid_size: Vector2i Vector2i(100, 100) # 100x100的网格 var grid_rect: Rect2i # 用于快速将世界坐标转换为网格ID的字典 var world_to_id_map: Dictionary {} func _ready(): instance self grid_rect Rect2i(0, 0, grid_size.x * cell_size.x, grid_size.y * cell_size.y) _initialize_astar_grid() func _initialize_astar_grid(): astar AStar2D.new() # 1. 添加所有网格点 for y in range(grid_size.y): for x in range(grid_size.x): var point_id _get_point_id(x, y) var point_position Vector2(x * cell_size.x cell_size.x/2, y * cell_size.y cell_size.y/2) astar.add_point(point_id, point_position) world_to_id_map[Vector2i(x, y)] point_id # 2. 连接网格点采用四方向连接避免切角 for y in range(grid_size.y): for x in range(grid_size.x): var current_id _get_point_id(x, y) # 检查右方格子 if x 1 grid_size.x: var right_id _get_point_id(x 1, y) if _can_connect_points(x, y, x 1, y): astar.connect_points(current_id, right_id, false) # 检查下方格子 if y 1 grid_size.y: var down_id _get_point_id(x, y 1) if _can_connect_points(x, y, x, y 1): astar.connect_points(current_id, down_id, false) # 根据网格坐标生成唯一ID func _get_point_id(x: int, y: int) - int: return x y * grid_size.x # 判断两个格子之间是否可以连接这里可以加入地形代价、初始障碍物判断 func _can_connect_points(x1: int, y1: int, x2: int, y2: int) - bool: # 示例这里可以读取你的地图数据如果两个格子中任意一个是障碍物则返回false # var tile_data1 get_tile_data(x1, y1) # var tile_data2 get_tile_data(x2, y2) # return not (tile_data1.is_obstacle or tile_data2.is_obstacle) return true # 暂时假设所有格子都可通行关键点解析_get_point_id: 使用x y * width的公式生成唯一ID这是一个标准且高效的做法可以方便地在坐标和ID间来回转换。_can_connect_points: 这是你注入游戏逻辑的地方。你可以在这里读取你的地图数据例如从TileMap中获取单元格的自定义数据custom_data判断该格子是否是山脉、水域等不可通行地形或者是否是预先放置的静态建筑。返回false可以阻止AStar2D连接这两个点从而在算法层面创建障碍。四方向连接注意connect_points的第三个参数是false因为我们是在循环中双向遍历的如果设为true会导致重复连接。先连右和下就能覆盖所有四方向连接。3.2 第二步处理动态障碍物单位、建筑静态障碍物在初始化时就确定了。但游戏中的建筑是动态建造的单位也会临时阻挡路径。我们不能频繁地调用astar.set_point_disabled()因为禁用/启用节点虽然比删除/添加快但大量调用仍有开销。推荐策略分层代价Layered Cost。静态层初始化时设置的基础通行性通过_can_connect_points控制。动态阻挡层我们不直接修改AStar2D的图结构而是为每个格子维护一个“动态阻挡计数”。寻路查询时当计算路径时我们通过一个自定义的_estimate_cost和_compute_cost函数需要继承AStar2D类并重写来动态评估成本。如果一个格子的动态阻挡计数大于0我们可以返回一个非常高的代价让算法“几乎”不会选择这个格子除非无路可走。这比直接禁用节点更灵活也避免了图结构的变更。# 在RTSAStarManager中扩展 var dynamic_block_cell: Dictionary {} # key: Vector2i网格坐标, value: 阻挡计数 func register_dynamic_obstacle(grid_position: Vector2i): if dynamic_block_cell.has(grid_position): dynamic_block_cell[grid_position] 1 else: dynamic_block_cell[grid_position] 1 # 注意这里不修改astar只更新我们的字典 func unregister_dynamic_obstacle(grid_position: Vector2i): if dynamic_block_cell.has(grid_position): dynamic_block_cell[grid_position] - 1 if dynamic_block_cell[grid_position] 0: dynamic_block_cell.erase(grid_position) # 需要一个自定义的AStar2D类来集成动态代价 class MyAStar2D extends AStar2D: var manager_ref: WeakRef func _compute_cost(from_id: int, to_id: int) - float: var base_cost super._compute_cost(from_id, to_id) var mgr manager_ref.get_ref() as RTSAStarManager if mgr: var from_pos mgr._id_to_grid(from_id) var to_pos mgr._id_to_grid(to_id) # 如果目标格子有动态阻挡增加巨额代价 if mgr.dynamic_block_cell.get(to_pos, 0) 0: base_cost 1000.0 return base_cost注意Godot 4.2的AStar2D的_compute_cost和_estimate_cost是虚函数但直接重写可能不如上述方法稳定。更实用的方法是在获取路径后进行路径后处理检查路径中的每个点如果它被动态阻挡则在该点之前重新规划一条局部路径或者让单位等待。对于大量单位维护动态代价层是更优解但实现稍复杂。初期可以先采用简单的后处理或直接忽略动态单位依靠局部避障。3.3 第三步发起寻路请求并平滑路径这是给游戏单位调用的核心接口。# RTSAStarManager 中 func calculate_path(start_world_pos: Vector2, end_world_pos: Vector2) - PackedVector2Array: # 1. 世界坐标转网格坐标 var start_grid _world_to_grid(start_world_pos) var end_grid _world_to_grid(end_world_pos) # 2. 检查起点和终点是否在网格内且可通行可选增加鲁棒性 if not _is_point_valid(start_grid) or not _is_point_valid(end_grid): return PackedVector2Array() # 返回空路径 # 3. 获取起点和终点的AStar ID var start_id _get_point_id(start_grid.x, start_grid.y) var end_id _get_point_id(end_grid.x, end_grid.y) # 4. 调用AStar算法获取原始路径格子中心点 var raw_path: PackedVector2Array astar.get_point_path(start_id, end_id) if raw_path.size() 2: return PackedVector2Array() # 起点终点相同或无法到达 # 5. 路径平滑处理 var smoothed_path _smooth_path(raw_path) return smoothed_path func _world_to_grid(world_pos: Vector2) - Vector2i: var x int((world_pos.x) / cell_size.x) var y int((world_pos.y) / cell_size.y) # 钳制到网格范围内 x clampi(x, 0, grid_size.x - 1) y clampi(y, 0, grid_size.y - 1) return Vector2i(x, y) func _is_point_valid(grid_pos: Vector2i) - bool: # 检查是否在网格内以及是否是障碍物结合静态和动态判断 if grid_pos.x 0 or grid_pos.y 0 or grid_pos.x grid_size.x or grid_pos.y grid_size.y: return false # 这里应加入你的地形障碍判断逻辑 # if is_obstacle_tile(grid_pos): return false return true func _smooth_path(raw_path: PackedVector2Array) - PackedVector2Array: if raw_path.size() 2: return raw_path var smoothed: PackedVector2Array [] smoothed.append(raw_path[0]) # 起点总是包含 var current_index 0 while current_index raw_path.size() - 1: var furthest_valid current_index 1 # 尝试跳过尽可能多的中间点 for check_index in range(current_index 2, raw_path.size()): if _has_line_of_sight(raw_path[current_index], raw_path[check_index]): furthest_valid check_index else: break # 一旦视线被阻停止检查更远的点 smoothed.append(raw_path[furthest_valid]) current_index furthest_valid return smoothed func _has_line_of_sight(point_a: Vector2, point_b: Vector2) - bool: # 这里需要实现一个射线检测检查线段AB是否与任何静态障碍物相交 # 可以使用PhysicsRayQueryParameters2D进行物理射线检测 # 或者根据你的障碍物数据如TileMap的特定图层进行数学判断。 # 这是一个简化示例假设我们有一个obstacle_layer用于检测。 var space_state get_world_2d().direct_space_state var query PhysicsRayQueryParameters2D.create(point_a, point_b) query.collision_mask 2 # 假设你的障碍物在第二层碰撞层 query.exclude [] # 可以排除单位自身 var result space_state.intersect_ray(query) return result.is_empty() # 没有碰到障碍物则有视线平滑算法解析_smooth_path函数实现了前面提到的拐点提取算法。它从起点开始不断尝试“看”向路径上更远的点如果两点间连线无障碍就跳过中间的所有点。最终得到的smoothed_path只包含关键的拐点路径点数量大大减少单位移动会更加直接、自然。3.4 第四步单位移动控制与队列管理最后我们需要一个单位脚本来使用寻路管理器。# Unit.gd extends CharacterBody2D class_name RTSUnit export var speed: float 200.0 var current_path: PackedVector2Array [] var current_path_index: int 0 var is_moving: bool false func move_to(target_world_pos: Vector2): var astar_manager RTSAStarManager.instance if not astar_manager: return current_path astar_manager.calculate_path(global_position, target_world_pos) if current_path.size() 1: current_path_index 1 # 0是当前位置从下一个点开始 is_moving true else: is_moving false print(无法到达目标点或已在目标点) func _physics_process(delta): if not is_moving or current_path_index current_path.size(): return var target_point current_path[current_path_index] var direction (target_point - global_position).normalized() var distance_to_target global_position.distance_to(target_point) # 简单移动逻辑 if distance_to_target 5.0: # 一个小的阈值防止在目标点附近振荡 velocity direction * speed move_and_slide() else: # 到达当前路径点前往下一个 current_path_index 1 if current_path_index current_path.size(): # 到达最终目的地 is_moving false velocity Vector2.ZERO4. 性能优化与高级技巧当你的单位数量达到几十上百时原始的寻路系统可能会遇到性能瓶颈。以下是几个关键的优化方向4.1 空间分割与局部更新不要每次单位移动都重新计算全局路径。对于RTS游戏单位的大部分移动是短距离的微操。路径分段对于长距离移动可以每走完一段路径比如10个格子再计算下一段。这能减少单次寻路的计算量。局部避障优先当检测到前方有动态障碍其他单位时优先尝试简单的转向或短暂等待而不是立即重新进行昂贵的全局AStar寻路。可以结合RayCast2D进行前方探测。4.2 异步寻路与队列寻路计算尤其是长距离或复杂地形的寻路可能在一帧内消耗较多时间。为了避免游戏卡顿必须进行异步处理。使用Callable与awaitGodot 4.x 的SceneTree提供了process_frame和physics_frame信号可以用于将长任务分摊到多帧。实现寻路任务队列创建一个队列来管理寻路请求。在_process中每帧只处理固定数量如1-2个的寻路请求将结果通过信号回调给请求的单位。这能平滑CPU使用率避免帧率骤降。# 在RTSAStarManager中 var pathfinding_queue: Array [] var requests_per_frame: int 2 func _process(delta): var processed 0 while pathfinding_queue.size() 0 and processed requests_per_frame: var request pathfinding_queue.pop_front() var path _calculate_path_sync(request.start, request.end) # 同步计算 request.callback.call(path) # 通过回调返回结果 processed 1 func request_path_async(start: Vector2, end: Vector2, callback: Callable): pathfinding_queue.append({“start”: start, “end”: end, “callback”: callback})4.3 内存管理与网格复用AStar2D对象本身比较轻量但如果你需要支持超大地图网格节点数会非常庞大1000x1000的地图就有100万个点。这时需要考虑流式加载/分区网格将大地图划分为多个区域chunks只加载和激活玩家当前所在区域及相邻区域的寻路网格。当单位靠近区域边界时预加载下一个区域的网格数据。使用更稀疏的导航网格对于开放区域可以使用更大的cell_size或者使用多边形导航网格NavigationRegion2D来处理复杂地形而AStar2D用于处理网格化的逻辑判断两者结合。5. 常见问题与调试技巧在实际开发中你肯定会遇到各种奇怪的问题。这里记录几个我踩过的坑和解决方法。5.1 路径抖动与“鬼畜”移动现象单位在移动时尤其是靠近障碍物或路径点密集时会频繁地微小转向看起来在抖动。原因路径点过于密集单位在_physics_process中每帧都判断“到达”了一个点然后立刻转向下一个点由于浮点数精度问题永远无法稳定“到达”。路径平滑算法有缺陷产生了非常接近的点。解决增加到达判定阈值如上面代码中的5.0像素。在平滑路径后增加一个“后处理”步骤移除距离过近的连续路径点例如距离小于cell_size * 0.5的点。确保单位的碰撞形状与cell_size匹配避免因为碰撞体过大导致“蹭”到障碍物边缘从而触发不必要的重新寻路。5.2 单位扎堆与死锁现象多个单位目标点很近时它们会挤在一起互相阻挡最终谁都动不了。原因所有单位都试图走完全相同的路径到达几乎相同的位置。解决目标点扩散如前所述为群体移动计算一个目标区域为每个单位分配区域内一个唯一的、分散的目标子点。简单的局部排斥力在单位的移动向量中加入一个远离附近友军的微小力。这可以在_physics_process中计算不需要修改全局寻路。# 在Unit的_physics_process中计算移动方向后 var avoidance_force Vector2.ZERO for other_unit in get_nearby_units(): # 需要自己实现一个获取附近单位的方法 var dir_to_other global_position - other_unit.global_position var distance dir_to_other.length() if distance AVOIDANCE_RADIUS and distance 0: avoidance_force dir_to_other.normalized() * (AVOIDANCE_RADIUS / distance) if avoidance_force.length_squared() 0: velocity (velocity.normalized() avoidance_force.normalized() * 0.3).normalized() * speed路径预留更复杂的方案是为每个单位“预定”它将要经过的格子在未来几秒内其他单位在寻路时会避开这些被预定的格子。这需要维护一个时间-空间预约表实现成本较高。5.3 性能热点排查当你发现游戏变卡时如何确定是不是寻路的问题使用Godot Profiler在调试器Debugger的Profiler标签页中查看_process和_physics_process中哪个函数耗时最长。如果calculate_path或get_point_path名列前茅那就是寻路问题。限制每帧寻路请求数这是必须做的如前文“异步寻路”所述。可视化调试在调试时将计算出的路径raw_path和smoothed_path用Line2D或draw_polyline画出来。同时也可以将不可通行的网格用颜色标出。这能帮你直观地判断寻路逻辑是否正确障碍物设置是否合理。# 在RTSAStarManager的_process中 func _draw(): if Engine.is_editor_hint(): return # 绘制障碍物格子 for grid_pos in obstacle_cells: var rect Rect2(grid_pos * cell_size, cell_size) draw_rect(rect, Color(1, 0, 0, 0.3)) # 绘制最后一次计算的路径用于调试 if last_debug_path: draw_polyline(last_debug_path, Color(0, 1, 0), 2.0)实现一个稳定高效的RTS寻路系统是一个持续迭代的过程。从基础的AStar2D网格寻路开始逐步加入路径平滑、动态障碍处理、群体移动优化和性能管理你的游戏单位才会真正告别“乱撞”变得智能而可靠。这套代码框架提供了一个坚实的起点你可以根据自己游戏的具体需求在上面添加更复杂的功能比如地形代价、空中/地面单位的不同图层寻路等。记住关键不是追求最完美的算法而是在性能、效果和开发成本之间找到最适合你项目的平衡点。