diff --git a/CLAUDE.md b/CLAUDE.md index 440711e..0a15b14 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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/`. diff --git a/dialogable.lua b/dialogable.lua index 0cad3ba..a062ccb 100644 --- a/dialogable.lua +++ b/dialogable.lua @@ -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 diff --git a/dialogue_manager.lua b/dialogue_manager.lua index 51effcf..f133d5f 100644 --- a/dialogue_manager.lua +++ b/dialogue_manager.lua @@ -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 diff --git a/docs/adding-npcs.md b/docs/adding-npcs.md index 584b439..78f83f7 100644 --- a/docs/adding-npcs.md +++ b/docs/adding-npcs.md @@ -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/.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. diff --git a/level_editor/editor.lua b/level_editor/editor.lua index 3d4b09b..1a106bd 100644 --- a/level_editor/editor.lua +++ b/level_editor/editor.lua @@ -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) diff --git a/main.lua b/main.lua index 6281f58..cdefa04 100644 --- a/main.lua +++ b/main.lua @@ -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 diff --git a/mark.lua b/mark.lua new file mode 100644 index 0000000..91454be --- /dev/null +++ b/mark.lua @@ -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 diff --git a/npc.lua b/npc.lua index 4df840d..b5fb70a 100644 --- a/npc.lua +++ b/npc.lua @@ -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 diff --git a/pathfinding.lua b/pathfinding.lua new file mode 100644 index 0000000..66976ea --- /dev/null +++ b/pathfinding.lua @@ -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 diff --git a/room.lua b/room.lua index d3d978f..18dd662 100644 --- a/room.lua +++ b/room.lua @@ -18,6 +18,7 @@ require "generic_spiky" require "generic_unmoveable" require "generic_player_blocker" require "note" +require "mark" local Movement = require "movement" local Wire = require "wire" @@ -32,7 +33,11 @@ function Room:initialize(t) self.collideables = { red = {}, green = {}, blue = {} } self.playerBlockers = { red = {}, green = {}, blue = {} } self.npcs = {} + -- Per-colour NPC copy residents, mirroring self.player: each room draws and + -- collides the copies currently standing in it (see npc.lua / Walker). + self.npcCopies = { red = {}, green = {}, blue = {} } self.notes = {} + self.marks = {} self.activeColors = {red = false, green = false, blue = false} self.colorsPlayerHas = {} @@ -172,16 +177,21 @@ function Room:draw(drawChannels) for _, blocker in pairs(self.playerBlockers[channel]) do blocker:draw(channel) end for _, switch in pairs(self.switches[channel]) do switch:draw(channel) end for _, switch in ipairs(self.wiredSwitches) do switch:draw(channel) end - for _, npc in ipairs(self.npcs) do npc:draw(channel) end + -- NPC copies render exactly like player copies: each room draws only the + -- copies currently standing in it, on this channel. A copy that has walked + -- into a red-only room shows only its red layer there. + for _, copy in ipairs(self.npcCopies[channel]) do copy:draw(channel) end end - -- Notes are an editor-only overlay: shown only while the editor build is - -- running (DEBUG) and the NOTES_VISIBLE toggle is on. Drawn once (not per - -- channel) in alpha so the faint icon reads as itself instead of screen-adding. - if DEBUG and NOTES_VISIBLE and #self.notes > 0 then + -- Debug non-collidables (notes, marks, …) are editor-only overlays: shown only + -- under DEBUG while the overlay toggle is on. Drawn once (not per channel) in + -- alpha so each icon reads as itself instead of screen-adding. Each kind draws + -- itself in its own pass; they share the toggle, not any code. + if DEBUG and NOTES_VISIBLE then local mode, alphaMode = love.graphics.getBlendMode() love.graphics.setBlendMode("alpha") for _, note in ipairs(self.notes) do note:draw() end + for _, mark in ipairs(self.marks) do mark:draw() end love.graphics.setBlendMode(mode, alphaMode) end end @@ -756,6 +766,11 @@ function Room:createEntity(class, color, x, y, data) self:registerNote(entity) return entity end + if class == "mark" then + local entity = Mark:new(x, y, data.name or data.markName) + self:registerMark(entity) + return entity + end local props = globalAssetProperties and globalAssetProperties[class] local behavior = props and props.class or "immoveable" @@ -868,9 +883,12 @@ function Room:registerPlayer(entityPointer, color) end function Room:registerNpc(entity) - -- npcs are dialogable entities: they carry .x/.y like any Entity, so - -- serialize's npc loop still round-trips them, and they get stamped into the - -- collision matrix (see createCollideablesMatrix) so a bump is a rejected move. + -- The NPC entity is a controller kept here for identity / dialogue / serialize. + -- Its visible, collidable presence is three per-colour copies (Walker) that + -- each belong to whatever room they stand in; initCopies seeds them here at the + -- NPC's home cell, registered into this room's npcCopies lists. + entity.room = self + if entity.initCopies then entity:initCopies(self) end table.insert(self.npcs, entity) end @@ -890,6 +908,44 @@ function Room:noteAtCell(cell) return nil end +function Room:registerMark(entity) + -- Marks, like notes, are passable annotations kept in their own registry so + -- serialize round-trips them and movement never stamps them into a matrix. + -- The difference is their name is meaningful: NPCs are sent here by it. + table.insert(self.marks, entity) +end + +-- The mark (if any) under a given grid cell — used by the editor's hover/click. +function Room:markAtCell(cell) + if not cell then return nil end + for _, mark in ipairs(self.marks) do + if mark:occupiesCell(cell) then return mark end + end + return nil +end + +-- The npc (if any) with a copy under a given grid cell in this room — used by the +-- editor's debug destination picker (click an NPC to send it to a mark). Checks +-- the visible copies, so clicking a walked-away copy still finds its controller. +function Room:npcAtCell(cell) + if not cell then return nil end + for _, color in ipairs({"red", "green", "blue"}) do + for _, copy in ipairs(self.npcCopies[color]) do + if copy:occupiesCell(cell) then return copy.npc end + end + end + return nil +end + +-- Code-side mark creation: register a named destination through the same path +-- authored marks use, so it collides/renders in debug identically. Flagged +-- `programmatic` so Room:serialize skips it — code recreates it on every load. +function Room:addMark(name, x, y) + local entity = self:createEntity("mark", nil, x, y, { name = name }) + if entity then entity.programmatic = true end + return entity +end + function Room:getCellFromMousePos(mouseX, mouseY) if gameWorld and gameWorld.levels and gameWorld.levels[self.name] == self and not gameWorld.drawingRoomNeighborhood then @@ -960,17 +1016,21 @@ function Room:attemptDeleteAtCell(cell, color) table.remove(self.wiredSwitches, index) end end - -- NPCs are stamped into every collision layer, but are kept in their own - -- registry rather than in self.collideables. Remove them directly so a - -- right-click works no matter which editor colour is currently selected. - for index = #self.npcs, 1, -1 do - if self.npcs[index]:occupiesCell(cell) then table.remove(self.npcs, index) end - end + -- An NPC is a controller plus per-colour copies spread across rooms; deleting + -- one means tearing down all of them (Walker:destroy), regardless of the + -- currently selected editor colour. + local npc = self:npcAtCell(cell) + if npc and npc.destroy then npc:destroy() end -- Notes are non-collidable and colourless, so a right-click removes them -- regardless of the currently selected editor colour. for index = #self.notes, 1, -1 do if self.notes[index]:occupiesCell(cell) then table.remove(self.notes, index) end end + -- Marks behave like notes for deletion: colourless, passable, removed on any + -- selected colour. + for index = #self.marks, 1, -1 do + if self.marks[index]:occupiesCell(cell) then table.remove(self.marks, index) end + end self:tick() end @@ -991,6 +1051,17 @@ function Room:attemptPlaceAtCell(name, props, cell, color, text) self:tick() return end + -- Marks are colourless, single-instance named destinations. One mark per cell: + -- drop any mark already here before adding the new one. `text` carries the name. + if name == "mark" then + if not cell then return end + for index = #self.marks, 1, -1 do + if self.marks[index]:occupiesCell(cell) then table.remove(self.marks, index) end + end + local mark = self:createEntity("mark", nil, cell.x, cell.y, { name = text }) + self:tick() + return mark + end if not props or not props.sprites then return end if not cell then return end -- Jorge is one dialogable NPC with three display channels, not three @@ -1155,6 +1226,14 @@ function Room:serialize(includePlayers) table.insert(objects, { class = "note", x = note.x, y = note.y, text = note.text }) end + -- Programmatic marks are recreated from code on every load, so persisting them + -- would duplicate them. Only authored (editor-placed) marks are written. + for _, mark in ipairs(self.marks) do + if not mark.programmatic then + table.insert(objects, { class = "mark", x = mark.x, y = mark.y, name = mark.markName }) + end + end + return { objects = objects, wires = self:serializeWires() } end diff --git a/rooms/room_16_17.sav b/rooms/room_16_17.sav new file mode 100644 index 0000000..0806d5c --- /dev/null +++ b/rooms/room_16_17.sav @@ -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={ +} +} \ No newline at end of file diff --git a/rooms/testing_movement.sav b/rooms/testing_movement.sav new file mode 100644 index 0000000..39830b6 --- /dev/null +++ b/rooms/testing_movement.sav @@ -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={ +} +} \ No newline at end of file diff --git a/rooms/world.sav b/rooms/world.sav index f4381e0..ebc776a 100644 --- a/rooms/world.sav +++ b/rooms/world.sav @@ -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 +} } \ No newline at end of file diff --git a/stories/jorge.ink b/stories/jorge.ink index 8aad2e8..61d860c 100644 --- a/stories/jorge.ink +++ b/stories/jorge.ink @@ -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 diff --git a/stories/jorge.lua b/stories/jorge.lua index bf52bfc..ccf60e6 100644 --- a/stories/jorge.lua +++ b/stories/jorge.lua @@ -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"]={}} \ No newline at end of file +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"]={}} diff --git a/tests/test_movement.lua b/tests/test_movement.lua index af0c302..b2618ba 100644 --- a/tests/test_movement.lua +++ b/tests/test_movement.lua @@ -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 diff --git a/world.lua b/world.lua index 35187ca..d261acf 100644 --- a/world.lua +++ b/world.lua @@ -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]