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

107
room.lua
View file

@ -18,6 +18,7 @@ require "generic_spiky"
require "generic_unmoveable"
require "generic_player_blocker"
require "note"
require "mark"
local Movement = require "movement"
local Wire = require "wire"
@ -32,7 +33,11 @@ function Room:initialize(t)
self.collideables = { red = {}, green = {}, blue = {} }
self.playerBlockers = { red = {}, green = {}, blue = {} }
self.npcs = {}
-- Per-colour NPC copy residents, mirroring self.player: each room draws and
-- collides the copies currently standing in it (see npc.lua / Walker).
self.npcCopies = { red = {}, green = {}, blue = {} }
self.notes = {}
self.marks = {}
self.activeColors = {red = false, green = false, blue = false}
self.colorsPlayerHas = {}
@ -172,16 +177,21 @@ function Room:draw(drawChannels)
for _, blocker in pairs(self.playerBlockers[channel]) do blocker:draw(channel) end
for _, switch in pairs(self.switches[channel]) do switch:draw(channel) end
for _, switch in ipairs(self.wiredSwitches) do switch:draw(channel) end
for _, npc in ipairs(self.npcs) do npc:draw(channel) end
-- NPC copies render exactly like player copies: each room draws only the
-- copies currently standing in it, on this channel. A copy that has walked
-- into a red-only room shows only its red layer there.
for _, copy in ipairs(self.npcCopies[channel]) do copy:draw(channel) end
end
-- Notes are an editor-only overlay: shown only while the editor build is
-- running (DEBUG) and the NOTES_VISIBLE toggle is on. Drawn once (not per
-- channel) in alpha so the faint icon reads as itself instead of screen-adding.
if DEBUG and NOTES_VISIBLE and #self.notes > 0 then
-- Debug non-collidables (notes, marks, …) are editor-only overlays: shown only
-- under DEBUG while the overlay toggle is on. Drawn once (not per channel) in
-- alpha so each icon reads as itself instead of screen-adding. Each kind draws
-- itself in its own pass; they share the toggle, not any code.
if DEBUG and NOTES_VISIBLE then
local mode, alphaMode = love.graphics.getBlendMode()
love.graphics.setBlendMode("alpha")
for _, note in ipairs(self.notes) do note:draw() end
for _, mark in ipairs(self.marks) do mark:draw() end
love.graphics.setBlendMode(mode, alphaMode)
end
end
@ -756,6 +766,11 @@ function Room:createEntity(class, color, x, y, data)
self:registerNote(entity)
return entity
end
if class == "mark" then
local entity = Mark:new(x, y, data.name or data.markName)
self:registerMark(entity)
return entity
end
local props = globalAssetProperties and globalAssetProperties[class]
local behavior = props and props.class or "immoveable"
@ -868,9 +883,12 @@ function Room:registerPlayer(entityPointer, color)
end
function Room:registerNpc(entity)
-- npcs are dialogable entities: they carry .x/.y like any Entity, so
-- serialize's npc loop still round-trips them, and they get stamped into the
-- collision matrix (see createCollideablesMatrix) so a bump is a rejected move.
-- The NPC entity is a controller kept here for identity / dialogue / serialize.
-- Its visible, collidable presence is three per-colour copies (Walker) that
-- each belong to whatever room they stand in; initCopies seeds them here at the
-- NPC's home cell, registered into this room's npcCopies lists.
entity.room = self
if entity.initCopies then entity:initCopies(self) end
table.insert(self.npcs, entity)
end
@ -890,6 +908,44 @@ function Room:noteAtCell(cell)
return nil
end
function Room:registerMark(entity)
-- Marks, like notes, are passable annotations kept in their own registry so
-- serialize round-trips them and movement never stamps them into a matrix.
-- The difference is their name is meaningful: NPCs are sent here by it.
table.insert(self.marks, entity)
end
-- The mark (if any) under a given grid cell — used by the editor's hover/click.
function Room:markAtCell(cell)
if not cell then return nil end
for _, mark in ipairs(self.marks) do
if mark:occupiesCell(cell) then return mark end
end
return nil
end
-- The npc (if any) with a copy under a given grid cell in this room — used by the
-- editor's debug destination picker (click an NPC to send it to a mark). Checks
-- the visible copies, so clicking a walked-away copy still finds its controller.
function Room:npcAtCell(cell)
if not cell then return nil end
for _, color in ipairs({"red", "green", "blue"}) do
for _, copy in ipairs(self.npcCopies[color]) do
if copy:occupiesCell(cell) then return copy.npc end
end
end
return nil
end
-- Code-side mark creation: register a named destination through the same path
-- authored marks use, so it collides/renders in debug identically. Flagged
-- `programmatic` so Room:serialize skips it — code recreates it on every load.
function Room:addMark(name, x, y)
local entity = self:createEntity("mark", nil, x, y, { name = name })
if entity then entity.programmatic = true end
return entity
end
function Room:getCellFromMousePos(mouseX, mouseY)
if gameWorld and gameWorld.levels and gameWorld.levels[self.name] == self and
not gameWorld.drawingRoomNeighborhood then
@ -960,17 +1016,21 @@ function Room:attemptDeleteAtCell(cell, color)
table.remove(self.wiredSwitches, index)
end
end
-- NPCs are stamped into every collision layer, but are kept in their own
-- registry rather than in self.collideables. Remove them directly so a
-- right-click works no matter which editor colour is currently selected.
for index = #self.npcs, 1, -1 do
if self.npcs[index]:occupiesCell(cell) then table.remove(self.npcs, index) end
end
-- An NPC is a controller plus per-colour copies spread across rooms; deleting
-- one means tearing down all of them (Walker:destroy), regardless of the
-- currently selected editor colour.
local npc = self:npcAtCell(cell)
if npc and npc.destroy then npc:destroy() end
-- Notes are non-collidable and colourless, so a right-click removes them
-- regardless of the currently selected editor colour.
for index = #self.notes, 1, -1 do
if self.notes[index]:occupiesCell(cell) then table.remove(self.notes, index) end
end
-- Marks behave like notes for deletion: colourless, passable, removed on any
-- selected colour.
for index = #self.marks, 1, -1 do
if self.marks[index]:occupiesCell(cell) then table.remove(self.marks, index) end
end
self:tick()
end
@ -991,6 +1051,17 @@ function Room:attemptPlaceAtCell(name, props, cell, color, text)
self:tick()
return
end
-- Marks are colourless, single-instance named destinations. One mark per cell:
-- drop any mark already here before adding the new one. `text` carries the name.
if name == "mark" then
if not cell then return end
for index = #self.marks, 1, -1 do
if self.marks[index]:occupiesCell(cell) then table.remove(self.marks, index) end
end
local mark = self:createEntity("mark", nil, cell.x, cell.y, { name = text })
self:tick()
return mark
end
if not props or not props.sprites then return end
if not cell then return end
-- Jorge is one dialogable NPC with three display channels, not three
@ -1155,6 +1226,14 @@ function Room:serialize(includePlayers)
table.insert(objects, { class = "note", x = note.x, y = note.y, text = note.text })
end
-- Programmatic marks are recreated from code on every load, so persisting them
-- would duplicate them. Only authored (editor-placed) marks are written.
for _, mark in ipairs(self.marks) do
if not mark.programmatic then
table.insert(objects, { class = "mark", x = mark.x, y = mark.y, name = mark.markName })
end
end
return { objects = objects, wires = self:serializeWires() }
end