- 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>
254 lines
9.1 KiB
Lua
254 lines
9.1 KiB
Lua
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
|
|
Entity.initialize(self, {
|
|
x = x, y = y,
|
|
color = "red", -- representative only; sprites are per-channel
|
|
collision = "unmoveable",
|
|
sprites = {
|
|
red = loadSprite("art/npc/start_sign_r.png"),
|
|
green = loadSprite("art/npc/start_sign_g.png"),
|
|
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
|
|
self.saveClass = "jorge"
|
|
Entity.initialize(self, {
|
|
x = x, y = y,
|
|
color = "red",
|
|
collision = "unmoveable",
|
|
sprites = {
|
|
red = loadSprite("art/npc/jorge_r.png"),
|
|
green = loadSprite("art/npc/jorge_g.png"),
|
|
blue = loadSprite("art/npc/jorge_b.png"),
|
|
},
|
|
})
|
|
self:initWalker()
|
|
end
|