diff --git a/CLAUDE.md b/CLAUDE.md index 0a15b14..440711e 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 **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. +**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. ## 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 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.) +- `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. - `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/_helpers.lua b/_helpers.lua index 215454c..002beb8 100644 --- a/_helpers.lua +++ b/_helpers.lua @@ -86,7 +86,6 @@ end -- Runtime saves live in LÖVE's write directory. This deliberately uses only -- love.filesystem operations: they cannot delete files from the source tree. function clearRuntimeSaveData(roomName) - if MapManager then MapManager:onClearRuntime(roomName) end local removed = 0 if roomName then -- love.filesystem.remove only affects the writable save directory, never diff --git a/dialogable.lua b/dialogable.lua index a062ccb..0cad3ba 100644 --- a/dialogable.lua +++ b/dialogable.lua @@ -8,20 +8,8 @@ Dialogable = {} function Dialogable:onBump() if self.book then local wasOpen = Dialogue.isOpen() - Dialogue.open(self.book, self:dialogueBindings()) + Dialogue.open(self.book) 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 f133d5f..51effcf 100644 --- a/dialogue_manager.lua +++ b/dialogue_manager.lua @@ -26,15 +26,9 @@ local function advance() selected = 1 end --- `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) +function Dialogue.open(book) 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 78f83f7..584b439 100644 --- a/docs/adding-npcs.md +++ b/docs/adding-npcs.md @@ -38,14 +38,11 @@ In `main.lua`, next to `startSignBook`: ## 4. Make the entity -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`: +In `npc.lua`, copy `Sign` — pick your art and name your book: ```lua Oracle = class("Oracle", Entity) Oracle:include(Dialogable) -Oracle:include(Walker) function Oracle:initialize(x, y) self.book = oracleBook -- from step 3 @@ -58,7 +55,6 @@ function Oracle:initialize(x, y) blue = loadSprite("art/npc/oracle_b.png"), }, }) - self:initWalker() end ``` @@ -86,44 +82,3 @@ 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/game_ui.lua b/game_ui.lua index 7f77436..7242112 100644 --- a/game_ui.lua +++ b/game_ui.lua @@ -51,50 +51,38 @@ function GameUI.drawVennOverlay() love.graphics.draw(uiVennCanvas, left, cellSize * 1.1, 0, scale, scale) end --- Render the venn from explicit colour sets: `has` are the channels the player --- possesses (drawn as a circle), `active` the subset shown filled (the rest --- dotted). Restores whatever canvas was bound, so callers drawing straight to --- the screen (e.g. the map overlay) work as well as the in-game HUD pass. -function GameUI.drawColorVennChannels(has, active) - if not gameUiBox then return end +function GameUI.drawColorVenn(room) + if not room or not gameUiBox then return end GameUI.drawBacking() local cellSize = width / gridWidth local sidebarWidth = cellSize * 2 if gameUiBox.w < sidebarWidth or not uiVennCanvas then return end - local previousCanvas = love.graphics.getCanvas() love.graphics.setCanvas(uiVennCanvas) love.graphics.clear(0, 0, 0, 0) love.graphics.setBlendMode("screen") local radius = 9 local dotRadius = 1 + local active = {} + for _, color in ipairs(room:getActiveColors()) do active[color] = true end for _, color in ipairs({"red", "green", "blue"}) do - if has[color] then + if room.player[color] then local layout = vennLayout[color] + local x, y = layout.x, layout.y local rgb = channelColor[color] if active[color] then love.graphics.setColor(rgb[1], rgb[2], rgb[3], 1) - love.graphics.circle("fill", layout.x, layout.y, radius) + love.graphics.circle("fill", x, y, radius) else - drawDottedCircle(layout.x, layout.y, radius, rgb, dotRadius) + drawDottedCircle(x, y, radius, rgb, dotRadius) end end end - love.graphics.setCanvas(previousCanvas) + love.graphics.setCanvas(gameCanvas) GameUI.drawVennOverlay() end -function GameUI.drawColorVenn(room) - if not room or not gameUiBox then return end - local has, active = {}, {} - for _, color in ipairs({"red", "green", "blue"}) do - if room.player[color] then has[color] = true end - end - for _, color in ipairs(room:getActiveColors()) do active[color] = true end - GameUI.drawColorVennChannels(has, active) -end - return GameUI diff --git a/level_editor/editor.lua b/level_editor/editor.lua index 1a106bd..2425ba7 100644 --- a/level_editor/editor.lua +++ b/level_editor/editor.lua @@ -2,7 +2,6 @@ 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" @@ -25,13 +24,6 @@ local hierarchy = {} local activeField = nil --this is for whenwe are doing text input things --- True when the keyboard is captured by a text field (room name, asset search, --- clone name, etc.) or the note editor. Callers use this to keep typed keys — --- notably `m` — from being stolen as global shortcuts like the map toggle. -function editorTextFieldActive() - return activeField ~= nil or noteEditorActive() -end - local uiButtons = {} local folderButtons = {} local assetListButtons = {} -- flat list of the selected folder's assets (scrolls) @@ -69,14 +61,6 @@ 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. @@ -334,7 +318,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", "mark" }, 28) + placeRow({ "save", "clearSaves", "world", "wire", "note" }, 28) end -- Build the full room-view layout (sections + a scroll list + a back/search @@ -770,16 +754,6 @@ 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, @@ -840,8 +814,7 @@ end function selectAsset(asset) print("selecting", asset) selectedAsset = asset - exitNoteMode() -- picking any real asset closes the note text field - markPlacing = false -- ...and disarms a pending mark placement + exitNoteMode() -- picking any real asset closes the note text field print(selectedAsset) end @@ -856,7 +829,6 @@ 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 @@ -886,216 +858,6 @@ 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" @@ -1450,11 +1212,6 @@ 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) @@ -1518,7 +1275,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", "mark" }) 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" }) do local b = uiButtons[name] if b and b:clicIsInBox(x, y) then b:onClic(button) @@ -1573,22 +1330,11 @@ function editorMouseHandler(x, y, button) exitNoteMode() return end - -- 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. + -- Left-clicking an existing note opens it for editing instead of + -- painting over it. 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 @@ -1655,10 +1401,6 @@ 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) @@ -1680,12 +1422,6 @@ 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 @@ -1711,9 +1447,6 @@ 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 @@ -1814,7 +1547,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", "mark" }) 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" }) do local button = uiButtons[name] if button then button:draw() @@ -1823,9 +1556,7 @@ 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) - or (name == "mark" and (markPlacing or markNameMode ~= nil)) then + if (asset and selectedAsset == asset) or (name == "note" and noteMode ~= 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 cdefa04..d7d9a52 100644 --- a/main.lua +++ b/main.lua @@ -15,7 +15,6 @@ require "room" require "world" require "world_seed" require "shaders" -require "map_manager" GameUI = require "game_ui" require "level_editor/editor" @@ -97,11 +96,9 @@ function love.update(dt) Input:stopMoveRepeat() else local repeatedMove = Input:handler(dt) - if repeatedMove and not Dialogue.isOpen() and not MapManager:isOpen() then gameWorld:movePlayer(currentRoom, repeatedMove) end + if repeatedMove and not Dialogue.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 -- if love.keyboard.isDown("s") then worldSha = worldSha - 1 end @@ -111,16 +108,14 @@ function love.draw() love.graphics.setCanvas(gameCanvas) love.graphics.clear() love.graphics.setBlendMode("screen") - if MapManager:isOpen() then - MapManager:draw() - elseif DEBUG and editorView == "world" then + if DEBUG and editorView == "world" then gameWorld:draw() elseif DEBUG and editorView == "wire" then gameWorld:drawWireEditor(wireEditorCamera()) else currentRoom:draw() end - if MapManager:isOpen() then MapManager:drawVenn() else GameUI.drawColorVenn(currentRoom) end + GameUI.drawColorVenn(currentRoom) love.graphics.setCanvas(finalCanvas) love.graphics.clear() @@ -175,8 +170,6 @@ 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() @@ -218,13 +211,6 @@ function love.keypressed(key) return end - -- The map overlay is modal: `m` toggles it, and while open it swallows keys. - -- But not while an editor text field is focused, or typing "m" in a search / - -- name box would open the map instead of entering the character. - if not (DEBUG and editorTextFieldActive()) then - if MapManager:keypressed(key) then return end - end - -- The focused note text field owns the keyboard entirely: arrows navigate the -- text instead of moving the player, Return inserts a newline, etc. if DEBUG and noteEditorActive() then @@ -232,24 +218,6 @@ 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/map_manager.lua b/map_manager.lua deleted file mode 100644 index 9df321b..0000000 --- a/map_manager.lua +++ /dev/null @@ -1,354 +0,0 @@ --- MAP MANAGER -------------------------------------------------------------- --- --- Self-contained "world map" render mode, bound to the `m` key. This module owns --- ALL of its own state, persistence, input handling, and rendering so it can be --- yanked out wholesale: delete this file and its handful of one-line call sites --- in main.lua / _helpers.lua and nothing else knows it existed. --- --- What it tracks (all in its own save file, map.sav, in LÖVE's write directory): --- * `seen` — the set of colours the player has EVER controlled. The game --- starts controlling only red, so red is all that shows until green/blue are --- acquired. A colour never controlled has no layer on the map at all. --- * `rooms` — per room, the set of colours that have actually VISITED it. "Red --- visited room X" is independent of green/blue. --- --- How it renders: an isometric, Mode-7-ish exploded stack. Each room is drawn as --- a sandwich of colour planes offset off-axis — blue on the bottom, then green, --- then red on top. A colour the player HAS but that hasn't visited a given room --- shows as a dotted outline with a "?" instead of that room's real contents. --- Number keys 1/2/3 focus red/green/blue (the same colour order as in play), --- dimming the other planes. --- --- Isolation contract: this file may READ shared globals the rest of the game --- already exposes (gameWorld, currentRoom, width/height, gridWidth/gridHeight, --- gColor, globalAssetProperties, loadSprite, the draw* sprite helpers) but it --- never mutates gameplay state. - -MapManager = {} - -MapManager.saveFile = "map.sav" -MapManager.colors = {"red", "green", "blue"} --- Room edge length on screen for each zoom level, as a fraction of the square --- play area. Three steps, small -> large. -MapManager.zoomFactors = {1 / 9, 1 / 5, 1 / 3} -MapManager.panSpeed = 7 -- rooms per second while an arrow/WASD key is held - --- Draw order for the exploded sandwich: bottom plane first. Red ends up on top. -MapManager.layerOrder = {"blue", "green", "red"} -MapManager.layerSepX = 0.18 -- per-plane off-axis shift right (fraction of room size) -MapManager.layerSepY = 0.42 -- per-plane off-axis shift up (fraction of room size) --- A horizontal shear plus a vertical squash together tilt every plane into an --- oblique/isometric view (a bare shear on its own just reads as italic skew). -MapManager.isoShear = 0.32 -MapManager.isoScaleY = 0.66 -MapManager.openDuration = 0.42 -- seconds for the sandwich to split apart on open (<= 0.5) -MapManager.dimAlpha = 0.16 -- opacity of the non-focused planes when a colour is focused - --- Lazy-load the persisted record. Safe to call repeatedly. -function MapManager:ensureLoaded() - if self.data then return end - self.data = {rooms = {}, seen = {}} - local raw = love.filesystem.read(self.saveFile) - if raw then - local ok, parsed = pcall(TSerial.unpack, raw) - if ok and type(parsed) == "table" and type(parsed.rooms) == "table" then - self.data = parsed - end - end - self.data.seen = self.data.seen or {} - -- Backfill the global "colours ever controlled" set from any per-room visit - -- data saved before that set existed: a visit implies the colour was had. - for _, roomSeen in pairs(self.data.rooms) do - for _, color in ipairs(self.colors) do - if roomSeen[color] then self.data.seen[color] = true end - end - end -end - -function MapManager:save() - self:ensureLoaded() - love.filesystem.write(self.saveFile, TSerial.pack(self.data, nil, true)) -end - --- Observe the room currently on screen. A colour that is actively controlled --- here has both (a) been controlled at all, so the player HAS it globally, and --- (b) visited THIS room. Called once per frame with currentRoom; only writes to --- disk when something new is discovered. -function MapManager:record(room) - if not room or not room.name or not room.colorIsActive then return end - self:ensureLoaded() - local entry = self.data.rooms[room.name] - local changed = false - for _, color in ipairs(self.colors) do - if room:colorIsActive(color) then - if not self.data.seen[color] then self.data.seen[color] = true; changed = true end - if not entry then entry = {}; self.data.rooms[room.name] = entry end - if not entry[color] then entry[color] = true; changed = true end - end - end - if changed then self:save() end -end - --- Editor "trash"/clear-saves hook. roomName clears one room; nil clears all. -function MapManager:onClearRuntime(roomName) - self:ensureLoaded() - if roomName then - self.data.rooms[roomName] = nil - else - self.data = {rooms = {}, seen = {}} - end - self:save() -end - --- INPUT / MODE -------------------------------------------------------------- - -function MapManager:isOpen() - return self.open == true -end - -function MapManager:centerOnCurrent() - self:ensureLoaded() - self.zoom = self.zoom or 2 - local location = currentRoom and gameWorld and gameWorld:roomLocation(currentRoom.name) - self.camX = location and location.x or (gameWorld and gameWorld.width / 2 or 9) - self.camY = location and location.y or (gameWorld and gameWorld.height / 2 or 9) -end - -function MapManager:toggle() - self.open = not self.open - if self.open then - self:centerOnCurrent() - self.openStart = love.timer.getTime() -- (re)start the split-open animation - end -end - --- Focus is a SET of colours, mirroring how the player can have several colour --- copies active at once. Nothing selected (or everything selected) means "all --- planes lit" — the default open state shows every acquired colour at full. -function MapManager:isFocused(color) - if not self.focus or next(self.focus) == nil then return true end - return self.focus[color] == true -end - --- Toggle a colour in/out of the focus set, like selecting a colour in play. --- Ignored for a colour the player has never controlled (nothing to focus). -function MapManager:focusColor(color) - self:ensureLoaded() - if not self.data.seen[color] then return end - self.focus = self.focus or {} - self.focus[color] = not self.focus[color] and true or nil -end - --- Modal keyboard handling. Returns true when the key was consumed. `m` always --- toggles the map; while the map is open every key is swallowed so nothing --- leaks through to the player/editor beneath it. -function MapManager:keypressed(key) - if key == "m" then self:toggle(); return true end - if not self.open then return false end - - if key == "escape" then self.open = false; return true end - if key == "1" then self:focusColor("red"); return true end - if key == "2" then self:focusColor("green"); return true end - if key == "3" then self:focusColor("blue"); return true end - if key == "=" or key == "+" then self.zoom = math.min(#self.zoomFactors, (self.zoom or 2) + 1); return true end - if key == "-" or key == "_" then self.zoom = math.max(1, (self.zoom or 2) - 1); return true end - -- Arrows/WASD pan (applied continuously in :draw); swallow everything else so - -- nothing falls through to move the player. - return true -end - --- RENDERING ----------------------------------------------------------------- - -function MapManager:layerAlpha(color) - return self:isFocused(color) and 1 or self.dimAlpha -end - --- Drive the HUD venn from the map's own state: `seen` colours are shown (dotted --- when not in the focus set), matching the in-play sidebar. Drawn by the normal --- love.draw pass into gameCanvas, so the sidebar/editor panel stay put. -function MapManager:drawVenn() - self:ensureLoaded() - local has, active = {}, {} - for _, color in ipairs(self.colors) do - if self.data.seen[color] then - has[color] = true - if self:isFocused(color) then active[color] = true end - end - end - if GameUI then GameUI.drawColorVennChannels(has, active) end -end - --- A dotted rectangle outline (LÖVE 0.10 has no dashed lines), used for the "not --- yet visited by this colour" placeholder plane. -function MapManager:dottedRect(x, y, w, h, gap) - local function dots(x1, y1, x2, y2) - local dx, dy = x2 - x1, y2 - y1 - local length = math.sqrt(dx * dx + dy * dy) - local n = math.max(1, math.floor(length / gap)) - for i = 0, n do - love.graphics.rectangle("fill", x1 + dx * (i / n) - 1, y1 + dy * (i / n) - 1, 2, 2) - end - end - dots(x, y, x + w, y) - dots(x + w, y, x + w, y + h) - dots(x + w, y + h, x, y + h) - dots(x, y + h, x, y) -end - --- One colour plane of one room, showing only that channel's objects. Mirrors --- World:drawRoomPreview's object loop, filtered to `color`, tinted with `alpha`, --- and drawn additively ("screen") so it matches how the channel reads in play. -function MapManager:drawRoomLayer(roomData, x, y, size, color, alpha) - local scale = size / (gridWidth * 16) - for _, object in ipairs(roomData.objects or {}) do - local onThisChannel - if object.class == "door" then - -- A colourless legacy door is present on every channel. - onThisChannel = object.color == nil or object.color == color - elseif object.class == "npc" or object.class == "jorge" or object.class == "note" then - onThisChannel = false - else - onThisChannel = object.color == color - end - if onThisChannel then - local props = globalAssetProperties and globalAssetProperties[object.class] - if props and props.sprites and props.sprites[color] then - local r, g, b = gColor[color]:set() - love.graphics.setColor(r, g, b, alpha) - local sprite = loadSprite(props.sprites[color]) - local dx, dy = x + (object.x - 1) * 16 * scale, y + (object.y - 1) * 16 * scale - if props.goopy and drawGoopySprite then - drawGoopySprite(sprite, dx, dy, scale) - elseif props.skewer and drawSkewerSprite then - drawSkewerSprite(sprite, props.cells and props.cells[color], dx, dy, scale) - elseif props.spiky and drawSpikySprite then - drawSpikySprite(sprite, props.cells and props.cells[color], dx, dy, scale) - else - love.graphics.draw(sprite, dx, dy, 0, scale, scale) - end - end - end - end -end - --- Placeholder plane: this colour has been acquired but hasn't reached this room. -function MapManager:drawMissingLayer(x, y, size, color, alpha) - local r, g, b = gColor[color]:set() - love.graphics.setColor(r, g, b, alpha * 0.7) - self:dottedRect(x + 2, y + 2, size - 4, size - 4, math.max(4, size * 0.09)) - - local font = love.graphics.getFont() - local qscale = size / 48 - local text = "?" - love.graphics.setColor(r, g, b, alpha) - love.graphics.print(text, x + size / 2 - font:getWidth(text) * qscale / 2, - y + size / 2 - font:getHeight() * qscale / 2, 0, qscale, qscale) -end - --- The exploded colour sandwich for one room. sepX/sepY are the per-plane --- offsets already scaled by the split-open animation. -function MapManager:drawRoomStack(name, roomSeen, baseX, baseY, size, isCurrent, sepX, sepY) - local roomData = gameWorld:getRoomData(name) - local topX, topY - for depth, color in ipairs(self.layerOrder) do -- blue, green, red (bottom -> top) - if self.data.seen[color] then - local lx = baseX + (depth - 1) * size * sepX - local ly = baseY - (depth - 1) * size * sepY - local alpha = self:layerAlpha(color) - topX, topY = lx, ly - - -- Faint backing so even an empty/placeholder plane reads as a card. - love.graphics.setBlendMode("alpha") - love.graphics.setColor(0.06, 0.06, 0.09, alpha * 0.85) - love.graphics.rectangle("fill", lx, ly, size, size) - - if roomSeen[color] then - if roomData then - love.graphics.setBlendMode("screen") - self:drawRoomLayer(roomData, lx, ly, size, color, alpha) - end - else - love.graphics.setBlendMode("alpha") - self:drawMissingLayer(lx, ly, size, color, alpha) - end - - love.graphics.setBlendMode("alpha") - local r, g, b = gColor[color]:set() - love.graphics.setColor(r, g, b, alpha * 0.5) - love.graphics.setLineWidth(1) - love.graphics.rectangle("line", lx, ly, size, size) - end - end - if isCurrent and topX then - love.graphics.setBlendMode("alpha") - love.graphics.setColor(1, 1, 1, 0.9) - love.graphics.setLineWidth(2) - love.graphics.rectangle("line", topX - 2, topY - 2, size + 4, size + 4) - end -end - -function MapManager:draw() - self:ensureLoaded() - if not self.zoom then self:centerOnCurrent() end - - -- Continuous pan from held keys (draw runs every frame while open). - local dt = love.timer.getDelta() - local step = self.panSpeed * dt - if love.keyboard.isDown("left", "a") then self.camX = self.camX - step end - if love.keyboard.isDown("right", "d") then self.camX = self.camX + step end - if love.keyboard.isDown("up", "w") then self.camY = self.camY - step end - if love.keyboard.isDown("down", "s") then self.camY = self.camY + step end - - -- Only the square play area is replaced; the venn/editor panels draw on top - -- of this canvas later in love.draw, so they stay put. - love.graphics.setBlendMode("alpha") - love.graphics.setColor(0.02, 0.02, 0.04, 1) - love.graphics.rectangle("fill", 0, 0, width, height) - - -- Split-open animation: the sandwich starts collapsed into a single plane and - -- eases apart. easeOutCubic for a fast, settling feel. - local progress = 1 - if self.openStart then - progress = math.min(1, (love.timer.getTime() - self.openStart) / self.openDuration) - end - local split = 1 - (1 - progress) * (1 - progress) * (1 - progress) - local sepX, sepY = self.layerSepX * split, self.layerSepY * split - - local roomSize = width * self.zoomFactors[self.zoom] - local currentName = currentRoom and currentRoom.name - local anyVisible = false - - -- Shear + vertical squash tilt every plane into an oblique/isometric view. - love.graphics.push() - love.graphics.translate(width / 2, height / 2) - love.graphics.shear(self.isoShear, 0) - love.graphics.scale(1, self.isoScaleY) - love.graphics.translate(-width / 2, -height / 2) - - for name, roomSeen in pairs(self.data.rooms) do - local location = gameWorld and gameWorld:roomLocation(name) - if location and next(roomSeen) then - local baseX = width / 2 + (location.x - self.camX) * roomSize - roomSize / 2 - local baseY = height / 2 + (location.y - self.camY) * roomSize - roomSize / 2 - -- Generous cull: the stacked planes reach up/right of the base cell. - if baseX + roomSize * 2 > 0 and baseX - roomSize < width and - baseY + roomSize * 2 > 0 and baseY - roomSize < height then - anyVisible = true - self:drawRoomStack(name, roomSeen, baseX, baseY, roomSize, name == currentName, sepX, sepY) - end - end - end - - love.graphics.pop() - - -- HUD (untransformed). - love.graphics.setBlendMode("alpha") - love.graphics.setColor(1, 1, 1, 0.85) - love.graphics.print("MAP [arrows/wasd] pan [-/+] zoom [1/2/3] r/g/b layer [m/esc] close", 10, 10) - if not anyVisible then - love.graphics.printf("no explored rooms in view", 0, height / 2, width, "center") - end - love.graphics.setLineWidth(1) -end - -return MapManager diff --git a/mark.lua b/mark.lua deleted file mode 100644 index 91454be..0000000 --- a/mark.lua +++ /dev/null @@ -1,78 +0,0 @@ -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 b5fb70a..4df840d 100644 --- a/npc.lua +++ b/npc.lua @@ -1,222 +1,11 @@ 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 @@ -230,12 +19,10 @@ 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 @@ -250,5 +37,4 @@ function Jorge:initialize(x, y) blue = loadSprite("art/npc/jorge_b.png"), }, }) - self:initWalker() end diff --git a/pathfinding.lua b/pathfinding.lua deleted file mode 100644 index 66976ea..0000000 --- a/pathfinding.lua +++ /dev/null @@ -1,125 +0,0 @@ --- 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 18dd662..d3d978f 100644 --- a/room.lua +++ b/room.lua @@ -18,7 +18,6 @@ require "generic_spiky" require "generic_unmoveable" require "generic_player_blocker" require "note" -require "mark" local Movement = require "movement" local Wire = require "wire" @@ -33,11 +32,7 @@ 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 = {} @@ -177,21 +172,16 @@ 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 - -- 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 + for _, npc in ipairs(self.npcs) do npc:draw(channel) end end - -- 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 + -- 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 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 @@ -766,11 +756,6 @@ 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" @@ -883,12 +868,9 @@ function Room:registerPlayer(entityPointer, color) end function Room:registerNpc(entity) - -- 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 + -- 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. table.insert(self.npcs, entity) end @@ -908,44 +890,6 @@ 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 @@ -1016,21 +960,17 @@ function Room:attemptDeleteAtCell(cell, color) table.remove(self.wiredSwitches, 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 + -- 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 -- 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 @@ -1051,17 +991,6 @@ 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 @@ -1226,14 +1155,6 @@ 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 deleted file mode 100644 index 0806d5c..0000000 --- a/rooms/room_16_17.sav +++ /dev/null @@ -1,888 +0,0 @@ -{ -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 deleted file mode 100644 index 39830b6..0000000 --- a/rooms/testing_movement.sav +++ /dev/null @@ -1,917 +0,0 @@ -{ -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 ebc776a..f4381e0 100644 --- a/rooms/world.sav +++ b/rooms/world.sav @@ -1,7 +1,6 @@ { -width=18, height=18, -lastRoom="room_16_17", +lastRoom="blocker_test", rooms={ { name="3", @@ -282,16 +281,7 @@ 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 61d860c..8aad2e8 100644 --- a/stories/jorge.ink +++ b/stories/jorge.ink @@ -4,10 +4,4 @@ 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 ccf60e6..bf52bfc 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."},{["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"]={}} +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 diff --git a/tests/test_movement.lua b/tests/test_movement.lua index b2618ba..af0c302 100644 --- a/tests/test_movement.lua +++ b/tests/test_movement.lua @@ -355,12 +355,6 @@ 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 d261acf..35187ca 100644 --- a/world.lua +++ b/world.lua @@ -213,9 +213,6 @@ 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 {} @@ -224,13 +221,6 @@ 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 @@ -242,19 +232,16 @@ function World:rebuildCollision() end end 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 + -- 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 end end for color, entities in pairs(room.playerBlockers) do @@ -284,17 +271,6 @@ 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 @@ -819,25 +795,6 @@ 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]