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