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