1. 项目概述为什么要在Unity里手搓A*寻路在Unity里做游戏想让NPC自己从A点走到B点这听起来是个基础需求。Unity引擎自带的NavMesh导航系统功能强大开箱即用为什么我们还要费劲去“手搓”一个A*寻路算法呢这恰恰是很多新手开发者甚至一些有经验的同行容易忽略的进阶思考点。NavMesh确实方便但它是一个“黑盒”你喂给它一个烘焙好的导航网格它给你一个路径结果。当你的游戏需要一些特殊规则时——比如NPC需要根据动态变化的“地形消耗”来选择路径雪地走得更慢、沼泽消耗体力或者你需要实现一个完全基于网格Grid-Based的战棋类、RTS类游戏的寻路逻辑时NavMesh的灵活性就可能不够用了。A*A-Star算法就是那个让你从“使用者”变成“创造者”的钥匙。它不仅仅是一个寻路算法更是一套解决“在加权图中寻找最短路径”的通用思想。在Unity里用C#实现它意味着你完全掌控了寻路的每一个细节如何定义“节点”Node、如何计算“代价”Cost、如何评估“启发式”Heuristic函数。这份掌控力能让你实现诸如“优先走大路避开小巷”、“躲避动态危险区域”、“寻路时考虑视野和隐蔽性”等高度定制化的AI行为。这次我们就抛开NavMesh从零开始用最纯粹的C#代码在Unity的网格世界里构建一个属于我们自己的A*寻路引擎。我会带你走过从原理理解、数据结构设计、核心算法实现到与Unity GameObject无缝集成、性能优化并最终打包成可复用的组件全过程。文末会提供完整的项目代码你可以直接拿去用在你的下一个创意里。2. 核心原理与数据结构设计A*算法是如何“思考”的在动手写代码之前我们必须先弄清楚A*算法的大脑是如何工作的。你可以把它想象成一个有策略的探险家而不是一个无头苍蝇。2.1 A*算法的核心思想代价、启发与优先级A*算法的目标是在一个由节点比如我们的网格格子构成的图中找到从起点到终点的最低成本路径。它通过维护两个列表来工作开放列表Open List存放所有已发现但尚未评估的节点。这就像探险家的“待考察地点清单”。封闭列表Closed List存放所有已经评估完毕的节点。这是“已排除或已走过”的地点。算法为每个节点计算三个关键值G值实际代价从起点移动到当前节点的实际累积成本。比如平地移动成本是1沼泽地成本可能是3。H值启发代价/预估代价从当前节点到终点的预估成本。这是一个“猜测”常用曼哈顿距离或欧几里得距离来计算。它引导算法向目标前进。F值总代价F G H。这是A*选择下一个评估节点的核心依据。算法总是优先从开放列表中选取F值最小的节点进行扩展。这个“FGH”的公式是A的灵魂。G值保证了路径的最优性实际成本最低H值保证了搜索的方向性和效率直奔目标。如果H值恒为0A就退化成效率低下的迪杰斯特拉算法如果H值远大于G值它可能退化成贪心的最佳优先搜索快但不一定最优。2.2 在Unity中的数据结构映射Node类设计在Unity的2D或3D网格世界中我们需要一个Node类来代表网格中的一个格子并封装上述的所有寻路信息。using UnityEngine; using System.Collections.Generic; public class Node { // 节点在世界中的位置网格坐标 public Vector2Int GridPosition { get; private set; } // 节点在世界中的实际中心点用于物体移动 public Vector3 WorldPosition { get; private set; } // 寻路核心三要素 public int GCost; // 从起点到本节点的实际代价 public int HCost; // 从本节点到终点的预估代价 public int FCost { get { return GCost HCost; } } // 总代价由G和H计算得出 // 节点是否可通过障碍物 public bool IsWalkable { get; set; } true; // 节点的额外移动成本例如沼泽3道路0.5 public int MovementPenalty { get; set; } 0; // 用于回溯路径当前节点是由哪个节点探索过来的 public Node Parent { get; set; } // 构造函数 public Node(Vector2Int gridPos, Vector3 worldPos, bool walkable true) { GridPosition gridPos; WorldPosition worldPos; IsWalkable walkable; } // 比较两个节点的FCost用于排序 public int CompareTo(Node other) { int compare FCost.CompareTo(other.FCost); if (compare 0) { // 如果FCost相同比较HCost更接近目标的优先 compare HCost.CompareTo(other.HCost); } return compare; } }注意这里我选择将GridPosition设为Vector2Int适用于2D平面网格或3D世界中在XZ平面上的网格。如果你的游戏是3D立体网格如体素世界则需要使用Vector3Int。WorldPosition是Vector3方便直接赋值给GameObject的Transform.position。2.3 网格管理器Grid类的职责单个节点没有意义我们需要一个Grid类来管理整个地图的节点网络。它的核心职责是根据地图尺寸和格子大小创建节点网格。将世界坐标转换为网格坐标反之亦然。获取一个节点的所有邻居节点通常是上下左右或加上对角线共8个方向。提供接口来动态更新节点的可通过状态例如动态放置障碍物。public class Grid : MonoBehaviour { public LayerMask unwalkableMask; // 用于检测障碍物的Layer public Vector2 gridWorldSize; // 网格覆盖的世界区域XZ平面 public float nodeRadius; // 每个节点的物理半径 public bool displayGridGizmos; // 是否在Scene视图中绘制网格Gizmo private float nodeDiameter; private int gridSizeX, gridSizeZ; private Node[,] grid; void Awake() { nodeDiameter nodeRadius * 2; // 计算网格在X和Z方向上有多少个节点 gridSizeX Mathf.RoundToInt(gridWorldSize.x / nodeDiameter); gridSizeZ Mathf.RoundToInt(gridWorldSize.y / nodeDiameter); // 注意gridWorldSize.y对应世界Z轴 CreateGrid(); } void CreateGrid() { grid new Node[gridSizeX, gridSizeZ]; // 计算网格左下角的世界坐标 Vector3 worldBottomLeft transform.position - Vector3.right * gridWorldSize.x / 2 - Vector3.forward * gridWorldSize.y / 2; for (int x 0; x gridSizeX; x) { for (int z 0; z gridSizeZ; z) { // 计算当前节点中心的世界坐标 Vector3 worldPoint worldBottomLeft Vector3.right * (x * nodeDiameter nodeRadius) Vector3.forward * (z * nodeDiameter nodeRadius); // 使用物理检测判断该点是否可通过 bool walkable !(Physics.CheckSphere(worldPoint, nodeRadius, unwalkableMask)); grid[x, z] new Node(new Vector2Int(x, z), worldPoint, walkable); } } } // 关键方法根据世界坐标获取对应的节点 public Node NodeFromWorldPoint(Vector3 worldPosition) { // 将世界坐标转换为相对于网格左下角的百分比 float percentX (worldPosition.x gridWorldSize.x / 2) / gridWorldSize.x; float percentZ (worldPosition.z gridWorldSize.y / 2) / gridWorldSize.y; // 钳制在0-1之间防止坐标超出网格范围 percentX Mathf.Clamp01(percentX); percentZ Mathf.Clamp01(percentZ); // 将百分比转换为网格数组索引 int x Mathf.RoundToInt((gridSizeX - 1) * percentX); int z Mathf.RoundToInt((gridSizeZ - 1) * percentZ); return grid[x, z]; } // 获取一个节点的所有邻居4方向或8方向 public ListNode GetNeighbours(Node node, bool allowDiagonal true, bool cutCorner false) { ListNode neighbours new ListNode(); int checkX, checkZ; // 检查上下左右四个方向 for (int x -1; x 1; x) { for (int z -1; z 1; z) { // 跳过自身 if (x 0 z 0) continue; // 如果不允许对角线移动则跳过对角方向 if (!allowDiagonal Mathf.Abs(x) Mathf.Abs(z)) continue; checkX node.GridPosition.x x; checkZ node.GridPosition.y z; // 注意GridPosition.y 对应的是网格Z坐标 // 检查邻居是否在网格范围内 if (checkX 0 checkX gridSizeX checkZ 0 checkZ gridSizeZ) { Node neighbour grid[checkX, checkZ]; // 如果允许对角线且设置了不切角(cutCorner)需要额外判断 if (allowDiagonal !cutCorner Mathf.Abs(x) Mathf.Abs(z)) { // 例如从当前节点到东北方向的对角节点需要确保北边和东边的节点都是可通行的否则不能“切着墙角走” Node sideNode1 grid[node.GridPosition.x x, node.GridPosition.y]; Node sideNode2 grid[node.GridPosition.x, node.GridPosition.y z]; if (!sideNode1.IsWalkable || !sideNode2.IsWalkable) { continue; // 跳过这个对角线邻居 } } neighbours.Add(neighbour); } } } return neighbours; } // 在Scene视图绘制网格便于调试 void OnDrawGizmos() { if (!displayGridGizmos || grid null) return; Gizmos.DrawWireCube(transform.position, new Vector3(gridWorldSize.x, 1, gridWorldSize.y)); if (grid ! null) { foreach (Node n in grid) { Gizmos.color (n.IsWalkable) ? Color.white : Color.red; Gizmos.DrawCube(n.WorldPosition, Vector3.one * (nodeDiameter - 0.1f)); } } } }实操心得GetNeighbours方法中的cutCorner参数非常实用。在大多数RTS或战棋游戏中单位不应该从一个障碍物的角落“挤”过去这看起来不真实。将cutCorner设为false可以避免这种情况但会稍微增加计算量因为需要检查两个侧向节点。你需要根据游戏类型权衡。对于俯视角的2D游戏通常需要设为false对于自由移动的3D角色可能可以设为true。3. A*寻路算法的C#核心实现有了Node和Grid我们现在可以编写A*算法的主逻辑了。我们将创建一个静态的Pathfinding类它不依赖于MonoBehaviour是一个纯粹的工具类。3.1 算法主循环FindPath方法这是A*算法的心脏。它接收起点和终点的世界坐标返回一个由世界坐标Vector3组成的路径列表。using System.Collections.Generic; using System.Linq; using UnityEngine; public static class Pathfinding { public static ListVector3 FindPath(Vector3 startPos, Vector3 targetPos, Grid grid) { Node startNode grid.NodeFromWorldPoint(startPos); Node targetNode grid.NodeFromWorldPoint(targetPos); // 如果起点或终点不可通过直接返回空路径 if (!startNode.IsWalkable || !targetNode.IsWalkable) { Debug.LogWarning(起点或终点不可通行); return new ListVector3(); } // 初始化开放列表和封闭列表 // 使用SortedSet或PriorityQueue以获得更好的性能这里为清晰使用List并手动排序 ListNode openSet new ListNode(); HashSetNode closedSet new HashSetNode(); openSet.Add(startNode); while (openSet.Count 0) { // 1. 从开放列表中取出F值最小的节点 Node currentNode openSet[0]; for (int i 1; i openSet.Count; i) { if (openSet[i].FCost currentNode.FCost || (openSet[i].FCost currentNode.FCost openSet[i].HCost currentNode.HCost)) { currentNode openSet[i]; } } // 2. 将当前节点移至封闭列表 openSet.Remove(currentNode); closedSet.Add(currentNode); // 3. 如果当前节点就是目标节点路径已找到 if (currentNode targetNode) { return RetracePath(startNode, targetNode); } // 4. 遍历当前节点的所有邻居 foreach (Node neighbour in grid.GetNeighbours(currentNode)) { // 跳过不可通行或已在封闭列表中的邻居 if (!neighbour.IsWalkable || closedSet.Contains(neighbour)) { continue; } // 计算从当前节点移动到邻居的新G值 // 基础移动成本为10水平/垂直对角线为14约等于10*√2 int newMovementCostToNeighbour currentNode.GCost GetDistance(currentNode, neighbour) neighbour.MovementPenalty; // 如果新路径更优或者该邻居尚未在开放列表中 if (newMovementCostToNeighbour neighbour.GCost || !openSet.Contains(neighbour)) { // 更新邻居的G、H值并设置父节点 neighbour.GCost newMovementCostToNeighbour; neighbour.HCost GetDistance(neighbour, targetNode); neighbour.Parent currentNode; // 如果邻居不在开放列表则加入 if (!openSet.Contains(neighbour)) { openSet.Add(neighbour); } // 注意如果已在开放列表中由于G值已更新下次循环排序时会自动调整位置 } } } // 开放列表为空仍未找到路径说明目标不可达 Debug.LogWarning(无法找到路径); return new ListVector3(); } // 回溯路径从终点节点通过Parent链一路回溯到起点 private static ListVector3 RetracePath(Node startNode, Node endNode) { ListNode path new ListNode(); Node currentNode endNode; while (currentNode ! startNode) { path.Add(currentNode); currentNode currentNode.Parent; } // 将节点列表反转并从起点开始转换为世界坐标列表 ListVector3 waypoints path.Select(node node.WorldPosition).Reverse().ToList(); // 通常也会把起点加进去 // waypoints.Insert(0, startNode.WorldPosition); return waypoints; } // 计算两个节点之间的移动距离用于G和H值计算 private static int GetDistance(Node nodeA, Node nodeB) { int dstX Mathf.Abs(nodeA.GridPosition.x - nodeB.GridPosition.x); int dstY Mathf.Abs(nodeA.GridPosition.y - nodeB.GridPosition.y); // 同样是Z轴 // 使用对角移动的优化距离公式 // 假设水平/垂直移动成本为10对角线移动成本为14 if (dstX dstY) return 14 * dstY 10 * (dstX - dstY); return 14 * dstX 10 * (dstY - dstX); } }3.2 关键细节与优化点解析开放列表的数据结构选择上面的代码使用ListNode并每次循环查找最小F值节点其时间复杂度是O(n)。对于大型地图这会成为性能瓶颈。生产环境强烈建议使用优先队列Priority Queue例如C#的SortedSet需自定义IComparer或System.Collections.Generic.PriorityQueue.NET 6。这可以将查找最小F值节点的操作降到O(log n)。距离计算与移动成本GetDistance函数使用了常见的“切比雪夫距离”的变种它假设对角线移动成本是水平移动的√2倍近似为14/10。这比简单的曼哈顿距离只允许4方向移动更符合在允许对角线移动的网格中的真实移动成本。MovementPenalty的加入使得算法能处理不同地形。路径回溯与简化RetracePath函数得到的路径点是每个网格的中心点。对于移动单位来说这条路径可能拐点太多“锯齿状”。一个常见的后处理步骤是路径点简化Path Smoothing使用射线检测等方法尝试合并共线的点或直接检测能否从当前点“看到”更远的点从而跳过中间点使路径更平滑。4. 在Unity中集成与驱动PathRequester组件算法核心准备好了现在我们需要一个MonoBehaviour组件来请求路径并让NPC沿着路径移动。这个组件将挂在需要寻路的NPC如敌人、玩家单位上。using System.Collections; using System.Collections.Generic; using UnityEngine; using UnityEngine.AI; // 我们不用NavMeshAgent但可能用到其他工具 [RequireComponent(typeof(Grid))] // 假设Grid脚本挂载在场景中的某个管理器对象上 public class PathRequester : MonoBehaviour { public Transform target; // 寻路目标 public float speed 5f; public float turnSpeed 5f; public float stoppingDistance 0.1f; public bool drawPathGizmo true; private Grid grid; private ListVector3 path; private int targetIndex; private Coroutine followPathCoroutine; void Start() { // 假设场景中只有一个Grid组件挂在一个名为“PathfindingGrid”的空物体上 grid FindObjectOfTypeGrid(); if (grid null) { Debug.LogError(场景中未找到Grid组件); this.enabled false; } } void Update() { // 示例按下空格键寻路到目标 if (Input.GetKeyDown(KeyCode.Space) target ! null) { RequestPath(target.position); } } public void RequestPath(Vector3 targetPosition) { // 如果正在跟随旧路径先停止 if (followPathCoroutine ! null) { StopCoroutine(followPathCoroutine); } // 调用静态的Pathfinding.FindPath方法 path Pathfinding.FindPath(transform.position, targetPosition, grid); if (path ! null path.Count 0) { // 开始新的协程来跟随路径 followPathCoroutine StartCoroutine(FollowPath()); } else { Debug.Log(寻路失败或路径为空。); } } IEnumerator FollowPath() { if (path null || path.Count 0) yield break; targetIndex 0; // 将第一个路径点设为当前目标 Vector3 currentWaypoint path[0]; while (true) { // 计算朝向当前路径点的方向 Vector3 direction (currentWaypoint - transform.position).normalized; // 平滑旋转朝向目标方向 if (direction ! Vector3.zero) { Quaternion lookRotation Quaternion.LookRotation(direction); transform.rotation Quaternion.Slerp(transform.rotation, lookRotation, Time.deltaTime * turnSpeed); } // 向前移动 transform.Translate(direction * speed * Time.deltaTime, Space.World); // 检查是否到达当前路径点 if (Vector3.Distance(transform.position, currentWaypoint) stoppingDistance) { targetIndex; // 如果已到达最后一个路径点结束移动 if (targetIndex path.Count) { path null; yield break; } // 更新到下一个路径点 currentWaypoint path[targetIndex]; } yield return null; // 等待下一帧 } } // 在Scene视图中绘制路径便于调试 void OnDrawGizmos() { if (drawPathGizmo path ! null) { Gizmos.color Color.blue; for (int i targetIndex; i path.Count; i) { Gizmos.DrawSphere(path[i], 0.2f); if (i targetIndex) { Gizmos.DrawLine(transform.position, path[i]); } else { Gizmos.DrawLine(path[i - 1], path[i]); } } } } }注意事项这个FollowPath协程是一个简单的直线移动实现。在实际项目中你可能需要更复杂的移动控制比如使用CharacterController或Rigidbody以获得真实的物理碰撞和移动。路径点到达判断使用2D平面距离忽略Y轴可能更合适。移动动画同步根据速度参数speed来驱动Animator中的速度参数。动态避障A*给出的是静态路径如果路径上突然出现动态障碍物需要结合局部避障算法如RVO、势场法或重新寻路。5. 性能优化与高级技巧让寻路更快更智能一个基础的A*实现在小地图上运行良好但当地图变大、单位变多时性能问题就会凸显。以下是几个关键的优化方向。5.1 数据结构优化使用优先队列如前所述将开放列表从List换成优先队列是提升性能最直接有效的方法。这里以.NET的PriorityQueue为例需要.NET 6或更高版本或安装相应的NuGet包。// 首先我们需要一个包装类来存储节点和其优先级F值 public class NodePriorityWrapper : IComparableNodePriorityWrapper { public Node Node { get; } public int Priority { get; } // F值 public NodePriorityWrapper(Node node, int priority) { Node node; Priority priority; } public int CompareTo(NodePriorityWrapper other) { return Priority.CompareTo(other.Priority); } } // 然后在FindPath方法中替换开放列表 // using System.Collections.Generic; // 确保引入 PriorityQueueNodePriorityWrapper, int openSet new PriorityQueueNodePriorityWrapper, int(); openSet.Enqueue(new NodePriorityWrapper(startNode, startNode.FCost), startNode.FCost); while (openSet.Count 0) { // 出队F值最小的节点 Node currentNode openSet.Dequeue().Node; // ... 后续逻辑与之前类似 ... // 当需要将邻居加入开放列表时 if (!openSet.UnorderedItems.Any(item item.Element.Node neighbour)) // 简化判断实际需优化 { openSet.Enqueue(new NodePriorityWrapper(neighbour, neighbour.FCost), neighbour.FCost); } else { // 如果已在队列中需要更新优先级PriorityQueue不支持直接更新通常做法是重新入队或使用更复杂的结构 // 一种常见策略是允许重复节点入队在出队时检查是否已在closedSet中 } }实操心得PriorityQueue在.NET中默认是最小堆非常适合A*算法。但请注意标准的PriorityQueue不支持直接更新队列中已有元素的优先级。常见的处理模式是允许重复节点入队但在从队列中取出节点时首先检查它是否已经在closedSet中如果是则直接跳过。虽然这会导致队列中有一些“过时”的节点但算法正确性不受影响且实现简单。另一种方案是使用支持优先级更新的自定义堆或第三方库。5.2 启发函数Heuristic的选择与优化启发函数H(n)极大地影响A*的搜索效率和路径最优性的平衡。曼哈顿距离H |dx| |dy|。适用于只允许上下左右四方向移动的网格如经典2D Roguelike。它高估了对角线移动的成本导致A*搜索更多节点但保证找到最短路径。对角线距离切比雪夫距离H max(|dx|, |dy|)。适用于允许八方向移动的网格。它更贴合允许对角线移动的实际成本。欧几里得距离H sqrt(dx^2 dy^2)。最符合“直线距离”的直觉但在网格寻路中计算平方根开销较大且可能轻微低估成本因为网格移动不是严格直线导致搜索节点略多。欧几里得距离的平方H dx^2 dy^2。为了避免开方运算可以直接使用平方值。但请注意这会使H值远大于G值算法会变得非常“贪婪”倾向于直奔目标而忽略障碍物可能无法找到最短路径甚至找不到路径。一般不推荐。在我们的实现中GetDistance函数同时用于G和H的计算它是对角线距离的一种整数近似10和14保证了启发函数的可采纳性即H值永远不会高估实际成本从而确保A*能找到最优解。5.3 路径平滑与后处理A*在网格上找到的路径通常是“网格对齐”的拐角很多。我们可以通过路径点简化来优化public static ListVector3 SimplifyPath(ListNode nodePath) { ListVector3 waypoints new ListVector3(); if (nodePath.Count 2) { waypoints.Add(nodePath[0].WorldPosition); return waypoints; } Vector2 oldDirection Vector2.zero; for (int i 1; i nodePath.Count; i) { // 计算当前线段的方向在XZ平面上 Vector2 newDirection new Vector2( nodePath[i].GridPosition.x - nodePath[i-1].GridPosition.x, nodePath[i].GridPosition.y - nodePath[i-1].GridPosition.y ).normalized; // 如果方向改变则上一个节点是一个拐点需要保留 if (newDirection ! oldDirection) { waypoints.Add(nodePath[i-1].WorldPosition); } oldDirection newDirection; } // 添加终点 waypoints.Add(nodePath[nodePath.Count - 1].WorldPosition); return waypoints; }更高级的平滑方法是射线投射法从起点开始向路径中后面的点发射射线忽略障碍物Layer如果能直接“看到”更远的点就跳过中间的所有点。这能得到更接近直线的最短路径但计算量稍大。5.4 分层寻路与方向搜索对于超大型地图如开放世界一次性寻路计算量巨大。可以采用分层寻路Hierarchical Pathfinding高层将地图划分为大的区块Chunk先在这些区块间寻路。底层在目标区块内进行精细的网格寻路。 这需要预先处理区块之间的连通性。另一种优化是搜索方向。标准的A是从起点向终点双向搜索。双向ABidirectional A*可以同时从起点和终点开始搜索直到两个搜索区域相遇这通常能减少总搜索节点数。6. 常见问题、调试技巧与实战避坑指南即使算法正确集成到Unity项目时还是会遇到各种问题。这里记录一些我踩过的坑和解决方法。6.1 NPC卡住、抖动或走“之”字形原因1路径点过于密集。如果网格节点很小路径点会非常接近NPC可能在一个点附近来回振荡。解决增加stoppingDistance或进行路径点简化移除过于接近的点。原因2移动和旋转在同一帧竞争。FollowPath协程中移动和旋转都使用Transform直接修改可能导致不自然的运动。解决使用Vector3.MoveTowards和Quaternion.RotateTowards进行插值移动或使用Rigidbody.velocity和Rigidbody.MoveRotation进行物理移动。原因3帧率波动导致移动距离不一致。使用transform.Translate(..., Time.deltaTime)基本可以解决但更稳健的做法是在FixedUpdate中进行物理移动。6.2 寻路结果不合理穿墙、绕远路原因1网格节点大小与障碍物碰撞体不匹配。nodeRadius设置得太小可能从两个障碍物的缝隙中“挤”过去设置得太大可能把可通行区域误判为障碍。解决在Scene视图中打开Grid的Gizmo显示仔细调整nodeRadius确保网格能准确覆盖地形。可以使用一个与玩家碰撞体大小相似的球体进行Physics.CheckSphere检测。原因2unwalkableMask设置错误。确保障碍物所在的Layer被正确添加到unwalkableMask中。原因3对角移动切割墙角。这就是前面提到的cutCorner参数。如果你不希望单位贴着障碍物边缘走在GetNeighbours调用时将其设为false。原因4启发函数权重过高。如果你给H值乘以一个大于1的权重w * H可以加快搜索速度称为Weighted A*但会牺牲路径最优性可能产生绕远路的结果。权重越大路径越不最优。6.3 性能问题帧率在寻路时骤降原因每帧都在进行昂贵的寻路计算。A*是CPU密集型操作。解决限制寻路频率为每个PathRequester设置一个寻路冷却时间比如每秒最多请求一次。使用协程分帧计算将FindPath函数改造成一个IEnumerator将循环中的部分计算分摊到多帧完成。这对于单次长距离寻路避免卡顿非常有效。对象池对于需要频繁寻路的单位如大量小兵可以考虑预计算常用路径或使用更简单的AI如朝着目标直线移动遇到障碍再触发寻路。优化网格大小不是格子越小越好。根据游戏角色的大小和移动精度选择合适的nodeRadius。通常节点直径略大于角色的碰撞体半径即可。6.4 动态障碍物处理我们的基础实现假设网格是静态的。处理动态障碍物如可破坏的墙、移动的车辆有两种主流策略局部避障 全局重规划A*负责计算全局静态路径。当NPC在移动中遇到动态障碍物时用一个简单的局部避障算法如向量场、RVO绕开它或者当障碍物挡住路径一段时间后触发一次从当前位置到目标的重规划。实时更新网格当动态障碍物出现或消失时调用Grid的某个方法如UpdateNodeWalkable(Vector3 position, bool walkable)更新对应节点的IsWalkable状态。注意这需要维护一个从世界坐标到Node对象的快速查找字典并且所有正在使用旧路径的单位可能需要重新寻路。6.5 多线程寻路对于需要同时为大量单位寻路的游戏如RTS在主线程进行寻路是不可接受的。可以将Pathfinding类改造成支持多线程。核心是将FindPath方法封装到一个单独的线程或任务中计算完成后通过回调如ActionListVector3或Unity的MainThreadDispatcher将结果传回主线程再交给PathRequester执行。重要警告Unity的API如Physics.CheckSphere,Transform不是线程安全的在多线程寻路中网格的创建和节点可通过性判断必须在主线程完成。通常的做法是在主线程预先创建好Grid并计算好所有节点的静态IsWalkable状态。寻路线程只读取这些数据并进行纯逻辑计算不调用任何Unity API。动态障碍物的更新也需要通过线程安全的方式同步到网格数据中。7. 完整项目集成与扩展思路将以上所有代码模块整合到一个Unity项目中你需要创建一个空GameObject命名为“PathfindingGrid”挂载Grid脚本。配置好gridWorldSize、nodeRadius和unwalkableMask。在场景中放置一些带有Collider的物体如Cube并将其Layer设置为unwalkableMask中包含的层。创建一个NPC如一个Capsule挂载PathRequester脚本。将“PathfindingGrid”对象拖拽到其Grid引用上或通过FindObjectOfType自动查找。创建一个目标物体如Sphere将其Transform赋值给NPC的target变量。运行游戏按下空格键观察NPC是否绕过障碍物走向目标。扩展思路不同地形代价在Node类中扩展MovementPenalty并在Grid创建时根据地面Tag或纹理进行赋值。在GetDistance计算G值时加入此代价。单元碰撞与动态避让A*处理静态环境对于其他移动单位可以使用局部避障算法如RVOReciprocal Velocity Obstacles它能让多个单位自然流畅地相互避让。与行为树/状态机集成将PathRequester.RequestPath作为AI行为树的一个Action节点或状态机中的一个状态。例如“巡逻”状态会在到达一个点后寻路到下一个巡逻点。制作编辑器工具为Grid脚本编写一个自定义Editor脚本在Inspector中提供按钮来可视化烘焙网格甚至手动绘制不可通行区域。手搓A*寻路的过程是一次对游戏AI底层逻辑的深刻旅行。它让你不再满足于使用现成的工具而是开始思考如何创造工具。当你看到自己编写的代码驱动着NPC在复杂的地形中穿梭时那种成就感是无可替代的。这份完整的项目代码希望能成为你探索更广阔AI世界的一块坚实跳板。记住所有复杂的系统都始于一个清晰、可工作的核心而你现在已经拥有了它。