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.
**LÖVE version matters.** The code targets the **0.10.x** API, not 11.x. Signals of this that you must preserve when editing:
- Colors are `0–255` integers (`love.graphics.setColor(255, 255, 255)`), not `0–1` floats.
- `sprite:getData()` / `img:getPixel` are used (see `sprite_manager.lua`); the 11.x replacements differ.
Do not "modernize" these to the 11.x API unless explicitly asked — it would break the whole render/color path.
**LÖVE version matters.** The code targets the **11.x** API — `conf.lua` pins `t.version = "11.5"`. Preserve these when editing:
- Colors are normalized `0–1` floats (`love.graphics.setColor(1, 1, 1)`), not `0–255` integers — see `color.lua`.
- 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 "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)
@ -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.
- `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`.
- `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`).
- `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()
if self.book then
local wasOpen = Dialogue.isOpen()
Dialogue.open(self.book)
Dialogue.open(self.book, self:dialogueBindings())
if Sound and not wasOpen then Sound.jabber() end
end
return true
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
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
story = narrator.init_story(book)
if bindings then
for name, fn in pairs(bindings) do story:bind(name, fn) end
end
story:begin()
advance()
end

View file

@ -38,11 +38,14 @@ In `main.lua`, next to `startSignBook`:
## 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
Oracle = class("Oracle", Entity)
Oracle:include(Dialogable)
Oracle:include(Walker)
function Oracle:initialize(x, y)
self.book = oracleBook -- from step 3
@ -55,6 +58,7 @@ function Oracle:initialize(x, y)
blue = loadSprite("art/npc/oracle_b.png"),
},
})
self:initWalker()
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
collision matrix, so they block movement and obey the world's colour rules (a
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/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)

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
end
currentRoom:update(dt)
if gameWorld then gameWorld:update(dt) end
MapManager:record(currentRoom)
-- if love.keyboard.isDown("w") then worldSha = worldSha + 1 end
@ -174,6 +175,8 @@ function love.draw()
love.graphics.setColor(1, 1, 1, 1)
if DEBUG then love.graphics.draw(sideBarCanvas, sideBarBox.x, sideBarBox.y) 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)
PerfProfiler:draw()
Dialogue.draw()
@ -229,6 +232,24 @@ function love.keypressed(key)
return
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).
if DEBUG and key == "tab" then
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 "dialogable"
local Pathfinding = require "pathfinding"
local channels = {"red", "green", "blue"}
-- An NpcCopy is one colour's presence of an NPC inside one room, in that room's
-- local coordinates — the exact analogue of room.player[color]. The room it lives
-- in draws it (Room:draw) and stamps it into collision (World:rebuildCollision),
-- so a copy obeys that room's colour view and collides there like any object.
-- An NPC has three copies, and while walking they may live in up to three
-- DIFFERENT rooms at once, exactly like the player's copies.
local NpcCopy = {}
NpcCopy.__index = NpcCopy
local function makeCopy(npc, color, room, x, y)
return setmetatable({ npc = npc, color = color, room = room, x = x, y = y }, NpcCopy)
end
-- The copy's cell in world coordinates (for pathfinding / collision).
function NpcCopy:worldCell()
if gameWorld then
local wc = gameWorld:worldCell(self.room, { x = self.x, y = self.y })
if wc then return wc end
end
return { x = self.x, y = self.y }
end
function NpcCopy:occupiesCell(cell)
for _, s in ipairs(self.npc:getCellShape()) do
if self.x + s.x - 1 == cell.x and self.y + s.y - 1 == cell.y then return true end
end
return false
end
-- Drawn by its room's per-channel pass, in that room's local coordinates — the
-- same call shape as every other object. `channel` always equals self.color.
function NpcCopy:draw(channel)
local sprite = self.npc.sprites[channel]
if not sprite then return end
local npc = self.npc
love.graphics.setColor(gColor[channel]:set())
local px = (self.x - 1) * width / gridWidth + npc:sha()
local py = (self.y - 1) * height / gridHeight + npc:sha()
love.graphics.draw(sprite, px + npc.drawOffset.x * drawScale, py + npc.drawOffset.y * drawScale, 0, drawScale, drawScale)
end
-- Walker: gives an NPC the player's RGB model. The NPC entity itself is only a
-- controller (kept in room.npcs for identity / dialogue / serialisation); its
-- visible, collidable presence is its three NpcCopy residents, which each belong
-- to whatever room they currently stand in and run their own A* toward a Mark.
Walker = {}
function Walker:initWalker()
self.paths = { red = nil, green = nil, blue = nil }
self.goal = nil
self.walking = false
self.moveTimer = 0
self.moveInterval = 0.15
end
-- Create the three copies at this NPC's home cell in `room` and register each
-- into that room's per-colour copy list. Called from Room:registerNpc.
function Walker:initCopies(room)
self.copies = {}
for _, color in ipairs(channels) do
local copy = makeCopy(self, color, room, self.x, self.y)
self.copies[color] = copy
table.insert(room.npcCopies[color], copy)
end
end
function Walker:isWalking() return self.walking end
-- Move one colour copy into `room` at local (x, y), transferring it between the
-- rooms' per-colour copy lists — the NPC equivalent of the player crossing a
-- room seam.
function Walker:placeCopy(copy, room, x, y)
if copy.room ~= room then
local from = copy.room.npcCopies[copy.color]
for i = #from, 1, -1 do if from[i] == copy then table.remove(from, i) end end
table.insert(room.npcCopies[copy.color], copy)
copy.room = room
end
copy.x, copy.y = x, y
end
local function npcLog(...)
if DEBUG then print("[npc]", ...) end
end
-- Send this NPC to a named Mark. Each colour plots its own route on its own
-- collision layer, so the copies may take different paths and cross into
-- different rooms.
function Walker:sendTo(markName)
if not gameWorld then npcLog("sendTo abort: no gameWorld"); return end
if not self.copies then npcLog("sendTo abort: no copies (not registered?)"); return end
local mark, markRoom = gameWorld:markByName(markName)
if not mark then npcLog("sendTo abort: no mark named '" .. tostring(markName) .. "'"); return end
local goal = gameWorld:worldCell(markRoom, { x = mark.x, y = mark.y })
if not goal then npcLog("sendTo abort: mark's room '" .. tostring(markRoom.name) .. "' is not placed on the world map"); return end
self.goal = goal
self.walking = true
-- Route is computed against the live collision matrix, so rebuild first (the
-- player and other NPCs may have moved since the last tick).
if gameWorld.rebuildCollision then gameWorld:rebuildCollision() end
local shape = self:getCellShape()
for _, color in ipairs(channels) do
local start = self.copies[color]:worldCell()
self.paths[color] = Pathfinding.route(start, goal, color, self, shape)
npcLog(("sendTo %s: start=%d,%d goal=%d,%d -> %s"):format(color, start.x, start.y, goal.x, goal.y,
self.paths[color] and ("route len " .. #self.paths[color])
or ("NO ROUTE (goal blocked by " .. (Pathfinding.blockReason(goal, color, self, shape) or "a wall between here and there") .. ")")))
end
self.moveTimer = 0
end
-- Advance one grid step per colour when the timer elapses. Returns true if any
-- copy moved (so the caller can rebuild collision). Driven by World:update.
function Walker:step(dt)
if not self.walking then return false end
self.moveTimer = self.moveTimer + dt
if self.moveTimer < self.moveInterval then return false end
self.moveTimer = self.moveTimer - self.moveInterval
local shape = self:getCellShape()
local moved = false
for _, color in ipairs(channels) do
local copy = self.copies[color]
local start = copy:worldCell()
if start.x ~= self.goal.x or start.y ~= self.goal.y then
-- (Re)plan whenever we have no live path — so a channel blocked this tick
-- keeps trying and resumes the moment the obstacle (a box, the player)
-- clears, instead of freezing forever.
local path = self.paths[color]
if not path or #path == 0 then
path = Pathfinding.route(start, self.goal, color, self, shape)
self.paths[color] = path
end
if path and #path > 0 then
local nextCell = path[1]
if Pathfinding.walkable(nextCell, color, self, shape) then
local room, localCell = gameWorld:roomCell(nextCell)
if room then
self:placeCopy(copy, room, localCell.x, localCell.y)
table.remove(path, 1)
moved = true
else
self.paths[color] = nil
end
else
-- blocked this tick: drop the stale path, replan next tick
if not self._blocked then self._blocked = {} end
local reason = Pathfinding.blockReason(nextCell, color, self, shape)
if self._blocked[color] ~= reason then
npcLog(("%s blocked at %d,%d by %s (waiting)"):format(color, nextCell.x, nextCell.y, reason or "?"))
self._blocked[color] = reason
end
self.paths[color] = nil
end
end
end
end
if self:allArrived() then self:arrive() end
return moved
end
function Walker:allArrived()
if not self.goal then return false end
for _, color in ipairs(channels) do
local wc = self.copies[color]:worldCell()
if wc.x ~= self.goal.x or wc.y ~= self.goal.y then return false end
end
return true
end
-- Every copy reached the destination. Stop, and move the controller (self, the
-- entity kept in room.npcs) into the room the copies ended up in, so a save
-- writes it in the right level and the editor finds it there.
function Walker:arrive()
self.walking = false
self.paths = { red = nil, green = nil, blue = nil }
self.goal = nil
local lead = self.copies[channels[1]]
if lead and lead.room then
if lead.room ~= self.room and self.room then
for i = #self.room.npcs, 1, -1 do if self.room.npcs[i] == self then table.remove(self.room.npcs, i) end end
table.insert(lead.room.npcs, self)
end
self.room = lead.room
self.x, self.y = lead.x, lead.y
end
if gameWorld and gameWorld.rebuildCollision then gameWorld:rebuildCollision() end
end
-- Remove this NPC entirely: its copies from every room's copy lists and its
-- controller from its room's npc registry. Used by the editor's right-click.
function Walker:destroy()
if self.copies then
for _, color in ipairs(channels) do
local copy = self.copies[color]
local list = copy.room.npcCopies[color]
for i = #list, 1, -1 do if list[i] == copy then table.remove(list, i) end end
end
end
if self.room then
for i = #self.room.npcs, 1, -1 do if self.room.npcs[i] == self then table.remove(self.room.npcs, i) end end
end
end
-- A dialogable placed in the world. Signs, npcs and artifacts will each be a
-- small Entity subclass that includes Dialogable and names its own book; Sign is
-- the first. It just picks art + a story — the talking lives in the mixin.
Sign = class("Sign", Entity)
Sign:include(Dialogable)
Sign:include(Walker)
function Sign:initialize(x, y)
self.book = startSignBook
@ -19,10 +230,12 @@ function Sign:initialize(x, y)
blue = loadSprite("art/npc/start_sign_b.png"),
},
})
self:initWalker()
end
Jorge = class("Jorge", Entity)
Jorge:include(Dialogable)
Jorge:include(Walker)
function Jorge:initialize(x, y)
self.book = jorgeBook
@ -37,4 +250,5 @@ function Jorge:initialize(x, y)
blue = loadSprite("art/npc/jorge_b.png"),
},
})
self:initWalker()
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_player_blocker"
require "note"
require "mark"
local Movement = require "movement"
local Wire = require "wire"
@ -32,7 +33,11 @@ function Room:initialize(t)
self.collideables = { red = {}, green = {}, blue = {} }
self.playerBlockers = { red = {}, green = {}, blue = {} }
self.npcs = {}
-- Per-colour NPC copy residents, mirroring self.player: each room draws and
-- collides the copies currently standing in it (see npc.lua / Walker).
self.npcCopies = { red = {}, green = {}, blue = {} }
self.notes = {}
self.marks = {}
self.activeColors = {red = false, green = false, blue = false}
self.colorsPlayerHas = {}
@ -172,16 +177,21 @@ function Room:draw(drawChannels)
for _, blocker in pairs(self.playerBlockers[channel]) do blocker:draw(channel) end
for _, switch in pairs(self.switches[channel]) do switch:draw(channel) end
for _, switch in ipairs(self.wiredSwitches) do switch:draw(channel) end
for _, npc in ipairs(self.npcs) do npc:draw(channel) end
-- NPC copies render exactly like player copies: each room draws only the
-- copies currently standing in it, on this channel. A copy that has walked
-- into a red-only room shows only its red layer there.
for _, copy in ipairs(self.npcCopies[channel]) do copy:draw(channel) end
end
-- Notes are an editor-only overlay: shown only while the editor build is
-- running (DEBUG) and the NOTES_VISIBLE toggle is on. Drawn once (not per
-- channel) in alpha so the faint icon reads as itself instead of screen-adding.
if DEBUG and NOTES_VISIBLE and #self.notes > 0 then
-- Debug non-collidables (notes, marks, …) are editor-only overlays: shown only
-- under DEBUG while the overlay toggle is on. Drawn once (not per channel) in
-- alpha so each icon reads as itself instead of screen-adding. Each kind draws
-- itself in its own pass; they share the toggle, not any code.
if DEBUG and NOTES_VISIBLE then
local mode, alphaMode = love.graphics.getBlendMode()
love.graphics.setBlendMode("alpha")
for _, note in ipairs(self.notes) do note:draw() end
for _, mark in ipairs(self.marks) do mark:draw() end
love.graphics.setBlendMode(mode, alphaMode)
end
end
@ -756,6 +766,11 @@ function Room:createEntity(class, color, x, y, data)
self:registerNote(entity)
return entity
end
if class == "mark" then
local entity = Mark:new(x, y, data.name or data.markName)
self:registerMark(entity)
return entity
end
local props = globalAssetProperties and globalAssetProperties[class]
local behavior = props and props.class or "immoveable"
@ -868,9 +883,12 @@ function Room:registerPlayer(entityPointer, color)
end
function Room:registerNpc(entity)
-- npcs are dialogable entities: they carry .x/.y like any Entity, so
-- serialize's npc loop still round-trips them, and they get stamped into the
-- collision matrix (see createCollideablesMatrix) so a bump is a rejected move.
-- The NPC entity is a controller kept here for identity / dialogue / serialize.
-- Its visible, collidable presence is three per-colour copies (Walker) that
-- each belong to whatever room they stand in; initCopies seeds them here at the
-- NPC's home cell, registered into this room's npcCopies lists.
entity.room = self
if entity.initCopies then entity:initCopies(self) end
table.insert(self.npcs, entity)
end
@ -890,6 +908,44 @@ function Room:noteAtCell(cell)
return nil
end
function Room:registerMark(entity)
-- Marks, like notes, are passable annotations kept in their own registry so
-- serialize round-trips them and movement never stamps them into a matrix.
-- The difference is their name is meaningful: NPCs are sent here by it.
table.insert(self.marks, entity)
end
-- The mark (if any) under a given grid cell — used by the editor's hover/click.
function Room:markAtCell(cell)
if not cell then return nil end
for _, mark in ipairs(self.marks) do
if mark:occupiesCell(cell) then return mark end
end
return nil
end
-- The npc (if any) with a copy under a given grid cell in this room — used by the
-- editor's debug destination picker (click an NPC to send it to a mark). Checks
-- the visible copies, so clicking a walked-away copy still finds its controller.
function Room:npcAtCell(cell)
if not cell then return nil end
for _, color in ipairs({"red", "green", "blue"}) do
for _, copy in ipairs(self.npcCopies[color]) do
if copy:occupiesCell(cell) then return copy.npc end
end
end
return nil
end
-- Code-side mark creation: register a named destination through the same path
-- authored marks use, so it collides/renders in debug identically. Flagged
-- `programmatic` so Room:serialize skips it — code recreates it on every load.
function Room:addMark(name, x, y)
local entity = self:createEntity("mark", nil, x, y, { name = name })
if entity then entity.programmatic = true end
return entity
end
function Room:getCellFromMousePos(mouseX, mouseY)
if gameWorld and gameWorld.levels and gameWorld.levels[self.name] == self and
not gameWorld.drawingRoomNeighborhood then
@ -960,17 +1016,21 @@ function Room:attemptDeleteAtCell(cell, color)
table.remove(self.wiredSwitches, index)
end
end
-- NPCs are stamped into every collision layer, but are kept in their own
-- registry rather than in self.collideables. Remove them directly so a
-- right-click works no matter which editor colour is currently selected.
for index = #self.npcs, 1, -1 do
if self.npcs[index]:occupiesCell(cell) then table.remove(self.npcs, index) end
end
-- An NPC is a controller plus per-colour copies spread across rooms; deleting
-- one means tearing down all of them (Walker:destroy), regardless of the
-- currently selected editor colour.
local npc = self:npcAtCell(cell)
if npc and npc.destroy then npc:destroy() end
-- Notes are non-collidable and colourless, so a right-click removes them
-- regardless of the currently selected editor colour.
for index = #self.notes, 1, -1 do
if self.notes[index]:occupiesCell(cell) then table.remove(self.notes, index) end
end
-- Marks behave like notes for deletion: colourless, passable, removed on any
-- selected colour.
for index = #self.marks, 1, -1 do
if self.marks[index]:occupiesCell(cell) then table.remove(self.marks, index) end
end
self:tick()
end
@ -991,6 +1051,17 @@ function Room:attemptPlaceAtCell(name, props, cell, color, text)
self:tick()
return
end
-- Marks are colourless, single-instance named destinations. One mark per cell:
-- drop any mark already here before adding the new one. `text` carries the name.
if name == "mark" then
if not cell then return end
for index = #self.marks, 1, -1 do
if self.marks[index]:occupiesCell(cell) then table.remove(self.marks, index) end
end
local mark = self:createEntity("mark", nil, cell.x, cell.y, { name = text })
self:tick()
return mark
end
if not props or not props.sprites then return end
if not cell then return end
-- Jorge is one dialogable NPC with three display channels, not three
@ -1155,6 +1226,14 @@ function Room:serialize(includePlayers)
table.insert(objects, { class = "note", x = note.x, y = note.y, text = note.text })
end
-- Programmatic marks are recreated from code on every load, so persisting them
-- would duplicate them. Only authored (editor-placed) marks are written.
for _, mark in ipairs(self.marks) do
if not mark.programmatic then
table.insert(objects, { class = "mark", x = mark.x, y = mark.y, name = mark.markName })
end
end
return { objects = objects, wires = self:serializeWires() }
end

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,
lastRoom="blocker_test",
lastRoom="room_16_17",
rooms={
{
name="3",
@ -281,7 +282,16 @@ rooms={
name="white_ok",
x=11,
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."
* [Mistreat Jorge] That's not something I respect.
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

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,
},
}
-- 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"
end

View file

@ -213,6 +213,9 @@ end
function World:rebuildCollision()
self.collidableMatrices = {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 = {}
local function put(matrix, cell, entity)
matrix[cell.y] = matrix[cell.y] or {}
@ -221,6 +224,13 @@ function World:rebuildCollision()
for name, room in pairs(self.levels) do
local location = self:roomLocation(name)
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 _, entity in pairs(entities) do
self.entityRooms[entity] = room
@ -232,16 +242,19 @@ function World:rebuildCollision()
end
end
end
-- NPCs are local room state, but globally collidable: a movement lookup
-- must find them so its normal bump() path can open dialogue.
for _, npc in ipairs(room.npcs) do
self.entityRooms[npc] = room
for _, cell in ipairs(npc:getOccupiedCells()) do
local worldCell = {
x = (location.x - 1) * gridWidth + cell.x,
y = (location.y - 1) * gridHeight + cell.y,
}
for color in pairs(self.collidableMatrices) do put(self.collidableMatrices[color], worldCell, npc) end
-- NPC copies are per-colour room residents (see npc.lua). Each colour
-- stamps only its own copy's cells — "red can only be hit by red" — at the
-- copy's position in the room it currently stands in. The controller
-- entity (copy.npc) is stored so a bump opens its dialogue.
for color, copies in pairs(room.npcCopies) do
for _, copy in ipairs(copies) do
self.entityRooms[copy.npc] = room
for _, shapeCell in ipairs(copy.npc:getCellShape()) do
put(self.collidableMatrices[color], {
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
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}
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)
return cell.x .. ":" .. cell.y
end
@ -795,6 +819,25 @@ function World:tick(room)
self:nextRoomCheck(room)
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)
for _, color in ipairs(room:getActiveColors()) do
local player = room.player[color]