mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 00:10:56 +02:00
377 lines
14 KiB
Lua
377 lines
14 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 PAL = Theme.PAL
|
|
|
|
local MapBrowser = {}
|
|
|
|
local CELL = 16 -- the walk grid; a cell is 16px of map art
|
|
|
|
local function clampZoom(z)
|
|
if z < 1 then return 1 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 sortedMapIds(data)
|
|
local ids = {}
|
|
for id in pairs(data.maps) 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
|
|
local fromMap = S.data.maps[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)
|
|
-- 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)
|
|
S.mapZoom = clampZoom((S.mapZoom or 2) + (dy > 0 and 0.25 or -0.25))
|
|
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]
|
|
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
|
|
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) do
|
|
love.graphics.rectangle("line", cellRect(wdef.x, wdef.y))
|
|
end
|
|
if S.save.player.map == S.mapId then
|
|
love.graphics.setColor(1, 0.36, 0.4, 0.9)
|
|
love.graphics.rectangle("fill", cellRect(S.save.player.x, S.save.player.y))
|
|
end
|
|
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
|
|
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
|
|
|
|
function MapBrowser.draw(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)
|
|
|
|
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 viewX = x + listW + gap
|
|
local viewW = w - listW - sideW - 2 * gap
|
|
|
|
-- --------------------------------------------------------- the map list
|
|
Kit.card(x, y, listW, h)
|
|
Kit.caption(x + pad, y + pad, "MAPS")
|
|
local qy = y + pad + Kit.textHeight("caption") + 8 * s
|
|
S.mapQuery = Kit.textfield("map-query", x + pad, qy, listW - 2 * pad, 32 * s,
|
|
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 = 34 * s
|
|
local gotoY = y + h - pad - gotoH
|
|
local pagerH = 30 * s
|
|
local pagerY = gotoY - 10 * s - pagerH
|
|
local listTop = qy + 32 * s + 10 * s
|
|
local mRowH = 26 * s
|
|
local mGap = 4 * s
|
|
local perPage = math.max(1, math.floor((pagerY - 10 * s - listTop) / (mRowH + mGap)))
|
|
S.mapListOffset = Ops.clamp(S.mapListOffset or 0, 0, math.max(0, #ids - perPage))
|
|
|
|
for i = 1, math.min(perPage, #ids - S.mapListOffset) do
|
|
local id = ids[S.mapListOffset + i]
|
|
local ry = listTop + (i - 1) * (mRowH + mGap)
|
|
if Kit.row(x + pad, ry, listW - 2 * pad, mRowH, id == S.mapId, PAL.blue, 7 * s) then
|
|
MapBrowser.select(S, id)
|
|
end
|
|
Kit.text("tiny", Kit.ellipsize("tiny", id, listW - 2 * pad - 18 * s),
|
|
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", x + pad + 9 * s, listTop + 8 * s, PAL.faint)
|
|
end
|
|
S.mapListOffset = Kit.pager(x + pad, pagerY, listW - 2 * pad, S.mapListOffset,
|
|
#ids, perPage)
|
|
if Kit.button(x + pad, gotoY, listW - 2 * pad, gotoH, "Go to save location",
|
|
{ font = "small", radius = 9 * s }) then
|
|
MapBrowser.select(S, S.save.player.map)
|
|
Ops.say(S, ("Jumped to %s (%d,%d)"):format(S.save.player.map,
|
|
S.save.player.x, S.save.player.y))
|
|
end
|
|
|
|
-- ---------------------------------------------------------- the viewport
|
|
Kit.card(viewX, y, viewW, h)
|
|
local vpad = 18 * s
|
|
local vx0 = viewX + vpad
|
|
local vinner = viewW - 2 * vpad
|
|
local headH = 28 * s
|
|
Kit.text("monoBig", tostring(S.mapId), vx0,
|
|
y + vpad + (headH - Kit.textHeight("monoBig")) / 2, PAL.heading)
|
|
|
|
local ok, map = pcall(MapLoader.load, S.data, S.mapId)
|
|
if not ok then
|
|
Kit.text("mono", "Failed to load map: " .. tostring(map), vx0,
|
|
y + vpad + headH + 20 * s, PAL.red)
|
|
return
|
|
end
|
|
|
|
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
|
|
Theme.stroke(oX, y + vpad + (headH - 20 * s) / 2, oW, 20 * s, 6 * s,
|
|
PAL.cardBorder, 0.3, 1)
|
|
Kit.textCenter("tiny", oLabel, oX,
|
|
y + vpad + (headH - 20 * s) / 2 + (20 * s - Kit.textHeight("tiny")) / 2, oW,
|
|
outdoor and PAL.green or PAL.muted)
|
|
|
|
-- zoom cluster, right-aligned in the viewport header
|
|
local centerW = 130 * s
|
|
local zBtn = 32 * s
|
|
local rightEdge = vx0 + vinner
|
|
if Kit.button(rightEdge - centerW, y + vpad, centerW, headH, "Center on player",
|
|
{ kind = "accent", font = "small", radius = 7 * s }) then
|
|
if S.save.player.map == S.mapId then
|
|
centerOn(S, S.save.player.x, S.save.player.y)
|
|
Ops.say(S, "Centred on the player")
|
|
else
|
|
Ops.say(S, "Player isn't on this map")
|
|
end
|
|
end
|
|
local zx = rightEdge - centerW - 10 * s - (2 * zBtn + 56 * s + 12 * s)
|
|
if Kit.stepper(zx, y + vpad, zBtn, headH, "-", { radius = 7 * s }) then
|
|
S.mapZoom = clampZoom(S.mapZoom - 0.5)
|
|
end
|
|
Kit.textCenter("mono", ("%.2fx"):format(S.mapZoom), zx + zBtn + 6 * s,
|
|
y + vpad + (headH - Kit.textHeight("mono")) / 2, 56 * s, PAL.muted)
|
|
if Kit.stepper(zx + zBtn + 62 * s, y + vpad, zBtn, headH, "+", { radius = 7 * s }) then
|
|
S.mapZoom = clampZoom(S.mapZoom + 0.5)
|
|
end
|
|
|
|
local legendH = 22 * s
|
|
local vy0 = y + vpad + headH + 12 * s
|
|
local vh0 = (y + h - vpad - legendH - 10 * s) - vy0
|
|
S._mapViewW, S._mapViewH = vinner, 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 S.save.player.map == S.mapId then
|
|
centerOn(S, S.save.player.x, S.save.player.y)
|
|
else
|
|
centerOn(S, map.widthCells / 2, map.heightCells / 2)
|
|
end
|
|
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.
|
|
if love.graphics.push then
|
|
love.graphics.setScissor(math.floor(vx0), math.floor(vy0),
|
|
math.ceil(vinner), math.ceil(vh0))
|
|
love.graphics.push()
|
|
love.graphics.translate(vx0, vy0)
|
|
love.graphics.scale(S.mapZoom, S.mapZoom)
|
|
map.renderer:draw(S.mapCamX, S.mapCamY)
|
|
drawOverlays(S, map)
|
|
love.graphics.pop()
|
|
love.graphics.setScissor()
|
|
end
|
|
|
|
-- click handling: warp cells jump the view, everything else selects
|
|
if Kit.mouseClicked 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 = y + h - vpad - legendH + 4 * s
|
|
local lx = vx0
|
|
local legend = {
|
|
{ PAL.blue, "warp", false },
|
|
{ PAL.red, "player", true },
|
|
{ PAL.green, "lastHeal", false },
|
|
{ PAL.yellow, "lastOutdoor", false },
|
|
}
|
|
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)
|
|
|
|
-- ------------------------------------------------------ spawn inspector
|
|
local sx0 = viewX + viewW + gap
|
|
Kit.card(sx0, y, sideW, h)
|
|
Kit.caption(sx0 + pad, y + pad, "SPAWN POINTS")
|
|
local sTop = y + pad + Kit.textHeight("caption") + 12 * s
|
|
local sInner = sideW - 2 * pad
|
|
local player = S.save.player
|
|
local out = S.save.lastOutdoor
|
|
local heal = S.save.lastHeal
|
|
local spawns = {
|
|
{ key = "PLAYER", color = PAL.red,
|
|
value = ("%s (%d,%d)"):format(player.map, player.x, player.y),
|
|
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 },
|
|
}
|
|
local spawnH = 62 * s
|
|
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, 26 * s,
|
|
"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 + 3 * (spawnH + 8 * s) + 6 * s
|
|
Kit.textCenter("tiny",
|
|
"Click a cell first. Warp cells follow the warp instead of selecting. " ..
|
|
"Arrow keys / WASD pan, the wheel zooms.",
|
|
sx0 + pad, noteY, sInner, PAL.caption)
|
|
end
|
|
|
|
return MapBrowser
|