日志
这篇我们要来实现网格游戏中最普遍也是最重要的算法基础——寻路算法,如果你只想使用寻路算法,可以直接使用
Unity提供的Navmeshagent和AI命名空间下的工具,不过因为这个项目尽可能不适用插件,并且我希望寻路算法更贴近
我的项目,尽可能更高效和可定制化,所以来自己实现一个A*寻路算法
在这里我不会写A星寻路算法的原理,网上有很多A星寻路的实现,我只会写我在把他运用到我的实际项目中遇到了哪些问题,做了哪些改进和定制化,在最后会贴上寻路代码
从c++到c#
在这个项目之前我已经学习过A寻路算法,它是Dijktstra算法的网格改进,但我只使用c++实现过它,当我把他转移到c#时就首先遇到了一些问题。
c++的stl提供了很多算法数据结构去优化效率,A寻路的思想是总选择当前最好的选择,也就是基于寻路消耗估计的贪婪算法,所以我们需要从openList(所有可选择的点)中选择最小的,并且我们需要频繁的向其中插入和选择最小。在c++中我们可以使用priority_queue也就是大小堆实现,但目前的c#标准中并没有提供堆容器,可以引入一些外部库来解决,但我自己写了一个c#的priority_queue,因为大小堆算法也并不困难,并且我们只需要一些基础的功能。
namespace Tools
{
public class PriorityQueue<T> where T : IComparable<T>
{
private List<T> queue;
public T Top
{
get => queue[0];
}
public int Count
{
get => queue.Count;
}
public PriorityQueue()
{
queue = new List<T>();
}
public void Enqueue(T item)
{
queue.Add(item);
AdjustUp(queue.Count - 1);
}
public T Dequeue()
{
if(queue.Count == 0) return default(T);
T temp = queue[0];
queue[0] = queue[Count - 1];
queue.RemoveAt(Count - 1);
AdjustDown(0);
return temp;
}
public bool Contains(T item)
{
return queue.Contains(item);
}
public bool Exists(Predicate<T> match)
{
return queue.Exists(match);
}
private void AdjustUp(int child)
{
int parent = (child - 1) / 2;
while(parent >= 0)
{
if(queue[parent].CompareTo(queue[child]) != 0)
{
T temp = queue[parent];
queue[parent] = queue[child];
queue[child] = temp;
child = parent;
parent = (child - 1) / 2;
}
else break;
}
}
private void AdjustDown(int parent)
{
int child = parent * 2 + 1;
while(child < queue.Count)
{
if(child + 1 < queue.Count && queue[child].CompareTo(queue[child + 1]) != 0) ++child;
if(queue[parent].CompareTo(queue[child]) != 0)
{
T temp = queue[parent];
queue[parent] = queue[child];
queue[child] = temp;
parent = child;
child = parent * 2 + 1;
}
else break;
}
}
}
}
网格对应的坐标
接下来是一个GridPos的遗留问题,当我真正处理寻路时我意识到这个问题,之前我获取GridPos只是将其取整,但负数的取整是向0的(按照绝对值取整),导致正负数取整方向不同,我们需要制定一个方格(1*1)和坐标(x, y)对应的规则,我决定以左下角为方格的坐标,使用Mathf.floor
更新后的GridPos
/// <summary>
/// 网格位置(整数)
/// </summary>
public class GridPos
{
public short x;
public float y;
public short z;
public Vector3 Pos
{
get => new Vector3(x, y, z);
}
/// <summary>
/// 将精确坐标转换为网格坐标
/// </summary>
/// <param name="pos"></param>
/// <returns></returns>
public static GridPos GetGridPos(Vector3 pos)
{
//向左下角取整
GridPos gridPos = new GridPos();
gridPos.x = (short)Mathf.Floor(pos.x);
gridPos.y = pos.y;
gridPos.z = (short)Mathf.Floor(pos.z);
return gridPos;
}
public bool Equal(GridPos other)
{
if(other == null) return false;
return x == other.x && z == other.z;
}
public string DebugStr
{
get { return "GridPos:" + x + " " + z; }
}
}
PathPoint
我们需要一个算法中的数据结构来表示每一个点以及他的消耗估值,并且提供一些便捷的比较、debug、转换等方法,他应该是仅供算法使用的,即应当是一个类中类
/// <summary>
/// 路径点
/// </summary>
private class PathPoint : IComparable<PathPoint>
{
public short x;
public short z;
public PathPoint parent;
public uint F;
public uint G;
public uint H;
public PathPoint(short _x, short _z)
{
x = _x;
z = _z;
F = 0;
G = 0;
H = 0;
parent = null;
}
public void Init(PathPoint _parent, int _endX, int _endZ)
{
parent = _parent;
uint cost;
if(x != parent.x && z != parent.z) cost = PathFinder.hypotenuseCost;
else cost = PathFinder.legCost;
G = parent.G + cost;
H = (uint)(Mathf.Abs(_endZ - z) + Mathf.Abs(_endX - x)) * PathFinder.legCost;
F = G + H;
}
public void TryUpdateParent(PathPoint newParent)
{
uint cost;
if(x != newParent.x && z != newParent.z) cost = PathFinder.hypotenuseCost;
else cost = PathFinder.legCost;
if(newParent.G + cost < G)
{
parent = newParent;
G = parent.G + cost;
F = G + H;
}
}
public bool Equal(PathPoint pathPoint)
{
return (pathPoint.x == x && pathPoint.z == z);
}
public int CompareTo(PathPoint other)
{
if(other.F < F) return 1;
else return 0;
}
public Vector3 GetVector()
{
return new Vector3(x, 0, z);
}
public string DebugStr
{
get => x + "," + z;
}
}
寻路时的碰撞体积
当真正投入到游戏时,我发现人物和怪物或者其他需要寻路的家伙都有不一样的碰撞体,并不是这个坐标处没有建筑物就可以通过,它可能需要周围几格都没有建筑物,有些飞行的怪物甚至可以忽视建筑物,所以我设置了一个IsWalkable函数而不是直接访问bool数组,它可以被定制(但目前我还没有定义对应的参数,只是使用了人物的大小),而且我们需要一个世界坐标(有正负)向数组index的转换以及越界检测,也可以在这里统一进行,相当于封装了对grids的访问,非常有利于debug和简化代码。
private static bool Walkable(bool[,] grids, int x, int z)
{
short rows = (short)grids.GetLength(0);
short columns = (short)grids.GetLength(1);
x += columns / 2;
z += rows / 2;
return (x >= 0 && x < rows && z >= 0 && z < columns &&
!grids[x, z] && (x - 1 < 0 || !grids[x - 1, z]) &&
(z - 1 < 0 || !grids[x, z - 1]) && (x - 1 < 0 || z - 1 < 0 || !grids[x - 1, z - 1]));
}
优化处理
仅保留拐点
我们已经可以通过寻路得到完整的PathPoint的列表,但我们应该直接返回这个列表吗?如果一次移动走了20格,我们就会返回20个格子,然后LocomotionController应该如何处理这20个格子?我们肯定希望开启协程来完成移动,更多的格子意味着更多的函数调用消耗。我们应该回想为什么要使用PathFinder,是为了绕开障碍物,如果没有PathFinder,我们就只能直线向目标移动,所以现在我们只需要确定保留那些需要拐弯(改变方向)的点,就可以很好的完成这件事,一次寻路可能只需要3-4此拐弯,而且debug时也更加方便的可以在地图上进行连线。
实现很简单,只需要额外记录一个lastDirection即可。
PathPoint p = closeList[closeList.Count - 1];
List<Vector3> path = new List<Vector3>();
//仅保留拐点
Vector2 lastDirection = new Vector2(0, 0);
while(p.parent != null)
{
PathPoint q = p.parent;
Vector2 direction = new Vector2(q.x - p.x, q.z - p.z);
if(direction != lastDirection) path.Add(p.GetVector());
lastDirection = direction;
p = q;
}
path.Add(p.GetVector());
path.Reverse();
//以精确点替代
path[0] = start;
path[path.Count - 1] = end;
因为pathpoint只是算法中使用的数据结构,我们的对外接口应该是vector3,所以在这里转换为了vector3,并且为了后续的路径平滑更加精确和减少多余点,这里将起点和终点换为了精确坐标
路径平滑
即便是取消了方向相同的点,我们并没有本质解决那些没有必要的拐弯,有一些点本可以直接到达,但因为正方形网格的原因需要额外的拐弯(只能走直线边),所以我们应该尝试将能够直接连接的点相连淘汰掉中间的拐点
//连接可直达的点,去除不必要的拐点,进行路径平滑
List<Vector3> result = new List<Vector3>();
int pre = 0;
int next = path.Count - 1;
for(; next > pre; --next)
{
UnityEngine.Debug.Log(path[pre]);
UnityEngine.Debug.Log(path[next]);
//相邻点无需检测肯定直接可达
if(next == pre + 1 || DirectlyReachable(grids, path[pre], path[next]))
{
UnityEngine.Debug.Log(true);
result.Add(path[pre]);
pre = next;
next = path.Count;
}
else UnityEngine.Debug.Log(false);
}
//添加剩余不可简化的点
while(pre < path.Count)
{
result.Add(path[pre++]);
}
return result;
private static bool DirectlyReachable(bool[,] grids, Vector3 start, Vector3 end)
{
float gradient;
if(end.x == start.x) gradient = float.MaxValue;//避免除0
else gradient = (end.z - start.z) / (end.x - start.x);
int startX = Mathf.FloorToInt(start.x);
int startZ = Mathf.FloorToInt(start.z);
int endX = Mathf.FloorToInt(end.x);
int endZ = Mathf.FloorToInt(end.z);
//斜率小于1时以x带入方程取点,大于1时代入z保证不会漏掉格子的检测
if(Mathf.Abs(gradient) <= 1)
{
int beginLoop = Mathf.Min(startX, endX), endLoop = Mathf.Max(startX, endX);
for(int i = beginLoop + 1; i < endLoop; ++i)
{
int x = i, z = Mathf.FloorToInt(gradient * (i - start.x) + start.z);
if(!Walkable(grids, x, z))
{
UnityEngine.Debug.DrawLine(start, new Vector3(x, 0, z), Color.blue, 180);
return false;
}
UnityEngine.Debug.DrawLine(start, new Vector3(x, 0, z), Color.yellow, 180);
}
}
else
{
int beginLoop = Mathf.Min(startZ, endZ), endLoop = Mathf.Max(startZ, endZ);
for(int i = beginLoop + 1; i < endLoop; ++i)
{
int x = (gradient != float.MaxValue) ? Mathf.FloorToInt((i - start.z) / gradient + start.x) : startX;
int z = i;
if(!Walkable(grids, x, z))
{
UnityEngine.Debug.DrawLine(start, new Vector3(x, 0, z), Color.blue, 180);
return false;
}
UnityEngine.Debug.DrawLine(start, new Vector3(x, 0, z), Color.yellow, 180);
}
}
return true;
}
取整的处理
public static List<Vector3> FindPath(bool[,] grids, Vector3 start, Vector3 end)
//为了使角色不在寻路时走“回头路”,我们需要在取网格时根据方向而不是直接floor
//简单说应该朝着靠近的方向取整
int startX = Mathf.FloorToInt(start.x);
int endX = Mathf.FloorToInt(end.x);
int startZ = Mathf.FloorToInt(start.z);
int endZ = Mathf.FloorToInt(end.z);
if(end.x > start.x) startX++;
else if(end.x < start.x) endX++;
if(end.z > start.z) startZ++;
else if(end.z < start.z) endZ++;
总结
这部分是一个工具,是整个游戏中最重要的基础部分之一,它将会在之后直接支持我们的人物鼠标点击移动,人物拾取物品和攻击敌人,以及怪物的AI逻辑中涉及到移动的部分,它的效果将会在之后看到