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

View file

@ -2,6 +2,7 @@ require "level_editor/object_attributes"
require "level_editor/gui"
require "level_editor/layout"
require "level_editor/note_editor"
require "mark" -- Mark.icon() supplies the MARK tool button art
editorView = "room" --can be "off", "room", "world", "asset"
selectedColor = "white"
@ -68,6 +69,14 @@ local boxPaletteSections = {}
local noteMode = nil
local editingNote = nil
-- Mark authoring is entirely independent of notes (they only share the debug
-- overlay toggle). `markPlacing` is true after the MARK tool is picked, until a
-- grid cell is clicked to drop one; naming then happens in a small screen-space
-- modal (`markNameMode` = "create" | "edit"), with `editingMark` the target.
local markPlacing = false
local markNameMode = nil
local editingMark = nil
-- Convert a pointer position into a valid room cell. Keeping brush state in
-- cell coordinates means a click that jitters within a tile cannot place or
-- erase that tile more than once.
@ -325,7 +334,7 @@ local function layoutRoomControls(c, s)
placeRow({ "white", "red", "green", "blue" }, 22)
placeRow({ "quick_player", "quick_static_wall", "quick_switch", "quick_door", "quick_wired_switch", "quick_wired_door", "quick_box" }, 22)
placeRow({ "save", "clearSaves", "world", "wire", "note" }, 28)
placeRow({ "save", "clearSaves", "world", "wire", "note", "mark" }, 28)
end
-- Build the full room-view layout (sections + a scroll list + a back/search
@ -761,6 +770,16 @@ function _initLevelEditor()
y = 0,
}, "note")
_addUIButton(createUIElement{
name = "mark",
sprite = Mark.icon(),
onClic = { left = function()
if editorView == "room" then beginMarkCreate() end
end },
x = 0,
y = 0,
}, "mark")
roomNameField = createUIElement{
name = "roomName",
text = editorRoomName,
@ -821,7 +840,8 @@ end
function selectAsset(asset)
print("selecting", asset)
selectedAsset = asset
exitNoteMode() -- picking any real asset closes the note text field
exitNoteMode() -- picking any real asset closes the note text field
markPlacing = false -- ...and disarms a pending mark placement
print(selectedAsset)
end
@ -836,6 +856,7 @@ end
-- clicks a grid cell to drop the note there (that placement is the "done").
function beginNoteCreate()
if editorView ~= "room" then return end
markPlacing = false
boxPaletteOpen = false
noteMode = "create"
editingNote = nil
@ -865,6 +886,216 @@ function cancelNoteMode()
exitNoteMode()
end
-- ---- Marks -------------------------------------------------------------------
-- Mark names must be globally unique (an NPC is sent to one by name). Keep the
-- typed name if free, otherwise suffix _2, _3, ... `exclude` is the mark being
-- renamed, so editing a mark without changing its name is a no-op.
local function uniqueMarkName(name, exclude)
if name == "" then return name end
local function taken(candidate)
if gameWorld and gameWorld.markByName then
local found = gameWorld:markByName(candidate)
return found ~= nil and found ~= exclude
end
for _, mk in ipairs(currentRoom.marks or {}) do
if mk ~= exclude and mk.markName == candidate then return true end
end
return false
end
if not taken(name) then return name end
local i = 2
while taken(name .. "_" .. i) do i = i + 1 end
return name .. "_" .. i
end
function markNameEditorActive() return markNameMode ~= nil end
-- Open the screen-space name modal for `mark`; `mode` is "create" (a just-placed
-- mark still needing a name) or "edit" (renaming an existing one).
local function openMarkNameEditor(mark, mode)
exitNoteMode()
markNameMode = mode
editingMark = mark
activeField = "markName"
inputText = mark.markName or ""
response = inputText
end
local function resetMarkNameEditor()
markNameMode = nil
editingMark = nil
if activeField == "markName" then activeField = nil end
inputText = ""
response = ""
end
function commitMarkName()
if editingMark then editingMark.markName = uniqueMarkName(inputText, editingMark) end
resetMarkNameEditor()
end
function cancelMarkName()
-- Abandoning a brand-new, still-unnamed mark removes it (it can't be sent to).
if markNameMode == "create" and editingMark and editingMark.markName == "" then
for i = #currentRoom.marks, 1, -1 do
if currentRoom.marks[i] == editingMark then table.remove(currentRoom.marks, i) end
end
end
resetMarkNameEditor()
end
-- The MARK tool: arm placement. The next grid click drops a mark and opens the
-- name modal on it.
function beginMarkCreate()
if editorView ~= "room" then return end
exitNoteMode()
boxPaletteOpen = false
markPlacing = true
editorStatus = "Click a cell to place a mark."
end
-- Left-clicking an existing mark reopens its name modal.
function beginMarkEdit(mark)
openMarkNameEditor(mark, "edit")
end
-- Debug destination picker. Left-clicking an NPC in the room view opens a
-- filterable list of every Mark name; picking one sends that NPC there via its
-- Walker (Npc:sendTo). Lets you exercise NPC pathfinding without an Ink story.
-- Drawn over the play area in screen space (see editorDrawMarkPicker), so its
-- geometry uses window coordinates, not the sidebar's virtual space.
local markPicker = nil -- { npc = npc } while open
function markPickerActive() return markPicker ~= nil end
local function markNamesMatching(filter)
local seen, names = {}, {}
local rooms = (gameWorld and gameWorld.levels) or { currentRoom }
for _, room in pairs(rooms) do
for _, mk in ipairs(room.marks or {}) do
if mk.markName ~= "" and not seen[mk.markName] and fuzzyMatches(mk.markName, filter) then
seen[mk.markName] = true
table.insert(names, mk.markName)
end
end
end
table.sort(names)
return names
end
local function openMarkPicker(npc)
markPicker = { npc = npc }
activeField = "markFilter"
inputText = ""
response = ""
end
function closeMarkPicker()
markPicker = nil
if activeField == "markFilter" then activeField = nil end
inputText = ""
response = ""
end
local function sendToMark(name)
if markPicker and markPicker.npc and markPicker.npc.sendTo then
markPicker.npc:sendTo(name)
editorStatus = "Sent NPC to '" .. name .. "'."
end
closeMarkPicker()
end
-- Panel + hit-tested rows, computed identically for draw and click, in screen
-- coordinates centered over the play area (left of the sidebar).
local function markPickerLayout()
local names = markNamesMatching(inputText)
local rowH, headerH, w = 26, 44, 320
local h = headerH + math.max(1, #names) * rowH + 12
local x = math.max(10, math.floor((sideBarBox.x - w) / 2))
local y = math.max(10, math.floor((height - h) / 2))
local rows = {}
for i, name in ipairs(names) do
rows[i] = { name = name, x = x + 8, y = y + headerH + (i - 1) * rowH, w = w - 16, h = rowH - 2 }
end
return { x = x, y = y, w = w, h = h, headerH = headerH, rows = rows, names = names }
end
function editorDrawMarkPicker()
if not (DEBUG and markPicker) then return end
local L = markPickerLayout()
local prevFont = love.graphics.getFont()
love.graphics.setFont(font)
love.graphics.setColor(0, 0, 0, 0.92)
love.graphics.rectangle("fill", L.x, L.y, L.w, L.h)
love.graphics.setColor(0.4, 1, 0.9, 1)
love.graphics.rectangle("line", L.x, L.y, L.w, L.h)
love.graphics.printf("SEND NPC TO (type to filter, Enter = top, Esc cancels)", L.x + 8, L.y + 6, L.w - 16, "left")
love.graphics.setColor(1, 1, 1, 1)
love.graphics.printf("> " .. inputText, L.x + 8, L.y + 24, L.w - 16, "left")
if #L.names == 0 then
love.graphics.setColor(0.7, 0.7, 0.7, 1)
love.graphics.printf("(no marks — place some with the MARK tool)", L.x + 8, L.y + L.headerH + 4, L.w - 16, "left")
love.graphics.setFont(prevFont)
return
end
local mx, my = love.mouse.getPosition()
for _, r in ipairs(L.rows) do
local hover = mx >= r.x and mx <= r.x + r.w and my >= r.y and my <= r.y + r.h
love.graphics.setColor(0.15, hover and 0.5 or 0.15, hover and 0.45 or 0.15, hover and 0.6 or 0.4)
love.graphics.rectangle("fill", r.x, r.y, r.w, r.h)
love.graphics.setColor(1, 1, 1, 1)
love.graphics.printf(r.name, r.x + 6, r.y + 5, r.w - 12, "left")
end
love.graphics.setFont(prevFont)
end
-- Screen-space click while the picker is open: choose a row, or dismiss on an
-- outside click. Returns true when the click was consumed.
function markPickerMousepressed(x, y, button)
if not markPicker then return false end
local L = markPickerLayout()
if button == 1 then
for _, r in ipairs(L.rows) do
if x >= r.x and x <= r.x + r.w and y >= r.y and y <= r.y + r.h then
sendToMark(r.name)
return true
end
end
end
closeMarkPicker()
return true
end
-- The mark name modal, drawn over the play area in screen space. Independent of
-- the note editor; it just reads the shared `inputText` filled via editorText-
-- Handler. Target-red to match the mark icon.
function editorDrawMarkNameEditor()
if not (DEBUG and markNameMode) then return end
local w, h = 340, 92
local x = math.max(10, math.floor((sideBarBox.x - w) / 2))
local y = math.max(10, math.floor((height - h) / 2))
local prevFont = love.graphics.getFont()
love.graphics.setFont(font)
love.graphics.setColor(0, 0, 0, 0.92)
love.graphics.rectangle("fill", x, y, w, h)
love.graphics.setColor(1, 0.35, 0.25, 1)
love.graphics.rectangle("line", x, y, w, h)
local title = markNameMode == "edit" and "RENAME MARK" or "NAME THIS MARK"
love.graphics.printf(title .. " (Enter = ok, Esc cancels)", x + 10, y + 8, w - 20, "left")
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("line", x + 10, y + 34, w - 20, 30)
love.graphics.printf("> " .. inputText, x + 16, y + 42, w - 32, "left")
love.graphics.setFont(prevFont)
end
-- Any click while naming commits the current text (a click is a blur = save).
function markNameEditorMousepressed(_, _, _)
if not markNameMode then return false end
commitMarkName()
return true
end
function selectQuickAsset(asset)
selectAsset(asset)
boxPaletteOpen = asset == "box"
@ -1219,6 +1450,11 @@ local function openWorldRoom(cellX, cellY)
end
function editorMouseHandler(x, y, button)
-- The mark modals (destination picker, name entry) are drawn over the play
-- area in raw screen coords, so they get first refusal before any sidebar/grid
-- handling.
if markPicker and markPickerMousepressed(x, y, button) then return end
if markNameMode and markNameEditorMousepressed(x, y, button) then return end
if x >= sideBarBox.x and x < sideBarBox.x + sideBarBox.w then
-- map from real window coords into the sidebar's fixed virtual space
-- (same transform used to scale the sidebar canvas up in love.draw)
@ -1282,7 +1518,7 @@ function editorMouseHandler(x, y, button)
end
-- control cluster (only the buttons that live there)
for _, name in ipairs({ "roomName", "white", "red", "green", "blue", "quick_player", "quick_static_wall", "quick_switch", "quick_door", "quick_wired_switch", "quick_wired_door", "quick_box", "save", "clearSaves", "world", "wire", "note" }) do
for _, name in ipairs({ "roomName", "white", "red", "green", "blue", "quick_player", "quick_static_wall", "quick_switch", "quick_door", "quick_wired_switch", "quick_wired_door", "quick_box", "save", "clearSaves", "world", "wire", "note", "mark" }) do
local b = uiButtons[name]
if b and b:clicIsInBox(x, y) then
b:onClic(button)
@ -1337,11 +1573,22 @@ function editorMouseHandler(x, y, button)
exitNoteMode()
return
end
-- Left-clicking an existing note opens it for editing instead of
-- painting over it.
-- Placing a new mark: drop a blank one, then open its name modal.
if cell and markPlacing then
markPlacing = false
local mark = currentRoom:attemptPlaceAtCell("mark", nil, cell, nil, "")
if mark then openMarkNameEditor(mark, "create") end
return
end
-- Left-clicking an existing note/mark opens it for editing instead of
-- painting over it; a Walker NPC opens the debug destination picker.
if cell then
local note = currentRoom:noteAtCell(cell)
if note then beginNoteEdit(note); return end
local mark = currentRoom:markAtCell(cell)
if mark then beginMarkEdit(mark); return end
local npc = currentRoom:npcAtCell(cell)
if npc and npc.sendTo then openMarkPicker(npc); return end
end
beginRoomBrush(x, y, button)
elseif editorView == "wire" then
@ -1408,6 +1655,10 @@ function editorMouseReleased(_, _, button)
end
function editorTextHandler(text)
if markPicker or markNameMode then
inputText = text
return
end
if editorView == "asset" and activeField then
inputText = text
editingAssetButtons[activeField]:editText(text)
@ -1429,6 +1680,12 @@ function editorTextHandler(text)
end
function editorCompleteResponse()
if markPicker then
local names = markNamesMatching(inputText)
if names[1] then sendToMark(names[1]) else closeMarkPicker() end
return
end
if markNameMode then commitMarkName(); return end
if editorView == "asset" and activeField then
assetProperties[activeField] = inputText
elseif editorView == "room" and activeField == "roomName" then
@ -1454,6 +1711,9 @@ function editorCompleteResponse()
end
function editorCancelResponse()
if markPicker then closeMarkPicker(); return true end
if markNameMode then cancelMarkName(); return true end
if markPlacing then markPlacing = false; editorStatus = "Mark placement cancelled."; return true end
if editorView == "wire" then discardWireRooms(); return true end
if editorView ~= "world" or activeField ~= "cloneName" then return false end
activeField = nil
@ -1554,7 +1814,7 @@ function editorDraw()
-- control cluster
love.graphics.setColor(gColor.white:set())
love.graphics.printf("LEVEL NAME", s.controls.x, s.labelY, s.controls.w, "center")
for _, name in ipairs({ "roomName", "white", "red", "green", "blue", "quick_player", "quick_static_wall", "quick_switch", "quick_door", "quick_wired_switch", "quick_wired_door", "quick_box", "save", "clearSaves", "world", "wire", "note" }) do
for _, name in ipairs({ "roomName", "white", "red", "green", "blue", "quick_player", "quick_static_wall", "quick_switch", "quick_door", "quick_wired_switch", "quick_wired_door", "quick_box", "save", "clearSaves", "world", "wire", "note", "mark" }) do
local button = uiButtons[name]
if button then
button:draw()
@ -1563,7 +1823,9 @@ function editorDraw()
love.graphics.setColor(1, 1, 1, 1)
love.graphics.print("W", button.x + button.w - 7, button.y - 4)
end
if (asset and selectedAsset == asset) or (name == "note" and noteMode ~= nil) then
if (asset and selectedAsset == asset)
or (name == "note" and noteMode ~= nil)
or (name == "mark" and (markPlacing or markNameMode ~= nil)) then
love.graphics.setColor(1, 0.85, 0.2, 1)
love.graphics.setLineWidth(2)
love.graphics.rectangle("line", button.x - 1, button.y - 1, button.w + 2, button.h + 2)