Collapse the spiky/sticky/skewer box-variant assets into a single per-
instance `adhesion` property on any moveable block, chosen in the editor
via a dropdown next to the colour picker and stored in the .sav alongside
the class.
- spiky -> sticky: soft grab on every true edge (goopy shader)
- skewer -> gluey: rigid weld on every true edge (spiked outline)
- old trailing-face sticky is removed; its saves fold into new sticky
Move the spike geometry + drawGlueySprite into shaders.lua, fold drawing
and the adhesion flag into GenericMoveable, and delete the three
Generic{Sticky,Spiky,Skewer} classes and box_variants.lua. Room:createEntity
normalises legacy spiky_/skewer_/sticky_ class prefixes so old saves load,
and serialization emits `adhesion`. World/map previews and the movement
proxy follow the rename.
Backfill rooms/*.sav to the new format via tools/backfill_adhesion.lua
(41 objects across 8 files). Update tests to the two-mode model; the two
tests asserting the removed lateral-shear limitation are dropped and a
legacy-load migration test added. 27/27 pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
352 lines
14 KiB
Lua
352 lines
14 KiB
Lua
-- 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
|