Add named Marks and cross-room NPC pathfinding
- Mark: a debug non-collidable named destination (pixel-art target icon), editor tool + name modal, globally-unique names, programmatic Room:addMark - NPCs walk to Marks: each RGB copy is a per-room resident (like the player), running its own A* on its own colour layer, transferring rooms as it crosses seams; rendered and collided by the room it currently stands in - Player copies are obstacles in NPC pathfinding - A* is footprint-aware for multi-cell NPCs - Editor: click an NPC to open a filterable destination picker - Ink: Dialogue.open binds walk_to; Jorge can be sent to "end"/"begin" - Fix: a blocked NPC now replans each tick and resumes instead of freezing - [npc] move diagnostics under DEBUG (start/goal/route or block reason) - Docs (adding-npcs) + CLAUDE.md LÖVE 11.x/float-colour corrections - Fix test harness loadSprite stub; movement tests green (28) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
ad405ed590
commit
c348b81a93
17 changed files with 2755 additions and 43 deletions
125
pathfinding.lua
Normal file
125
pathfinding.lua
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
-- 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue