[Godot] JPS跳点寻路和RVO避障

前言

之前敌人寻路用了流场,然后玩家单位我准备用JPS跳点寻路,这里给大家分享我的实现思路以及优化

开发流程大致如下:

  1. JPS跳点寻路:初始化JPS路线
  2. 路线压缩和靠墙优化:让路线更自然
  3. 多线程移动:移动实现
  4. RVO避障与墙修正:优化单位移动和靠墙碰撞

效果展示


JPS跳点寻路

A*和JPS对比

经典的A*寻路在网格大的情况下要搜很多节点,性能不友好。JPS跳点寻路就是来解决这个的,它通过跳点剪枝跳过大量中间格子,速度以及性能会更好。

跳点规则

JPS的核心是"跳点",跳点是起点终点、拥有强迫邻居或延伸方向含跳点的关键节点。

强迫邻居:因障碍阻挡必须经由当前节点到达的邻居
1.直线移动规则 :从父节点直线走到当前节点,若旁边有障碍物挡住了某个邻居,导致该邻居只能通过当前节点到达,则该邻居为强迫邻居。
2.对角线移动规则:从父节点斜着走到当前节点,若在水平或垂直方向上发现了强迫邻居,则该邻居为强迫邻居。

搜索示例:

方向剪枝

JPS只有起点8个方向都搜,其余的会排除掉父节点方向,注意除了添加天然邻居,直线搜索朝向方向的斜着上下两格,斜着搜朝向方向的上下左右其中的两格

注意我在脚本里面使用的Walkable方法是确定点是否可达的,大家根据自己项目实际编写即可

cs 复制代码
private List<Vector2I> GetDirs(Vector2I pos, Vector2I parent)
{
    // 起点8方向全搜
    if (parent == Vector2I.MinValue)
        return new List<Vector2I>
        {
            new Vector2I(0, -1), new Vector2I(0, 1),
            new Vector2I(-1, 0), new Vector2I(1, 0),
            new Vector2I(-1, -1), new Vector2I(1, -1),
            new Vector2I(-1, 1), new Vector2I(1, 1)
        };

    var dirs = new List<Vector2I>();
    int dx = Math.Sign(pos.X - parent.X);
    int dy = Math.Sign(pos.Y - parent.Y);

    // 对角线
    if (dx != 0 && dy != 0)
    {
        // 天然邻居
        if (Walkable(new Vector2I(pos.X + dx, pos.Y + dy))) dirs.Add(new Vector2I(dx, dy));
        if (Walkable(new Vector2I(pos.X + dx, pos.Y))) dirs.Add(new Vector2I(dx, 0));
        if (Walkable(new Vector2I(pos.X, pos.Y + dy))) dirs.Add(new Vector2I(0, dy));

        // 强迫邻居
        if (!Walkable(new Vector2I(pos.X - dx, pos.Y)) &&
            Walkable(new Vector2I(pos.X - dx, pos.Y + dy)))
            dirs.Add(new Vector2I(-dx, dy));
        if (!Walkable(new Vector2I(pos.X, pos.Y - dy)) &&
            Walkable(new Vector2I(pos.X + dx, pos.Y - dy)))
            dirs.Add(new Vector2I(dx, -dy));
    }
    // 直线
    else
    {
        dirs.Add(new Vector2I(dx, dy));

        // 强迫邻居
        if (!Walkable(new Vector2I(pos.X + dy, pos.Y - dx)) &&
            Walkable(new Vector2I(pos.X + dx + dy, pos.Y + dy - dx)))
            dirs.Add(new Vector2I(dx + dy, dy - dx));
        if (!Walkable(new Vector2I(pos.X - dy, pos.Y + dx)) &&
            Walkable(new Vector2I(pos.X + dx - dy, pos.Y + dy + dx)))
            dirs.Add(new Vector2I(dx - dy, dy + dx));
    }

    return dirs;
}

跳点搜索

和方向剪枝有所区别,没有天然邻居,还有我这里加了个不能过两个方块墙角的规则

cs 复制代码
private Vector2I? Jump(Vector2I pos, Vector2I dir, Vector2I end)
{
    var next = pos + dir;
    if (!Walkable(next)) return null;
    if (next == end) return next;

    int dx = dir.X;
    int dy = dir.Y;

    // 对角线跳点
    if (dx != 0 && dy != 0)
    {
        // 撞墙角继续搜
        if (!Walkable(new Vector2I(next.X - dx, next.Y)) &&
            !Walkable(new Vector2I(next.X, next.Y - dy)))
            return null;

        // 发现强迫邻居
        if (!Walkable(new Vector2I(next.X - dx, pos.Y)) &&
            Walkable(new Vector2I(next.X - dx, next.Y + dy)))
            return next;
        if (!Walkable(new Vector2I(pos.X, next.Y - dy)) &&
            Walkable(new Vector2I(next.X + dx, next.Y - dy)))
            return next;

        // 递归搜水平和垂直方向
        if (Jump(next, new Vector2I(dx, 0), end) != null ||
            Jump(next, new Vector2I(0, dy), end) != null)
            return next;
    }
    else
    {
        // 直线跳点
        if (!Walkable(new Vector2I(next.X + dy, next.Y - dx)) &&
            Walkable(new Vector2I(next.X + dx + dy, next.Y + dy - dx)))
            return next;
        if (!Walkable(new Vector2I(next.X - dy, next.Y + dx)) &&
            Walkable(new Vector2I(next.X + dx - dy, next.Y + dy + dx)))
            return next;
    }

    return Jump(next, dir, end);
}

回溯路径

我们不断搜索跳点到终点后,根据父节点回溯路径,大家注意我这里是倒序的,也就是终点到起点

cs 复制代码
private List<Vector2I> ReversePath(List<(Vector2I pos, float g, Vector2I parent)> points, Vector2I end)        //回溯路径(终点>起点)
{
    var path = new List<Vector2I> { end };
    var parent = points.First(p => p.pos == end).parent;

    while (parent != Vector2I.MinValue)
    {
        path.Add(parent);
        parent = points.First(p => p.pos == parent).parent;
    }
    return OptimizePath(ZipPath(path));
}

主循环

我这里没有过滤重复点以及选择最短距离g,对于我的项目来说是够用的

cs 复制代码
public List<Vector2I> GetJPSpath(Vector2I start, Vector2I end)
{
    if (!Walkable(start) || !Walkable(end)) return null;

    var openList = new List<(Vector2I pos, float g, Vector2I parent)>();
    var closeHas = new HashSet<Vector2I>();

    openList.Add((start, 0, Vector2I.MinValue));

    while (openList.Count > 0)
    {
        // 取最小f值
        (Vector2I pos, float g, Vector2I parent) point = default;
        float min = float.MaxValue;
        foreach (var p in openList)
        {
            if (closeHas.Contains(p.pos)) continue;
            float f = p.g + Hdis(p.pos, end);
            if (f < min) { min = f; point = p; }
        }
        if (min == float.MaxValue) break;
        closeHas.Add(point.pos);

        if (point.pos == end)
            return ReversePath(openList, point.pos);

        // 跳点探索
        foreach (var dir in GetDirs(point.pos, point.parent))
        {
            var pos = Jump(point.pos, dir, end);
            if (pos != null)
            {
                float dist = Hdis(pos.Value, point.pos);
                openList.Add((pos.Value, point.g + dist, point.pos));
            }
        }
    }

    return null;
}

对角线距离计算:

cs 复制代码
private float Hdis(Vector2I start, Vector2I end)      //对角线距离计算(启发式函数)
{
    float dx = Math.Abs(start.X - end.X);
    float dy = Math.Abs(start.Y - end.Y);
    return Math.Min(dx, dy) * 0.414f + Math.Max(dx, dy);
}

路径压缩和靠墙优化

路径压缩

我的JPS出来的路径是先走斜线再走直线,导致很不自然,有些能连成直线,下面是我的优化路径算法:

cs 复制代码
private List<Vector2I> ZipPath(List<Vector2I> path)        //压缩路径
{
    if (path == null || path.Count <= 2) return path;

    var newPath = new List<Vector2I> { path[0] };
    Vector2I last = path[0];

    for (int i = 1; i < path.Count; i++)
    {
        if (!WalkableLine(last, path[i]))
        {
            newPath.Add(path[i - 1]);
            last = path[i - 1];
        }
    }

    newPath.Add(path[path.Count - 1]);
    return newPath;
}

private bool WalkableLine(Vector2I start, Vector2I end)     //布雷森汉姆直线检测
{
    int x0 = start.X, y0 = start.Y;
    int x1 = end.X, y1 = end.Y;
    int dx = Math.Abs(x1 - x0), dy = Math.Abs(y1 - y0);
    int sx = x0 < x1 ? 1 : -1;
    int sy = y0 < y1 ? 1 : -1;
    int err = dx - dy;

    while (true)
    {
        Vector2I pos = new Vector2I(x0, y0);
        if (!Walkable(pos)) return false;
        if (x0 == x1 && y0 == y1) break;

        int e2 = 2 * err;
        if (e2 > -dy) { err -= dy; x0 += sx; }
        if (e2 < dx) { err += dx; y0 += sy; }
    }
    return true;
}

靠墙优化

寻路出来的路径经常会贴着墙走,看起来不太自然,这里我利用了流场算法中的成本场原理来计算5*5范围的格子成本,范围内每一个墙格子都增加成本

cs 复制代码
private int CalcCost(Vector2I pos)      //成本计算
{
    int cost = 0;
    for (int x = -2; x <= 2; x++)
        for (int y = -2; y <= 2; y++)
            if (!Walkable(pos + new Vector2I(x, y)))
                cost += 10;
    return cost;
}

private List<Vector2I> OptimizePath(List<Vector2I> path)        //靠墙路径优化
{
    if (path == null || path.Count <= 2) return path;

    //注意需要循环几次才能找到点
    bool moved = true;
    while (moved)
    {
        moved = false;
        for (int i = 1; i < path.Count - 1; i++)
        {
            Vector2I p = path[i];
            Vector2I best = p;
            int minCost = CalcCost(p);

            //跳过不靠墙的点
            if (minCost == 0) continue;

            for (int x = -1; x <= 1; x++)
                for (int y = -1; y <= 1; y++)
                {
                    if (x == 0 && y == 0) continue;

                    Vector2I v = p + new Vector2I(x, y);
                    if (!Walkable(v)) continue;

                    int cost = CalcCost(v);
                    if (cost >= minCost) continue;

                    //检测路径连通
                    if (!WalkableLine(path[i - 1], v)) continue;
                    if (!WalkableLine(v, path[i + 1])) continue;

                    minCost = cost;
                    best = v;
                    moved = true;
                }

            path[i] = best;
        }
    }

    return path;
}

多线程移动

像流场移动一样,这里我们也利用多线程来移动和计算,大家注意写好单位数据与行为分离即可

还有需要注意JPS是基于网格的,我们还需要乘网格大小换算过来,以及起点的设置

cs 复制代码
private void UnitJPSMove(float delta)
{
    if (unitDatas.Count == 0) return;

    var units = unitDatas.ToArray();
    var velcitys = new Vector2[unitDatas.Count];
    var positions = new Vector2[unitDatas.Count];
    var arriveds = new bool[unitDatas.Count];

    Parallel.For(0, unitDatas.Count, i =>
    {
        UnitData unit = unitDatas[i];
        if (unit.unitState != UnitState.Move)
        {
            velcitys[i] = unit.velcity;
            positions[i] = unit.position;
            return;
        }

        var target = unit.path[unit.path.Count - 1];
        var dir = target - unit.position;

        if (dir.Length() < 2f)
        {
            arriveds[i] = true;
            velcitys[i] = unit.velcity;
            positions[i] = unit.position;
        }
        else
        {
            var newVel = dir.Normalized() * unit.moveSpeed;

            // RVO避障,后面代码会讲
            ......

            // 插值平滑,让移动更自然
            velcitys[i] = unit.velcity.Lerp(newVel, 0.1f);

            // 硬靠墙修正
            positions[i] = ApplyWallCorrect(unit, unit.position + velcitys[i] * delta);
        }
    });

    // 主线程更新
    for (int i = 0; i < unitDatas.Count; i++)
    {
        var unit = unitDatas[i];
        if (unit.unitState != UnitState.Move) continue;

        //弹出到达路径点
        if (arriveds[i])
        {
            unit.path.RemoveAt(unit.path.Count - 1);
            if (unit.path.Count == 0) unit.unitState = UnitState.Idle;
        }

        unit.velcity = velcitys[i];
        unit.position = positions[i];
        unitDatas[i] = unit;
        unitNodes[unit.onlyID].unitNode.Position = unit.position;
    }
}

RVO避障

简单来说就是"各让一步",判断距离较近的单位是否会碰撞,检测到了便会同时互相偏转一半来躲避

公式如下:
a = v ⃗ r e l ⋅ v ⃗ r e l = ∣ v ⃗ r e l ∣ 2 b = p ⃗ r e l ⋅ v ⃗ r e l c = p ⃗ r e l ⋅ p ⃗ r e l − r 2 = ∣ p ⃗ r e l ∣ 2 − r 2 Δ = b 2 − a ⋅ c 其中 : v ⃗ r e l = v ⃗ u n i t − v ⃗ o t h e r ------相对速度 p ⃗ r e l = p ⃗ o t h e r − p ⃗ u n i t ------相对位置 r = r u n i t + r o t h e r + 2 ------合并半径 时间计算 : t = b − Δ a a = \vec{v}{rel} \cdot \vec{v}{rel} = |\vec{v}{rel}|^2 \\ b = \vec{p}{rel} \cdot \vec{v}{rel} \\ c = \vec{p}{rel} \cdot \vec{p}{rel} - r^2 = |\vec{p}{rel}|^2 - r^2 \\ \Delta = b^2 - a \cdot c\\ 其中:\\ \vec{v}{rel} = \vec{v}{unit} - \vec{v}{other} ------ 相对速度\\ \vec{p}{rel} = \vec{p}{other} - \vec{p}{unit} ------ 相对位置\\ r = r_{unit} + r_{other} + 2 ------ 合并半径\\ 时间计算:\\ t = \frac{b - \sqrt{\Delta}}{a} a=v rel⋅v rel=∣v rel∣2b=p rel⋅v relc=p rel⋅p rel−r2=∣p rel∣2−r2Δ=b2−a⋅c其中:v rel=v unit−v other------相对速度p rel=p other−p unit------相对位置r=runit+rother+2------合并半径时间计算:t=ab−Δ

代码实现:

cs 复制代码
//RVO避障
for (int j = 0; j < units.Length; j++)
{
    if (j == i) continue;
    var other = units[j];

    //计算相对位置距离
    var relativePos = other.position - unit.position;
    var relativeVel = unit.velcity - other.velcity;
    float dist = relativePos.Length();
    float radio = unit.unitRadius + other.unitRadius + 2f;

    if (dist > radio * 3 || dist < 0.1f) continue;

    //碰撞预测
    float a = relativeVel.Dot(relativeVel);
    float b = relativePos.Dot(relativeVel);
    float c = relativePos.Dot(relativePos) - radio * radio;
    float dis = b * b - a * c;

    if (dis > 0 && a > 0.01f)
    {
        float t = (b - (float)Math.Sqrt(dis)) / a;
        if (t > 0 && t < 1)
        {
            //计算垂直线
            var avoidDir = new Vector2(-relativePos.Y, relativePos.X).Normalized();
            newVel = (newVel + avoidDir * unit.moveSpeed * (1f - t)).Normalized() * unit.moveSpeed;
        }
    }
}

硬靠墙修正

RVO避障可能把单位推到墙里面,寻路时也可能会贴墙,我们给墙添加硬修正把它推出来,这里我直接利用了我之前写流场寻路时用的的代码,记得声明好ThreadStatic墙点集

先获取单位附近墙格子的最近点

cs 复制代码
public void GetCloseWallPoints(Vector2 pos, int range, List<Vector2> points)        //获取最近的墙点
{
    points.Clear();

    int vecX = (int)Math.Floor(pos.X / gridSize);
    int vecY = (int)Math.Floor(pos.Y / gridSize);

    for (int x = -range; x <= range; x++)
        for (int y = -range; y <= range; y++)
        {
            int dx = vecX + x;
            int dy = vecY + y;

            if (Walkable(new Vector2I(dx, dy))) continue;

            float wallLeftX = dx * gridSize;
            float wallLeftY = dy * gridSize;
            float wallRightX = (dx + 1) * gridSize;
            float wallRightY = (dy + 1) * gridSize;

            float closeX = Math.Clamp(pos.X, wallLeftX, wallRightX);
            float closeY = Math.Clamp(pos.Y, wallLeftY, wallRightY);

            points.Add(new Vector2(closeX, closeY));
        }
}

然后把单位从每个墙最近点推开

cs 复制代码
private Vector2 ApplyWallCorrect(UnitData unitData, Vector2 newPosition)
{
    Vector2 correctVec = newPosition;
    float correctRange = unitData.unitRadius;

    foreach (var point in _tmpWallPoints)
    {
        Vector2 toPoint = point - correctVec;
        float dis = toPoint.LengthSquared();

        if (dis > correctRange * correctRange) continue;

        float dist = (float)Math.Sqrt(dis);
        float deep = correctRange - dist;
        correctVec += -toPoint / dist * deep;
    }

    return correctVec;
}

多单位占位解决

我们的项目不能允许两个单位站在同一个格子上,后来的那个要找别的空地,这里我用BFS扩散找最近的可走位置

cs 复制代码
private Vector2I? FindOtherPos(Vector2I pos)     //BFS寻找没有被占空地
{
    var q = new Queue<Vector2I>();
    var visited = new HashSet<Vector2I>();
    var dirs = new Vector2I[] { new(1, 0), new(-1, 0), new(0, 1), new(0, -1) };
    q.Enqueue(pos);
    visited.Add(pos);
    while (q.Count > 0)
    {
        var cur = q.Dequeue();
        if (!unitOccupy.Contains(cur) && jpsManager.Walkable(cur))
            return cur;

        foreach (var dir in dirs)
        {
            var p = cur + dir;
            if (jpsManager.Walkable(p) && !visited.Contains(p))
            {
                visited.Add(p);
                q.Enqueue(p);
            }
        }
    }

    return null;
}

移动调用

大家根据自己的项目落地即可,这里简单给大家分享一下我的部分代码,需要注意格子偏移,我项目里面还用了状态机

cs 复制代码
public Vector2? UnitMoveTarget(long onlyID, Vector2 target)      //单位移动
{
    var unit = unitNodes[onlyID];
    var unitData = unitDatas[unit.listIndex];
    //转换成网格坐标
    Vector2I start = gameManager.FloorGrid(unitData.position);
    Vector2I end = gameManager.FloorGrid(target);

    //判断占位
    if (!jpsManager.Walkable(end)) return null;
    var newEnd = unitOccupy.Contains(end) ? FindOtherPos(end) : end;
    if (!newEnd.HasValue) return null;

    //计算JPS路径
    var path = jpsManager.GetJPSpath(start, newEnd.Value);
    if (path == null) return null;

    unitOccupy.Remove(gameManager.FloorGrid(unitData.position));
    unitOccupy.Add(newEnd.Value);

    //注意我这里有半格格子的偏移,也就是到格子中心
    var newPath = path.Select(p => (Vector2)p).ToList();
    var tileSize = gameManager.GetTileSize();
    for (int i = 0; i < path.Count; i++)
        newPath[i] = newPath[i] * tileSize + new Vector2(tileSize * 0.5f, tileSize * 0.5f);

    //设置终点和起点
    newPath[newPath.Count - 1] = unitData.position;
    newPath[0] = newEnd.Value * tileSize + new Vector2(tileSize * 0.5f, tileSize * 0.5f);

    unitData.path = newPath;
    unitData.unitState = UnitState.Move;
    unitDatas[unit.listIndex] = unitData;
    return newPath[0];
}

效果展示:


结尾

以上就是我实现JPS寻路以及优化的代码分享,大家可以根据自己的实际情况来修改,感谢大家观看

我的博客

相关推荐
电子云与长程纠缠1 小时前
Godot学习06 - AnimationPlayer内置动画
学习·游戏引擎·godot
rgb2gray1 小时前
论文详解:基于POI数据的城市功能区动态演化分析——以北京为例
人工智能·算法·机器学习·回归·gwr
m0_734998011 小时前
Day 26
数据结构·c++·算法
信奥卷王2 小时前
2026年03月GESPC++二级真题解析(含视频)
算法
从零开始学习人工智能2 小时前
国产阿特拉斯无人机蜂群核心算法(一)
算法·无人机
励志的小陈2 小时前
双指针算法--移除元素、删除有序数组中的重复项、合并两个有序数组
算法
hoiii1873 小时前
Mean Shift目标跟踪算法MATLAB实现
算法·matlab·目标跟踪
励志的小陈3 小时前
复杂度算法题——旋转数组(三种思路)
c语言·数据结构·算法
tankeven3 小时前
HJ151 模意义下最大子序列和(Easy Version)
c++·算法