This commit is contained in:
Your Name 2026-08-21 10:57:32 -04:00
parent 6d41894036
commit ad405ed590
5 changed files with 397 additions and 12 deletions

354
map_manager.lua Normal file
View file

@ -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