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 = (function() local m = loadSprite("art/npc/start_sign.png") -- one master, tinted per gun at draw time return { red = m, green = m, blue = m } end)(), }) 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 = (function() local m = loadSprite("art/npc/jorge.png") -- one master, tinted per gun at draw time return { red = m, green = m, blue = m } end)(), }) self:initWalker() end