mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 08:21:02 +02:00
Merge pull request #690 from spiritsnails/fix/faithful-ratio-mobile
This commit is contained in:
@@ -8,8 +8,21 @@
|
||||
-- letterbox entirely, so the surface is the Game Boy screen and nothing else.
|
||||
--
|
||||
-- Persisted as save.options.faithfulRes (0 = OFF). Applied from OptionsMenu
|
||||
-- and on boot via Game:applyOptions. No-ops on mobile and in headless stubs
|
||||
-- that lack love.window.
|
||||
-- and on boot via Game:applyOptions. No-ops in headless stubs that lack
|
||||
-- love.window.
|
||||
--
|
||||
-- MOBILE takes the other route to the same place. There is no window to
|
||||
-- resize -- the window IS the screen, and it rotates -- so the lock caps the
|
||||
-- RENDER scale instead: the renderer draws the Game Boy screen at exactly N
|
||||
-- physical pixels per GB pixel and centres it, and the rest of the display
|
||||
-- stays black. Same promise as the desktop lock (a GB pixel is exactly N
|
||||
-- screen pixels, no more) reached by moving the picture rather than the
|
||||
-- window. This used to return false on the first line, so the row sat in
|
||||
-- OPTIONS on Android and iOS doing nothing at all.
|
||||
--
|
||||
-- Scale, not size, is also what makes rotation free: Renderer:fitScale runs
|
||||
-- every frame off the live drawable size, so portrait and landscape both get
|
||||
-- the same locked scale with the bars falling wherever the screen is longer.
|
||||
|
||||
local FaithfulRes = {}
|
||||
|
||||
@@ -17,6 +30,10 @@ FaithfulRes.WIDTH, FaithfulRes.HEIGHT = 160, 144
|
||||
FaithfulRes.LEVELS = { 0, 1, 2, 3, 4 }
|
||||
FaithfulRes.DEFAULT = 0
|
||||
|
||||
-- mobile only: the locked scale in physical pixels per GB pixel, 0 for OFF.
|
||||
-- Renderer:fitScale reads it through FaithfulRes.scaleCap.
|
||||
FaithfulRes.mobileScale = 0
|
||||
|
||||
-- conf.lua's floor for the resizable desktop window, restored when the lock
|
||||
-- is released. 1X and 2X are BELOW it, so the lock has to lower the minimum
|
||||
-- as well as set the size or LOVE clamps the window back up.
|
||||
@@ -25,21 +42,52 @@ FaithfulRes.MIN_W, FaithfulRes.MIN_H = 480, 360
|
||||
-- whether this module currently owns the window size
|
||||
FaithfulRes.locked = false
|
||||
|
||||
-- The highest level this display can actually show.
|
||||
--
|
||||
-- On desktop it is 4: the levels are window sizes, and 4X is the ceiling the
|
||||
-- feature shipped with. On mobile there is no window to size, so a fixed
|
||||
-- 1..4 ladder is meaningless -- 4X is a quarter of a 1080p phone, and the
|
||||
-- levels the panel could really use are not on the list at all. Derive it
|
||||
-- from the screen instead, so a 1080x2400 phone offers up to 6X and the top
|
||||
-- of the ladder is the biggest exact-pixel picture it can draw.
|
||||
--
|
||||
-- OFF (0) is untouched by any of this and keeps doing exactly what it always
|
||||
-- did: the renderer fits and letterboxes as usual.
|
||||
function FaithfulRes.maxLevel()
|
||||
-- Mobile is ON or OFF. A ladder of absolute multiples is a desktop idea --
|
||||
-- there it names a window size you can see. On a phone the same number
|
||||
-- means a different fraction of every device, and every level below the top
|
||||
-- is just a smaller picture for no reason. ON means one thing instead:
|
||||
-- lock the viewport to the Game Boy's 10:9 and size it to this screen.
|
||||
if FaithfulRes.isMobile() then return 1 end
|
||||
return 4
|
||||
end
|
||||
|
||||
-- the selectable ladder for this display: OFF, then 1X..maxLevel
|
||||
function FaithfulRes.levels()
|
||||
local out = { 0 }
|
||||
for i = 1, FaithfulRes.maxLevel() do out[#out + 1] = i end
|
||||
return out
|
||||
end
|
||||
|
||||
function FaithfulRes.normalize(v)
|
||||
v = math.floor(tonumber(v) or FaithfulRes.DEFAULT)
|
||||
if v < 0 then return 0 end
|
||||
if v > 4 then return 4 end
|
||||
local max = FaithfulRes.maxLevel()
|
||||
if v > max then return max end
|
||||
return v
|
||||
end
|
||||
|
||||
function FaithfulRes.label(v)
|
||||
v = FaithfulRes.normalize(v)
|
||||
if v == 0 then return "OFF" end
|
||||
-- mobile has one ON: the level is chosen from the display, not the player
|
||||
if FaithfulRes.isMobile() then return "ON" end
|
||||
return tostring(v) .. "X"
|
||||
end
|
||||
|
||||
function FaithfulRes.cycle(v, dir)
|
||||
local levels = FaithfulRes.LEVELS
|
||||
local levels = FaithfulRes.levels()
|
||||
local cur = 1
|
||||
for i, level in ipairs(levels) do
|
||||
if level == FaithfulRes.normalize(v) then cur = i break end
|
||||
@@ -48,6 +96,12 @@ function FaithfulRes.cycle(v, dir)
|
||||
end
|
||||
|
||||
function FaithfulRes.isMobile()
|
||||
-- POKEPORT_FORCE_MOBILE=1: take the mobile branch on a desktop build, so the
|
||||
-- scale lock can be seen and driven without a device. The window is still
|
||||
-- resizable, which is the point -- drag it to a phone aspect, rotate it by
|
||||
-- dragging the other way, and the lock has to hold through both. Only this
|
||||
-- module reads isMobile, so the override cannot leak into anything else.
|
||||
if os.getenv("POKEPORT_FORCE_MOBILE") == "1" then return true end
|
||||
if not love or not love.system or not love.system.getOS then return false end
|
||||
local osName = love.system.getOS()
|
||||
return osName == "Android" or osName == "iOS"
|
||||
@@ -86,13 +140,50 @@ end
|
||||
|
||||
-- Push the lock into the live window. Returns true when the window is
|
||||
-- locked afterwards.
|
||||
-- The largest WHOLE multiple of the Game Boy screen this display can hold.
|
||||
-- Integer, never fractional: a GB pixel has to be the same number of screen
|
||||
-- pixels in both axes or it is not pixel perfect, it is resampled.
|
||||
--
|
||||
-- The leftover is black bars, and on a tall phone there is a lot of it
|
||||
-- vertically -- that is simply what a 10:9 screen looks like on a 9:20
|
||||
-- display, and it is what an emulator shows too.
|
||||
function FaithfulRes.deviceScale()
|
||||
local g = love and love.graphics
|
||||
if not (g and g.getPixelDimensions) then return 1 end
|
||||
local pw, ph = g.getPixelDimensions()
|
||||
if not pw or not ph or pw <= 0 or ph <= 0 then return 1 end
|
||||
return math.max(1, math.floor(math.min(pw / FaithfulRes.WIDTH,
|
||||
ph / FaithfulRes.HEIGHT)))
|
||||
end
|
||||
|
||||
-- The scale the renderer must lock to, or nil for "fit the window as usual".
|
||||
-- Only ever set on mobile: on desktop the window itself is the lock, so
|
||||
-- fitScale already lands on N and this would be a second, redundant one.
|
||||
--
|
||||
-- Always the device maximum. Anything less is a smaller picture for no gain,
|
||||
-- which is how the first cut ended up showing a postage stamp on a 1080p
|
||||
-- phone.
|
||||
function FaithfulRes.scaleCap()
|
||||
if not FaithfulRes.locked then return nil end
|
||||
if not FaithfulRes.isMobile() then return nil end
|
||||
return FaithfulRes.deviceScale()
|
||||
end
|
||||
|
||||
function FaithfulRes.apply(v)
|
||||
if FaithfulRes.isMobile() then return false end
|
||||
v = FaithfulRes.normalize(v)
|
||||
-- Mobile: lock the render scale instead of the window. The scale itself
|
||||
-- comes from the display (deviceScale), not from v -- v only says whether
|
||||
-- the lock is on. Nothing to restore on release: the renderer simply goes
|
||||
-- back to filling the display.
|
||||
if FaithfulRes.isMobile() then
|
||||
FaithfulRes.locked = v > 0
|
||||
FaithfulRes.mobileScale = FaithfulRes.locked and FaithfulRes.deviceScale() or 0
|
||||
return FaithfulRes.locked
|
||||
end
|
||||
if not love or not love.window or not love.window.setMode
|
||||
or not love.window.getMode then
|
||||
return false
|
||||
end
|
||||
v = FaithfulRes.normalize(v)
|
||||
local curW, curH, flags = love.window.getMode()
|
||||
flags = flags or {}
|
||||
|
||||
|
||||
+27
-1
@@ -12,6 +12,8 @@ local PaletteFX = require("src.render.PaletteFX")
|
||||
local Pipelines = require("src.render.Pipelines")
|
||||
local PixelCanvas = require("src.render.PixelCanvas")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
-- leaf module (no renderer dependency), so requiring it here cannot cycle
|
||||
local FaithfulRes = require("src.core.FaithfulRes")
|
||||
|
||||
local Renderer = {}
|
||||
|
||||
@@ -124,7 +126,16 @@ end
|
||||
function Renderer:fitScale()
|
||||
local _, _, pw, ph = displayMetrics()
|
||||
local w, h = self:uiSize()
|
||||
return math.max(1, math.floor(math.min(pw / w, ph / h)))
|
||||
local s = math.max(1, math.floor(math.min(pw / w, ph / h)))
|
||||
-- FAITHFUL RATIO on mobile locks the scale here rather than by resizing the
|
||||
-- window, which a phone does not have (see src/core/FaithfulRes.lua). The
|
||||
-- cap is the largest WHOLE multiple the display holds, so the picture is as
|
||||
-- big as exact pixels allow and the remainder is bars. Computed per frame
|
||||
-- off the live drawable size, so a rotate re-derives it with nothing to
|
||||
-- re-apply.
|
||||
local cap = FaithfulRes.scaleCap()
|
||||
if cap and cap < s then s = cap end
|
||||
return s
|
||||
end
|
||||
|
||||
-- Integer framebuffer pixels per GB pixel for the UI pass.
|
||||
@@ -224,6 +235,21 @@ end
|
||||
-- tilt is inactive).
|
||||
function Renderer:worldViewSize()
|
||||
local _, _, pw, ph = displayMetrics()
|
||||
-- FAITHFUL RATIO on mobile. The world pass deliberately expands to cover the
|
||||
-- WHOLE display, so letterbox voids become more map instead of black bars.
|
||||
-- That is why the lock appeared to do nothing in the overworld: it shrank
|
||||
-- the UI blit while the map kept filling the screen -- and showed MORE of
|
||||
-- the map, because a smaller scale fits more world pixels in.
|
||||
--
|
||||
-- Size the view against the LOCKED VIEWPORT rather than the display. A
|
||||
-- desktop lock gets this for free by making the window exactly 160N x 144N;
|
||||
-- this is the same sum with the viewport standing in for the window, so
|
||||
-- both platforms show the same map area at the same zoom.
|
||||
local cap = FaithfulRes.scaleCap()
|
||||
if cap then
|
||||
local uiw, uih = self:uiSize()
|
||||
pw, ph = uiw * cap, uih * cap
|
||||
end
|
||||
local sp = Zoom.scale(self:fitScale())
|
||||
local vw, vh = math.ceil(pw / sp), math.ceil(ph / sp)
|
||||
-- Even sizes keep Camera:follow on integer pixels (viewW/2 is integral),
|
||||
|
||||
@@ -354,7 +354,7 @@ local function buildRows(game)
|
||||
-- Game Boy screen with no letterbox at all. Sits next to VIDEO MODE
|
||||
-- because it overrides it: holding an exact size means dropping
|
||||
-- fullscreen.
|
||||
{ id = "faithfulRes", label = Strings("FAITHFUL RES"),
|
||||
{ id = "faithfulRes", label = Strings("FAITHFUL RATIO"),
|
||||
value = function(g)
|
||||
return FaithfulRes.label(g.save.options.faithfulRes)
|
||||
end,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
-- FAITHFUL RES: lock the window to an exact 160x144 multiple so the surface
|
||||
-- FAITHFUL RATIO: lock the window to an exact 160x144 multiple so the surface
|
||||
-- is the Game Boy screen with no letterbox at all.
|
||||
--
|
||||
-- The interesting parts are the two things a naive setMode gets wrong: the
|
||||
@@ -121,11 +121,22 @@ T.eq(calls[1].flags.minwidth, FaithfulRes.MIN_W, "with conf.lua's floor restored
|
||||
T.eq(calls[1].flags.minheight, FaithfulRes.MIN_H, "on both axes")
|
||||
T.eq(FaithfulRes.locked, false, "and the module no longer claims the window")
|
||||
|
||||
-- mobile has no resizable window to lock
|
||||
-- Mobile has no resizable window to lock, so it locks the RENDER scale
|
||||
-- instead and never calls setMode. It used to report unlocked and do
|
||||
-- nothing at all, which is why the OPTIONS row was inert on Android and iOS;
|
||||
-- tests/engine/faithful_res_mobile.lua covers the scale side.
|
||||
calls = stubWindow(1)
|
||||
love.system = { getOS = function() return "Android" end }
|
||||
T.eq(FaithfulRes.apply(4), false, "mobile reports unlocked")
|
||||
T.eq(#calls, 0, "and never touches the window")
|
||||
T.eq(FaithfulRes.apply(4), true, "mobile locks, by capping the render scale")
|
||||
-- the level asked for is irrelevant on mobile: ON is ON, and the scale is
|
||||
-- read off the display so the picture is as big as exact pixels allow
|
||||
T.eq(FaithfulRes.scaleCap(), FaithfulRes.deviceScale(),
|
||||
"and the scale comes from the display, not from the level")
|
||||
T.eq(#calls, 0, "still without ever touching the window")
|
||||
T.eq(FaithfulRes.apply(0), false, "OFF releases it")
|
||||
T.eq(FaithfulRes.scaleCap(), nil, "and the cap goes away with it")
|
||||
T.eq(#calls, 0, "the window is left alone either way")
|
||||
FaithfulRes.mobileScale = 0
|
||||
|
||||
love.window, love.system = savedWindow, savedSystem
|
||||
if love.graphics then
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
-- FAITHFUL RATIO on Android / iOS.
|
||||
--
|
||||
-- On mobile the setting is ON or OFF, and ON means one thing: lock the
|
||||
-- viewport to the Game Boy's 10:9 at the largest WHOLE multiple this screen
|
||||
-- can hold, centred, black around it -- the way an emulator opens a Game Boy
|
||||
-- game on a phone.
|
||||
--
|
||||
-- Three things had to be true and none of them were:
|
||||
--
|
||||
-- * it had to apply at all. FaithfulRes.apply returned false on its first
|
||||
-- line for mobile, so the OPTIONS row did nothing on Android and iOS.
|
||||
-- A phone has no window to resize, so the lock caps the RENDER scale.
|
||||
--
|
||||
-- * it had to be sized for the device. The first cut kept the desktop's
|
||||
-- absolute 1X-4X ladder, which names a window size you can see on a
|
||||
-- desktop and means a different fraction of every phone: 4X was a quarter
|
||||
-- of a 1080p display and 5X/6X were not on the list at all. The scale is
|
||||
-- read off the display now, not chosen by the player.
|
||||
--
|
||||
-- * it had to work in the OVERWORLD. The world pass deliberately expands
|
||||
-- to cover the whole display so letterbox voids become more map. So the
|
||||
-- lock shrank the UI blit while the map kept filling the screen -- and
|
||||
-- showed MORE map, since a smaller scale fits more world pixels in.
|
||||
--
|
||||
-- Pixel perfect throughout: whole multiples only. The leftover is bars, and
|
||||
-- on a 9:20 phone there is a lot of it vertically. That is what a 10:9
|
||||
-- screen looks like on a tall display; stretching to reach the edges would
|
||||
-- resample every pixel, which is the one thing this setting exists to refuse.
|
||||
-- luajit tests/engine/faithful_res_mobile.lua
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.modkit")
|
||||
local FaithfulRes = require("src.core.FaithfulRes")
|
||||
local Renderer = require("src.render.Renderer")
|
||||
local Zoom = require("src.render.Zoom")
|
||||
|
||||
local g = love.graphics
|
||||
local realDims, realPixelDims = g.getDimensions, g.getPixelDimensions
|
||||
local realOS = love.system and love.system.getOS
|
||||
local savedOffset = Zoom.offset
|
||||
love.system = love.system or {}
|
||||
|
||||
local function pose(w, h, osName)
|
||||
love.system.getOS = function() return osName or "Android" end
|
||||
g.getDimensions = function() return w, h end
|
||||
g.getPixelDimensions = function() return w, h end
|
||||
end
|
||||
|
||||
Renderer.uiWidth, Renderer.uiHeight = Renderer.WIDTH, Renderer.HEIGHT
|
||||
Zoom.offset = 0
|
||||
|
||||
-- ------------------------------------------------------- it applies at all
|
||||
|
||||
pose(1080, 2400) -- a Pixel 7, portrait
|
||||
T.eq(FaithfulRes.isMobile(), true, "the fixture reads as a phone")
|
||||
T.eq(FaithfulRes.apply(1), true, "FAITHFUL RATIO applies on mobile at all now")
|
||||
T.eq(FaithfulRes.locked, true, "and reports itself locked")
|
||||
|
||||
-- ------------------------------------------------- one ON, sized by the device
|
||||
|
||||
T.eq(FaithfulRes.maxLevel(), 1, "mobile offers ON, not a ladder of multiples")
|
||||
T.eq(#FaithfulRes.levels(), 2, "so the row is exactly OFF and ON")
|
||||
T.eq(FaithfulRes.label(1), "ON", "and ON is spelled ON, not 1X")
|
||||
T.eq(FaithfulRes.label(0), "OFF", "with OFF unchanged")
|
||||
|
||||
-- 1080/160 = 6.75 and 2400/144 = 16.6, so the largest WHOLE multiple is 6
|
||||
T.eq(FaithfulRes.deviceScale(), 6, "the scale comes off the display: 6x here")
|
||||
T.eq(FaithfulRes.scaleCap(), 6, "and that is what the renderer is told to lock")
|
||||
T.eq(Renderer:fitScale(), 6, "so a GB pixel is exactly 6 screen pixels")
|
||||
|
||||
-- the player never picks a smaller one, which is how the first cut managed to
|
||||
-- draw a postage stamp on a 1080p phone
|
||||
T.eq(FaithfulRes.normalize(3), 1, "any ON value is just ON")
|
||||
FaithfulRes.apply(1)
|
||||
T.eq(Renderer:fitScale(), 6, "and always lands on the device maximum")
|
||||
|
||||
-- --------------------------------------------------------- the overworld
|
||||
|
||||
FaithfulRes.apply(0)
|
||||
local unlockedW = Renderer:worldViewSize()
|
||||
T.check(unlockedW > 160, "unlocked, the overworld view covers the whole display")
|
||||
|
||||
FaithfulRes.apply(1)
|
||||
local lockedW, lockedH = Renderer:worldViewSize()
|
||||
T.eq(lockedW, 160, "locked, the overworld shows exactly a GB screen wide")
|
||||
T.eq(lockedH, 144, "and exactly a GB screen tall")
|
||||
T.check(lockedW < unlockedW,
|
||||
"so the lock SHRINKS the map area instead of growing it")
|
||||
|
||||
-- ------------------------------------------------------ pixel perfect
|
||||
|
||||
-- 6 x 160 = 960 of 1080 wide. Reaching the edges would need 6.75, which
|
||||
-- resamples every pixel; the bars are the honest answer.
|
||||
local cap = FaithfulRes.scaleCap()
|
||||
T.eq(cap, math.floor(cap), "the locked scale is a whole number, never fractional")
|
||||
T.eq(160 * cap, 960, "which puts the GB screen at 960 of 1080 pixels wide")
|
||||
|
||||
-- ------------------------------------------------------- rotation is free
|
||||
|
||||
-- fitScale reads the live drawable size every frame, so a rotate re-derives
|
||||
-- the scale with nothing to re-apply
|
||||
pose(1080, 2400); FaithfulRes.apply(1)
|
||||
T.eq(Renderer:fitScale(), 6, "portrait locks at 6x")
|
||||
pose(2400, 1080); FaithfulRes.apply(1)
|
||||
T.eq(Renderer:fitScale(), 7, "landscape re-derives to 7x (1080/144), still whole")
|
||||
T.eq(Renderer:worldViewSize(), 160, "and the overworld stays locked through it")
|
||||
|
||||
-- ---------------------------------------------------------- OFF is OFF
|
||||
|
||||
-- Nothing about OFF changed: it is the behaviour the game already had.
|
||||
pose(1080, 2400)
|
||||
FaithfulRes.apply(0)
|
||||
T.eq(FaithfulRes.locked, false, "OFF releases the lock")
|
||||
T.eq(FaithfulRes.scaleCap(), nil, "with no cap on the renderer")
|
||||
T.eq(Renderer:fitScale(), 6, "the UI fits exactly as it did before")
|
||||
T.check(Renderer:worldViewSize() > 160, "and the overworld fills the screen again")
|
||||
|
||||
-- ------------------------------------------------- desktop is untouched
|
||||
|
||||
pose(1280, 800, "Windows")
|
||||
T.eq(FaithfulRes.isMobile(), false, "the fixture reads as desktop")
|
||||
T.eq(FaithfulRes.maxLevel(), 4, "desktop keeps its 1X-4X ladder")
|
||||
T.eq(FaithfulRes.label(2), "2X", "and its labels")
|
||||
T.eq(FaithfulRes.scaleCap(), nil, "no cap: the window itself is the lock there")
|
||||
T.eq(Renderer:fitScale(), 5, "so fitScale is untouched (800/144 = 5)")
|
||||
|
||||
g.getDimensions, g.getPixelDimensions = realDims, realPixelDims
|
||||
if realOS then love.system.getOS = realOS end
|
||||
Zoom.offset = savedOffset
|
||||
FaithfulRes.locked = false
|
||||
FaithfulRes.mobileScale = 0
|
||||
|
||||
T.finish("faithful ratio mobile")
|
||||
Reference in New Issue
Block a user