map idea
This commit is contained in:
parent
6d41894036
commit
ad405ed590
5 changed files with 397 additions and 12 deletions
354
map_manager.lua
Normal file
354
map_manager.lua
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue