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
214
npc.lua
214
npc.lua
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue