chroma_solstice/pathfinding.lua

126 lines
4.9 KiB
Lua
Raw Normal View History

-- 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