diff --git a/_helpers.lua b/_helpers.lua index 002beb8..215454c 100644 --- a/_helpers.lua +++ b/_helpers.lua @@ -86,6 +86,7 @@ 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/game_ui.lua b/game_ui.lua index 7242112..7f77436 100644 --- a/game_ui.lua +++ b/game_ui.lua @@ -51,38 +51,50 @@ function GameUI.drawVennOverlay() love.graphics.draw(uiVennCanvas, left, cellSize * 1.1, 0, scale, scale) end -function GameUI.drawColorVenn(room) - if not room or not gameUiBox then return 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 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 room.player[color] then + if has[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", x, y, radius) + love.graphics.circle("fill", layout.x, layout.y, radius) else - drawDottedCircle(x, y, radius, rgb, dotRadius) + drawDottedCircle(layout.x, layout.y, radius, rgb, dotRadius) end end end - love.graphics.setCanvas(gameCanvas) + love.graphics.setCanvas(previousCanvas) 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 2425ba7..3d4b09b 100644 --- a/level_editor/editor.lua +++ b/level_editor/editor.lua @@ -24,6 +24,13 @@ 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) diff --git a/main.lua b/main.lua index d7d9a52..6281f58 100644 --- a/main.lua +++ b/main.lua @@ -15,6 +15,7 @@ require "room" require "world" require "world_seed" require "shaders" +require "map_manager" GameUI = require "game_ui" require "level_editor/editor" @@ -96,9 +97,10 @@ function love.update(dt) Input:stopMoveRepeat() else local repeatedMove = Input:handler(dt) - if repeatedMove and not Dialogue.isOpen() then gameWorld:movePlayer(currentRoom, repeatedMove) end + if repeatedMove and not Dialogue.isOpen() and not MapManager:isOpen() then gameWorld:movePlayer(currentRoom, repeatedMove) end end currentRoom:update(dt) + MapManager:record(currentRoom) -- if love.keyboard.isDown("w") then worldSha = worldSha + 1 end -- if love.keyboard.isDown("s") then worldSha = worldSha - 1 end @@ -108,14 +110,16 @@ function love.draw() love.graphics.setCanvas(gameCanvas) love.graphics.clear() love.graphics.setBlendMode("screen") - if DEBUG and editorView == "world" then + if MapManager:isOpen() then + MapManager:draw() + elseif DEBUG and editorView == "world" then gameWorld:draw() elseif DEBUG and editorView == "wire" then gameWorld:drawWireEditor(wireEditorCamera()) else currentRoom:draw() end - GameUI.drawColorVenn(currentRoom) + if MapManager:isOpen() then MapManager:drawVenn() else GameUI.drawColorVenn(currentRoom) end love.graphics.setCanvas(finalCanvas) love.graphics.clear() @@ -211,6 +215,13 @@ 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 diff --git a/map_manager.lua b/map_manager.lua new file mode 100644 index 0000000..9df321b --- /dev/null +++ b/map_manager.lua @@ -0,0 +1,354 @@ +-- 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