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:
Your Name 2026-08-21 15:56:53 -04:00
parent ad405ed590
commit c348b81a93
17 changed files with 2755 additions and 43 deletions

View file

@ -213,6 +213,9 @@ end
function World:rebuildCollision()
self.collidableMatrices = {red = {}, green = {}, blue = {}}
self.playerBlockerMatrices = {red = {}, green = {}, blue = {}}
-- Per-colour set of world cells occupied by a player copy. NPC pathfinding
-- treats these as solid (a walker can't push the player) — see pathfinding.lua.
self.playerCells = {red = {}, green = {}, blue = {}}
self.entityRooms = {}
local function put(matrix, cell, entity)
matrix[cell.y] = matrix[cell.y] or {}
@ -221,6 +224,13 @@ function World:rebuildCollision()
for name, room in pairs(self.levels) do
local location = self:roomLocation(name)
if location then
for color, player in pairs(room.player) do
for _, cell in ipairs(player:getOccupiedCells()) do
local x = (location.x - 1) * gridWidth + cell.x
local y = (location.y - 1) * gridHeight + cell.y
self.playerCells[color][x .. ":" .. y] = true
end
end
for color, entities in pairs(room.collideables) do
for _, entity in pairs(entities) do
self.entityRooms[entity] = room
@ -232,16 +242,19 @@ function World:rebuildCollision()
end
end
end
-- NPCs are local room state, but globally collidable: a movement lookup
-- must find them so its normal bump() path can open dialogue.
for _, npc in ipairs(room.npcs) do
self.entityRooms[npc] = room
for _, cell in ipairs(npc:getOccupiedCells()) do
local worldCell = {
x = (location.x - 1) * gridWidth + cell.x,
y = (location.y - 1) * gridHeight + cell.y,
}
for color in pairs(self.collidableMatrices) do put(self.collidableMatrices[color], worldCell, npc) end
-- NPC copies are per-colour room residents (see npc.lua). Each colour
-- stamps only its own copy's cells — "red can only be hit by red" — at the
-- copy's position in the room it currently stands in. The controller
-- entity (copy.npc) is stored so a bump opens its dialogue.
for color, copies in pairs(room.npcCopies) do
for _, copy in ipairs(copies) do
self.entityRooms[copy.npc] = room
for _, shapeCell in ipairs(copy.npc:getCellShape()) do
put(self.collidableMatrices[color], {
x = (location.x - 1) * gridWidth + copy.x + shapeCell.x - 1,
y = (location.y - 1) * gridHeight + copy.y + shapeCell.y - 1,
}, copy.npc)
end
end
end
for color, entities in pairs(room.playerBlockers) do
@ -271,6 +284,17 @@ function World:roomCell(cell)
return room, {x = cell.x - (roomX - 1) * gridWidth, y = cell.y - (roomY - 1) * gridHeight}
end
-- Marks are globally unique named destinations. This resolves one for an NPC's
-- Walker:sendTo, scanning every loaded room's mark registry.
function World:markByName(name)
for _, room in pairs(self.levels or {}) do
for _, mark in ipairs(room.marks or {}) do
if mark.markName == name then return mark, room end
end
end
return nil
end
local function cellKey(cell)
return cell.x .. ":" .. cell.y
end
@ -795,6 +819,25 @@ function World:tick(room)
self:nextRoomCheck(room)
end
-- Real-time NPC movement across every placed room, independent of player turns.
-- Snapshot the NPC list first: Walker:arrive can hand an NPC to another room's
-- registry, which would otherwise mutate a list we are iterating.
function World:update(dt)
local walking = {}
for name, room in pairs(self.levels or {}) do
if self:roomLocation(name) then
for _, npc in ipairs(room.npcs) do
if npc.step then table.insert(walking, npc) end
end
end
end
local moved = false
for _, npc in ipairs(walking) do
if npc:step(dt) then moved = true end
end
if moved then self:rebuildCollision() end
end
function World:nextRoomCheck(room)
for _, color in ipairs(room:getActiveColors()) do
local player = room.player[color]