3D沙盒游戏开发日志4——网格寻路系统

日志

  这篇我们要来实现网格游戏中最普遍也是最重要的算法基础——寻路算法,如果你只想使用寻路算法,可以直接使用
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逻辑中涉及到移动的部分,它的效果将会在之后看到

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值