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

214
npc.lua
View file

@ -1,11 +1,222 @@
require "entity"
require "dialogable"
local Pathfinding = require "pathfinding"
local channels = {"red", "green", "blue"}
-- An NpcCopy is one colour's presence of an NPC inside one room, in that room's
-- local coordinates — the exact analogue of room.player[color]. The room it lives
-- in draws it (Room:draw) and stamps it into collision (World:rebuildCollision),
-- so a copy obeys that room's colour view and collides there like any object.
-- An NPC has three copies, and while walking they may live in up to three
-- DIFFERENT rooms at once, exactly like the player's copies.
local NpcCopy = {}
NpcCopy.__index = NpcCopy
local function makeCopy(npc, color, room, x, y)
return setmetatable({ npc = npc, color = color, room = room, x = x, y = y }, NpcCopy)
end
-- The copy's cell in world coordinates (for pathfinding / collision).
function NpcCopy:worldCell()
if gameWorld then
local wc = gameWorld:worldCell(self.room, { x = self.x, y = self.y })
if wc then return wc end
end
return { x = self.x, y = self.y }
end
function NpcCopy:occupiesCell(cell)
for _, s in ipairs(self.npc:getCellShape()) do
if self.x + s.x - 1 == cell.x and self.y + s.y - 1 == cell.y then return true end
end
return false
end
-- Drawn by its room's per-channel pass, in that room's local coordinates — the
-- same call shape as every other object. `channel` always equals self.color.
function NpcCopy:draw(channel)
local sprite = self.npc.sprites[channel]
if not sprite then return end
local npc = self.npc
love.graphics.setColor(gColor[channel]:set())
local px = (self.x - 1) * width / gridWidth + npc:sha()
local py = (self.y - 1) * height / gridHeight + npc:sha()
love.graphics.draw(sprite, px + npc.drawOffset.x * drawScale, py + npc.drawOffset.y * drawScale, 0, drawScale, drawScale)
end
-- Walker: gives an NPC the player's RGB model. The NPC entity itself is only a
-- controller (kept in room.npcs for identity / dialogue / serialisation); its
-- visible, collidable presence is its three NpcCopy residents, which each belong
-- to whatever room they currently stand in and run their own A* toward a Mark.
Walker = {}
function Walker:initWalker()
self.paths = { red = nil, green = nil, blue = nil }
self.goal = nil
self.walking = false
self.moveTimer = 0
self.moveInterval = 0.15
end
-- Create the three copies at this NPC's home cell in `room` and register each
-- into that room's per-colour copy list. Called from Room:registerNpc.
function Walker:initCopies(room)
self.copies = {}
for _, color in ipairs(channels) do
local copy = makeCopy(self, color, room, self.x, self.y)
self.copies[color] = copy
table.insert(room.npcCopies[color], copy)
end
end
function Walker:isWalking() return self.walking end
-- Move one colour copy into `room` at local (x, y), transferring it between the
-- rooms' per-colour copy lists — the NPC equivalent of the player crossing a
-- room seam.
function Walker:placeCopy(copy, room, x, y)
if copy.room ~= room then
local from = copy.room.npcCopies[copy.color]
for i = #from, 1, -1 do if from[i] == copy then table.remove(from, i) end end
table.insert(room.npcCopies[copy.color], copy)
copy.room = room
end
copy.x, copy.y = x, y
end
local function npcLog(...)
if DEBUG then print("[npc]", ...) end
end
-- Send this NPC to a named Mark. Each colour plots its own route on its own
-- collision layer, so the copies may take different paths and cross into
-- different rooms.
function Walker:sendTo(markName)
if not gameWorld then npcLog("sendTo abort: no gameWorld"); return end
if not self.copies then npcLog("sendTo abort: no copies (not registered?)"); return end
local mark, markRoom = gameWorld:markByName(markName)
if not mark then npcLog("sendTo abort: no mark named '" .. tostring(markName) .. "'"); return end
local goal = gameWorld:worldCell(markRoom, { x = mark.x, y = mark.y })
if not goal then npcLog("sendTo abort: mark's room '" .. tostring(markRoom.name) .. "' is not placed on the world map"); return end
self.goal = goal
self.walking = true
-- Route is computed against the live collision matrix, so rebuild first (the
-- player and other NPCs may have moved since the last tick).
if gameWorld.rebuildCollision then gameWorld:rebuildCollision() end
local shape = self:getCellShape()
for _, color in ipairs(channels) do
local start = self.copies[color]:worldCell()
self.paths[color] = Pathfinding.route(start, goal, color, self, shape)
npcLog(("sendTo %s: start=%d,%d goal=%d,%d -> %s"):format(color, start.x, start.y, goal.x, goal.y,
self.paths[color] and ("route len " .. #self.paths[color])
or ("NO ROUTE (goal blocked by " .. (Pathfinding.blockReason(goal, color, self, shape) or "a wall between here and there") .. ")")))
end
self.moveTimer = 0
end
-- Advance one grid step per colour when the timer elapses. Returns true if any
-- copy moved (so the caller can rebuild collision). Driven by World:update.
function Walker:step(dt)
if not self.walking then return false end
self.moveTimer = self.moveTimer + dt
if self.moveTimer < self.moveInterval then return false end
self.moveTimer = self.moveTimer - self.moveInterval
local shape = self:getCellShape()
local moved = false
for _, color in ipairs(channels) do
local copy = self.copies[color]
local start = copy:worldCell()
if start.x ~= self.goal.x or start.y ~= self.goal.y then
-- (Re)plan whenever we have no live path — so a channel blocked this tick
-- keeps trying and resumes the moment the obstacle (a box, the player)
-- clears, instead of freezing forever.
local path = self.paths[color]
if not path or #path == 0 then
path = Pathfinding.route(start, self.goal, color, self, shape)
self.paths[color] = path
end
if path and #path > 0 then
local nextCell = path[1]
if Pathfinding.walkable(nextCell, color, self, shape) then
local room, localCell = gameWorld:roomCell(nextCell)
if room then
self:placeCopy(copy, room, localCell.x, localCell.y)
table.remove(path, 1)
moved = true
else
self.paths[color] = nil
end
else
-- blocked this tick: drop the stale path, replan next tick
if not self._blocked then self._blocked = {} end
local reason = Pathfinding.blockReason(nextCell, color, self, shape)
if self._blocked[color] ~= reason then
npcLog(("%s blocked at %d,%d by %s (waiting)"):format(color, nextCell.x, nextCell.y, reason or "?"))
self._blocked[color] = reason
end
self.paths[color] = nil
end
end
end
end
if self:allArrived() then self:arrive() end
return moved
end
function Walker:allArrived()
if not self.goal then return false end
for _, color in ipairs(channels) do
local wc = self.copies[color]:worldCell()
if wc.x ~= self.goal.x or wc.y ~= self.goal.y then return false end
end
return true
end
-- Every copy reached the destination. Stop, and move the controller (self, the
-- entity kept in room.npcs) into the room the copies ended up in, so a save
-- writes it in the right level and the editor finds it there.
function Walker:arrive()
self.walking = false
self.paths = { red = nil, green = nil, blue = nil }
self.goal = nil
local lead = self.copies[channels[1]]
if lead and lead.room then
if lead.room ~= self.room and self.room then
for i = #self.room.npcs, 1, -1 do if self.room.npcs[i] == self then table.remove(self.room.npcs, i) end end
table.insert(lead.room.npcs, self)
end
self.room = lead.room
self.x, self.y = lead.x, lead.y
end
if gameWorld and gameWorld.rebuildCollision then gameWorld:rebuildCollision() end
end
-- Remove this NPC entirely: its copies from every room's copy lists and its
-- controller from its room's npc registry. Used by the editor's right-click.
function Walker:destroy()
if self.copies then
for _, color in ipairs(channels) do
local copy = self.copies[color]
local list = copy.room.npcCopies[color]
for i = #list, 1, -1 do if list[i] == copy then table.remove(list, i) end end
end
end
if self.room then
for i = #self.room.npcs, 1, -1 do if self.room.npcs[i] == self then table.remove(self.room.npcs, i) end end
end
end
-- A dialogable placed in the world. Signs, npcs and artifacts will each be a
-- small Entity subclass that includes Dialogable and names its own book; Sign is
-- the first. It just picks art + a story — the talking lives in the mixin.
Sign = class("Sign", Entity)
Sign:include(Dialogable)
Sign:include(Walker)
function Sign:initialize(x, y)
self.book = startSignBook
@ -19,10 +230,12 @@ function Sign:initialize(x, y)
blue = loadSprite("art/npc/start_sign_b.png"),
},
})
self:initWalker()
end
Jorge = class("Jorge", Entity)
Jorge:include(Dialogable)
Jorge:include(Walker)
function Jorge:initialize(x, y)
self.book = jorgeBook
@ -37,4 +250,5 @@ function Jorge:initialize(x, y)
blue = loadSprite("art/npc/jorge_b.png"),
},
})
self:initWalker()
end