diff --git a/docs/new-features.md b/docs/new-features.md index 2e636bb5..983034f2 100644 --- a/docs/new-features.md +++ b/docs/new-features.md @@ -11,6 +11,7 @@ Features intentionally added beyond the original Pokémon Red, Blue, and Yellow * **Persistent custom options** stored separately from game saves * **Optional widescreen battle layout** * **Mobile touch controls** with editable layouts, vibration, and orientation settings +* **Screen position setting** (center, upper, top) shared across all games, for clamp-on controllers that cover the lower screen * **Touch skins** in RetroArch overlay format and Delta `.deltaskin` (including PDF-wrapped bezel art), with per-button press states and Super Game Boy borders * **Pokédex diploma and printer image exports** diff --git a/src/core/Game.lua b/src/core/Game.lua index ac928aed..b2c8e5bf 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -1260,6 +1260,7 @@ function Game:applyOptions(opts) -- after VideoMode: a faithful-resolution lock is an exact window size, so -- it has to be the last word on the window (it drops fullscreen to hold) require("src.core.FaithfulRes").applyOptions(opts) + require("src.core.ScreenPosition").applyOptions(opts) -- normalizes a nil/garbage cap to the 60 default, so old saves with no -- fpsCap key pace at the standard rate (issue #88) require("src.core.FrameCap").applyOptions(opts) diff --git a/src/core/Game2.lua b/src/core/Game2.lua index fb6c1c12..3eee6a6a 100644 --- a/src/core/Game2.lua +++ b/src/core/Game2.lua @@ -1660,8 +1660,9 @@ function Game2:drawScene(w, h) -- row to opt into the step-down half, so CENTERED is the whole rule -- here. local s = self.world:fitScale() + local ox, oy = Chrome.fitOrigin(w, h, s) G.push() - G.translate(math.floor((w - 160 * s) / 2), math.floor((h - 144 * s) / 2)) + G.translate(ox, oy) G.scale(s, s) self.stack:draw() G.pop() @@ -1982,6 +1983,7 @@ function Game2:applyOptions() haptics = options.haptics, }) require("src.core.VideoMode").applyOptions(options) + require("src.core.ScreenPosition").applyOptions(options) require("src.core.FrameCap").applyOptions(options) require("src.world.gen2.BorderFill").applyOptions(options) local GBCFX = require("src.render.GBCFX") diff --git a/src/core/SaveData.lua b/src/core/SaveData.lua index 69a1ac2d..f8f5149e 100644 --- a/src/core/SaveData.lua +++ b/src/core/SaveData.lua @@ -285,6 +285,7 @@ function SaveData.defaultOptions() -- lock the window to an exact 160x144 multiple, 1..4 (0 = OFF); see -- src/core/FaithfulRes.lua. Ignored on mobile. faithfulRes = 0, + screenPos = "center", -- hard render frame-rate cap; render-only pacing (issue #88, FrameCap.lua) fpsCap = 60, -- graphics performance tier: auto | high | balanced | low. "auto" diff --git a/src/core/ScreenPosition.lua b/src/core/ScreenPosition.lua new file mode 100644 index 00000000..d4834db4 --- /dev/null +++ b/src/core/ScreenPosition.lua @@ -0,0 +1,66 @@ +local ScreenPosition = {} + +ScreenPosition.MODES = { "center", "upper", "top" } +ScreenPosition.DEFAULT = "center" +ScreenPosition.mode = ScreenPosition.DEFAULT + +local LABELS = { center = "CENTER", upper = "UPPER", top = "TOP" } + +function ScreenPosition.normalize(v) + if LABELS[v] then return v end + return ScreenPosition.DEFAULT +end + +function ScreenPosition.label(v) + return LABELS[ScreenPosition.normalize(v)] +end + +function ScreenPosition.cycle(v, dir) + v = ScreenPosition.normalize(v) + local modes = ScreenPosition.MODES + local cur = 1 + for i, mode in ipairs(modes) do + if mode == v then cur = i break end + end + return modes[(cur - 1 + (dir or 1)) % #modes + 1] +end + +function ScreenPosition.setMode(v) + ScreenPosition.mode = ScreenPosition.normalize(v) +end + +function ScreenPosition.applyOptions(opts) + ScreenPosition.setMode(opts and opts.screenPos) +end + +function ScreenPosition.safeTop() + local ok, SafeArea = pcall(require, "src.core.SafeArea") + if not ok then return 0 end + local okr, _, y = pcall(SafeArea.rect) + if not okr then return 0 end + return math.max(0, tonumber(y) or 0) +end + +function ScreenPosition.skinActive(w, h) + local ok, TouchSkin = pcall(require, "src.core.TouchSkin") + if not ok or type(TouchSkin.viewport) ~= "function" then return false end + local okv, x = pcall(TouchSkin.viewport, w, h) + return okv and x ~= nil +end + +function ScreenPosition.lift(viewH, contentH, safeTop) + if ScreenPosition.mode == "center" then return 0 end + viewH = tonumber(viewH) or 0 + contentH = tonumber(contentH) or 0 + local slack = viewH - contentH + if slack <= 0 then return 0 end + local centered = math.floor(slack / 2) + local target = ScreenPosition.mode == "top" and 0 or math.floor(slack / 4) + safeTop = math.floor(tonumber(safeTop) or 0) + if safeTop > 0 and target < safeTop then + target = math.min(safeTop, centered) + end + return centered - target +end + +return ScreenPosition diff --git a/src/core/gen2/Save.lua b/src/core/gen2/Save.lua index beda404a..945144d6 100644 --- a/src/core/gen2/Save.lua +++ b/src/core/gen2/Save.lua @@ -288,6 +288,7 @@ Save.DEFAULT_OPTIONS = { musicFilter = 0, -- low-pass steps, 0 = off haptics = "light", touchControls = { enabled = true }, + screenPos = "center", } function Save.defaultOptions() @@ -308,7 +309,7 @@ end Save.OPTIONS_KEY = "gold" local SHARED_KEYS = { - touchControls = true, haptics = true, + touchControls = true, haptics = true, screenPos = true, mods = true, modsByVersion = true, modsGen2 = true, modOptions = true, modProfiles = true, modProfilesSeeded = true, activeProfile = true, diff --git a/src/import/LauncherSettings.lua b/src/import/LauncherSettings.lua index b4e94809..b832121e 100644 --- a/src/import/LauncherSettings.lua +++ b/src/import/LauncherSettings.lua @@ -332,6 +332,17 @@ local function coreRows(opts, hooks) end) end + local okSp, ScreenPos = pcall(require, "src.core.ScreenPosition") + if okSp then + add(Strings("SCREEN POS"), + function() return Strings(ScreenPos.label(opts.screenPos)) end, + function(dir) + opts.screenPos = ScreenPos.cycle(opts.screenPos, dir) + ScreenPos.setMode(opts.screenPos) + return true + end) + end + local okCap, FrameCap = pcall(require, "src.core.FrameCap") if okCap then add(Strings("MAX FPS"), diff --git a/src/render/Renderer.lua b/src/render/Renderer.lua index 79b79501..bfb29b0c 100644 --- a/src/render/Renderer.lua +++ b/src/render/Renderer.lua @@ -15,6 +15,7 @@ local Runtime = require("src.mods.Runtime") local GameViewport = require("src.render.GameViewport") -- leaf module (no renderer dependency), so requiring it here cannot cycle local FaithfulRes = require("src.core.FaithfulRes") +local ScreenPosition = require("src.core.ScreenPosition") local Playfield = require("src.render.Playfield") local Renderer = {} @@ -94,6 +95,11 @@ local function displayMetrics() return ww, wh, pw, ph, dpiX, dpiY, vx, vy, cut, grow end +local function positionLift(ph, contentPx, dpiY, cut) + if cut then return 0 end + return ScreenPosition.lift(ph, contentPx, ScreenPosition.safeTop() * dpiY) +end + function Renderer:init() -- 160x144 real pixels, never DPI-scaled: see src/render/PixelCanvas.lua -- (#208). Every canvas below is sized in framebuffer pixels for the same @@ -269,7 +275,7 @@ end -- corners; flat mode returns exactly today's size (growth factor is 1 when -- tilt is inactive). function Renderer:worldViewSize() - local _, _, pw, ph, _, _, _, _, cut, grow = displayMetrics() + local _, _, pw, ph, _, dpiY, _, _, cut, grow = 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 @@ -293,6 +299,9 @@ function Renderer:worldViewSize() -- so unfloored FX/sprite math cannot phase-shimmer against the tile layer. if vw % 2 ~= 0 then vw = vw + 1 end if vh % 2 ~= 0 then vh = vh + 1 end + local _, uih = self:uiSize() + local lift = positionLift(ph, uih * self:fitScale(), dpiY, cut) + if lift > 0 then vh = vh + 2 * math.ceil(lift / sp) end if Tilt.active() then local g = Tilt.viewGrowth() vw, vh = math.ceil(vw * g), math.ceil(vh * g) @@ -774,8 +783,9 @@ function Renderer:frameRects() r.uiw, r.uih = uiw, uih r.vpw, r.vph = uiw * r.Sx, uih * r.Sy -- Snap the letterbox origin to a framebuffer pixel, then convert to units. + r.lift = positionLift(ph, uih * Sp, dpiY, cut) r.ox = (vx + math.floor((pw - uiw * Sp) / 2)) / dpiX - r.oy = (vy + math.floor((ph - uih * Sp) / 2)) / dpiY + r.oy = (vy + math.floor((ph - uih * Sp) / 2) - r.lift) / dpiY -- The UI has its own scale: it steps down as the survey zoom goes out (see -- uiScale), so it can be smaller than the world letterbox. Un-zoomed these -- are identical to Sp/ox/oy and every rect below is what it always was. @@ -795,7 +805,7 @@ function Renderer:frameRects() r.Up, r.Ux, r.Uy = Up, Up / dpiX, Up / dpiY r.uvpw, r.uvph = uiw * r.Ux, uih * r.Uy r.uox = (vx + math.floor((pw - uiw * Up) / 2)) / dpiX - r.uoy = (vy + math.floor((ph - uih * Up) / 2)) / dpiY + r.uoy = (vy + math.max(0, math.floor((ph - uih * Up) / 2) - r.lift)) / dpiY return r end @@ -1002,7 +1012,7 @@ function Renderer:endFrame(zones, worldZones) local wvw = self.worldCanvas:getWidth() local wvh = self.worldCanvas:getHeight() local wox = (vx + math.floor((pw - wvw * sp) / 2)) / dpiX - local woy = (vy + math.floor((ph - wvh * sp) / 2)) / dpiY + local woy = (vy + math.floor((ph - wvh * sp) / 2) - R.lift) / dpiY -- Tilt mode projects the ground world pass through the perspective mesh -- (SGB zones baked in beforehand -- see drawTiltedWorld -- so no zone -- scissoring here). drawTiltedWorld returns false when tilt is off or diff --git a/src/ui/OptionsMenu.lua b/src/ui/OptionsMenu.lua index 945aaca1..d79d2b5d 100644 --- a/src/ui/OptionsMenu.lua +++ b/src/ui/OptionsMenu.lua @@ -21,6 +21,7 @@ local GameVersion = require("src.core.GameVersion") local VideoMode = require("src.core.VideoMode") local Orientation = require("src.core.Orientation") local FaithfulRes = require("src.core.FaithfulRes") +local ScreenPosition = require("src.core.ScreenPosition") local FrameCap = require("src.core.FrameCap") local Performance = require("src.core.Performance") local Logger = require("src.core.Logger") @@ -443,6 +444,16 @@ local function buildRows(game) FaithfulRes.apply(o.faithfulRes) return true end }, + { id = "screenPos", label = Strings("SCREEN POS"), + value = function(g) + return Strings(ScreenPosition.label(g.save.options.screenPos)) + end, + step = function(g, dir) + local o = g.save.options + o.screenPos = ScreenPosition.cycle(o.screenPos, dir) + ScreenPosition.setMode(o.screenPos) + return true + end }, -- hard render cap (issue #88): bounds the present rate so a -- driver-forced vsync-off run cannot spin at thousands of FPS. Logic -- is fixed-step off dt, so this touches presentation only. diff --git a/src/ui/gen2/BattleTransition.lua b/src/ui/gen2/BattleTransition.lua index ed3e4a15..06cc7e16 100644 --- a/src/ui/gen2/BattleTransition.lua +++ b/src/ui/gen2/BattleTransition.lua @@ -619,8 +619,8 @@ function BattleTransition:grid(w, h) scale = math.max(1, math.floor(math.min(w / 160, h / 144))) end local size = 8 * scale - local ox = math.floor((w - 160 * scale) / 2) - local oy = math.floor((h - 144 * scale) / 2) + local Chrome = require("src.ui.gen2.Chrome") + local ox, oy = Chrome.fitOrigin(w, h, scale) return size, ox, oy end diff --git a/src/ui/gen2/Chrome.lua b/src/ui/gen2/Chrome.lua index 709a21f0..12f8a802 100644 --- a/src/ui/gen2/Chrome.lua +++ b/src/ui/gen2/Chrome.lua @@ -66,6 +66,15 @@ function Chrome.fitOrigin(winW, winH, scale) local x, y, w, h = playfieldRect(winW, winH) return x + math.floor((w - Chrome.SCREEN_W * 8 * scale) / 2), y + math.floor((h - Chrome.SCREEN_H * 8 * scale) / 2) + - Chrome.positionLift(winW, winH, scale) +end + +function Chrome.positionLift(winW, winH, scale) + local ok, ScreenPosition = pcall(require, "src.core.ScreenPosition") + if not ok or ScreenPosition.skinActive(winW, winH) then return 0 end + local _, _, _, h = playfieldRect(winW, winH) + return ScreenPosition.lift(h, Chrome.SCREEN_H * 8 * (scale + or Chrome.fitScale(winW, winH)), ScreenPosition.safeTop()) end -- A bordered box, tile coords. Leaves the draw color black for text. diff --git a/src/ui/gen2/OptionsMenu.lua b/src/ui/gen2/OptionsMenu.lua index 901f4d2a..0d3a3e11 100644 --- a/src/ui/gen2/OptionsMenu.lua +++ b/src/ui/gen2/OptionsMenu.lua @@ -196,6 +196,15 @@ local ROWS = { return VideoMode.normalize(options.videoMode) == "borderless" and "FULL" or "WINDOWED" end }, + { label = "SCREEN POS", key = "screenPos", port = true, + cycle = function(options, delta) + local ScreenPosition = require("src.core.ScreenPosition") + options.screenPos = ScreenPosition.cycle(options.screenPos, delta) + ScreenPosition.setMode(options.screenPos) + end, + text = function(options) + return require("src.core.ScreenPosition").label(options.screenPos) + end }, { id = "touchControls", label = "TOUCH PAD", port = true, text = function(options) local tc = options.touchControls diff --git a/src/world/gen2/World.lua b/src/world/gen2/World.lua index a35795f0..527d7d66 100644 --- a/src/world/gen2/World.lua +++ b/src/world/gen2/World.lua @@ -10068,6 +10068,15 @@ function World:draw() if self.shake then self.camera.y = self.camera.y + (self.shake.phase or 0) end + local ScreenPosition = require("src.core.ScreenPosition") + local posLift = 0 + if not ScreenPosition.skinActive(w, h) then + posLift = ScreenPosition.lift(h, 144 * self:fitScale(), + ScreenPosition.safeTop()) + end + if posLift > 0 then + self.camera.y = self.camera.y + posLift / s + end local override = pipelineId and self:drawPipeline(pipelineId, w, h, s) or nil @@ -10093,7 +10102,7 @@ function World:draw() local pad = POKEPIC.pad[math.floor(pw / 8)] or POKEPIC.pad[7] G.push() G.translate(math.floor((w - 160 * sPic) / 2), - math.floor((h - 144 * sPic) / 2)) + math.floor((h - 144 * sPic) / 2) - posLift) G.scale(sPic, sPic) G.setColor(1, 1, 1, 1) local function body() diff --git a/tests/engine/screen_position.lua b/tests/engine/screen_position.lua new file mode 100644 index 00000000..c0b679c1 --- /dev/null +++ b/tests/engine/screen_position.lua @@ -0,0 +1,113 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq +love = love or require("tests.love_stub") + +local ScreenPosition = require("src.core.ScreenPosition") +local TouchSkin = require("src.core.TouchSkin") +local Renderer = require("src.render.Renderer") +local Chrome = require("src.ui.gen2.Chrome") +local Zoom = require("src.render.Zoom") + +eq(ScreenPosition.normalize(nil), "center", "no setting is CENTER") +eq(ScreenPosition.normalize("junk"), "center", "garbage degrades to CENTER") +eq(ScreenPosition.normalize("top"), "top", "top passes through") +eq(ScreenPosition.label("upper"), "UPPER", "upper reads UPPER") + +local seen, v = {}, "center" +for _ = 1, 3 do + seen[#seen + 1] = ScreenPosition.label(v) + v = ScreenPosition.cycle(v, 1) +end +eq(table.concat(seen, ","), "CENTER,UPPER,TOP", "the row cycles CENTER,UPPER,TOP") +eq(ScreenPosition.cycle("top", 1), "center", "and wraps back to CENTER") +eq(ScreenPosition.cycle("center", -1), "top", "stepping back lands on TOP") + +ScreenPosition.setMode("center") +eq(ScreenPosition.lift(640, 288), 0, "CENTER never lifts") + +ScreenPosition.setMode("top") +eq(ScreenPosition.lift(640, 288), 176, "TOP lifts the centered origin to 0") +eq(ScreenPosition.lift(288, 288), 0, "no slack, no lift") +eq(ScreenPosition.lift(144, 288), 0, "negative slack, no lift") +eq(ScreenPosition.lift(640, 288, 40), 136, "TOP stops at the safe-area inset") +eq(ScreenPosition.lift(640, 288, 999), 0, + "a safe inset past center degrades to centered") + +ScreenPosition.setMode("upper") +eq(ScreenPosition.lift(640, 288), 88, "UPPER lands halfway between") +eq(ScreenPosition.lift(640, 288, 40), 88, "a small inset leaves UPPER alone") +eq(ScreenPosition.lift(640, 288, 120), 56, "a large inset pushes UPPER down") + +ScreenPosition.applyOptions({ screenPos = "top" }) +eq(ScreenPosition.mode, "top", "applyOptions takes the stored key") +ScreenPosition.applyOptions(nil) +eq(ScreenPosition.mode, "center", "applyOptions without options is CENTER") + +local function setWindow(w, h) + love.graphics.getDimensions = function() return w, h end + love.graphics.getPixelDimensions = function() return w, h end +end + +setWindow(360, 640) +TouchSkin.setActive(nil) +Renderer:init() +Zoom.offset = 0 + +ScreenPosition.setMode("center") +local r = Renderer:frameRects() +eq(r.Sp, 2, "360x640 fits two whole GB pixels") +eq(r.lift, 0, "CENTER: no lift") +eq(r.oy, 176, "CENTER: the letterbox is centered") +local _, vhCenter = Renderer:worldViewSize() + +ScreenPosition.setMode("top") +r = Renderer:frameRects() +eq(r.lift, 176, "TOP: the full centered slack lifts away") +eq(r.oy, 0, "TOP: the letterbox sits at the top edge") +eq(r.uoy, 0, "TOP: the UI letterbox follows") +eq(r.ox, math.floor((360 - 320) / 2), "TOP: horizontal centering is untouched") +local _, vhTop = Renderer:worldViewSize() +eq(vhTop, vhCenter + 2 * math.ceil(176 / 2), + "TOP: the world canvas grows to keep the bottom covered") + +ScreenPosition.setMode("upper") +r = Renderer:frameRects() +eq(r.oy, 88, "UPPER: the letterbox centers in the upper half") + +ScreenPosition.setMode("top") +local ox, oy = Chrome.fitOrigin(360, 640) +eq(oy, 0, "TOP: Gold's letterbox sits at the top edge") +eq(ox, math.floor((360 - 320) / 2), "TOP: Gold's horizontal centering is untouched") +ScreenPosition.setMode("center") +local _, cy = Chrome.fitOrigin(360, 640) +eq(cy, 176, "CENTER: Gold's letterbox is centered") + +ScreenPosition.setMode("top") +local skin = assert(TouchSkin.parse([[ +overlays = 1 +overlay0_name = "bezel" +overlay0_full_screen = true +overlay0_normalized = true +overlay0_viewport = "0.1,0.1,0.8,0.4" +overlay0_descs = 1 +overlay0_desc0 = "nul,0.5,0.5,rect,0.02,0.02" +]])) +TouchSkin.setActive(skin) +TouchSkin.setOverlayLive(false) +r = Renderer:frameRects() +eq(r.cut, true, "the skin viewport cuts the playfield") +eq(r.lift, 0, "a skin viewport disables the lift") +eq(select(1, ScreenPosition.skinActive(360, 640)), true, + "skinActive sees the viewport") +local _, sy = Chrome.fitOrigin(360, 640) +local _, cy2 = (function() + ScreenPosition.setMode("center") + return Chrome.fitOrigin(360, 640) +end)() +eq(sy, cy2, "with a skin the Gold origin ignores the mode") +TouchSkin.setActive(nil) +ScreenPosition.setMode("center") + +T.finish("screen_position")