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
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue