Files
gen1recomp/src/render/Zoom.lua
T
2026-08-21 14:03:25 -04:00

146 lines
4.9 KiB
Lua
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
-- Overworld survey zoom: integer pixels-per-world-pixel scales stepped
-- by the mouse wheel, Options ZOOM row, or hotkey `4`. Stored as an
-- offset from the window fit scale S so a resize keeps the relative
-- zoom. Persisted as save.options.zoom (default 0 = FIT).
-- Spec: docs/new-features.md (survey zoom)
local Runtime = require("src.mods.Runtime")
local Zoom = {}
Zoom.offset = 0
-- Survey zoom (zooming out past FIT, which renders connected neighbor maps)
-- is the port's most expensive optional extra. The performance tier sets
-- this false on LOW hardware (Game:applyOptions); offsetRange then floors
-- the range at FIT so the option row, hotkey, and mouse wheel all stop at
-- close-up. Nil/true keeps the historical full range.
Zoom.allowSurvey = true
-- legal offset range for a given fit scale (vanilla: survey at 1 px/world
-- through 2× fit). zoom.range may widen or shrink the window.
-- When the window only fits 1×, 1-S is 0 and there would be no OUT
-- levels; keep three survey steps so OPTIONS always has zoom-out.
function Zoom.offsetRange(S)
S = math.max(1, math.floor(tonumber(S) or 1))
local lo, hi = 1 - S, S
if Runtime.wantsHook("zoom.range") then
lo, hi = Runtime.call("zoom.range", function(a, b) return a, b end, lo, hi, S)
lo = math.floor(tonumber(lo) or (1 - S))
hi = math.floor(tonumber(hi) or S)
if lo > hi then lo, hi = hi, lo end
end
-- LOW performance tier: no survey (negative offsets), even if a mod's
-- zoom.range widened it. == false so nil/true stays permissive.
if Zoom.allowSurvey == false then
if lo < 0 then lo = 0 end
elseif lo > -3 then
lo = -3
end
return lo, hi
end
-- effective scale s' = S + offset, clamped to the (possibly modded) range.
-- Vanilla stays in [1, 2*S]. A zoom.range wrapper that lowers `lo` below
-- 1-S permits sub-1 survey scales so the whole region can fit on screen.
function Zoom.scale(S)
local lo, hi = Zoom.offsetRange(S)
local s = S + Zoom.offset
local minScale = S + lo
local maxScale = math.max(minScale, S + hi)
if s < minScale then s = minScale end
if s > maxScale then s = maxScale end
-- Integer offset below 1px/world (OPTIONS OUT on a 1× window): 1/2, 1/4, …
if s < 1 then s = 0.5 ^ (1 - s) end
if s < 0.25 then s = 0.25 end
return s
end
function Zoom.clampOffset(offset, S)
local lo, hi = Zoom.offsetRange(S)
offset = math.floor(tonumber(offset) or 0)
if offset < lo then return lo end
if offset > hi then return hi end
return offset
end
function Zoom.step(delta, S)
Zoom.offset = Zoom.clampOffset(Zoom.offset + delta, S)
return Zoom.offset
end
-- Advance one zoom level toward max close-up, then wrap to full survey.
-- Returns the new offset.
function Zoom.cycle(S)
local lo, hi = Zoom.offsetRange(S)
local next = Zoom.offset + 1
if next > hi then next = lo end
Zoom.offset = next
return Zoom.offset
end
function Zoom.reset()
Zoom.offset = 0
end
function Zoom.applyOptions(opts)
Zoom.offset = math.floor(tonumber(opts and opts.zoom) or 0)
end
-- Integer fit used when OPTIONS has no live renderer (launcher, title).
function Zoom.windowFitScale()
if love and love.graphics and love.graphics.getDimensions then
local ww, wh = love.graphics.getDimensions()
ww, wh = tonumber(ww) or 0, tonumber(wh) or 0
if ww >= 160 and wh >= 144 then
return math.max(1, math.floor(math.min(ww / 160, wh / 144)))
end
end
return 1
end
-- One step of the OPTIONS ZOOM row (dir +1 in, -1 out). Shared by Red
-- and Gold so both ladders offer OUT / FIT / IN.
function Zoom.nudgeOptions(options, dir, S)
S = math.max(1, math.floor(tonumber(S) or Zoom.windowFitScale()))
local lo, hi = Zoom.offsetRange(S)
local off = math.floor(tonumber(options and options.zoom) or 0) + (dir or 1)
if off > hi then off = lo elseif off < lo then off = hi end
if options then options.zoom = off end
Zoom.offset = off
return off
end
-- FIT / OUT1 / OUT2 / … / IN1 / IN2 / …
function Zoom.offsetLabel(offset)
offset = math.floor(tonumber(offset) or 0)
if offset == 0 then return "FIT" end
if offset < 0 then return "OUT" .. tostring(-offset) end
return "IN" .. tostring(offset)
end
-- world pixels covered by a w x h letterbox viewport at fit scale S
-- (legacy GB-framed size; prefer fillViewSize for the live world pass)
function Zoom.viewSize(S, w, h)
local s = Zoom.scale(S)
return math.ceil(w * S / s), math.ceil(h * S / s)
end
-- world pixels needed to fill a ww x wh window at the current zoom scale
-- (fills letterbox "black voids" with more map, phones, tall windows)
function Zoom.fillViewSize(s, ww, wh)
return math.ceil(ww / s), math.ceil(wh / s)
end
-- zoom input is honored only while free-roaming the overworld
function Zoom.gateOK(top, overworld)
if top == nil or top ~= overworld then return false end
if top.transitioning then return false end
if top.runner and top.runner.isRunning and top.runner:isRunning() then
return false
end
return true
end
return Zoom