前言
之前敌人寻路用了流场,然后玩家单位我准备用JPS跳点寻路,这里给大家分享我的实现思路以及优化
开发流程大致如下:
- JPS跳点寻路:初始化JPS路线
- 路线压缩和靠墙优化:让路线更自然
- 多线程移动:移动实现
- 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寻路以及优化的代码分享,大家可以根据自己的实际情况来修改,感谢大家观看