Hello, everyone.
I’ve been working on a real-time strategy game, and have struggled with what I consider to be one of the most important aspects of an RTS: pathfinding. I went from using Roblox’s default pathfinding service, to creating a custom A* pathfinding script, to “upgrading” the A* algorithm to Jump Point Search. However, the results have been disheartening. Using JPS, it seems to perform worse than when I was using A*.
Below is an image of the map I’m currently using to test pathfinding.
The map is 100 * 100 studs in size. A uniform grid consisting of 2 * 2 stud-sized nodes populate the map, creating a 50 * 50 grid. Nodes placed on the edges of cliffs are marked as blocked.
On this map, creating a path usually takes longest when the starting point is on the right corner, and the goal is at the top corner (the highest platform on the map). Originally, calculating a path from these two points using JPS could take as much as .2 seconds!
I spent the last couple days researching the Forum and the Developer Hub for resources on optimization, and made several changes to how my code was structured. Now, in the worst-case scenario, creating a path takes about 0.08 seconds. While it is a major improvement, the delay remains noticeable. On top of this, I anticipate that maps in the final product of this game would be much larger than 100 * 100.
I’ve already considered yet another way to fix pathfinding for my game. But before I make another massive overhaul to the pathfinding mechanics, I would like to hear any of your thoughts in regards to my code, and if it could be optimized even further. (For the sake of readability, I’ve also removed several comments that were kept solely for debugging purposes.)
--[[
JPS Pathfinding Script
Cyber_Star00
1/16/2021
]]
--[[
Sources:
Daniel Harabor and Alban Grastien's papers:
Online Graph Pruning for Pathfinding on Grid Maps:
http://users.cecs.anu.edu.au/~dharabor/data/papers/harabor-grastien-aaai11.pdf
JavaScript Pathfinding Visualizer:
https://qiao.github.io/PathFinding.js/visual/
Source code of said Visualizer:
https://github.com/qiao/PathFinding.js
]]
--[[
MIT License
© 2011-2012 Xueqiao Xu <xueqiaoxu@gmail.com>
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
associated documentation files (the "Software"), to deal in the Software without restriction,
including without limitation the rights to use, copy, modify, merge, publish, distribute,
sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial
portions of the Software.
]]
local BLOCK_SIZE = 2
local OFFSET = BLOCK_SIZE / 2
local newVector3 = Vector3.new
local insert = table.insert
local Pathfinder = {}
local gScore = {}; --Dictionary
local fScore = {}; --Dictionary
local hScore = {}; --Dictionary
local openNodes = {}; --List
local closedNodes = {}; --List
local parentOf = {}; --Dictionary
local function drawLine(pointA, pointB)
local attachmentA = Instance.new("Attachment")
attachmentA.Parent = workspace.Platforms.Baseplate
attachmentA.WorldPosition = pointA
local attachmentB = Instance.new("Attachment")
attachmentB.Parent = workspace.Platforms.Baseplate
attachmentB.WorldPosition = pointB
local line = Instance.new("Beam")
line.Attachment0 = attachmentA
line.Attachment1 = attachmentB
line.Width0 = 0.5
line.Width1 = 0.5
line.FaceCamera = true
line.Transparency = NumberSequence.new(0)
line.Color = ColorSequence.new(Color3.fromRGB(75, 75, 75))
line.Parent = workspace.Platforms.Baseplate
end
--Use a dictionary to store and index nodes, as this is much faster than using
--Instance:FindFirstChild()
local Grid = require(script.Grid)
local getNode = Grid.GetNode
local isBlocked = Grid.IsBlocked
local function getDirection(vectorA, vectorB)
--[[
Return the normalized direction of travel from one point to another.
param vectorA: The starting point.
param vectorB: The ending point.
]]
return newVector3(
((vectorA.X - vectorB.X) / math.max(math.abs(vectorA.X - vectorB.X), 1)) * BLOCK_SIZE,
0,
((vectorA.Z - vectorB.Z) / math.max(math.abs(vectorA.Z - vectorB.Z), 1)) * BLOCK_SIZE
)
end
local function heuristic(vectorA, vectorB)
--Manhattan distance on a square grid
return math.abs(vectorB.X - vectorA.X) + math.abs(vectorB.Z - vectorA.Z)
end
local function octileHeuristic(x, z)
local f = math.sqrt(2) - 1;
if x < z then
return f * x + z
else
return f * z + x
end
end
local function snapTo(number, step)
return number - (number % step)
end
local function reconstructPath(startNode, goalNode)
--Return a table of Vector3s
--Clear any preexisting lines
local path = {}
local currentNode = goalNode
repeat
insert(path, 1, currentNode)
currentNode = parentOf[currentNode]
until not currentNode or currentNode == startNode
return path
end
local function jump(nodeX, nodeZ, parentX, parentZ, goalNode)
--[[
Search recursively in the direction (parent -> child), stopping only when a jump point is found.
param nodePosition:
param parentNodePosition:
param goalNode:
]]
local direction = getDirection(newVector3(nodeX, 0, nodeZ), newVector3(parentX, 0, parentZ))
--Make some abbreviated variables
local directionX, directionZ = direction.X, direction.Z
--local case1 = getNode(newVector3(nodeX, 0, nodeZ))
if isBlocked(nodeX, nodeZ) == true then
return nil
end
--if trackJumpRecursion == true then
--Indicate that this node has been tested
--end
if getNode(nodeX, nodeZ) == goalNode then
return newVector3(nodeX, 0, nodeZ)
end
local xd = nodeX + directionX
local xdn = nodeX - directionX
local xb = nodeX + BLOCK_SIZE
local xbn = nodeX - BLOCK_SIZE
local zd = nodeZ + directionZ
local zdn = nodeZ - directionZ
local zb = nodeZ + BLOCK_SIZE
local zbn = nodeZ - BLOCK_SIZE
--Check for forced neighbors along the diagonal (if the direction is diagonal)
if directionX ~= 0 and directionZ ~= 0 then
--Moving diagonally, check for vertical / horizontal jump points
if jump(xd, nodeZ, nodeX, nodeZ, goalNode)
or
jump(nodeX, zd, nodeX, nodeZ, goalNode)
then
return newVector3(nodeX, 0, nodeZ)
end
--Check for forced neighbors horizontally
elseif directionX ~= 0 then
if (
isBlocked(nodeX, zbn) == false
and
isBlocked(xdn, zbn) == true
)
or
(
isBlocked(nodeX, zb) == false
and
isBlocked(xdn, zb) == true
)
then
return newVector3(nodeX, 0, nodeZ)
end
--Check for forced neighbors vertically
elseif directionZ ~= 0 then
if (
isBlocked(xbn, nodeZ) == false
and
isBlocked(xbn, zdn) == true
)
or
(
isBlocked(xb, nodeZ) == false
and
isBlocked(xb, zdn) == true
)
then
return newVector3(nodeX, 0, nodeZ)
end
end
--Moving diagonally, make sure one of the vertical / horizontal neighbors is open to allow the
--path.
if (
isBlocked(xd, nodeZ) == false
and
isBlocked(nodeX, zd) == false
)
then
return jump(xd, zd, nodeX, nodeZ, goalNode)
else
return nil
end
end
local function getNeighbors(x, z)
--[[
Get the neighbors of the given node.
offsets (S): diagonalOffsets (D):
+---+---+---+ +---+---+---+
| | 0 | | | 0 | | 1 |
+---+---+---+ +---+---+---+
| 3 | | 1 | | | | |
+---+---+---+ +---+---+---+
| | 2 | | | 3 | | 2 |
+---+---+---+ +---+---+---+
if offsets[i] is valid, then
diagonalOffsets[i] and
diagonalOffsets[(i + 1) % 4] is valid.
]]
--print("Getting neighbors of the given node")
local neighbors = {}
local s0, s1, s2, s3 = false, false, false, false --"Guilty until proven innocent"
local d0, d1, d2, d3 = false, false, false, false
-- ↑
if isBlocked(x, z - BLOCK_SIZE) == false then
insert(neighbors, getNode(x, z - BLOCK_SIZE))
s0 = true
end
-- →
if isBlocked(x + BLOCK_SIZE, z) == false then
insert(neighbors, getNode(x + BLOCK_SIZE, z))
s1 = true
end
-- ↓
if isBlocked(x, z + BLOCK_SIZE) == false then
insert(neighbors, getNode(x, z + BLOCK_SIZE))
s2 = true
end
-- ←
if isBlocked(x - BLOCK_SIZE, z) == false then
insert(neighbors, getNode(x - BLOCK_SIZE, z))
s3 = true
end
d0 = s3 and s0
d1 = s0 and s1
d2 = s1 and s2
d3 = s2 and s3
-- ↖
if d0 == true and isBlocked(x - BLOCK_SIZE, z - BLOCK_SIZE) == false then
insert(neighbors, getNode(x - BLOCK_SIZE, z - BLOCK_SIZE))
end
-- ↗
if d1 == true and isBlocked(x + BLOCK_SIZE, z - BLOCK_SIZE) == false then
insert(neighbors, getNode(x + BLOCK_SIZE, z - BLOCK_SIZE))
end
-- ↘
if d2 == true and isBlocked(x + BLOCK_SIZE, z + BLOCK_SIZE) == false then
insert(neighbors, getNode(x + BLOCK_SIZE, z + BLOCK_SIZE))
end
-- ↙
if d3 == true and isBlocked(x - BLOCK_SIZE, z + BLOCK_SIZE) == false then
insert(neighbors, getNode(x - BLOCK_SIZE, z + BLOCK_SIZE))
end
return neighbors
end
local function prune(node)
local parentNode = parentOf[node]
local neighborNodes = {}
local nodeX, nodeZ = node.Position.X, node.Position.Z
--Directed pruning; can ignore most neighbors unless forced.
if parentNode then
--Get the normalized direction of travel
local direction = getDirection(node.Position, parentNode.Position)
local directionX, directionZ = direction.X, direction.Z
--Search diagonally (if the direction is diagonal)
if directionX ~= 0 and directionZ ~= 0 then
--print(getNode(newVector3(nodeX, 0, nodeZ + direction.Z)).Blocked.Value,
-- getNode(newVector3(nodeX + direction.X, 0, nodeZ)).Blocked.Value
--)
if isBlocked(nodeX, nodeZ + directionZ) == false then
insert(neighborNodes, getNode(nodeX, nodeZ + directionZ))
end
if isBlocked(nodeX + direction.X, nodeZ) == false then
insert(neighborNodes, getNode(nodeX + directionX, nodeZ))
end
if isBlocked(nodeX, nodeZ + directionZ) == false
and
isBlocked(nodeX + directionX, nodeZ) == false
then
insert(neighborNodes, getNode(nodeX + directionX, nodeZ + directionZ))
end
--Search horizontally / vertically (based on the direction of travel)
else
local isNextNodeBlocked
--Search horizontally
if directionX ~= 0 then
isNextNodeBlocked = isBlocked(nodeX + directionX, nodeZ)
local isTopBlocked = isBlocked(nodeX, nodeZ + BLOCK_SIZE)
local isBottomBlocked = isBlocked(nodeX, nodeZ - BLOCK_SIZE)
if isNextNodeBlocked == false then
insert(neighborNodes, getNode(nodeX + directionX, nodeZ))
if isTopBlocked == false then
insert(neighborNodes, getNode(nodeX + directionX, nodeZ + BLOCK_SIZE))
end
if isBottomBlocked == false then
insert(neighborNodes, getNode(nodeX + directionX, nodeZ - BLOCK_SIZE))
end
end
if isTopBlocked == false then
insert(neighborNodes, getNode(nodeX, nodeZ + BLOCK_SIZE))
end
if isBottomBlocked == false then
insert(neighborNodes, getNode(nodeX, nodeZ - BLOCK_SIZE))
end
--Search vertically
elseif directionZ ~= 0 then
isNextNodeBlocked = isBlocked(nodeX, nodeZ + directionZ)
local isRightBlocked = isBlocked(nodeX + BLOCK_SIZE, nodeZ)
local isLeftBlocked = isBlocked(nodeX - BLOCK_SIZE, nodeZ)
if isNextNodeBlocked == false then
insert(neighborNodes, getNode(nodeX, nodeZ + directionZ))
if isRightBlocked == false then
insert(neighborNodes, getNode(nodeX + BLOCK_SIZE, nodeZ + directionZ))
end
if isLeftBlocked == false then
insert(neighborNodes, getNode(nodeX - BLOCK_SIZE, nodeZ + directionZ))
end
end
if isRightBlocked == false then
insert(neighborNodes, getNode(nodeX + BLOCK_SIZE, nodeZ))
end
if isLeftBlocked == false then
insert(neighborNodes, getNode(nodeX - BLOCK_SIZE, nodeZ))
end
end
end
--No parent node found; return all neighbors
else
neighborNodes = getNeighbors(nodeX, nodeZ)
end
return neighborNodes
end
local function identifySuccessors(node, goalNode)
--[[
Identify successors for the given node. Runs a jump point search in the direction of each
available neighbor, adding any points found to the open list.
param node:
param goalNode:
]]
local neighbors = prune(node)
local nodePosition = node.Position
local nodeX, nodeZ = nodePosition.X, nodePosition.Z
for i = 1, #neighbors do
local neighborPosition = neighbors[i].Position
local jumpPoint = jump(neighborPosition.X, neighborPosition.Z, nodeX, nodeZ, goalNode)
if jumpPoint then
local jumpNode = getNode(jumpPoint.X, jumpPoint.Z)
--if isBlocked(jumpPoint.X, jumpPoint.Z) == true then
-- continue
--end
if table.find(closedNodes, jumpNode) then
continue
end
--Include distance, as the parent node may not be immediately adjacent
local distance = octileHeuristic(math.abs(jumpPoint.X - nodeX), math.abs(jumpPoint.Z - nodeZ))
--local distance = heuristic(jumpPoint, node)
local nextGScore = gScore[node] + distance
if (not table.find(openNodes, jumpNode)) or (nextGScore < gScore[jumpNode]) then
gScore[jumpNode] = nextGScore
hScore[jumpNode] = hScore[jumpNode] or heuristic(goalNode.Position, jumpPoint)
fScore[jumpNode] = gScore[jumpNode] + hScore[jumpNode]
parentOf[jumpNode] = node
--Add the jump node to the open list if it has not been added, already.
--print("Adding jump node")
if not table.find(openNodes, jumpNode) then
insert(openNodes, jumpNode)
end
end
else
end
end
table.sort(openNodes, function(a, b)
return fScore[a] < fScore[b]
end)
end
function Pathfinder:CreatePath(startVector, goalVector)
--[[
Find and return a path from one point to another
param startVector: the starting point for the path.
param goalVector: the point the path is trying to reach.
]]
--Reset the path data
closedNodes = {}
fScore = {}
gScore = {}
hScore = {}
openNodes = {}
parentOf = {}
--Update the vectors given so that they are snapped to the grid's measurments
startVector = newVector3(snapTo(startVector.X, BLOCK_SIZE) + OFFSET, 0, snapTo(startVector.Z, BLOCK_SIZE) + OFFSET)
goalVector = newVector3(snapTo(goalVector.X, BLOCK_SIZE) + OFFSET, 0, snapTo(goalVector.Z, BLOCK_SIZE) + OFFSET)
--print(startVector, goalVector) --DEBUG
--Convert the given Vector3s into strings so that we can index the closest available nodes
local startNode = getNode(startVector.X, startVector.Z)
local goalNode = getNode(goalVector.X, goalVector.Z)
--print(startNode.Position, goalNode.Position) --DEBUG
--Set the G score and F score of the startNode to 0
gScore[startNode] = 0
fScore[startNode] = 0
--Put the start node in the open list
insert(openNodes, startNode)
while #openNodes > 0 do
local nodeWithLowestFScore = table.remove(openNodes, 1)
--Remove the node with the lowest F score from the openNodes list
--table.remove(openNodes, currentNodeIndex)
--Add said node to the closedNodes list to indicate it has already been evaluated
insert(closedNodes, nodeWithLowestFScore)
if nodeWithLowestFScore == goalNode then
return true, reconstructPath(startNode, goalNode)
end
identifySuccessors(nodeWithLowestFScore, goalNode)
end
--Complete path not found
return false, {}
end
return Pathfinder
This ModuleScript does require another sub-module called “Grid.” If needed, I can also provide the code to that.