游戏开发实战:A*算法在Unity中实现NPC智能寻路(附C#代码)
在游戏世界里,一个NPC能否流畅、智能地绕过障碍物,找到通往玩家的最佳路径,往往直接决定了游戏的沉浸感和策略深度。对于Unity开发者而言,实现这一目标,A*寻路算法是一个绕不开的经典选择。它不像Dijkstra那样“广撒网”,也不像简单的BFS那样“一根筋”,而是在效率和准确性之间找到了一个绝佳的平衡点,尤其适合处理网格化或节点化的游戏地图。
这篇文章不是一篇枯燥的算法论文,而是一份面向Unity开发者的实战手册。我们将彻底抛开那些纯理论的推导,直接聚焦于如何将A算法无缝集成到你的Unity项目中,让NPC真正“活”起来。我会结合自己多次在项目中实现和优化A的经验,从Unity的网格系统对接开始,一步步带你构建一个高效、可复用的寻路模块,并深入探讨在复杂动态场景下的性能优化与避障策略。无论你是正在开发一款RPG、策略游戏,还是任何需要智能移动的游戏,这里的内容都将为你提供直接的、可落地的解决方案。
1. 从理论到实践:在Unity中构建A*算法的核心骨架
在动手写代码之前,我们需要先理解A算法在游戏环境下的核心思想。简单来说,A通过评估每个潜在移动节点的“代价”来决定探索方向。这个代价由两部分组成:从起点到该节点的实际代价(G),以及从该节点到终点的预估代价(H)。算法总是优先探索总代价(F = G + H)最小的节点,直到找到终点。
在Unity中,我们首先要解决的是地图的表示问题。与纯算法演示中简单的二维数组不同,游戏世界更复杂。一个最直接且高效的方式是使用网格(Grid) 系统。Unity本身没有内置的A*组件,但我们可以利用Tilemap或自定义的网格生成器来构建寻路用的数据层。
1.1 创建寻路网格与节点类
我们首先定义一个PathNode类,它将代表网格中的每一个格子。
using UnityEngine;
using System.Collections.Generic;
public class PathNode
{
public Vector2Int GridPosition { get; private set; }
public Vector3 WorldPosition { get; set; }
public bool IsWalkable { get; set; } = true;
// A* 核心代价
public int GCost; // 从起点到本节点的实际代价
public int HCost; // 从本节点到终点的预估代价
public int FCost => GCost + HCost; // 总代价
// 用于回溯路径
public PathNode CameFromNode;
public PathNode(Vector2Int gridPos, Vector3 worldPos, bool walkable = true)
{
GridPosition = gridPos;
WorldPosition = worldPos;
IsWalkable = walkable;
}
public void Reset()
{
GCost = int.MaxValue;
HCost = 0;
CameFromNode = null;
}
}
接下来,我们需要一个PathfindingGrid来管理所有这些节点。这个网格负责将游戏世界坐标转换为网格坐标,并提供节点的查询功能。
public class PathfindingGrid : MonoBehaviour
{
public Vector2Int GridSize = new Vector2Int(20, 20);
public float NodeRadius = 0.5f;
public LayerMask UnwalkableMask;
private PathNode[,] grid;
private float nodeDiameter;
private Vector3 gridWorldBottomLeft;
void Start()
{
nodeDiameter = NodeRadius * 2;
CreateGrid();
}
void CreateGrid()
{
grid = new PathNode[GridSize.x, GridSize.y];
Vector3 worldBottomLeft = transform.position - Vector3.right * GridSize.x / 2 * nodeDiameter - Vector3.forward * GridSize.y / 2 * nodeDiameter;
gridWorldBottomLeft = worldBottomLeft;
for (int x = 0; x < GridSize.x; x++)
{
for (int y = 0; y < GridSize.y; y++)
{
Vector3 worldPoint = worldBottomLeft + Vector3.right * (x * nodeDiameter + NodeRadius) + Vector3.forward * (y * nodeDiameter + NodeRadius);
// 检测该点是否可通行
bool walkable = !(Physics.CheckSphere(worldPoint, NodeRadius, UnwalkableMask));
grid[x, y] = new PathNode(new Vector2Int(x, y), worldPoint, walkable);
}
}
}
public PathNode GetNodeFromWorldPoint(Vector3 worldPosition)
{
// 将世界坐标转换为网格比例坐标
float percentX = (worldPosition.x + GridSize.x * nodeDiameter / 2 - gridWorldBottomLeft.x) / (GridSize.x * nodeDiameter);
float percentY = (worldPosition.z + GridSize.y * nodeDiameter / 2 - gridWorldBottomLeft.z) / (GridSize.y * nodeDiameter);
percentX = Mathf.Clamp01(percentX);
percentY = Mathf.Clamp01(percentY);
int x = Mathf.RoundToInt((GridSize.x - 1) * percentX);
int y = Mathf.RoundToInt((GridSize.y - 1) * percentY);
return grid[x, y];
}
public List<PathNode> GetNeighbours(PathNode node)
{
List<PathNode> neighbours = new List<PathNode>();
// 检查上下左右四个方向(四方向寻路)
for (int x = -1; x <= 1; x++)
{
for (int y = -1; y <= 1; y++)
{
// 跳过自身
if (x == 0 && y == 0)
continue;
// 如果希望实现八方向寻路(允许斜向移动),则移除下面这行注释掉的判断。
// 但需要注意斜向移动的代价计算和穿墙判断。
// if (Mathf.Abs(x) == Mathf.Abs(y)) continue; // 这行用于禁止斜向移动
int checkX = node.GridPosition.x + x;
int checkY = node.GridPosition.y + y;
if (checkX >= 0 && checkX < GridSize.x && checkY >= 0 && checkY < GridSize.y)
{
neighbours.Add(grid[checkX, checkY]);
}
}
}
return neighbours;
}
// 可视化网格,便于调试
void OnDrawGizmos()
{
if (grid != null)
{
foreach (PathNode n in grid)
{
Gizmos.color = (n.IsWalkable) ? Color.white : Color.red;
Gizmos.DrawCube(n.WorldPosition, Vector3.one * (nodeDiameter - .1f));
}
}
}
}
提示:
OnDrawGizmos方法对于调试寻路网格至关重要。在Scene视图中,你可以清晰地看到哪些节点被标记为不可行走(红色方块),这能帮你快速定位碰撞体设置或地图生成的问题。
1.2 实现A*算法的核心循环
有了网格和节点,我们现在可以实现A*算法的主逻辑了。我们将创建一个静态的Pathfinder类,它包含一个FindPath方法。
using System.Collections.Generic;
using System.Linq;
using UnityEngine;
public static class Pathfinder
{
public static List<Vector3> FindPath(Vector3 startPos, Vector3 targetPos, PathfindingGrid grid)
{
PathNode startNode = grid.GetNodeFromWorldPoint(startPos);
PathNode targetNode = grid.GetNodeFromWorldPoint(targetPos);
if (startNode == null || targetNode == null || !startNode.IsWalkable || !targetNode.IsWalkable)
{
Debug.LogWarning("起点或终点不可达!");
return null;
}
// 初始化Open和Close集合
List<PathNode> openSet = new List<PathNode>();
HashSet<PathNode> closedSet = new HashSet<PathNode>();
// 重置所有节点的代价
for (int x = 0; x < grid.GridSize.x; x++)
{
for (int y = 0; y < grid.GridSize.y; y++)
{
// 这里假设grid有一个公开的GetGrid方法,或者我们修改设计让Pathfinder能访问所有节点。
// 更优的做法是在PathfindingGrid中提供一个ResetAllNodes方法。
}
}
startNode.Reset();
startNode.GCost = 0;
startNode.HCost = GetDistance(startNode, targetNode);
openSet.Add(startNode);
while (openSet.Count > 0)
{
// 找到OpenSet中FCost最小的节点,如果FCost相同,选HCost小的
PathNode currentNode = openSet.OrderBy(node => node.FCost).ThenBy(node => node.HCost).First();
// 找到目标,回溯路径
if (currentNode == targetNode)
{
return RetracePath(startNode, targetNode);
}
openSet.Remove(currentNode);
closedSet.Add(currentNode);
foreach (PathNode neighbour in grid.GetNeighbours(currentNode))
{
if (!neighbour.IsWalkable || closedSet.Contains(neighbour))
{
continue;
}
// 计算从当前节点移动到邻居的代价。假设直线移动代价为10,斜向为14。
int movementCostToNeighbour = currentNode.GCost + GetDistance(currentNode, neighbour);
if (movementCostToNeighbour < neighbour.GCost || !openSet.Contains(neighbour))
{
neighbour.GCost = movementCostToNeighbour;
neighbour.HCost = GetDistance(neighbour, targetNode);
neighbour.CameFromNode = currentNode;
if (!openSet.Contains(neighbour))
{
openSet.Add(neighbour);
}
}
}
}
// OpenSet为空,未找到路径
Debug.LogWarning("未找到可行路径!");
return null;
}
private static List<Vector3> RetracePath(PathNode startNode, PathNode endNode)
{
List<PathNode> path = new List<PathNode>();
PathNode currentNode = endNode;
while (currentNode != startNode)
{
path.Add(currentNode);
currentNode = currentNode.CameFromNode;
}
path.Reverse(); // 反转,让路径从起点到终点
// 将节点列表转换为世界坐标列表
List<Vector3> waypoints = new List<Vector3>();
foreach (PathNode node in path)
{
waypoints.Add(node.WorldPosition);
}
return waypoints;
}
private static int GetDistance(PathNode nodeA, PathNode nodeB)
{
// 使用曼哈顿距离(适用于四方向移动)
int dstX = Mathf.Abs(nodeA.GridPosition.x - nodeB.GridPosition.x);
int dstY = Mathf.Abs(nodeA.GridPosition.y - nodeB.GridPosition.y);
// 如果允许斜向移动,代价计算会更复杂,例如:
// int diagonal = Mathf.Min(dstX, dstY);
// int straight = Mathf.Abs(dstX - dstY);
// return diagonal * 14 + straight * 10;
return (dstX + dstY) * 10;
}
}
这个基础版本已经可以在Unity中运行,并为NPC提供一条避开障碍物的路径。你可以创建一个空的GameObject挂载PathfindingGrid脚本,然后在NPC的移动脚本中调用Pathfinder.FindPath来获取路径点列表。
2. 性能优化:让A*在游戏中跑得更快
基础的A*实现在小地图上表现良好,但当地图变大、寻路请求频繁时,性能瓶颈就会显现。OpenSet的排序(OrderBy)和查找操作是主要开销。在游戏开发中,我们通常使用优先队列(Priority Queue) 来优化OpenSet的管理。
2.1 实现一个简单的二叉堆优先队列
C#的标准库没有内置的优先队列(直到.NET 6),我们可以自己实现一个基于二叉堆的版本,它将Enqueue(入队)和Dequeue(出队最小元素)的时间复杂度都控制在O(log n)。
public class PriorityQueue<T> where T : IComparable<T>
{
private List<T> data;
public int Count { get { return data.Count; } }
public PriorityQueue()
{
this.data = new List<T>();
}
public void Enqueue(T item)
{
data.Add(item);
int childIndex = data.Count - 1;
while (childIndex > 0)
{
int parentIndex = (childIndex - 1) / 2;
if (data[childIndex].CompareTo(data[parentIndex]) >= 0)
break;
T tmp = data[childIndex];
data[childIndex] = data[parentIndex];
data[parentIndex] = tmp;
childIndex = parentIndex;
}
}
public T Dequeue()
{
if (data.Count == 0) throw new InvalidOperationException("Queue is empty.");
int lastIndex = data.Count - 1;
T frontItem = data[0];
data[0] = data[lastIndex];
data.RemoveAt(lastIndex);
--lastIndex;
int parentIndex = 0;
while (true)
{
int childIndex = parentIndex * 2 + 1;
if (childIndex > lastIndex) break;
int rightChild = childIndex + 1;
if (rightChild <= lastIndex && data[rightChild].CompareTo(data[childIndex]) < 0)
childIndex = rightChild;
if (data[parentIndex].CompareTo(data[childIndex]) <= 0) break;
T tmp = data[parentIndex];
data[parentIndex] = data[childIndex];
data[childIndex] = tmp;
parentIndex = childIndex;
}
return frontItem;
}
public bool Contains(T item)
{
return data.Contains(item);
}
}
为了让PathNode能用于这个优先队列,我们需要让它实现IComparable<PathNode>接口,比较规则基于FCost和HCost。
public class PathNode : IComparable<PathNode>
{
// ... 其他成员保持不变 ...
public int CompareTo(PathNode other)
{
int compare = FCost.CompareTo(other.FCost);
if (compare == 0)
{
compare = HCost.CompareTo(other.HCost);
}
return compare;
}
}
然后,在Pathfinder.FindPath方法中,将List<PathNode> openSet替换为PriorityQueue<PathNode> openSet,并将openSet.OrderBy(...).First()和openSet.Remove(currentNode)替换为currentNode = openSet.Dequeue()。同时,我们需要一个额外的HashSet<PathNode>来快速判断节点是否在OpenSet中,因为二叉堆的Contains操作是O(n)的。
2.2 启发函数(Heuristic)的选择与优化
启发函数H的估算准确性直接影响A*的搜索效率。常用的有:
| 启发函数 | 计算公式(网格坐标) | 特点 | 适用场景 |
|---|---|---|---|
| 曼哈顿距离 | `H = | dx | + |
| 对角线距离 | H = D * (dx + dy) + (D2 - 2*D) * min(dx, dy) (常设D=10, D2=14) | 计算稍复杂,允许八方向移动时更准确。 | 网格地图,允许斜向移动。 |
| 欧几里得距离 | H = sqrt(dx² + dy²) | 最符合几何直觉,但计算涉及开方,较慢。 | 连续或非网格地图(需配合其他空间划分法如导航网格)。 |
| 切比雪夫距离 | `H = max( | dx | , |
在大多数2D/3D网格游戏里,对角线距离(又称切角距离) 是平衡精度和速度的最佳选择。我们可以优化GetDistance函数:
private static int GetDistance(PathNode nodeA, PathNode nodeB)
{
int dstX = Mathf.Abs(nodeA.GridPosition.x - nodeB.GridPosition.x);
int dstY = Mathf.Abs(nodeA.GridPosition.y - nodeB.GridPosition.y);
if (dstX > dstY)
return 14 * dstY + 10 * (dstX - dstY);
return 14 * dstX + 10 * (dstY - dstX);
}
注意:这里的常数10和14代表了直线移动和斜线移动的基础代价比例。10和14是近似值,实际是10和10√2≈14.14。使用整数可以避免浮点数运算,提升性能。
2.3 路径平滑与路径点简化
A*算法返回的路径通常是网格中心的点,这会导致NPC移动时产生“锯齿状”的僵硬轨迹。我们可以通过路径平滑来优化。
一种简单有效的方法是使用射线投射(Raycasting) 进行路径点简化:从起点开始,向路径中后续的点发射射线,如果射线没有碰到障碍物,说明可以直接走到那个点,就可以跳过中间的所有点。
public static List<Vector3> SimplifyPath(List<Vector3> path, LayerMask obstacleMask)
{
if (path == null || path.Count < 3) return path;
List<Vector3> simplifiedPath = new List<Vector3>();
Vector3 lastAddedPoint = path[0];
simplifiedPath.Add(lastAddedPoint);
for (int i = 2; i < path.Count; i++)
{
Vector3 start = lastAddedPoint;
Vector3 end = path[i];
// 检查从lastAddedPoint到path[i]是否有直接视线
if (Physics.Linecast(start, end, obstacleMask))
{
// 有障碍物,将前一个点(path[i-1])加入路径
simplifiedPath.Add(path[i-1]);
lastAddedPoint = path[i-1];
}
}
// 加入终点
simplifiedPath.Add(path[path.Count - 1]);
return simplifiedPath;
}
将这个函数应用在RetracePath返回的路径之后,可以显著减少路径点的数量,使移动更加流畅自然。在实际项目中,我经常将平滑后的路径点数量减少60%以上。
3. 应对复杂场景:动态障碍与分层寻路
游戏世界是动态的,障碍物会移动、出现或消失。此外,大型开放世界地图如果用一个精细的全局网格,内存和计算开销都是不可接受的。这就需要更高级的策略。
3.1 处理动态障碍物
对于动态障碍物,最简单的策略是定期重新寻路。当NPC正在沿着路径移动时,可以每隔几秒或在感知到周围环境有显著变化时(例如,通过触发器或事件系统),重新计算从当前位置到目标点的路径。
public class NPCMovement : MonoBehaviour
{
public float repathInterval = 1.0f;
private float repathTimer;
private List<Vector3> currentPath;
private int currentWaypointIndex;
void Update()
{
repathTimer += Time.deltaTime;
if (repathTimer >= repathInterval)
{
repathTimer = 0;
// 触发重新寻路,例如通过事件或直接调用
RequestNewPath();
}
// ... 沿着currentPath移动的逻辑 ...
}
void RequestNewPath()
{
// 调用Pathfinder.FindPath获取新路径
// 注意:频繁的寻路调用是性能杀手,需要做好节流和缓存。
}
}
然而,频繁的全局寻路代价高昂。更优的方案是局部避障(Local Avoidance) 与全局路径(Global Path) 结合。A*负责计算宏观的、绕过静态障碍的全局路径。当NPC沿着全局路径移动时,使用自主智能体(Autonomous Agent) 的力导向算法(如RVO、避障向量场)来处理与其他动态实体(其他NPC、玩家)的即时避让。Unity的NavMesh系统就采用了这种思路,但其底层是黑盒。自己实现的话,可以在每个移动帧,为NPC计算一个“转向力”,使其既趋向于下一个路径点,又远离附近的动态障碍。
3.2 分层寻路(HPA*)与导航网格(NavMesh)
对于超大地图,分层寻路是关键技术。其核心思想是将地图划分为多个簇(Cluster) 或区块(Chunk)。先在粗粒度的高层图上进行寻路(比如,区块到区块),然后再在每个区块内部进行精细的寻路。这大大减少了需要搜索的节点数量。
HPA(Hierarchical Pathfinding A)** 是这一思想的经典算法。它在预处理阶段为每个区块计算入口点(连接点)之间的内部最短路径,并存储其代价。运行时,寻路首先在高层区块图上进行,然后再细化到具体网格。
另一种在3D游戏中更主流、更强大的方案是使用导航网格(Navigation Mesh)。NavMesh将可行走区域划分为凸多边形(通常是三角形),而不是规则的网格。它的优势非常明显:
- 更贴合场景几何:可以精确地描述复杂地形、斜坡、楼梯。
- 路径更自然:路径点在多边形边上,移动轨迹更平滑。
- 内存效率高:对于复杂开放地形,用多边形表示比高精度网格节省大量空间。
Unity内置了强大的NavMesh系统,包括自动烘焙、动态障碍物、局部避障(通过NavMeshAgent组件)等全套功能。对于大多数3D游戏项目,直接使用Unity的NavMesh是最高效、最可靠的选择。它的底层算法虽然不一定是标准的A*,但原理相通,且经过了高度优化。
那么,为什么我们还要学习基于网格的A*呢?原因有几个:
- 完全可控:你可以定制算法的每一个细节,适应特殊的游戏规则(如不同地形消耗不同行动力)。
- 2D游戏友好:Unity的NavMesh原生对2D支持较弱,基于网格的A*在2D项目中实现更简单。
- 教育意义:理解A*是理解更复杂寻路算法和AI决策的基础。
- 特定需求:例如在策略游戏中,你需要精确到每个格子的移动和战斗计算。
4. 实战整合:构建一个完整的NPC寻路系统
现在,让我们把前面所有的模块整合起来,创建一个可以直接用于生产的NPC寻路移动脚本。这个脚本将包含路径请求、路径跟随、动态重新规划以及简单的动画控制。
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
[RequireComponent(typeof(CharacterController))] // 假设使用CharacterController移动
public class AdvancedNPCAI : MonoBehaviour
{
public PathfindingGrid pathfindingGrid;
public float moveSpeed = 5f;
public float rotationSpeed = 10f;
public float stoppingDistance = 0.5f;
public float repathThreshold = 2.0f; // 目标移动超过此距离则重新寻路
public bool smoothPath = true;
public LayerMask obstacleMaskForSmoothing;
private CharacterController controller;
private Vector3 currentTargetPosition;
private List<Vector3> currentPath;
private int currentPathIndex;
private Vector3 lastTargetPosition;
void Start()
{
controller = GetComponent<CharacterController>();
if (pathfindingGrid == null)
{
pathfindingGrid = FindObjectOfType<PathfindingGrid>();
}
}
public void SetDestination(Vector3 targetWorldPos)
{
currentTargetPosition = targetWorldPos;
RequestPathToTarget();
lastTargetPosition = targetWorldPos;
}
void Update()
{
if (currentPath != null && currentPath.Count > 0)
{
// 检查目标是否移动显著
if (Vector3.Distance(currentTargetPosition, lastTargetPosition) > repathThreshold)
{
SetDestination(currentTargetPosition); // 重新寻路
return;
}
// 移动到当前路径点
Vector3 waypoint = currentPath[currentPathIndex];
Vector3 direction = (waypoint - transform.position).normalized;
direction.y = 0; // 保持水平移动
if (direction != Vector3.zero)
{
// 平滑转向
Quaternion toRotation = Quaternion.LookRotation(direction, Vector3.up);
transform.rotation = Quaternion.Slerp(transform.rotation, toRotation, rotationSpeed * Time.deltaTime);
}
// 移动
Vector3 moveVector = direction * moveSpeed * Time.deltaTime;
controller.Move(moveVector);
// 检查是否到达当前路径点
if (Vector3.Distance(transform.position, waypoint) < stoppingDistance)
{
currentPathIndex++;
if (currentPathIndex >= currentPath.Count)
{
// 到达终点
currentPath = null;
Debug.Log("到达目的地");
// 这里可以触发一个到达事件
}
}
}
}
void RequestPathToTarget()
{
if (pathfindingGrid == null) return;
// 在实际项目中,应该将寻路请求放入一个队列,在固定更新(如FixedUpdate)或协程中处理,避免同一帧过多计算。
StartCoroutine(CalculatePathCoroutine());
}
IEnumerator CalculatePathCoroutine()
{
// 使用协程避免阻塞主线程,对于复杂地图尤其重要
List<Vector3> rawPath = Pathfinder.FindPath(transform.position, currentTargetPosition, pathfindingGrid);
if (rawPath != null && rawPath.Count > 0)
{
if (smoothPath)
{
currentPath = Pathfinder.SimplifyPath(rawPath, obstacleMaskForSmoothing);
}
else
{
currentPath = rawPath;
}
currentPathIndex = 0;
// 可视化路径(调试用)
DebugDrawPath(currentPath);
}
else
{
Debug.LogWarning("无法计算路径到目标位置。");
currentPath = null;
}
yield return null;
}
void DebugDrawPath(List<Vector3> path)
{
for (int i = 0; i < path.Count - 1; i++)
{
Debug.DrawLine(path[i], path[i + 1], Color.green, 2.0f);
}
}
// 在Scene视图中绘制Gizmos,显示当前路径和目标
void OnDrawGizmosSelected()
{
if (currentPath != null)
{
Gizmos.color = Color.yellow;
for (int i = 0; i < currentPath.Count - 1; i++)
{
Gizmos.DrawLine(currentPath[i], currentPath[i + 1]);
Gizmos.DrawSphere(currentPath[i], 0.2f);
}
if (currentPath.Count > 0)
{
Gizmos.DrawSphere(currentPath[currentPath.Count - 1], 0.2f);
}
}
Gizmos.color = Color.red;
Gizmos.DrawWireSphere(currentTargetPosition, 0.5f);
}
}
这个脚本提供了一个完整的、可工作的基础。你可以将它挂载到任何NPC上,并通过调用SetDestination方法命令其移动到指定位置。它包含了路径平滑、动态重新规划(当目标移动时)和可视化调试功能。
在真正的项目开发中,你还需要考虑更多细节,比如:
- 寻路请求管理:为整个游戏建立一个
PathRequestManager单例,将所有寻路请求放入队列,在每一帧或固定时间间隔内处理固定数量的请求,避免性能尖峰。 - 多线程寻路:将耗时的A*计算放到另一个线程中,避免卡顿主线程。Unity的Job System和Burst Compiler非常适合用来加速这类计算密集型任务。
- 移动预测与插值:对于网络游戏或需要极其平滑移动的场景,需要对路径点进行曲线插值(如Catmull-Rom样条),并预测未来位置以实现更自然的移动。
实现一个健壮的寻路系统是游戏AI开发中最有成就感的事情之一。从最基础的网格A开始,逐步引入优化策略,最终根据项目需求选择或混合使用网格、HPA或NavMesh方案,这个过程本身就能极大地提升你对游戏AI和性能优化的理解。我最初在实现一个RTS游戏的寻路时,被单位卡在一起的问题折磨了很久,后来引入了简单的向量场避障才解决。记住,没有“最好”的算法,只有“最适合”当前游戏需求的解决方案。
&spm=1001.2101.3001.5002&articleId=152883906&d=1&t=3&u=c5fbc63842dc42f1bf153998cc2f3e3a)
2790

被折叠的 条评论
为什么被折叠?



