Files
gen1recomp/tools/save-editor/panels/MapBrowser.lua
T

1007 lines
34 KiB
Lua

-- Map browser: view any map, follow its warps, and set the save's spawn point
-- / remembered outdoor or heal spot by clicking cells on the rendered map.
-- Reuses the game's own MapLoader/TileRenderer/Warp so the editor's view
-- matches what the player would actually see.
--
-- Three columns: a searchable map list, the viewport, and the spawn
-- inspector. Overlays are drawn in this order so the selection always wins:
-- cyan hollow warp cell (clicking follows the warp)
-- red filled the save's player position
-- green / amber lastHeal / lastOutdoor
-- yellow hollow the current click selection
local MapLoader = require("src.world.MapLoader")
local Warp = require("src.world.Warp")
local Theme = require("Theme")
local Ops = require("Ops")
local Gen = require("Gen")
local PAL = Theme.PAL
local MapBrowser = {}
local Motion = require("Motion")
local Chooser = require("Chooser")
local CELL = 16 -- the walk grid; a cell is 16px of map art
local function playerPos(S)
local map, x, y = Gen.playerMap(S.save)
return map, x or 0, y or 0
end
local function clampZoom(z)
if z < 0.03125 then
return 0.03125
end
if z > 4 then
return 4
end
return z
end
-- Point the camera so (cx,cy) lands in the middle of the viewport. The
-- viewport size is only known while drawing, so it is stashed on S.
local function centerOn(S, cx, cy)
local vw = S._mapViewW or 480
local vh = S._mapViewH or 432
S.mapCamX = cx * CELL - vw / (2 * S.mapZoom)
S.mapCamY = cy * CELL - vh / (2 * S.mapZoom)
end
MapBrowser.centerOn = centerOn
local function zoomTo(S, zoom)
local old = S.mapZoom or 2
local vw, vh = S._mapViewW or 0, S._mapViewH or 0
local cx = (S.mapCamX or 0) + vw / (2 * old)
local cy = (S.mapCamY or 0) + vh / (2 * old)
S.mapZoom = clampZoom(zoom)
S.mapCamX, S.mapCamY = cx - vw / (2 * S.mapZoom), cy - vh / (2 * S.mapZoom)
S.mapAutoFit = false
end
local function stepZoom(S, direction)
local zoom = S.mapZoom or 2
local levels = { 0.03125, 0.0625, 0.125, 0.25, 0.5, 0.75, 1, 1.5, 2, 2.5, 3, 3.5, 4 }
if direction > 0 then
for _, value in ipairs(levels) do
if value > zoom + 0.001 then zoomTo(S, value); return end
end
else
for i = #levels, 1, -1 do
if levels[i] < zoom - 0.001 then zoomTo(S, levels[i]); return end
end
end
end
function MapBrowser.fit(S, map)
local wc = map.widthCells or (map.width or 1) * 2
local hc = map.heightCells or (map.height or 1) * 2
S.mapZoom = clampZoom(math.min((S._mapViewW or 1) / (wc * CELL), (S._mapViewH or 1) / (hc * CELL)) * 0.96)
centerOn(S, wc / 2, hc / 2)
S.mapAutoFit = true
end
local function showPlayer(S)
local id = playerPos(S)
if id then
if S.mapId ~= id then MapBrowser.select(S, id) end
S._mapCenterPlayer = true
end
end
local function insideView(S, x, y)
local r = S._mapViewRect
return r and x >= r.x and x < r.x + r.w and y >= r.y and y < r.y + r.h
end
MapBrowser.contains = insideView
local function panTo(S, x, y)
local d = S._mapDrag
if not d then return end
local dx, dy = x - d.mx, y - d.my
if not d.moved and math.abs(dx) + math.abs(dy) <= 10 then return end
d.moved = true
S.mapCamX, S.mapCamY = d.camX - dx / S.mapZoom, d.camY - dy / S.mapZoom
S.mapAutoFit = false
end
-- Keep the map point beneath the pinch midpoint fixed while zooming.
function MapBrowser.touchpressed(S, id, x, y)
if S.tab ~= "map" or S.navPopup or S.editPopup or Motion.active()
or (S._mapStacked and S.mapSection ~= "view") or not insideView(S, x, y) then return false end
if S._mapPinch then return true end
S._mapTouches = S._mapTouches or {}
S._mapTouches[id] = { x = x, y = y }
local first, second
for key in pairs(S._mapTouches) do
if not first then first = key elseif not second then second = key end
end
if not second then return false end
local a, b = S._mapTouches[first], S._mapTouches[second]
local mx, my = (a.x + b.x) / 2, (a.y + b.y) / 2
local r = S._mapViewRect
S._mapPinch = {
first = first, second = second,
distance = math.max(1, math.sqrt((a.x - b.x)^2 + (a.y - b.y)^2)),
zoom = S.mapZoom,
wx = S.mapCamX + (mx - r.x) / S.mapZoom,
wy = S.mapCamY + (my - r.y) / S.mapZoom,
}
S._mapDrag, S.mapAutoFit = nil, false
return true
end
function MapBrowser.touchmoved(S, id, x, y)
local points = S._mapTouches
if not points or not points[id] then return false end
points[id].x, points[id].y = x, y
local pinch, r = S._mapPinch, S._mapViewRect
if not pinch then panTo(S, x, y); return false end
if not r then return false end
local a, b = points[pinch.first], points[pinch.second]
if not a or not b then return false end
local distance = math.sqrt((a.x - b.x)^2 + (a.y - b.y)^2)
S.mapZoom = clampZoom(pinch.zoom * distance / pinch.distance)
S.mapCamX = pinch.wx - ((a.x + b.x) / 2 - r.x) / S.mapZoom
S.mapCamY = pinch.wy - ((a.y + b.y) / 2 - r.y) / S.mapZoom
return true
end
function MapBrowser.touchreleased(S, id)
local points = S._mapTouches
if not points or not points[id] then return false end
points[id] = nil
local pinched = S._mapPinch ~= nil
S._mapPinch, S._mapDrag = nil, nil
return pinched
end
function MapBrowser.clearTouches(S)
S._mapTouches, S._mapPinch, S._mapDrag = nil, nil, nil
end
local function sortedMapIds(data)
local ids = {}
for id in pairs(Gen.maps(data)) do
ids[#ids + 1] = id
end
table.sort(ids)
return ids
end
-- LAST_MAP warps resolve against the remembered outdoor spot; skip with a
-- status message if the save has none (fresh games, or old saves). Mirrors
-- the game: leaving an OVERWORLD/PLATEAU map via a warp updates lastOutdoor
-- so building exits (Indigo lobby, Route 22 Gate, ...) return to the map you
-- entered from.
local OUTSIDE_TILESETS = { OVERWORLD = true, PLATEAU = true }
local function goToWarp(S, warp)
local def = warp.def
if Gen.of(S.save) == 3 or Gen.of(S.save) == 2 then
local dest = def.destMap or def.map
if dest then
MapBrowser.select(S, dest)
S.status = "Followed warp to " .. tostring(dest)
else
S.status = "Warp has no destination map"
end
return
end
local fromMap = Gen.maps(S.data)[S.mapId]
if
fromMap
and OUTSIDE_TILESETS[fromMap.tileset]
and def.destMap ~= "LAST_MAP"
and def.destMap ~= S.mapId
then
S.save.lastOutdoor = { id = S.mapId, x = def.x, y = def.y }
end
if def.destMap == "LAST_MAP" and not S.save.lastOutdoor then
S.status = "Can't follow warp: no remembered outdoor map (lastOutdoor unset)"
return
end
local ok, destMap, dx, dy = pcall(Warp.destination, S.data, def, S.save.lastOutdoor)
if not ok then
S.status = "Warp failed: " .. tostring(destMap)
return
end
S.mapId = destMap
S.mapClickCell = nil
centerOn(S, dx, dy)
S.mapAutoFit = false
-- claim the lazy first-draw centering below, so it does not immediately
-- re-centre the destination map and lose the warp's landing cell
S._mapCenteredFor = destMap
S.status = "Followed warp to " .. destMap
end
-- Screen-space point inside the viewport -> map cell, or nil if the point is
-- outside the viewport or off the edge of the map.
local function cellAtScreen(S, map, Kit, vx, vy, vw, vh)
if Kit.mouseX < vx or Kit.mouseX >= vx + vw or Kit.mouseY < vy or Kit.mouseY >= vy + vh then
return nil
end
local wx = (Kit.mouseX - vx) / S.mapZoom + S.mapCamX
local wy = (Kit.mouseY - vy) / S.mapZoom + S.mapCamY
local cx, cy = math.floor(wx / CELL), math.floor(wy / CELL)
if not map:inBounds(cx, cy) then
return nil
end
return cx, cy
end
-- Wired from App.wheelmoved while the Map tab is active.
function MapBrowser.wheelmoved(S, dy)
if dy ~= 0 then stepZoom(S, dy) end
end
local PAN_KEYS = {
up = { 0, -CELL },
w = { 0, -CELL },
down = { 0, CELL },
s = { 0, CELL },
left = { -CELL, 0 },
a = { -CELL, 0 },
right = { CELL, 0 },
d = { CELL, 0 },
}
-- Wired from App.keypressed while the Map tab is active.
function MapBrowser.keypressed(S, key)
local d = PAN_KEYS[key]
if not d then
return
end
S.mapCamX = (S.mapCamX or 0) + d[1]
S.mapCamY = (S.mapCamY or 0) + d[2]
S.mapAutoFit = false
end
-- Select a map by id. The camera is left to the first-draw centering in
-- draw(), which knows the viewport size and so can actually centre.
function MapBrowser.select(S, id)
S.mapId = id
S.mapClickCell = nil
S._mapCenteredFor = nil
MapBrowser.clearTouches(S)
S.status = "Viewing " .. id
end
-- Called inside the viewport's translate+scale transform, so every rect is
-- in map space: a cell is CELL units wide whatever the zoom is.
local function drawOverlays(S, map)
local function cellRect(cx, cy)
return cx * CELL - S.mapCamX, cy * CELL - S.mapCamY, CELL, CELL
end
love.graphics.setColor(0.27, 0.59, 1, 0.55)
for _, wdef in ipairs(map.def.warps or {}) do
love.graphics.rectangle("line", cellRect(wdef.x, wdef.y))
end
local playerMap, px, py = playerPos(S)
if playerMap == S.mapId then
love.graphics.setColor(1, 0.36, 0.4, 0.9)
love.graphics.rectangle("fill", cellRect(px, py))
end
if Gen.of(S.save) == 3 then
local healMap = S.save.healMap
if healMap == S.mapId and S.save.healX and S.save.healY then
love.graphics.setColor(0.24, 0.88, 0.54, 0.9)
love.graphics.rectangle("line", cellRect(S.save.healX, S.save.healY))
end
elseif Gen.of(S.save) ~= 2 then
local heal = S.save.lastHeal
if heal and heal.map == S.mapId then
love.graphics.setColor(0.24, 0.88, 0.54, 0.9)
love.graphics.rectangle("line", cellRect(heal.x, heal.y))
end
local out = S.save.lastOutdoor
if out and out.id == S.mapId then
love.graphics.setColor(1, 0.8, 0.02, 0.9)
love.graphics.rectangle("line", cellRect(out.x, out.y))
end
end
if S.mapClickCell then
love.graphics.setColor(1, 1, 0.35, 0.95)
love.graphics.rectangle("line", cellRect(S.mapClickCell.cx, S.mapClickCell.cy))
end
love.graphics.setColor(1, 1, 1, 1)
end
local function gridMap(def, id, generation)
local factor = generation == 3 and 1 or 2
local map = {
id = id, def = def, width = def.width, height = def.height,
widthCells = def.width * factor, heightCells = def.height * factor,
warps = def.warps or {},
}
function map:inBounds(cx, cy)
return cx >= 0 and cy >= 0 and cx < self.widthCells and cy < self.heightCells
end
function map:warpAtCell(cx, cy)
for i, warp in ipairs(self.warps) do
if warp.x == cx and warp.y == cy then return { index = i, def = warp } end
end
end
return map
end
function MapBrowser.preview(S)
local generation = Gen.of(S.save, S.version)
local def = Gen.maps(S.data)[S.mapId]
if not def then return false, "unknown map" end
if generation == 3 and not def.midLayout then
S._mapNativeTried = S._mapNativeTried or {}
if not S._mapNativeTried[def] then
S._mapNativeTried[def] = true
pcall(function()
require("src.core.game3.map").ensureMidLayout({ data = S.data }, S.mapId, def)
end)
end
end
if type(def.width) ~= "number" or type(def.height) ~= "number" then
return false, "incomplete map record (missing width/height)"
end
local map = gridMap(def, S.mapId, generation)
local reason
if generation == 3 then
local layout = def.midLayout
local pair = def.pair or (layout and layout.pair)
if not layout or type(layout.midAt) ~= "function" then
reason = "missing native map layout"
elseif not pair then
reason = "missing native tileset pair"
else
local NativeTileset = require("src.core.game3.tileset_native")
local loaded, ts = pcall(NativeTileset.get, pair)
if not loaded or not ts or not ts.image then
reason = "native tileset image unavailable: " .. tostring(pair)
else
function map:tileAtCell(cx, cy) return layout:midAt(cx, cy) or 0 end
map.renderer = {
draw = function(_, camX, camY)
local zoom = S.mapZoom or 1
local startCx = math.max(0, math.floor(camX / CELL) - 1)
local endCx = math.min(def.width - 1, math.ceil((camX + (S._mapViewW or 480) / zoom) / CELL) + 1)
local startCy = math.max(0, math.floor(camY / CELL) - 1)
local endCy = math.min(def.height - 1, math.ceil((camY + (S._mapViewH or 432) / zoom) / CELL) + 1)
love.graphics.setColor(1, 1, 1, 1)
for cy = startCy, endCy do
for cx = startCx, endCx do
local mid = layout:midAt(cx, cy)
if mid and mid >= 0 then
local slot = NativeTileset.slotFor(ts, mid)
local quad = NativeTileset.quad(ts, slot)
if quad then love.graphics.draw(ts.image, quad, cx * CELL - camX, cy * CELL - camY) end
if ts.layered and ts.overImage then
local over = NativeTileset.overQuad(ts, slot)
if over then love.graphics.draw(ts.overImage, over, cx * CELL - camX, cy * CELL - camY) end
end
end
end
end
end,
}
end
end
else
local tileset = Gen.tilesets(S.data)[def.tileset]
if not tileset then
reason = "missing tileset: " .. tostring(def.tileset)
elseif not tileset.image then
reason = "missing tileset image: " .. tostring(def.tileset)
elseif not tileset.blocks then
reason = "missing tileset blocks: " .. tostring(def.tileset)
elseif generation == 2 then
local loadedMap, built = pcall(require("src.world.gen2.Map").new, def, tileset)
if not loadedMap then return false, built end
map = built
local MapPreview = require("src.world.gen2.MapPreview")
S._g2MapBaker = S._g2MapBaker or MapPreview.baker({
tilesets = Gen.tilesets(S.data), gen2Roofs = S.data.gen2Roofs,
roofs = S.data.roofs, gen2Palettes = S.data.gen2Palettes, palettes = S.data.palettes,
})
local loaded, renderer = pcall(MapPreview.renderer, S._g2MapBaker, map)
if loaded and renderer then map.renderer = renderer
else reason = "tileset image unavailable: " .. tostring(tileset.image) end
else
local loaded, rendered = pcall(MapLoader.load, S.data, S.mapId)
if loaded then map = rendered
else reason = "tileset image unavailable: " .. tostring(tileset.image) end
end
end
return true, map, reason and ("Preview unavailable: " .. reason .. ". Coordinate grid remains available.")
end
local function drawSection(S, Kit, x, y, w, h)
local s = Kit.scale
local gap = 20 * s
local pad = 16 * s
S.mapQuery = S.mapQuery or ""
S.mapZoom = clampZoom(S.mapZoom or 2)
-- Column plan (#715). Side by side, the list and spawn cards claim ~470
-- logical px before the viewport gets anything, and a portrait phone does
-- not have it: the old layout answered by laying the viewport out at a
-- negative width, which the scissor below rejected ("Can't set scissor
-- with negative width and/or height") and took the whole editor down.
-- Phones open Maps / View / Spawn as sliding pages. Every viewport
-- dimension is clamped so no window shape creates a negative scissor.
local listW = math.max(200 * s, math.min(260 * s, w * 0.2))
local sideW = math.max(230 * s, math.min(300 * s, w * 0.22))
local viewW = w - listW - sideW - 2 * gap
local stacked = S.mapFocused or h > w or viewW < 260 * s
S._mapStacked = stacked
local lr, vr, sr -- list / viewport / spawn card rects
local row, gapNav = Kit.controlH(), 6 * s
local labelW = 48 * s
local inlineZoom = stacked and w > h and w >= 5 * row + labelW + 6 * gapNav + 100 * s
if stacked then
S.mapSection = S.mapSection or "view"
local sections = { { "maps", "Maps" }, { "view", "View" }, { "spawn", "Spawn" } }
if Kit.iconButton(x, y, row, row, S.mapFocused and "chevron-left" or "expand",
S.mapFocused and "Back to editor" or "Focus map") then
S.mapFocused = not S.mapFocused
MapBrowser.clearTouches(S)
Kit.blur()
end
local navX, navW = x + row + gapNav, w - row - gapNav
if inlineZoom and S.mapSection == "view" then
navW = w - 5 * row - labelW - 6 * gapNav
Chooser.navigation(S, Kit, "mapSection", "Map section", sections, navX, y, navW, row)
local zx = navX + navW + gapNav
if Kit.stepper(zx, y, row, row, "minus") then
stepZoom(S, -1)
end
Kit.textCenter(
"small",
("%d%%"):format(math.floor(S.mapZoom * 100 + 0.5)),
zx + row + gapNav,
y + (row - Kit.textHeight("small")) / 2,
labelW,
PAL.caption
)
zx = zx + row + labelW + 2 * gapNav
if Kit.stepper(zx, y, row, row, "plus") then stepZoom(S, 1) end
if Kit.iconButton(zx + row + gapNav, y, row, row, "grid-2x2", "Fit map") then
S._mapFitRequested = true
end
if Kit.iconButton(x + w - row, y, row, row, "map-pin", "Center on player") then
showPlayer(S)
end
else
Chooser.navigation(
S,
Kit,
"mapSection",
"Map section",
sections,
navX,
y,
math.min(navW, 360 * s),
row
)
end
y, h = y + row + gapNav, math.max(0, h - row - gapNav)
lr = { x = x, y = y, w = w, h = h }
vr = lr
sr = lr
else
lr = { x = x, y = y, w = listW, h = h }
vr = { x = x + listW + gap, y = y, w = math.max(0, viewW), h = h }
sr = { x = x + w - sideW, y = y, w = sideW, h = h }
end
-- --------------------------------------------------------- the map list
if not stacked or S.mapSection == "maps" then
local listInner = lr.w - 2 * pad
Kit.card(lr.x, lr.y, lr.w, lr.h)
local compactList = lr.h < 260 * s
if not compactList then
Kit.caption(lr.x + pad, lr.y + pad, "MAPS")
end
local qy = lr.y + pad + (compactList and 0 or Kit.textHeight("caption") + 8 * s)
S.mapQuery = Kit.textfield(
"map-query",
lr.x + pad,
qy,
compactList and (listInner - 88 * s) or listInner,
Kit.controlH(),
S.mapQuery,
"search maps..."
)
local ids = {}
for _, id in ipairs(sortedMapIds(S.data)) do
if S.mapQuery == "" or id:lower():find(S.mapQuery:lower(), 1, true) then
ids[#ids + 1] = id
end
end
local gotoH = Kit.controlH()
local gotoY = lr.y + lr.h - pad - gotoH
local pagerH = Kit.controlH()
local pagerY = gotoY - 10 * s - pagerH
local listTop = qy + Kit.controlH() + 10 * s
local mRowH = Kit.controlH()
local mGap = 4 * s
local listBodyH = (compactList and lr.y + lr.h - pad or pagerY - 10 * s) - listTop
local perPage = math.max(1, math.floor(listBodyH / (mRowH + mGap)))
-- wheel and touch drag reach the list too (#715): App routes the wheel to
-- zoom on this tab, so the list rides Kit's drag path and the pager alone
-- on desktop -- on a phone the drag is the difference between "stuck" and
-- scrollable.
local drawn, shift =
Kit.list(S, "mapListOffset", lr.x + pad, listTop, listInner, listBodyH, #ids, mRowH + mGap)
Kit.pushClip(lr.x + pad, listTop, listInner, math.max(0, listBodyH))
for i = 1, drawn do
local id = ids[S.mapListOffset + i]
local ry = listTop + (i - 1) * (mRowH + mGap) - shift
if Kit.row(lr.x + pad, ry, listInner, mRowH, id == S.mapId, PAL.blue, 7 * s) then
MapBrowser.select(S, id)
if stacked then
Motion.change(S, "mapSection", "view", 1)
end
end
Kit.text(
"tiny",
Kit.ellipsize("tiny", id, listInner - 18 * s),
lr.x + pad + 9 * s,
ry + (mRowH - Kit.textHeight("tiny")) / 2,
id == S.mapId and PAL.heading or PAL.muted
)
end
if #ids == 0 then
Kit.text("mono", "no map matches", lr.x + pad + 9 * s, listTop + 8 * s, PAL.faint)
end
Kit.popClip()
Kit.listScrollbar(S, "mapListOffset", lr.x + pad, listTop, listInner, listBodyH)
if not compactList then
S.mapListOffset = Kit.pager(lr.x + pad, pagerY, listInner, S.mapListOffset, #ids, perPage)
end
if
Kit.button(
compactList and lr.x + pad + listInner - 80 * s or lr.x + pad,
compactList and qy or gotoY,
compactList and 80 * s or listInner,
gotoH,
compactList and "Player" or "Go to save location",
{ font = "small", radius = 9 * s }
)
then
local pmap, px, py = playerPos(S)
if pmap then
MapBrowser.select(S, pmap)
Ops.say(S, ("Jumped to %s (%d,%d)"):format(pmap, px, py))
else
Ops.say(S, "No player location on this save")
end
end
if stacked then
return
end
end
-- ---------------------------------------------------------- the viewport
if not stacked or S.mapSection == "view" then
Kit.card(vr.x, vr.y, vr.w, vr.h)
end
local vpad = stacked and 8 * s or 18 * s
local vx0 = vr.x + vpad
local vinner = math.max(0, vr.w - 2 * vpad)
local headH = stacked and (Kit.textHeight("small") + 4 * s) or Kit.controlH()
if not stacked or S.mapSection == "view" then
Kit.text(
stacked and "small" or "monoBig",
Kit.ellipsize(stacked and "small" or "monoBig", tostring(S.mapId), vinner),
vx0,
vr.y + vpad + (headH - Kit.textHeight(stacked and "small" or "monoBig")) / 2,
PAL.heading
)
end
local ok, map, previewReason = MapBrowser.preview(S)
S.mapPreviewReason = previewReason
if not ok then
Kit.text(
"mono",
"Failed to load map: " .. tostring(map),
vx0,
vr.y + vpad + headH + 20 * s,
PAL.red
)
return
end
if not stacked or S.mapSection == "view" then
local outdoor = Ops.isOutdoor(S, map)
local oLabel = outdoor and "OUTDOOR" or "INDOOR"
local oW = Kit.textWidth("tiny", oLabel) + 16 * s
local oX = vx0 + Kit.textWidth("monoBig", tostring(S.mapId)) + 14 * s
if not stacked then
Theme.stroke(
oX,
vr.y + vpad + (headH - 20 * s) / 2,
oW,
20 * s,
6 * s,
PAL.cardBorder,
0.3,
1
)
Kit.textCenter(
"tiny",
oLabel,
oX,
vr.y + vpad + (headH - 20 * s) / 2 + (20 * s - Kit.textHeight("tiny")) / 2,
oW,
outdoor and PAL.green or PAL.muted
)
end
-- zoom cluster, right-aligned in the viewport header. The centre button
-- is the one part with a long label; on a header too narrow to hold it
-- beside the title it is dropped (its job is covered by the list's "Go to
-- save location" plus the first-draw centering) rather than painted over
-- the map name (#715).
local centerW = 130 * s
local zBtn = Kit.controlH()
local rightEdge = vx0 + vinner
local zoomW = 2 * zBtn + 56 * s + 12 * s
local pmap, px, py = playerPos(S)
local showCenter = not inlineZoom and vinner >= zoomW + 10 * s + centerW + 160 * s
if showCenter and not stacked then
if
Kit.button(
rightEdge - centerW,
vr.y + vpad,
centerW,
headH,
"Center on player",
{ kind = "accent", font = "small", radius = 7 * s }
)
then
showPlayer(S)
Ops.say(S, "Centred on the player")
end
end
local zoomY = vr.y + vpad
local zx = rightEdge - (showCenter and (centerW + 10 * s) or 0) - zoomW
if not stacked then
if Kit.stepper(zx, zoomY, zBtn, headH, "minus", { radius = 7 * s }) then
stepZoom(S, -1)
end
Kit.textCenter(
"mono",
("%.2fx"):format(S.mapZoom),
zx + zBtn + 6 * s,
zoomY + (headH - Kit.textHeight("mono")) / 2,
56 * s,
PAL.muted
)
if Kit.stepper(zx + zBtn + 62 * s, zoomY, zBtn, headH, "plus", { radius = 7 * s }) then
stepZoom(S, 1)
end
end
local legendH = stacked and (18 * s + (inlineZoom and 0 or row + 6 * s)) or 22 * s
local vy0 = zoomY + headH + (stacked and 6 or 12) * s
local vh0 = math.max(0, (vr.y + vr.h - vpad - legendH - 8 * s) - vy0)
local oldW, oldH = S._mapViewW, S._mapViewH
S._mapViewW, S._mapViewH = vinner, vh0
S._mapViewRect = { x = vx0, y = vy0, w = vinner, h = vh0 }
-- First draw of a map: park the camera somewhere meaningful rather than at
-- (0,0), which leaves a small map wedged in the top-left corner. Deferred
-- to here because centerOn needs the viewport size, which only exists once
-- the panel has laid itself out.
if S._mapCenteredFor ~= S.mapId then
S._mapCenteredFor = S.mapId
if stacked then
MapBrowser.fit(S, map)
elseif pmap == S.mapId then
centerOn(S, px + 0.5, py + 0.5)
S.mapAutoFit = false
else
centerOn(
S,
(map.widthCells or map.width or 10) / 2,
(map.heightCells or map.height or 10) / 2
)
S.mapAutoFit = false
end
elseif oldW and (oldW ~= vinner or oldH ~= vh0) then
if S.mapAutoFit then
MapBrowser.fit(S, map)
else
S.mapCamX = S.mapCamX + (oldW - vinner) / (2 * S.mapZoom)
S.mapCamY = S.mapCamY + (oldH - vh0) / (2 * S.mapZoom)
end
end
if S._mapFitRequested then
MapBrowser.fit(S, map)
S._mapFitRequested = nil
end
if S._mapCenterPlayer then
centerOn(S, px + 0.5, py + 0.5)
S._mapCenterPlayer, S.mapAutoFit = nil, false
end
if Kit.mouseDown and not Kit.blockClicks and not S._mapPinch
and (S._mapDrag or Kit.hit(vx0, vy0, vinner, vh0)) then
local d = S._mapDrag
if not d then
S._mapDrag = { mx = Kit.mouseX, my = Kit.mouseY, camX = S.mapCamX, camY = S.mapCamY }
else
panTo(S, Kit.mouseX, Kit.mouseY)
end
elseif not Kit.mouseDown or S._mapPinch then
S._mapDrag = nil
end
Theme.col(PAL.bgBot, 1)
love.graphics.rectangle("fill", vx0, vy0, vinner, vh0, 12 * s, 12 * s)
Theme.stroke(vx0, vy0, vinner, vh0, 12 * s, PAL.cardBorder, 0.28, 1)
-- love_stub (headless tests) lacks push/pop/scale/scissor; skip the actual
-- render there but keep all click/button logic below running. The size
-- guard is the #715 crash fix proper: an exhausted viewport (a window
-- shorter or narrower than the chrome) renders nothing instead of handing
-- LOVE a negative scissor rect.
if love.graphics.push and vinner > 0 and vh0 > 0 then
Kit.pushClip(vx0, vy0, vinner, vh0)
love.graphics.push()
love.graphics.translate(vx0, vy0)
love.graphics.scale(S.mapZoom, S.mapZoom)
if map.renderer and map.renderer.draw then
love.graphics.setColor(1, 1, 1, 1)
map.renderer:draw(S.mapCamX, S.mapCamY)
else
local wc = map.widthCells or ((map.width or 8) * 2)
local hc = map.heightCells or ((map.height or 8) * 2)
for cy = 0, hc - 1 do
for cx = 0, wc - 1 do
if (cx + cy) % 2 == 0 then
love.graphics.setColor(0.18, 0.22, 0.32, 1)
else
love.graphics.setColor(0.14, 0.17, 0.26, 1)
end
love.graphics.rectangle(
"fill",
cx * CELL - S.mapCamX,
cy * CELL - S.mapCamY,
CELL,
CELL
)
end
end
love.graphics.setColor(1, 1, 1, 1)
end
drawOverlays(S, map)
love.graphics.pop()
if previewReason then
Kit.textWrapped("tiny", previewReason, vx0 + 8 * s, vy0 + 8 * s, math.max(0, vinner - 16 * s), PAL.yellow)
end
Kit.popClip()
end
-- click handling: warp cells jump the view, everything else selects
if Kit.mouseClicked and not Kit.blockClicks and not S._mapPinch then
local cx, cy = cellAtScreen(S, map, Kit, vx0, vy0, vinner, vh0)
if cx then
local warp = map:warpAtCell(cx, cy)
if warp then
goToWarp(S, warp)
else
S.mapClickCell = { cx = cx, cy = cy }
S.status = string.format("Selected cell (%d,%d) on %s", cx, cy, S.mapId)
end
end
end
-- legend + the current selection readout
local ly = vr.y + vr.h - vpad - (stacked and 18 * s or legendH) + 4 * s
local lx = vx0
local legend = {
{ PAL.blue, "warp", false },
{ PAL.red, "player", true },
{ PAL.green, "lastHeal", false },
{ PAL.yellow, "lastOutdoor", false },
}
if stacked then
if not inlineZoom then
local toolY = ly - row - 6 * s
local fitW = Kit.buttonWidth("Fit", { font = "small" }, row)
local percentW = Kit.textWidth("tiny", "400%") + 4 * s
local toolsW = 3 * row + fitW + percentW + 4 * gapNav
local tx = vx0 + (vinner - toolsW) / 2
if Kit.stepper(tx, toolY, row, row, "minus") then stepZoom(S, -1) end
Kit.textCenter("tiny", ("%d%%"):format(math.floor(S.mapZoom * 100 + 0.5)),
tx + row + gapNav, toolY + (row - Kit.textHeight("tiny")) / 2, percentW, PAL.caption)
tx = tx + row + percentW + 2 * gapNav
if Kit.stepper(tx, toolY, row, row, "plus") then stepZoom(S, 1) end
tx = tx + row + gapNav
if Kit.button(tx, toolY, fitW, row, "Fit", { font = "small" }) then MapBrowser.fit(S, map) end
if Kit.iconButton(tx + fitW + gapNav, toolY, row, row, "map-pin", "Center on player") then showPlayer(S) end
end
Kit.text(
"tiny",
S.mapClickCell
and ("Selected (%d,%d) · set in Spawn"):format(S.mapClickCell.cx, S.mapClickCell.cy)
or "Pinch to zoom · drag to pan · tap a cell",
vx0,
ly,
PAL.caption
)
else
for _, item in ipairs(legend) do
local box = 10 * s
if item[3] then
Theme.col(item[1], 1)
love.graphics.rectangle("fill", lx, ly + 2 * s, box, box)
else
Theme.stroke(lx, ly + 2 * s, box, box, 0, item[1], 1, 1.5 * s)
end
Kit.text("tiny", item[2], lx + box + 6 * s, ly, PAL.muted)
lx = lx + box + 6 * s + Kit.textWidth("tiny", item[2]) + 16 * s
end
Kit.textRight(
"mono",
S.mapClickCell and ("selected (%d,%d)"):format(S.mapClickCell.cx, S.mapClickCell.cy)
or "click a cell to select it",
vx0 + vinner,
ly,
PAL.caption
)
end
if stacked then
return
end
end
-- ------------------------------------------------------ spawn inspector
local sx0 = sr.x
Kit.card(sx0, sr.y, sr.w, sr.h)
Kit.caption(sx0 + pad, sr.y + pad, "SPAWN POINTS")
local sTop = sr.y + pad + Kit.textHeight("caption") + 12 * s
local sInner = sr.w - 2 * pad
local pmap2, px2, py2 = playerPos(S)
local playerValue = pmap2 and ("%s (%d,%d)"):format(pmap2, px2, py2) or "unset"
local spawns
if Gen.of(S.save) == 3 then
local healMap = S.save.healMap
local healX = S.save.healX or 0
local healY = S.save.healY or 0
spawns = {
{
key = "PLAYER",
color = PAL.red,
value = playerValue,
set = function()
Ops.setPlayerHere(S)
end,
},
{
key = "LAST HEAL",
color = PAL.green,
value = healMap and ("%s (%d,%d)"):format(healMap, healX, healY) or "unset",
set = function()
Ops.setLastHeal(S)
end,
},
}
elseif Gen.of(S.save) == 2 then
spawns = {
{
key = "PLAYER",
color = PAL.red,
value = playerValue,
set = function()
Ops.setPlayerHere(S)
end,
},
{
key = "SPAWN",
color = PAL.green,
value = tostring(S.save.spawn or "SPAWN_HOME"),
set = function()
Ops.setLastHeal(S)
end,
},
}
else
local out = S.save.lastOutdoor
local heal = S.save.lastHeal
spawns = {
{
key = "PLAYER",
color = PAL.red,
value = playerValue,
set = function()
Ops.setPlayerHere(S)
end,
},
{
key = "LAST HEAL",
color = PAL.green,
value = heal and ("%s (%d,%d)"):format(heal.map, heal.x, heal.y) or "unset",
set = function()
Ops.setLastHeal(S)
end,
},
{
key = "LAST OUTDOOR",
color = PAL.yellow,
value = out and ("%s (%d,%d)"):format(out.id, out.x, out.y) or "unset",
set = function()
Ops.setLastOutdoor(S, map)
end,
},
}
end
local spawnH = math.max(80 * s, Kit.controlH() + 2 * Kit.textHeight("mono"))
local spawnTop = sTop
local spawnViewH = math.max(0, sr.y + sr.h - pad - spawnTop)
S.mapSpawnScroll = Kit.scrollPixels(
sx0 + pad,
spawnTop,
sInner,
spawnViewH,
S.mapSpawnScroll or 0,
#spawns * (spawnH + 8 * s) + 70 * s
)
Kit.pushClip(sx0 + pad, spawnTop, sInner, spawnViewH)
sTop = sTop - S.mapSpawnScroll
for i, sp in ipairs(spawns) do
local ry = sTop + (i - 1) * (spawnH + 8 * s)
Theme.row(sx0 + pad, ry, sInner, spawnH, 10 * s, 0.6)
Kit.text("tiny", sp.key, sx0 + pad + 12 * s, ry + 11 * s, sp.color)
local setW = 70 * s
if
Kit.button(
sx0 + pad + sInner - 12 * s - setW,
ry + 8 * s,
setW,
Kit.controlH(),
"Set here",
{ kind = "accent", font = "tiny", radius = 7 * s, enabled = S.mapClickCell ~= nil }
)
then
sp.set()
end
Kit.text(
"mono",
Kit.ellipsize("mono", sp.value, sInner - 24 * s),
sx0 + pad + 12 * s,
ry + spawnH - 10 * s - Kit.textHeight("mono"),
PAL.muted
)
end
local noteY = sTop + #spawns * (spawnH + 8 * s) + 6 * s
Kit.textWrapped(
"tiny",
"Select a cell in View, then set a spawn point here. Drag to pan; use the zoom controls to zoom.",
sx0 + pad,
noteY,
sInner,
PAL.caption
)
Kit.popClip()
end
function MapBrowser.draw(S, Kit, x, y, w, h)
local width, height = love.graphics.getDimensions()
local dpi = love.graphics.getDPIScale and love.graphics.getDPIScale() or 1
local windowKey = width .. "x" .. height .. "@" .. dpi
if S._mapWindowKey and S._mapWindowKey ~= windowKey and S._g2MapBaker then
require("src.world.gen2.MapPreview").clear(S._g2MapBaker)
end
S._mapWindowKey = windowKey
S._mapViewRect = nil
Motion.pages(S, Kit, "mapSection", x, y, w, h, drawSection)
if S._mapStacked and S.mapSection ~= "view" then S._mapViewRect = nil end
end
return MapBrowser