-- 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 object.adhesion == "gluey" and drawGlueySprite then drawGlueySprite(sprite, props.cells and props.cells[color], dx, dy, scale) elseif object.adhesion == "sticky" and drawGoopySprite then drawGoopySprite(sprite, 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