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

View file

@ -86,6 +86,7 @@ end
-- Runtime saves live in LÖVE's write directory. This deliberately uses only -- Runtime saves live in LÖVE's write directory. This deliberately uses only
-- love.filesystem operations: they cannot delete files from the source tree. -- love.filesystem operations: they cannot delete files from the source tree.
function clearRuntimeSaveData(roomName) function clearRuntimeSaveData(roomName)
if MapManager then MapManager:onClearRuntime(roomName) end
local removed = 0 local removed = 0
if roomName then if roomName then
-- love.filesystem.remove only affects the writable save directory, never -- love.filesystem.remove only affects the writable save directory, never

View file

@ -51,38 +51,50 @@ function GameUI.drawVennOverlay()
love.graphics.draw(uiVennCanvas, left, cellSize * 1.1, 0, scale, scale) love.graphics.draw(uiVennCanvas, left, cellSize * 1.1, 0, scale, scale)
end end
function GameUI.drawColorVenn(room) -- Render the venn from explicit colour sets: `has` are the channels the player
if not room or not gameUiBox then return end -- 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() GameUI.drawBacking()
local cellSize = width / gridWidth local cellSize = width / gridWidth
local sidebarWidth = cellSize * 2 local sidebarWidth = cellSize * 2
if gameUiBox.w < sidebarWidth or not uiVennCanvas then return end if gameUiBox.w < sidebarWidth or not uiVennCanvas then return end
local previousCanvas = love.graphics.getCanvas()
love.graphics.setCanvas(uiVennCanvas) love.graphics.setCanvas(uiVennCanvas)
love.graphics.clear(0, 0, 0, 0) love.graphics.clear(0, 0, 0, 0)
love.graphics.setBlendMode("screen") love.graphics.setBlendMode("screen")
local radius = 9 local radius = 9
local dotRadius = 1 local dotRadius = 1
local active = {}
for _, color in ipairs(room:getActiveColors()) do active[color] = true end
for _, color in ipairs({"red", "green", "blue"}) do for _, color in ipairs({"red", "green", "blue"}) do
if room.player[color] then if has[color] then
local layout = vennLayout[color] local layout = vennLayout[color]
local x, y = layout.x, layout.y
local rgb = channelColor[color] local rgb = channelColor[color]
if active[color] then if active[color] then
love.graphics.setColor(rgb[1], rgb[2], rgb[3], 1) 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 else
drawDottedCircle(x, y, radius, rgb, dotRadius) drawDottedCircle(layout.x, layout.y, radius, rgb, dotRadius)
end end
end end
end end
love.graphics.setCanvas(gameCanvas) love.graphics.setCanvas(previousCanvas)
GameUI.drawVennOverlay() GameUI.drawVennOverlay()
end 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 return GameUI

View file

@ -24,6 +24,13 @@ local hierarchy = {}
local activeField = nil --this is for whenwe are doing text input things 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 uiButtons = {}
local folderButtons = {} local folderButtons = {}
local assetListButtons = {} -- flat list of the selected folder's assets (scrolls) local assetListButtons = {} -- flat list of the selected folder's assets (scrolls)

View file

@ -15,6 +15,7 @@ require "room"
require "world" require "world"
require "world_seed" require "world_seed"
require "shaders" require "shaders"
require "map_manager"
GameUI = require "game_ui" GameUI = require "game_ui"
require "level_editor/editor" require "level_editor/editor"
@ -96,9 +97,10 @@ function love.update(dt)
Input:stopMoveRepeat() Input:stopMoveRepeat()
else else
local repeatedMove = Input:handler(dt) 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 end
currentRoom:update(dt) currentRoom:update(dt)
MapManager:record(currentRoom)
-- if love.keyboard.isDown("w") then worldSha = worldSha + 1 end -- if love.keyboard.isDown("w") then worldSha = worldSha + 1 end
-- if love.keyboard.isDown("s") 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.setCanvas(gameCanvas)
love.graphics.clear() love.graphics.clear()
love.graphics.setBlendMode("screen") love.graphics.setBlendMode("screen")
if DEBUG and editorView == "world" then if MapManager:isOpen() then
MapManager:draw()
elseif DEBUG and editorView == "world" then
gameWorld:draw() gameWorld:draw()
elseif DEBUG and editorView == "wire" then elseif DEBUG and editorView == "wire" then
gameWorld:drawWireEditor(wireEditorCamera()) gameWorld:drawWireEditor(wireEditorCamera())
else else
currentRoom:draw() currentRoom:draw()
end end
GameUI.drawColorVenn(currentRoom) if MapManager:isOpen() then MapManager:drawVenn() else GameUI.drawColorVenn(currentRoom) end
love.graphics.setCanvas(finalCanvas) love.graphics.setCanvas(finalCanvas)
love.graphics.clear() love.graphics.clear()
@ -211,6 +215,13 @@ function love.keypressed(key)
return return
end 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 -- The focused note text field owns the keyboard entirely: arrows navigate the
-- text instead of moving the player, Return inserts a newline, etc. -- text instead of moving the player, Return inserts a newline, etc.
if DEBUG and noteEditorActive() then if DEBUG and noteEditorActive() then

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