126 lines
4.9 KiB
Lua
126 lines
4.9 KiB
Lua
|
|
-- Grid A* over the world collidable grid, in WORLD coordinates, one color at a
|
||
|
|
-- time. NPC color copies each call this on their own channel (see Npc:sendTo),
|
||
|
|
-- so a red copy routes around only red obstacles — "red can only be hit by red".
|
||
|
|
--
|
||
|
|
-- The world grid is continuous across rooms (World:worldCell / World:roomCell),
|
||
|
|
-- so a route can cross a room seam without any special casing.
|
||
|
|
local Pathfinding = {}
|
||
|
|
|
||
|
|
local directions = {
|
||
|
|
{x = 1, y = 0}, {x = -1, y = 0}, {x = 0, y = 1}, {x = 0, y = -1},
|
||
|
|
}
|
||
|
|
|
||
|
|
local singleCell = {{x = 1, y = 1}}
|
||
|
|
|
||
|
|
local function key(cell) return cell.x .. ":" .. cell.y end
|
||
|
|
local function heuristic(a, b) return math.abs(a.x - b.x) + math.abs(a.y - b.y) end
|
||
|
|
|
||
|
|
-- Is one world cell free for `color`? Free means on the map and its collidable
|
||
|
|
-- slot is empty, holds the moving entity itself, or holds a passable object
|
||
|
|
-- (marks, notes). Everything else — walls, boxes, doors, other NPC copies on this
|
||
|
|
-- channel — blocks.
|
||
|
|
local function cellFree(cell, color, ignoreEntity)
|
||
|
|
if not gameWorld then return false end
|
||
|
|
if not gameWorld:roomCell(cell) then return false end
|
||
|
|
-- Player copies are solid to a walker (it can't push them), so they block.
|
||
|
|
local players = gameWorld.playerCells and gameWorld.playerCells[color]
|
||
|
|
if players and players[cell.x .. ":" .. cell.y] then return false end
|
||
|
|
local row = gameWorld.collidableMatrices[color] and gameWorld.collidableMatrices[color][cell.y]
|
||
|
|
local entity = row and row[cell.x]
|
||
|
|
if not entity then return true end
|
||
|
|
if entity == ignoreEntity then return true end
|
||
|
|
if entity.canPassOver and entity:canPassOver() then return true end
|
||
|
|
return false
|
||
|
|
end
|
||
|
|
|
||
|
|
-- An anchor cell is walkable only when EVERY cell of the entity's footprint is
|
||
|
|
-- free there. `shape` is an entity cellShape (1-based offsets); a nil shape means
|
||
|
|
-- a single 1x1 cell. This is what stops a 2x2 NPC squeezing through a 1-wide gap
|
||
|
|
-- as if it were its top-left cell alone.
|
||
|
|
local function walkable(anchor, color, ignoreEntity, shape)
|
||
|
|
shape = shape or singleCell
|
||
|
|
for _, offset in ipairs(shape) do
|
||
|
|
local cell = { x = anchor.x + offset.x - 1, y = anchor.y + offset.y - 1 }
|
||
|
|
if not cellFree(cell, color, ignoreEntity) then return false end
|
||
|
|
end
|
||
|
|
return true
|
||
|
|
end
|
||
|
|
|
||
|
|
Pathfinding.walkable = walkable
|
||
|
|
|
||
|
|
-- Human-readable reason an anchor is not walkable (for diagnostics), or nil if it
|
||
|
|
-- is free. Reports the first offending footprint cell.
|
||
|
|
function Pathfinding.blockReason(anchor, color, ignoreEntity, shape)
|
||
|
|
shape = shape or singleCell
|
||
|
|
if not gameWorld then return "no gameWorld" end
|
||
|
|
for _, offset in ipairs(shape) do
|
||
|
|
local cell = { x = anchor.x + offset.x - 1, y = anchor.y + offset.y - 1 }
|
||
|
|
if not gameWorld:roomCell(cell) then return ("offmap@%d,%d"):format(cell.x, cell.y) end
|
||
|
|
local players = gameWorld.playerCells and gameWorld.playerCells[color]
|
||
|
|
if players and players[cell.x .. ":" .. cell.y] then return ("player@%d,%d"):format(cell.x, cell.y) end
|
||
|
|
local row = gameWorld.collidableMatrices[color] and gameWorld.collidableMatrices[color][cell.y]
|
||
|
|
local e = row and row[cell.x]
|
||
|
|
if e and e ~= ignoreEntity and not (e.canPassOver and e:canPassOver()) then
|
||
|
|
local what = e.getClass and e:getClass() or "obstacle"
|
||
|
|
return ("%s@%d,%d"):format(what, cell.x, cell.y)
|
||
|
|
end
|
||
|
|
end
|
||
|
|
return nil
|
||
|
|
end
|
||
|
|
|
||
|
|
-- Returns a list of anchor world cells from just-after `start` through `goal`
|
||
|
|
-- inclusive, or nil if no route exists. `shape` is the entity's cellShape, so
|
||
|
|
-- the whole footprint must fit at every step and at the goal.
|
||
|
|
function Pathfinding.route(start, goal, color, ignoreEntity, shape)
|
||
|
|
if not gameWorld then return nil end
|
||
|
|
if start.x == goal.x and start.y == goal.y then return {} end
|
||
|
|
if not walkable(goal, color, ignoreEntity, shape) then return nil end
|
||
|
|
|
||
|
|
local open = { start } -- frontier as a plain list
|
||
|
|
local cameFrom = {}
|
||
|
|
local gScore = { [key(start)] = 0 }
|
||
|
|
local inOpen = { [key(start)] = true }
|
||
|
|
|
||
|
|
while #open > 0 do
|
||
|
|
-- pick the open node with the lowest f = g + h (small grids: linear scan)
|
||
|
|
local bestIndex, best = 1, open[1]
|
||
|
|
local bestF = gScore[key(best)] + heuristic(best, goal)
|
||
|
|
for i = 2, #open do
|
||
|
|
local node = open[i]
|
||
|
|
local f = gScore[key(node)] + heuristic(node, goal)
|
||
|
|
if f < bestF then bestIndex, best, bestF = i, node, f end
|
||
|
|
end
|
||
|
|
table.remove(open, bestIndex)
|
||
|
|
inOpen[key(best)] = nil
|
||
|
|
|
||
|
|
if best.x == goal.x and best.y == goal.y then
|
||
|
|
local path, node = {}, best
|
||
|
|
while cameFrom[key(node)] do
|
||
|
|
table.insert(path, 1, node)
|
||
|
|
node = cameFrom[key(node)]
|
||
|
|
end
|
||
|
|
return path
|
||
|
|
end
|
||
|
|
|
||
|
|
for _, dir in ipairs(directions) do
|
||
|
|
local neighbor = { x = best.x + dir.x, y = best.y + dir.y }
|
||
|
|
if walkable(neighbor, color, ignoreEntity, shape) then
|
||
|
|
local tentative = gScore[key(best)] + 1
|
||
|
|
local nk = key(neighbor)
|
||
|
|
if not gScore[nk] or tentative < gScore[nk] then
|
||
|
|
cameFrom[nk] = best
|
||
|
|
gScore[nk] = tentative
|
||
|
|
if not inOpen[nk] then
|
||
|
|
table.insert(open, neighbor)
|
||
|
|
inOpen[nk] = true
|
||
|
|
end
|
||
|
|
end
|
||
|
|
end
|
||
|
|
end
|
||
|
|
end
|
||
|
|
|
||
|
|
return nil
|
||
|
|
end
|
||
|
|
|
||
|
|
return Pathfinding
|