This commit is contained in:
bryanthaboi
2026-08-20 10:46:27 -04:00
parent c777e85641
commit 25166ff3a1
14 changed files with 253 additions and 9 deletions
+1
View File
@@ -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**
+1
View File
@@ -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)
+3 -1
View File
@@ -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")
+1
View File
@@ -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"
+66
View File
@@ -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
+2 -1
View File
@@ -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,
+11
View File
@@ -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"),
+14 -4
View File
@@ -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
+11
View File
@@ -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.
+2 -2
View File
@@ -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
+9
View File
@@ -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.
+9
View File
@@ -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
+10 -1
View File
@@ -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()
+113
View File
@@ -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")