ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

C#实现A*寻路算法:从原理到Unity游戏集成实战

C#实现A*寻路算法:从原理到Unity游戏集成实战 1. 项目概述与核心价值最近在重构一个2D小游戏的NPC移动逻辑发现之前用的简单随机游走或者直线追踪在遇到稍微复杂点的地图比如有树林、河流、墙壁这些障碍物时表现就特别“蠢”。角色要么卡在墙角不停抽搐要么对着一条河直冲过去然后掉头毫无智能可言。这让我下定决心要把经典的A星A*寻路算法给集成进去。A星算法在游戏开发里可以说是智能寻路的基石从早期的《星际争霸》、《魔兽争霸3》到现在的各种开放世界RPG和MOBA游戏背后都有它的身影。它解决的问题非常明确在一个由格子或者更复杂的导航网格构成的地图上为游戏角色找到一条从起点到终点的、避开所有障碍物的、同时又是最短或接近最短的路径。你可能听说过Dijkstra算法或者广度优先搜索BFS它们也能找最短路径但效率在游戏这种实时性要求极高的场景下往往不够看。A星算法的聪明之处在于它引入了一个“启发式”的预估成本让搜索过程变得有方向性像是一个有经验的向导而不是无头苍蝇一样乱撞。这次我就用C#从最基础的概念开始手把手实现一个干净、高效、可复用的A星寻路模块。无论你是想给自己的独立游戏增加点智能NPC还是单纯对算法如何在游戏中落地感到好奇跟着走一遍你都能获得一个可以直接拿来用的解决方案并且彻底理解其背后的每一个决策。2. A星算法核心原理深度拆解要动手实现必须先吃透原理。A星算法之所以强大是因为它巧妙地结合了“已知成本”和“预估成本”。2.1 算法核心三要素G、H、F值我们可以把寻路过程想象成在一个陌生的城市里找一家特定的餐厅。你手里有一张不完整的地图已知部分道路还有一个手机导航APP它知道所有道路但你需要付费解锁完整信息。A星算法就是你大脑里的那个“混合导航系统”。G值实际代价 这代表从起点走到当前格子已经花费的“真实成本”。通常就是走过的步数如果地形有差异比如草地走得慢公路走得快也可以把移动速度折算进去。在我们的基础实现里可以简单认为从一个格子走到其上下左右相邻的格子G值增加1走到斜角相邻的格子G值增加约1.414即√2以模拟更长的对角线距离。G值记录的是确凿无疑的、已经发生的代价。H值启发代价/预估代价 这是从当前格子到终点的“直线距离”估算成本。注意这是“估算”因为中间可能有墙你不能真的穿过去。最常用的估算方法是曼哈顿距离适用于只能上下左右移动的四方向寻路和欧几里得距离适用于可以八方向移动的寻路。H值就像那个导航APP给你的“直线距离剩余”提示它引导你朝着终点的大致方向前进避免搜索跑偏。F值总代价 这是A星做决策的核心依据。F G H。算法在每一步都会优先选择F值最小的格子作为下一个探索目标。这很好理解既要考虑已经走了多远G不能太大也要考虑离目标还有多远H不能太大两者加起来最小的就是当前看来“性价比”最高的下一步。注意 H值启发函数的选择至关重要它必须满足“可采纳性”即永远不能高估到达终点的实际成本。如果H值高估了算法可能找不到最短路径但如果H值严重低估比如恒为0A星就退化成了Dijkstra算法效率降低。曼哈顿距离和欧几里得距离都是可采纳的。2.2 算法运行流程与两大列表算法维护两个关键列表来管理搜索过程开放列表Open List 一个待检查的格子“候选队列”。它通常被实现为一个优先队列Priority Queue以确保每次都能快速取出F值最小的格子。一开始只有起点在开放列表中。关闭列表Closed List 一个已经检查过、无需再考虑的格子集合。通常用HashSet或布尔数组标记查找效率高。已经处理过的格子会被移入关闭列表防止算法在原地打转或陷入循环。算法的主循环步骤如下这个过程直到找到终点或开放列表为空意味着无路可走才会结束从开放列表中取出F值最小的格子我们称它为“当前格”。将“当前格”移入关闭列表。检查“当前格”的所有邻居格通常是上下左右或加上四个斜角共八个方向。对每一个邻居格如果它是障碍物或者在关闭列表中则忽略它。如果它不在开放列表中就把它加入并设置它的“父节点”为当前格同时计算它的G、H、F值。如果它已经在开放列表中检查通过当前格到达它是否是一条更优的路径即新的G值是否比它原有的G值更小。如果是则更新这个邻居格的G值、F值并将其“父节点”改为当前格。如果终点被加入了开放列表则路径找到循环结束。如果开放列表空了还没找到终点则路径不存在。路径回溯很简单从终点开始根据每个格子的“父节点”指针一路向前追溯到起点这个反向序列就是找到的最短路径最后反转一下即可。3. C#实现从数据结构到完整类设计理解了原理我们用C#来把它具体化。一个好的实现应该职责清晰便于集成到游戏引擎如Unity或任何C#项目中。3.1 定义核心数据结构PathNode首先我们需要一个类来代表地图上的每一个格子节点。public class PathNode { // 节点在地图网格中的坐标 public int X { get; } public int Y { get; } // 寻路核心三要素 public float G { get; set; } // 从起点到本节点的实际代价 public float H { get; set; } // 从本节点到终点的预估代价 public float F G H; // 总代价使用属性简化 // 是否为障碍物 public bool IsWalkable { get; set; } true; // 父节点用于最终路径回溯 public PathNode Parent { get; set; } // 构造函数 public PathNode(int x, int y) { X x; Y y; } // 重写Equals和GetHashCode便于在集合中使用 public override bool Equals(object obj) obj is PathNode other X other.X Y other.Y; public override int GetHashCode() HashCode.Combine(X, Y); }这里有几个设计点F属性被设计为只读由G和H计算得出保证了数据一致性。Equals和GetHashCode的重写至关重要因为后面我们会将PathNode放入HashSet关闭列表和作为字典的键正确的哈希和相等性判断能极大提升性能。IsWalkable属性允许我们动态改变地图的通行状态。3.2 实现A星寻路核心类AStarPathfinder这是算法的主类。我们将采用面向对象的方式使其可以配置不同的启发函数和移动方式。using System.Collections.Generic; public class AStarPathfinder { // 地图网格假设我们已经有一个二维的PathNode数组 private PathNode[,] _grid; private int _gridWidth _grid.GetLength(0); private int _gridHeight _grid.GetLength(1); // 定义移动方向四方向或八方向 private readonly (int dx, int dy)[] _directions; public AStarPathfinder(PathNode[,] grid, bool allowDiagonal true) { _grid grid; // 根据参数初始化移动方向 if (allowDiagonal) { // 八方向上、下、左、右、左上、右上、左下、右下 _directions new (int, int)[] { (0, 1), (0, -1), (-1, 0), (1, 0), (-1, 1), (1, 1), (-1, -1), (1, -1) }; } else { // 四方向上、下、左、右 _directions new (int, int)[] { (0, 1), (0, -1), (-1, 0), (1, 0) }; } } // 核心寻路方法 public ListPathNode FindPath(int startX, int startY, int endX, int endY) { // 边界和有效性检查 if (!IsWithinGrid(startX, startY) || !IsWithinGrid(endX, endY)) return null; var startNode _grid[startX, startY]; var endNode _grid[endX, endY]; if (!startNode.IsWalkable || !endNode.IsWalkable) return null; // 起点或终点不可通行 // 初始化开放列表使用优先队列和关闭列表 var openSet new PriorityQueuePathNode, float(); var closedSet new HashSetPathNode(); // 重置起点代价 startNode.G 0; startNode.H CalculateHeuristic(startNode, endNode); openSet.Enqueue(startNode, startNode.F); while (openSet.Count 0) { // 取出当前F值最小的节点 var currentNode openSet.Dequeue(); // 如果到达终点回溯路径 if (currentNode.Equals(endNode)) { return RetracePath(startNode, endNode); } closedSet.Add(currentNode); // 遍历邻居 foreach (var direction in _directions) { int neighborX currentNode.X direction.dx; int neighborY currentNode.Y direction.dy; if (!IsWithinGrid(neighborX, neighborY)) continue; var neighborNode _grid[neighborX, neighborY]; // 检查邻居是否不可通行或已在关闭列表 if (!neighborNode.IsWalkable || closedSet.Contains(neighborNode)) continue; // 计算从当前节点到邻居节点的移动代价 // 基础代价为1对角线代价约为1.414 float moveCost (direction.dx ! 0 direction.dy ! 0) ? 1.414f : 1.0f; float newGCost currentNode.G moveCost; // 如果邻居不在开放列表中或者找到更优路径 if (newGCost neighborNode.G || !openSet.UnorderedItems.Any(item item.Element.Equals(neighborNode))) { // 更新邻居节点的代价和父节点 neighborNode.G newGCost; neighborNode.H CalculateHeuristic(neighborNode, endNode); neighborNode.Parent currentNode; // 如果邻居是新的加入开放列表如果已存在需要更新其在优先队列中的优先级。 // 注意.NET 6的PriorityQueue没有直接的更新优先级方法这里简化处理先删除再插入或使用更复杂的结构。 // 为了简单演示我们这里选择直接重新入队对于小规模网格可以接受但存在重复节点。 // 生产环境应考虑使用支持更新优先级的优先队列实现。 openSet.Enqueue(neighborNode, neighborNode.F); } } } // 开放列表为空未找到路径 return null; } // 回溯构建路径 private ListPathNode RetracePath(PathNode startNode, PathNode endNode) { ListPathNode path new ListPathNode(); PathNode currentNode endNode; while (!currentNode.Equals(startNode)) { path.Add(currentNode); currentNode currentNode.Parent; } path.Reverse(); // 反转得到从起点到终点的顺序 return path; } // 启发函数计算这里使用欧几里得距离适用于八方向 private float CalculateHeuristic(PathNode a, PathNode b) { int dx Math.Abs(a.X - b.X); int dy Math.Abs(a.Y - b.Y); // 欧几里得距离 return (float)Math.Sqrt(dx * dx dy * dy); // 曼哈顿距离适用于四方向 return dx dy; // 切比雪夫距离适用于八方向且对角线代价为1 return Math.Max(dx, dy); } // 辅助方法检查坐标是否在地图内 private bool IsWithinGrid(int x, int y) { return x 0 x _gridWidth y 0 y _gridHeight; } }代码关键点与避坑指南优先队列的选择 .NET 6 引入了PriorityQueueTElement, TPriority我们直接使用它作为开放列表。它的Enqueue和Dequeue操作是O(log n)的效率很高。但请注意它没有内置的“更新优先级”方法。在上面的代码中当发现一条通往已有开放节点更优的路径时我们选择了直接重新入队。这会导致开放列表中存在同一个节点的多个副本但最终Dequeue出来的是优先级最高F值最小的那个不影响正确性只是略微增加内存和计算开销。对于性能要求极高的游戏你需要自己实现一个支持DecreaseKey操作的优先队列例如基于斐波那契堆。关闭列表使用HashSetHashSetPathNode的Contains操作平均是O(1)比用List快得多这是性能关键。启发函数的选择 代码中使用了欧几里得距离它对于八方向移动是“可采纳”且相对准确的。如果你限制为四方向移动应切换为曼哈顿距离这样更高效且不会高估。对角线移动代价 我们给对角线移动设置了1.414的代价这比上下左右的1要大符合几何事实。这能防止寻路结果出现“锯齿状”路径而更倾向于先走直线。路径回溯 从终点通过Parent指针反向追溯到起点再反转列表这是标准操作。3.3 集成到游戏循环一个简单的Unity示例假设你在Unity中使用你需要将世界坐标转换为网格坐标并在每帧或需要时为角色计算路径。// 挂在游戏管理器或某个控制器上 public class GamePathfindingSystem : MonoBehaviour { public int gridWidth 50; public int gridHeight 50; public float cellSize 1.0f; private PathNode[,] _grid; private AStarPathfinder _pathfinder; void Start() { InitializeGrid(); _pathfinder new AStarPathfinder(_grid, true); // 允许对角线移动 } void InitializeGrid() { _grid new PathNode[gridWidth, gridHeight]; for (int x 0; x gridWidth; x) { for (int y 0; y gridHeight; y) { _grid[x, y] new PathNode(x, y); // 这里可以根据你的游戏地图信息如碰撞体来设置IsWalkable // 例如通过Physics2D.OverlapBox检查该位置是否有障碍物 Vector2 worldPos GridToWorld(x, y); Collider2D hit Physics2D.OverlapBox(worldPos, Vector2.one * cellSize * 0.9f, 0, obstacleLayerMask); _grid[x, y].IsWalkable (hit null); } } } // 为某个角色请求路径 public ListVector2 RequestPath(Vector2 startWorldPos, Vector2 targetWorldPos) { var startNode WorldToGrid(startWorldPos); var endNode WorldToGrid(targetWorldPos); var nodePath _pathfinder.FindPath(startNode.x, startNode.y, endNode.x, endNode.y); if (nodePath null) return null; // 将节点路径转换回世界坐标路径 ListVector2 worldPath new ListVector2(); foreach (var node in nodePath) { worldPath.Add(GridToWorld(node.X, node.Y)); } return worldPath; } private Vector2 GridToWorld(int x, int y) new Vector2(x * cellSize, y * cellSize); private (int x, int y) WorldToGrid(Vector2 worldPos) (Mathf.FloorToInt(worldPos.x / cellSize), Mathf.FloorToInt(worldPos.y / cellSize)); }在角色控制器中你可以这样使用public class NPCMovement : MonoBehaviour { public GamePathfindingSystem pathfindingSystem; public float speed 3.0f; private ListVector2 _currentPath; private int _currentPathIndex; public void SetDestination(Vector2 targetPosition) { _currentPath pathfindingSystem.RequestPath(transform.position, targetPosition); _currentPathIndex 0; } void Update() { if (_currentPath ! null _currentPathIndex _currentPath.Count) { Vector2 target _currentPath[_currentPathIndex]; transform.position Vector2.MoveTowards(transform.position, target, speed * Time.deltaTime); if (Vector2.Distance(transform.position, target) 0.05f) { _currentPathIndex; // 如果到达路径终点 if (_currentPathIndex _currentPath.Count) { _currentPath null; // 到达目的地可以触发后续行为 } } } } }4. 性能优化与高级技巧基础的A星跑起来后面对大地图或大量单位同时寻路性能可能成为瓶颈。下面是一些实战中非常有效的优化手段。4.1 使用更高效的数据结构我们之前提到了优先队列更新优先级的问题。一个成熟的方案是使用二叉堆Binary Heap并配合一个字典来跟踪每个节点在堆中的索引从而实现高效的DecreaseKey操作。网上有很多C#的开源实现比如OptimizedPriorityQueue。替换后当需要更新一个已在开放列表中的节点的F值时你可以直接更新它的G值并调用DecreaseKey方法而不是重复入队这能显著减少开放列表的大小和操作次数。4.2 分层寻路与路点图对于超大型地图如开放世界对整个网格进行A星搜索是不现实的。这时需要分层寻路。高层寻路 将地图划分为大的区域房间、街区先用A星在这些大区域之间找一条粗略路径。底层寻路 在角色当前所在区域和下一个目标区域之间使用网格A星进行精细寻路。 另一种方法是使用导航网格NavMesh或预计算的路点图Waypoint Graph。你不再使用均匀网格而是将地图抽象成由凸多边形NavMesh或关键位置点Waypoint及其连接关系构成的图。A星算法可以同样应用在这个图上搜索的节点数大大减少效率飞跃提升。Unity内置的NavMesh系统就是基于这个原理。4.3 方向搜索与跳点搜索这是对标准A星搜索邻居过程的优化。方向搜索 在遍历邻居时如果不是起点可以先判断父节点的方向。如果当前移动方向是直线可以优先考虑继续沿该方向搜索因为直线路径通常更优。这可以减少不必要的拐弯评估。跳点搜索JPS 这是A星在均匀网格上的一个革命性优化。它的核心思想是“跳过”那些没有决策意义的格子。例如在一条空旷的直线上算法不会一步步检查每个格子而是直接“跳”到这条直线的尽头遇到障碍物或地图边缘或者一个“拐点”。JPS在开阔地带能将性能提升一个数量级但在障碍物极其密集如迷宫的地形中优势不明显。实现JPS比标准A星复杂需要识别“强迫邻居”和“跳点”。4.4 路径平滑与移动优化A星基于网格寻出的路径往往是“网格对齐”的会有一格一格的直角拐点看起来不自然。路径平滑 找到路径后可以进行一次后处理。常用的是漏斗算法。你可以把路径节点看作一系列多边形的顶点然后尝试“拉紧”这条路径让角色走更直接的通道消除不必要的锯齿。一个简单的实现是遍历路径从起点开始检查能否“看到”后面的某个点即两点连线不穿过障碍物如果能就跳过中间的点。移动优化 在角色移动时不要僵硬地逐格走向路径点。可以使用转向行为Steering Behaviors如“寻求Seek”结合“避开障碍Obstacle Avoidance”让移动更平滑、更智能。这样即使路径略有偏差角色也能自然地绕开动态的小障碍。5. 常见问题、调试与实战心得在实际集成A星的过程中你肯定会遇到一些“坑”。下面是我踩过的一些以及解决方法。5.1 路径为什么看起来“很蠢”现象 路径绕远路或者贴着障碍物走很不自然的折线。排查检查启发函数H 如果你用了八方向移动但启发函数用的是曼哈顿距离它会严重高估对角线方向的成本导致算法“不敢”走对角线从而走出阶梯状的路径。确保移动方式与启发函数匹配。检查移动代价 对角线移动代价是否设置正确如果也设为1那么算法会认为走斜线和走直线一样“便宜”可能导致路径在某些情况下不够直。使用1.414是更合理的。检查地图数据 用调试绘图把IsWalkable为false的格子画出来看看你的障碍物地图是否和场景中的碰撞体精确对应。有时候碰撞体比视觉模型大一点会导致可行走区域比预期小。路径平滑 如上所述原始网格路径就是这样的。考虑增加路径平滑后处理。5.2 性能突然变差卡顿明显现象 平时很流畅角色走到某个复杂区域或同时多个单位寻路时帧率下降。排查与优化Profiler是朋友 使用Unity Profiler或.NET的性能分析工具锁定是CPU的哪一部分耗时最多。通常是FindPath里的循环。限制寻路频率 不要每帧都为每个AI寻路。可以每N秒如0.5秒寻路一次或者当目标移动超过一定距离后再重新寻路。使用协程分帧计算 如果单次寻路计算量很大可以把FindPath放入协程每帧只计算一部分比如处理开放列表中的100个节点避免单帧卡顿。地图粒度 你的网格是不是太细了将cellSize从0.5调到1.0网格节点数会变为原来的1/4寻路速度能快很多。需要在精度和性能间权衡。考虑分层或预计算 对于静态地图是否可以预计算一些关键点之间的路径5.3 动态障碍物处理我们的基础实现中IsWalkable是在初始化时设置的。如果游戏中有可移动的障碍物比如其他NPC、可推箱子需要动态更新。方案 为AStarPathfinder或PathNode提供一个更新方法。当动态障碍物移动时更新它影响的所有格子的IsWalkable状态。更高效的做法是在寻路时进行实时碰撞检测但这会增加每次评估邻居时的开销。一个折中方案是使用局部避障A星负责规划全局静态路径当角色接近动态障碍物时用简单的物理转向或局部重新规划来避开。5.4 调试可视化在开发阶段将算法过程可视化是理解问题和调试的终极武器。绘制网格 在Unity的OnDrawGizmos中用不同颜色绘制所有格子可行走/不可行走。绘制开放/关闭列表 在寻路过程中将开放列表中的格子用黄色半透明方块绘制关闭列表中的用红色半透明绘制。你可以清晰地看到算法的“探索前沿”。绘制最终路径 用绿色线条或方块连接路径上的所有点。绘制G/H/F值 在屏幕上每个格子旁显示其G、H、F值这对于深入理解算法决策过程非常有帮助。实现A星寻路从理解原理到写出可工作的代码再到优化和集成是一个典型的“学以致用”的过程。它不仅仅是一个算法更是一套解决空间搜索问题的思维方式。当你看到自己控制的角色或NPC在复杂的地图中流畅、智能地穿梭时那种成就感是实实在在的。希望这篇从原理到实战的详细拆解能帮你少走弯路顺利地把这个强大的工具应用到你的C#游戏项目中去。记住第一步是先让基础版本跑起来画出路径然后再逐步考虑优化和扩展。动手试试吧
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进