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

@ -15,10 +15,10 @@ lua tests/test_movement.lua # run the pure matrix-movement tests
`love` is not installed in this environment — you cannot run the game here; reason about the code statically. `love` is not installed in this environment — you cannot run the game here; reason about the code statically.
**LÖVE version matters.** The code targets the **0.10.x** API, not 11.x. Signals of this that you must preserve when editing: **LÖVE version matters.** The code targets the **11.x** API — `conf.lua` pins `t.version = "11.5"`. Preserve these when editing:
- Colors are `0–255` integers (`love.graphics.setColor(255, 255, 255)`), not `0–1` floats. - Colors are normalized `0–1` floats (`love.graphics.setColor(1, 1, 1)`), not `0–255` integers — see `color.lua`.
- `sprite:getData()` / `img:getPixel` are used (see `sprite_manager.lua`); the 11.x replacements differ. - Pixel inspection uses the 11.x ImageData API: `love.image.newImageData(path)` + `ImageData:getPixel` (see `getCellsFromSprite` in `_helpers.lua`). `Image:getData()` was removed in 11 — don't reintroduce it.
Do not "modernize" these to the 11.x API unless explicitly asked — it would break the whole render/color path. Do not "downgrade" these to the 0.10.x API — it would break the whole render/color path.
## The core mechanic (read this before touching gameplay code) ## The core mechanic (read this before touching gameplay code)
@ -36,7 +36,7 @@ The player is not one entity — it is **up to three simultaneous colored copies
- `entity.lua` — `Entity` base class (middleclass). Everything on the grid subclasses it: `Player`, `Box`, `Wall`, `Switch`, `Door`, `Artifact`, `Art`, `Ass` (assorted decor), `npc`. Grid↔pixel conversion, occupied-cell shapes, and the `sha()` jitter all live here. - `entity.lua` — `Entity` base class (middleclass). Everything on the grid subclasses it: `Player`, `Box`, `Wall`, `Switch`, `Door`, `Artifact`, `Art`, `Ass` (assorted decor), `npc`. Grid↔pixel conversion, occupied-cell shapes, and the `sha()` jitter all live here.
- `room.lua` — the orchestrator: entity registries, per-color matrices, movement/push/pull, tick, switch/door logic, room transitions (`nextRoom`), and serialization. - `room.lua` — the orchestrator: entity registries, per-color matrices, movement/push/pull, tick, switch/door logic, room transitions (`nextRoom`), and serialization.
- `color.lua` — `Color` class + the canonical RGB(W) table. `gColor` globals are created in `main._initGlobalColors`. - `color.lua` — `Color` class + the canonical RGB(W) table. `gColor` globals are created in `main._initGlobalColors`.
- `sprite_manager.lua` — loads all images into the `sprites` global and precomputes `spriteCells` (which 16×16 cells of a sprite are non-empty, for multi-cell collision shapes). Sprites are `16px`-per-cell. - Sprite loading: `loadSprite(path)` in `_helpers.lua` returns a cached `Image`. Per-asset occupied-cell shapes (which 16×16 cells are non-empty, for multi-cell collision) are precomputed by `getCellsFromSprite` (`_helpers.lua`) and gathered into `globalAssetProperties` by `getAllAssets` (`level_editor/object_attributes.lua`). Sprites are `16px`-per-cell. (An older `sprite_manager.lua` that populated `sprites`/`spriteCells` globals is gone; the stray `sprites.*` / `spriteCells[...]` references in stub files like `artifact.lua` / `assorted.lua` are vestigial.)
- `input.lua`, `shaders.lua` (`wiggle`, `glow` GLSL), `_helpers.lua` (`lerp`/`map`/`constrain`), `_debug_helpers.lua` (`printMatrix`, `shallowTablePrint`, `randomPosition`). - `input.lua`, `shaders.lua` (`wiggle`, `glow` GLSL), `_helpers.lua` (`lerp`/`map`/`constrain`), `_debug_helpers.lua` (`printMatrix`, `shallowTablePrint`, `randomPosition`).
- `dialogue_manager.lua` is currently empty; `npc.lua`/`art.lua`/`artifact.lua` are stubs or reference art folders not present in `art/`. - `dialogue_manager.lua` is currently empty; `npc.lua`/`art.lua`/`artifact.lua` are stubs or reference art folders not present in `art/`.

View file

@ -8,8 +8,20 @@ Dialogable = {}
function Dialogable:onBump() function Dialogable:onBump()
if self.book then if self.book then
local wasOpen = Dialogue.isOpen() local wasOpen = Dialogue.isOpen()
Dialogue.open(self.book) Dialogue.open(self.book, self:dialogueBindings())
if Sound and not wasOpen then Sound.jabber() end if Sound and not wasOpen then Sound.jabber() end
end end
return true return true
end end
-- Ink external functions exposed to this talker's book. A walking NPC (one that
-- includes Walker) can be sent to a named Mark with `~ walk_to("mark_name")`,
-- e.g. to leave after a conversation.
function Dialogable:dialogueBindings()
local talker = self
return {
walk_to = function(markName)
if talker.sendTo then talker:sendTo(markName) end
end,
}
end

View file

@ -26,9 +26,15 @@ local function advance()
selected = 1 selected = 1
end end
function Dialogue.open(book) -- `bindings` (optional) is a {name = function} table wired in as Ink external
-- functions before the story starts, so a book can call e.g. `~ walk_to("gate")`
-- to drive game state. Dialogable supplies these per talker (see dialogable.lua).
function Dialogue.open(book, bindings)
if story then return end -- already talking: ignore repeat bumps if story then return end -- already talking: ignore repeat bumps
story = narrator.init_story(book) story = narrator.init_story(book)
if bindings then
for name, fn in pairs(bindings) do story:bind(name, fn) end
end
story:begin() story:begin()
advance() advance()
end end

View file

@ -38,11 +38,14 @@ In `main.lua`, next to `startSignBook`:
## 4. Make the entity ## 4. Make the entity
In `npc.lua`, copy `Sign` — pick your art and name your book: In `npc.lua`, copy `Sign` — pick your art and name your book. Include `Walker`
too (it's what lets the NPC path to a Mark; see "Making an NPC walk" below), and
call `self:initWalker()` after `Entity.initialize`:
```lua ```lua
Oracle = class("Oracle", Entity) Oracle = class("Oracle", Entity)
Oracle:include(Dialogable) Oracle:include(Dialogable)
Oracle:include(Walker)
function Oracle:initialize(x, y) function Oracle:initialize(x, y)
self.book = oracleBook -- from step 3 self.book = oracleBook -- from step 3
@ -55,6 +58,7 @@ function Oracle:initialize(x, y)
blue = loadSprite("art/npc/oracle_b.png"), blue = loadSprite("art/npc/oracle_b.png"),
}, },
}) })
self:initWalker()
end end
``` ```
@ -82,3 +86,44 @@ Then add it to a room's `rooms/<room>.sav` `objects` list:
That's it — walk into it and it talks. NPCs are stamped into every colour's That's it — walk into it and it talks. NPCs are stamped into every colour's
collision matrix, so they block movement and obey the world's colour rules (a collision matrix, so they block movement and obey the world's colour rules (a
red-only player reads them in red). red-only player reads them in red).
# Marks — named destinations
A **Mark** is a debug non-collidable: an editor-only annotation the game reads
back. It carries a globally-unique name, and it's the only thing an NPC can be
sent to. It is *not* a Note — it just shares the debug overlay toggle. In the
editor, click the **target** tool at the bottom of the panel, then click a cell
to drop one and type its name (colliding names auto-suffix `_2`, `_3`, …).
Left-click an existing mark to rename it; right-click to delete. Marks show only
under `DEBUG` while the overlay is on (`Tab`).
You can also create marks from code so they exist without living in a `.sav`:
```lua
room:addMark("gate", 5, 9) -- registers + renders in debug; skipped by serialize
```
# Making an NPC walk
Any NPC that includes `Walker` (see step 4) can be sent to a Mark. From Ink, call
the bound external function — e.g. to leave once the conversation ends:
```ink
Oracle: The way is open now. Go.
~ walk_to("gate")
-> END
```
`walk_to(name)` is wired for every Dialogable (`dialogable.lua`); it resolves the
Mark by name and calls `npc:sendTo`. Under the hood the NPC becomes the player's
RGB model — up to three colour copies, each running its **own** A* on its own
collision layer (`pathfinding.lua`), stepping in real time and pathing across
room seams over the world grid. Copies split apart and additively re-merge to
white on arrival. Pathfinding is footprint-aware, so a 2×2 NPC routes its whole
body around obstacles rather than threading a 1-wide gap.
## Testing without a story
You don't need an Ink book to try it: in the editor (`DEBUG`), **left-click an
NPC** to open the destination picker — a filterable list of every Mark. Type to
filter, click a row (or Enter for the top match) to send it; Esc cancels.

View file

@ -2,6 +2,7 @@ require "level_editor/object_attributes"
require "level_editor/gui" require "level_editor/gui"
require "level_editor/layout" require "level_editor/layout"
require "level_editor/note_editor" require "level_editor/note_editor"
require "mark" -- Mark.icon() supplies the MARK tool button art
editorView = "room" --can be "off", "room", "world", "asset" editorView = "room" --can be "off", "room", "world", "asset"
selectedColor = "white" selectedColor = "white"
@ -68,6 +69,14 @@ local boxPaletteSections = {}
local noteMode = nil local noteMode = nil
local editingNote = 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 -- 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 -- cell coordinates means a click that jitters within a tile cannot place or
-- erase that tile more than once. -- erase that tile more than once.
@ -325,7 +334,7 @@ local function layoutRoomControls(c, s)
placeRow({ "white", "red", "green", "blue" }, 22) 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({ "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 end
-- Build the full room-view layout (sections + a scroll list + a back/search -- Build the full room-view layout (sections + a scroll list + a back/search
@ -761,6 +770,16 @@ function _initLevelEditor()
y = 0, y = 0,
}, "note") }, "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{ roomNameField = createUIElement{
name = "roomName", name = "roomName",
text = editorRoomName, text = editorRoomName,
@ -822,6 +841,7 @@ function selectAsset(asset)
print("selecting", asset) print("selecting", asset)
selectedAsset = 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) print(selectedAsset)
end end
@ -836,6 +856,7 @@ end
-- clicks a grid cell to drop the note there (that placement is the "done"). -- clicks a grid cell to drop the note there (that placement is the "done").
function beginNoteCreate() function beginNoteCreate()
if editorView ~= "room" then return end if editorView ~= "room" then return end
markPlacing = false
boxPaletteOpen = false boxPaletteOpen = false
noteMode = "create" noteMode = "create"
editingNote = nil editingNote = nil
@ -865,6 +886,216 @@ function cancelNoteMode()
exitNoteMode() exitNoteMode()
end 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) function selectQuickAsset(asset)
selectAsset(asset) selectAsset(asset)
boxPaletteOpen = asset == "box" boxPaletteOpen = asset == "box"
@ -1219,6 +1450,11 @@ local function openWorldRoom(cellX, cellY)
end end
function editorMouseHandler(x, y, button) 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 if x >= sideBarBox.x and x < sideBarBox.x + sideBarBox.w then
-- map from real window coords into the sidebar's fixed virtual space -- map from real window coords into the sidebar's fixed virtual space
-- (same transform used to scale the sidebar canvas up in love.draw) -- (same transform used to scale the sidebar canvas up in love.draw)
@ -1282,7 +1518,7 @@ function editorMouseHandler(x, y, button)
end end
-- control cluster (only the buttons that live there) -- 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] local b = uiButtons[name]
if b and b:clicIsInBox(x, y) then if b and b:clicIsInBox(x, y) then
b:onClic(button) b:onClic(button)
@ -1337,11 +1573,22 @@ function editorMouseHandler(x, y, button)
exitNoteMode() exitNoteMode()
return return
end end
-- Left-clicking an existing note opens it for editing instead of -- Placing a new mark: drop a blank one, then open its name modal.
-- painting over it. 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 if cell then
local note = currentRoom:noteAtCell(cell) local note = currentRoom:noteAtCell(cell)
if note then beginNoteEdit(note); return end 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 end
beginRoomBrush(x, y, button) beginRoomBrush(x, y, button)
elseif editorView == "wire" then elseif editorView == "wire" then
@ -1408,6 +1655,10 @@ function editorMouseReleased(_, _, button)
end end
function editorTextHandler(text) function editorTextHandler(text)
if markPicker or markNameMode then
inputText = text
return
end
if editorView == "asset" and activeField then if editorView == "asset" and activeField then
inputText = text inputText = text
editingAssetButtons[activeField]:editText(text) editingAssetButtons[activeField]:editText(text)
@ -1429,6 +1680,12 @@ function editorTextHandler(text)
end end
function editorCompleteResponse() 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 if editorView == "asset" and activeField then
assetProperties[activeField] = inputText assetProperties[activeField] = inputText
elseif editorView == "room" and activeField == "roomName" then elseif editorView == "room" and activeField == "roomName" then
@ -1454,6 +1711,9 @@ function editorCompleteResponse()
end end
function editorCancelResponse() 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 == "wire" then discardWireRooms(); return true end
if editorView ~= "world" or activeField ~= "cloneName" then return false end if editorView ~= "world" or activeField ~= "cloneName" then return false end
activeField = nil activeField = nil
@ -1554,7 +1814,7 @@ function editorDraw()
-- control cluster -- control cluster
love.graphics.setColor(gColor.white:set()) love.graphics.setColor(gColor.white:set())
love.graphics.printf("LEVEL NAME", s.controls.x, s.labelY, s.controls.w, "center") 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] local button = uiButtons[name]
if button then if button then
button:draw() button:draw()
@ -1563,7 +1823,9 @@ function editorDraw()
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
love.graphics.print("W", button.x + button.w - 7, button.y - 4) love.graphics.print("W", button.x + button.w - 7, button.y - 4)
end 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.setColor(1, 0.85, 0.2, 1)
love.graphics.setLineWidth(2) love.graphics.setLineWidth(2)
love.graphics.rectangle("line", button.x - 1, button.y - 1, button.w + 2, button.h + 2) love.graphics.rectangle("line", button.x - 1, button.y - 1, button.w + 2, button.h + 2)

View file

@ -100,6 +100,7 @@ function love.update(dt)
if repeatedMove and not Dialogue.isOpen() and not MapManager:isOpen() then gameWorld:movePlayer(currentRoom, repeatedMove) end if repeatedMove and not Dialogue.isOpen() and not MapManager:isOpen() then gameWorld:movePlayer(currentRoom, repeatedMove) end
end end
currentRoom:update(dt) currentRoom:update(dt)
if gameWorld then gameWorld:update(dt) end
MapManager:record(currentRoom) MapManager:record(currentRoom)
-- if love.keyboard.isDown("w") then worldSha = worldSha + 1 end -- if love.keyboard.isDown("w") then worldSha = worldSha + 1 end
@ -174,6 +175,8 @@ function love.draw()
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
if DEBUG then love.graphics.draw(sideBarCanvas, sideBarBox.x, sideBarBox.y) end if DEBUG then love.graphics.draw(sideBarCanvas, sideBarBox.x, sideBarBox.y) end
if DEBUG then editorDrawNoteHover() end if DEBUG then editorDrawNoteHover() end
if DEBUG then editorDrawMarkPicker() end
if DEBUG then editorDrawMarkNameEditor() end
love.graphics.print("Current FPS: "..tostring(love.timer.getFPS( )), 10, 10) love.graphics.print("Current FPS: "..tostring(love.timer.getFPS( )), 10, 10)
PerfProfiler:draw() PerfProfiler:draw()
Dialogue.draw() Dialogue.draw()
@ -229,6 +232,24 @@ function love.keypressed(key)
return return
end end
-- The mark modals (destination picker, name entry) own the keyboard while open:
-- characters filter/type via love.textinput, Enter confirms, Esc cancels.
if DEBUG and (markPickerActive() or markNameEditorActive()) then
if key == "escape" then
editorCancelResponse()
elseif key == "return" then
response = ""
editorCompleteResponse()
elseif key == "backspace" then
local byteoffset = utf8.offset(response, -1)
if byteoffset then
response = string.sub(response, 1, byteoffset - 1)
editorTextHandler(response)
end
end
return
end
-- Tab toggles the editor's note overlay (DEBUG-only; default on). -- Tab toggles the editor's note overlay (DEBUG-only; default on).
if DEBUG and key == "tab" then if DEBUG and key == "tab" then
NOTES_VISIBLE = not NOTES_VISIBLE NOTES_VISIBLE = not NOTES_VISIBLE

78
mark.lua Normal file
View file

@ -0,0 +1,78 @@
require "entity"
-- A named destination on the grid. NPCs are sent to a Mark by name (see
-- Npc:sendTo / World:markByName), so the name is the whole point of the object.
--
-- A Mark is a *debug non-collidable*: an editor-only annotation that everything
-- passes through, shown only while the debug overlay is on. It is NOT a Note and
-- shares no code with one — the two just happen to be siblings in that category.
-- Its icon is a target, drawn procedurally so it needs no art asset.
Mark = class("Mark", Entity)
-- Entity only ever calls :getWidth/:getHeight on the sizing sprite (Mark:draw
-- paints a target instead of blitting it), so a lightweight stub is enough and
-- keeps Mark free of any borrowed artwork.
local sizeStub = { getWidth = function() return 16 end, getHeight = function() return 16 end }
-- The target is pixel art: a 16x16 bullseye (two rings + a centre dot) baked once
-- into a list of on-pixels, drawn as blocky rectangles so it stays crisp at the
-- game's nearest-neighbour scale instead of a smooth vector circle.
local GRID = 16
local targetPixels = {}
do
local c = (GRID - 1) / 2 -- 7.5, the sub-pixel centre
for row = 0, GRID - 1 do
for col = 0, GRID - 1 do
local dist = math.sqrt((col - c) ^ 2 + (row - c) ^ 2)
local on = math.abs(dist - 6.5) < 0.7 -- outer ring
or math.abs(dist - 3.5) < 0.7 -- inner ring
or dist < 1.3 -- centre dot
if on then targetPixels[#targetPixels + 1] = { col, row } end
end
end
end
function Mark:initialize(x, y, name)
self.markName = name or ""
self.saveClass = "mark"
-- programmatic marks are created from code on every room load, so they are
-- skipped by Room:serialize (see there) to avoid duplicating on disk.
self.programmatic = false
Entity.initialize(self, {
x = x, y = y,
color = "white", -- representative only; Mark:draw is channel-agnostic
collision = "none",
sprite = sizeStub,
})
end
-- Drawn once (not per-channel) from the room's debug overlay pass, so it takes no
-- channel argument. A faint pixel-art red target with its name beside it, kept at
-- the same low opacity as a Note so it reads as an annotation, not a game object.
function Mark:draw()
local p = self:getDrawPos()
love.graphics.setColor(1, 0.3, 0.25, 0.85)
for _, px in ipairs(targetPixels) do
love.graphics.rectangle("fill", p.x + px[1] * drawScale, p.y + px[2] * drawScale, drawScale, drawScale)
end
if self.markName ~= "" then
love.graphics.setColor(1, 0.5, 0.42, 0.85)
love.graphics.print(self.markName, p.x, p.y - 12)
end
love.graphics.setColor(1, 1, 1, 1)
end
-- The same pixel-art target baked into a 16x16 Image, for the editor toolbar
-- button (full opacity there — it's a control, not a faint overlay). Cached, and
-- built lazily so it only touches love.graphics once the editor needs it.
function Mark.static.icon()
if Mark.static._icon then return Mark.static._icon end
local data = love.image.newImageData(GRID, GRID)
for _, px in ipairs(targetPixels) do
data:setPixel(px[1], px[2], 1, 0.3, 0.25, 1)
end
local img = love.graphics.newImage(data)
img:setFilter("nearest", "nearest")
Mark.static._icon = img
return img
end

214
npc.lua
View file

@ -1,11 +1,222 @@
require "entity" require "entity"
require "dialogable" 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 -- 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 -- 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. -- the first. It just picks art + a story — the talking lives in the mixin.
Sign = class("Sign", Entity) Sign = class("Sign", Entity)
Sign:include(Dialogable) Sign:include(Dialogable)
Sign:include(Walker)
function Sign:initialize(x, y) function Sign:initialize(x, y)
self.book = startSignBook self.book = startSignBook
@ -19,10 +230,12 @@ function Sign:initialize(x, y)
blue = loadSprite("art/npc/start_sign_b.png"), blue = loadSprite("art/npc/start_sign_b.png"),
}, },
}) })
self:initWalker()
end end
Jorge = class("Jorge", Entity) Jorge = class("Jorge", Entity)
Jorge:include(Dialogable) Jorge:include(Dialogable)
Jorge:include(Walker)
function Jorge:initialize(x, y) function Jorge:initialize(x, y)
self.book = jorgeBook self.book = jorgeBook
@ -37,4 +250,5 @@ function Jorge:initialize(x, y)
blue = loadSprite("art/npc/jorge_b.png"), blue = loadSprite("art/npc/jorge_b.png"),
}, },
}) })
self:initWalker()
end end

125
pathfinding.lua Normal file
View file

@ -0,0 +1,125 @@
-- Grid A* over the world collidable grid, in WORLD coordinates, one color at a
-- time. NPC color copies each call this on their own channel (see Npc:sendTo),
-- so a red copy routes around only red obstacles — "red can only be hit by red".
--
-- The world grid is continuous across rooms (World:worldCell / World:roomCell),
-- so a route can cross a room seam without any special casing.
local Pathfinding = {}
local directions = {
{x = 1, y = 0}, {x = -1, y = 0}, {x = 0, y = 1}, {x = 0, y = -1},
}
local singleCell = {{x = 1, y = 1}}
local function key(cell) return cell.x .. ":" .. cell.y end
local function heuristic(a, b) return math.abs(a.x - b.x) + math.abs(a.y - b.y) end
-- Is one world cell free for `color`? Free means on the map and its collidable
-- slot is empty, holds the moving entity itself, or holds a passable object
-- (marks, notes). Everything else — walls, boxes, doors, other NPC copies on this
-- channel — blocks.
local function cellFree(cell, color, ignoreEntity)
if not gameWorld then return false end
if not gameWorld:roomCell(cell) then return false end
-- Player copies are solid to a walker (it can't push them), so they block.
local players = gameWorld.playerCells and gameWorld.playerCells[color]
if players and players[cell.x .. ":" .. cell.y] then return false end
local row = gameWorld.collidableMatrices[color] and gameWorld.collidableMatrices[color][cell.y]
local entity = row and row[cell.x]
if not entity then return true end
if entity == ignoreEntity then return true end
if entity.canPassOver and entity:canPassOver() then return true end
return false
end
-- An anchor cell is walkable only when EVERY cell of the entity's footprint is
-- free there. `shape` is an entity cellShape (1-based offsets); a nil shape means
-- a single 1x1 cell. This is what stops a 2x2 NPC squeezing through a 1-wide gap
-- as if it were its top-left cell alone.
local function walkable(anchor, color, ignoreEntity, shape)
shape = shape or singleCell
for _, offset in ipairs(shape) do
local cell = { x = anchor.x + offset.x - 1, y = anchor.y + offset.y - 1 }
if not cellFree(cell, color, ignoreEntity) then return false end
end
return true
end
Pathfinding.walkable = walkable
-- Human-readable reason an anchor is not walkable (for diagnostics), or nil if it
-- is free. Reports the first offending footprint cell.
function Pathfinding.blockReason(anchor, color, ignoreEntity, shape)
shape = shape or singleCell
if not gameWorld then return "no gameWorld" end
for _, offset in ipairs(shape) do
local cell = { x = anchor.x + offset.x - 1, y = anchor.y + offset.y - 1 }
if not gameWorld:roomCell(cell) then return ("offmap@%d,%d"):format(cell.x, cell.y) end
local players = gameWorld.playerCells and gameWorld.playerCells[color]
if players and players[cell.x .. ":" .. cell.y] then return ("player@%d,%d"):format(cell.x, cell.y) end
local row = gameWorld.collidableMatrices[color] and gameWorld.collidableMatrices[color][cell.y]
local e = row and row[cell.x]
if e and e ~= ignoreEntity and not (e.canPassOver and e:canPassOver()) then
local what = e.getClass and e:getClass() or "obstacle"
return ("%s@%d,%d"):format(what, cell.x, cell.y)
end
end
return nil
end
-- Returns a list of anchor world cells from just-after `start` through `goal`
-- inclusive, or nil if no route exists. `shape` is the entity's cellShape, so
-- the whole footprint must fit at every step and at the goal.
function Pathfinding.route(start, goal, color, ignoreEntity, shape)
if not gameWorld then return nil end
if start.x == goal.x and start.y == goal.y then return {} end
if not walkable(goal, color, ignoreEntity, shape) then return nil end
local open = { start } -- frontier as a plain list
local cameFrom = {}
local gScore = { [key(start)] = 0 }
local inOpen = { [key(start)] = true }
while #open > 0 do
-- pick the open node with the lowest f = g + h (small grids: linear scan)
local bestIndex, best = 1, open[1]
local bestF = gScore[key(best)] + heuristic(best, goal)
for i = 2, #open do
local node = open[i]
local f = gScore[key(node)] + heuristic(node, goal)
if f < bestF then bestIndex, best, bestF = i, node, f end
end
table.remove(open, bestIndex)
inOpen[key(best)] = nil
if best.x == goal.x and best.y == goal.y then
local path, node = {}, best
while cameFrom[key(node)] do
table.insert(path, 1, node)
node = cameFrom[key(node)]
end
return path
end
for _, dir in ipairs(directions) do
local neighbor = { x = best.x + dir.x, y = best.y + dir.y }
if walkable(neighbor, color, ignoreEntity, shape) then
local tentative = gScore[key(best)] + 1
local nk = key(neighbor)
if not gScore[nk] or tentative < gScore[nk] then
cameFrom[nk] = best
gScore[nk] = tentative
if not inOpen[nk] then
table.insert(open, neighbor)
inOpen[nk] = true
end
end
end
end
end
return nil
end
return Pathfinding

107
room.lua
View file

@ -18,6 +18,7 @@ require "generic_spiky"
require "generic_unmoveable" require "generic_unmoveable"
require "generic_player_blocker" require "generic_player_blocker"
require "note" require "note"
require "mark"
local Movement = require "movement" local Movement = require "movement"
local Wire = require "wire" local Wire = require "wire"
@ -32,7 +33,11 @@ function Room:initialize(t)
self.collideables = { red = {}, green = {}, blue = {} } self.collideables = { red = {}, green = {}, blue = {} }
self.playerBlockers = { red = {}, green = {}, blue = {} } self.playerBlockers = { red = {}, green = {}, blue = {} }
self.npcs = {} 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.notes = {}
self.marks = {}
self.activeColors = {red = false, green = false, blue = false} self.activeColors = {red = false, green = false, blue = false}
self.colorsPlayerHas = {} self.colorsPlayerHas = {}
@ -172,16 +177,21 @@ function Room:draw(drawChannels)
for _, blocker in pairs(self.playerBlockers[channel]) do blocker:draw(channel) end 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 pairs(self.switches[channel]) do switch:draw(channel) end
for _, switch in ipairs(self.wiredSwitches) 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 end
-- Notes are an editor-only overlay: shown only while the editor build is -- Debug non-collidables (notes, marks, …) are editor-only overlays: shown only
-- running (DEBUG) and the NOTES_VISIBLE toggle is on. Drawn once (not per -- under DEBUG while the overlay toggle is on. Drawn once (not per channel) in
-- channel) in alpha so the faint icon reads as itself instead of screen-adding. -- alpha so each icon reads as itself instead of screen-adding. Each kind draws
if DEBUG and NOTES_VISIBLE and #self.notes > 0 then -- itself in its own pass; they share the toggle, not any code.
if DEBUG and NOTES_VISIBLE then
local mode, alphaMode = love.graphics.getBlendMode() local mode, alphaMode = love.graphics.getBlendMode()
love.graphics.setBlendMode("alpha") love.graphics.setBlendMode("alpha")
for _, note in ipairs(self.notes) do note:draw() end 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) love.graphics.setBlendMode(mode, alphaMode)
end end
end end
@ -756,6 +766,11 @@ function Room:createEntity(class, color, x, y, data)
self:registerNote(entity) self:registerNote(entity)
return entity return entity
end 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 props = globalAssetProperties and globalAssetProperties[class]
local behavior = props and props.class or "immoveable" local behavior = props and props.class or "immoveable"
@ -868,9 +883,12 @@ function Room:registerPlayer(entityPointer, color)
end end
function Room:registerNpc(entity) function Room:registerNpc(entity)
-- npcs are dialogable entities: they carry .x/.y like any Entity, so -- The NPC entity is a controller kept here for identity / dialogue / serialize.
-- serialize's npc loop still round-trips them, and they get stamped into the -- Its visible, collidable presence is three per-colour copies (Walker) that
-- collision matrix (see createCollideablesMatrix) so a bump is a rejected move. -- 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) table.insert(self.npcs, entity)
end end
@ -890,6 +908,44 @@ function Room:noteAtCell(cell)
return nil return nil
end 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) function Room:getCellFromMousePos(mouseX, mouseY)
if gameWorld and gameWorld.levels and gameWorld.levels[self.name] == self and if gameWorld and gameWorld.levels and gameWorld.levels[self.name] == self and
not gameWorld.drawingRoomNeighborhood then not gameWorld.drawingRoomNeighborhood then
@ -960,17 +1016,21 @@ function Room:attemptDeleteAtCell(cell, color)
table.remove(self.wiredSwitches, index) table.remove(self.wiredSwitches, index)
end end
end end
-- NPCs are stamped into every collision layer, but are kept in their own -- An NPC is a controller plus per-colour copies spread across rooms; deleting
-- registry rather than in self.collideables. Remove them directly so a -- one means tearing down all of them (Walker:destroy), regardless of the
-- right-click works no matter which editor colour is currently selected. -- currently selected editor colour.
for index = #self.npcs, 1, -1 do local npc = self:npcAtCell(cell)
if self.npcs[index]:occupiesCell(cell) then table.remove(self.npcs, index) end if npc and npc.destroy then npc:destroy() end
end
-- Notes are non-collidable and colourless, so a right-click removes them -- Notes are non-collidable and colourless, so a right-click removes them
-- regardless of the currently selected editor colour. -- regardless of the currently selected editor colour.
for index = #self.notes, 1, -1 do for index = #self.notes, 1, -1 do
if self.notes[index]:occupiesCell(cell) then table.remove(self.notes, index) end if self.notes[index]:occupiesCell(cell) then table.remove(self.notes, index) end
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() self:tick()
end end
@ -991,6 +1051,17 @@ function Room:attemptPlaceAtCell(name, props, cell, color, text)
self:tick() self:tick()
return return
end 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 props or not props.sprites then return end
if not cell then return end if not cell then return end
-- Jorge is one dialogable NPC with three display channels, not three -- 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 }) table.insert(objects, { class = "note", x = note.x, y = note.y, text = note.text })
end 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() } return { objects = objects, wires = self:serializeWires() }
end end

888
rooms/room_16_17.sav Normal file
View file

@ -0,0 +1,888 @@
{
objects={
{
x=1,
class="static_wall",
y=1,
color="green"
},
{
x=1,
class="static_wall",
y=2,
color="green"
},
{
x=1,
class="static_wall",
y=3,
color="green"
},
{
x=1,
class="static_wall",
y=4,
color="green"
},
{
x=1,
class="static_wall",
y=1,
color="green"
},
{
x=2,
class="static_wall",
y=1,
color="green"
},
{
x=3,
class="static_wall",
y=1,
color="green"
},
{
x=4,
class="static_wall",
y=1,
color="green"
},
{
x=5,
class="static_wall",
y=1,
color="green"
},
{
x=6,
class="static_wall",
y=1,
color="green"
},
{
x=6,
class="static_wall",
y=1,
color="green"
},
{
x=7,
class="static_wall",
y=1,
color="green"
},
{
x=8,
class="static_wall",
y=1,
color="green"
},
{
x=9,
class="static_wall",
y=1,
color="green"
},
{
x=10,
class="static_wall",
y=1,
color="green"
},
{
x=11,
class="static_wall",
y=1,
color="green"
},
{
x=11,
class="static_wall",
y=2,
color="green"
},
{
x=11,
class="static_wall",
y=3,
color="green"
},
{
x=11,
class="static_wall",
y=4,
color="green"
},
{
x=11,
class="static_wall",
y=5,
color="green"
},
{
x=11,
class="static_wall",
y=4,
color="green"
},
{
x=11,
class="static_wall",
y=5,
color="green"
},
{
x=11,
class="static_wall",
y=6,
color="green"
},
{
x=11,
class="static_wall",
y=7,
color="green"
},
{
x=11,
class="static_wall",
y=8,
color="green"
},
{
x=11,
class="static_wall",
y=9,
color="green"
},
{
x=11,
class="static_wall",
y=10,
color="green"
},
{
x=11,
class="static_wall",
y=11,
color="green"
},
{
x=10,
class="static_wall",
y=11,
color="green"
},
{
x=9,
class="static_wall",
y=11,
color="green"
},
{
x=8,
class="static_wall",
y=11,
color="green"
},
{
x=7,
class="static_wall",
y=11,
color="green"
},
{
x=6,
class="static_wall",
y=11,
color="green"
},
{
x=5,
class="static_wall",
y=11,
color="green"
},
{
x=4,
class="static_wall",
y=11,
color="green"
},
{
x=1,
class="static_wall",
y=11,
color="green"
},
{
x=2,
class="static_wall",
y=11,
color="green"
},
{
x=3,
class="static_wall",
y=11,
color="green"
},
{
x=4,
class="static_wall",
y=11,
color="green"
},
{
x=6,
class="static_wall",
y=8,
color="green"
},
{
x=6,
class="static_wall",
y=7,
color="green"
},
{
x=6,
class="static_wall",
y=6,
color="green"
},
{
x=6,
class="static_wall",
y=5,
color="green"
},
{
x=6,
class="static_wall",
y=4,
color="green"
},
{
x=6,
class="static_wall",
y=4,
color="green"
},
{
x=7,
class="static_wall",
y=9,
color="green"
},
{
x=9,
class="static_wall",
y=7,
color="green"
},
{
x=10,
class="static_wall",
y=7,
color="green"
},
{
x=1,
class="static_wall",
y=1,
color="blue"
},
{
x=1,
class="static_wall",
y=2,
color="blue"
},
{
x=1,
class="static_wall",
y=3,
color="blue"
},
{
x=1,
class="static_wall",
y=4,
color="blue"
},
{
x=1,
class="static_wall",
y=1,
color="blue"
},
{
x=2,
class="static_wall",
y=1,
color="blue"
},
{
x=3,
class="static_wall",
y=1,
color="blue"
},
{
x=4,
class="static_wall",
y=1,
color="blue"
},
{
x=5,
class="static_wall",
y=1,
color="blue"
},
{
x=6,
class="static_wall",
y=1,
color="blue"
},
{
x=6,
class="static_wall",
y=1,
color="blue"
},
{
x=7,
class="static_wall",
y=1,
color="blue"
},
{
x=8,
class="static_wall",
y=1,
color="blue"
},
{
x=9,
class="static_wall",
y=1,
color="blue"
},
{
x=10,
class="static_wall",
y=1,
color="blue"
},
{
x=11,
class="static_wall",
y=1,
color="blue"
},
{
x=11,
class="static_wall",
y=2,
color="blue"
},
{
x=11,
class="static_wall",
y=3,
color="blue"
},
{
x=11,
class="static_wall",
y=4,
color="blue"
},
{
x=11,
class="static_wall",
y=5,
color="blue"
},
{
x=11,
class="static_wall",
y=4,
color="blue"
},
{
x=11,
class="static_wall",
y=5,
color="blue"
},
{
x=11,
class="static_wall",
y=6,
color="blue"
},
{
x=11,
class="static_wall",
y=7,
color="blue"
},
{
x=11,
class="static_wall",
y=8,
color="blue"
},
{
x=11,
class="static_wall",
y=9,
color="blue"
},
{
x=11,
class="static_wall",
y=10,
color="blue"
},
{
x=11,
class="static_wall",
y=11,
color="blue"
},
{
x=10,
class="static_wall",
y=11,
color="blue"
},
{
x=9,
class="static_wall",
y=11,
color="blue"
},
{
x=8,
class="static_wall",
y=11,
color="blue"
},
{
x=7,
class="static_wall",
y=11,
color="blue"
},
{
x=6,
class="static_wall",
y=11,
color="blue"
},
{
x=5,
class="static_wall",
y=11,
color="blue"
},
{
x=4,
class="static_wall",
y=11,
color="blue"
},
{
x=1,
class="static_wall",
y=11,
color="blue"
},
{
x=2,
class="static_wall",
y=11,
color="blue"
},
{
x=3,
class="static_wall",
y=11,
color="blue"
},
{
x=4,
class="static_wall",
y=11,
color="blue"
},
{
x=1,
class="static_wall",
y=8,
color="blue"
},
{
x=2,
class="static_wall",
y=8,
color="blue"
},
{
x=4,
class="static_wall",
y=7,
color="blue"
},
{
x=4,
class="static_wall",
y=6,
color="blue"
},
{
x=4,
class="static_wall",
y=6,
color="blue"
},
{
x=4,
class="static_wall",
y=5,
color="blue"
},
{
x=4,
class="static_wall",
y=4,
color="blue"
},
{
x=5,
class="static_wall",
y=8,
color="blue"
},
{
x=6,
class="static_wall",
y=8,
color="blue"
},
{
x=7,
class="static_wall",
y=8,
color="blue"
},
{
x=1,
class="static_wall",
y=1,
color="red"
},
{
x=1,
class="static_wall",
y=2,
color="red"
},
{
x=1,
class="static_wall",
y=1,
color="red"
},
{
x=1,
class="static_wall",
y=2,
color="red"
},
{
x=1,
class="static_wall",
y=3,
color="red"
},
{
x=1,
class="static_wall",
y=4,
color="red"
},
{
x=1,
class="static_wall",
y=1,
color="red"
},
{
x=2,
class="static_wall",
y=1,
color="red"
},
{
x=3,
class="static_wall",
y=1,
color="red"
},
{
x=4,
class="static_wall",
y=1,
color="red"
},
{
x=5,
class="static_wall",
y=1,
color="red"
},
{
x=6,
class="static_wall",
y=1,
color="red"
},
{
x=6,
class="static_wall",
y=1,
color="red"
},
{
x=7,
class="static_wall",
y=1,
color="red"
},
{
x=8,
class="static_wall",
y=1,
color="red"
},
{
x=9,
class="static_wall",
y=1,
color="red"
},
{
x=10,
class="static_wall",
y=1,
color="red"
},
{
x=11,
class="static_wall",
y=1,
color="red"
},
{
x=11,
class="static_wall",
y=2,
color="red"
},
{
x=11,
class="static_wall",
y=3,
color="red"
},
{
x=11,
class="static_wall",
y=4,
color="red"
},
{
x=11,
class="static_wall",
y=5,
color="red"
},
{
x=11,
class="static_wall",
y=4,
color="red"
},
{
x=11,
class="static_wall",
y=5,
color="red"
},
{
x=11,
class="static_wall",
y=6,
color="red"
},
{
x=11,
class="static_wall",
y=7,
color="red"
},
{
x=11,
class="static_wall",
y=8,
color="red"
},
{
x=11,
class="static_wall",
y=9,
color="red"
},
{
x=11,
class="static_wall",
y=10,
color="red"
},
{
x=11,
class="static_wall",
y=11,
color="red"
},
{
x=10,
class="static_wall",
y=11,
color="red"
},
{
x=9,
class="static_wall",
y=11,
color="red"
},
{
x=8,
class="static_wall",
y=11,
color="red"
},
{
x=7,
class="static_wall",
y=11,
color="red"
},
{
x=6,
class="static_wall",
y=11,
color="red"
},
{
x=5,
class="static_wall",
y=11,
color="red"
},
{
x=4,
class="static_wall",
y=11,
color="red"
},
{
x=1,
class="static_wall",
y=11,
color="red"
},
{
x=2,
class="static_wall",
y=11,
color="red"
},
{
x=3,
class="static_wall",
y=11,
color="red"
},
{
x=4,
class="static_wall",
y=11,
color="red"
},
{
x=4,
class="static_wall",
y=4,
color="red"
},
{
x=4,
class="static_wall",
y=5,
color="red"
},
{
x=5,
class="static_wall",
y=5,
color="red"
},
{
x=10,
class="static_wall",
y=10,
color="red"
},
{
x=9,
class="static_wall",
y=10,
color="red"
},
{
x=8,
class="static_wall",
y=9,
color="red"
},
{
x=7,
class="static_wall",
y=9,
color="red"
},
{
x=7,
class="static_wall",
y=8,
color="red"
},
{
x=7,
class="mark",
name="end",
y=6
}
},
wires={
}
}

917
rooms/testing_movement.sav Normal file
View file

@ -0,0 +1,917 @@
{
objects={
{
x=11,
class="static_wall",
y=7,
color="green"
},
{
x=11,
class="static_wall",
y=4,
color="green"
},
{
x=11,
class="static_wall",
y=3,
color="green"
},
{
x=11,
class="static_wall",
y=2,
color="green"
},
{
x=11,
class="static_wall",
y=1,
color="green"
},
{
x=10,
class="static_wall",
y=1,
color="green"
},
{
x=10,
class="static_wall",
y=1,
color="green"
},
{
x=9,
class="static_wall",
y=1,
color="green"
},
{
x=8,
class="static_wall",
y=1,
color="green"
},
{
x=7,
class="static_wall",
y=1,
color="green"
},
{
x=6,
class="static_wall",
y=1,
color="green"
},
{
x=5,
class="static_wall",
y=1,
color="green"
},
{
x=4,
class="static_wall",
y=1,
color="green"
},
{
x=4,
class="static_wall",
y=1,
color="green"
},
{
x=3,
class="static_wall",
y=1,
color="green"
},
{
x=2,
class="static_wall",
y=1,
color="green"
},
{
x=1,
class="static_wall",
y=1,
color="green"
},
{
x=1,
class="static_wall",
y=2,
color="green"
},
{
x=1,
class="static_wall",
y=3,
color="green"
},
{
x=1,
class="static_wall",
y=4,
color="green"
},
{
x=1,
class="static_wall",
y=5,
color="green"
},
{
x=1,
class="static_wall",
y=6,
color="green"
},
{
x=1,
class="static_wall",
y=7,
color="green"
},
{
x=1,
class="static_wall",
y=8,
color="green"
},
{
x=1,
class="static_wall",
y=9,
color="green"
},
{
x=1,
class="static_wall",
y=10,
color="green"
},
{
x=1,
class="static_wall",
y=11,
color="green"
},
{
x=2,
class="static_wall",
y=11,
color="green"
},
{
x=3,
class="static_wall",
y=11,
color="green"
},
{
x=4,
class="static_wall",
y=11,
color="green"
},
{
x=5,
class="static_wall",
y=11,
color="green"
},
{
x=6,
class="static_wall",
y=11,
color="green"
},
{
x=7,
class="static_wall",
y=11,
color="green"
},
{
x=8,
class="static_wall",
y=11,
color="green"
},
{
x=8,
class="static_wall",
y=11,
color="green"
},
{
x=9,
class="static_wall",
y=11,
color="green"
},
{
x=10,
class="static_wall",
y=11,
color="green"
},
{
x=11,
class="static_wall",
y=11,
color="green"
},
{
x=11,
class="static_wall",
y=10,
color="green"
},
{
x=11,
class="static_wall",
y=9,
color="green"
},
{
x=11,
class="static_wall",
y=8,
color="green"
},
{
x=11,
class="static_wall",
y=7,
color="green"
},
{
x=8,
class="static_wall",
y=2,
color="green"
},
{
x=8,
class="static_wall",
y=3,
color="green"
},
{
x=8,
class="static_wall",
y=4,
color="green"
},
{
x=8,
class="static_wall",
y=5,
color="green"
},
{
x=8,
class="static_wall",
y=8,
color="green"
},
{
x=8,
class="static_wall",
y=9,
color="green"
},
{
x=8,
class="static_wall",
y=10,
color="green"
},
{
x=8,
class="static_wall",
y=11,
color="green"
},
{
x=11,
class="static_wall",
y=7,
color="blue"
},
{
x=11,
class="static_wall",
y=4,
color="blue"
},
{
x=11,
class="static_wall",
y=3,
color="blue"
},
{
x=11,
class="static_wall",
y=2,
color="blue"
},
{
x=11,
class="static_wall",
y=1,
color="blue"
},
{
x=10,
class="static_wall",
y=1,
color="blue"
},
{
x=10,
class="static_wall",
y=1,
color="blue"
},
{
x=9,
class="static_wall",
y=1,
color="blue"
},
{
x=8,
class="static_wall",
y=1,
color="blue"
},
{
x=7,
class="static_wall",
y=1,
color="blue"
},
{
x=6,
class="static_wall",
y=1,
color="blue"
},
{
x=5,
class="static_wall",
y=1,
color="blue"
},
{
x=4,
class="static_wall",
y=1,
color="blue"
},
{
x=4,
class="static_wall",
y=1,
color="blue"
},
{
x=3,
class="static_wall",
y=1,
color="blue"
},
{
x=2,
class="static_wall",
y=1,
color="blue"
},
{
x=1,
class="static_wall",
y=1,
color="blue"
},
{
x=1,
class="static_wall",
y=2,
color="blue"
},
{
x=1,
class="static_wall",
y=3,
color="blue"
},
{
x=1,
class="static_wall",
y=4,
color="blue"
},
{
x=1,
class="static_wall",
y=5,
color="blue"
},
{
x=1,
class="static_wall",
y=6,
color="blue"
},
{
x=1,
class="static_wall",
y=7,
color="blue"
},
{
x=1,
class="static_wall",
y=8,
color="blue"
},
{
x=1,
class="static_wall",
y=9,
color="blue"
},
{
x=1,
class="static_wall",
y=10,
color="blue"
},
{
x=1,
class="static_wall",
y=11,
color="blue"
},
{
x=2,
class="static_wall",
y=11,
color="blue"
},
{
x=3,
class="static_wall",
y=11,
color="blue"
},
{
x=4,
class="static_wall",
y=11,
color="blue"
},
{
x=5,
class="static_wall",
y=11,
color="blue"
},
{
x=6,
class="static_wall",
y=11,
color="blue"
},
{
x=7,
class="static_wall",
y=11,
color="blue"
},
{
x=8,
class="static_wall",
y=11,
color="blue"
},
{
x=8,
class="static_wall",
y=11,
color="blue"
},
{
x=9,
class="static_wall",
y=11,
color="blue"
},
{
x=10,
class="static_wall",
y=11,
color="blue"
},
{
x=11,
class="static_wall",
y=11,
color="blue"
},
{
x=11,
class="static_wall",
y=10,
color="blue"
},
{
x=11,
class="static_wall",
y=9,
color="blue"
},
{
x=11,
class="static_wall",
y=8,
color="blue"
},
{
x=11,
class="static_wall",
y=7,
color="blue"
},
{
x=8,
class="static_wall",
y=2,
color="blue"
},
{
x=8,
class="static_wall",
y=3,
color="blue"
},
{
x=8,
class="static_wall",
y=4,
color="blue"
},
{
x=8,
class="static_wall",
y=5,
color="blue"
},
{
x=8,
class="static_wall",
y=8,
color="blue"
},
{
x=8,
class="static_wall",
y=9,
color="blue"
},
{
x=8,
class="static_wall",
y=10,
color="blue"
},
{
x=8,
class="static_wall",
y=11,
color="blue"
},
{
x=11,
class="static_wall",
y=7,
color="red"
},
{
x=11,
class="static_wall",
y=4,
color="red"
},
{
x=11,
class="static_wall",
y=3,
color="red"
},
{
x=11,
class="static_wall",
y=2,
color="red"
},
{
x=11,
class="static_wall",
y=1,
color="red"
},
{
x=10,
class="static_wall",
y=1,
color="red"
},
{
x=10,
class="static_wall",
y=1,
color="red"
},
{
x=9,
class="static_wall",
y=1,
color="red"
},
{
x=8,
class="static_wall",
y=1,
color="red"
},
{
x=7,
class="static_wall",
y=1,
color="red"
},
{
x=6,
class="static_wall",
y=1,
color="red"
},
{
x=5,
class="static_wall",
y=1,
color="red"
},
{
x=4,
class="static_wall",
y=1,
color="red"
},
{
x=4,
class="static_wall",
y=1,
color="red"
},
{
x=3,
class="static_wall",
y=1,
color="red"
},
{
x=2,
class="static_wall",
y=1,
color="red"
},
{
x=1,
class="static_wall",
y=1,
color="red"
},
{
x=1,
class="static_wall",
y=2,
color="red"
},
{
x=1,
class="static_wall",
y=3,
color="red"
},
{
x=1,
class="static_wall",
y=4,
color="red"
},
{
x=1,
class="static_wall",
y=5,
color="red"
},
{
x=1,
class="static_wall",
y=6,
color="red"
},
{
x=1,
class="static_wall",
y=7,
color="red"
},
{
x=1,
class="static_wall",
y=8,
color="red"
},
{
x=1,
class="static_wall",
y=9,
color="red"
},
{
x=1,
class="static_wall",
y=10,
color="red"
},
{
x=1,
class="static_wall",
y=11,
color="red"
},
{
x=2,
class="static_wall",
y=11,
color="red"
},
{
x=3,
class="static_wall",
y=11,
color="red"
},
{
x=4,
class="static_wall",
y=11,
color="red"
},
{
x=5,
class="static_wall",
y=11,
color="red"
},
{
x=6,
class="static_wall",
y=11,
color="red"
},
{
x=7,
class="static_wall",
y=11,
color="red"
},
{
x=8,
class="static_wall",
y=11,
color="red"
},
{
x=8,
class="static_wall",
y=11,
color="red"
},
{
x=9,
class="static_wall",
y=11,
color="red"
},
{
x=10,
class="static_wall",
y=11,
color="red"
},
{
x=11,
class="static_wall",
y=11,
color="red"
},
{
x=11,
class="static_wall",
y=10,
color="red"
},
{
x=11,
class="static_wall",
y=9,
color="red"
},
{
x=11,
class="static_wall",
y=8,
color="red"
},
{
x=11,
class="static_wall",
y=7,
color="red"
},
{
x=8,
class="static_wall",
y=2,
color="red"
},
{
x=8,
class="static_wall",
y=3,
color="red"
},
{
x=8,
class="static_wall",
y=4,
color="red"
},
{
x=8,
class="static_wall",
y=5,
color="red"
},
{
x=8,
class="static_wall",
y=8,
color="red"
},
{
x=8,
class="static_wall",
y=9,
color="red"
},
{
x=8,
class="static_wall",
y=10,
color="red"
},
{
x=8,
class="static_wall",
y=11,
color="red"
},
{
x=5,
class="jorge",
y=4
},
{
x=5,
class="mark",
name="begin",
y=4
}
},
wires={
}
}

View file

@ -1,6 +1,7 @@
{ {
width=18,
height=18, height=18,
lastRoom="blocker_test", lastRoom="room_16_17",
rooms={ rooms={
{ {
name="3", name="3",
@ -281,7 +282,16 @@ rooms={
name="white_ok", name="white_ok",
x=11, x=11,
y=1 y=1
},
{
name="room_16_17",
x=16,
y=17
},
{
name="testing_movement",
x=15,
y=17
} }
}, }
width=18
} }

View file

@ -4,4 +4,10 @@ Jorge: I'm Jorge. I'm LGBTQ+, and I spent too long hiding that.
Jorge smiles. "Thank you. Being met with kindness makes this place feel safer." Jorge smiles. "Thank you. Being met with kindness makes this place feel safer."
* [Mistreat Jorge] That's not something I respect. * [Mistreat Jorge] That's not something I respect.
Jorge's smile fades. "You can choose cruelty, but you don't get to define me." Jorge's smile fades. "You can choose cruelty, but you don't get to define me."
* [Send him to the end] Go wait for me at the end.
~ walk_to("end")
Jorge nods and heads for the end.
* [Send him to the start] Head back to the beginning.
~ walk_to("begin")
Jorge nods and heads back the way you came.
- -> END - -> END

View file

@ -1 +1 @@
return {["constants"]={},["tree"]={["_"]={["_"]={"Jorge: I'm Jorge. I'm LGBTQ+, and I spent too long hiding that.",{["node"]={"Jorge smiles. \"Thank you. Being met with kindness makes this place feel safer.\""},["choice"]="Be kind",["text"]="I'm glad you told me."},{["node"]={"Jorge's smile fades. \"You can choose cruelty, but you don't get to define me.\""},["choice"]="Mistreat Jorge",["text"]="That's not something I respect."},{["divert"]={["path"]="END",["tunnel"]=false}}}}},["inclusions"]={},["params"]={},["version"]={["tree"]=1,["engine"]=2},["variables"]={},["lists"]={}} return {["constants"]={},["tree"]={["_"]={["_"]={"Jorge: I'm Jorge. I'm LGBTQ+, and I spent too long hiding that.",{["node"]={"Jorge smiles. \"Thank you. Being met with kindness makes this place feel safer.\""},["choice"]="Be kind",["text"]="I'm glad you told me."},{["node"]={"Jorge's smile fades. \"You can choose cruelty, but you don't get to define me.\""},["choice"]="Mistreat Jorge",["text"]="That's not something I respect."},{["node"]={{["var"]="",["value"]="walk_to(\"end\")"},"Jorge nods and heads for the end."},["choice"]="Send him to the end",["text"]="Go wait for me at the end."},{["node"]={{["var"]="",["value"]="walk_to(\"begin\")"},"Jorge nods and heads back the way you came."},["choice"]="Send him to the start",["text"]="Head back to the beginning."},{["divert"]={["path"]="END",["tunnel"]=false}}}}},["inclusions"]={},["params"]={},["version"]={["tree"]=1,["engine"]=2},["variables"]={},["lists"]={}}

View file

@ -355,6 +355,12 @@ local function loadRoomClass()
end, end,
}, },
} }
-- room pulls in note.lua/mark.lua, which call loadSprite at module-load time.
-- Provide a default stub so requiring room works; individual tests still
-- override loadSprite as needed.
loadSprite = loadSprite or function()
return {getWidth = function() return 16 end, getHeight = function() return 16 end}
end
require "room" require "room"
end end

View file

@ -213,6 +213,9 @@ end
function World:rebuildCollision() function World:rebuildCollision()
self.collidableMatrices = {red = {}, green = {}, blue = {}} self.collidableMatrices = {red = {}, green = {}, blue = {}}
self.playerBlockerMatrices = {red = {}, green = {}, blue = {}} self.playerBlockerMatrices = {red = {}, green = {}, blue = {}}
-- Per-colour set of world cells occupied by a player copy. NPC pathfinding
-- treats these as solid (a walker can't push the player) — see pathfinding.lua.
self.playerCells = {red = {}, green = {}, blue = {}}
self.entityRooms = {} self.entityRooms = {}
local function put(matrix, cell, entity) local function put(matrix, cell, entity)
matrix[cell.y] = matrix[cell.y] or {} matrix[cell.y] = matrix[cell.y] or {}
@ -221,6 +224,13 @@ function World:rebuildCollision()
for name, room in pairs(self.levels) do for name, room in pairs(self.levels) do
local location = self:roomLocation(name) local location = self:roomLocation(name)
if location then if location then
for color, player in pairs(room.player) do
for _, cell in ipairs(player:getOccupiedCells()) do
local x = (location.x - 1) * gridWidth + cell.x
local y = (location.y - 1) * gridHeight + cell.y
self.playerCells[color][x .. ":" .. y] = true
end
end
for color, entities in pairs(room.collideables) do for color, entities in pairs(room.collideables) do
for _, entity in pairs(entities) do for _, entity in pairs(entities) do
self.entityRooms[entity] = room self.entityRooms[entity] = room
@ -232,16 +242,19 @@ function World:rebuildCollision()
end end
end end
end end
-- NPCs are local room state, but globally collidable: a movement lookup -- NPC copies are per-colour room residents (see npc.lua). Each colour
-- must find them so its normal bump() path can open dialogue. -- stamps only its own copy's cells — "red can only be hit by red" — at the
for _, npc in ipairs(room.npcs) do -- copy's position in the room it currently stands in. The controller
self.entityRooms[npc] = room -- entity (copy.npc) is stored so a bump opens its dialogue.
for _, cell in ipairs(npc:getOccupiedCells()) do for color, copies in pairs(room.npcCopies) do
local worldCell = { for _, copy in ipairs(copies) do
x = (location.x - 1) * gridWidth + cell.x, self.entityRooms[copy.npc] = room
y = (location.y - 1) * gridHeight + cell.y, for _, shapeCell in ipairs(copy.npc:getCellShape()) do
} put(self.collidableMatrices[color], {
for color in pairs(self.collidableMatrices) do put(self.collidableMatrices[color], worldCell, npc) end x = (location.x - 1) * gridWidth + copy.x + shapeCell.x - 1,
y = (location.y - 1) * gridHeight + copy.y + shapeCell.y - 1,
}, copy.npc)
end
end end
end end
for color, entities in pairs(room.playerBlockers) do for color, entities in pairs(room.playerBlockers) do
@ -271,6 +284,17 @@ function World:roomCell(cell)
return room, {x = cell.x - (roomX - 1) * gridWidth, y = cell.y - (roomY - 1) * gridHeight} return room, {x = cell.x - (roomX - 1) * gridWidth, y = cell.y - (roomY - 1) * gridHeight}
end end
-- Marks are globally unique named destinations. This resolves one for an NPC's
-- Walker:sendTo, scanning every loaded room's mark registry.
function World:markByName(name)
for _, room in pairs(self.levels or {}) do
for _, mark in ipairs(room.marks or {}) do
if mark.markName == name then return mark, room end
end
end
return nil
end
local function cellKey(cell) local function cellKey(cell)
return cell.x .. ":" .. cell.y return cell.x .. ":" .. cell.y
end end
@ -795,6 +819,25 @@ function World:tick(room)
self:nextRoomCheck(room) self:nextRoomCheck(room)
end end
-- Real-time NPC movement across every placed room, independent of player turns.
-- Snapshot the NPC list first: Walker:arrive can hand an NPC to another room's
-- registry, which would otherwise mutate a list we are iterating.
function World:update(dt)
local walking = {}
for name, room in pairs(self.levels or {}) do
if self:roomLocation(name) then
for _, npc in ipairs(room.npcs) do
if npc.step then table.insert(walking, npc) end
end
end
end
local moved = false
for _, npc in ipairs(walking) do
if npc:step(dt) then moved = true end
end
if moved then self:rebuildCollision() end
end
function World:nextRoomCheck(room) function World:nextRoomCheck(room)
for _, color in ipairs(room:getActiveColors()) do for _, color in ipairs(room:getActiveColors()) do
local player = room.player[color] local player = room.player[color]